Compare commits

...
Author SHA1 Message Date
admin 975f9ee13e fix(android): match dropdown anchor to installed Material version
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 3m5s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 36s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 24s
Production dependency audit / API · production dependencies (pull_request) Successful in 16s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 15s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 1m45s
2026-09-14 18:02:11 -03:00
admin 39dcd6bd4a fix(ci): skip unavailable legacy Android tools package
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Failing after 1m4s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 37s
DH V2 CI / Docker / scripts contract (pull_request) Canceled after 0s
Production dependency audit / API · production dependencies (pull_request) Canceled after 0s
DH V2 CI / WEB · typecheck, build (pull_request) Canceled after 22s
Production dependency audit / WEB · production dependencies (pull_request) Canceled after 0s
2026-09-14 17:59:57 -03:00
admin ffeae96530 fix(android): unify installation type in searchable dropdown
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Failing after 22s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 36s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 22s
Production dependency audit / API · production dependencies (pull_request) Successful in 15s
DH V2 CI / Docker / scripts contract (pull_request) Canceled after 0s
Production dependency audit / WEB · production dependencies (pull_request) Canceled after 14s
2026-09-14 17:58:16 -03:00
admin 9b12144052 test(android): align final field contract with f6.2
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m23s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 50s
2026-09-14 16:07:53 -03:00
admin becf9fc050 fix(android): use valid email regex in act representative flow
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 2m38s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 49s
2026-09-14 16:01:49 -03:00
admin 7fe59bccd2 feat(f6.2): add per-act representative signing and user smtp
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 1m25s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m16s
2026-09-14 15:53:55 -03:00
admin 103ecf2fae fix(api): prevent NaN act version during mobile create
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m19s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m5s
2026-09-14 14:06:26 -03:00
admin 23880521d9 fix(api): align health version with package metadata
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m16s
DH V2 CI / Docker / scripts contract (push) Successful in 50s
2026-09-14 10:54:14 -03:00
admin 637c28dc2f fix(web): align visible version with 0.23.0-2
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m1s
2026-09-14 10:37:58 -03:00
admin fc0c62c70a docs(android): pin 0.19.2 server compatibility
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / Docker / scripts contract (push) Canceled after 0s
Production dependency audit / API · production dependencies (push) Canceled after 0s
Production dependency audit / WEB · production dependencies (push) Canceled after 0s
DH V2 CI / WEB · typecheck, build (push) Canceled after 0s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m34s
2026-09-14 10:31:54 -03:00
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
admin d44ae049f3 test(android): align 0.19.1 release contract and record field review
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m11s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m12s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 30s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Production dependency audit / API · production dependencies (pull_request) Successful in 8s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 48s
2026-09-14 00:04:01 -03:00
admin 78e92970ff fix(android): align GPS and technical inventory field workflow
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m12s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m9s
DH V2 CI / API · typecheck, tests, build (pull_request) Failing after 25s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
DH V2 CI / Docker / scripts contract (pull_request) Skipped
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
2026-09-13 23:48:55 -03:00
admin 7fcb1d42b6 fix(android): harden field session and writes for 0.19.1 candidate
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m25s
2026-09-13 23:35:08 -03:00
admin e55684bf4f fix(web): compact installation parent checkbox options
DH V2 CI / API · typecheck, tests, build (push) Successful in 29s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
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 58s
2026-09-13 18:37:25 -03:00
admin ddf3b158e7 deploy: serialize Gitea production deployments
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
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 48s
2026-09-13 17:11:51 -03:00
admin 8564f1933c deploy: add native Gitea production workflow
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
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 48s
2026-09-13 16:56:54 -03:00
admin ea890cb807 ci(gitea): make API health probe DinD-compatible
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
Production dependency audit / API · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m5s
2026-09-13 16:40:46 -03:00
admin ce27cd01e9 ci(gitea): pin setup-gradle v4.4.3 for act compatibility
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m39s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
DH V2 CI / Docker / scripts contract (push) Failing after 1m38s
2026-09-13 13:03:21 -03:00
admin a155d9e075 Merge PR #59 · reparar Actividad global de Inventarios
Corrige la consulta SQL común del expediente de Actividad y aísla las facetas auxiliares para evitar errores globales.
2026-09-12 12:23:15 -03:00
admin e89d815868 test(api): actualizar contrato de Actividad territorial 2026-09-12 12:14:57 -03:00
admin ff0b6d83db test(api): cubrir fallo global de actividad 2026-09-12 12:11:55 -03:00
admin d894391d97 fix(api): registrar servicio de actividad resiliente 2026-09-12 12:11:40 -03:00
admin c79537def9 fix(api): usar dossier de actividad resiliente 2026-09-12 12:11:27 -03:00
admin 83c9e2ca3e fix(api): aislar y reparar consultas de actividad de inventario 2026-09-12 12:11:02 -03:00
admin 9254583154 Merge PR #58 · reparar Actividad de Yacimiento
Desacopla el dossier territorial del subsistema de fusión y evita que merge-status bloquee la pestaña Actividad.
2026-09-12 11:40:55 -03:00
admin 0b21e6bc50 test(f7): cubrir Actividad de Yacimiento sin dependencia de merge 2026-09-12 11:33:59 -03:00
admin 350e26566f fix(web): no bloquear Actividad por estado de fusión 2026-09-12 11:33:45 -03:00
admin ba79fbf9ae fix(api): desacoplar dossier territorial de merge 2026-09-12 11:32:50 -03:00
admin 77def3b511 Merge PR #56 · simplificación integral de Inventarios
F7 · modelo simple y experiencia cotidiana de Inventarios
2026-09-11 22:20:02 -03:00
88 changed files with 2710 additions and 495 deletions
+14 -4
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:
@@ -43,12 +43,14 @@ jobs:
- name: Android SDK
uses: android-actions/setup-android@v3
with:
packages: platform-tools
- name: Android API 36
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- name: Gradle 8.13
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
with:
gradle-version: '8.13'
@@ -63,7 +65,7 @@ jobs:
- name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug
run: gradle --no-daemon :app:lintDebug :app:lintRelease
- name: Print complete lint failures
if: failure()
@@ -109,7 +111,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"
@@ -130,6 +132,14 @@ jobs:
if-no-files-found: error
retention-days: 30
- name: Upload unsigned release candidate
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-UNSIGNED
path: android-app/app/build/outputs/apk/release/app-release-unsigned.apk
if-no-files-found: error
retention-days: 30
- name: Upload Android diagnostics
if: always()
uses: actions/upload-artifact@v4
+6 -1
View File
@@ -309,9 +309,14 @@ jobs:
docker compose --env-file .env.example build api
docker compose --env-file .env.example up -d api
# In containerized runners (Gitea DinD), 127.0.0.1 of the job
# is not the Docker daemon host. Probe the production API from
# inside its own container so this barrier works on GitHub and Gitea.
api_ready=0
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then
if 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(); process.stdout.write(t); if (!r.ok) process.exit(1); }).catch(() => process.exit(1))' \
>/tmp/dhv2-health.json 2>/dev/null; then
api_ready=1
break
fi
+9 -4
View File
@@ -1,15 +1,20 @@
# Checklist final Android 0.19.0
# 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.
+42 -11
View File
@@ -1,29 +1,31 @@
# DH Inspección Android · release final de campo 0.19.0
# 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.0**.
- `versionCode`: **28**.
- `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.0 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.
@@ -95,3 +97,32 @@ Antes de distribuir una APK productiva:
- realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda;
- ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección;
- registrar el SHA Git y SHA-256 de la APK distribuida.
## Revisión 0.19.1
- Renovación de sesión coordinada entre los cuatro clientes API; no cierra sesión por pérdida de red, HTTP 429/500 o cancelación.
- Una respuesta tardía no puede restaurar una sesión cerrada ni usar la de otro inspector.
- Guardia de escrituras contra doble toque y conteo correcto de operaciones pendientes.
- Búsquedas descartan respuestas antiguas. Cambio de inspección limpia el Acta anterior.
- Mensajes visibles de permisos/GPS en evidencia y respeto de barras de sistema/teclado.
- Diez nuevas pruebas de comportamiento sobre sesión y concurrencia.
### Alcance real
Esta candidata requiere conexión. No implementa trabajo offline ni cola persistente de sincronización; un fallo de red no equivale a guardado. Tras un timeout de escritura debe verificarse el registro antes de repetir. No se presenta el artefacto debug como una release productiva. La firma histórica y el smoke en tablet siguen siendo requisitos para distribuir la release final.
### Segunda pasada funcional
- Coordenadas normalizadas al contrato API (6 decimales, precisión 3).
- 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í.
+49
View File
@@ -0,0 +1,49 @@
# DH Android 0.19.1 · revisión de candidata
## Idea funcional verificada contra código y API
La app es la herramienta del inspector en campo. Oficina administra territorio, operadoras, clasificaciones, campos y catálogo; la APK consulta esas fuentes. Una Inspección contiene varias Actas, con una sola en elaboración simultánea. El Hallazgo pertenece al Acta y al elemento seleccionado. Cerrar contenido lo deja inmutable y pendiente de firma. Firma del inspector y manifestación/firma o negativa de empresa permiten sellar el Acta. La Inspección exige todas sus Actas activas selladas.
Yacimiento, Instalación y Subinstalación son destinos de Hallazgos. Una Instalación nueva debe crearse dentro de un Yacimiento y una Subinstalación dentro de una Instalación compatible. El inventario nacido en campo necesita GPS y foto antes del Hallazgo.
## Hallazgos corregidos
| Problema observado | Corrección |
| --- | --- |
| Cuatro clientes renovaban por separado la misma sesión | Coordinador compartido; protege rotación, logout y cambio de usuario |
| Cortes de red durante refresh borraban la sesión | Sólo una respuesta 401 de la sesión vigente la invalida |
| Operaciones solapadas liberaban ocupado antes de terminar | Conteo de pendientes y rechazo del segundo guardado |
| Respuestas de búsquedas antiguas sobrescribían resultados recientes | Generación de búsqueda y verificación del ID de inspección |
| Un Acta anterior podía persistir al cambiar de inspección | Limpieza de estado de Acta al abrir otra inspección |
| Coordenadas crudas excedían decimales admitidos | Normalización 6 decimales para coordenadas y 3 para precisión |
| Inventario móvil ocultaba Yacimientos | Selección para Hallazgos y alta de Instalación dentro de Yacimiento |
| Alta de Instalación podía adoptar otro tipo por fallback | Eliminado; pide elegir el Yacimiento padre correcto |
| Datos técnicos por familia no estaban editables | Formulario conectado a technical-values; mismos campos de oficina |
| Fallos de GPS/permisos en evidencia sin mensaje | Error visible y accionable |
| Barras del sistema/teclado podían tapar controles | Insets de sistema y teclado en la raíz |
## Evidencia automática
25 pruebas Android: 8 contratos existentes y 17 pruebas nuevas de comportamiento (sesión 7, concurrencia 3, GPS 3, campos técnicos 4). Lint debug/release y compilación debug/release en workflow Android. CI general comprueba API, WEB, scripts y migraciones; la auditoría de dependencias se ejecuta separadamente. Consultar las ejecuciones del SHA de la entrega, no sólo un resultado anterior.
## Límites de esta candidata
- Requiere conexión. No existe cola persistente offline ni resolución de conflictos sin señal.
- Un timeout de escritura puede ocurrir después de guardar en servidor. Revisar el registro antes de repetir; no se afirma idempotencia general de todas las escrituras.
- Los datos técnicos por familia se completan en un paso separado tras seleccionar/crear el elemento. GPS/foto continúa siendo la barrera de Hallazgos del servidor.
- No se ejecutó aceptación en tablet física: quedan por comprobar cámara, GPS, biometría, rotación/recreación de actividad, firmas y actualización sobre la APK instalada.
- La release compila sin firma. Se requiere la clave histórica y su certificado de referencia para distribuir una actualización productiva. No crear otra clave como sustituto.
- La candidata debug usa com.korexlabs.dhinspeccion.debug y no reemplaza la app productiva. La compatibilidad con una debug anterior depende también de su certificado; no desinstalar una app con información pendiente sin revisar antes.
## Recorrido de aceptación en tablet
1. Ingresar con inspector y cargar inspecciones asignadas.
2. Iniciar una inspección, crear Acta, seleccionar Yacimiento/Instalación/Subinstalación.
3. Crear Instalación bajo Yacimiento y Subinstalación compatible; probar GPS permitido/denegado y foto tomada/cancelada.
4. Completar campos técnicos requeridos, incluyendo No booleano, decimal con coma, fecha y opción de lista; volver a abrir para comprobar persistencia.
5. Crear Hallazgo catalogado y OTROS, adjuntar evidencia.
6. Cerrar contenido, comprobar inmutabilidad y estado Pendiente de firma.
7. Registrar firmas o negativa de empresa; sellar Acta.
8. Crear otra Acta, comprobar que una pendiente impide cerrar la Inspección; completar y cerrar.
9. Cortar conexión y recuperarla: errores visibles, sin pérdida de sesión por red ni falso aviso de guardado.
10. Verificar en Dashboard los mismos registros y documentos.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 28
versionName = "0.19.0"
versionCode = 32
versionName = "0.19.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository
@@ -24,11 +25,14 @@ import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import java.io.File
import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val operations = OperationTracker()
private var inventorySearchGeneration = 0
private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application)
@@ -84,7 +88,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Ingresá usuario y contraseña."
return
}
launchBusy {
launchBusy(mutation = true) {
session = repository.login(identifier, password)
notice = "Sesión iniciada."
loadVisitsInternal()
@@ -113,7 +117,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun openVisit(id: String) = launchBusy {
visit = repository.visit(id)
val loadedVisit = repository.visit(id)
clearActState()
visit = loadedVisit
inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList()
@@ -123,6 +129,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun closeVisitView() {
if (busy) return
inventorySearchGeneration += 1
visit = null
inventory = emptyList()
inventoryParentId = null
@@ -135,7 +143,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun startVisit() {
val id = visit?.id ?: return
launchBusy {
launchBusy(mutation = true) {
visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada."
@@ -157,45 +165,31 @@ 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 {
val created = actsRepository.create(
currentVisit.id,
asset?.id,
currentVisit.code,
urgency,
)
launchBusy(mutation = true) {
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
launchBusy {
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
val result = repository.fieldInventory(currentVisit.id, search, parentId).data
if (generation == inventorySearchGeneration && visit?.id == currentVisit.id) inventory = result
}
}
@@ -203,6 +197,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val currentVisit = visit ?: return
launchBusy {
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventorySearchGeneration += 1
inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
@@ -216,7 +211,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return
launchBusy {
launchBusy(mutation = true) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección."
reloadCurrentInventory(visitId)
@@ -252,7 +247,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
return
}
launchBusy {
launchBusy(mutation = true) {
val request = CreateFieldInventoryRequest(
typeId = type.id,
parentId = parentId,
@@ -261,9 +256,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
deviceLatitude = latitude,
deviceLongitude = longitude,
deviceAccuracyM = accuracyM,
deviceLatitude = FieldCoordinates.latitude(latitude),
deviceLongitude = FieldCoordinates.longitude(longitude),
deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
deviceCapturedAt = Instant.now().toString(),
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
@@ -283,7 +278,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Explicá brevemente por qué se trata de un duplicado."
return
}
launchBusy {
launchBusy(mutation = true) {
val result = repository.mergeFieldAsset(
visitId = visitId,
assetId = source.id,
@@ -310,7 +305,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) {
val visitId = visit?.id ?: return
val asset = selectedFieldAsset?.asset ?: return
launchBusy {
launchBusy(mutation = true) {
val response = repository.uploadFieldPhoto(
visitId = visitId,
assetId = asset.id,
@@ -345,7 +340,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
return
}
launchBusy {
launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, assetId, draft.id)
@@ -379,7 +374,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "La gravedad debe estar entre 1 y 10."
return
}
launchBusy {
launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create(
visitId,
@@ -410,7 +405,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
title: String? = null,
description: String? = null,
) {
launchBusy {
launchBusy(mutation = true) {
findingsRepository.uploadObservationPhoto(
findingId = findingId,
file = file,
@@ -438,11 +433,13 @@ 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 {
launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
@@ -451,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."
}
}
@@ -465,7 +462,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de ausencia de al menos 10 caracteres."
return
}
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
@@ -473,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
launchBusy {
actClosure = actsRepository.lock(actId)
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Definí la urgencia antes de cerrar el Acta."
return
}
launchBusy(mutation = true) {
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."
}
}
@@ -497,7 +499,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
accuracyM: Double?,
) {
val actId = selectedAct?.id ?: return
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
notice = "Firma del inspector incorporada al Acta."
}
@@ -512,14 +514,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
statement: String?,
) {
val actId = selectedAct?.id ?: return
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.signCompany(
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."
}
}
}
@@ -534,7 +536,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de al menos 10 caracteres."
return
}
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = "Negativa a firmar asentada."
}
@@ -543,7 +545,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeSelectedAct() {
val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
@@ -554,7 +556,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeInspection() {
val visitId = visit?.id ?: return
launchBusy {
launchBusy(mutation = true) {
visit = actsRepository.closeVisit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
@@ -637,17 +639,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fieldFindingEvidence = emptyMap()
}
private fun launchBusy(block: suspend () -> Unit) {
private fun launchBusy(mutation: Boolean = false, block: suspend () -> Unit) {
if (!operations.begin(mutation)) return
busy = operations.busy
viewModelScope.launch {
busy = true
error = null
try {
block()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (throwable: Throwable) {
error = DhRepository.humanError(throwable)
if (repository.currentSession() == null) session = null
} finally {
busy = false
operations.end()
busy = operations.busy
}
}
}
@@ -0,0 +1,16 @@
package com.korexlabs.dhinspeccion
/** Main-thread gate: reads may overlap; a second write is never queued by a double tap. */
internal class OperationTracker {
private var pending = 0
val busy: Boolean get() = pending > 0
fun begin(mutation: Boolean): Boolean {
if (mutation && busy) return false
pending += 1
return true
}
fun end() {
check(pending > 0)
pending -= 1
}
}
@@ -7,8 +7,6 @@ import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -451,7 +449,6 @@ class SecureSessionStore(context: Context) {
class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -530,13 +527,13 @@ class DhRepository(context: Context) {
visitId = visitId,
assetId = assetId,
file = part,
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text),
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text),
exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text),
)
}
@@ -548,23 +545,19 @@ class DhRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
session = refresh(session)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
companion object {
fun humanError(error: Throwable): String {
if (error is java.io.IOException) {
return "No se pudo conectar. Revisá la señal. Si estabas guardando, comprobá el registro antes de reintentar."
}
if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
@@ -0,0 +1,14 @@
package com.korexlabs.dhinspeccion.data
import java.math.BigDecimal
import java.math.RoundingMode
internal object FieldCoordinates {
private fun normalized(value: Double, scale: Int, min: Double, max: Double): Double {
require(value.isFinite() && value in min..max) { "La ubicación GPS recibida no es válida." }
return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).toDouble()
}
fun latitude(value: Double) = normalized(value, 6, -90.0, 90.0)
fun longitude(value: Double) = normalized(value, 6, -180.0, 180.0)
fun accuracy(value: Double) = normalized(value, 3, 0.0, 100000.0)
}
@@ -4,8 +4,6 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -176,7 +174,6 @@ private interface FieldFindingsApi {
*/
class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -227,9 +224,9 @@ class FieldFindingsRepository(context: Context) {
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text),
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
)
}
@@ -241,18 +238,11 @@ class FieldFindingsRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
session = refresh(session)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
}
@@ -4,8 +4,6 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -37,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,
@@ -62,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,
@@ -93,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>,
)
@@ -132,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,
@@ -187,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(),
@@ -251,6 +253,7 @@ private interface MobileActsApi {
suspend fun lock(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: PrepareMobileActRequest,
): MobileActClosure
@Multipart
@@ -310,7 +313,6 @@ private interface MobileActsApi {
class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -329,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(),
),
)
}
@@ -363,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(
@@ -429,9 +428,9 @@ class MobileActsRepository(context: Context) {
val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text)
val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text)
val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text)
if (company) {
api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
@@ -454,18 +453,11 @@ class MobileActsRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
session = refresh(session)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
}
@@ -4,8 +4,6 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
@@ -55,7 +53,6 @@ private interface MobileInspectionOpenApi {
class MobileInspectionOpenRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileInspectionOpenApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -86,18 +83,11 @@ class MobileInspectionOpenRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
session = refresh(session)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
}
@@ -0,0 +1,32 @@
package com.korexlabs.dhinspeccion.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import retrofit2.HttpException
/** One refresh lock for every API client sharing the encrypted session. */
internal object MobileSessionCoordinator {
private val mutex = Mutex()
suspend fun refresh(
previous: StoredSession,
load: () -> StoredSession?,
save: (MobileSessionResponse) -> StoredSession,
clear: () -> Unit,
request: suspend (RefreshRequest) -> MobileSessionResponse,
): StoredSession = mutex.withLock {
val latest = load() ?: throw IllegalStateException("Sesión no iniciada")
check(latest.userId == previous.userId) { "La sesión cambió. Volvé a abrir la operación." }
if (latest.refreshToken != previous.refreshToken) return@withLock latest
val response = try {
request(RefreshRequest(previous.refreshToken))
} catch (error: HttpException) {
// Network failures and server outages must not discard the saved login.
if (error.code() == 401 && load()?.refreshToken == previous.refreshToken) clear()
throw error
}
// A logout or another login during the request must never be undone.
check(load()?.refreshToken == previous.refreshToken) { "La sesión cambió. Volvé a ingresar." }
save(response)
}
}
@@ -0,0 +1,64 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.*
import java.time.LocalDate
import java.time.OffsetDateTime
internal data class TechnicalDefinition(
val id: String, val code: String, val name: String, val dataType: String,
val isRequired: Boolean = false, val isActive: Boolean = true,
val unit: String? = null, val options: List<String>? = null,
)
internal data class TechnicalValues(
val assetId: String, val family: FieldInventoryFamily,
val definitions: List<TechnicalDefinition>, val values: Map<String, Any?>,
)
internal data class TechnicalValuesRequest(val values: Map<String, Any?>)
private interface TechnicalApi {
@GET("assets/{id}/technical-values")
suspend fun get(@Header("Authorization") auth: String, @Path("id") id: String): TechnicalValues
@PUT("assets/{id}/technical-values")
suspend fun put(@Header("Authorization") auth: String, @Path("id") id: String, @Body body: TechnicalValuesRequest): TechnicalValues
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
internal class TechnicalRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val api = Retrofit.Builder().baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()))
.build().create(TechnicalApi::class.java)
suspend fun get(id: String) = authorized { api.get("Bearer ${it.accessToken}", id) }
suspend fun save(id: String, values: Map<String, Any?>) = authorized {
api.put("Bearer ${it.accessToken}", id, TechnicalValuesRequest(values))
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
val previous = store.load() ?: error("Sesión no iniciada")
try { return block(previous) }
catch (error: HttpException) { if (error.code() != 401) throw error }
val current = MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
return block(current)
}
}
/** Keeps IDs and typed JSON values identical to the Dashboard contract. */
internal fun technicalInputValue(definition: TechnicalDefinition, text: String): Any? {
val value = text.trim()
require(value.isNotBlank() || !definition.isRequired) { "Completá ${definition.name}." }
if (value.isBlank()) return null
fun invalid(): Nothing = throw IllegalArgumentException("Revisá el valor de ${definition.name}.")
return when (definition.dataType) {
"NUMBER" -> value.replace(',', '.').toDoubleOrNull()?.takeIf { it.isFinite() } ?: invalid()
"BOOLEAN" -> when (value) { "true" -> true; "false" -> false; else -> invalid() }
"DATE" -> runCatching { LocalDate.parse(value).toString() }.getOrElse { invalid() }
"DATETIME" -> runCatching { OffsetDateTime.parse(value).toInstant().toString() }.getOrElse { invalid() }
"SELECT" -> value.takeIf { it in definition.options.orEmpty() } ?: invalid()
else -> value.takeIf { it.length <= 4000 } ?: invalid()
}
}
@@ -75,6 +75,7 @@ fun FieldFindingScreen(model: MainViewModel) {
var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
var localError by rememberSaveable { mutableStateOf<String?>(null) }
var requestedFindingId by remember { mutableStateOf<String?>(null) }
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
@@ -110,6 +111,7 @@ fun FieldFindingScreen(model: MainViewModel) {
pendingPhotoFindingId = findingId
takePicture.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
}
}
@@ -122,6 +124,7 @@ fun FieldFindingScreen(model: MainViewModel) {
val findingId = requestedFindingId
requestedFindingId = null
if (camera && location && findingId != null) beginPhoto(findingId)
else localError = "Permití cámara y ubicación para adjuntar una foto con GPS."
}
fun requestPhoto(findingId: String) {
@@ -192,6 +195,7 @@ fun FieldFindingScreen(model: MainViewModel) {
}
}
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.error?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(it, Modifier.padding(12.dp))
@@ -11,6 +11,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -80,7 +84,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
}
DhTheme {
Surface(Modifier.fillMaxSize()) {
Surface(Modifier.fillMaxSize().safeDrawingPadding().imePadding()) {
when {
model.session == null -> EnhancedLoginScreen(model) {
passwordLoginInFlight = true
@@ -106,10 +110,10 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
@Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
@@ -203,7 +207,7 @@ private fun BiometricUnlockScreen(
LaunchedEffect(Unit) { authenticate() }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Column(
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
@@ -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) },
)
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -36,6 +37,11 @@ import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
@@ -106,7 +112,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 +129,6 @@ fun ModernVisitRoot(model: MainViewModel) {
private fun ModernVisitOverview(
model: MainViewModel,
onActs: () -> Unit,
onInventory: () -> Unit,
) {
val visit = model.visit ?: return
Column(
@@ -183,7 +187,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 +221,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") }
}
@@ -280,6 +265,7 @@ private fun ModernChecklistCard(visit: VisitDetail) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
val context = LocalContext.current
@@ -300,6 +286,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
var familyExpanded by remember { mutableStateOf(false) }
val attributeValues = remember { mutableStateMapOf<String, String>() }
var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) }
var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
@@ -316,6 +303,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
selectedTypeId = null
selectedFamilyId = null
familySearch = ""
familyExpanded = false
attributeValues.clear()
localError = null
}
@@ -329,13 +317,13 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
model.loadFieldTypes(null)
}
fun startInstallation() {
fun startInstallation(parent: FieldInventoryItem? = null) {
model.clearSelectedFieldAsset()
modeName = ModernInventoryMode.CREATE_INSTALLATION.name
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
parentId = parent?.id
parentLabel = parent?.name ?: visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetForm()
model.loadFieldTypes(null)
model.loadFieldTypes(parent?.id)
}
fun startSubinstallation() {
@@ -349,6 +337,10 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
fun chooseParent(item: FieldInventoryItem) {
if (modernItemTypeCode(item) == "yacimiento") {
startInstallation(item)
return
}
keyboard?.hide()
focusManager.clearFocus()
parentId = item.id
@@ -385,8 +377,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
ModernInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
else -> false
}
} ?: model.fieldTypes.firstOrNull()
if (model.fieldTypes.none { it.id == selectedTypeId }) {
}
if (selectedTypeId != preferred?.id) {
selectedTypeId = preferred?.id
selectedFamilyId = null
familySearch = ""
@@ -396,6 +388,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val editableAttributes = selectedType?.attributes.orEmpty().filterNot {
selectedType?.familyRequired == true && it.code == "tipo_instalacion"
}
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
val filteredFamilies = remember(selectedType, familySearch) {
val needle = modernNormalize(familySearch)
@@ -435,7 +430,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
fun createWithLocation() {
val type = selectedType ?: return
val effectiveParent = if (mode == ModernInventoryMode.CREATE_SUBINSTALLATION) parentId else null
val effectiveParent = parentId
scope.launch {
runCatching { currentModernGeo(context) }
.onSuccess { geo ->
@@ -529,7 +524,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() },
@@ -544,6 +539,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
if (selectedCapture != null) {
if (modernItemTypeCode(selectedCapture.asset) in setOf("instalacion", "subinstalacion")) {
TechnicalFieldsButton(selectedCapture.asset.id, enabled = !model.busy)
}
ModernCaptureCard(
item = selectedCapture.asset,
gps = selectedCapture.capture.creationGpsCaptured,
@@ -735,8 +733,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
}
if (model.fieldTypes.isEmpty() && !model.busy) {
ModernLocalError("No hay un tipo habilitado para esta ubicación.") {}
if (selectedType == null && !model.busy) {
ModernLocalError("Elegí un Yacimiento en Inventario y tocá Agregar Instalación. No se puede crear una Instalación directamente dentro de un Área.") {}
OutlinedButton(onClick = { backToBrowse() }) { Text("Elegir Yacimiento") }
}
if (model.fieldTypes.size > 1) {
@@ -759,37 +758,51 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
if (selectedType?.familyRequired == true) {
Text("Clasificación técnica", fontWeight = FontWeight.Bold)
OutlinedTextField(
value = familySearch,
onValueChange = { familySearch = it },
label = { Text("Buscar clasificación") },
leadingIcon = { Icon(Icons.Filled.Search, null) },
supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
filteredFamilies.take(12).forEach { family ->
Surface(
onClick = { selectedFamilyId = family.id },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant),
ExposedDropdownMenuBox(
expanded = familyExpanded && !model.busy,
onExpandedChange = {
if (!model.busy) {
familyExpanded = it
familySearch = ""
}
},
) {
OutlinedTextField(
value = if (familyExpanded) familySearch else selectedFamily?.name.orEmpty(),
onValueChange = { familySearch = it; familyExpanded = true },
label = { Text(if (isSubinstallation) "Tipo de subinstalación" else "Tipo de instalación") },
placeholder = { Text("Buscar por nombre o código") },
leadingIcon = { Icon(Icons.Filled.Search, null) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = familyExpanded) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
singleLine = true,
)
ExposedDropdownMenu(
expanded = familyExpanded && !model.busy,
onDismissRequest = { familyExpanded = false; familySearch = "" },
modifier = Modifier.heightIn(max = 280.dp),
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
(if (family.isOther) "Otro · " else "") + family.name,
Modifier.weight(1f),
fontWeight = if (selectedFamilyId == family.id) FontWeight.Bold else FontWeight.Normal,
if (filteredFamilies.isEmpty()) {
DropdownMenuItem(text = { Text("Sin coincidencias") }, onClick = {}, enabled = false)
}
filteredFamilies.forEach { family ->
DropdownMenuItem(
text = { Text((if (family.isOther) "Otro · " else "") + family.name) },
trailingIcon = {
if (selectedFamilyId == family.id) Icon(Icons.Filled.CheckCircle, "Seleccionado")
},
onClick = {
selectedFamilyId = family.id
familyExpanded = false
familySearch = ""
keyboard?.hide()
focusManager.clearFocus()
},
)
if (selectedFamilyId == family.id) Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
}
}
}
if (filteredFamilies.size > 12) {
Text("Seguí escribiendo para reducir la lista.", style = MaterialTheme.typography.bodySmall)
}
selectedFamily?.let { family ->
if (family.isOther) {
Text("Quedará marcado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary)
@@ -824,7 +837,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
minLines = 2,
)
selectedType?.attributes?.forEach { definition ->
editableAttributes.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
@@ -853,10 +866,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
}
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val attributesReady = editableAttributes
.filter { it.isRequired }
.all { attributeValues[it.code].orEmpty().isNotBlank() }
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = { requestCreate() },
@@ -886,7 +898,7 @@ private fun ModernInventoryBrowse(
) {
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("instalacion", "subinstalacion") }
val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("yacimiento", "instalacion", "subinstalacion") }
Column(Modifier.fillMaxSize().padding(horizontal = 18.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = onStartSubinstallation, modifier = Modifier.weight(1f)) {
@@ -904,7 +916,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),
@@ -934,7 +946,7 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
StatusPill(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación")
StatusPill(when (typeCode) { "subinstalacion" -> "Subinstalación"; "yacimiento" -> "Yacimiento"; else -> "Instalación" })
}
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text(
@@ -946,11 +958,11 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Seleccionar para Hallazgo")
}
if (typeCode == "instalacion") {
if (typeCode in setOf("yacimiento", "instalacion")) {
OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp))
Text("Agregar Subinstalación")
Text(if (typeCode == "yacimiento") "Agregar Instalación" else "Agregar Subinstalación")
}
}
}
@@ -0,0 +1,99 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.data.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@Composable
internal fun TechnicalFieldsButton(assetId: String, enabled: Boolean) {
val context = LocalContext.current
val repository = remember { TechnicalRepository(context) }
val scope = rememberCoroutineScope()
var open by rememberSaveable(assetId) { mutableStateOf(false) }
var busy by remember(assetId) { mutableStateOf(false) }
var snapshot by remember(assetId) { mutableStateOf<TechnicalValues?>(null) }
var error by remember(assetId) { mutableStateOf<String?>(null) }
var saved by remember(assetId) { mutableStateOf(false) }
val values = remember(assetId) { mutableStateMapOf<String, String>() }
OutlinedButton(onClick = { open = true }, enabled = enabled,
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp)) {
Text(if (saved) "Datos técnicos guardados · Revisar" else "Completar datos técnicos")
}
if (!open) return
LaunchedEffect(assetId, open) {
busy = true; error = null; snapshot = null
try {
val loaded = repository.get(assetId)
snapshot = loaded
values.clear()
loaded.values.forEach { (id, value) -> values[id] = value?.toString().orEmpty() }
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
AlertDialog(
onDismissRequest = { if (!busy) open = false },
title = { Text(snapshot?.family?.name ?: "Datos técnicos") },
text = {
Column(Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Son los mismos campos de la clasificación administrada en oficina. Los marcados con * son obligatorios.")
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
snapshot?.definitions?.filter { it.isActive }?.forEach { definition ->
val label = definition.name + (if (definition.isRequired) " *" else "") + (definition.unit?.let { " ($it)" } ?: "")
val current = values[definition.id].orEmpty()
if (definition.dataType in setOf("BOOLEAN", "SELECT")) {
Text(label)
val options = if (definition.dataType == "BOOLEAN") listOf("true", "false") else definition.options.orEmpty()
options.forEach { option ->
FilterChip(selected = current == option, enabled = !busy,
onClick = { values[definition.id] = option },
label = { Text(if (definition.dataType == "BOOLEAN") if (option == "true") "" else "No" else option) })
}
if (!definition.isRequired) TextButton(enabled = !busy, onClick = { values[definition.id] = "" }) { Text("Sin dato") }
} else {
OutlinedTextField(value = current, onValueChange = { values[definition.id] = it },
label = { Text(label) }, enabled = !busy, modifier = Modifier.fillMaxWidth(),
supportingText = {
when (definition.dataType) {
"DATE" -> Text("AAAA-MM-DD")
"DATETIME" -> Text("AAAA-MM-DDTHH:MM:SS-03:00")
else -> Unit
}
})
}
}
if (snapshot?.definitions?.none { it.isActive } == true) Text("Esta clasificación no tiene campos técnicos activos.")
}
},
confirmButton = {
TextButton(enabled = !busy && snapshot != null, onClick = {
val current = snapshot ?: return@TextButton
busy = true; error = null
scope.launch {
try {
val payload = current.definitions.filter { it.isActive }.mapNotNull { definition ->
technicalInputValue(definition, values[definition.id].orEmpty())?.let { definition.id to it }
}.toMap()
snapshot = repository.save(assetId, payload)
saved = true; open = false
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
}) { Text("Guardar datos") }
},
dismissButton = { TextButton(enabled = !busy, onClick = { open = false }) { Text("Cerrar") } },
)
}
@@ -0,0 +1,24 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import org.junit.Assert.*
import org.junit.Test
class FieldCoordinatesTest {
@Test fun realGpsIsRoundedToApiPrecision() {
assertEquals(-32.889459, FieldCoordinates.latitude(-32.889458762), 0.0)
assertEquals(-68.845839, FieldCoordinates.longitude(-68.845838912), 0.0)
assertEquals(4.123, FieldCoordinates.accuracy(4.123456789), 0.0)
}
@Test fun boundsAndZeroRemainValid() {
assertEquals(90.0, FieldCoordinates.latitude(90.0), 0.0)
assertEquals(-180.0, FieldCoordinates.longitude(-180.0), 0.0)
assertEquals(0.0, FieldCoordinates.accuracy(0.0), 0.0)
}
@Test fun invalidCoordinatesAreRejected() {
for (value in listOf(Double.NaN, Double.POSITIVE_INFINITY, 90.1, -90.1)) {
try { FieldCoordinates.latitude(value); fail("Invalid latitude accepted") }
catch (_: IllegalArgumentException) { }
}
try { FieldCoordinates.accuracy(-1.0); fail("Negative accuracy accepted") }
catch (_: IllegalArgumentException) { }
}
}
@@ -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\""))
}
@@ -38,14 +45,16 @@ class FinalFieldFlowContractTest {
@Test
fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"yacimiento\", \"instalacion\", \"subinstalacion\")"))
}
@Test
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"))
@@ -0,0 +1,105 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import java.io.IOException
import kotlinx.coroutines.*
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
class MobileSessionCoordinatorTest {
private fun old() = StoredSession("u1", "inspector", "Inspector", "a1", "r1")
private fun response() = MobileSessionResponse(MobileUser("u1", "inspector"), "a2", "r2", 900)
private fun http(code: Int) = HttpException(Response.error<Unit>(code, "error".toResponseBody()))
private class Store(var value: StoredSession?) {
var clears = 0
fun load() = value
fun clear() { clears++; value = null }
fun save(r: MobileSessionResponse): StoredSession =
StoredSession(r.user.id, r.user.username, r.user.username, r.accessToken, r.refreshToken).also { value = it }
}
@Test fun concurrentClientsRotateTheTokenOnlyOnce() = runBlocking {
val previous = old(); val store = Store(previous); var calls = 0
val results = coroutineScope {
(1..4).map {
async {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
calls++; delay(20); response()
}
}
}.awaitAll()
}
assertEquals(1, calls)
assertTrue(results.all { it.refreshToken == "r2" })
assertEquals(0, store.clears)
}
@Test fun networkAndServerFailuresKeepSessionForRetry() = runBlocking {
for (failure in listOf(IOException("offline"), http(500), http(429))) {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw failure }
fail("Expected request failure")
} catch (caught: Exception) { assertSame(failure, caught) }
assertEquals(previous, store.value)
assertEquals(0, store.clears)
}
}
@Test fun invalidRefreshClearsOnlyTheExpiredSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw http(401) }
fail("Expected unauthorized")
} catch (caught: HttpException) { assertEquals(401, caught.code()) }
assertNull(store.value)
assertEquals(1, store.clears)
}
@Test fun cancelledRefreshKeepsSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw CancellationException("cancel") }
fail("Expected cancellation")
} catch (_: CancellationException) { }
assertEquals(previous, store.value)
}
@Test fun logoutDuringRefreshCannotBeUndone() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.clear(); response()
}
fail("Must reject a refresh after logout")
} catch (_: IllegalStateException) { }
assertNull(store.value)
}
@Test fun staleRequestCannotUseAnotherInspectorsSession() = runBlocking {
val previous = old(); val store = Store(previous.copy(userId = "u2", refreshToken = "other"))
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
fail("No refresh allowed for another inspector"); response()
}
fail("Must reject account switch")
} catch (_: IllegalStateException) { }
assertEquals("u2", store.value?.userId)
}
@Test fun lateUnauthorizedDoesNotClearANewLogin() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.value = previous.copy(refreshToken = "new-login")
throw http(401)
}
} catch (_: HttpException) { }
assertEquals("new-login", store.value?.refreshToken)
assertEquals(0, store.clears)
}
}
@@ -0,0 +1,28 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.*
import org.junit.Test
class OperationTrackerTest {
@Test fun doubleTapCannotQueueASecondWrite() {
val tracker = OperationTracker()
assertTrue(tracker.begin(mutation = true))
assertFalse(tracker.begin(mutation = true))
tracker.end()
assertFalse(tracker.busy)
assertTrue(tracker.begin(mutation = true))
}
@Test fun BusyRemainsUntilEveryReadCompletes() {
val tracker = OperationTracker()
assertTrue(tracker.begin(false)); assertTrue(tracker.begin(false))
tracker.end(); assertTrue(tracker.busy)
assertFalse(tracker.begin(true))
tracker.end(); assertFalse(tracker.busy)
}
@Test fun readDuringWriteDoesNotReleaseItsGuardEarly() {
val tracker = OperationTracker()
assertTrue(tracker.begin(true)); assertTrue(tracker.begin(false))
tracker.end(); assertFalse(tracker.begin(true))
tracker.end(); assertTrue(tracker.begin(true))
}
}
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(28, BuildConfig.VERSION_CODE)
assertEquals("0.19.0-debug", BuildConfig.VERSION_NAME)
assertEquals(32, BuildConfig.VERSION_CODE)
assertEquals("0.19.4-debug", BuildConfig.VERSION_NAME)
}
@Test
@@ -0,0 +1,28 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import org.junit.Assert.*
import org.junit.Test
class TechnicalValuesTest {
private fun field(type: String, required: Boolean = false) = TechnicalDefinition("id", "code", "Campo", type, required, options = listOf("A", "B"))
@Test fun decimalCommaAndBooleanFalseKeepTheirTypes() {
assertEquals(12.5, technicalInputValue(field("NUMBER"), "12,5"))
assertEquals(false, technicalInputValue(field("BOOLEAN", true), "false"))
assertEquals(0.0, technicalInputValue(field("NUMBER", true), "0"))
}
@Test fun optionalBlankIsOmittedButRequiredBlankFails() {
assertNull(technicalInputValue(field("TEXT"), " "))
try { technicalInputValue(field("TEXT", true), ""); fail("Required missing") }
catch (_: IllegalArgumentException) { }
}
@Test fun invalidNumbersDatesAndOptionsFailBeforeSave() {
for ((type, value) in listOf("NUMBER" to "NaN", "DATE" to "2026-02-30", "SELECT" to "C", "BOOLEAN" to "quizás")) {
try { technicalInputValue(field(type), value); fail("Invalid value accepted") }
catch (_: IllegalArgumentException) { }
}
}
@Test fun validDatesAndSelectRemainCanonical() {
assertEquals("2026-09-14", technicalInputValue(field("DATE"), "2026-09-14"))
assertEquals("A", technicalInputValue(field("SELECT"), "A"))
assertEquals("2026-09-14T13:00:00Z", technicalInputValue(field("DATETIME"), "2026-09-14T10:00:00-03:00"))
}
}
+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(
@@ -0,0 +1,407 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
type DossierRecord = Record<string, any>;
@Injectable()
export class ActivityDossierService {
private readonly logger = new Logger(ActivityDossierService.name);
constructor(private readonly dataSource: DataSource) {}
async dossier(id: string): Promise<Record<string, unknown>> {
const assetRows = (await this.dataSource.query(
`SELECT id, code, name, common_name AS "commonName", created_at AS "createdAt"
FROM assets
WHERE id = $1`,
[id],
)) as Array<{ id: string; code: string; name: string; commonName: string | null; createdAt: Date }>;
const asset = assetRows[0];
if (!asset) {
throw new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
const failedFacets: string[] = [];
const safeQuery = async (facet: string, sql: string): Promise<DossierRecord[]> => {
try {
return (await this.dataSource.query(sql, [id])) as DossierRecord[];
} catch (error) {
failedFacets.push(facet);
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`No se pudo cargar la faceta ${facet} del expediente ${id}: ${message}`);
return [];
}
};
const [
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
media,
versions,
] = await Promise.all([
safeQuery('visits', `
SELECT visit.id, visit.code, visit.status,
visit.planned_start_at AS "plannedStartAt",
visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt",
visit.created_at AS "createdAt"
FROM inspection_visits visit
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_visit_assets visit_asset
WHERE visit_asset.visit_id = visit.id
AND visit_asset.asset_id = $1
AND visit_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_act_assets act_asset
ON act_asset.act_id = act.id AND act_asset.included = true
WHERE act.visit_id = visit.id
AND act_asset.asset_id = $1
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_findings finding ON finding.act_id = act.id
WHERE act.visit_id = visit.id
AND finding.asset_id = $1
)
ORDER BY COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at) DESC
LIMIT 200
`),
safeQuery('acts', `
SELECT act.id, act.visit_id AS "visitId", act.code, act.status,
act.occurred_at AS "occurredAt", act.title, act.summary,
act.closed_at AS "closedAt", act.current_version AS "currentVersion",
visit.code AS "visitCode"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY act.occurred_at DESC
LIMIT 200
`),
safeQuery('findings', `
SELECT finding.id, finding.act_id AS "actId", finding.code, finding.status,
finding.title, finding.description,
finding.correction_due_on AS "correctionDueOn",
finding.company_response_received_on AS "companyResponseReceivedOn",
finding.next_control_on AS "nextControlOn",
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
act.code AS "actCode", act.occurred_at AS "actOccurredAt",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id = finding.act_id
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE finding.asset_id = $1
ORDER BY finding.created_at DESC
LIMIT 500
`),
safeQuery('evidence', `
SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId",
evidence.kind, evidence.purpose, evidence.original_name AS "originalName",
evidence.title, evidence.description, evidence.captured_at AS "capturedAt",
evidence.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_evidence evidence
JOIN inspection_findings finding ON finding.id = evidence.finding_id
WHERE finding.asset_id = $1
ORDER BY COALESCE(evidence.captured_at, evidence.created_at) DESC
LIMIT 500
`),
safeQuery('communications', `
SELECT communication.id, communication.finding_id AS "findingId",
communication.direction, communication.channel, communication.type,
communication.occurred_at AS "occurredAt", communication.subject,
communication.details, communication.contact_name AS "contactName",
communication.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_communications communication
JOIN inspection_findings finding ON finding.id = communication.finding_id
WHERE finding.asset_id = $1
ORDER BY communication.occurred_at DESC
LIMIT 500
`),
safeQuery('verificationResults', `
SELECT verification_link.id,
verification_link.finding_id AS "findingId",
verification_link.visit_id AS "visitId",
verification_link.target_control_on AS "targetControlOn",
verification_link.outcome,
verification_link.result_notes AS "resultNotes",
verification_link.verified_at AS "verifiedAt",
verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn",
finding.code AS "findingCode", finding.title AS "findingTitle",
visit.code AS "visitCode", visit.status AS "visitStatus",
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence verification_evidence
WHERE verification_evidence.finding_id = finding.id
AND verification_evidence.verification_visit_id = visit.id
AND verification_evidence.purpose = 'VERIFICATION') AS "evidenceCount"
FROM inspection_finding_verification_visits verification_link
JOIN inspection_findings finding ON finding.id = verification_link.finding_id
JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE finding.asset_id = $1 AND verification_link.outcome IS NOT NULL
ORDER BY verification_link.verified_at DESC NULLS LAST, verification_link.result_recorded_at DESC
LIMIT 500
`),
safeQuery('documents', `
SELECT document.id, document.document_type AS "documentType",
document.document_number AS "documentNumber", document.title,
document.issuer, document.document_date AS "documentDate",
document.external_reference AS "externalReference",
link.relation_type AS "relationType", link.notes,
link.created_at AS "linkedAt"
FROM asset_source_documents link
JOIN source_documents document ON document.id = link.document_id
WHERE link.asset_id = $1
ORDER BY COALESCE(document.document_date::timestamptz, link.created_at) DESC
LIMIT 300
`),
safeQuery('inspectionReports', `
SELECT report.id, report.code, report.status,
report.pdf_status AS "pdfStatus", report.title,
report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256",
act.id AS "actId", act.code AS "actCode",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_reports report
JOIN inspection_acts act ON act.id = report.act_id
JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY report.generated_at DESC
LIMIT 200
`),
safeQuery('media', `
SELECT media.id, media.kind, media.original_name AS "originalName",
media.title, media.description, media.captured_at AS "capturedAt",
media.created_at AS "createdAt", media.source
FROM asset_media media
WHERE media.asset_id = $1 AND media.deleted_at IS NULL
ORDER BY COALESCE(media.captured_at, media.created_at) DESC
LIMIT 500
`),
safeQuery('versions', `
SELECT version.id, version.version_number AS "versionNumber",
version.change_type AS "changeType", version.changed_fields AS "changedFields",
version.occurred_at AS "occurredAt", version.actor_username AS "actorUsername",
version.source
FROM asset_versions version
WHERE version.asset_id = $1
ORDER BY version.occurred_at DESC
LIMIT 500
`),
]);
const timeline: DossierRecord[] = [];
const push = (event: DossierRecord) => timeline.push(event);
versions.forEach((version) => push({
id: `version:${String(version.id)}`,
kind: 'INVENTORY_CHANGE',
occurredAt: version.occurredAt,
title: version.changeType === 'CREATED' || version.changeType === 'BASELINE'
? 'Registro incorporado al inventario'
: 'Inventario actualizado',
description: Array.isArray(version.changedFields) && version.changedFields.length > 0
? `Campos: ${(version.changedFields as string[]).join(', ')}`
: null,
meta: {
versionNumber: version.versionNumber,
changeType: version.changeType,
actorUsername: version.actorUsername,
source: version.source,
},
}));
visits.forEach((visit) => push({
id: `visit:${String(visit.id)}`,
kind: 'INSPECTION',
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
title: `Inspección ${String(visit.code)}`,
description: null,
href: `/inspecciones/${String(visit.id)}`,
meta: { status: visit.status },
}));
acts.forEach((act) => push({
id: `act:${String(act.id)}`,
kind: 'ACT',
occurredAt: act.occurredAt,
title: `Acta ${String(act.code)}`,
description: act.title,
href: `/inspecciones/actas/${String(act.id)}`,
meta: { status: act.status, visitCode: act.visitCode },
}));
findings.forEach((finding) => {
push({
id: `finding:${String(finding.id)}`,
kind: 'FINDING',
occurredAt: finding.createdAt,
title: `Hallazgo ${String(finding.code)}`,
description: finding.title,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status, actCode: finding.actCode },
});
if (finding.closedAt) {
push({
id: `finding-close:${String(finding.id)}`,
kind: 'FINDING_CLOSED',
occurredAt: finding.closedAt,
title: `Hallazgo ${String(finding.code)} cerrado`,
description: finding.closureNotes,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status },
});
}
});
verificationResults.forEach((verification) => push({
id: `verification:${String(verification.id)}`,
kind: 'VERIFICATION',
occurredAt: verification.verifiedAt ?? verification.resultRecordedAt,
title: verification.outcome === 'RESOLVED'
? `Verificación conforme · ${String(verification.findingCode)}`
: verification.outcome === 'NOT_RESOLVED'
? `Verificación no conforme · ${String(verification.findingCode)}`
: `Verificación reprogramada · ${String(verification.findingCode)}`,
description: verification.resultNotes,
href: `/hallazgos/${String(verification.findingId)}`,
meta: {
outcome: verification.outcome,
visitCode: verification.visitCode,
targetControlOn: verification.targetControlOn,
rescheduledControlOn: verification.rescheduledControlOn,
evidenceCount: verification.evidenceCount,
},
}));
communications.forEach((communication) => push({
id: `communication:${String(communication.id)}`,
kind: 'COMMUNICATION',
occurredAt: communication.occurredAt,
title: communication.type === 'COMPANY_RESPONSE'
? 'Respuesta de la empresa'
: String(communication.subject),
description: communication.details,
href: `/hallazgos/${String(communication.findingId)}`,
meta: {
findingCode: communication.findingCode,
direction: communication.direction,
channel: communication.channel,
},
}));
evidence.forEach((item) => push({
id: `evidence:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía / evidencia' : 'Documento incorporado',
description: item.title ?? item.originalName,
href: `/hallazgos/${String(item.findingId)}`,
meta: { findingCode: item.findingCode, purpose: item.purpose },
}));
inspectionReports.forEach((report) => push({
id: `inspection-report:${String(report.id)}`,
kind: 'REPORT',
occurredAt: report.generatedAt,
title: `Informe ${String(report.code)}`,
description: report.title,
href: `/informes/${String(report.id)}`,
meta: { status: report.status, pdfStatus: report.pdfStatus, actCode: report.actCode },
}));
documents.forEach((document) => push({
id: `source-document:${String(document.id)}`,
kind: document.documentType === 'TECHNICAL_REPORT' ? 'REPORT' : 'SOURCE_DOCUMENT',
occurredAt: document.documentDate ?? document.linkedAt,
title: String(document.title),
description: document.documentNumber
? `Documento ${String(document.documentNumber)}`
: 'Documento vinculado al inventario',
meta: { documentType: document.documentType, relationType: document.relationType },
}));
media.forEach((item) => push({
id: `asset-media:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía del inventario' : 'Archivo del inventario',
description: item.title ?? item.originalName,
meta: { source: item.source },
}));
timeline.sort(
(a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime(),
);
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
return {
asset: {
id: asset.id,
code: asset.code,
name: asset.name,
commonName: asset.commonName,
},
counters: {
inspections: visits.length,
acts: acts.length,
findings: findings.length,
verifications: verificationResults.length,
openFindings,
closedFindings,
evidence: evidence.length,
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
photos: evidence.filter((item) => item.kind === 'PHOTO').length
+ media.filter((item) => item.kind === 'PHOTO').length,
reports: reports.length + inspectionReports.length,
},
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
reports,
media,
versions,
timeline: timeline.slice(0, 500),
warnings: failedFacets,
};
}
}
@@ -32,6 +32,7 @@ import { InventoryTechnicalValuesService } from './inventory-technical-values.se
import { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service';
import { ActivityDossierService } from './activity-dossier.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service';
@@ -66,6 +67,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService,
ActivityDossierService,
MergedInventoryDossierService,
AssetGeometriesService,
AssetHistoryService,
@@ -1,10 +1,13 @@
import { Injectable } from '@nestjs/common';
import { ActivityDossierService } from './activity-dossier.service';
import { AssetsService } from './assets.service';
import { InventoryFunctionService } from './inventory-function.service';
import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>;
const MERGEABLE_DOSSIER_TYPES = new Set(['instalacion', 'subinstalacion']);
function dedupeById<T extends LooseRecord>(items: T[]): T[] {
const seen = new Set<string>();
const result: T[] = [];
@@ -29,11 +32,22 @@ function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[
export class MergedInventoryDossierService {
constructor(
private readonly assets: AssetsService,
private readonly activity: ActivityDossierService,
private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService,
) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
const requestedAsset = await this.assets.getById(requestedAssetId);
const requestedTypeCode = requestedAsset.type.code.trim().toLowerCase();
// La conciliación/fusión existe sólo para Instalaciones y Subinstalaciones.
// Los registros territoriales (Departamento, Área, Yacimiento) deben poder
// abrir su Actividad sin depender del subsistema de merge.
if (!MERGEABLE_DOSSIER_TYPES.has(requestedTypeCode)) {
return await this.activity.dossier(requestedAssetId);
}
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
const canonical = mergeStatus.canonical as LooseRecord;
const requested = mergeStatus.requested as LooseRecord;
@@ -44,7 +58,7 @@ export class MergedInventoryDossierService {
.filter((value, index, all) => all.indexOf(value) === index);
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
const dossier = await this.assets.dossier(assetId) as LooseRecord;
const dossier = await this.activity.dossier(assetId) as LooseRecord;
const identity = dossier.asset as LooseRecord;
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier };
@@ -145,6 +159,9 @@ export class MergedInventoryDossierService {
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const warnings = dossiers.flatMap(({ assetId, dossier }) =>
((dossier.warnings ?? []) as string[]).map((facet) => `${assetId}:${facet}`),
);
return {
asset: {
@@ -194,6 +211,7 @@ export class MergedInventoryDossierService {
media,
versions,
timeline: timeline.slice(0, 1000),
warnings,
};
}
}
+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 = 28/);
assert.match(gradle, /versionName = "0\.19\.0"/);
assert.match(gradle, /versionCode = 32/);
assert.match(gradle, /versionName = "0\.19\.4"/);
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\)/);
});
@@ -0,0 +1,47 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { ActivityDossierService } from '../../src/asset-master/activity-dossier.service';
test('F7 Actividad no usa DISTINCT con ORDER BY COALESCE en visitas', () => {
const source = readFileSync('src/asset-master/activity-dossier.service.ts', 'utf8');
assert.doesNotMatch(source, /SELECT\s+DISTINCT\s+visit\.id/i);
assert.match(source, /ORDER BY COALESCE\(visit\.actual_started_at, visit\.planned_start_at, visit\.created_at\) DESC/);
assert.match(source, /EXISTS \(\s*SELECT 1\s*FROM inspection_visit_assets/s);
});
test('F7 una faceta auxiliar defectuosa no derriba todo el expediente', async () => {
const dataSource = {
query: async (sql: string) => {
if (sql.includes('FROM assets\n WHERE id = $1')) {
return [{
id: 'bcba740f-cc84-48b3-8d06-88a9c439e25c',
code: 'YAC-0005',
name: 'Agua Botada',
commonName: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
}];
}
if (sql.includes('inspection_finding_communications')) {
throw new Error('simulated optional facet failure');
}
return [];
},
};
const service = new ActivityDossierService(dataSource as never);
const dossier = await service.dossier('bcba740f-cc84-48b3-8d06-88a9c439e25c') as {
asset: { code: string; name: string };
counters: { inspections: number; findings: number };
warnings: string[];
timeline: unknown[];
};
assert.equal(dossier.asset.code, 'YAC-0005');
assert.equal(dossier.asset.name, 'Agua Botada');
assert.equal(dossier.counters.inspections, 0);
assert.equal(dossier.counters.findings, 0);
assert.deepEqual(dossier.warnings, ['communications']);
assert.deepEqual(dossier.timeline, []);
});
@@ -0,0 +1,26 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
const mergedDossier = readFileSync('src/asset-master/merged-inventory-dossier.service.ts', 'utf8');
const dossierPanel = readFileSync('../web-v2/src/features/assets/AssetDossierPanel.tsx', 'utf8');
const simpleDetail = readFileSync('../web-v2/src/pages/SimpleInventoryDetailPage.tsx', 'utf8');
test('F7 mantiene Actividad disponible para Yacimiento', () => {
assert.match(simpleDetail, /kind === 'YACIMIENTO'/);
assert.match(simpleDetail, /setTab\('activity'\)/);
assert.match(simpleDetail, /AssetDossierPanel assetId=\{asset\.id\}/);
});
test('F7 no hace depender el dossier territorial del subsistema de fusión', () => {
assert.match(mergedDossier, /MERGEABLE_DOSSIER_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/);
assert.match(mergedDossier, /if \(!MERGEABLE_DOSSIER_TYPES\.has\(requestedTypeCode\)\)/);
assert.match(mergedDossier, /private readonly activity: ActivityDossierService/);
assert.match(mergedDossier, /return await this\.activity\.dossier\(requestedAssetId\)/);
});
test('F7 la consulta auxiliar de merge nunca bloquea la carga de Actividad', () => {
assert.match(dossierPanel, /getInventoryMergeStatus\(assetId\)\.catch\(\(\) => null\)/);
assert.match(dossierPanel, /getAssetDossier\(assetId\)/);
});
+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
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP="/var/www/dhv2.korexlabs.com"
BACKUP_ROOT="/root/DH_V2_BACKUPS"
LOCK="/var/lock/dhv2-deploy.lock"
exec 9>"$LOCK"
if ! flock -n 9; then
echo "Otro deploy de DH V2 ya está en curso. No se realiza ninguna modificación."
exit 0
fi
DEPLOY_REF="${DHV2_DEPLOY_REF:-deploy}"
STAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
STAGE="/root/dhv2-gitea-stage-${STAMP}"
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
API_TEST_IMAGE="dhv2-api:gitea-${STAMP}"
WEB_TEST_IMAGE="dhv2-web:gitea-${STAMP}"
PHASE="bootstrap"
PREV_SHA=""
TARGET_SHA=""
EXPECTED_API_VERSION=""
EXPECTED_WEB_VERSION=""
APP_TOUCHED=0
cd "$APP"
exec > >(tee -a "$LOG") 2>&1
cleanup() {
set +e
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
rm -rf "$STAGE"
docker image rm "$API_TEST_IMAGE" "$WEB_TEST_IMAGE" >/dev/null 2>&1 || true
}
publish_status() {
local rc="${1:-1}"
set +e
local outcome="failure"
[ "$rc" -eq 0 ] && outcome="success"
local current="unknown"
current="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
local status_file log_file status_blob log_blob tree commit
status_file="$(mktemp /tmp/dhv2-status.XXXXXX)"
log_file="$(mktemp /tmp/dhv2-log.XXXXXX)"
{
echo "status=$outcome"
echo "exit_code=$rc"
echo "phase=$PHASE"
echo "timestamp=$(date --iso-8601=seconds)"
echo "deploy_ref=$DEPLOY_REF"
echo "previous_sha=${PREV_SHA:-unknown}"
echo "target_sha=${TARGET_SHA:-unknown}"
echo "current_sha=$current"
echo "api_version=${EXPECTED_API_VERSION:-unknown}"
echo "web_version=${EXPECTED_WEB_VERSION:-unknown}"
echo "app_touched=$APP_TOUCHED"
echo "backup=${BACKUP:-unknown}"
} > "$status_file"
tail -n 500 "$LOG" > "$log_file" 2>/dev/null || true
status_blob="$(git hash-object -w "$status_file" 2>/dev/null || true)"
log_blob="$(git hash-object -w "$log_file" 2>/dev/null || true)"
if [ -n "$status_blob" ] && [ -n "$log_blob" ]; then
tree="$(printf '100644 blob %s\tdeploy.log\n100644 blob %s\tstatus.txt\n' "$log_blob" "$status_blob" | git mktree 2>/dev/null || true)"
if [ -n "$tree" ]; then
commit="$(printf 'deploy-status: %s · phase %s\n' "$outcome" "$PHASE" | git -c user.name='DH V2 Deploy Bot' -c user.email='deploy@dhv2.local' commit-tree "$tree" 2>/dev/null || true)"
[ -z "$commit" ] || git push --force origin "$commit:refs/heads/deploy-status" >/dev/null 2>&1 || true
fi
fi
rm -f "$status_file" "$log_file"
}
on_exit() {
local rc=$?
trap - EXIT ERR
cleanup
publish_status "$rc"
exit "$rc"
}
trap on_exit EXIT
rollback() {
local rc=$?
trap - ERR
PHASE="rollback"
echo
echo "============================================================"
echo " DH V2 · DEPLOY FALLÓ · ROLLBACK"
echo "============================================================"
cd "$APP"
if [ "$APP_TOUCHED" -eq 1 ] && [ -n "${PREV_SHA:-}" ]; then
echo "Restaurando aplicación al commit previo: $PREV_SHA"
git reset --hard "$PREV_SHA" || true
docker compose build api web </dev/null || true
docker compose up -d --no-deps --force-recreate api web </dev/null || true
else
echo "El candidato falló antes de modificar producción; no se reconstruye ni reinicia la aplicación activa."
fi
echo
echo "Estado actual:"
docker compose ps -a </dev/null || true
if [ "$APP_TOUCHED" -eq 1 ]; then
echo
echo "Últimos logs:"
docker compose logs --tail=160 api web </dev/null || true
fi
echo
if [ -d "$BACKUP" ]; then
echo "Backup PRE disponible en: $BACKUP"
echo "Las migraciones son forward-only; database-before.dump queda disponible para restauración manual si hiciera falta."
else
echo "No fue necesario crear backup PRE: el fallo ocurrió durante el preflight del candidato, antes de tocar producción."
fi
exit "$rc"
}
trap rollback ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY DESDE GITEA · $DEPLOY_REF"
echo "============================================================"
for cmd in git docker curl tar node; do
command -v "$cmd" >/dev/null || { echo "ERROR: falta $cmd"; false; }
done
[ -d .git ] || { echo "ERROR: $APP no es repositorio Git"; false; }
[ -f .env ] || { echo "ERROR: falta $APP/.env"; false; }
ORIGIN_URL="$(git remote get-url origin)"
EXPECTED_ORIGIN="https://git.korexlabs.com.ar/admin/dh-inspeccion-v2.git"
if [ "$ORIGIN_URL" != "$EXPECTED_ORIGIN" ]; then
echo "ERROR: origin no apunta al Gitea autorizado."
echo "Actual: $ORIGIN_URL"
echo "Esperado: $EXPECTED_ORIGIN"
false
fi
git config --global --get-all safe.directory 2>/dev/null | grep -Fxq "$APP" || git config --global --add safe.directory "$APP"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "ERROR: hay cambios locales versionados en producción."
git status --short
false
fi
PREV_SHA="$(git rev-parse HEAD)"
PHASE="fetch"
git fetch origin "$DEPLOY_REF"
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
echo "Actual: $PREV_SHA"
echo "Objetivo: $TARGET_SHA"
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
echo "Producción ya está en el commit autorizado."
PHASE="complete"
exit 0
fi
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
false
fi
PHASE="candidate-preflight"
rm -rf "$STAGE"
git worktree add --detach "$STAGE" "$TARGET_SHA" >/dev/null
EXPECTED_API_VERSION="$(node -p "require('$STAGE/api-v3/package.json').version")"
EXPECTED_WEB_VERSION="$(node -p "require('$STAGE/web-v2/package.json').version")"
echo "API candidata: $EXPECTED_API_VERSION"
echo "WEB candidata: $EXPECTED_WEB_VERSION"
docker compose --env-file "$APP/.env" -f "$STAGE/docker-compose.yml" config >/dev/null
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
echo
echo "========== TEST API CANDIDATA =========="
docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" </dev/null
docker run --rm \
-v "$STAGE/api-v3/test:/app/test:ro" \
-v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
-v "$STAGE/docker-compose.yml:/docker-compose.yml:ro" \
-v "$STAGE/web-v2:/web-v2:ro" \
-v "$STAGE/android-app:/android-app:ro" \
"$API_TEST_IMAGE" npm test </dev/null
echo
echo "========== BUILD WEB CANDIDATA =========="
docker build -t "$WEB_TEST_IMAGE" "$STAGE/web-v2" </dev/null
PHASE="backup"
echo
echo "========== BACKUP PRE =========="
install -d -m 700 "$BACKUP"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-before.dump"
tar \
--exclude='./.git' \
--exclude='./.env' \
--exclude='*/node_modules' \
--exclude='*/dist' \
--exclude='*.zip' \
--exclude='*.tar.gz' \
--exclude='*.tgz' \
-czf "$BACKUP/source-before.tar.gz" .
install -m 600 .env "$BACKUP/.env"
git rev-parse HEAD > "$BACKUP/previous.sha"
printf '%s\n' "$TARGET_SHA" > "$BACKUP/target.sha"
docker compose ps -a > "$BACKUP/docker-before.txt"
(
cd "$BACKUP"
sha256sum database-before.dump source-before.tar.gz .env previous.sha target.sha docker-before.txt > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="fast-forward"
echo
echo "========== FAST-FORWARD =========="
git log --oneline --no-decorate "$PREV_SHA..$TARGET_SHA"
APP_TOUCHED=1
git merge --ff-only "origin/$DEPLOY_REF"
PHASE="build"
echo
echo "========== BUILD PRODUCCIÓN =========="
docker compose build api migrate web </dev/null
PHASE="migrations"
echo
echo "========== MIGRACIONES =========="
docker compose --profile tools run --rm migrate </dev/null
docker compose --profile tools run --rm migrate npm run migration:show </dev/null | tee "$BACKUP/migrations.txt"
grep -Fq 'Pending migrations: no' "$BACKUP/migrations.txt"
PHASE="recreate"
echo
echo "========== RECREATE API + WEB =========="
docker compose up -d --no-deps --force-recreate api web </dev/null
PHASE="health"
echo
echo "========== HEALTH =========="
HEALTH_OK=0
for _ in $(seq 1 60); do
if curl -fsS --max-time 5 http://127.0.0.1:3101/api/v3/health > "$BACKUP/health.json" 2>/dev/null; then
if grep -Fq '"status":"ok"' "$BACKUP/health.json" \
&& grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json" \
&& grep -Fq '"database":"ok"' "$BACKUP/health.json"; then
HEALTH_OK=1
break
fi
CURRENT_API_VERSION="$(node -e 'try { const h = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(h.version || "unknown")); } catch { process.stdout.write("invalid"); }' "$BACKUP/health.json")"
echo "API respondió pero aún no es la candidata (actual=$CURRENT_API_VERSION, esperada=$EXPECTED_API_VERSION). Reintentando..."
fi
sleep 2
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "ERROR: API candidata no pasó healthcheck/version/database dentro del plazo."
[ ! -f "$BACKUP/health.json" ] || cat "$BACKUP/health.json"
docker compose logs --tail=180 api
false
fi
cat "$BACKUP/health.json"
echo
grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json"
grep -Fq '"database":"ok"' "$BACKUP/health.json"
WEB_CODE="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:8182/)"
[ "$WEB_CODE" = "200" ] || { echo "ERROR: WEB HTTP $WEB_CODE"; false; }
PHASE="verify"
echo
echo "========== VERIFICACIÓN FINAL =========="
docker compose ps -a | tee "$BACKUP/docker-after.txt"
if docker compose ps --status running --services | grep -Fxq api && docker compose ps --status running --services | grep -Fxq web && docker compose ps --status running --services | grep -Fxq db; then
echo "Servicios críticos: OK"
else
echo "ERROR: falta un servicio crítico en ejecución."
false
fi
PHASE="post-backup"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-after.dump"
git rev-parse HEAD > "$BACKUP/deployed.sha"
printf 'API=%s\nWEB=%s\n' "$EXPECTED_API_VERSION" "$EXPECTED_WEB_VERSION" > "$BACKUP/deployed-versions.txt"
(
cd "$BACKUP"
sha256sum database-after.dump deployed.sha deployed-versions.txt health.json migrations.txt docker-after.txt >> SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="complete"
trap - ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY OK"
echo "============================================================"
echo "Commit: $TARGET_SHA"
echo "API: $EXPECTED_API_VERSION"
echo "WEB: $EXPECTED_WEB_VERSION"
echo "Backup: $BACKUP"
echo "============================================================"
+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';
@@ -81,10 +81,12 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
const load = () => {
setLoading(true);
setError('');
setMergeStatus(null);
Promise.all([
getAssetDossier(assetId),
getAsset(assetId),
getInventoryMergeStatus(assetId),
// La conciliación es auxiliar: nunca debe bloquear la Actividad del Inventario.
getInventoryMergeStatus(assetId).catch(() => null),
])
.then(([loadedDossier, loadedAsset, loadedMerge]) => {
setDossier(loadedDossier as ExtendedDossier);
@@ -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;
@@ -245,8 +245,8 @@ export function AuthoritativeInventoryConfigPage() {
{canManage && <div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<h3 style={{ marginTop: 0 }}>+ Nuevo tipo</h3>
<label className="field"><span>Nombre</span><input value={newTypeName} onChange={(event) => setNewTypeName(event.target.value)} placeholder={level === 'INSTALLATION' ? 'Ej. Planta de tratamiento' : 'Ej. Bomba centrífuga'} /></label>
{level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} />{family.name}</label>)}
{level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div className="inventory-parent-options">
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} className="inventory-parent-option"><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} /><span>{family.name}</span></label>)}
</div></div>}
<button type="button" className="button primary" disabled={saving || !newTypeName.trim()} onClick={() => void createType()}><Icon name="plus" />Crear tipo</button>
</div>}
@@ -265,10 +265,10 @@ export function AuthoritativeInventoryConfigPage() {
{selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}>
<strong>Puede estar dentro de:</strong>
<div style={{ display: 'grid', gap: 8, marginTop: 10 }}>
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="inventory-parent-options">
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} className="inventory-parent-option">
<input type="checkbox" disabled={!canManage || saving} checked={selectedFamily.parentFamilyIds.includes(family.id)} onChange={() => void toggleSelectedParent(family.id)} />
{family.name}
<span>{family.name}</span>
</label>)}
</div>
</div>}
+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>
+54
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; }
@@ -1296,3 +1298,55 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
/* D5.6.4 · combobox buscable global */
.searchable-select{position:relative;width:100%;min-width:0}.searchable-select-native{position:absolute!important;inset:0;width:1px!important;height:1px!important;opacity:0;pointer-events:none}.searchable-select-trigger{display:flex;width:100%;min-height:41px;align-items:center;justify-content:space-between;gap:10px;padding:9px 11px;border:1px solid #d7dce5;border-radius:8px;background:#fff;color:var(--ink);font-size:13px;text-align:left;cursor:pointer;outline:none}.searchable-select-trigger:hover{border-color:#bcc6d6}.searchable-select-trigger:focus-visible{border-color:#6b95ed;box-shadow:0 0 0 3px rgba(40,100,220,.1)}.searchable-select-trigger:disabled{cursor:not-allowed;color:#9199a8;background:#f1f3f6}.searchable-select-trigger>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.searchable-select-trigger .placeholder{color:#7e8796}.searchable-select-trigger .icon{flex:0 0 auto;transform:rotate(90deg)}.searchable-select-popup{position:fixed;z-index:10000;max-height:315px;padding:6px;border:1px solid #ccd4e0;border-radius:10px;background:#fff;box-shadow:0 14px 40px rgba(18,31,53,.18)}.searchable-select-search{display:flex;align-items:center;gap:7px;padding:5px 7px 7px;border-bottom:1px solid var(--line)}.searchable-select-search input{width:100%;min-width:0;height:34px;padding:6px 8px;border:0;outline:0;background:transparent;color:var(--ink);font-size:12px}.searchable-select-options{max-height:245px;overflow:auto;padding-top:4px}.searchable-select-options>button{display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:var(--ink);font-size:12px;text-align:left;cursor:pointer}.searchable-select-options>button:hover,.searchable-select-options>button:focus-visible{background:#f2f5fa;outline:none}.searchable-select-options>button.selected{background:#eef4ff;color:#174ea6;font-weight:750}.searchable-select-options>button:disabled{cursor:not-allowed;color:#a0a7b3;background:transparent}.searchable-select-empty{padding:14px 10px;color:var(--muted);font-size:11px;text-align:center}.select-field>.searchable-select{width:100%}.survey-inline-select.searchable-select{min-width:130px;margin-top:6px;padding:0;border:0;background:transparent}.survey-inline-select.wide.searchable-select{min-width:175px;margin-top:0}.survey-inline-select .searchable-select-trigger{min-height:33px;padding:6px 8px;border-radius:7px;font-size:9px}.operational-context-selectors .searchable-select-trigger{min-height:36px;padding:7px 9px;font-size:10px}.parent-picker .searchable-select-trigger{border-radius:5px 5px 8px 8px}
.inspection-quick-create{max-width:980px;margin-left:auto;margin-right:auto}.inspection-quick-create .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.inspection-generated-code{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--soft)}.inspection-generated-code small{color:var(--muted);font-weight:700}.inspection-generated-code strong{font-size:15px;letter-spacing:.02em}@media(max-width:760px){.inspection-quick-create .form-grid{grid-template-columns:1fr}}
/* Parent choices need fixed-size controls even inside a generic form field. */
.inventory-parent-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
gap: 8px;
margin: 10px 0 14px;
}
.inventory-parent-option {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-width: 0;
min-height: 42px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--ink);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
cursor: pointer;
}
.inventory-parent-option input[type="checkbox"] {
flex: 0 0 17px;
width: 17px;
height: 17px;
min-height: 17px;
padding: 0;
margin: 0;
accent-color: var(--blue);
cursor: inherit;
}
.inventory-parent-option > span {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
.inventory-parent-option:has(input:checked) {
border-color: #9db9ed;
background: #eef4ff;
}
.inventory-parent-option:has(input:focus-visible) {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.inventory-parent-option:has(input:disabled) {
cursor: default;
opacity: .65;
}