Compare commits

..
Author SHA1 Message Date
DH V2 08f164a209 fix(web): package Mendoza favicon in production image
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / Docker / scripts contract (push) Successful in 1m2s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
2026-09-14 19:36:36 -03:00
DH V2 23450bb6d0 fix(field): repair installation creation and streamline mobile form
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m4s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m0s
Production dependency audit / API · production dependencies (push) Successful in 9s
2026-09-14 19:25:20 -03:00
admin 20ccba5292 Android 0.19.5: tipo de instalación y alta corregida (#2)
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m15s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 51s
Unifica Tipo de instalación en un desplegable con buscador y corrige el payload de atributos técnicos para usar IDs de definición. Android CI: 26 pruebas, lint y compilaciones debug/release aprobadas. CI general y auditoría de dependencias aprobadas.
2026-09-14 18:48:26 -03:00
admin c0a5367920 test(android): align 0.19.5 version code
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 2m57s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 31s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 49s
Production dependency audit / API · production dependencies (pull_request) Successful in 8s
2026-09-14 18:42:46 -03:00
admin ff30462259 fix(android): send inventory attributes by definition id
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Failing after 2m13s
DH V2 CI / WEB · typecheck, build (pull_request) Canceled after 0s
DH V2 CI / Docker / scripts contract (pull_request) Canceled after 0s
Production dependency audit / API · production dependencies (pull_request) Canceled after 0s
Production dependency audit / WEB · production dependencies (pull_request) Canceled after 0s
DH V2 CI / API · typecheck, tests, build (pull_request) Canceled after 29s
2026-09-14 18:40:06 -03:00
admin 975f9ee13e fix(android): match dropdown anchor to installed Material version
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 3m5s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 36s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 24s
Production dependency audit / API · production dependencies (pull_request) Successful in 16s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 15s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 1m45s
2026-09-14 18:02:11 -03:00
admin 39dcd6bd4a fix(ci): skip unavailable legacy Android tools package
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Failing after 1m4s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 37s
DH V2 CI / Docker / scripts contract (pull_request) Canceled after 0s
Production dependency audit / API · production dependencies (pull_request) Canceled after 0s
DH V2 CI / WEB · typecheck, build (pull_request) Canceled after 22s
Production dependency audit / WEB · production dependencies (pull_request) Canceled after 0s
2026-09-14 17:59:57 -03:00
admin ffeae96530 fix(android): unify installation type in searchable dropdown
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Failing after 22s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 36s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 22s
Production dependency audit / API · production dependencies (pull_request) Successful in 15s
DH V2 CI / Docker / scripts contract (pull_request) Canceled after 0s
Production dependency audit / WEB · production dependencies (pull_request) Canceled after 14s
2026-09-14 17:58:16 -03:00
admin 9b12144052 test(android): align final field contract with f6.2
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m23s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 50s
2026-09-14 16:07:53 -03:00
admin becf9fc050 fix(android): use valid email regex in act representative flow
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 2m38s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 49s
2026-09-14 16:01:49 -03:00
admin 7fe59bccd2 feat(f6.2): add per-act representative signing and user smtp
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 1m25s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m16s
2026-09-14 15:53:55 -03:00
admin 103ecf2fae fix(api): prevent NaN act version during mobile create
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m19s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m5s
2026-09-14 14:06:26 -03:00
admin 23880521d9 fix(api): align health version with package metadata
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m16s
DH V2 CI / Docker / scripts contract (push) Successful in 50s
2026-09-14 10:54:14 -03:00
admin 637c28dc2f fix(web): align visible version with 0.23.0-2
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m1s
2026-09-14 10:37:58 -03:00
admin fc0c62c70a docs(android): pin 0.19.2 server compatibility
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / Docker / scripts contract (push) Canceled after 0s
Production dependency audit / API · production dependencies (push) Canceled after 0s
Production dependency audit / WEB · production dependencies (push) Canceled after 0s
DH V2 CI / WEB · typecheck, build (push) Canceled after 0s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m34s
2026-09-14 10:31:54 -03:00
63 changed files with 1102 additions and 244 deletions
+4 -2
View File
@@ -1,5 +1,5 @@
name: Android CI / RC name: Android CI / RC
# F6.1 presentation barrier: lint + real tests + debug artifact + release compile. # F6.2 field closing barrier: lint + real tests + debug artifact + release compile.
on: on:
push: push:
@@ -43,6 +43,8 @@ jobs:
- name: Android SDK - name: Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with:
packages: platform-tools
- name: Android API 36 - name: Android API 36
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0' run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
@@ -109,7 +111,7 @@ jobs:
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk" cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256" sha256sum "$apk" > "${apk}.sha256"
{ {
echo "phase=F6.1" echo "phase=F6.2"
echo "version=$version" echo "version=$version"
echo "versionCode=$code" echo "versionCode=$code"
echo "commit=$GITHUB_SHA" echo "commit=$GITHUB_SHA"
+5 -1
View File
@@ -1,4 +1,4 @@
# Checklist final Android 0.19.2 # Checklist final Android 0.19.3
La candidata sólo se promueve si el mismo SHA cumple: La candidata sólo se promueve si el mismo SHA cumple:
@@ -8,6 +8,10 @@ La candidata sólo se promueve si el mismo SHA cumple:
- exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos; - exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos;
- registrar Hallazgos sobre el elemento seleccionado; - registrar Hallazgos sobre el elemento seleccionado;
- al cerrar el contenido, exigir Urgente/No urgente, persistir esa decisión y mostrar **Pendiente de firma**; - al cerrar el contenido, exigir Urgente/No urgente, persistir esa decisión y mostrar **Pendiente de firma**;
- exigir por cada Acta los datos del **Representante de la empresa**: nombres y apellidos, DNI, cargo/función y email;
- permitir precargar esos datos desde el Acta anterior de la misma Inspección, pero exigir confirmación y firma nueva en cada Acta;
- resolver por Acta **firma en conformidad**, **firma en disconformidad con motivo** o **negativa a firmar con motivo**;
- verificar que la urgencia existe sólo a nivel **Acta** y nunca a nivel Hallazgo;
- completar firma del Inspector y manifestación/firma o negativa de la empresa; - completar firma del Inspector y manifestación/firma o negativa de la empresa;
- mostrar el Acta como **Firmada y cerrada**; - mostrar el Acta como **Firmada y cerrada**;
- permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas; - permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas;
+10 -8
View File
@@ -1,19 +1,20 @@
# DH Inspección Android · release final de campo 0.19.2 # DH Inspección Android · release final de campo 0.19.3
## Candidata vigente ## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**. - Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.2**. - `versionName`: **0.19.3**.
- `versionCode`: **30**. - `versionCode`: **31**.
- Application ID release: `com.korexlabs.dhinspeccion`. - Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`. - API: `https://dhv2.korexlabs.com/api/v3/`.
- Servidor compatible de esta candidata: **API 0.29.0-4 / WEB 0.23.0-3**.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica. La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
## Procedimiento operativo validado ## Procedimiento operativo validado
La APK 0.19.2 fija como recorrido principal de campo: La APK 0.19.3 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. 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. 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.
@@ -21,9 +22,10 @@ La APK 0.19.2 fija como recorrido principal de campo:
4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección. 4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección.
5. Si no existe, se da de alta desde campo sin abandonar el Acta. 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. 6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
7. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar Acta y dejar pendiente de firma**. La urgencia queda fijada con el cierre; desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**. 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. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**. 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 Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas. 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.
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. La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
@@ -117,7 +119,7 @@ Esta candidata requiere conexión. No implementa trabajo offline ni cola persist
- Se agregan siete pruebas para coordenadas y valores técnicos. - Se agregan siete pruebas para coordenadas y valores técnicos.
## Revisión 0.19.2 · flujo conceptual corregido ## Revisión 0.19.3 · flujo conceptual corregido
- Se elimina **Inventario de campo** como acción independiente de la pantalla de Inspección. - Se elimina **Inventario de campo** como acción independiente de la pantalla de Inspección.
- El alta o selección de Inventario existe únicamente dentro de **Agregar Hallazgo**. - El alta o selección de Inventario existe únicamente dentro de **Agregar Hallazgo**.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 30 versionCode = 34
versionName = "0.19.2" versionName = "0.19.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -244,7 +244,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
if (type.familyRequired && familyId == null) { if (type.familyRequired && familyId == null) {
error = "Elegí una clasificación técnica o la opción Otro / no catalogado." error = "Elegí el Tipo de instalación o subinstalación correspondiente."
return return
} }
launchBusy(mutation = true) { launchBusy(mutation = true) {
@@ -433,8 +433,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
phone: String?, phone: String?,
) { ) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank()) { val normalizedEmail = email?.trim().orEmpty()
error = "Completá nombre, documento y cargo del responsable de la empresa." val emailValid = Regex("""^[^\s@]+@[^\s@]+\.[^\s@]+$""").matches(normalizedEmail)
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank() || !emailValid) {
error = "Completá nombres y apellidos, DNI, cargo/función y un email válido del representante de la empresa."
return return
} }
launchBusy(mutation = true) { launchBusy(mutation = true) {
@@ -446,11 +448,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
documentType = documentType, documentType = documentType,
documentNumber = documentNumber.trim(), documentNumber = documentNumber.trim(),
position = position.trim(), position = position.trim(),
email = email?.trim()?.takeIf { it.isNotBlank() }, email = normalizedEmail.lowercase(),
phone = phone?.trim()?.takeIf { it.isNotBlank() }, phone = phone?.trim()?.takeIf { it.isNotBlank() },
), ),
) )
notice = "Responsable de empresa registrado para el Acta." notice = "Representante de la empresa registrado para esta Acta."
} }
} }
@@ -468,7 +470,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
absenceReason = reason.trim(), absenceReason = reason.trim(),
), ),
) )
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta." notice = "Ausencia del representante registrada. La manifestación de la empresa deberá resolverse antes de firmar y cerrar el Acta."
} }
} }
@@ -482,7 +484,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
actClosure = actsRepository.lock(actId, urgency) actClosure = actsRepository.lock(actId, urgency)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "Acta cerrada como $urgencyLabel y pendiente de firma. Su contenido quedó inmutable." notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas."
} }
} }
@@ -517,9 +519,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
actId, png, latitude, longitude, accuracyM, manifestation, statement, actId, png, latitude, longitude, accuracyM, manifestation, statement,
) )
notice = if (manifestation == "DISSENT") { notice = if (manifestation == "DISSENT") {
"Firma de empresa registrada con disidencia." "Firma del representante registrada en disconformidad."
} else { } else {
"Firma de empresa registrada." "Firma del representante de la empresa registrada."
} }
} }
} }
@@ -0,0 +1,19 @@
package com.korexlabs.dhinspeccion.data
/** Builds the API payload using definition IDs while the form keeps stable field codes. */
internal fun fieldAttributePayload(
definitions: List<FieldAttributeDefinition>,
valuesByCode: Map<String, String>,
): Map<String, Any?> = definitions.mapNotNull { definition ->
val raw = valuesByCode[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.id to coerceFieldAttribute(definition, raw)
}.toMap()
private fun coerceFieldAttribute(definition: FieldAttributeDefinition, raw: String): Any =
when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
@@ -188,6 +188,7 @@ data class MobileActClosure(
val act: MobileActClosureHeader, val act: MobileActClosureHeader,
val visit: MobileVisitClosureHeader, val visit: MobileVisitClosureHeader,
val responsible: MobileResponsible? = null, val responsible: MobileResponsible? = null,
val representativeSuggestion: MobileResponsible? = null,
val closure: MobileClosureRecord? = null, val closure: MobileClosureRecord? = null,
val signatures: List<MobileSignature> = emptyList(), val signatures: List<MobileSignature> = emptyList(),
val consents: MobileClosureConsents = MobileClosureConsents(), val consents: MobileClosureConsents = MobileClosureConsents(),
@@ -60,6 +60,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -521,18 +522,7 @@ private fun InventoryCard(item: FieldInventoryItem, canModify: Boolean, onSelect
} }
private fun buildAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> = private fun buildAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition -> fieldAttributePayload(type.attributes, values)
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceAttribute(definition, raw)
}.toMap()
private fun coerceAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun hasPermission(context: Context, permission: String): Boolean = private fun hasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -56,6 +56,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -693,11 +694,11 @@ private fun DynamicFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit
} }
if (selectedType?.familyRequired == true) { if (selectedType?.familyRequired == true) {
Text("Clasificación técnica *", fontWeight = FontWeight.Bold) Text("Tipo de instalación *", fontWeight = FontWeight.Bold)
OutlinedTextField( OutlinedTextField(
value = familySearch, value = familySearch,
onValueChange = { familySearch = it }, onValueChange = { familySearch = it },
label = { Text("Buscar clasificación") }, label = { Text("Buscar tipo de instalación") },
supportingText = { Text("${filteredFamilies.size} opciones compatibles") }, supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
@@ -906,18 +907,7 @@ private fun dynamicStatusLabel(status: String): String = when (status) {
} }
private fun buildDynamicAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> = private fun buildDynamicAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition -> fieldAttributePayload(type.attributes, values)
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceDynamicAttribute(definition, raw)
}.toMap()
private fun coerceDynamicAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun dynamicHasPermission(context: Context, permission: String): Boolean = private fun dynamicHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -57,6 +57,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -720,18 +721,7 @@ private fun statusLabel(status: String): String = when (status) {
} }
private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> = private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition -> fieldAttributePayload(type.attributes, values)
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceF3Attribute(definition, raw)
}.toMap()
private fun coerceF3Attribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun f3HasPermission(context: Context, permission: String): Boolean = private fun f3HasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -57,30 +57,31 @@ fun MobileActsScreen(
val visit = model.visit ?: return val visit = model.visit ?: return
val selected = model.selectedAct val selected = model.selectedAct
val closure = model.actClosure val closure = model.actClosure
val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") } var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var attendance by rememberSaveable(selected?.id) { var attendance by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT")
} }
var fullName by rememberSaveable(selected?.id) { var fullName by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.fullName.orEmpty()) mutableStateOf(representativeSeed?.fullName.orEmpty())
} }
var documentType by rememberSaveable(selected?.id) { var documentType by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentType ?: "DNI") mutableStateOf("DNI")
} }
var documentNumber by rememberSaveable(selected?.id) { var documentNumber by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) mutableStateOf(representativeSeed?.documentNumber.orEmpty())
} }
var position by rememberSaveable(selected?.id) { var position by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.position.orEmpty()) mutableStateOf(representativeSeed?.position.orEmpty())
} }
var email by rememberSaveable(selected?.id) { var email by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.email.orEmpty()) mutableStateOf(representativeSeed?.email.orEmpty())
} }
var phone by rememberSaveable(selected?.id) { var phone by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.phone.orEmpty()) mutableStateOf(representativeSeed?.phone.orEmpty())
} }
var absenceReason by rememberSaveable(selected?.id) { var absenceReason by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) mutableStateOf(closure?.responsible?.absenceReason.orEmpty())
@@ -225,29 +226,26 @@ fun MobileActsScreen(
} }
HorizontalDivider() HorizontalDivider()
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") }) AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") }) AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
} }
if (attendance == "PRESENT") { if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth())
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind -> OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth())
AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "$kind" else kind) }) OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth())
} if (closure?.responsible == null && closure?.representativeSuggestion != null) {
Text("Datos precargados del Acta anterior. Confirmalos para esta Acta.", style = MaterialTheme.typography.bodySmall)
} }
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth())
Button( Button(
onClick = { onClick = {
model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone)
}, },
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(), enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Guardar responsable") } ) { Text("Guardar representante") }
} else { } else {
OutlinedTextField( OutlinedTextField(
absenceReason, absenceReason,
@@ -265,7 +263,7 @@ fun MobileActsScreen(
HorizontalDivider() HorizontalDivider()
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al cerrar el Acta, el contenido y los Hallazgos quedan inmutables. Definí ahora la urgencia según lo constatado.") 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.")
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip( AssistChip(
onClick = { closingUrgency = "NON_URGENT" }, onClick = { closingUrgency = "NON_URGENT" },
@@ -310,7 +308,7 @@ fun MobileActsScreen(
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold) Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) { if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) { val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad" "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar" "REFUSED" -> "Negativa a firmar"
else -> companyOutcome.status else -> companyOutcome.status
} }
@@ -319,26 +317,26 @@ fun MobileActsScreen(
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") { } else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Text( Text(
"El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.", "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, style = MaterialTheme.typography.bodyMedium,
) )
} else { } else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") }) AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "En disidencia") }) AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disconformidad" else "En disconformidad") })
} }
if (manifestation == "DISSENT") { if (manifestation == "DISSENT") {
OutlinedTextField( OutlinedTextField(
dissentStatement, dissentStatement,
{ dissentStatement = it }, { dissentStatement = it },
label = { Text("Manifestación de disidencia *") }, label = { Text("Motivo de disconformidad *") },
minLines = 2, minLines = 2,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
SignaturePad( SignaturePad(
label = "Firma del responsable de empresa", label = "Firma del representante de la empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) }, onCaptured = { file -> signWithGeo(file, company = true) },
) )
@@ -72,17 +72,18 @@ fun ModernMobileActsScreen(
val visit = model.visit ?: return val visit = model.visit ?: return
val selected = model.selectedAct val selected = model.selectedAct
val closure = model.actClosure val closure = model.actClosure
val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") } var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") } var attendance by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT") }
var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) } var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) }
var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") } var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") }
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) } var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) }
var position by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.position.orEmpty()) } var position by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.position.orEmpty()) }
var email by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.email.orEmpty()) } var email by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.email.orEmpty()) }
var phone by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.phone.orEmpty()) } var phone by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.phone.orEmpty()) }
var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) } var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) }
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") } var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") } var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
@@ -293,28 +294,25 @@ fun ModernMobileActsScreen(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary) Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
} }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") }) FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") })
FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") }) FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") })
} }
if (attendance == "PRESENT") { if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind -> OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) }) OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
} if (closure?.responsible == null && closure?.representativeSuggestion != null) {
Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Button( Button(
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) }, onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(), enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Guardar responsable") } ) { Text("Guardar representante") }
} else { } else {
OutlinedTextField( OutlinedTextField(
absenceReason, absenceReason,
@@ -340,7 +338,7 @@ fun ModernMobileActsScreen(
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
} }
Text( Text(
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia según lo constatado en campo.", "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.",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold) Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
@@ -407,7 +405,7 @@ fun ModernMobileActsScreen(
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold) Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) { if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) { val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad" "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar" "REFUSED" -> "Negativa a firmar"
else -> humanBackendLabelEs(companyOutcome.status) else -> humanBackendLabelEs(companyOutcome.status)
} }
@@ -429,19 +427,19 @@ fun ModernMobileActsScreen(
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") }) FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") }) FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disconformidad") })
} }
if (manifestation == "DISSENT") { if (manifestation == "DISSENT") {
OutlinedTextField( OutlinedTextField(
dissentStatement, dissentStatement,
{ dissentStatement = it }, { dissentStatement = it },
label = { Text("Manifestación de disidencia *") }, label = { Text("Motivo de disconformidad *") },
minLines = 2, minLines = 2,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
SignaturePad( SignaturePad(
label = "Firma del responsable de empresa", label = "Firma del representante de la empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) }, onCaptured = { file -> signWithGeo(file, company = true) },
) )
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -36,6 +37,11 @@ import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -57,6 +63,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -76,6 +83,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -259,6 +267,7 @@ private fun ModernChecklistCard(visit: VisitDetail) {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) { private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
@@ -279,6 +288,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") } var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
var familyExpanded by remember { mutableStateOf(false) }
val attributeValues = remember { mutableStateMapOf<String, String>() } val attributeValues = remember { mutableStateMapOf<String, String>() }
var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) } var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) }
var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
@@ -295,6 +305,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
selectedTypeId = null selectedTypeId = null
selectedFamilyId = null selectedFamilyId = null
familySearch = "" familySearch = ""
familyExpanded = false
attributeValues.clear() attributeValues.clear()
localError = null localError = null
} }
@@ -379,6 +390,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId } val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val editableAttributes = selectedType?.attributes.orEmpty().filterNot {
selectedType?.familyRequired == true && it.code == "tipo_instalacion"
}
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId } val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
val filteredFamilies = remember(selectedType, familySearch) { val filteredFamilies = remember(selectedType, familySearch) {
val needle = modernNormalize(familySearch) val needle = modernNormalize(familySearch)
@@ -746,37 +760,56 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
if (selectedType?.familyRequired == true) { if (selectedType?.familyRequired == true) {
Text("Clasificación técnica", fontWeight = FontWeight.Bold) ExposedDropdownMenuBox(
OutlinedTextField( expanded = familyExpanded && !model.busy,
value = familySearch, onExpandedChange = {
onValueChange = { familySearch = it }, if (!model.busy) {
label = { Text("Buscar clasificación") }, familyExpanded = it
leadingIcon = { Icon(Icons.Filled.Search, null) }, familySearch = ""
supportingText = { Text("${filteredFamilies.size} opciones compatibles") }, }
modifier = Modifier.fillMaxWidth(), },
singleLine = true, ) {
) OutlinedTextField(
filteredFamilies.take(12).forEach { family -> value = if (familyExpanded) familySearch else selectedFamily?.name.orEmpty(),
Surface( onValueChange = { familySearch = it; familyExpanded = true },
onClick = { selectedFamilyId = family.id }, label = { Text(if (isSubinstallation) "Tipo de subinstalación" else "Tipo de instalación") },
modifier = Modifier.fillMaxWidth(), placeholder = { Text("Buscar por nombre o código") },
shape = MaterialTheme.shapes.medium, leadingIcon = { Icon(Icons.Filled.Search, null) },
color = if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = familyExpanded) },
border = BorderStroke(1.dp, if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant), 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,
onDismissRequest = { familyExpanded = false; familySearch = "" },
modifier = Modifier.heightIn(max = 280.dp),
) { ) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { if (filteredFamilies.isEmpty()) {
Text( DropdownMenuItem(text = { Text("Sin coincidencias") }, onClick = {}, enabled = false)
(if (family.isOther) "Otro · " else "") + family.name, }
Modifier.weight(1f), filteredFamilies.forEach { family ->
fontWeight = if (selectedFamilyId == family.id) FontWeight.Bold else FontWeight.Normal, DropdownMenuItem(
text = { Text((if (family.isOther) "Otro · " else "") + family.name) },
trailingIcon = {
if (selectedFamilyId == family.id) Icon(Icons.Filled.CheckCircle, "Seleccionado")
},
onClick = {
selectedFamilyId = family.id
familyExpanded = false
familySearch = ""
keyboard?.hide()
focusManager.clearFocus()
},
) )
if (selectedFamilyId == family.id) Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
} }
} }
} }
if (filteredFamilies.size > 12) {
Text("Seguí escribiendo para reducir la lista.", style = MaterialTheme.typography.bodySmall)
}
selectedFamily?.let { family -> selectedFamily?.let { family ->
if (family.isOther) { if (family.isOther) {
Text("Quedará marcado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary) Text("Quedará marcado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary)
@@ -791,9 +824,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Nombre técnico *") }, 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(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
OutlinedTextField( OutlinedTextField(
value = commonName, value = commonName,
@@ -801,17 +836,24 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
label = { Text("Nombre habitual (opcional)") }, label = { Text("Nombre habitual (opcional)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
OutlinedTextField( OutlinedTextField(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
label = { Text("Descripción (opcional)") }, label = { Text("Descripción (opcional)") },
supportingText = { Text("Dato equivalente al alta desde el panel web.") },
modifier = Modifier.fillMaxWidth(), 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() },
),
) )
selectedType?.attributes?.forEach { definition -> editableAttributes.forEachIndexed { index, definition ->
val isLastAttribute = index == editableAttributes.lastIndex
OutlinedTextField( OutlinedTextField(
value = attributeValues[definition.code].orEmpty(), value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it }, onValueChange = { attributeValues[definition.code] = it },
@@ -822,8 +864,14 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}, },
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text, 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(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
) )
} }
@@ -840,10 +888,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
} }
val attributesReady = selectedType?.attributes val attributesReady = editableAttributes
?.filter { it.isRequired } .filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() } .all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button( Button(
onClick = { requestCreate() }, onClick = { requestCreate() },
@@ -1074,18 +1121,7 @@ private fun modernNormalize(value: String): String = value.trim().lowercase()
private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status) private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status)
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> = private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition -> fieldAttributePayload(type.attributes, values)
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceModernAttribute(definition, raw)
}.toMap()
private fun coerceModernAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun modernHasPermission(context: Context, permission: String): Boolean = private fun modernHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -10,7 +10,7 @@ class DynamicFieldFlowContractTest {
@Test @Test
fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() { fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() {
assertTrue(source.contains("Elegí la Instalación padre")) assertTrue(source.contains("Elegí la Instalación padre"))
assertTrue(source.contains("Clasificación técnica *")) assertTrue(source.contains("Tipo de instalación *"))
assertTrue(source.contains("Guardar y tomar foto")) assertTrue(source.contains("Guardar y tomar foto"))
assertTrue(source.contains("model.loadFieldTypes(item.id)")) assertTrue(source.contains("model.loadFieldTypes(item.id)"))
assertTrue(source.contains("parentId = item.id")) assertTrue(source.contains("parentId = item.id"))
@@ -0,0 +1,29 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FieldAttributePayloadTest {
@Test
fun payloadUsesDefinitionIdsExpectedByApiAndKeepsTypedValues() {
val definitions = listOf(
FieldAttributeDefinition("id-marca", "campo_marca", "Marca", "TEXT"),
FieldAttributeDefinition("id-capacidad", "campo_capacidad", "Capacidad", "NUMBER"),
FieldAttributeDefinition("id-serie", "campo_numero_serie", "Número de serie", "TEXT"),
)
val payload = fieldAttributePayload(
definitions,
mapOf("campo_marca" to " algo ", "campo_capacidad" to "37,73", "campo_numero_serie" to " "),
)
assertEquals("algo", payload["id-marca"])
assertEquals(37.73, payload["id-capacidad"])
assertFalse(payload.containsKey("id-serie"))
assertTrue(payload.keys.none { it.startsWith("campo_") })
}
}
@@ -21,6 +21,13 @@ class FinalFieldFlowContractTest {
assertTrue(visit.contains("Subinstalación")) assertTrue(visit.contains("Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto")) assertTrue(visit.contains("Guardar y tomar foto"))
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma")) assertTrue(acts.contains("Cerrar Acta y dejar pendiente de 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 no se asigna a cada Hallazgo"))
assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\"")) assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\""))
} }
@@ -45,7 +52,9 @@ class FinalFieldFlowContractTest {
fun currentFieldUiDoesNotExposeBackendEnglishStates() { fun currentFieldUiDoesNotExposeBackendEnglishStates() {
assertTrue(home.contains("visitStatusLabelEs(visit.status)")) assertTrue(home.contains("visitStatusLabelEs(visit.status)"))
assertTrue(findings.contains("findingStatusLabelEs(finding.status)")) assertTrue(findings.contains("findingStatusLabelEs(finding.status)"))
assertTrue(acts.contains("documentTypeLabelEs(kind)")) assertTrue(acts.contains("DNI *"))
assertFalse(acts.contains("PASSPORT"))
assertFalse(acts.contains("OTHER"))
assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\"")) assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\""))
assertTrue(labels.contains("\"OTHER\" -> \"Otro\"")) assertTrue(labels.contains("\"OTHER\" -> \"Otro\""))
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta")) assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test @Test
fun debugBuildKeepsSeparateApplicationIdentity() { fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(30, BuildConfig.VERSION_CODE) assertEquals(34, BuildConfig.VERSION_CODE)
assertEquals("0.19.2-debug", BuildConfig.VERSION_NAME) assertEquals("0.19.6-debug", BuildConfig.VERSION_NAME)
} }
@Test @Test
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-2", "version": "0.29.0-4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-2", "version": "0.29.0-4",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-2", "version": "0.29.0-4",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { PhaseADataModule } from '../core-data/phase-a-data.module'; import { PhaseADataModule } from '../core-data/phase-a-data.module';
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
import { RolesController } from './roles/roles.controller'; import { RolesController } from './roles/roles.controller';
import { RolesService } from './roles/roles.service'; import { RolesService } from './roles/roles.service';
import { UsersController } from './users/users.controller'; import { UsersController } from './users/users.controller';
import { UsersService } from './users/users.service'; import { UsersService } from './users/users.service';
@Module({ @Module({
imports: [PhaseADataModule, AuditModule, AuthModule], imports: [PhaseADataModule, AuditModule, AuthModule, InspectionReportsModule],
controllers: [UsersController, RolesController], controllers: [UsersController, RolesController],
providers: [UsersService, RolesService], providers: [UsersService, RolesService],
}) })
@@ -28,10 +28,9 @@ export class CreateUserDto {
@Transform(({ value }) => @Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null, typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
) )
@IsOptional()
@IsEmail() @IsEmail()
@MaxLength(320) @MaxLength(320)
email?: string | null; email!: string;
@Transform(({ value }) => @Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null, typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
@@ -0,0 +1,30 @@
import { Transform } from 'class-transformer';
import {
IsEmail,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class UpdateSelfProfileDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsEmail()
@MaxLength(320)
email!: string;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(160)
jobTitle?: string | null;
}
@@ -0,0 +1,65 @@
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
import { SmtpSecurityMode } from '../../../database/entities';
export enum UserSmtpMode {
SYSTEM = 'SYSTEM',
CUSTOM = 'CUSTOM',
}
const trimmed = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export class UpdateUserSmtpSettingsDto {
@IsEnum(UserSmtpMode)
mode!: UserSmtpMode;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@Transform(trimmed)
@IsString()
@MinLength(1)
@MaxLength(255)
host?: string;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@IsInt()
@Min(1)
@Max(65535)
port?: number;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@IsEnum(SmtpSecurityMode)
securityMode?: SmtpSecurityMode;
@IsOptional()
@Transform(trimmed)
@IsString()
@MaxLength(255)
username?: string | null;
@IsOptional()
@IsString()
@MaxLength(512)
password?: string | null;
@IsOptional()
@Transform(trimmed)
@IsString()
@MaxLength(200)
fromName?: string | null;
@IsOptional()
@IsBoolean()
enabled = true;
}
@@ -10,23 +10,33 @@ import {
Query, Query,
Req, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuditService } from '../../audit/audit.service';
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator'; import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
import type { import type {
AuthPrincipal, AuthPrincipal,
RequestWithContext, RequestWithContext,
} from '../../common/http/request-context'; } from '../../common/http/request-context';
import { AuditAction } from '../../database/entities';
import { SmtpDeliveryService } from '../../inspection-reports/smtp-delivery.service';
import { administrationAuditContext } from '../common/administration-audit';
import { ChangeUserStatusDto } from './dto/change-user-status.dto'; import { ChangeUserStatusDto } from './dto/change-user-status.dto';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto'; import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto'; import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import { ResetUserPasswordDto } from './dto/reset-user-password.dto'; import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
import { UpdateUserSmtpSettingsDto } from './dto/update-user-smtp-settings.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
@Controller('users') @Controller('users')
export class UsersController { export class UsersController {
constructor(private readonly users: UsersService) {} constructor(
private readonly users: UsersService,
private readonly smtp: SmtpDeliveryService,
private readonly audit: AuditService,
) {}
@Get() @Get()
@RequirePermissions('users.read') @RequirePermissions('users.read')
@@ -34,6 +44,73 @@ export class UsersController {
return this.users.list(query); return this.users.list(query);
} }
@Get('self/profile')
selfProfile(@CurrentAuth() principal: AuthPrincipal) {
return this.users.getSelfProfile(principal.userId);
}
@Patch('self/profile')
updateSelfProfile(
@Body() dto: UpdateSelfProfileDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.updateSelfProfile(dto, principal, request);
}
@Get('self/smtp')
selfSmtp(@CurrentAuth() principal: AuthPrincipal) {
return this.smtp.publicUserSettings(principal.userId);
}
@Put('self/smtp')
async updateSelfSmtp(
@Body() dto: UpdateUserSmtpSettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
const before = await this.smtp.publicUserSettings(principal.userId);
const after = await this.smtp.saveUserSettings(principal.userId, dto);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_SMTP_SETTINGS_UPDATED,
entityType: 'user_smtp_settings',
entityId: principal.userId,
beforeData: before as Record<string, unknown>,
afterData: after as Record<string, unknown>,
metadata: { passwordNeverReturned: true, scope: 'SELF' },
});
return after;
}
@Post('self/smtp/test')
async testSelfSmtp(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
const profile = await this.users.getSelfProfile(principal.userId);
if (!profile.email) throw new Error('El usuario no tiene email configurado');
const sent = await this.smtp.send({
to: profile.email,
subject: 'DH Inspección · Prueba de correo personal',
text: 'Este correo confirma que tu configuración de correo en DH Inspección funciona correctamente.',
attachment: {
filename: 'dh-inspeccion-prueba-correo.txt',
mimeType: 'text/plain',
content: Buffer.from('DH Inspección · Correo personal OK\n', 'utf8'),
},
}, principal.userId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_SMTP_TEST_SENT,
entityType: 'user_smtp_settings',
entityId: principal.userId,
afterData: { recipient: profile.email, messageId: sent.messageId },
metadata: { scope: 'SELF' },
});
return { ok: true, recipient: profile.email, messageId: sent.messageId };
}
@Post() @Post()
@RequirePermissions('users.create') @RequirePermissions('users.create')
create( create(
@@ -29,6 +29,7 @@ import type { CreateUserDto } from './dto/create-user.dto';
import type { ListUsersQueryDto } from './dto/list-users-query.dto'; import type { ListUsersQueryDto } from './dto/list-users-query.dto';
import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto'; import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import type { ResetUserPasswordDto } from './dto/reset-user-password.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 type { UpdateUserDto } from './dto/update-user.dto';
export interface UserRoleView { export interface UserRoleView {
@@ -56,6 +57,7 @@ export interface AdministrativeUserView {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
roles: UserRoleView[]; roles: UserRoleView[];
smtpMode: 'SYSTEM' | 'CUSTOM';
} }
interface UserViewRow extends AdministrativeUserView { interface UserViewRow extends AdministrativeUserView {
@@ -76,10 +78,10 @@ function roleSelectionInvalid(): BadRequestException {
}); });
} }
function inspectorEmailRequired(): BadRequestException { function userEmailRequired(): BadRequestException {
return new BadRequestException({ return new BadRequestException({
code: 'INSPECTOR_EMAIL_REQUIRED', code: 'USER_EMAIL_REQUIRED',
message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones', message: 'Cada usuario de Hidrocarburos debe tener un email válido',
}); });
} }
@@ -144,6 +146,7 @@ export class UsersService {
user_account.password_changed_at AS "passwordChangedAt", user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt", user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt", user_account.updated_at AS "updatedAt",
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
COALESCE( COALESCE(
JSONB_AGG( JSONB_AGG(
JSONB_BUILD_OBJECT( JSONB_BUILD_OBJECT(
@@ -185,6 +188,24 @@ export class UsersService {
); );
} }
async getSelfProfile(userId: string): Promise<AdministrativeUserView> {
return this.getById(userId);
}
async updateSelfProfile(
dto: UpdateSelfProfileDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
if (!dto.email?.trim()) throw userEmailRequired();
return this.update(
principal.userId,
{ email: dto.email, phone: dto.phone, jobTitle: dto.jobTitle },
principal,
request,
);
}
async create( async create(
dto: CreateUserDto, dto: CreateUserDto,
principal: AuthPrincipal, principal: AuthPrincipal,
@@ -195,10 +216,10 @@ export class UsersService {
try { try {
return await this.dataSource.transaction(async (manager) => { return await this.dataSource.transaction(async (manager) => {
const roles = await this.resolveRoles(manager, dto.roleIds); const roles = await this.resolveRoles(manager, dto.roleIds);
this.assertInspectorHasEmail(roles, dto.email ?? null); if (!dto.email?.trim()) throw userEmailRequired();
const user = manager.getRepository(User).create({ const user = manager.getRepository(User).create({
username: dto.username.trim().toLowerCase(), username: dto.username.trim().toLowerCase(),
email: dto.email?.trim().toLowerCase() || null, email: dto.email.trim().toLowerCase(),
dni: dto.dni ?? null, dni: dto.dni ?? null,
phone: dto.phone ?? null, phone: dto.phone ?? null,
jobTitle: dto.jobTitle ?? null, jobTitle: dto.jobTitle ?? null,
@@ -268,15 +289,13 @@ export class UsersService {
return await this.dataSource.transaction(async (manager) => { return await this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id); const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id); const before = await this.loadUserView(manager, id);
if (dto.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) { if (dto.email !== undefined && !dto.email) throw userEmailRequired();
throw inspectorEmailRequired();
}
if (dto.username !== undefined) { if (dto.username !== undefined) {
user.username = dto.username.trim().toLowerCase(); user.username = dto.username.trim().toLowerCase();
} }
if (dto.email !== undefined) { if (dto.email !== undefined) {
user.email = dto.email?.trim().toLowerCase() || null; user.email = dto.email!.trim().toLowerCase();
} }
if (dto.dni !== undefined) user.dni = dto.dni ?? null; if (dto.dni !== undefined) user.dni = dto.dni ?? null;
if (dto.phone !== undefined) user.phone = dto.phone ?? null; if (dto.phone !== undefined) user.phone = dto.phone ?? null;
@@ -286,6 +305,13 @@ export class UsersService {
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim(); if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
user.updatedBy = principal.userId; user.updatedBy = principal.userId;
await manager.getRepository(User).save(user); await manager.getRepository(User).save(user);
if (dto.email !== undefined && user.email) {
await manager.query(`
UPDATE user_smtp_settings
SET from_email=$2,reply_to=$2,updated_by=$1,updated_at=CURRENT_TIMESTAMP
WHERE user_id=$1 AND mode='CUSTOM'
`, [id, user.email]);
}
const updated = await this.loadUserView(manager, id); const updated = await this.loadUserView(manager, id);
await this.audit.record( await this.audit.record(
@@ -414,7 +440,6 @@ export class UsersService {
await this.lockUser(manager, id); await this.lockUser(manager, id);
const roles = await this.resolveRoles(manager, dto.roleIds); const roles = await this.resolveRoles(manager, dto.roleIds);
const before = await this.loadUserView(manager, id); const before = await this.loadUserView(manager, id);
this.assertInspectorHasEmail(roles, before.email);
const beforeIds = before.roles.map((role) => role.id).sort(); const beforeIds = before.roles.map((role) => role.id).sort();
const afterIds = roles.map((role) => role.id).sort(); const afterIds = roles.map((role) => role.id).sort();
if (beforeIds.join(',') === afterIds.join(',')) return before; if (beforeIds.join(',') === afterIds.join(',')) return before;
@@ -453,12 +478,6 @@ export class UsersService {
return roles; return roles;
} }
private assertInspectorHasEmail(roles: Role[], email: string | null | undefined): void {
if (roles.some((role) => role.code === 'inspector') && !email?.trim()) {
throw inspectorEmailRequired();
}
}
private async insertUserRoles( private async insertUserRoles(
manager: EntityManager, manager: EntityManager,
userId: string, userId: string,
@@ -511,6 +530,7 @@ export class UsersService {
user_account.password_changed_at AS "passwordChangedAt", user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt", user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt", user_account.updated_at AS "updatedAt",
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
COALESCE( COALESCE(
JSONB_AGG( JSONB_AGG(
JSONB_BUILD_OBJECT( JSONB_BUILD_OBJECT(
@@ -760,6 +760,7 @@ export class AssetsService {
parentId: dto.parentId, parentId: dto.parentId,
operationalAreaId: dto.operationalAreaId, operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId, operatorCompanyId: dto.operatorCompanyId,
inventoryFamilyId: dto.inventoryFamilyId ?? null,
code: dto.code, code: dto.code,
name: dto.name, name: dto.name,
commonName: dto.commonName ?? null, commonName: dto.commonName ?? null,
@@ -36,6 +36,10 @@ export class CreateFieldDiscoveryDto {
@IsUUID('4') @IsUUID('4')
operatorCompanyId!: string; operatorCompanyId!: string;
@IsOptional()
@IsUUID('4')
inventoryFamilyId?: string | null;
@IsOptional() @IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString() @IsString()
+4 -5
View File
@@ -118,10 +118,9 @@ async function main(): Promise<void> {
); );
} }
email = (await prompt.question('Email (optional): ')).trim().toLowerCase(); email = (await prompt.question('Email: ')).trim().toLowerCase();
if ( if (
email && !email || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
(email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
) { ) {
throw new Error('Email is invalid'); throw new Error('Email is invalid');
} }
@@ -189,7 +188,7 @@ async function main(): Promise<void> {
OR ($2::text IS NOT NULL AND LOWER(email) = $2) OR ($2::text IS NOT NULL AND LOWER(email) = $2)
LIMIT 1 LIMIT 1
`, `,
[username, email || null], [username, email],
)) as unknown[]; )) as unknown[];
if (duplicate.length > 0) { if (duplicate.length > 0) {
throw new Error('Username or email already exists'); throw new Error('Username or email already exists');
@@ -202,7 +201,7 @@ async function main(): Promise<void> {
const user = manager.getRepository(User).create({ const user = manager.getRepository(User).create({
username, username,
email: email || null, email,
passwordHash, passwordHash,
firstName, firstName,
lastName, lastName,
@@ -103,6 +103,8 @@ export enum AuditAction {
INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED', INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED',
SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED', SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED',
SMTP_TEST_SENT = 'SMTP_TEST_SENT', SMTP_TEST_SENT = 'SMTP_TEST_SENT',
USER_SMTP_SETTINGS_UPDATED = 'USER_SMTP_SETTINGS_UPDATED',
USER_SMTP_TEST_SENT = 'USER_SMTP_TEST_SENT',
DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED', DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED',
DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED', DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT', DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
+2 -2
View File
@@ -16,8 +16,8 @@ export class User extends TimestampedEntity {
@Column({ type: 'varchar', length: 80 }) @Column({ type: 'varchar', length: 80 })
username!: string; username!: string;
@Column({ type: 'varchar', length: 320, nullable: true }) @Column({ type: 'varchar', length: 320 })
email!: string | null; email!: string;
@Column({ type: 'varchar', length: 32, nullable: true }) @Column({ type: 'varchar', length: 32, nullable: true })
dni!: string | null; dni!: string | null;
@@ -0,0 +1,105 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class F62UserSmtpAndActRepresentative1790117400000 implements MigrationInterface {
name = 'F62UserSmtpAndActRepresentative1790117400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM users WHERE email IS NULL OR btrim(email)='') THEN
RAISE EXCEPTION 'USER_EMAIL_REQUIRED_BEFORE_F62';
END IF;
END $$
`);
await queryRunner.query(`ALTER TABLE users ALTER COLUMN email SET NOT NULL`);
await queryRunner.query(`
CREATE TABLE user_smtp_settings (
user_id uuid PRIMARY KEY,
mode varchar(16) NOT NULL DEFAULT 'SYSTEM',
host varchar(255),
port integer,
security_mode varchar(24),
username varchar(255),
password_enc text,
from_name varchar(200),
from_email varchar(320),
reply_to varchar(320),
enabled boolean NOT NULL DEFAULT true,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_user_smtp_mode CHECK (mode IN ('SYSTEM','CUSTOM')),
CONSTRAINT chk_user_smtp_security CHECK (security_mode IS NULL OR security_mode IN ('NONE','STARTTLS','TLS')),
CONSTRAINT chk_user_smtp_port CHECK (port IS NULL OR (port > 0 AND port <= 65535)),
CONSTRAINT chk_user_smtp_custom_complete CHECK (
mode='SYSTEM' OR (
LENGTH(TRIM(COALESCE(host,''))) > 0
AND port IS NOT NULL
AND security_mode IS NOT NULL
AND LENGTH(TRIM(COALESCE(from_email,''))) > 0
)
),
CONSTRAINT fk_user_smtp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_smtp_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
ALTER TABLE inspection_act_responsibles
DROP CONSTRAINT chk_inspection_act_responsibles_details
`);
await queryRunner.query(`
ALTER TABLE inspection_act_responsibles
ADD CONSTRAINT chk_inspection_act_responsibles_details CHECK (
(
attendance_status = 'PRESENT'
AND LENGTH(TRIM(COALESCE(full_name, ''))) > 0
AND document_type IS NOT NULL
AND LENGTH(TRIM(COALESCE(document_number, ''))) > 0
AND LENGTH(TRIM(COALESCE(position, ''))) > 0
AND LENGTH(TRIM(COALESCE(email, ''))) > 0
AND absence_reason IS NULL
) OR (
attendance_status = 'ABSENT'
AND LENGTH(TRIM(COALESCE(absence_reason, ''))) >= 10
)
)
`);
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 ON TABLE user_smtp_settings TO ${applicationRole}`);
await queryRunner.query(`REVOKE DELETE ON TABLE user_smtp_settings FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_act_responsibles
DROP CONSTRAINT chk_inspection_act_responsibles_details
`);
await queryRunner.query(`
ALTER TABLE inspection_act_responsibles
ADD CONSTRAINT chk_inspection_act_responsibles_details CHECK (
(
attendance_status = 'PRESENT'
AND LENGTH(TRIM(COALESCE(full_name, ''))) > 0
AND document_type IS NOT NULL
AND LENGTH(TRIM(COALESCE(document_number, ''))) > 0
AND LENGTH(TRIM(COALESCE(position, ''))) > 0
AND absence_reason IS NULL
) OR (
attendance_status = 'ABSENT'
AND LENGTH(TRIM(COALESCE(absence_reason, ''))) >= 10
)
)
`);
await queryRunner.query(`DROP TABLE IF EXISTS user_smtp_settings`);
await queryRunner.query(`ALTER TABLE users ALTER COLUMN email DROP NOT NULL`);
}
}
@@ -728,10 +728,13 @@ export class InspectionActsService {
principal: AuthPrincipal, principal: AuthPrincipal,
): Promise<number> { ): Promise<number> {
const [row] = (await manager.query(` const [row] = (await manager.query(`
UPDATE inspection_acts WITH updated AS (
SET current_version = current_version + 1 UPDATE inspection_acts
WHERE id = $1 SET current_version = current_version + 1
RETURNING current_version AS "versionNumber" WHERE id = $1
RETURNING current_version
)
SELECT current_version AS "versionNumber" FROM updated
`, [act.id])) as Array<{ versionNumber: number }>; `, [act.id])) as Array<{ versionNumber: number }>;
const versionNumber = Number(row.versionNumber); const versionNumber = Number(row.versionNumber);
act.currentVersion = versionNumber; act.currentVersion = versionNumber;
@@ -213,14 +213,14 @@ export class CompanySignatureInviteService {
if (!publicUrl) { if (!publicUrl) {
deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada'; deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada';
} else if (!(await this.smtp.configured())) { } else if (!(await this.smtp.configured(principal.userId))) {
deliveryError = 'SMTP no configurado'; deliveryError = 'SMTP no configurado';
} else { } else {
const body = [ const body = [
`Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`, `Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`,
`Inspección: ${created.inspectionCode}.`, `Inspección: ${created.inspectionCode}.`,
'', '',
'El enlace permite firmar en conformidad, firmar en disidencia o registrar una negativa a firmar.', 'El enlace permite firmar en conformidad, firmar en disconformidad o registrar una negativa a firmar.',
'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.', 'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.',
'', '',
`Enlace seguro: ${publicUrl}`, `Enlace seguro: ${publicUrl}`,
@@ -236,7 +236,7 @@ export class CompanySignatureInviteService {
mimeType: 'text/plain', mimeType: 'text/plain',
content: Buffer.from(body, 'utf8'), content: Buffer.from(body, 'utf8'),
}, },
}); }, principal.userId);
emailSent = true; emailSent = true;
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_act_company_signature_invites UPDATE inspection_act_company_signature_invites
@@ -54,7 +54,9 @@ export class UpsertInspectionResponsibleDto {
@MaxLength(200) @MaxLength(200)
position?: string; position?: string;
@IsOptional() @ValidateIf((dto: UpsertInspectionResponsibleDto) => (
dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT
))
@Transform(trimOrUndefined) @Transform(trimOrUndefined)
@IsEmail() @IsEmail()
@MaxLength(320) @MaxLength(320)
@@ -152,6 +152,7 @@ export interface InspectionClosureView {
actualClosedAt: Date | null; actualClosedAt: Date | null;
}; };
responsible: ResponsibleView | null; responsible: ResponsibleView | null;
representativeSuggestion: ResponsibleView | null;
closure: null | { closure: null | {
schemaVersion: string; schemaVersion: string;
preparedSha256: string; preparedSha256: string;
@@ -1022,6 +1023,7 @@ export class InspectionClosingService {
}>; }>;
if (!context) throw actNotFound(); if (!context) throw actNotFound();
const responsible = await this.loadResponsible(manager, actId); const responsible = await this.loadResponsible(manager, actId);
const representativeSuggestion = responsible ? null : await this.loadResponsibleSuggestion(manager, actId);
const closure = await this.loadClosure(manager, actId); const closure = await this.loadClosure(manager, actId);
const signatures = await this.loadSignatures(manager, actId); const signatures = await this.loadSignatures(manager, actId);
return { return {
@@ -1051,6 +1053,7 @@ export class InspectionClosingService {
actualClosedAt: context.visitActualClosedAt, actualClosedAt: context.visitActualClosedAt,
}, },
responsible, responsible,
representativeSuggestion,
closure: closure ? { closure: closure ? {
schemaVersion: closure.schemaVersion, schemaVersion: closure.schemaVersion,
preparedSha256: closure.preparedSha256, preparedSha256: closure.preparedSha256,
@@ -1083,12 +1086,30 @@ export class InspectionClosingService {
return row ?? null; return row ?? null;
} }
private async loadResponsibleSuggestion(manager: EntityManager, actId: string): Promise<ResponsibleView | null> {
const [row] = await manager.query(`
SELECT responsible.act_id AS "actId",responsible.attendance_status AS "attendanceStatus",
responsible.full_name AS "fullName",responsible.document_type AS "documentType",
responsible.document_number AS "documentNumber",responsible.position,responsible.email,responsible.phone,
responsible.absence_reason AS "absenceReason",responsible.updated_by AS "updatedBy",
responsible.created_at AS "createdAt",responsible.updated_at AS "updatedAt"
FROM inspection_acts current_act
JOIN inspection_acts previous_act
ON previous_act.visit_id=current_act.visit_id AND previous_act.id<>current_act.id
JOIN inspection_act_responsibles responsible ON responsible.act_id=previous_act.id
WHERE current_act.id=$1 AND responsible.attendance_status='PRESENT'
ORDER BY responsible.updated_at DESC,previous_act.created_at DESC
LIMIT 1
`, [actId]) as ResponsibleView[];
return row ?? null;
}
private async requireResponsible(manager: EntityManager, actId: string): Promise<ResponsibleView> { private async requireResponsible(manager: EntityManager, actId: string): Promise<ResponsibleView> {
const responsible = await this.loadResponsible(manager, actId); const responsible = await this.loadResponsible(manager, actId);
if (!responsible) { if (!responsible) {
throw new ConflictException({ throw new ConflictException({
code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED', code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED',
message: 'Debe identificarse al responsable o documentar su ausencia antes de finalizar el acta', message: 'Debe identificarse al representante de la empresa o documentar su ausencia antes de cerrar el Acta',
}); });
} }
return responsible; return responsible;
@@ -99,8 +99,10 @@ function lines(snapshot: Record<string, unknown>): string[] {
`Fecha: ${date(act.occurredAt)}`, `Fecha: ${date(act.occurredAt)}`,
`Urgencia: ${urgencyLabel(act.urgency)}`, `Urgencia: ${urgencyLabel(act.urgency)}`,
`Plazo: ${deadlineText}`, `Plazo: ${deadlineText}`,
`Responsable empresa: ${text(responsible.fullName)}`, `Representante de la empresa: ${text(responsible.fullName)}`,
`Cargo: ${text(responsible.position)}`, `DNI: ${text(responsible.documentNumber)}`,
`Cargo / funcion: ${text(responsible.position)}`,
`Email: ${text(responsible.email)}`,
'', '',
'RESUMEN', 'RESUMEN',
...wrap(text(act.summary)), ...wrap(text(act.summary)),
@@ -136,7 +138,7 @@ function lines(snapshot: Record<string, unknown>): string[] {
out.push('Manifestacion de empresa: pendiente.'); out.push('Manifestacion de empresa: pendiente.');
} else if (text(companySignature.status, '') === 'SIGNED') { } else if (text(companySignature.status, '') === 'SIGNED') {
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY'); const manifestation = text(companySignature.companyManifestation, 'CONFORMITY');
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disidencia' : 'Empresa: firma en conformidad'); out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disconformidad' : 'Empresa: firma en conformidad');
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement))); if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
} else { } else {
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`)); out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
@@ -281,7 +281,7 @@ export class InspectionDocumentDeliveryService {
email = company?.email ?? null; email = company?.email ?? null;
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) { } else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
const [inspector] = await this.dataSource.query(` const [inspector] = await this.dataSource.query(`
SELECT email FROM users WHERE id=$1 AND is_active=true SELECT email FROM users WHERE id=$1 AND status='ACTIVE'
`, [row.recipientUserId]) as Array<{ email: string | null }>; `, [row.recipientUserId]) as Array<{ email: string | null }>;
email = inspector?.email ?? null; email = inspector?.email ?? null;
} else { } else {
@@ -305,7 +305,14 @@ export class InspectionDocumentDeliveryService {
await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado'); await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');
return; return;
} }
if (!await this.smtp.configured()) { const [sender] = await this.dataSource.query(`
SELECT visit.lead_inspector_user_id AS "userId"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
WHERE act.id=$1
`, [row.actId]) as Array<{ userId: string | null }>;
const senderUserId = sender?.userId ?? undefined;
if (!await this.smtp.configured(senderUserId)) {
await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado'); await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');
return; return;
} }
@@ -348,7 +355,7 @@ export class InspectionDocumentDeliveryService {
: `Se adjunta el acta sellada e inmutable ${row.actCode}.`; : `Se adjunta el acta sellada e inmutable ${row.actCode}.`;
const sent = await this.smtp.send({ const sent = await this.smtp.send({
to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment, to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
}); }, senderUserId);
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_document_deliveries UPDATE inspection_document_deliveries
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2, SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
@@ -147,8 +147,10 @@ function documentXml(input: ReportWordInput): string {
paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'), paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
labelValue('Resumen del Acta', text(snapshot.act.summary)), labelValue('Resumen del Acta', text(snapshot.act.summary)),
labelValue('Observaciones del Acta', text(snapshot.act.observations)), labelValue('Observaciones del Acta', text(snapshot.act.observations)),
labelValue('Responsable de empresa', text(snapshot.responsible.fullName)), labelValue('Representante de la empresa', text(snapshot.responsible.fullName)),
labelValue('Cargo', text(snapshot.responsible.position)), labelValue('DNI', text(snapshot.responsible.documentNumber)),
labelValue('Cargo / función', text(snapshot.responsible.position)),
labelValue('Email', text(snapshot.responsible.email)),
paragraph('Inventario inspeccionado', 'Heading1'), paragraph('Inventario inspeccionado', 'Heading1'),
inventoryRows.length inventoryRows.length
? table(['Código', 'Nombre', 'Tipo'], inventoryRows) ? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
@@ -11,7 +11,7 @@ interface MailInput { to:string; subject:string; text:string; attachment:MailAtt
interface Reply { code:number; text:string; } interface Reply { code:number; text:string; }
export interface EffectiveSmtpSettings { export interface EffectiveSmtpSettings {
source: 'DATABASE' | 'ENVIRONMENT'; source: 'USER' | 'DATABASE' | 'ENVIRONMENT';
host: string; host: string;
port: number; port: number;
securityMode: SmtpSecurityMode; securityMode: SmtpSecurityMode;
@@ -47,10 +47,10 @@ export class SmtpDeliveryService {
private readonly config:ConfigService, private readonly config:ConfigService,
){} ){}
async configured():Promise<boolean>{return Boolean(await this.resolveSettings());} async configured(userId?:string):Promise<boolean>{return Boolean(await this.resolveSettings(userId));}
async fromAddress():Promise<string|null>{ async fromAddress(userId?:string):Promise<string|null>{
const settings=await this.resolveSettings(); const settings=await this.resolveSettings(userId);
if(!settings)return null; if(!settings)return null;
return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail; return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail;
} }
@@ -73,6 +73,56 @@ export class SmtpDeliveryService {
}:{source:'NONE',enabled:false}; }:{source:'NONE',enabled:false};
} }
async publicUserSettings(userId:string){
const [user]=await this.dataSource.query(`
SELECT email,first_name AS "firstName",last_name AS "lastName" FROM users WHERE id=$1
`,[userId]) as Array<{email:string;firstName:string;lastName:string}>;
if(!user)throw new Error('Usuario no encontrado');
const [row]=await this.dataSource.query(`
SELECT mode,host,port,security_mode AS "securityMode",username,
(password_enc IS NOT NULL) AS "hasPassword",from_name AS "fromName",
from_email AS "fromEmail",enabled,updated_at AS "updatedAt"
FROM user_smtp_settings WHERE user_id=$1
`,[userId]) as Array<Record<string,unknown>>;
return {
mode:row?.mode??'SYSTEM',email:user.email,generalConfigured:Boolean(await this.resolveSystemSettings()),
custom:row?{
host:row.host??'',port:row.port??587,securityMode:row.securityMode??'STARTTLS',
username:row.username??'',hasPassword:Boolean(row.hasPassword),
fromName:row.fromName??`${user.firstName} ${user.lastName}`,fromEmail:row.fromEmail??user.email,
enabled:row.enabled!==false,updatedAt:row.updatedAt??null,
}:null,
};
}
async saveUserSettings(userId:string,input:{
mode:'SYSTEM'|'CUSTOM';host?:string;port?:number;securityMode?:SmtpSecurityMode;
username?:string|null;password?:string|null;fromName?:string|null;enabled?:boolean;
}){
const [user]=await this.dataSource.query(`SELECT email,first_name AS "firstName",last_name AS "lastName" FROM users WHERE id=$1`,[userId]) as Array<{email:string;firstName:string;lastName:string}>;
if(!user?.email)throw new Error('El usuario debe tener un email configurado');
const [existing]=await this.dataSource.query(`SELECT password_enc AS "passwordEnc" FROM user_smtp_settings WHERE user_id=$1`,[userId]) as Array<{passwordEnc:string|null}>;
if(input.mode==='SYSTEM'){
await this.dataSource.query(`
INSERT INTO user_smtp_settings(user_id,mode,updated_by) VALUES($1,'SYSTEM',$1)
ON CONFLICT(user_id) DO UPDATE SET mode='SYSTEM',updated_by=$1,updated_at=CURRENT_TIMESTAMP
`,[userId]);
return this.publicUserSettings(userId);
}
if(!input.host||!input.port||!input.securityMode)throw new Error('La configuración SMTP propia está incompleta');
const passwordEnc=input.password===undefined?existing?.passwordEnc??null:input.password?this.encryptSecret(input.password):null;
const fromName=input.fromName?.trim()||`${user.firstName} ${user.lastName}`.trim();
await this.dataSource.query(`
INSERT INTO user_smtp_settings(user_id,mode,host,port,security_mode,username,password_enc,from_name,from_email,reply_to,enabled,updated_by)
VALUES($1,'CUSTOM',$2,$3,$4,$5,$6,$7,$8,$8,$9,$1)
ON CONFLICT(user_id) DO UPDATE SET mode='CUSTOM',host=EXCLUDED.host,port=EXCLUDED.port,
security_mode=EXCLUDED.security_mode,username=EXCLUDED.username,password_enc=EXCLUDED.password_enc,
from_name=EXCLUDED.from_name,from_email=EXCLUDED.from_email,reply_to=EXCLUDED.reply_to,
enabled=EXCLUDED.enabled,updated_by=$1,updated_at=CURRENT_TIMESTAMP
`,[userId,input.host,input.port,input.securityMode,input.username?.trim()||null,passwordEnc,fromName,user.email,input.enabled!==false]);
return this.publicUserSettings(userId);
}
async saveSettings(input:{ async saveSettings(input:{
host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null; host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null;
password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean; password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean;
@@ -96,8 +146,8 @@ export class SmtpDeliveryService {
return this.publicSettings(); return this.publicSettings();
} }
async send(input:MailInput):Promise<{messageId:string}>{ async send(input:MailInput,userId?:string):Promise<{messageId:string}>{
const settings=await this.resolveSettings(); const settings=await this.resolveSettings(userId);
if(!settings)throw new Error('SMTP no configurado'); if(!settings)throw new Error('SMTP no configurado');
const {host,port,securityMode,userName,password}= { const {host,port,securityMode,userName,password}= {
host:settings.host,port:settings.port,securityMode:settings.securityMode, host:settings.host,port:settings.port,securityMode:settings.securityMode,
@@ -137,7 +187,33 @@ export class SmtpDeliveryService {
return {messageId:match?.[1]??randomUUID()}; return {messageId:match?.[1]??randomUUID()};
} }
private async resolveSettings():Promise<EffectiveSmtpSettings|null>{ private async resolveSettings(userId?:string):Promise<EffectiveSmtpSettings|null>{
if(userId){
const [row]=await this.dataSource.query(`
SELECT settings.mode,settings.host,settings.port,settings.security_mode AS "securityMode",
settings.username,settings.password_enc AS "passwordEnc",settings.from_name AS "fromName",
settings.enabled,user_account.email AS "userEmail"
FROM users user_account
LEFT JOIN user_smtp_settings settings ON settings.user_id=user_account.id
WHERE user_account.id=$1
`,[userId]) as Array<{
mode:'SYSTEM'|'CUSTOM'|null;host:string|null;port:number|null;securityMode:SmtpSecurityMode|null;
username:string|null;passwordEnc:string|null;fromName:string|null;enabled:boolean|null;userEmail:string;
}>;
if(row?.mode==='CUSTOM'&&row.enabled!==false&&row.host&&row.port&&row.securityMode&&row.userEmail){
return {
source:'USER',host:row.host,port:Number(row.port),securityMode:row.securityMode,
username:row.username,password:row.passwordEnc?this.decryptSecret(row.passwordEnc):'',
fromName:row.fromName,fromEmail:row.userEmail,replyTo:row.userEmail,
};
}
const general=await this.resolveSystemSettings();
return general&&row?.userEmail?{...general,replyTo:row.userEmail}:general;
}
return this.resolveSystemSettings();
}
private async resolveSystemSettings():Promise<EffectiveSmtpSettings|null>{
const [row]=await this.dataSource.query(` const [row]=await this.dataSource.query(`
SELECT host,port,security_mode AS "securityMode",username,password_enc AS "passwordEnc", SELECT host,port,security_mode AS "securityMode",username,password_enc AS "passwordEnc",
from_name AS "fromName",from_email AS "fromEmail",reply_to AS "replyTo",enabled from_name AS "fromName",from_email AS "fromEmail",reply_to AS "replyTo",enabled
@@ -295,6 +295,7 @@ export class FieldInventoryService {
operationalAreaId: context.areaId, operationalAreaId: context.areaId,
// Compatibility-only creation snapshot. Membership never depends on it. // Compatibility-only creation snapshot. Membership never depends on it.
operatorCompanyId: context.companyId, operatorCompanyId: context.companyId,
inventoryFamilyId: dto.familyId ?? null,
description: dto.description ?? null, description: dto.description ?? null,
discoveryNotes: dto.discoveryNotes ?? null, discoveryNotes: dto.discoveryNotes ?? null,
attributes: dto.attributes, attributes: dto.attributes,
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-1'; export const API_VERSION = '0.29.0-4';
export const API_PHASE = 'F6.1'; export const API_PHASE = 'F6.2';
@@ -25,11 +25,11 @@ test('F3.1 amplía el perfil personal y protege email del Inspector también en
assert.match(userMigration, /trg_users_inspector_email/); assert.match(userMigration, /trg_users_inspector_email/);
}); });
test('F3.1 exige email al crear o asignar el rol Inspector', () => { test('F3.1 email del Inspector sigue protegido y F6.2 endurece email para todo usuario', () => {
assert.match(usersService, /assertInspectorHasEmail\(roles, dto\.email/); assert.match(usersService, /USER_EMAIL_REQUIRED/);
assert.match(usersService, /assertInspectorHasEmail\(roles, before\.email\)/); assert.match(usersService, /Cada usuario de Hidrocarburos debe tener un email válido/);
assert.match(usersService, /Los usuarios con rol Inspector deben tener un email válido/); assert.match(usersService, /if \(!dto\.email\?\.trim\(\)\) throw userEmailRequired\(\)/);
assert.match(usersService, /dto\.email !== undefined && !dto\.email && before\.roles\.some/); assert.match(usersService, /dto\.email !== undefined && !dto\.email/);
}); });
test('F3.1 dossier canónico agrega alias sin borrar identidad histórica', () => { test('F3.1 dossier canónico agrega alias sin borrar identidad histórica', () => {
+6 -2
View File
@@ -1,8 +1,12 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { API_PHASE, API_VERSION } from '../../src/version'; import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6.1 release', () => { test('health metadata reports the current F6.1 release', () => {
assert.equal(API_PHASE, 'F6.1'); assert.equal(API_PHASE, 'F6.2');
assert.equal(API_VERSION, '0.29.0-1'); 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');
}); });
+17 -2
View File
@@ -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', () => { 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'); const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 30/); assert.match(gradle, /versionCode = 34/);
assert.match(gradle, /versionName = "0\.19\.2"/); assert.match(gradle, /versionName = "0\.19\.6"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/); 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, /AS "isOther"/);
assert.match(service, /isOtherFamily: family\.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/);
});
@@ -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]; const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version); assert.equal(visibleVersion, pkg.version);
assert.match(version, /APP_PHASE\s*=\s*'F6\.1 · Contexto operativo ÁreaOperadora consolidado'/); assert.match(version, /APP_PHASE\s*=\s*'F6\.2 · Firma por Acta y correo de usuario'/);
}); });
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => { test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
@@ -33,7 +33,7 @@ test('F6.1 presentation keeps the complete Inspector profile and documentary-cop
for (const field of ['dni', 'phone', 'jobTitle', 'employeeNumber']) { for (const field of ['dni', 'phone', 'jobTitle', 'employeeNumber']) {
assert.match(user, new RegExp(`name="${field}"`)); assert.match(user, new RegExp(`name="${field}"`));
} }
assert.match(user, /email es obligatorio para un Inspector/i); assert.match(user, /email es obligatorio para todos los usuarios de Hidrocarburos/i);
assert.match(user, /la documentación se enviará también/i); assert.match(user, /la documentación se enviará también/i);
assert.match(delivery, /recipientKind:'INSPECTOR'/); assert.match(delivery, /recipientKind:'INSPECTOR'/);
assert.match(delivery, /documentKind:'ACT_PDF'/); assert.match(delivery, /documentKind:'ACT_PDF'/);
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const migration = read('src/database/migrations/1790117400000-f6-2-user-smtp-and-act-representative.ts');
const closing = read('src/inspection-closing/inspection-closing.service.ts');
const responsibleDto = read('src/inspection-closing/dto/upsert-inspection-responsible.dto.ts');
const prepareDto = read('src/inspection-closing/dto/prepare-inspection-act.dto.ts');
const findingEntity = read('src/database/entities/inspection-finding.entity.ts');
const smtp = read('src/inspection-reports/smtp-delivery.service.ts');
const users = read('src/administration/users/users.controller.ts');
const delivery = read('src/inspection-reports/inspection-document-delivery.service.ts');
const android = read('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt');
const androidModel = read('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
const profilePage = read('../web-v2/src/pages/MyProfilePage.tsx');
test('F6.2 keeps urgency exclusively at Act close, never on individual findings', () => {
assert.match(prepareDto, /InspectionActUrgency/);
assert.match(prepareDto, /urgency!/);
assert.doesNotMatch(findingEntity, /\burgency\b/i);
assert.match(android, /Urgencia del Acta/);
assert.match(android, /urgencia no se asigna a cada Hallazgo/i);
});
test('F6.2 requires the company representative identity and email on every Act', () => {
assert.match(responsibleDto, /attendanceStatus === InspectionResponsibleAttendanceStatus\.PRESENT/);
assert.match(responsibleDto, /@IsEmail\(\)/);
assert.match(migration, /LENGTH\(TRIM\(COALESCE\(email, ''\)\)\) > 0/);
assert.match(closing, /representativeSuggestion/);
assert.match(closing, /previous_act\.visit_id=current_act\.visit_id/);
assert.match(androidModel, /representativeSuggestion/);
assert.match(android, /Nombres y apellidos \*/);
assert.match(android, /DNI \*/);
assert.match(android, /Cargo \/ función \*/);
assert.match(android, /Email \*/);
assert.match(android, /Firma del representante de la empresa/);
assert.match(android, /En disconformidad/);
assert.match(android, /Motivo de disconformidad \*/);
});
test('F6.2 gives every authenticated user a general SMTP default and encrypted custom override', () => {
assert.match(migration, /CREATE TABLE user_smtp_settings/);
assert.match(migration, /ALTER COLUMN email SET NOT NULL/);
assert.match(migration, /mode IN \('SYSTEM','CUSTOM'\)/);
assert.match(smtp, /publicUserSettings/);
assert.match(smtp, /saveUserSettings/);
assert.match(smtp, /source:\s*["']USER["']/);
assert.match(smtp, /encryptSecret/);
assert.match(smtp, /password_enc/);
});
test('F6.2 exposes personal mail settings in Mi perfil without granting administration permissions', () => {
assert.match(users, /@Get\('self\/profile'\)/);
assert.match(users, /@Patch\('self\/profile'\)/);
assert.match(users, /@Get\('self\/smtp'\)/);
assert.match(users, /@Put\('self\/smtp'\)/);
assert.match(users, /@Post\('self\/smtp\/test'\)/);
assert.match(profilePage, /MI PERFIL/);
assert.match(profilePage, /Usar SMTP general/);
assert.match(profilePage, /Usar SMTP propio/);
assert.match(profilePage, /Enviar correo de prueba/);
assert.doesNotMatch(profilePage, /document_delivery\.manage/);
});
test('F6.2 sends Act-related mail through the lead inspector transport selection', () => {
assert.match(delivery, /lead_inspector_user_id AS "userId"/);
assert.match(delivery, /configured\(senderUserId\)/);
assert.match(delivery, /}, senderUserId\);/);
});
@@ -63,3 +63,12 @@ test('F6.1 Acta urgency is null while drafting and is persisted atomically at lo
assert.match(migration, /ALTER COLUMN urgency DROP NOT NULL/); assert.match(migration, /ALTER COLUMN urgency DROP NOT NULL/);
assert.match(migration, /SET urgency=NULL WHERE status='DRAFT'/); assert.match(migration, /SET urgency=NULL WHERE status='DRAFT'/);
}); });
test('F6.3 Acta version capture uses a SELECT-shaped CTE so TypeORM never turns the version into NaN', () => {
const service = source('src/inspection-acts/inspection-acts.service.ts');
assert.match(service, /WITH updated AS \(/);
assert.match(service, /RETURNING current_version/);
assert.match(service, /SELECT current_version AS "versionNumber" FROM updated/);
assert.match(service, /const versionNumber = Number\(row\.versionNumber\)/);
});
+1
View File
@@ -3,6 +3,7 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY tsconfig*.json vite.config.ts index.html ./ COPY tsconfig*.json vite.config.ts index.html ./
COPY public ./public
COPY src ./src COPY src ./src
RUN npm run build RUN npm run build
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="strict-origin-when-cross-origin" /> <meta name="referrer" content="strict-origin-when-cross-origin" />
<link rel="icon" type="image/webp" href="/favicon.webp" />
<title>DH Inspección V2</title> <title>DH Inspección V2</title>
</head> </head>
<body> <body>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-2", "version": "0.23.0-3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-2", "version": "0.23.0-3",
"dependencies": { "dependencies": {
"maplibre-gl": "6.4.1", "maplibre-gl": "6.4.1",
"react": "^19.0.0", "react": "^19.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-2", "version": "0.23.0-3",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

+2
View File
@@ -9,6 +9,7 @@ import { CompanySignaturePage } from '../pages/CompanySignaturePage';
import { DashboardPage } from '../pages/DashboardPage'; import { DashboardPage } from '../pages/DashboardPage';
import { LoginPage } from '../pages/LoginPage'; import { LoginPage } from '../pages/LoginPage';
import { NewUserPage } from '../pages/NewUserPage'; import { NewUserPage } from '../pages/NewUserPage';
import { MyProfilePage } from '../pages/MyProfilePage';
import { RolesPage } from '../pages/RolesPage'; import { RolesPage } from '../pages/RolesPage';
import { AccessDeniedPage, NotFoundPage } from '../pages/SystemPages'; import { AccessDeniedPage, NotFoundPage } from '../pages/SystemPages';
import { UserDetailPage } from '../pages/UserDetailPage'; import { UserDetailPage } from '../pages/UserDetailPage';
@@ -45,6 +46,7 @@ export function App() {
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route path="/change-password" element={<ChangePasswordPage />} /> <Route path="/change-password" element={<ChangePasswordPage />} />
<Route element={<OperationalContextProvider><AppLayout /></OperationalContextProvider>}> <Route element={<OperationalContextProvider><AppLayout /></OperationalContextProvider>}>
<Route path="/mi-perfil" element={<MyProfilePage />} />
<Route element={<PermissionRoute permission="dashboard.read" />}><Route index element={<DashboardPage />} /></Route> <Route element={<PermissionRoute permission="dashboard.read" />}><Route index element={<DashboardPage />} /></Route>
<Route element={<PermissionRoute permission="assets.read" />}><Route path="/mapa" element={<MapPage />} /></Route> <Route element={<PermissionRoute permission="assets.read" />}><Route path="/mapa" element={<MapPage />} /></Route>
<Route element={<PermissionRoute permission="assets.read" />}> <Route element={<PermissionRoute permission="assets.read" />}>
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.23.0-1'; export const APP_VERSION = '0.23.0-3';
export const APP_PHASE = 'F6.1 · Contexto operativo ÁreaOperadora consolidado'; export const APP_PHASE = 'F6.2 · Firma por Acta y correo de usuario';
+7 -5
View File
@@ -100,11 +100,13 @@ export function AppLayout() {
</nav> </nav>
<div className="sidebar-user"> <div className="sidebar-user">
<div className="user-avatar">{user?.firstName?.[0]}{user?.lastName?.[0]}</div> <NavLink to="/mi-perfil" onClick={close} className="sidebar-user-profile" title="Abrir Mi perfil">
<div className="user-copy"> <div className="user-avatar">{user?.firstName?.[0]}{user?.lastName?.[0]}</div>
<strong>{user?.firstName} {user?.lastName}</strong> <div className="user-copy">
<small>{user?.roles.join(' · ') || user?.username}</small> <strong>{user?.firstName} {user?.lastName}</strong>
</div> <small>Mi perfil · {user?.roles.join(' · ') || user?.username}</small>
</div>
</NavLink>
<button className="icon-button dark" onClick={handleLogout} title="Cerrar sesión" aria-label="Cerrar sesión"> <button className="icon-button dark" onClick={handleLogout} title="Cerrar sesión" aria-label="Cerrar sesión">
<Icon name="logout" /> <Icon name="logout" />
</button> </button>
+1
View File
@@ -98,6 +98,7 @@ export interface AdministrativeUser {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
roles: RoleSummary[]; roles: RoleSummary[];
smtpMode: 'SYSTEM' | 'CUSTOM';
} }
export interface Permission { id: string; code: string; description: string } export interface Permission { id: string; code: string; description: string }
+67
View File
@@ -0,0 +1,67 @@
import { apiRequest } from './api';
import type { AdministrativeUserProfile } from './userProfileApi';
export type UserSmtpMode = 'SYSTEM' | 'CUSTOM';
export type UserSmtpSecurityMode = 'NONE' | 'STARTTLS' | 'TLS';
export interface UserSmtpCustomSettings {
host: string;
port: number;
securityMode: UserSmtpSecurityMode;
username: string;
hasPassword: boolean;
fromName: string;
fromEmail: string;
enabled: boolean;
updatedAt: string | null;
}
export interface UserSmtpSettings {
mode: UserSmtpMode;
email: string;
generalConfigured: boolean;
custom: UserSmtpCustomSettings | null;
}
export interface UserSmtpSettingsInput {
mode: UserSmtpMode;
host?: string;
port?: number;
securityMode?: UserSmtpSecurityMode;
username?: string | null;
password?: string | null;
fromName?: string | null;
enabled?: boolean;
}
export function getSelfProfile() {
return apiRequest<AdministrativeUserProfile>('/users/self/profile');
}
export function updateSelfProfile(input: {
email: string;
phone?: string | null;
jobTitle?: string | null;
}) {
return apiRequest<AdministrativeUserProfile>('/users/self/profile', {
method: 'PATCH',
body: JSON.stringify(input),
});
}
export function getSelfSmtpSettings() {
return apiRequest<UserSmtpSettings>('/users/self/smtp');
}
export function saveSelfSmtpSettings(input: UserSmtpSettingsInput) {
return apiRequest<UserSmtpSettings>('/users/self/smtp', {
method: 'PUT',
body: JSON.stringify(input),
});
}
export function testSelfSmtpSettings() {
return apiRequest<{ ok: boolean; recipient: string; messageId: string }>('/users/self/smtp/test', {
method: 'POST',
});
}
+1
View File
@@ -21,6 +21,7 @@ export interface UserProfileInput {
export interface CreateUserProfileInput extends UserProfileInput { export interface CreateUserProfileInput extends UserProfileInput {
username: string; username: string;
email: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
password: string; password: string;
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { useAuth } from '../auth/AuthContext';
import { initials } from '../lib/format';
import {
getSelfProfile,
getSelfSmtpSettings,
saveSelfSmtpSettings,
testSelfSmtpSettings,
updateSelfProfile,
} from '../lib/myProfileApi';
import type {
UserSmtpMode,
UserSmtpSecurityMode,
UserSmtpSettings,
} from '../lib/myProfileApi';
import type { AdministrativeUserProfile } from '../lib/userProfileApi';
export function MyProfilePage() {
const { user } = useAuth();
const [profile, setProfile] = useState<AdministrativeUserProfile | null>(null);
const [smtp, setSmtp] = useState<UserSmtpSettings | null>(null);
const [mode, setMode] = useState<UserSmtpMode>('SYSTEM');
const [host, setHost] = useState('');
const [port, setPort] = useState(587);
const [securityMode, setSecurityMode] = useState<UserSmtpSecurityMode>('STARTTLS');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [fromName, setFromName] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const applySmtp = (settings: UserSmtpSettings) => {
setSmtp(settings);
setMode(settings.mode);
setHost(settings.custom?.host ?? '');
setPort(settings.custom?.port ?? 587);
setSecurityMode(settings.custom?.securityMode ?? 'STARTTLS');
setUsername(settings.custom?.username ?? '');
setFromName(settings.custom?.fromName ?? '');
setPassword('');
};
const load = async () => {
setLoading(true);
try {
const [loadedProfile, loadedSmtp] = await Promise.all([
getSelfProfile(),
getSelfSmtpSettings(),
]);
setProfile(loadedProfile);
applySmtp(loadedSmtp);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setLoading(false);
}
};
useEffect(() => { void load(); }, []);
const saveProfile = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!profile) return;
setError(''); setSuccess(''); setSaving('profile');
try {
const form = new FormData(event.currentTarget);
const updated = await updateSelfProfile({
email: String(form.get('email') ?? '').trim(),
phone: String(form.get('phone') ?? '').trim() || null,
jobTitle: String(form.get('jobTitle') ?? '').trim() || null,
});
setProfile(updated);
setSuccess('Perfil actualizado correctamente.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving('');
}
};
const saveSmtp = async () => {
setError(''); setSuccess(''); setSaving('smtp');
try {
const updated = await saveSelfSmtpSettings(mode === 'SYSTEM' ? {
mode: 'SYSTEM',
} : {
mode: 'CUSTOM', host: host.trim(), port, securityMode,
username: username.trim() || null,
password: password || undefined,
fromName: fromName.trim() || null,
enabled: true,
});
applySmtp(updated);
setSuccess(mode === 'SYSTEM' ? 'Usarás el SMTP general.' : 'SMTP propio guardado.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving('');
}
};
const testSmtp = async () => {
setError(''); setSuccess(''); setSaving('test');
try {
const result = await testSelfSmtpSettings();
setSuccess(`Correo de prueba enviado a ${result.recipient}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving('');
}
};
if (loading) return <LoadingBlock label="Cargando tu perfil…" />;
if (!profile || !smtp) return <Alert>{error || 'No se pudo cargar el perfil.'}</Alert>;
return <section>
<div className="page-heading user-heading">
<div className="profile-title">
<span className="profile-avatar">{initials(profile.firstName, profile.lastName)}</span>
<div>
<span className="eyebrow">MI PERFIL</span>
<h1>{profile.firstName} {profile.lastName}</h1>
<p>@{profile.username} · {profile.jobTitle || 'Función sin informar'}</p>
</div>
</div>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<div className="detail-grid">
<form className="panel form-panel" onSubmit={saveProfile}>
<div className="panel-heading">
<div><span className="eyebrow">CONTACTO</span><h2>Datos del usuario</h2></div>
</div>
<div className="form-grid">
<label className="field"><span>Nombres y apellidos</span><input value={`${profile.firstName} ${profile.lastName}`} disabled /></label>
<label className="field"><span>Email institucional *</span><input name="email" type="email" defaultValue={profile.email ?? ''} required /></label>
<label className="field"><span>Teléfono</span><input name="phone" type="tel" defaultValue={profile.phone ?? ''} /></label>
<label className="field"><span>Cargo / función</span><input name="jobTitle" defaultValue={profile.jobTitle ?? ''} /></label>
</div>
<div className="form-actions">
<button className="button primary" type="submit" disabled={saving === 'profile'}>
{saving === 'profile' ? 'Guardando…' : 'Guardar perfil'}
</button>
</div>
</form>
<div className="panel form-panel">
<div className="panel-heading">
<div><span className="eyebrow">CORREO SALIENTE</span><h2>SMTP personal</h2></div>
</div>
<p className="muted-copy">Por defecto, tus correos salen mediante el SMTP general. Podés cambiar a una cuenta SMTP propia con un clic.</p>
<div className="choice-grid">
<label className={`choice-card ${mode === 'SYSTEM' ? 'selected' : ''}`}>
<input type="radio" name="smtpMode" checked={mode === 'SYSTEM'} onChange={() => setMode('SYSTEM')} />
<span><strong>Usar SMTP general</strong><small>{smtp.generalConfigured ? 'Configurado y disponible' : 'Todavía no configurado por el administrador'}</small></span>
<span className="icon"></span>
</label>
<label className={`choice-card ${mode === 'CUSTOM' ? 'selected' : ''}`}>
<input type="radio" name="smtpMode" checked={mode === 'CUSTOM'} onChange={() => setMode('CUSTOM')} />
<span><strong>Usar SMTP propio</strong><small>Los envíos saldrán con tu cuenta {profile.email}</small></span>
<span className="icon"></span>
</label>
</div>
{mode === 'CUSTOM' && <>
<div className="form-grid" style={{ marginTop: 16 }}>
<label className="field"><span>Servidor SMTP *</span><input value={host} onChange={(event) => setHost(event.target.value)} placeholder="smtp.ejemplo.com" /></label>
<label className="field"><span>Puerto *</span><input type="number" min={1} max={65535} value={port} onChange={(event) => setPort(Number(event.target.value))} /></label>
<label className="field"><span>Seguridad *</span><select value={securityMode} onChange={(event) => setSecurityMode(event.target.value as UserSmtpSecurityMode)}><option value="STARTTLS">STARTTLS</option><option value="TLS">TLS</option><option value="NONE">Sin cifrado</option></select></label>
<label className="field"><span>Usuario SMTP</span><input value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" /></label>
<label className="field"><span>Contraseña SMTP</span><input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" placeholder={smtp.custom?.hasPassword ? 'Dejar vacío para conservarla' : 'Contraseña SMTP'} /></label>
<label className="field"><span>Nombre del remitente</span><input value={fromName} onChange={(event) => setFromName(event.target.value)} placeholder={`${profile.firstName} ${profile.lastName}`} /></label>
</div>
<p className="muted-copy">La contraseña se cifra en el servidor y nunca vuelve a mostrarse. El remitente será <strong>{profile.email}</strong>.</p>
</>}
<div className="form-actions wrap-actions">
<button className="button primary" type="button" onClick={saveSmtp} disabled={saving === 'smtp' || (mode === 'CUSTOM' && (!host.trim() || !port))}>{saving === 'smtp' ? 'Guardando…' : 'Guardar configuración'}</button>
<button className="button secondary" type="button" onClick={testSmtp} disabled={Boolean(saving)}>{saving === 'test' ? 'Enviando…' : 'Enviar correo de prueba'}</button>
</div>
</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>
<p className="muted-copy">Usuario autenticado: @{user?.username ?? profile.username}</p>
</div>
</section>;
}
+5 -5
View File
@@ -30,15 +30,15 @@ export function NewUserPage() {
setError(''); setError('');
const data = new FormData(event.currentTarget); const data = new FormData(event.currentTarget);
const email = String(data.get('email') ?? '').trim(); const email = String(data.get('email') ?? '').trim();
if (inspectorSelected && !email) { if (!email) {
setError('El email es obligatorio para usuarios con rol Inspector porque allí recibirán la documentación de sus inspecciones.'); setError('El email es obligatorio para todos los usuarios de Hidrocarburos.');
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
const created = await createUserProfile({ const created = await createUserProfile({
username: String(data.get('username')), username: String(data.get('username')),
email: email || null, email,
dni: String(data.get('dni') ?? '').trim() || null, dni: String(data.get('dni') ?? '').trim() || null,
phone: String(data.get('phone') ?? '').trim() || null, phone: String(data.get('phone') ?? '').trim() || null,
jobTitle: String(data.get('jobTitle') ?? '').trim() || null, jobTitle: String(data.get('jobTitle') ?? '').trim() || null,
@@ -75,10 +75,10 @@ export function NewUserPage() {
</div> </div>
<div className="form-section"> <div className="form-section">
<div><h2>Acceso y contacto</h2><p className="section-copy">El email del Inspector se utiliza también como destinatario de la documentación al cerrar la inspección.</p></div> <div><h2>Acceso y contacto</h2><p className="section-copy">Cada usuario debe tener un email institucional. Los Inspectores también lo utilizan en la entrega documental de sus Actas.</p></div>
<div className="form-grid"> <div className="form-grid">
<label className="field"><span>Usuario <em>obligatorio</em></span><input name="username" required minLength={3} maxLength={80} pattern="[a-zA-Z][a-zA-Z0-9._-]+" /></label> <label className="field"><span>Usuario <em>obligatorio</em></span><input name="username" required minLength={3} maxLength={80} pattern="[a-zA-Z][a-zA-Z0-9._-]+" /></label>
<label className="field"><span>Email {inspectorSelected ? <em>obligatorio para Inspector</em> : <em>recomendado</em>}</span><input name="email" type="email" maxLength={320} required={inspectorSelected} /></label> <label className="field"><span>Email <em>obligatorio</em></span><input name="email" type="email" maxLength={320} required /></label>
</div> </div>
{inspectorSelected && <div className="temporal-notice"><Icon name="mail" /><p><strong>Inspector:</strong> este email recibirá copia de las Actas/Informe correspondientes al cierre de la inspección.</p></div>} {inspectorSelected && <div className="temporal-notice"><Icon name="mail" /><p><strong>Inspector:</strong> este email recibirá copia de las Actas/Informe correspondientes al cierre de la inspección.</p></div>}
</div> </div>
+4 -3
View File
@@ -57,8 +57,8 @@ export function UserDetailPage() {
setError(''); setSuccess(''); setError(''); setSuccess('');
const data = new FormData(event.currentTarget); const data = new FormData(event.currentTarget);
const email = String(data.get('email') ?? '').trim(); const email = String(data.get('email') ?? '').trim();
if (inspectorSelected && !email) { if (!email) {
setError('El email es obligatorio para un Inspector porque allí recibe la documentación de sus inspecciones.'); setError('El email es obligatorio para todos los usuarios de Hidrocarburos.');
return; return;
} }
setSaving('profile'); setSaving('profile');
@@ -139,6 +139,7 @@ export function UserDetailPage() {
<div className="detail-grid"> <div className="detail-grid">
<form className="panel form-panel" onSubmit={saveProfile}> <form className="panel form-panel" onSubmit={saveProfile}>
<div className="panel-heading"><div><span className="eyebrow">PERFIL</span><h2>Datos personales y contacto</h2></div><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span></div> <div className="panel-heading"><div><span className="eyebrow">PERFIL</span><h2>Datos personales y contacto</h2></div><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span></div>
<p className="muted-copy">Correo saliente: <strong>{user.smtpMode === 'CUSTOM' ? 'SMTP propio' : 'SMTP general'}</strong>. Cada usuario administra su modalidad desde Mi perfil.</p>
<div className="form-grid"> <div className="form-grid">
<label className="field"><span>Nombre</span><input name="firstName" defaultValue={user.firstName} required disabled={!canUpdate} /></label> <label className="field"><span>Nombre</span><input name="firstName" defaultValue={user.firstName} required disabled={!canUpdate} /></label>
<label className="field"><span>Apellido</span><input name="lastName" defaultValue={user.lastName} required disabled={!canUpdate} /></label> <label className="field"><span>Apellido</span><input name="lastName" defaultValue={user.lastName} required disabled={!canUpdate} /></label>
@@ -147,7 +148,7 @@ export function UserDetailPage() {
<label className="field"><span>Cargo / función</span><input name="jobTitle" defaultValue={user.jobTitle ?? ''} maxLength={160} disabled={!canUpdate} /></label> <label className="field"><span>Cargo / función</span><input name="jobTitle" defaultValue={user.jobTitle ?? ''} maxLength={160} disabled={!canUpdate} /></label>
<label className="field"><span>Legajo / matrícula</span><input name="employeeNumber" defaultValue={user.employeeNumber ?? ''} maxLength={80} disabled={!canUpdate} /></label> <label className="field"><span>Legajo / matrícula</span><input name="employeeNumber" defaultValue={user.employeeNumber ?? ''} maxLength={80} disabled={!canUpdate} /></label>
<label className="field"><span>Usuario</span><input name="username" defaultValue={user.username} required disabled={!canUpdate} /></label> <label className="field"><span>Usuario</span><input name="username" defaultValue={user.username} required disabled={!canUpdate} /></label>
<label className="field"><span>Email {inspectorSelected && <em>obligatorio para Inspector</em>}</span><input name="email" type="email" defaultValue={user.email ?? ''} required={inspectorSelected} disabled={!canUpdate} /></label> <label className="field"><span>Email <em>obligatorio</em></span><input name="email" type="email" defaultValue={user.email ?? ''} required disabled={!canUpdate} /></label>
</div> </div>
{inspectorSelected && <div className="temporal-notice"><Icon name="mail" /><p><strong>Destinatario del Inspector:</strong> al finalizar una inspección, la documentación se enviará también a <strong>{user.email || 'este email cuando lo completes'}</strong>.</p></div>} {inspectorSelected && <div className="temporal-notice"><Icon name="mail" /><p><strong>Destinatario del Inspector:</strong> al finalizar una inspección, la documentación se enviará también a <strong>{user.email || 'este email cuando lo completes'}</strong>.</p></div>}
<div className="metadata-grid"><div><small>Último acceso</small><strong>{formatDate(user.lastLoginAt)}</strong></div><div><small>Último cambio de clave</small><strong>{formatDate(user.passwordChangedAt)}</strong></div><div><small>Intentos fallidos</small><strong>{user.failedLoginAttempts}</strong></div><div><small>Bloqueado hasta</small><strong>{formatDate(user.lockedUntil)}</strong></div></div> <div className="metadata-grid"><div><small>Último acceso</small><strong>{formatDate(user.lastLoginAt)}</strong></div><div><small>Último cambio de clave</small><strong>{formatDate(user.passwordChangedAt)}</strong></div><div><small>Intentos fallidos</small><strong>{user.failedLoginAttempts}</strong></div><div><small>Bloqueado hasta</small><strong>{formatDate(user.lockedUntil)}</strong></div></div>
+2
View File
@@ -46,6 +46,8 @@ a { color: inherit; }
.nav-link.active .icon { color: #73a0ff; } .nav-link.active .icon { color: #73a0ff; }
.sidebar-user { display: grid; grid-template-columns: auto minmax(0,1fr) auto; gap: 10px; align-items: center; margin-top: auto; padding: 16px 6px 0; border-top: 1px solid rgba(255,255,255,.09); } .sidebar-user { display: grid; grid-template-columns: auto minmax(0,1fr) auto; gap: 10px; align-items: center; margin-top: auto; padding: 16px 6px 0; border-top: 1px solid rgba(255,255,255,.09); }
.sidebar-user-profile { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-width:0; color:inherit; text-decoration:none; border-radius:10px; padding:4px; }
.sidebar-user-profile:hover { background:rgba(255,255,255,.06); }
.user-avatar, .mini-avatar, .profile-avatar { display: grid; place-items: center; color: #2759c3; background: #dce8ff; font-weight: 800; } .user-avatar, .mini-avatar, .profile-avatar { display: grid; place-items: center; color: #2759c3; background: #dce8ff; font-weight: 800; }
.user-avatar { width: 34px; height: 34px; border-radius: 50%; font-size: 11px; } .user-avatar { width: 34px; height: 34px; border-radius: 50%; font-size: 11px; }
.user-copy { min-width: 0; } .user-copy { min-width: 0; }