Compare commits

..
Author SHA1 Message Date
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
admin 3ab10b37ea chore(web): bump version for deferred Acta urgency
DH V2 CI / Docker / scripts contract (pull_request) Skipped
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / WEB · typecheck, 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 / API · typecheck, tests, build (pull_request) Failing after 26s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 20s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
2026-09-14 10:30:15 -03:00
admin c6475c851e fix(api): clear legacy draft urgency during migration
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m17s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 32s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 19s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Successful in 28s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 50s
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / WEB · typecheck, 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
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Canceled after 3m58s
2026-09-14 10:13:02 -03:00
admin b757bf7729 ci(gitea): run planning smoke API requests inside container
DH V2 CI / API · typecheck, tests, build (pull_request) Canceled after 0s
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
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Canceled after 4m11s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Successful in 27s
2026-09-14 10:09:29 -03:00
admin e30e5d90ea fix(android): align Acta flow and defer urgency to close
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m31s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m30s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 33s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Failing after 1m33s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 1m5s
2026-09-14 09:27:30 -03:00
64 changed files with 1164 additions and 343 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
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:
push:
@@ -109,7 +109,7 @@ jobs:
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256"
{
echo "phase=F6.1"
echo "phase=F6.2"
echo "version=$version"
echo "versionCode=$code"
echo "commit=$GITHUB_SHA"
+9 -4
View File
@@ -1,15 +1,20 @@
# Checklist final Android 0.19.1
# Checklist final Android 0.19.3
La candidata sólo se promueve si el mismo SHA cumple:
- iniciar Inspección;
- crear/abrir Acta;
- seleccionar o crear Instalación/Subinstalación;
- crear/abrir Acta sin pedir urgencia ni Inventario;
- entrar a **Agregar Hallazgo** y recién allí seleccionar o crear Instalación/Subinstalación;
- exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos;
- registrar Hallazgos sobre el elemento seleccionado;
- cerrar contenido del Acta 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;
- mostrar el Acta como **Firmada y cerrada**;
- permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas;
- no mostrar **Inventario de campo** como acción paralela de la Inspección;
- no exponer estados internos en inglés en la interfaz de campo;
- Android CI / RC, DH V2 CI y auditoría de dependencias en verde.
+22 -11
View File
@@ -1,29 +1,31 @@
# DH Inspección Android · release final de campo 0.19.1
# DH Inspección Android · release final de campo 0.19.3
## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.1**.
- `versionCode`: **29**.
- `versionName`: **0.19.3**.
- `versionCode`: **31**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
- Servidor compatible de esta candidata: **API 0.29.0-4 / WEB 0.23.0-3**.
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
La APK 0.19.1 fija como recorrido principal de campo:
La APK 0.19.3 fija como recorrido principal de campo:
1. **Iniciar Inspección**. Al iniciarla se habilitan Actas, Hallazgos e Inventario de campo.
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.
3. **Agregar Hallazgos** sobre una Instalación o Subinstalación seleccionada.
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.
3. **Agregar Hallazgos**. Recién desde esta acción se selecciona la Instalación/Subinstalación afectada.
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.
6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
7. Al terminar el contenido del Acta se usa **Cerrar Acta y dejar pendiente de firma**. Desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
8. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**.
9. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
7. Antes del cierre se identifican, para esa Acta, los datos del **Representante de la empresa** que acompañó el recorrido: nombres y apellidos, DNI, cargo/función y email. Si coincide con el Acta anterior, la APK puede precargarlos, pero deben confirmarse nuevamente.
8. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar Acta y dejar pendiente de firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
9. La firma del Inspector y la manifestación del representante completan el Acta: **conformidad**, **disconformidad con motivo obligatorio** o **negativa a firmar con motivo obligatorio**. La firma se registra Acta por Acta aunque sea la misma persona.
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.
@@ -57,7 +59,7 @@ La terminología visible se simplifica:
- `LOCKED`**Pendiente de firma**;
- `SEALED`**Firmada y cerrada**.
Cerrar el contenido no cierra automáticamente la Inspección y tampoco obliga a terminar la firma en ese instante: puede continuarse con otra Acta. Una vez cerrado el contenido, el Acta es inmutable.
Cerrar el contenido no cierra automáticamente la Inspección y tampoco obliga a terminar la firma en ese instante: puede continuarse con otra Acta. La urgencia se decide en ese cierre, nunca al crear el borrador. Una vez cerrado el contenido, el Acta es inmutable.
Después de la firma/cierre definitivo se mantiene el circuito documental existente: PDF del Acta e informe INF editable (DOCX) para oficina.
@@ -115,3 +117,12 @@ Esta candidata requiere conexión. No implementa trabajo offline ni cola persist
- Yacimiento seleccionable para Hallazgos y como padre explícito de nuevas Instalaciones. Se elimina el fallback que podía presentar un tipo Yacimiento como alta de Instalación.
- Formulario Datos técnicos sobre el elemento seleccionado: carga y guarda las definiciones/valores por familia mediante los endpoints existentes del Dashboard; valida obligatorios, números, Sí/No, fechas y opciones. Es un paso separado del alta estructural y GPS/foto.
- Se agregan siete pruebas para coordenadas y valores técnicos.
## Revisión 0.19.3 · flujo conceptual corregido
- 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 borrador de Acta nace vacío, sin Inventario y sin urgencia predeterminada.
- La urgencia se exige al cerrar el Acta y se persiste en la misma operación que la vuelve inmutable.
- `inspection_acts.urgency` puede quedar pendiente durante el borrador; el bloqueo exige la decisión y calcula el vencimiento desde allí.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 29
versionName = "0.19.1"
versionCode = 31
versionName = "0.19.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -165,41 +165,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun createActForSelectedInventory(urgency: String = "NON_URGENT") {
fun createAct() {
val currentVisit = visit ?: return
val asset = selectedFieldAsset?.asset
if (currentVisit.status != "IN_PROGRESS") {
error = "La Inspección debe estar en curso para crear un Acta."
return
}
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Elegí si el Acta es urgente o no urgente."
return
}
if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
return
}
launchBusy(mutation = true) {
val created = actsRepository.create(
currentVisit.id,
asset?.id,
currentVisit.code,
urgency,
)
val created = actsRepository.create(currentVisit.id, currentVisit.code)
selectedAct = created
actClosure = actsRepository.closure(created.id)
loadActsInternal(currentVisit.id, selectDraft = false)
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
notice = "${created.code} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
}
}
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return
val generation = ++inventorySearchGeneration
@@ -449,8 +433,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
phone: String?,
) {
val actId = selectedAct?.id ?: return
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank()) {
error = "Completá nombre, documento y cargo del responsable de la empresa."
val normalizedEmail = email?.trim().orEmpty()
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
}
launchBusy(mutation = true) {
@@ -462,11 +448,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
documentType = documentType,
documentNumber = documentNumber.trim(),
position = position.trim(),
email = email?.trim()?.takeIf { it.isNotBlank() },
email = normalizedEmail.lowercase(),
phone = phone?.trim()?.takeIf { it.isNotBlank() },
),
)
notice = "Responsable de empresa registrado para el Acta."
notice = "Representante de la empresa registrado para esta Acta."
}
}
@@ -484,16 +470,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
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."
}
}
fun prepareSelectedAct() {
fun prepareSelectedAct(urgency: String) {
val actId = selectedAct?.id ?: return
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Definí la urgencia antes de cerrar el Acta."
return
}
launchBusy(mutation = true) {
actClosure = actsRepository.lock(actId)
actClosure = actsRepository.lock(actId, urgency)
refreshSelectedActInternal(actId)
notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación."
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas."
}
}
@@ -528,9 +519,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
actId, png, latitude, longitude, accuracyM, manifestation, statement,
)
notice = if (manifestation == "DISSENT") {
"Firma de empresa registrada con disidencia."
"Firma del representante registrada en disconformidad."
} else {
"Firma de empresa registrada."
"Firma del representante de la empresa registrada."
}
}
}
@@ -35,7 +35,7 @@ data class MobileActSummary(
val title: String,
val summary: String,
val observations: String? = null,
val urgency: String = "NON_URGENT",
val urgency: String? = null,
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
@@ -60,7 +60,7 @@ data class MobileActDetail(
val title: String,
val summary: String,
val observations: String? = null,
val urgency: String = "NON_URGENT",
val urgency: String? = null,
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
@@ -91,13 +91,16 @@ data class MobileActListResponse(
data class CreateMobileActRequest(
val occurredAt: String,
val urgency: String,
val title: String,
val summary: String,
val observations: String? = null,
val assetIds: List<String>,
)
data class PrepareMobileActRequest(
val urgency: String,
)
data class UpdateMobileActRequest(
val assetIds: List<String>,
)
@@ -130,7 +133,7 @@ data class MobileActClosureHeader(
val code: String,
val status: String,
val visitId: String,
val urgency: String = "NON_URGENT",
val urgency: String? = null,
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
@@ -185,6 +188,7 @@ data class MobileActClosure(
val act: MobileActClosureHeader,
val visit: MobileVisitClosureHeader,
val responsible: MobileResponsible? = null,
val representativeSuggestion: MobileResponsible? = null,
val closure: MobileClosureRecord? = null,
val signatures: List<MobileSignature> = emptyList(),
val consents: MobileClosureConsents = MobileClosureConsents(),
@@ -249,6 +253,7 @@ private interface MobileActsApi {
suspend fun lock(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: PrepareMobileActRequest,
): MobileActClosure
@Multipart
@@ -326,19 +331,16 @@ class MobileActsRepository(context: Context) {
suspend fun create(
visitId: String,
assetId: String? = null,
visitCode: String,
urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session ->
api.createAct(
"Bearer ${session.accessToken}",
visitId,
CreateMobileActRequest(
occurredAt = Instant.now().toString(),
urgency = urgency,
title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = listOfNotNull(assetId),
assetIds = emptyList(),
),
)
}
@@ -360,8 +362,8 @@ class MobileActsRepository(context: Context) {
api.responsible("Bearer ${session.accessToken}", actId, request)
}
suspend fun lock(actId: String): MobileActClosure = authorized { session ->
api.lock("Bearer ${session.accessToken}", actId)
suspend fun lock(actId: String, urgency: String): MobileActClosure = authorized { session ->
api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency))
}
suspend fun signInspector(
@@ -57,30 +57,31 @@ fun MobileActsScreen(
val visit = model.visit ?: return
val selected = model.selectedAct
val closure = model.actClosure
val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion
val context = LocalContext.current
val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var attendance by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT")
}
var fullName by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.fullName.orEmpty())
mutableStateOf(representativeSeed?.fullName.orEmpty())
}
var documentType by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentType ?: "DNI")
mutableStateOf("DNI")
}
var documentNumber by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentNumber.orEmpty())
mutableStateOf(representativeSeed?.documentNumber.orEmpty())
}
var position by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.position.orEmpty())
mutableStateOf(representativeSeed?.position.orEmpty())
}
var email by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.email.orEmpty())
mutableStateOf(representativeSeed?.email.orEmpty())
}
var phone by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.phone.orEmpty())
mutableStateOf(representativeSeed?.phone.orEmpty())
}
var absenceReason by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.absenceReason.orEmpty())
@@ -137,7 +138,11 @@ fun MobileActsScreen(
}
model.acts.forEach { act ->
val active = selected?.id == act.id
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente"
val urgency = when (act.urgency) {
"URGENT" -> "URGENTE"
"NON_URGENT" -> "No urgente"
else -> "Urgencia pendiente"
}
OutlinedButton(
onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(),
@@ -165,23 +170,8 @@ fun MobileActsScreen(
style = MaterialTheme.typography.bodySmall,
)
}
Text("Urgencia", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { newActUrgency = "URGENT" },
label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla."
} else {
"El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla."
},
"La urgencia se define al cerrar el Acta, después de completar los Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
Text(
@@ -189,7 +179,7 @@ fun MobileActsScreen(
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { model.createAct(newActUrgency) },
onClick = { model.createAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
@@ -203,7 +193,13 @@ fun MobileActsScreen(
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status))
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
Text(
when (selected.urgency) {
"URGENT" -> "Urgencia: Urgente"
"NON_URGENT" -> "Urgencia: No urgente"
else -> "Urgencia: se define al cerrar el Acta"
},
)
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
@@ -230,29 +226,26 @@ fun MobileActsScreen(
}
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)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
}
if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "$kind" else kind) })
}
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth())
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(
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(),
) { Text("Guardar responsable") }
) { Text("Guardar representante") }
} else {
OutlinedTextField(
absenceReason,
@@ -270,10 +263,20 @@ fun MobileActsScreen(
HorizontalDivider()
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
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)) {
AssistChip(
onClick = { closingUrgency = "NON_URGENT" },
label = { Text(if (closingUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { closingUrgency = "URGENT" },
label = { Text(if (closingUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Button(
onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null,
onClick = { model.prepareSelectedAct(closingUrgency) },
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Finalizar y BLOQUEAR Acta") }
}
@@ -305,7 +308,7 @@ fun MobileActsScreen(
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar"
else -> companyOutcome.status
}
@@ -314,26 +317,26 @@ fun MobileActsScreen(
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
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,
)
} else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "En disidencia") })
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disconformidad" else "En disconformidad") })
}
if (manifestation == "DISSENT") {
OutlinedTextField(
dissentStatement,
{ dissentStatement = it },
label = { Text("Manifestación de disidencia *") },
label = { Text("Motivo de disconformidad *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
SignaturePad(
label = "Firma del responsable de empresa",
label = "Firma del representante de la empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) },
)
@@ -72,17 +72,18 @@ fun ModernMobileActsScreen(
val visit = model.visit ?: return
val selected = model.selectedAct
val closure = model.actClosure
val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion
val context = LocalContext.current
val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") }
var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) }
var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") }
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) }
var position by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.position.orEmpty()) }
var email by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.email.orEmpty()) }
var phone by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.phone.orEmpty()) }
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var attendance by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT") }
var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) }
var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") }
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) }
var position by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.position.orEmpty()) }
var email by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.email.orEmpty()) }
var phone by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.phone.orEmpty()) }
var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) }
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
@@ -195,7 +196,7 @@ fun ModernMobileActsScreen(
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(act.code, fontWeight = FontWeight.Bold)
Text(
"${modernActStatusLabel(act.status)} · ${if (act.urgency == "URGENT") "Urgente" else "No urgente"}",
"${modernActStatusLabel(act.status)} · ${when (act.urgency) { "URGENT" -> "Urgente"; "NON_URGENT" -> "No urgente"; else -> "Urgencia pendiente" }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -225,36 +226,13 @@ fun ModernMobileActsScreen(
}
}
Text("Urgencia", fontWeight = FontWeight.SemiBold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = newActUrgency == "NON_URGENT",
onClick = { newActUrgency = "NON_URGENT" },
label = { Text("No urgente") },
)
FilterChip(
selected = newActUrgency == "URGENT",
onClick = { newActUrgency = "URGENT" },
label = { Text("Urgente") },
)
}
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido."
} else {
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
},
Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
Text(
"La urgencia se define recién al cerrar el Acta, cuando ya se conoce el resultado de la inspección.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.createAct(newActUrgency) },
onClick = { model.createAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
@@ -278,7 +256,14 @@ fun ModernMobileActsScreen(
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
StatusPill(modernActStatusLabel(selected.status))
}
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
when (selected.urgency) {
"URGENT" -> "Urgencia · Urgente"
"NON_URGENT" -> "Urgencia · No urgente"
else -> "Urgencia · Se define al cerrar el Acta"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
@@ -309,28 +294,25 @@ fun ModernMobileActsScreen(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") })
FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") })
}
if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) })
}
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
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(
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(),
) { Text("Guardar responsable") }
) { Text("Guardar representante") }
} else {
OutlinedTextField(
absenceReason,
@@ -356,12 +338,34 @@ fun ModernMobileActsScreen(
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.",
"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,
)
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = closingUrgency == "NON_URGENT",
onClick = { closingUrgency = "NON_URGENT" },
label = { Text("No urgente") },
)
FilterChip(
selected = closingUrgency == "URGENT",
onClick = { closingUrgency = "URGENT" },
label = { Text("Urgente") },
)
}
Text(
when (closingUrgency) {
"URGENT" -> "El plazo urgente se computará desde el Acta según la política vigente."
"NON_URGENT" -> "El plazo no urgente comenzará con la oficialización GEDO."
else -> "Elegí una opción para habilitar el cierre."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null,
onClick = { model.prepareSelectedAct(closingUrgency) },
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta y dejar pendiente de firma") }
}
@@ -401,7 +405,7 @@ fun ModernMobileActsScreen(
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar"
else -> humanBackendLabelEs(companyOutcome.status)
}
@@ -423,19 +427,19 @@ fun ModernMobileActsScreen(
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") })
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disconformidad") })
}
if (manifestation == "DISSENT") {
OutlinedTextField(
dissentStatement,
{ dissentStatement = it },
label = { Text("Manifestación de disidencia *") },
label = { Text("Motivo de disconformidad *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
SignaturePad(
label = "Firma del responsable de empresa",
label = "Firma del representante de la empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) },
)
@@ -106,7 +106,6 @@ fun ModernVisitRoot(model: MainViewModel) {
ModernVisitScreen.OVERVIEW -> ModernVisitOverview(
model = model,
onActs = { screenName = ModernVisitScreen.ACTS.name },
onInventory = { screenName = ModernVisitScreen.INVENTORY.name },
)
ModernVisitScreen.ACTS -> ModernMobileActsScreen(
model = model,
@@ -124,7 +123,6 @@ fun ModernVisitRoot(model: MainViewModel) {
private fun ModernVisitOverview(
model: MainViewModel,
onActs: () -> Unit,
onInventory: () -> Unit,
) {
val visit = model.visit ?: return
Column(
@@ -183,7 +181,7 @@ private fun ModernVisitOverview(
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Listo para iniciar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Al iniciar se habilitan Actas, Hallazgos y el Inventario de campo.",
"Al iniciar se habilitan Actas y Hallazgos.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
@@ -217,25 +215,6 @@ private fun ModernVisitOverview(
}
}
}
ElevatedCard(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Icon(Icons.Filled.Factory, null, Modifier.padding(11.dp), tint = MaterialTheme.colorScheme.secondary)
}
Column(Modifier.weight(1f)) {
Text("Inventario de campo", fontWeight = FontWeight.Bold)
Text(
"Buscar existente o cargar Instalación / Subinstalación",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") }
}
@@ -533,7 +512,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
ModernInventoryMode.PICK_PARENT -> "Elegir Instalación"
ModernInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación"
ModernInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación"
ModernInventoryMode.BROWSE -> "Inventario de campo"
ModernInventoryMode.BROWSE -> "Ubicación del Hallazgo"
},
subtitle = visit.scopeAsset?.name ?: visit.code,
onBack = { if (mode == ModernInventoryMode.BROWSE) onBack() else backToBrowse() },
@@ -912,7 +891,7 @@ private fun ModernInventoryBrowse(
OutlinedTextField(
value = search,
onValueChange = onSearchChange,
label = { Text("Buscar Inventario") },
label = { Text("Buscar Instalación o Subinstalación") },
placeholder = { Text("Nombre, código o dato técnico") },
leadingIcon = { Icon(Icons.Filled.Search, null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
@@ -21,6 +21,13 @@ class FinalFieldFlowContractTest {
assertTrue(visit.contains("Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto"))
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
assertTrue(acts.contains("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\""))
}
@@ -45,7 +52,9 @@ class FinalFieldFlowContractTest {
fun currentFieldUiDoesNotExposeBackendEnglishStates() {
assertTrue(home.contains("visitStatusLabelEs(visit.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("\"OTHER\" -> \"Otro\""))
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(29, BuildConfig.VERSION_CODE)
assertEquals("0.19.1-debug", BuildConfig.VERSION_NAME)
assertEquals(31, BuildConfig.VERSION_CODE)
assertEquals("0.19.3-debug", BuildConfig.VERSION_NAME)
}
@Test
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-api",
"version": "0.29.0-1",
"version": "0.29.0-4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-api",
"version": "0.29.0-1",
"version": "0.29.0-4",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.29.0-1",
"version": "0.29.0-4",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.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 { RolesService } from './roles/roles.service';
import { UsersController } from './users/users.controller';
import { UsersService } from './users/users.service';
@Module({
imports: [PhaseADataModule, AuditModule, AuthModule],
imports: [PhaseADataModule, AuditModule, AuthModule, InspectionReportsModule],
controllers: [UsersController, RolesController],
providers: [UsersService, RolesService],
})
@@ -28,10 +28,9 @@ export class CreateUserDto {
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
)
@IsOptional()
@IsEmail()
@MaxLength(320)
email?: string | null;
email!: string;
@Transform(({ value }) =>
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,
Req,
} from '@nestjs/common';
import { AuditService } from '../../audit/audit.service';
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
import type {
AuthPrincipal,
RequestWithContext,
} 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 { CreateUserDto } from './dto/create-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ReplaceUserRolesDto } from './dto/replace-user-roles.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 { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {}
constructor(
private readonly users: UsersService,
private readonly smtp: SmtpDeliveryService,
private readonly audit: AuditService,
) {}
@Get()
@RequirePermissions('users.read')
@@ -34,6 +44,73 @@ export class UsersController {
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()
@RequirePermissions('users.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 { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import type { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
import type { UpdateUserDto } from './dto/update-user.dto';
export interface UserRoleView {
@@ -56,6 +57,7 @@ export interface AdministrativeUserView {
createdAt: Date;
updatedAt: Date;
roles: UserRoleView[];
smtpMode: 'SYSTEM' | 'CUSTOM';
}
interface UserViewRow extends AdministrativeUserView {
@@ -76,10 +78,10 @@ function roleSelectionInvalid(): BadRequestException {
});
}
function inspectorEmailRequired(): BadRequestException {
function userEmailRequired(): BadRequestException {
return new BadRequestException({
code: 'INSPECTOR_EMAIL_REQUIRED',
message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones',
code: 'USER_EMAIL_REQUIRED',
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.created_at AS "createdAt",
user_account.updated_at AS "updatedAt",
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
COALESCE(
JSONB_AGG(
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(
dto: CreateUserDto,
principal: AuthPrincipal,
@@ -195,10 +216,10 @@ export class UsersService {
try {
return await this.dataSource.transaction(async (manager) => {
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({
username: dto.username.trim().toLowerCase(),
email: dto.email?.trim().toLowerCase() || null,
email: dto.email.trim().toLowerCase(),
dni: dto.dni ?? null,
phone: dto.phone ?? null,
jobTitle: dto.jobTitle ?? null,
@@ -268,15 +289,13 @@ export class UsersService {
return await this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id);
if (dto.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) {
throw inspectorEmailRequired();
}
if (dto.email !== undefined && !dto.email) throw userEmailRequired();
if (dto.username !== undefined) {
user.username = dto.username.trim().toLowerCase();
}
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.phone !== undefined) user.phone = dto.phone ?? null;
@@ -286,6 +305,13 @@ export class UsersService {
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
user.updatedBy = principal.userId;
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);
await this.audit.record(
@@ -414,7 +440,6 @@ export class UsersService {
await this.lockUser(manager, id);
const roles = await this.resolveRoles(manager, dto.roleIds);
const before = await this.loadUserView(manager, id);
this.assertInspectorHasEmail(roles, before.email);
const beforeIds = before.roles.map((role) => role.id).sort();
const afterIds = roles.map((role) => role.id).sort();
if (beforeIds.join(',') === afterIds.join(',')) return before;
@@ -453,12 +478,6 @@ export class UsersService {
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(
manager: EntityManager,
userId: string,
@@ -511,6 +530,7 @@ export class UsersService {
user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt",
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
+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 (
email &&
(email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
!email || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
) {
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)
LIMIT 1
`,
[username, email || null],
[username, email],
)) as unknown[];
if (duplicate.length > 0) {
throw new Error('Username or email already exists');
@@ -202,7 +201,7 @@ async function main(): Promise<void> {
const user = manager.getRepository(User).create({
username,
email: email || null,
email,
passwordHash,
firstName,
lastName,
@@ -103,6 +103,8 @@ export enum AuditAction {
INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED',
SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED',
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_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
@@ -71,8 +71,8 @@ export class InspectionAct extends TimestampedEntity {
@Column({ type: 'text', nullable: true })
observations!: string | null;
@Column({ name: 'urgency', type: 'varchar', length: 24, default: InspectionActUrgency.NON_URGENT })
urgency!: InspectionActUrgency;
@Column({ name: 'urgency', type: 'varchar', length: 24, nullable: true })
urgency!: InspectionActUrgency | null;
@Column({ name: 'deadline_days', type: 'integer', nullable: true })
deadlineDays!: number | null;
+2 -2
View File
@@ -16,8 +16,8 @@ export class User extends TimestampedEntity {
@Column({ type: 'varchar', length: 80 })
username!: string;
@Column({ type: 'varchar', length: 320, nullable: true })
email!: string | null;
@Column({ type: 'varchar', length: 320 })
email!: string;
@Column({ type: 'varchar', length: 32, nullable: true })
dni!: string | null;
@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F61ActUrgencyAtLock1790113800000 implements MigrationInterface {
name = 'F61ActUrgencyAtLock1790113800000';
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency DROP DEFAULT`);
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency DROP NOT NULL`);
await q.query(`UPDATE inspection_acts SET urgency=NULL WHERE status='DRAFT'`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`UPDATE inspection_acts SET urgency='NON_URGENT' WHERE urgency IS NULL`);
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency SET NOT NULL`);
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency SET DEFAULT 'NON_URGENT'`);
}
}
@@ -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`);
}
}
@@ -3,7 +3,6 @@ import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsEnum,
IsISO8601,
IsOptional,
IsString,
@@ -11,15 +10,11 @@ import {
MaxLength,
MinLength,
} from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class CreateInspectionActDto {
@IsISO8601({ strict: true })
occurredAt!: string;
@IsEnum(InspectionActUrgency)
urgency!: InspectionActUrgency;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@@ -4,7 +4,6 @@ import {
ArrayMinSize,
ArrayUnique,
IsArray,
IsEnum,
IsISO8601,
IsOptional,
IsString,
@@ -12,17 +11,12 @@ import {
MaxLength,
MinLength,
} from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class UpdateInspectionActDto {
@IsOptional()
@IsISO8601({ strict: true })
occurredAt?: string;
@IsOptional()
@IsEnum(InspectionActUrgency)
urgency?: InspectionActUrgency;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@@ -74,7 +74,7 @@ export interface InspectionActListItem {
title: string;
summary: string;
observations: string | null;
urgency: InspectionActUrgency;
urgency: InspectionActUrgency | null;
deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null;
@@ -314,7 +314,7 @@ export class InspectionActsService {
title: dto.title,
summary: dto.summary,
observations: dto.observations ?? null,
urgency: dto.urgency,
urgency: null,
deadlineDays: null,
deadlineDayType: null,
deadlineBasis: null,
@@ -379,7 +379,6 @@ export class InspectionActsService {
await this.assertVisitAssets(manager, visit.id, nextAssetIds);
const before = await this.loadView(manager, id);
if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt;
if (dto.urgency !== undefined) act.urgency = dto.urgency;
if (dto.title !== undefined) act.title = dto.title;
if (dto.summary !== undefined) act.summary = dto.summary;
if (dto.observations !== undefined) act.observations = dto.observations;
@@ -729,10 +728,13 @@ export class InspectionActsService {
principal: AuthPrincipal,
): Promise<number> {
const [row] = (await manager.query(`
UPDATE inspection_acts
SET current_version = current_version + 1
WHERE id = $1
RETURNING current_version AS "versionNumber"
WITH updated AS (
UPDATE inspection_acts
SET current_version = current_version + 1
WHERE id = $1
RETURNING current_version
)
SELECT current_version AS "versionNumber" FROM updated
`, [act.id])) as Array<{ versionNumber: number }>;
const versionNumber = Number(row.versionNumber);
act.currentVersion = versionNumber;
@@ -213,14 +213,14 @@ export class CompanySignatureInviteService {
if (!publicUrl) {
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';
} else {
const body = [
`Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`,
`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.',
'',
`Enlace seguro: ${publicUrl}`,
@@ -236,7 +236,7 @@ export class CompanySignatureInviteService {
mimeType: 'text/plain',
content: Buffer.from(body, 'utf8'),
},
});
}, principal.userId);
emailSent = true;
await this.dataSource.query(`
UPDATE inspection_act_company_signature_invites
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class PrepareInspectionActDto {
@IsEnum(InspectionActUrgency)
urgency!: InspectionActUrgency;
}
@@ -54,7 +54,9 @@ export class UpsertInspectionResponsibleDto {
@MaxLength(200)
position?: string;
@IsOptional()
@ValidateIf((dto: UpsertInspectionResponsibleDto) => (
dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT
))
@Transform(trimOrUndefined)
@IsEmail()
@MaxLength(320)
@@ -17,6 +17,7 @@ import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CloseInspectionActDto } from './dto/close-inspection-act.dto';
import { PrepareInspectionActDto } from './dto/prepare-inspection-act.dto';
import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
import { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
@@ -52,10 +53,11 @@ export class InspectionClosingController {
@RequirePermissions('inspection_closure.prepare')
lock(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: PrepareInspectionActDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.closing.prepare(actId, principal, request);
return this.closing.prepare(actId, dto, principal, request);
}
@Post('signatures/inspector')
@@ -35,6 +35,7 @@ import { assertMobileInspector } from '../inspection-operations/mobile-inspector
import { InspectionReportsService } from '../inspection-reports/inspection-reports.service';
import { sha256CanonicalJson } from './canonical-json';
import type { CloseInspectionActDto } from './dto/close-inspection-act.dto';
import type { PrepareInspectionActDto } from './dto/prepare-inspection-act.dto';
import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
@@ -130,7 +131,7 @@ export interface InspectionClosureView {
code: string;
status: InspectionActStatus;
visitId: string;
urgency: InspectionActUrgency;
urgency: InspectionActUrgency | null;
deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null;
@@ -151,6 +152,7 @@ export interface InspectionClosureView {
actualClosedAt: Date | null;
};
responsible: ResponsibleView | null;
representativeSuggestion: ResponsibleView | null;
closure: null | {
schemaVersion: string;
preparedSha256: string;
@@ -268,6 +270,7 @@ export class InspectionClosingService {
/** Finaliza el contenido: desde este punto el Acta queda inmutable. */
async prepare(
actId: string,
dto: PrepareInspectionActDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionClosureView> {
@@ -285,16 +288,16 @@ export class InspectionClosingService {
}
const policy = await this.deadlinePolicy(manager);
const deadlineDays = act.urgency === InspectionActUrgency.URGENT
const deadlineDays = dto.urgency === InspectionActUrgency.URGENT
? policy.urgentDays
: policy.nonUrgentDays;
const deadlineDayType = act.urgency === InspectionActUrgency.URGENT
const deadlineDayType = dto.urgency === InspectionActUrgency.URGENT
? policy.urgentDayType
: policy.nonUrgentDayType;
const deadlineBasis = act.urgency === InspectionActUrgency.URGENT
const deadlineBasis = dto.urgency === InspectionActUrgency.URGENT
? InspectionDeadlineBasis.ACT_DATE
: InspectionDeadlineBasis.GEDO_DATE;
const deadlineBaseAt = act.urgency === InspectionActUrgency.URGENT
const deadlineBaseAt = dto.urgency === InspectionActUrgency.URGENT
? act.occurredAt
: null;
const deadlineAt = deadlineBaseAt
@@ -305,20 +308,22 @@ export class InspectionClosingService {
const [updated] = (await manager.query(`
UPDATE inspection_acts
SET status='LOCKED',
deadline_days=$2,
deadline_day_type=$3,
deadline_basis=$4,
deadline_base_at=$5,
deadline_at=$6,
locked_at=$7,
locked_by=$8,
urgency=$2,
deadline_days=$3,
deadline_day_type=$4,
deadline_basis=$5,
deadline_base_at=$6,
deadline_at=$7,
locked_at=$8,
locked_by=$9,
current_version=current_version+1,
updated_by=$8,
updated_at=$7
updated_by=$9,
updated_at=$8
WHERE id=$1
RETURNING current_version AS "versionNumber"
`, [
actId,
dto.urgency,
deadlineDays,
deadlineDayType,
deadlineBasis,
@@ -1009,7 +1014,7 @@ export class InspectionClosingService {
WHERE act.id=$1
`, [actId]) as Array<{
id: string; code: string; status: InspectionActStatus; visitId: string;
urgency: InspectionActUrgency; deadlineDays: number | null;
urgency: InspectionActUrgency | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null;
deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null;
lockedSha256: string | null; sealedAt: Date | null; currentVersion: number;
@@ -1018,6 +1023,7 @@ export class InspectionClosingService {
}>;
if (!context) throw actNotFound();
const responsible = await this.loadResponsible(manager, actId);
const representativeSuggestion = responsible ? null : await this.loadResponsibleSuggestion(manager, actId);
const closure = await this.loadClosure(manager, actId);
const signatures = await this.loadSignatures(manager, actId);
return {
@@ -1047,6 +1053,7 @@ export class InspectionClosingService {
actualClosedAt: context.visitActualClosedAt,
},
responsible,
representativeSuggestion,
closure: closure ? {
schemaVersion: closure.schemaVersion,
preparedSha256: closure.preparedSha256,
@@ -1079,12 +1086,30 @@ export class InspectionClosingService {
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> {
const responsible = await this.loadResponsible(manager, actId);
if (!responsible) {
throw new ConflictException({
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;
@@ -99,8 +99,10 @@ function lines(snapshot: Record<string, unknown>): string[] {
`Fecha: ${date(act.occurredAt)}`,
`Urgencia: ${urgencyLabel(act.urgency)}`,
`Plazo: ${deadlineText}`,
`Responsable empresa: ${text(responsible.fullName)}`,
`Cargo: ${text(responsible.position)}`,
`Representante de la empresa: ${text(responsible.fullName)}`,
`DNI: ${text(responsible.documentNumber)}`,
`Cargo / funcion: ${text(responsible.position)}`,
`Email: ${text(responsible.email)}`,
'',
'RESUMEN',
...wrap(text(act.summary)),
@@ -136,7 +138,7 @@ function lines(snapshot: Record<string, unknown>): string[] {
out.push('Manifestacion de empresa: pendiente.');
} else if (text(companySignature.status, '') === 'SIGNED') {
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)));
} else {
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
@@ -281,7 +281,7 @@ export class InspectionDocumentDeliveryService {
email = company?.email ?? null;
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
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 }>;
email = inspector?.email ?? null;
} else {
@@ -305,7 +305,14 @@ export class InspectionDocumentDeliveryService {
await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');
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');
return;
}
@@ -348,7 +355,7 @@ export class InspectionDocumentDeliveryService {
: `Se adjunta el acta sellada e inmutable ${row.actCode}.`;
const sent = await this.smtp.send({
to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
});
}, senderUserId);
await this.dataSource.query(`
UPDATE inspection_document_deliveries
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.'),
labelValue('Resumen del Acta', text(snapshot.act.summary)),
labelValue('Observaciones del Acta', text(snapshot.act.observations)),
labelValue('Responsable de empresa', text(snapshot.responsible.fullName)),
labelValue('Cargo', text(snapshot.responsible.position)),
labelValue('Representante de la empresa', text(snapshot.responsible.fullName)),
labelValue('DNI', text(snapshot.responsible.documentNumber)),
labelValue('Cargo / función', text(snapshot.responsible.position)),
labelValue('Email', text(snapshot.responsible.email)),
paragraph('Inventario inspeccionado', 'Heading1'),
inventoryRows.length
? 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; }
export interface EffectiveSmtpSettings {
source: 'DATABASE' | 'ENVIRONMENT';
source: 'USER' | 'DATABASE' | 'ENVIRONMENT';
host: string;
port: number;
securityMode: SmtpSecurityMode;
@@ -47,10 +47,10 @@ export class SmtpDeliveryService {
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>{
const settings=await this.resolveSettings();
async fromAddress(userId?:string):Promise<string|null>{
const settings=await this.resolveSettings(userId);
if(!settings)return null;
return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail;
}
@@ -73,6 +73,56 @@ export class SmtpDeliveryService {
}:{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:{
host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null;
password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean;
@@ -96,8 +146,8 @@ export class SmtpDeliveryService {
return this.publicSettings();
}
async send(input:MailInput):Promise<{messageId:string}>{
const settings=await this.resolveSettings();
async send(input:MailInput,userId?:string):Promise<{messageId:string}>{
const settings=await this.resolveSettings(userId);
if(!settings)throw new Error('SMTP no configurado');
const {host,port,securityMode,userName,password}= {
host:settings.host,port:settings.port,securityMode:settings.securityMode,
@@ -137,7 +187,33 @@ export class SmtpDeliveryService {
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(`
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
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-1';
export const API_PHASE = 'F6.1';
export const API_VERSION = '0.29.0-4';
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/);
});
test('F3.1 exige email al crear o asignar el rol Inspector', () => {
assert.match(usersService, /assertInspectorHasEmail\(roles, dto\.email/);
assert.match(usersService, /assertInspectorHasEmail\(roles, before\.email\)/);
assert.match(usersService, /Los usuarios con rol Inspector deben tener un email válido/);
assert.match(usersService, /dto\.email !== undefined && !dto\.email && before\.roles\.some/);
test('F3.1 email del Inspector sigue protegido y F6.2 endurece email para todo usuario', () => {
assert.match(usersService, /USER_EMAIL_REQUIRED/);
assert.match(usersService, /Cada usuario de Hidrocarburos debe tener un email válido/);
assert.match(usersService, /if \(!dto\.email\?\.trim\(\)\) throw userEmailRequired\(\)/);
assert.match(usersService, /dto\.email !== undefined && !dto\.email/);
});
test('F3.1 dossier canónico agrega alias sin borrar identidad histórica', () => {
@@ -20,7 +20,7 @@ test('F4 WEB has an explicit LOCKED and SEALED act contract', () => {
assert.match(f4Api, /lockedAt: string \| null/);
assert.match(f4Api, /lockedSha256: string \| null/);
assert.match(f4Api, /sealedAt: string \| null/);
assert.match(f4Api, /urgency: InspectionActUrgencyF4/);
assert.match(f4Api, /urgency: InspectionActUrgencyF4 \| null/);
assert.match(f4Api, /deadlineAt: string \| null/);
});
@@ -35,7 +35,7 @@ test('F4 Android uses only lock and seal endpoints for the active Act lifecycle'
});
test('F4 Android ViewModel calls lock and seal directly', () => {
assert.match(viewModel, /actsRepository\.lock\(actId\)/);
assert.match(viewModel, /actsRepository\.lock\(actId, urgency\)/);
assert.match(viewModel, /actsRepository\.seal\(actId\)/);
assert.doesNotMatch(viewModel, /actsRepository\.prepare\(/);
assert.doesNotMatch(viewModel, /actsRepository\.reopen\(/);
+6 -2
View File
@@ -1,8 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6.1 release', () => {
assert.equal(API_PHASE, 'F6.1');
assert.equal(API_VERSION, '0.29.0-1');
assert.equal(API_PHASE, 'F6.2');
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');
});
+2 -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', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 29/);
assert.match(gradle, /versionName = "0\.19\.1"/);
assert.match(gradle, /versionCode = 31/);
assert.match(gradle, /versionName = "0\.19\.3"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version);
assert.match(version, /APP_PHASE\s*=\s*'F6\.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', () => {
@@ -33,7 +33,7 @@ test('F6.1 presentation keeps the complete Inspector profile and documentary-cop
for (const field of ['dni', 'phone', 'jobTitle', 'employeeNumber']) {
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(delivery, /recipientKind:'INSPECTOR'/);
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\);/);
});
@@ -20,7 +20,7 @@ test('F6.3 an Acta can start empty and receive Inventory when Hallazgos are adde
assert.doesNotMatch(dto, /ArrayMinSize\(1\)/);
assert.match(service, /if \(assetIds\.length === 0\) return/);
assert.match(mobile, /assetIds = listOfNotNull\(assetId\)/);
assert.match(mobile, /assetIds = emptyList\(\)/);
});
test('F6.3 every Installation and Subinstallation receives the common field card', () => {
@@ -35,15 +35,40 @@ test('F6.3 every Installation and Subinstallation receives the common field card
});
test('F6.3 Android follows Inspección → Acta → Hallazgo → Inventario', () => {
const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt');
const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt');
const vm = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
assert.match(root, /model\.startVisit\(\); onActs\(\)/);
assert.match(root, /Text\("Nuevo Hallazgo"/);
assert.match(root, /Buscar instalación o subinstalación/);
assert.match(acts, /Text\("\+ Agregar Hallazgo"\)/);
assert.match(acts, /model\.createAct\(newActUrgency\)/);
assert.match(vm, /fun createAct\(urgency: String = "NON_URGENT"\)/);
assert.doesNotMatch(root, /Text\("Inventario de campo"/);
assert.match(acts, /Text\("Agregar Hallazgo"\)/);
assert.match(acts, /model\.createAct\(\)/);
assert.match(acts, /model\.prepareSelectedAct\(closingUrgency\)/);
assert.match(vm, /fun createAct\(\)/);
assert.match(vm, /fun prepareSelectedAct\(urgency: String\)/);
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
});
test('F6.1 Acta urgency is null while drafting and is persisted atomically at lock', () => {
const createDto = source('src/inspection-acts/dto/create-inspection-act.dto.ts');
const prepareDto = source('src/inspection-closing/dto/prepare-inspection-act.dto.ts');
const actsService = source('src/inspection-acts/inspection-acts.service.ts');
const closingService = source('src/inspection-closing/inspection-closing.service.ts');
const migration = source('src/database/migrations/1790113800000-f6-1-act-urgency-at-lock.ts');
assert.doesNotMatch(createDto, /urgency/);
assert.match(prepareDto, /urgency!: InspectionActUrgency/);
assert.match(actsService, /urgency: null/);
assert.match(closingService, /urgency=\$2/);
assert.match(migration, /ALTER COLUMN urgency DROP NOT NULL/);
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 -5
View File
@@ -5,21 +5,18 @@ import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CancelInspectionActDto } from '../../src/inspection-acts/dto/cancel-inspection-act.dto';
import { CreateInspectionActDto } from '../../src/inspection-acts/dto/create-inspection-act.dto';
import { InspectionActUrgency } from '../../src/database/entities';
const ASSET_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
test('inspection act DTO accepts urgency, content and selected visit assets', async () => {
test('inspection act DTO accepts content and selected visit assets without deciding urgency', async () => {
const dto = plainToInstance(CreateInspectionActDto, {
occurredAt: '2026-08-14T15:30:00.000Z',
urgency: InspectionActUrgency.NON_URGENT,
title: ' Verificación de instalación ',
summary: ' Se inspeccionó el activo seleccionado. ',
observations: ' Sin novedades. ',
assetIds: [ASSET_ID],
});
assert.deepEqual(await validate(dto), []);
assert.equal(dto.urgency, InspectionActUrgency.NON_URGENT);
assert.equal(dto.title, 'Verificación de instalación');
assert.equal(dto.summary, 'Se inspeccionó el activo seleccionado.');
assert.equal(dto.observations, 'Sin novedades.');
@@ -28,7 +25,6 @@ test('inspection act DTO accepts urgency, content and selected visit assets', as
test('inspection act DTO rejects duplicate assets', async () => {
const dto = plainToInstance(CreateInspectionActDto, {
occurredAt: '2026-08-14T15:30:00.000Z',
urgency: InspectionActUrgency.NON_URGENT,
title: 'Acta',
summary: 'Contenido',
assetIds: [ASSET_ID, ASSET_ID],
@@ -5,6 +5,8 @@ import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateCompanyOutcomeDto } from '../../src/inspection-closing/dto/create-company-outcome.dto';
import { CreateInspectionSignatureDto } from '../../src/inspection-closing/dto/create-inspection-signature.dto';
import { PrepareInspectionActDto } from '../../src/inspection-closing/dto/prepare-inspection-act.dto';
import { InspectionActUrgency } from '../../src/database/entities';
import { UpsertInspectionResponsibleDto } from '../../src/inspection-closing/dto/upsert-inspection-responsible.dto';
test('D5 present responsible requires identity and position', async () => {
@@ -54,3 +56,13 @@ test('D5 signature DTO transforms multipart consent and coordinates', async () =
assert.equal(dto.consentAccepted, true);
assert.equal(dto.latitude, -32.889458);
});
test('F6.1 urgency is selected when the Acta is closed, not when it is created', async () => {
const urgent = plainToInstance(PrepareInspectionActDto, { urgency: InspectionActUrgency.URGENT });
const nonUrgent = plainToInstance(PrepareInspectionActDto, { urgency: InspectionActUrgency.NON_URGENT });
const invalid = plainToInstance(PrepareInspectionActDto, { urgency: 'UNKNOWN' });
assert.deepEqual(await validate(urgent), []);
assert.deepEqual(await validate(nonUrgent), []);
assert.equal((await validate(invalid)).some((error) => error.property === 'urgency'), true);
});
+27 -17
View File
@@ -126,11 +126,20 @@ INSERT INTO auth_sessions(
SQL
docker compose --env-file .env.example up -d api
# Gitea Actions runs Docker through DinD: the job container's 127.0.0.1 is
# not the Docker daemon host. Probe the API from inside its own container,
# matching the main CI contract instead of relying on the published host port.
api_health() {
docker compose --env-file .env.example exec -T api \
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}/api/v3/health`).then(async r => { const t = await r.text(); if (!r.ok) { console.error(t); process.exit(1); } }).catch(() => process.exit(1))'
}
for _ in $(seq 1 30); do
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null 2>&1 && break
if api_health >/dev/null 2>&1; then break; fi
sleep 2
done
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null
api_health >/dev/null
TOKEN="$(docker compose --env-file .env.example exec -T api node - <<'NODE'
const jwt = require('jsonwebtoken');
@@ -164,28 +173,29 @@ CONTEXT="$(docker compose --env-file .env.example exec -T db \
IFS='|' read -r DEPARTMENT_ID AREA_ID YACIMIENTO_ID COMPANY_ID <<< "$CONTEXT"
test -n "$DEPARTMENT_ID" && test -n "$AREA_ID" && test -n "$YACIMIENTO_ID" && test -n "$COMPANY_ID"
curl -fsS -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments \
>/tmp/dhv2-departments.json
api_get() {
local request_path="$1"
docker compose --env-file .env.example exec -T \
-e REQUEST_TOKEN="$TOKEN" -e REQUEST_PATH="$request_path" api \
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}${process.env.REQUEST_PATH}`, {headers:{Authorization:`Bearer ${process.env.REQUEST_TOKEN}`}}).then(async r => { const t=await r.text(); process.stdout.write(t); if (!r.ok) process.exit(1); }).catch(e => { console.error(e); process.exit(1); })'
}
api_get '/api/v3/inspection-visits/planning-context/departments' >/tmp/dhv2-departments.json
grep -Fq "$DEPARTMENT_ID" /tmp/dhv2-departments.json
curl -fsS -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments/$DEPARTMENT_ID/areas" \
>/tmp/dhv2-areas.json
api_get "/api/v3/inspection-visits/planning-context/departments/$DEPARTMENT_ID/areas" >/tmp/dhv2-areas.json
grep -Fq "$AREA_ID" /tmp/dhv2-areas.json
curl -fsS -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/areas/$AREA_ID/yacimientos" \
>/tmp/dhv2-yacimientos.json
api_get "/api/v3/inspection-visits/planning-context/areas/$AREA_ID/yacimientos" >/tmp/dhv2-yacimientos.json
grep -Fq "$YACIMIENTO_ID" /tmp/dhv2-yacimientos.json
START_AT="$(date -u -d '+1 day' +'%Y-%m-%dT%H:%M:%S.000Z')"
HTTP_CODE="$(curl -sS -o /tmp/dhv2-created.json -w '%{http_code}' \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"operationalAreaId\":\"$AREA_ID\",\"scopeAssetId\":\"$YACIMIENTO_ID\",\"operatorCompanyId\":\"$COMPANY_ID\",\"plannedStartAt\":\"$START_AT\",\"leadInspectorUserId\":\"22222222-2222-4222-8222-222222222222\"}" \
http://127.0.0.1:3101/api/v3/inspection-visits)"
REQUEST_BODY="{\"operationalAreaId\":\"$AREA_ID\",\"scopeAssetId\":\"$YACIMIENTO_ID\",\"operatorCompanyId\":\"$COMPANY_ID\",\"plannedStartAt\":\"$START_AT\",\"leadInspectorUserId\":\"22222222-2222-4222-8222-222222222222\"}"
POST_RESULT="$(docker compose --env-file .env.example exec -T \
-e REQUEST_TOKEN="$TOKEN" -e REQUEST_BODY="$REQUEST_BODY" api \
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}/api/v3/inspection-visits`, {method:"POST",headers:{Authorization:`Bearer ${process.env.REQUEST_TOKEN}`,"Content-Type":"application/json"},body:process.env.REQUEST_BODY}).then(async r => process.stdout.write(JSON.stringify({status:r.status,body:await r.text()}))).catch(e => { console.error(e); process.exit(1); })')"
HTTP_CODE="$(printf '%s' "$POST_RESULT" | python -c 'import json,sys; print(json.load(sys.stdin)["status"])')"
printf '%s' "$POST_RESULT" | python -c 'import json,sys; sys.stdout.write(json.load(sys.stdin)["body"])' >/tmp/dhv2-created.json
if [ "$HTTP_CODE" != 201 ]; then
echo "ERROR: real inspection create returned HTTP $HTTP_CODE" >&2
cat /tmp/dhv2-created.json >&2
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-web",
"version": "0.23.0-1",
"version": "0.23.0-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-web",
"version": "0.23.0-1",
"version": "0.23.0-3",
"dependencies": {
"maplibre-gl": "6.4.1",
"react": "^19.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-web",
"version": "0.23.0-1",
"version": "0.23.0-3",
"private": true,
"type": "module",
"engines": {
+2
View File
@@ -9,6 +9,7 @@ import { CompanySignaturePage } from '../pages/CompanySignaturePage';
import { DashboardPage } from '../pages/DashboardPage';
import { LoginPage } from '../pages/LoginPage';
import { NewUserPage } from '../pages/NewUserPage';
import { MyProfilePage } from '../pages/MyProfilePage';
import { RolesPage } from '../pages/RolesPage';
import { AccessDeniedPage, NotFoundPage } from '../pages/SystemPages';
import { UserDetailPage } from '../pages/UserDetailPage';
@@ -45,6 +46,7 @@ export function App() {
<Route element={<ProtectedRoute />}>
<Route path="/change-password" element={<ChangePasswordPage />} />
<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="assets.read" />}><Route path="/mapa" element={<MapPage />} /></Route>
<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_PHASE = 'F6.1 · Contexto operativo ÁreaOperadora consolidado';
export const APP_VERSION = '0.23.0-3';
export const APP_PHASE = 'F6.2 · Firma por Acta y correo de usuario';
@@ -90,7 +90,7 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
</div>
<div className="responsible-summary">
<div><small>Urgencia</small><strong>{closure.act.urgency === 'URGENT' ? 'Urgente' : 'No urgente'}</strong></div>
<div><small>Urgencia</small><strong>{closure.act.urgency === 'URGENT' ? 'Urgente' : closure.act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}</strong></div>
<div><small>Plazo configurado</small><strong>{closure.act.deadlineDays ? `${closure.act.deadlineDays} días ${closure.act.deadlineDayType === 'BUSINESS' ? 'hábiles' : 'corridos'}` : 'Pendiente'}</strong></div>
<div><small>Inicio del plazo</small><strong>{closure.act.deadlineBaseAt ? formatDate(closure.act.deadlineBaseAt) : 'Pendiente de evento válido'}</strong></div>
<div><small>Vencimiento</small><strong>{closure.act.deadlineAt ? formatDate(closure.act.deadlineAt) : 'Todavía no iniciado'}</strong></div>
+7 -5
View File
@@ -100,11 +100,13 @@ export function AppLayout() {
</nav>
<div className="sidebar-user">
<div className="user-avatar">{user?.firstName?.[0]}{user?.lastName?.[0]}</div>
<div className="user-copy">
<strong>{user?.firstName} {user?.lastName}</strong>
<small>{user?.roles.join(' · ') || user?.username}</small>
</div>
<NavLink to="/mi-perfil" onClick={close} className="sidebar-user-profile" title="Abrir Mi perfil">
<div className="user-avatar">{user?.firstName?.[0]}{user?.lastName?.[0]}</div>
<div className="user-copy">
<strong>{user?.firstName} {user?.lastName}</strong>
<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">
<Icon name="logout" />
</button>
+1
View File
@@ -98,6 +98,7 @@ export interface AdministrativeUser {
createdAt: string;
updatedAt: string;
roles: RoleSummary[];
smtpMode: 'SYSTEM' | 'CUSTOM';
}
export interface Permission { id: string; code: string; description: string }
+2 -2
View File
@@ -53,7 +53,7 @@ export interface InspectionActListItemF4 {
title: string;
summary: string;
observations: string | null;
urgency: InspectionActUrgencyF4;
urgency: InspectionActUrgencyF4 | null;
deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayTypeF4 | null;
deadlineBasis: InspectionDeadlineBasisF4 | null;
@@ -107,7 +107,7 @@ export interface InspectionClosureF4 {
code: string;
status: InspectionActStatusF4;
visitId: string;
urgency: InspectionActUrgencyF4;
urgency: InspectionActUrgencyF4 | null;
deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayTypeF4 | null;
deadlineBasis: InspectionDeadlineBasisF4 | null;
+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 {
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
+1 -1
View File
@@ -63,7 +63,7 @@ export function InspectionActEditorPage() {
<div className="responsible-summary">
<div><small>Fecha y hora</small><strong>{formatDate(act.occurredAt)}</strong></div>
<div><small>Título</small><strong>{act.title}</strong></div>
<div><small>Urgencia</small><strong>{act.urgency === 'URGENT' ? 'Urgente' : 'No urgente'}</strong></div>
<div><small>Urgencia</small><strong>{act.urgency === 'URGENT' ? 'Urgente' : act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}</strong></div>
<div><small>Hallazgos</small><strong>{act.findingCount}</strong></div>
</div>
<div className="closure-section"><span className="eyebrow">DESCRIPCIÓN DE LO ACTUADO</span><p>{act.summary}</p>{act.observations && <><span className="eyebrow">OBSERVACIONES</span><p>{act.observations}</p></>}</div>
+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('');
const data = new FormData(event.currentTarget);
const email = String(data.get('email') ?? '').trim();
if (inspectorSelected && !email) {
setError('El email es obligatorio para usuarios con rol Inspector porque allí recibirán la documentación de sus inspecciones.');
if (!email) {
setError('El email es obligatorio para todos los usuarios de Hidrocarburos.');
return;
}
setSubmitting(true);
try {
const created = await createUserProfile({
username: String(data.get('username')),
email: email || null,
email,
dni: String(data.get('dni') ?? '').trim() || null,
phone: String(data.get('phone') ?? '').trim() || null,
jobTitle: String(data.get('jobTitle') ?? '').trim() || null,
@@ -75,10 +75,10 @@ export function NewUserPage() {
</div>
<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">
<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>
{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>
+4 -3
View File
@@ -57,8 +57,8 @@ export function UserDetailPage() {
setError(''); setSuccess('');
const data = new FormData(event.currentTarget);
const email = String(data.get('email') ?? '').trim();
if (inspectorSelected && !email) {
setError('El email es obligatorio para un Inspector porque allí recibe la documentación de sus inspecciones.');
if (!email) {
setError('El email es obligatorio para todos los usuarios de Hidrocarburos.');
return;
}
setSaving('profile');
@@ -139,6 +139,7 @@ export function UserDetailPage() {
<div className="detail-grid">
<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>
<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">
<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>
@@ -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>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>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>
{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>
+2
View File
@@ -46,6 +46,8 @@ a { color: inherit; }
.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-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 { width: 34px; height: 34px; border-radius: 50%; font-size: 11px; }
.user-copy { min-width: 0; }