Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b12144052 | ||
|
|
becf9fc050 | ||
|
|
7fe59bccd2 | ||
|
|
103ecf2fae | ||
|
|
23880521d9 | ||
|
|
637c28dc2f | ||
|
|
fc0c62c70a | ||
|
|
3ab10b37ea | ||
|
|
c6475c851e | ||
|
|
b757bf7729 | ||
|
|
e30e5d90ea | ||
|
|
d44ae049f3 | ||
|
|
78e92970ff | ||
|
|
7fcb1d42b6 | ||
|
|
e55684bf4f | ||
|
|
ddf3b158e7 | ||
|
|
8564f1933c | ||
|
|
ea890cb807 | ||
|
|
ce27cd01e9 | ||
|
|
a155d9e075 | ||
|
|
e89d815868 | ||
|
|
ff0b6d83db | ||
|
|
d894391d97 | ||
|
|
c79537def9 | ||
|
|
83c9e2ca3e | ||
|
|
9254583154 | ||
|
|
0b21e6bc50 | ||
|
|
350e26566f | ||
|
|
ba79fbf9ae | ||
|
|
77def3b511 | ||
|
|
ba01bc844e | ||
|
|
fa6d4bd06a | ||
|
|
f54e41d9c9 | ||
|
|
1b769a0ff4 | ||
|
|
5695643fa8 | ||
|
|
a6e71fa299 | ||
|
|
02091e14e6 | ||
|
|
827b172c66 | ||
|
|
45a1a08b16 | ||
|
|
3b9153f30a | ||
|
|
4f90032d00 | ||
|
|
f621677527 | ||
|
|
eeffdc441e | ||
|
|
351752256e | ||
|
|
abe7e40c99 | ||
|
|
3b7db0a869 | ||
|
|
0213c29465 | ||
|
|
fce370b287 | ||
|
|
537b0a6f42 | ||
|
|
a57d6633bd | ||
|
|
e18e86e918 | ||
|
|
cadf766d3c | ||
|
|
1b151fe964 | ||
|
|
c748aba650 | ||
|
|
b8b578a3f0 |
@@ -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:
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
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 +63,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 +109,7 @@ jobs:
|
||||
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
||||
sha256sum "$apk" > "${apk}.sha256"
|
||||
{
|
||||
echo "phase=F6.1"
|
||||
echo "phase=F6.2"
|
||||
echo "version=$version"
|
||||
echo "versionCode=$code"
|
||||
echo "commit=$GITHUB_SHA"
|
||||
@@ -130,6 +130,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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í.
|
||||
|
||||
@@ -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.
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 28
|
||||
versionName = "0.19.0"
|
||||
versionCode = 31
|
||||
versionName = "0.19.3"
|
||||
|
||||
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)
|
||||
}
|
||||
+7
-17
@@ -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
-14
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
+32
@@ -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) },
|
||||
)
|
||||
|
||||
+62
-58
@@ -72,17 +72,18 @@ fun ModernMobileActsScreen(
|
||||
val visit = model.visit ?: return
|
||||
val selected = model.selectedAct
|
||||
val closure = model.actClosure
|
||||
val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
|
||||
var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") }
|
||||
var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) }
|
||||
var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") }
|
||||
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) }
|
||||
var position by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.position.orEmpty()) }
|
||||
var email by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.email.orEmpty()) }
|
||||
var phone by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.phone.orEmpty()) }
|
||||
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||
var attendance by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT") }
|
||||
var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) }
|
||||
var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") }
|
||||
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) }
|
||||
var position by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.position.orEmpty()) }
|
||||
var email by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.email.orEmpty()) }
|
||||
var phone by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.phone.orEmpty()) }
|
||||
var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) }
|
||||
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
|
||||
@@ -195,7 +196,7 @@ fun ModernMobileActsScreen(
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(act.code, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"${modernActStatusLabel(act.status)} · ${if (act.urgency == "URGENT") "Urgente" else "No urgente"}",
|
||||
"${modernActStatusLabel(act.status)} · ${when (act.urgency) { "URGENT" -> "Urgente"; "NON_URGENT" -> "No urgente"; else -> "Urgencia pendiente" }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -225,36 +226,13 @@ fun ModernMobileActsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Text("Urgencia", fontWeight = FontWeight.SemiBold)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = newActUrgency == "NON_URGENT",
|
||||
onClick = { newActUrgency = "NON_URGENT" },
|
||||
label = { Text("No urgente") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = newActUrgency == "URGENT",
|
||||
onClick = { newActUrgency = "URGENT" },
|
||||
label = { Text("Urgente") },
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Text(
|
||||
if (newActUrgency == "URGENT") {
|
||||
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido."
|
||||
} else {
|
||||
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
|
||||
},
|
||||
Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"La urgencia se define recién al cerrar el Acta, cuando ya se conoce el resultado de la inspección.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = { model.createAct(newActUrgency) },
|
||||
onClick = { model.createAct() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
@@ -278,7 +256,14 @@ fun ModernMobileActsScreen(
|
||||
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
StatusPill(modernActStatusLabel(selected.status))
|
||||
}
|
||||
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
when (selected.urgency) {
|
||||
"URGENT" -> "Urgencia · Urgente"
|
||||
"NON_URGENT" -> "Urgencia · No urgente"
|
||||
else -> "Urgencia · Se define al cerrar el Acta"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
|
||||
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
|
||||
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
|
||||
@@ -309,28 +294,25 @@ fun ModernMobileActsScreen(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") })
|
||||
FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") })
|
||||
}
|
||||
if (attendance == "PRESENT") {
|
||||
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
|
||||
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) })
|
||||
}
|
||||
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
if (closure?.responsible == null && closure?.representativeSuggestion != null) {
|
||||
Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
Button(
|
||||
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
|
||||
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
|
||||
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Guardar responsable") }
|
||||
) { Text("Guardar representante") }
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
absenceReason,
|
||||
@@ -356,12 +338,34 @@ fun ModernMobileActsScreen(
|
||||
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(
|
||||
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.",
|
||||
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia del Acta completa. La urgencia no se asigna a cada Hallazgo.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = closingUrgency == "NON_URGENT",
|
||||
onClick = { closingUrgency = "NON_URGENT" },
|
||||
label = { Text("No urgente") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = closingUrgency == "URGENT",
|
||||
onClick = { closingUrgency = "URGENT" },
|
||||
label = { Text("Urgente") },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
when (closingUrgency) {
|
||||
"URGENT" -> "El plazo urgente se computará desde el Acta según la política vigente."
|
||||
"NON_URGENT" -> "El plazo no urgente comenzará con la oficialización GEDO."
|
||||
else -> "Elegí una opción para habilitar el cierre."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = { model.prepareSelectedAct() },
|
||||
enabled = !model.busy && closure?.responsible != null,
|
||||
onClick = { model.prepareSelectedAct(closingUrgency) },
|
||||
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar Acta y dejar pendiente de firma") }
|
||||
}
|
||||
@@ -401,7 +405,7 @@ fun ModernMobileActsScreen(
|
||||
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
|
||||
if (companyOutcome != null && companyResolved) {
|
||||
val detail = when (companyOutcome.status) {
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad"
|
||||
"REFUSED" -> "Negativa a firmar"
|
||||
else -> humanBackendLabelEs(companyOutcome.status)
|
||||
}
|
||||
@@ -423,19 +427,19 @@ fun ModernMobileActsScreen(
|
||||
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
|
||||
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") })
|
||||
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disconformidad") })
|
||||
}
|
||||
if (manifestation == "DISSENT") {
|
||||
OutlinedTextField(
|
||||
dissentStatement,
|
||||
{ dissentStatement = it },
|
||||
label = { Text("Manifestación de disidencia *") },
|
||||
label = { Text("Motivo de disconformidad *") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
SignaturePad(
|
||||
label = "Firma del responsable de empresa",
|
||||
label = "Firma del representante de la empresa",
|
||||
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||
onCaptured = { file -> signWithGeo(file, company = true) },
|
||||
)
|
||||
|
||||
@@ -106,7 +106,6 @@ fun ModernVisitRoot(model: MainViewModel) {
|
||||
ModernVisitScreen.OVERVIEW -> ModernVisitOverview(
|
||||
model = model,
|
||||
onActs = { screenName = ModernVisitScreen.ACTS.name },
|
||||
onInventory = { screenName = ModernVisitScreen.INVENTORY.name },
|
||||
)
|
||||
ModernVisitScreen.ACTS -> ModernMobileActsScreen(
|
||||
model = model,
|
||||
@@ -124,7 +123,6 @@ fun ModernVisitRoot(model: MainViewModel) {
|
||||
private fun ModernVisitOverview(
|
||||
model: MainViewModel,
|
||||
onActs: () -> Unit,
|
||||
onInventory: () -> Unit,
|
||||
) {
|
||||
val visit = model.visit ?: return
|
||||
Column(
|
||||
@@ -183,7 +181,7 @@ private fun ModernVisitOverview(
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Listo para iniciar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Al iniciar se habilitan Actas, Hallazgos y el Inventario de campo.",
|
||||
"Al iniciar se habilitan Actas y Hallazgos.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
@@ -217,25 +215,6 @@ private fun ModernVisitOverview(
|
||||
}
|
||||
}
|
||||
}
|
||||
ElevatedCard(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||
Icon(Icons.Filled.Factory, null, Modifier.padding(11.dp), tint = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Inventario de campo", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Buscar existente o cargar Instalación / Subinstalación",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (visit.status == "CLOSED") {
|
||||
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") }
|
||||
}
|
||||
@@ -329,13 +308,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 +328,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 +368,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 = ""
|
||||
@@ -435,7 +418,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 +512,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
||||
ModernInventoryMode.PICK_PARENT -> "Elegir Instalación"
|
||||
ModernInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación"
|
||||
ModernInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación"
|
||||
ModernInventoryMode.BROWSE -> "Inventario de campo"
|
||||
ModernInventoryMode.BROWSE -> "Ubicación del Hallazgo"
|
||||
},
|
||||
subtitle = visit.scopeAsset?.name ?: visit.code,
|
||||
onBack = { if (mode == ModernInventoryMode.BROWSE) onBack() else backToBrowse() },
|
||||
@@ -544,6 +527,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 +721,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) {
|
||||
@@ -886,7 +873,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 +891,7 @@ private fun ModernInventoryBrowse(
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = onSearchChange,
|
||||
label = { Text("Buscar Inventario") },
|
||||
label = { Text("Buscar Instalación o Subinstalación") },
|
||||
placeholder = { Text("Nombre, código o dato técnico") },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
@@ -934,7 +921,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 +933,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") "Sí" 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) { }
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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"))
|
||||
|
||||
+105
@@ -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(31, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.19.3-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"))
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { AssetRegistryService } from './asset-registry.service';
|
||||
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
||||
import { InventoryStructureController } from './inventory-structure.controller';
|
||||
import { InventoryStructureService } from './inventory-structure.service';
|
||||
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
|
||||
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
|
||||
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
||||
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
|
||||
@@ -31,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';
|
||||
@@ -59,11 +61,13 @@ import { InventoryBrowserService } from './inventory-browser.service';
|
||||
AssetTypesService,
|
||||
AssetsService,
|
||||
InventoryStructureService,
|
||||
SimpleInventoryStructureService,
|
||||
InventoryFamilyCatalogService,
|
||||
InventoryTechnicalValuesService,
|
||||
InventoryFunctionService,
|
||||
InventoryBrowserService,
|
||||
InventoryMergeService,
|
||||
ActivityDossierService,
|
||||
MergedInventoryDossierService,
|
||||
AssetGeometriesService,
|
||||
AssetHistoryService,
|
||||
|
||||
@@ -142,27 +142,8 @@ export class AssetOperationalRelationsService {
|
||||
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
|
||||
}
|
||||
|
||||
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
|
||||
const [currentOperator] = (await manager.query(`
|
||||
SELECT relation.id,company.name AS "companyName"
|
||||
FROM area_company_relations relation
|
||||
JOIN assets company ON company.id=relation.company_id
|
||||
WHERE relation.area_id=$1
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
AND relation.company_id<>$2
|
||||
ORDER BY relation.valid_from DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE OF relation
|
||||
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
|
||||
if (currentOperator) {
|
||||
throw new ConflictException({
|
||||
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
|
||||
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// F7: un Área puede estar explotada por varias Empresas simultáneamente.
|
||||
// La unicidad válida es Área + Empresa + rol activo; no existe una única Operadora por Área.
|
||||
const [row] = (await manager.query(`
|
||||
INSERT INTO area_company_relations (
|
||||
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
|
||||
@@ -170,9 +151,6 @@ export class AssetOperationalRelationsService {
|
||||
RETURNING id
|
||||
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
|
||||
|
||||
// F5/F6: changing the Area operator is a temporal relation event only.
|
||||
// Existing Inventory keeps its creation/historical operator snapshot and
|
||||
// physical hierarchy unchanged. Runtime ownership must resolve this row.
|
||||
const created = await this.loadRelation(manager, row.id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
@@ -181,7 +159,7 @@ export class AssetOperationalRelationsService {
|
||||
entityId: row.id,
|
||||
afterData: this.auditView(created),
|
||||
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
|
||||
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
|
||||
? { inventoryHierarchyChanged:false, multipleAreaOperatorsAllowed:true }
|
||||
: undefined,
|
||||
}, manager);
|
||||
return created;
|
||||
@@ -212,7 +190,6 @@ export class AssetOperationalRelationsService {
|
||||
});
|
||||
}
|
||||
|
||||
// F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
|
||||
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
|
||||
await manager.query(`
|
||||
UPDATE area_company_relations
|
||||
@@ -232,6 +209,7 @@ export class AssetOperationalRelationsService {
|
||||
afterData: this.auditView(updated),
|
||||
metadata: {
|
||||
inventoryHierarchyChanged:false,
|
||||
operatorSnapshotPreserved:true,
|
||||
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
|
||||
},
|
||||
}, manager);
|
||||
|
||||
@@ -4,10 +4,14 @@ import { RequirePermissions } from '../authorization/decorators/require-permissi
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
|
||||
import { InventoryStructureService } from './inventory-structure.service';
|
||||
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
|
||||
|
||||
@Controller('inventory-structure')
|
||||
export class InventoryStructureController {
|
||||
constructor(private readonly inventoryStructure: InventoryStructureService) {}
|
||||
constructor(
|
||||
private readonly inventoryStructure: InventoryStructureService,
|
||||
private readonly simpleInventoryStructure: SimpleInventoryStructureService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('assets.read')
|
||||
@@ -24,6 +28,17 @@ export class InventoryStructureController {
|
||||
return this.inventoryStructure.parents(kind, search);
|
||||
}
|
||||
|
||||
@Post('simple')
|
||||
@RequirePermissions('assets.create')
|
||||
createSimple(
|
||||
@Body() dto: CreateInventoryStructureDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.simpleInventoryStructure.create(dto, principal, request);
|
||||
}
|
||||
|
||||
// Compatibilidad temporal con el alta anterior. La WEB F7 utiliza /simple.
|
||||
@Post()
|
||||
@RequirePermissions('assets.create')
|
||||
create(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import {
|
||||
AssetDataOrigin,
|
||||
AssetInformationStatus,
|
||||
AssetOperationalStatus,
|
||||
AssetVersionChangeType,
|
||||
AuditAction,
|
||||
} from '../database/entities';
|
||||
import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/create-inventory-structure.dto';
|
||||
import { AssetHistoryService } from './asset-history.service';
|
||||
|
||||
type StructureTypeRow = { id: string; code: string; name: string };
|
||||
type ParentRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
typeCode: string;
|
||||
inventoryFamilyId: string | null;
|
||||
operationalAreaId: string | null;
|
||||
operatorCompanyId: string | null;
|
||||
};
|
||||
type FamilyRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
};
|
||||
|
||||
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
|
||||
DEPARTAMENTO: 'departamento',
|
||||
AREA: 'area',
|
||||
YACIMIENTO: 'yacimiento',
|
||||
INSTALACION: 'instalacion',
|
||||
SUBINSTALACION: 'subinstalacion',
|
||||
};
|
||||
|
||||
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
|
||||
EMPRESA: null,
|
||||
DEPARTAMENTO: null,
|
||||
AREA: 'departamento',
|
||||
YACIMIENTO: 'area',
|
||||
INSTALACION: 'yacimiento',
|
||||
SUBINSTALACION: 'instalacion',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SimpleInventoryStructureService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly history: AssetHistoryService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
dto: CreateInventoryStructureDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
const type = await this.requireStructureType(manager, dto.kind);
|
||||
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
|
||||
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
|
||||
|
||||
let operationalAreaId: string | null = null;
|
||||
let operatorCompanyId: string | null = null;
|
||||
|
||||
if (dto.kind === 'YACIMIENTO') {
|
||||
if (!parent) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_AREA_REQUIRED', message: 'Seleccioná el Área del Yacimiento' });
|
||||
if (!dto.operatorCompanyId) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED', message: 'Seleccioná la Empresa operadora del Yacimiento' });
|
||||
await this.requireAreaCompanyRelation(manager, parent.id, dto.operatorCompanyId);
|
||||
operationalAreaId = parent.id;
|
||||
operatorCompanyId = dto.operatorCompanyId;
|
||||
} else if (dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') {
|
||||
operationalAreaId = parent?.operationalAreaId ?? null;
|
||||
operatorCompanyId = parent?.operatorCompanyId ?? null;
|
||||
if (!operationalAreaId || !operatorCompanyId) {
|
||||
throw new ConflictException({
|
||||
code: 'INVENTORY_YACIMIENTO_CONTEXT_MISSING',
|
||||
message: 'El Yacimiento de origen debe tener Área y Empresa operadora válidas',
|
||||
});
|
||||
}
|
||||
} else if (dto.operatorCompanyId || dto.concessionTypeId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_SIMPLE_CONTEXT_NOT_ALLOWED',
|
||||
message: 'Empresa operadora sólo corresponde al Yacimiento',
|
||||
});
|
||||
}
|
||||
|
||||
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
|
||||
const rows = (await manager.query(`
|
||||
INSERT INTO assets (
|
||||
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
|
||||
code,name,common_name,description,information_status,operational_status,
|
||||
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
|
||||
) VALUES (
|
||||
$1::uuid,$2::uuid,$3::uuid,$4::uuid,NULL,$5::uuid,
|
||||
$6::varchar,$7::varchar,NULL,NULL,$8::asset_information_status,$9::asset_operational_status,
|
||||
$10::varchar,$11::varchar,$12::varchar,$13::text,$14::uuid,$14::uuid,$14::uuid
|
||||
) RETURNING id
|
||||
`, [
|
||||
type.id,
|
||||
parent?.id ?? null,
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
family?.id ?? null,
|
||||
generatedCode,
|
||||
dto.name,
|
||||
AssetInformationStatus.DRAFT,
|
||||
AssetOperationalStatus.UNKNOWN,
|
||||
AssetDataOrigin.MANUAL,
|
||||
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Alta simple de Inventario',
|
||||
`inventory-simple:${dto.kind.toLowerCase()}`,
|
||||
family ? `Tipo: ${family.name}` : null,
|
||||
principal.userId,
|
||||
])) as Array<{ id: string }>;
|
||||
const id = rows[0]?.id;
|
||||
if (!id) throw new Error('No se pudo crear el registro');
|
||||
|
||||
if (dto.kind === 'EMPRESA') {
|
||||
await manager.query(`
|
||||
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
|
||||
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
|
||||
ON CONFLICT (asset_id) DO UPDATE
|
||||
SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
|
||||
`, [id, dto.name, principal.userId]);
|
||||
}
|
||||
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
id,
|
||||
AssetVersionChangeType.CREATED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
|
||||
await manager.query(`
|
||||
INSERT INTO asset_context_history (
|
||||
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
|
||||
change_reason,asset_version_number,source,request_id,created_by
|
||||
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
|
||||
`, [
|
||||
id,
|
||||
parent?.id ?? null,
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
dto.kind === 'YACIMIENTO'
|
||||
? 'Alta de Yacimiento con Empresa operadora vinculada al Área'
|
||||
: 'Alta simple de Inventario',
|
||||
versionNumber,
|
||||
request.requestId,
|
||||
principal.userId,
|
||||
]);
|
||||
|
||||
const created = await this.loadView(manager, id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_CREATED,
|
||||
entityType: 'asset',
|
||||
entityId: id,
|
||||
afterData: created as Record<string, unknown>,
|
||||
metadata: {
|
||||
versionNumber,
|
||||
inventoryStructureKind: dto.kind,
|
||||
inventoryFamilyId: family?.id ?? null,
|
||||
inventoryFamilyCode: family?.code ?? null,
|
||||
operatorCompanyId,
|
||||
simpleInventoryFlow: true,
|
||||
},
|
||||
}, manager);
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS', message: 'Ya existe un registro con ese código' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
|
||||
const sql = kind === 'EMPRESA'
|
||||
? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
|
||||
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
|
||||
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind, 'EMPRESA'>]];
|
||||
const rows = (await manager.query(sql, params)) as StructureTypeRow[];
|
||||
if (!rows[0]) throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', message: `El nivel ${kind} no está configurado` });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async requireParent(
|
||||
manager: EntityManager,
|
||||
kind: InventoryStructureKind,
|
||||
parentId: string | null,
|
||||
): Promise<ParentRow | null> {
|
||||
const expectedType = PARENT_TYPE_BY_KIND[kind];
|
||||
if (!expectedType) {
|
||||
if (parentId) throw new BadRequestException({ code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT', message: `${kind === 'EMPRESA' ? 'Empresa' : 'Departamento'} no admite padre` });
|
||||
return null;
|
||||
}
|
||||
if (!parentId) throw new BadRequestException({ code: 'INVENTORY_PARENT_REQUIRED', message: `Seleccioná el nivel superior para crear ${kind.toLowerCase()}` });
|
||||
|
||||
const rows = (await manager.query(`
|
||||
SELECT asset.id,asset.code,asset.name,lower(type.code) AS "typeCode",
|
||||
asset.inventory_family_id AS "inventoryFamilyId",
|
||||
CASE WHEN lower(type.code)='area' THEN asset.id ELSE asset.operational_area_id END AS "operationalAreaId",
|
||||
asset.operator_company_id AS "operatorCompanyId"
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
|
||||
FOR KEY SHARE OF asset
|
||||
`, [parentId])) as ParentRow[];
|
||||
const parent = rows[0];
|
||||
if (!parent || parent.typeCode !== expectedType) {
|
||||
throw new BadRequestException({ code: 'INVENTORY_PARENT_INVALID', message: `El registro padre debe ser ${expectedType}` });
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
private async requireAreaCompanyRelation(manager: EntityManager, areaId: string, companyId: string): Promise<void> {
|
||||
const rows = await manager.query(`
|
||||
SELECT 1
|
||||
FROM area_company_relations relation
|
||||
JOIN assets company ON company.id=relation.company_id
|
||||
JOIN asset_types company_type ON company_type.id=company.asset_type_id
|
||||
WHERE relation.area_id=$1::uuid
|
||||
AND relation.company_id=$2::uuid
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
AND company.information_status<>'INACTIVE'
|
||||
AND company_type.operational_role='COMPANY'
|
||||
AND company_type.is_active=true
|
||||
LIMIT 1
|
||||
FOR KEY SHARE OF relation
|
||||
`, [areaId, companyId]);
|
||||
if (!rows[0]) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA',
|
||||
message: 'La Empresa seleccionada no está vinculada como explotadora del Área',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async requireFamily(
|
||||
manager: EntityManager,
|
||||
kind: InventoryStructureKind,
|
||||
familyId: string | null,
|
||||
parent: ParentRow | null,
|
||||
): Promise<FamilyRow | null> {
|
||||
if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') {
|
||||
if (familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_NOT_ALLOWED', message: 'Este nivel no usa Tipo de Instalación/Subinstalación' });
|
||||
return null;
|
||||
}
|
||||
if (!familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_REQUIRED', message: `Seleccioná el tipo de ${kind === 'INSTALACION' ? 'Instalación' : 'Subinstalación'}` });
|
||||
|
||||
const rows = (await manager.query(`
|
||||
SELECT id,code,name,level::text AS level
|
||||
FROM inventory_families
|
||||
WHERE id=$1::uuid AND is_active=true
|
||||
LIMIT 1
|
||||
FOR KEY SHARE
|
||||
`, [familyId])) as FamilyRow[];
|
||||
const family = rows[0];
|
||||
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El tipo seleccionado no existe o está oculto' });
|
||||
const expected = kind === 'INSTALACION' ? 'INSTALLATION' : 'SUBINSTALLATION';
|
||||
if (family.level !== expected) throw new BadRequestException({ code: 'INVENTORY_FAMILY_LEVEL_INVALID', message: 'El tipo seleccionado no corresponde a este nivel' });
|
||||
|
||||
if (kind === 'SUBINSTALACION') {
|
||||
if (!parent?.inventoryFamilyId) throw new BadRequestException({ code: 'INVENTORY_PARENT_FAMILY_REQUIRED', message: 'La Instalación debe tener un tipo configurado' });
|
||||
const compatible = await manager.query(`
|
||||
SELECT 1 FROM inventory_family_parent_rules
|
||||
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
|
||||
LIMIT 1
|
||||
`, [family.id, parent.inventoryFamilyId]);
|
||||
if (!compatible[0]) throw new BadRequestException({ code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', message: 'Ese tipo de Subinstalación no está habilitado dentro de la Instalación seleccionada' });
|
||||
}
|
||||
return family;
|
||||
}
|
||||
|
||||
private generatedCode(kind: InventoryStructureKind, name: string): string {
|
||||
const prefix = kind === 'EMPRESA' ? 'EMP'
|
||||
: kind === 'DEPARTAMENTO' ? 'DEP'
|
||||
: kind === 'AREA' ? 'AREA'
|
||||
: kind === 'YACIMIENTO' ? 'YAC'
|
||||
: kind === 'INSTALACION' ? 'INST'
|
||||
: 'SUB';
|
||||
const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
|
||||
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
|
||||
}
|
||||
|
||||
private async loadView(manager: EntityManager, id: string) {
|
||||
const rows = await manager.query(`
|
||||
SELECT asset.id,asset.code,asset.name,asset.common_name AS "commonName",asset.description,
|
||||
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
|
||||
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
||||
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
|
||||
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany",
|
||||
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',family.id,'code',family.code,'name',family.name,'level',family.level) END AS "inventoryFamily",
|
||||
asset.created_at AS "createdAt",asset.updated_at AS "updatedAt"
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
||||
LEFT JOIN assets area ON area.id=asset.operational_area_id
|
||||
LEFT JOIN assets company ON company.id=asset.operator_company_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||
WHERE asset.id=$1::uuid
|
||||
`, [id]);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,179 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class F7SimpleInventoryContext1790110200000 implements MigrationInterface {
|
||||
name = 'F7SimpleInventoryContext1790110200000';
|
||||
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
UPDATE asset_types
|
||||
SET description='Yacimiento. Pertenece a un Área, tiene una única Empresa operadora vigente y puede recibir Hallazgos.',
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE lower(code)='yacimiento'
|
||||
`);
|
||||
|
||||
await q.query(`
|
||||
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
kind text;
|
||||
asset_role asset_type_operational_role;
|
||||
parent_kind text;
|
||||
parent_area uuid;
|
||||
parent_company uuid;
|
||||
parent_family uuid;
|
||||
company_role asset_type_operational_role;
|
||||
family_level text;
|
||||
BEGIN
|
||||
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
|
||||
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
|
||||
|
||||
IF asset_role='COMPANY'::asset_type_operational_role THEN
|
||||
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
|
||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
RETURN NEW;
|
||||
ELSIF kind='departamento' THEN
|
||||
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
|
||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
|
||||
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
|
||||
INTO parent_kind,parent_area,parent_company,parent_family
|
||||
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
|
||||
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
|
||||
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
|
||||
|
||||
IF kind='area' THEN
|
||||
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
|
||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
ELSIF kind='yacimiento' THEN
|
||||
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
|
||||
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere una Empresa operadora'; END IF;
|
||||
SELECT type.operational_role INTO company_role
|
||||
FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
|
||||
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
|
||||
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa operadora del Yacimiento no es válida';
|
||||
END IF;
|
||||
IF NOT EXISTS(
|
||||
SELECT 1 FROM area_company_relations relation
|
||||
WHERE relation.area_id=NEW.parent_id
|
||||
AND relation.company_id=NEW.operator_company_id
|
||||
AND relation.relation_role='OPERATOR'::area_organization_role
|
||||
AND relation.valid_until IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa del Yacimiento debe estar vinculada como explotadora del Área';
|
||||
END IF;
|
||||
IF NEW.concession_type_id IS NOT NULL
|
||||
AND NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de concesión indicado no es válido';
|
||||
END IF;
|
||||
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre';
|
||||
END IF;
|
||||
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
ELSIF kind='instalacion' THEN
|
||||
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
|
||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
||||
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
|
||||
IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Yacimiento debe tener una Empresa operadora'; END IF;
|
||||
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
|
||||
ELSIF kind='subinstalacion' THEN
|
||||
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
|
||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
||||
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
|
||||
IF NOT EXISTS(
|
||||
SELECT 1 FROM inventory_family_parent_rules rule
|
||||
WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family
|
||||
) THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no está habilitado dentro del Tipo de instalación padre';
|
||||
END IF;
|
||||
IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación debe pertenecer a un Yacimiento con Empresa operadora'; END IF;
|
||||
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
UPDATE asset_types
|
||||
SET description='Yacimiento. Pertenece a un Área y define directamente Tipo de concesión y Empresa relacionada.',
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE lower(code)='yacimiento'
|
||||
`);
|
||||
|
||||
await q.query(`
|
||||
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
kind text;
|
||||
asset_role asset_type_operational_role;
|
||||
parent_kind text;
|
||||
parent_area uuid;
|
||||
parent_company uuid;
|
||||
parent_family uuid;
|
||||
company_role asset_type_operational_role;
|
||||
family_level text;
|
||||
BEGIN
|
||||
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
|
||||
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
|
||||
|
||||
IF asset_role='COMPANY'::asset_type_operational_role THEN
|
||||
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
|
||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
RETURN NEW;
|
||||
ELSIF kind='departamento' THEN
|
||||
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
|
||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
|
||||
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
|
||||
INTO parent_kind,parent_area,parent_company,parent_family
|
||||
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
|
||||
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
|
||||
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
|
||||
|
||||
IF kind='area' THEN
|
||||
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
|
||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
ELSIF kind='yacimiento' THEN
|
||||
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
|
||||
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere Empresa relacionada'; END IF;
|
||||
SELECT type.operational_role INTO company_role FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
|
||||
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
|
||||
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa relacionada del Yacimiento no es válida'; END IF;
|
||||
IF NEW.concession_type_id IS NULL OR NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere un Tipo de concesión válido';
|
||||
END IF;
|
||||
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; END IF;
|
||||
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
ELSIF kind='instalacion' THEN
|
||||
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
|
||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
||||
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
|
||||
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
|
||||
ELSIF kind='subinstalacion' THEN
|
||||
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
|
||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
||||
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family) THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no es compatible con el Tipo de instalación padre';
|
||||
END IF;
|
||||
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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'`);
|
||||
}
|
||||
}
|
||||
+105
@@ -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
|
||||
|
||||
@@ -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\(/);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -32,7 +32,7 @@ test('F5.1 canonical hierarchy starts at Departamento and Area is no longer root
|
||||
assert.match(migration, /SET can_be_root=false/);
|
||||
});
|
||||
|
||||
test('Authoritative manual creation keeps Area physical and stores Company plus concession on Yacimiento', () => {
|
||||
test('Historical authoritative endpoint keeps the former Yacimiento Company plus concession contract for compatibility', () => {
|
||||
const structure = source('src/asset-master/inventory-structure.service.ts');
|
||||
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
|
||||
|
||||
@@ -66,27 +66,60 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items
|
||||
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
|
||||
});
|
||||
|
||||
test('Authoritative Configuration presents exact physical relationships and technical families', () => {
|
||||
test('F7 Inventory Configuration exposes only simple types, containment and fields', () => {
|
||||
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
|
||||
|
||||
assert.match(configPage, /El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento/);
|
||||
assert.match(configPage, /Tipo de concesión/);
|
||||
assert.match(configPage, /Empresa relacionada/);
|
||||
assert.match(configPage, /Yacimiento → Instalación/);
|
||||
assert.match(configPage, /Instalación → Subinstalación/);
|
||||
assert.match(configPage, /installationFamilies\.map/);
|
||||
assert.match(configPage, /subinstallationFamilies\.map/);
|
||||
assert.match(configPage, /Hallazgos asociados/);
|
||||
assert.match(configPage, /Tipos y campos/);
|
||||
assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
|
||||
assert.match(configPage, /Un Área puede estar vinculada a varias Empresas/);
|
||||
assert.match(configPage, /Cada Yacimiento elige una sola Empresa operadora/);
|
||||
assert.match(configPage, /Tipos de Instalación/);
|
||||
assert.match(configPage, /Tipos de Subinstalación/);
|
||||
assert.match(configPage, /Puede estar dentro de/);
|
||||
assert.match(configPage, /\{ value: 'TEXT', label: 'Texto' \}/);
|
||||
assert.match(configPage, /\{ value: 'NUMBER', label: 'Número' \}/);
|
||||
assert.match(configPage, /\{ value: 'DATE', label: 'Fecha' \}/);
|
||||
assert.match(configPage, /\{ value: 'BOOLEAN', label: 'Sí \/ No' \}/);
|
||||
assert.doesNotMatch(configPage, /Hallazgos asociados/);
|
||||
});
|
||||
|
||||
test('Authoritative Web creation exposes Departamento as root and Yacimiento as operational owner', () => {
|
||||
test('F7 Web creation keeps the fixed hierarchy and asks only the required simple context', () => {
|
||||
const createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx');
|
||||
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
|
||||
|
||||
assert.match(createPage, /kind: 'DEPARTAMENTO'/);
|
||||
assert.match(createPage, /AREA:'Departamento'/);
|
||||
assert.match(createPage, /Empresa relacionada/);
|
||||
assert.match(createPage, /Tipo de concesión/);
|
||||
assert.match(createPage, /Datos opcionales/);
|
||||
assert.match(configPage, /<strong>Departamento<\/strong>/);
|
||||
assert.match(configPage, /<strong>Yacimiento<\/strong>/);
|
||||
assert.match(createPage, /AREA: 'Departamento'/);
|
||||
assert.match(createPage, /YACIMIENTO: 'Área'/);
|
||||
assert.match(createPage, /INSTALACION: 'Yacimiento'/);
|
||||
assert.match(createPage, /SUBINSTALACION: 'Instalación'/);
|
||||
assert.match(createPage, /listCompaniesForInventoryArea/);
|
||||
assert.match(createPage, /Empresa operadora/);
|
||||
assert.match(createPage, /ALTA RÁPIDA/);
|
||||
assert.doesNotMatch(createPage, /Tipo de concesión/);
|
||||
assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
|
||||
});
|
||||
|
||||
test('F7 canonical detail uses one short everyday profile and keeps advanced administration separate', () => {
|
||||
const detailRoute = source('../web-v2/src/pages/InventoryDetailPage.tsx');
|
||||
const detail = source('../web-v2/src/pages/SimpleInventoryDetailPage.tsx');
|
||||
|
||||
assert.match(detailRoute, /isSimpleInventoryAsset\(asset\) && !advanced/);
|
||||
assert.match(detailRoute, /<SimpleInventoryDetailPage initialAsset=\{asset\}/);
|
||||
assert.match(detail, /DEPARTAMENTO/);
|
||||
assert.match(detail, /AREA/);
|
||||
assert.match(detail, /YACIMIENTO/);
|
||||
assert.match(detail, /INSTALACION/);
|
||||
assert.match(detail, /SUBINSTALACION/);
|
||||
assert.match(detail, />Resumen</);
|
||||
assert.match(detail, />Actividad</);
|
||||
assert.match(detail, />Ubicación</);
|
||||
assert.match(detail, />Fotos</);
|
||||
assert.match(detail, />Cambios</);
|
||||
assert.match(detail, /Empresa operadora/);
|
||||
assert.match(detail, /Tipo técnico/);
|
||||
assert.match(detail, /AssetTechnicalDataPanel/);
|
||||
assert.match(detail, /AssetDossierPanel/);
|
||||
assert.match(detail, /Administración avanzada/);
|
||||
assert.doesNotMatch(detail, /Tipo de concesión/);
|
||||
assert.doesNotMatch(detail, /AssetProvenancePanel/);
|
||||
});
|
||||
|
||||
@@ -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 = 31/);
|
||||
assert.match(gradle, /versionName = "0\.19\.3"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||
});
|
||||
|
||||
@@ -88,14 +88,21 @@ test('F6.1 WEB Inventory searches use physical scope and never the operator snap
|
||||
}
|
||||
});
|
||||
|
||||
test('WEB creation captures Company and concession only while creating a Yacimiento', () => {
|
||||
test('F7 WEB creation selects one Area-linked Company for Yacimiento and retires concession from the simple flow', () => {
|
||||
const page = source('../web-v2/src/pages/InventoryCreatePage.tsx');
|
||||
const structure = source('src/asset-master/inventory-structure.service.ts');
|
||||
const api = source('../web-v2/src/lib/inventoryStructureApi.ts');
|
||||
const simpleStructure = source('src/asset-master/simple-inventory-structure.service.ts');
|
||||
const migration = source('src/database/migrations/1790110200000-f7-simple-inventory-context.ts');
|
||||
|
||||
assert.match(page, /requiresYacimientoContext = kind === 'YACIMIENTO'/);
|
||||
assert.match(page, /Empresa relacionada/);
|
||||
assert.match(page, /Tipo de concesión/);
|
||||
assert.match(page, /operatorCompanyId:operatorCompanyId \|\| null/);
|
||||
assert.match(page, /concessionTypeId:concessionTypeId \|\| null/);
|
||||
assert.match(structure, /Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica/);
|
||||
assert.match(page, /const requiresCompany = kind === 'YACIMIENTO'/);
|
||||
assert.match(page, /listCompaniesForInventoryArea\(parentId\)/);
|
||||
assert.match(page, /Empresa operadora/);
|
||||
assert.match(page, /operatorCompanyId: operatorCompanyId \|\| null/);
|
||||
assert.doesNotMatch(page, /Tipo de concesión/);
|
||||
assert.doesNotMatch(page, /concessionTypeId/);
|
||||
assert.match(api, /inventory-structure\/simple/);
|
||||
assert.match(simpleStructure, /requireAreaCompanyRelation/);
|
||||
assert.match(simpleStructure, /INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA/);
|
||||
assert.match(migration, /La Empresa del Yacimiento debe estar vinculada como explotadora del Área/);
|
||||
assert.match(migration, /NEW\.operational_area_id:=NEW\.parent_id/);
|
||||
});
|
||||
|
||||
@@ -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 Área–Operadora 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\)/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# F7 · Modelo simple de Inventarios
|
||||
|
||||
Este documento fija el nuevo eje funcional de Inventarios para DH Inspección V2. Su objetivo es reducir la complejidad visible y orientar toda la experiencia al trabajo real de inspección.
|
||||
|
||||
## 1. Jerarquía
|
||||
|
||||
La estructura física es única:
|
||||
|
||||
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
|
||||
|
||||
- Departamento y Área organizan el territorio.
|
||||
- Yacimiento, Instalación y Subinstalación son niveles válidos para registrar Hallazgos.
|
||||
- Una Instalación pertenece a un Yacimiento.
|
||||
- Una Subinstalación pertenece a una Instalación.
|
||||
|
||||
## 2. Empresas
|
||||
|
||||
- Un Área puede estar explotada por varias Empresas al mismo tiempo.
|
||||
- Cada Yacimiento tiene una sola Empresa operadora vigente.
|
||||
- La Empresa del Yacimiento se elige entre las Empresas vinculadas al Área.
|
||||
- Instalaciones y Subinstalaciones heredan el contexto de Empresa desde su Yacimiento.
|
||||
- Cambiar la Empresa de un Yacimiento no mueve ni recrea su Inventario físico.
|
||||
|
||||
## 3. Datos simples
|
||||
|
||||
Todos los registros usan una base común reducida:
|
||||
|
||||
- Nombre.
|
||||
- Código, opcional y autogenerable.
|
||||
- Ubicación jerárquica.
|
||||
- Estado.
|
||||
- GPS cuando corresponda.
|
||||
- Observaciones sólo cuando aporten valor.
|
||||
|
||||
Departamento, Área y Yacimiento no necesitan un constructor técnico complejo.
|
||||
|
||||
Instalaciones y Subinstalaciones pueden tener campos específicos configurados por el Superadmin técnico.
|
||||
|
||||
Los tipos de campo visibles para la puesta a punto deben mantenerse simples:
|
||||
|
||||
- Texto.
|
||||
- Número.
|
||||
- Fecha.
|
||||
- Sí / No.
|
||||
|
||||
Teléfono, email o identificadores simples pueden resolverse como Texto. GPS es un dato básico del registro, no un atributo técnico configurable.
|
||||
|
||||
## 4. Administración de tipos
|
||||
|
||||
Sólo el Superadmin técnico modifica la configuración.
|
||||
|
||||
La pantalla administrativa debe permitir únicamente:
|
||||
|
||||
1. Crear/ocultar Tipos de Instalación.
|
||||
2. Crear/ocultar Tipos de Subinstalación.
|
||||
3. Definir qué Tipos de Subinstalación puede contener cada Tipo de Instalación.
|
||||
4. Agregar, mostrar/ocultar y marcar como obligatorios los campos simples de cada tipo.
|
||||
|
||||
Los términos técnicos internos como `inventory_families`, reglas de compatibilidad o schemas no deben exponerse al usuario.
|
||||
|
||||
## 5. Cambio de tipo / función
|
||||
|
||||
La identidad física del elemento no cambia cuando cambia su función.
|
||||
|
||||
Ejemplo: `TK-57` sigue siendo `TK-57`, aunque hoy funcione como tanque de petróleo y mañana como tanque de agua.
|
||||
|
||||
El cambio debe registrarse cronológicamente con:
|
||||
|
||||
- tipo / función anterior;
|
||||
- tipo / función nueva;
|
||||
- fecha efectiva;
|
||||
- observación opcional.
|
||||
|
||||
No se crea un Inventario nuevo para representar un cambio de función.
|
||||
|
||||
## 6. Cronología útil
|
||||
|
||||
Para Yacimiento, Instalación y Subinstalación la cronología debe concentrar sólo hechos operativos relevantes:
|
||||
|
||||
- Hallazgos.
|
||||
- Acta asociada.
|
||||
- Empresa operadora correspondiente a la fecha.
|
||||
- Cambio de tipo / función.
|
||||
- Cambio de ubicación cuando corresponda.
|
||||
- Alta e inactivación.
|
||||
|
||||
La experiencia normal no debe obligar a navegar por procedencia, snapshots, registros técnicos o paneles administrativos separados.
|
||||
|
||||
## 7. Filosofía de la APK
|
||||
|
||||
La APK existe para **inspeccionar, generar Acta y generar Informe**.
|
||||
|
||||
El Inspector no administra Inventarios como tarea separada.
|
||||
|
||||
La mayoría de las altas reales ocurren en campo mientras se registra un Hallazgo.
|
||||
|
||||
Flujo:
|
||||
|
||||
1. La Inspección se planifica desde WEB y ya conoce el Yacimiento.
|
||||
2. El Inspector abre la Inspección al llegar.
|
||||
3. Pulsa `+ Crear hallazgo`.
|
||||
4. Decide si el Hallazgo corresponde a Yacimiento, Instalación o Subinstalación.
|
||||
5. Si la Instalación no existe, puede crearla rápidamente sin abandonar el flujo.
|
||||
6. Si necesita una Subinstalación que no existe, puede crearla rápidamente dentro de la Instalación seleccionada.
|
||||
7. El catálogo ayuda, pero `OTRO` siempre está disponible.
|
||||
8. Con el tiempo, el Inventario se completa naturalmente y disminuye la necesidad de altas nuevas.
|
||||
|
||||
## 8. Planificación y recorrido
|
||||
|
||||
La planificación WEB define:
|
||||
|
||||
- Área.
|
||||
- Empresa.
|
||||
- Yacimiento.
|
||||
- Inspector.
|
||||
- Fecha/hora.
|
||||
- Instalaciones/Subinstalaciones elegidas preventivamente.
|
||||
- pendientes por vencer, mostrados por elemento a controlar.
|
||||
- pendientes vencidos, mostrados por elemento a controlar.
|
||||
|
||||
La APK presenta ese conjunto como recorrido sugerido. No es una ruta rígida: el Inspector puede registrar Hallazgos nuevos en cualquier momento.
|
||||
|
||||
## 9. Actas e Informes
|
||||
|
||||
- Abrir una Inspección no crea automáticamente un Acta.
|
||||
- El Inspector pulsa `+ Nueva Acta` cuando empieza a documentar.
|
||||
- El número definitivo del Acta se genera al cerrarla.
|
||||
- Una Inspección puede tener varias Actas.
|
||||
- Al cerrar un Acta se pueden agregar uno o más acompañantes con Nombre + Email.
|
||||
- El Acta se envía a Empresa, Inspector y acompañantes.
|
||||
- El Informe se genera automáticamente en Word y se envía únicamente al Inspector que realizó el Acta.
|
||||
- La firma o validación legal definitiva queda para una etapa posterior; la prioridad actual es la usabilidad.
|
||||
|
||||
## 10. Regla de diseño
|
||||
|
||||
Si una pantalla, campo o estado necesita explicación técnica para ser usado correctamente, debe simplificarse antes de incorporarse a la experiencia cotidiana.
|
||||
@@ -4,37 +4,59 @@ set -Eeuo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LAYOUT="$ROOT/web-v2/src/layout/AppLayout.tsx"
|
||||
APP="$ROOT/web-v2/src/app/App.tsx"
|
||||
PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx"
|
||||
CREATE_PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx"
|
||||
ADMIN_PAGE="$ROOT/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx"
|
||||
API="$ROOT/web-v2/src/lib/inventoryStructureApi.ts"
|
||||
|
||||
for file in "$LAYOUT" "$APP" "$PAGE"; do
|
||||
for file in "$LAYOUT" "$APP" "$CREATE_PAGE" "$ADMIN_PAGE" "$API"; do
|
||||
test -f "$file" || {
|
||||
echo "F4 WEB contract: falta $file"
|
||||
echo "F7 WEB contract: falta $file"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
if grep -Fq "label: 'Relevamientos'" "$LAYOUT"; then
|
||||
echo "F4 WEB contract: Relevamientos no debe volver al menú principal"
|
||||
echo "F7 WEB contract: Relevamientos no debe volver al menú principal"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq 'path="/relevamiento/*"' "$APP"; then
|
||||
echo "F4 WEB contract: Survey/Relevamiento no debe volver a exponer rutas activas"
|
||||
echo "F7 WEB contract: Survey/Relevamiento no debe volver a exponer rutas activas"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -Fq 'path="/inventarios/nuevo"' "$APP"
|
||||
grep -Fq 'InventoryCreatePage' "$APP"
|
||||
|
||||
for kind in AREA YACIMIENTO INSTALACION SUBINSTALACION; do
|
||||
grep -Fq "kind: '$kind'" "$PAGE"
|
||||
for kind in DEPARTAMENTO AREA YACIMIENTO INSTALACION SUBINSTALACION; do
|
||||
grep -Fq "kind: '$kind'" "$CREATE_PAGE"
|
||||
done
|
||||
|
||||
if grep -Fq "kind: 'EQUIPO'" "$PAGE"; then
|
||||
echo "F4 WEB contract: el alta guiada no debe restaurar EQUIPO como quinto nivel estructural"
|
||||
if grep -Fq "kind: 'EQUIPO'" "$CREATE_PAGE"; then
|
||||
echo "F7 WEB contract: EQUIPO no debe restaurarse como nivel estructural"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -Fq 'getInventoryFamilyFindings' "$PAGE"
|
||||
# F7: Yacimiento elige una Empresa ya vinculada al Área; concesión deja de ser una decisión del alta cotidiana.
|
||||
grep -Fq 'listCompaniesForInventoryArea' "$CREATE_PAGE"
|
||||
if grep -Fq 'concessionTypeId' "$CREATE_PAGE"; then
|
||||
echo "F7 WEB contract: el alta simple no debe exponer Tipo de concesión"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 'F4 WEB contract: OK'
|
||||
# El alta normal usa el endpoint simple; el endpoint histórico queda sólo por compatibilidad transitoria.
|
||||
grep -Fq "'/inventory-structure/simple'" "$API"
|
||||
|
||||
# La configuración técnica visible se reduce a tipos, contención y campos sencillos.
|
||||
grep -Fq 'Tipos y campos' "$ADMIN_PAGE"
|
||||
grep -Fq 'Tipos de Instalación' "$ADMIN_PAGE"
|
||||
grep -Fq 'Tipos de Subinstalación' "$ADMIN_PAGE"
|
||||
grep -Fq '+ Agregar campo' "$ADMIN_PAGE"
|
||||
|
||||
# El catálogo de Hallazgos no debe mezclarse con el formulario de alta de Inventario.
|
||||
if grep -Fq 'getInventoryFamilyFindings' "$CREATE_PAGE"; then
|
||||
echo "F7 WEB contract: el alta simple no debe cargar catálogo de Hallazgos"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 'F7 WEB contract: OK'
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+327
@@ -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 "============================================================"
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-1",
|
||||
"version": "0.23.0-3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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" />}>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.23.0-1';
|
||||
export const APP_PHASE = 'F6.1 · Contexto operativo Área–Operadora 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface InventoryTechnicalValues {
|
||||
}
|
||||
export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; }
|
||||
export interface InventoryStructureOption { id:string; code:string; name:string; }
|
||||
export interface InventoryAreaCompanyOption extends InventoryStructureOption { commonName?:string|null; typeName?:string; }
|
||||
export interface InventoryStructureOptions {
|
||||
independentMasters:InventoryStructureLevel[];
|
||||
levels:InventoryStructureLevel[];
|
||||
@@ -45,7 +46,7 @@ export interface CreatedInventoryStructure {
|
||||
type:{id:string;code:string;name:string}; parent:null|{id:string;code:string;name:string}; operationalArea?:null|{id:string;code:string;name:string};
|
||||
operatorCompany?:null|{id:string;code:string;name:string};
|
||||
concessionType?:null|{id:string;code:string;name:string};
|
||||
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]};
|
||||
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels?:string[]};
|
||||
}
|
||||
|
||||
export function getInventoryStructureOptions(){return apiRequest<InventoryStructureOptions>('/inventory-structure');}
|
||||
@@ -53,6 +54,9 @@ export async function listInventoryStructureParents(kind:InventoryStructureKind,
|
||||
const query=new URLSearchParams(); if(search.trim()) query.set('search',search.trim()); const suffix=query.size?`?${query}`:'';
|
||||
return (await apiRequest<{data:InventoryStructureParent[]}>(`/inventory-structure/parents/${kind}${suffix}`)).data;
|
||||
}
|
||||
export async function listCompaniesForInventoryArea(areaId:string){
|
||||
return (await apiRequest<{data:InventoryAreaCompanyOption[]}>(`/asset-operational-relations/areas/${areaId}/companies`)).data;
|
||||
}
|
||||
export function getInventoryFamilyFindings(familyId:string){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);}
|
||||
export function getInventoryFamilyAttributes(familyId:string){return apiRequest<InventoryFamilyAttributes>(`/inventory-families/${familyId}/attributes`);}
|
||||
export function getInventoryTechnicalValues(assetId:string){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`);}
|
||||
@@ -63,4 +67,4 @@ export function updateInventoryFamily(familyId:string,input:{name?:string;parent
|
||||
export function createInventoryFamilyAttribute(familyId:string,input:{code:string;name:string;dataType:InventoryFamilyAttributeDataType;isRequired?:boolean;unit?:string|null;options?:string[];sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes`,{method:'POST',body:JSON.stringify(input)});}
|
||||
export function updateInventoryFamilyAttribute(familyId:string,attributeId:string,input:{name?:string;dataType?:InventoryFamilyAttributeDataType;isRequired?:boolean;isActive?:boolean;unit?:string|null;options?:string[]|null;sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes/${attributeId}`,{method:'PATCH',body:JSON.stringify(input)});}
|
||||
export function replaceInventoryFamilyFindings(familyId:string,input:{itemIds:string[];reason:string}){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`,{method:'PUT',body:JSON.stringify(input)});}
|
||||
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;operatorCompanyId?:string|null;concessionTypeId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
|
||||
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;operatorCompanyId?:string|null;concessionTypeId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure/simple',{method:'POST',body:JSON.stringify(input)});}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface UserProfileInput {
|
||||
|
||||
export interface CreateUserProfileInput extends UserProfileInput {
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password: string;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
.asset-compact-form {
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.asset-compact-section {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 20px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.asset-compact-section-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.asset-compact-section-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.asset-compact-primary-grid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.asset-compact-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-compact-meta > span {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #e0e5ed;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.asset-compact-meta small {
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
|
||||
.asset-compact-meta strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--ink);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-compact-context-button {
|
||||
min-height: 34px;
|
||||
padding: 7px 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-compact-context-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.asset-compact-context-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 12px 13px;
|
||||
border: 1px solid #e1e6ee;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.asset-compact-context-item small {
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
|
||||
.asset-compact-context-item strong {
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-compact-context-item span {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-compact-details {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.asset-compact-details > summary {
|
||||
min-height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 13px 22px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.asset-compact-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.asset-compact-details > summary:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.asset-compact-details > summary > span:first-child {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.asset-compact-details > summary strong {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.asset-compact-details > summary small {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-compact-details > summary > span:last-child {
|
||||
color: #7b8496;
|
||||
font-size: 16px;
|
||||
transition: transform .18s ease;
|
||||
}
|
||||
|
||||
.asset-compact-details[open] > summary > span:last-child {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.asset-compact-details-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 2px 22px 20px;
|
||||
}
|
||||
|
||||
.asset-compact-technical {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.asset-compact-technical h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.asset-compact-form > .form-actions {
|
||||
padding: 16px 22px 18px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.asset-compact-section {
|
||||
padding: 17px;
|
||||
}
|
||||
|
||||
.asset-compact-section-heading {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.asset-compact-context-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.asset-compact-context-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.asset-compact-details > summary {
|
||||
padding: 13px 17px;
|
||||
}
|
||||
|
||||
.asset-compact-details-body {
|
||||
padding: 2px 17px 18px;
|
||||
}
|
||||
|
||||
.asset-compact-form > .form-actions {
|
||||
padding: 14px 17px 17px;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import './AssetEditorPage.css';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { ASSET_OPERATIONAL_STATUSES, ASSET_STATUSES, assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
|
||||
import {
|
||||
ASSET_OPERATIONAL_STATUSES,
|
||||
ASSET_STATUSES,
|
||||
assetOperationalStatusLabel,
|
||||
assetStatusClass,
|
||||
assetStatusLabel,
|
||||
} from '../features/assets/assetPresentation';
|
||||
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
|
||||
import { AssetDossierPanel } from '../features/assets/AssetDossierPanel';
|
||||
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
|
||||
@@ -15,15 +22,31 @@ import { AssetRegistryPanel } from '../features/assets/AssetRegistryPanel';
|
||||
import { AssetContextHistoryPanel } from '../features/assets/AssetContextHistoryPanel';
|
||||
import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel';
|
||||
import {
|
||||
createAsset, getAsset, getAssetLineage, listAssetParentOptions, listAssetTypes, listCompaniesForArea,
|
||||
listOperationalAreas, updateAsset, updateAssetInformationStatus, updateAssetOperationalStatus,
|
||||
createAsset,
|
||||
getAsset,
|
||||
getAssetLineage,
|
||||
listAssetParentOptions,
|
||||
listAssetTypes,
|
||||
listCompaniesForArea,
|
||||
listOperationalAreas,
|
||||
updateAsset,
|
||||
updateAssetInformationStatus,
|
||||
updateAssetOperationalStatus,
|
||||
} from '../lib/api';
|
||||
import type {
|
||||
AssetAttributeDefinition, AssetDetail, AssetInformationStatus, AssetOperationalStatus,
|
||||
AssetLineageItem, AssetListItem, AssetType, OperationalAssetSummary,
|
||||
AssetAttributeDefinition,
|
||||
AssetDetail,
|
||||
AssetInformationStatus,
|
||||
AssetOperationalStatus,
|
||||
AssetLineageItem,
|
||||
AssetListItem,
|
||||
AssetType,
|
||||
OperationalAssetSummary,
|
||||
} from '../lib/api';
|
||||
|
||||
const AssetGeometryEditor = lazy(() => import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })));
|
||||
const AssetGeometryEditor = lazy(() =>
|
||||
import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })),
|
||||
);
|
||||
|
||||
type DetailTab = 'summary' | 'dossier' | 'findings' | 'location' | 'registry' | 'files' | 'history';
|
||||
|
||||
@@ -35,16 +58,34 @@ function localDateTime(value: unknown): string {
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function normalizeAttributeValues(definitions: AssetAttributeDefinition[], values: Record<string, unknown>): Record<string, unknown> {
|
||||
function normalizeAttributeValues(
|
||||
definitions: AssetAttributeDefinition[],
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
definitions.filter((item) => item.isActive).forEach((definition) => {
|
||||
const raw = values[definition.id];
|
||||
if (definition.dataType === 'BOOLEAN') result[definition.id] = Boolean(raw);
|
||||
else if (raw !== undefined && raw !== null && raw !== '') result[definition.id] = definition.dataType === 'NUMBER' ? Number(raw) : raw;
|
||||
else if (raw !== undefined && raw !== null && raw !== '') {
|
||||
result[definition.id] = definition.dataType === 'NUMBER' ? Number(raw) : raw;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function hasAttributeValue(definition: AssetAttributeDefinition, value: unknown): boolean {
|
||||
if (definition.dataType === 'BOOLEAN') return value === true;
|
||||
return value !== undefined && value !== null && String(value).trim() !== '';
|
||||
}
|
||||
|
||||
function normalizeStructuralType(value: string | null | undefined): string {
|
||||
return (value ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function AssetEditorPage() {
|
||||
const { id } = useParams();
|
||||
const editing = Boolean(id);
|
||||
@@ -52,7 +93,11 @@ export function AssetEditorPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const contextParentId = !editing ? searchParams.get('parentId') : null;
|
||||
const requestedTab = searchParams.get('tab') as DetailTab | null;
|
||||
const tab: DetailTab = requestedTab && ['summary','dossier','findings','location','registry','files','history'].includes(requestedTab) ? requestedTab : 'summary';
|
||||
const tab: DetailTab = requestedTab
|
||||
&& ['summary', 'dossier', 'findings', 'location', 'registry', 'files', 'history'].includes(requestedTab)
|
||||
? requestedTab
|
||||
: 'summary';
|
||||
|
||||
const { hasPermission } = useAuth();
|
||||
const canEdit = editing ? hasPermission('assets.update') : hasPermission('assets.create');
|
||||
const canCreate = hasPermission('assets.create');
|
||||
@@ -104,10 +149,14 @@ export function AssetEditorPage() {
|
||||
const [success, setSuccess] = useState('');
|
||||
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
|
||||
|
||||
const canDirectContextEdit = !editing || Boolean(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT');
|
||||
const canDirectContextEdit = !editing
|
||||
|| Boolean(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT');
|
||||
|
||||
const selectedType = types.find((type) => type.id === typeId) ?? null;
|
||||
const definitions = useMemo(() => selectedType?.attributes.filter((item) => item.isActive) ?? [], [selectedType]);
|
||||
const definitions = useMemo(
|
||||
() => selectedType?.attributes.filter((item) => item.isActive) ?? [],
|
||||
[selectedType],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -119,13 +168,26 @@ export function AssetEditorPage() {
|
||||
setTypes(loadedTypes);
|
||||
setLineage(loadedLineage);
|
||||
if (loadedAsset) {
|
||||
setAsset(loadedAsset); setCode(loadedAsset.code); setName(loadedAsset.name); setCommonName(loadedAsset.commonName ?? ''); setTypeId(loadedAsset.type.id);
|
||||
setParentId(loadedAsset.parent?.id ?? ''); setOperationalAreaId(loadedAsset.operationalArea?.id ?? '');
|
||||
setOperatorCompanyId(loadedAsset.operatorCompany?.id ?? ''); setDescription(loadedAsset.description ?? '');
|
||||
setStatus(loadedAsset.informationStatus); setOperationalStatus(loadedAsset.operationalStatus);
|
||||
setAttributeValues(Object.fromEntries(loadedAsset.attributes.map((attribute) => [attribute.definitionId, attribute.dataType === 'DATETIME' ? localDateTime(attribute.value) : attribute.value ?? ''])));
|
||||
setAsset(loadedAsset);
|
||||
setCode(loadedAsset.code);
|
||||
setName(loadedAsset.name);
|
||||
setCommonName(loadedAsset.commonName ?? '');
|
||||
setTypeId(loadedAsset.type.id);
|
||||
setParentId(loadedAsset.parent?.id ?? '');
|
||||
setOperationalAreaId(loadedAsset.operationalArea?.id ?? '');
|
||||
setOperatorCompanyId(loadedAsset.operatorCompany?.id ?? '');
|
||||
setDescription(loadedAsset.description ?? '');
|
||||
setStatus(loadedAsset.informationStatus);
|
||||
setOperationalStatus(loadedAsset.operationalStatus);
|
||||
setAttributeValues(Object.fromEntries(
|
||||
loadedAsset.attributes.map((attribute) => [
|
||||
attribute.definitionId,
|
||||
attribute.dataType === 'DATETIME' ? localDateTime(attribute.value) : attribute.value ?? '',
|
||||
]),
|
||||
));
|
||||
} else if (!contextParentId) {
|
||||
const first = loadedTypes.find((type) => type.isActive && type.canBeRoot) ?? loadedTypes.find((type) => type.isActive);
|
||||
const first = loadedTypes.find((type) => type.isActive && type.canBeRoot)
|
||||
?? loadedTypes.find((type) => type.isActive);
|
||||
if (first) setTypeId(first.id);
|
||||
}
|
||||
})
|
||||
@@ -135,38 +197,56 @@ export function AssetEditorPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (editing || !contextParentId || types.length === 0) return;
|
||||
Promise.all([getAsset(contextParentId), getAssetLineage(contextParentId)]).then(([parent, parentLineage]) => {
|
||||
setContextParent(parent);
|
||||
setLineage(parentLineage);
|
||||
const compatible = types.find((type) => type.isActive && type.allowedParentTypes.some((allowed) => allowed.id === parent.type.id));
|
||||
if (compatible) {
|
||||
setTypeId(compatible.id); setParentId(parent.id);
|
||||
const parentType = types.find((type) => type.id === parent.type.id);
|
||||
if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id);
|
||||
else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id);
|
||||
}
|
||||
}).catch((requestError) => setError(errorMessage(requestError)));
|
||||
Promise.all([getAsset(contextParentId), getAssetLineage(contextParentId)])
|
||||
.then(([parent, parentLineage]) => {
|
||||
setContextParent(parent);
|
||||
setLineage(parentLineage);
|
||||
const compatible = types.find(
|
||||
(type) => type.isActive && type.allowedParentTypes.some((allowed) => allowed.id === parent.type.id),
|
||||
);
|
||||
if (compatible) {
|
||||
setTypeId(compatible.id);
|
||||
setParentId(parent.id);
|
||||
const parentType = types.find((type) => type.id === parent.type.id);
|
||||
if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id);
|
||||
else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id);
|
||||
}
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)));
|
||||
}, [editing, contextParentId, types]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!typeId) { setParents([]); return; }
|
||||
const timer = window.setTimeout(() => listAssetParentOptions(typeId, id, parentSearch).then(setParents).catch((requestError) => setError(errorMessage(requestError))), 220);
|
||||
if (!typeId) {
|
||||
setParents([]);
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(
|
||||
() => listAssetParentOptions(typeId, id, parentSearch)
|
||||
.then(setParents)
|
||||
.catch((requestError) => setError(errorMessage(requestError))),
|
||||
220,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [typeId, id, parentSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) {
|
||||
setOperationalAreas([]);
|
||||
if (!parentId) { setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); }
|
||||
return;
|
||||
}
|
||||
listOperationalAreas(parentId).then((loadedAreas) => {
|
||||
setOperationalAreas(loadedAreas);
|
||||
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
|
||||
if (!parentId) {
|
||||
setOperationalAreaId('');
|
||||
if (!editing) setOperatorCompanyId('');
|
||||
}
|
||||
}).catch((requestError) => setError(errorMessage(requestError)));
|
||||
return;
|
||||
}
|
||||
listOperationalAreas(parentId)
|
||||
.then((loadedAreas) => {
|
||||
setOperationalAreas(loadedAreas);
|
||||
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
|
||||
setOperationalAreaId('');
|
||||
if (!editing) setOperatorCompanyId('');
|
||||
}
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)));
|
||||
}, [canReadRelations, selectedType?.operationalRole, parentId, editing]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -174,39 +254,85 @@ export function AssetEditorPage() {
|
||||
setOperationalCompanies([]);
|
||||
return;
|
||||
}
|
||||
listCompaniesForArea(operationalAreaId).then((loadedCompanies) => {
|
||||
setOperationalCompanies(loadedCompanies);
|
||||
setOperatorCompanyId((current) => loadedCompanies.some((company) => company.id === current)
|
||||
? current
|
||||
: loadedCompanies.length === 1 ? (loadedCompanies[0]?.id ?? '') : '');
|
||||
}).catch((requestError) => {
|
||||
setOperationalCompanies([]);
|
||||
setOperatorCompanyId('');
|
||||
setError(errorMessage(requestError));
|
||||
});
|
||||
listCompaniesForArea(operationalAreaId)
|
||||
.then((loadedCompanies) => {
|
||||
setOperationalCompanies(loadedCompanies);
|
||||
setOperatorCompanyId((current) => loadedCompanies.some((company) => company.id === current)
|
||||
? current
|
||||
: loadedCompanies.length === 1 ? (loadedCompanies[0]?.id ?? '') : '');
|
||||
})
|
||||
.catch((requestError) => {
|
||||
setOperationalCompanies([]);
|
||||
setOperatorCompanyId('');
|
||||
setError(errorMessage(requestError));
|
||||
});
|
||||
}, [editing, canReadRelations, operationalAreaId, selectedType?.operationalRole]);
|
||||
|
||||
const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); };
|
||||
const setAttribute = (definitionId: string, value: unknown) => setAttributeValues((current) => ({ ...current, [definitionId]: value }));
|
||||
const changeType = (nextTypeId: string) => {
|
||||
setTypeId(nextTypeId);
|
||||
setParentId('');
|
||||
setOperationalAreaId('');
|
||||
if (!editing) setOperatorCompanyId('');
|
||||
setAttributeValues({});
|
||||
setParentSearch('');
|
||||
};
|
||||
|
||||
const setAttribute = (definitionId: string, value: unknown) =>
|
||||
setAttributeValues((current) => ({ ...current, [definitionId]: value }));
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault(); if (!selectedType) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
event.preventDefault();
|
||||
if (!selectedType) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
const attributes = normalizeAttributeValues(definitions, attributeValues);
|
||||
let saved: AssetDetail;
|
||||
if (editing && id) {
|
||||
if (canEdit) saved = await updateAsset(id, { typeId: canDirectContextEdit ? typeId : undefined, code, name, commonName: commonName.trim() || null, parentId: canDirectContextEdit ? parentId || null : undefined, operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined, description: description.trim() || null, attributes });
|
||||
else if (asset) saved = asset; else return;
|
||||
if (canChangeStatus && saved.informationStatus !== status) saved = await updateAssetInformationStatus(id, status);
|
||||
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) saved = await updateAssetOperationalStatus(id, operationalStatus);
|
||||
setAsset(saved); setSuccess('Registro actualizado correctamente'); setHistoryRefreshKey((current) => current + 1);
|
||||
if (canEdit) {
|
||||
saved = await updateAsset(id, {
|
||||
typeId: canDirectContextEdit ? typeId : undefined,
|
||||
code,
|
||||
name,
|
||||
commonName: commonName.trim() || null,
|
||||
parentId: canDirectContextEdit ? parentId || null : undefined,
|
||||
operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined,
|
||||
description: description.trim() || null,
|
||||
attributes,
|
||||
});
|
||||
} else if (asset) saved = asset;
|
||||
else return;
|
||||
|
||||
if (canChangeStatus && saved.informationStatus !== status) {
|
||||
saved = await updateAssetInformationStatus(id, status);
|
||||
}
|
||||
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) {
|
||||
saved = await updateAssetOperationalStatus(id, operationalStatus);
|
||||
}
|
||||
setAsset(saved);
|
||||
setSuccess('Registro actualizado correctamente');
|
||||
setHistoryRefreshKey((current) => current + 1);
|
||||
} else {
|
||||
saved = await createAsset({ code, name, commonName: commonName.trim() || null, typeId, parentId: parentId || null, operationalAreaId: operationalAreaId || null, operatorCompanyId: operatorCompanyId || null, description: description.trim() || null, informationStatus: canChangeStatus ? status : 'DRAFT', attributes });
|
||||
saved = await createAsset({
|
||||
code,
|
||||
name,
|
||||
commonName: commonName.trim() || null,
|
||||
typeId,
|
||||
parentId: parentId || null,
|
||||
operationalAreaId: operationalAreaId || null,
|
||||
operatorCompanyId: operatorCompanyId || null,
|
||||
description: description.trim() || null,
|
||||
informationStatus: canChangeStatus ? status : 'DRAFT',
|
||||
attributes,
|
||||
});
|
||||
navigate(`/inventarios/${saved.id}`, { replace: true });
|
||||
}
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando registro…" />;
|
||||
@@ -221,44 +347,627 @@ export function AssetEditorPage() {
|
||||
{ key: 'history', label: 'Historial', show: editing && canReadHistory },
|
||||
] as const;
|
||||
|
||||
const breadcrumbSection = asset?.type.code === 'empresa' || contextParent?.type.code === 'empresa' ? 'companies' : 'territory';
|
||||
const breadcrumbSection = asset?.type.code === 'empresa' || contextParent?.type.code === 'empresa'
|
||||
? 'companies'
|
||||
: 'territory';
|
||||
|
||||
const structuralTypeCode = normalizeStructuralType(selectedType?.code);
|
||||
const structuralTypeName = normalizeStructuralType(selectedType?.name);
|
||||
const compactStructuralSummary = editing && (
|
||||
['instalacion', 'instalacion-superficie', 'instalacion_de_superficie', 'instalacion-de-superficie', 'subinstalacion']
|
||||
.includes(structuralTypeCode)
|
||||
|| structuralTypeName === 'instalacion'
|
||||
|| structuralTypeName === 'instalacion de superficie'
|
||||
|| structuralTypeName === 'subinstalacion'
|
||||
);
|
||||
|
||||
const technicalFilledCount = definitions.filter(
|
||||
(definition) => hasAttributeValue(definition, attributeValues[definition.id]),
|
||||
).length;
|
||||
const missingRequiredTechnical = definitions.some(
|
||||
(definition) => definition.isRequired && !hasAttributeValue(definition, attributeValues[definition.id]),
|
||||
);
|
||||
|
||||
const handleContextChanged = (saved: AssetDetail) => {
|
||||
setAsset(saved);
|
||||
setParentId(saved.parent?.id ?? '');
|
||||
setOperationalAreaId(saved.operationalArea?.id ?? '');
|
||||
setOperatorCompanyId(saved.operatorCompany?.id ?? '');
|
||||
setLineage([]);
|
||||
getAssetLineage(saved.id).then(setLineage).catch(() => undefined);
|
||||
setHistoryRefreshKey((current) => current + 1);
|
||||
};
|
||||
|
||||
const renderAttributes = (): ReactNode => {
|
||||
if (definitions.length === 0) {
|
||||
return <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div>;
|
||||
}
|
||||
|
||||
return <div className="dynamic-attributes">
|
||||
{definitions.map((definition) => {
|
||||
const value = attributeValues[definition.id];
|
||||
const label = <span>
|
||||
{definition.name}{definition.unit ? ` (${definition.unit})` : ''}
|
||||
{definition.isRequired ? <em>obligatorio</em> : <em>opcional</em>}
|
||||
</span>;
|
||||
|
||||
if (definition.dataType === 'BOOLEAN') {
|
||||
return <label className="check-row attribute-check" key={definition.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => setAttribute(definition.id, event.target.checked)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<span><strong>{definition.name}</strong><small>{definition.code}</small></span>
|
||||
</label>;
|
||||
}
|
||||
|
||||
if (definition.dataType === 'SELECT') {
|
||||
return <label className="field" key={definition.id}>
|
||||
{label}
|
||||
<SearchableSelect
|
||||
value={String(value ?? '')}
|
||||
onChange={(event) => setAttribute(definition.id, event.target.value)}
|
||||
disabled={!canEdit}
|
||||
required={definition.isRequired}
|
||||
>
|
||||
<option value="">Seleccionar…</option>
|
||||
{definition.options?.map((option) =>
|
||||
<option key={option} value={option}>{option}</option>)}
|
||||
</SearchableSelect>
|
||||
</label>;
|
||||
}
|
||||
|
||||
const inputType = definition.dataType === 'NUMBER'
|
||||
? 'number'
|
||||
: definition.dataType === 'DATE'
|
||||
? 'date'
|
||||
: definition.dataType === 'DATETIME'
|
||||
? 'datetime-local'
|
||||
: 'text';
|
||||
|
||||
return <label className="field" key={definition.id}>
|
||||
{label}
|
||||
<input
|
||||
type={inputType}
|
||||
value={String(value ?? '')}
|
||||
onChange={(event) => setAttribute(definition.id, event.target.value)}
|
||||
disabled={!canEdit}
|
||||
required={definition.isRequired}
|
||||
step={definition.dataType === 'NUMBER' ? 'any' : undefined}
|
||||
maxLength={definition.dataType === 'TEXT' ? 4000 : undefined}
|
||||
/>
|
||||
</label>;
|
||||
})}
|
||||
</div>;
|
||||
};
|
||||
|
||||
const renderSaveActions = () => canSave && <div className="form-actions">
|
||||
<Link className="button secondary" to="/inventarios">Cancelar</Link>
|
||||
<button className="button primary" disabled={saving}>
|
||||
<Icon name="check" />
|
||||
{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}
|
||||
</button>
|
||||
</div>;
|
||||
|
||||
const renderCompactStructuralForm = () => <form className="panel form-panel asset-compact-form" onSubmit={save}>
|
||||
<section className="asset-compact-section">
|
||||
<div className="asset-compact-section-heading">
|
||||
<div>
|
||||
<h2>Datos principales</h2>
|
||||
<p className="section-copy">Lo que se usa todos los días para reconocer este registro.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid asset-compact-primary-grid">
|
||||
<label className="field">
|
||||
<span>Nombre técnico</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
disabled={!canEdit}
|
||||
required
|
||||
maxLength={200}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Nombre habitual / sobrenombre <em>opcional</em></span>
|
||||
<input
|
||||
value={commonName}
|
||||
onChange={(event) => setCommonName(event.target.value)}
|
||||
disabled={!canEdit}
|
||||
maxLength={200}
|
||||
placeholder="Ej.: planta vieja, celda principal…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="asset-compact-meta">
|
||||
<span><small>Código DH</small><strong>{code}</strong></span>
|
||||
<span><small>Tipo</small><strong>{selectedType?.name ?? 'Sin tipo'}</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="asset-compact-section">
|
||||
<div className="asset-compact-section-heading">
|
||||
<div>
|
||||
<h2>Ubicación actual</h2>
|
||||
<p className="section-copy">Dónde está contenido el elemento dentro del Inventario.</p>
|
||||
</div>
|
||||
{canReadHistory && id && <Link className="button secondary asset-compact-context-button" to={`/inventarios/${id}?tab=history`}>
|
||||
<Icon name="edit" />Cambiar / ver historial
|
||||
</Link>}
|
||||
</div>
|
||||
|
||||
<div className="asset-compact-context-grid">
|
||||
<div className="asset-compact-context-item">
|
||||
<small>Registro padre</small>
|
||||
<strong>{asset?.parent?.name ?? 'Sin padre'}</strong>
|
||||
{asset?.parent?.code && <span>{asset.parent.code}</span>}
|
||||
</div>
|
||||
<div className="asset-compact-context-item">
|
||||
<small>Área</small>
|
||||
<strong>{asset?.operationalArea?.name ?? 'Sin área asignada'}</strong>
|
||||
{asset?.operationalArea?.code && <span>{asset.operationalArea.code}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(canChangeStatus || canChangeOperationalStatus) && <details className="asset-compact-details">
|
||||
<summary>
|
||||
<span><strong>Estado y opciones</strong><small>Calidad del dato y situación operativa</small></span>
|
||||
<span aria-hidden="true">⌄</span>
|
||||
</summary>
|
||||
<div className="asset-compact-details-body">
|
||||
<div className="form-grid">
|
||||
{canChangeStatus && <label className="field">
|
||||
<span>Estado del dato</span>
|
||||
<SearchableSelect
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}
|
||||
disabled={!canEdit && !canChangeStatus}
|
||||
>
|
||||
{ASSET_STATUSES.map((item) =>
|
||||
<option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</SearchableSelect>
|
||||
</label>}
|
||||
{canChangeOperationalStatus && <label className="field">
|
||||
<span>Estado operativo</span>
|
||||
<SearchableSelect
|
||||
value={operationalStatus}
|
||||
onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}
|
||||
>
|
||||
{ASSET_OPERATIONAL_STATUSES.map((item) =>
|
||||
<option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</SearchableSelect>
|
||||
</label>}
|
||||
</div>
|
||||
</div>
|
||||
</details>}
|
||||
|
||||
<details className="asset-compact-details" open={missingRequiredTechnical || canDirectContextEdit}>
|
||||
<summary>
|
||||
<span>
|
||||
<strong>Más datos</strong>
|
||||
<small>
|
||||
{description.trim() ? 'Descripción cargada' : 'Sin descripción'}
|
||||
{' · '}
|
||||
{technicalFilledCount}/{definitions.length} datos técnicos cargados
|
||||
</small>
|
||||
</span>
|
||||
<span aria-hidden="true">⌄</span>
|
||||
</summary>
|
||||
<div className="asset-compact-details-body">
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Código DH</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value.toUpperCase())}
|
||||
disabled={!canEdit}
|
||||
required
|
||||
maxLength={120}
|
||||
pattern="[A-Z0-9][A-Z0-9._/-]*"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{canDirectContextEdit && <label className="field">
|
||||
<span>Tipo de elemento</span>
|
||||
<SearchableSelect
|
||||
value={typeId}
|
||||
onChange={(event) => changeType(event.target.value)}
|
||||
disabled={!canEdit}
|
||||
required
|
||||
>
|
||||
<option value="">Seleccionar…</option>
|
||||
{types.filter((type) => type.isActive || type.id === typeId).map((type) =>
|
||||
<option key={type.id} value={type.id}>{type.name}</option>)}
|
||||
</SearchableSelect>
|
||||
</label>}
|
||||
</div>
|
||||
|
||||
{canDirectContextEdit && <div className="form-grid">
|
||||
<div className="field parent-picker">
|
||||
<span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span>
|
||||
<input
|
||||
className="parent-search"
|
||||
value={parentSearch}
|
||||
onChange={(event) => setParentSearch(event.target.value)}
|
||||
disabled={!canEdit}
|
||||
placeholder="Buscar planta, batería, estación…"
|
||||
/>
|
||||
<SearchableSelect
|
||||
value={parentId}
|
||||
onChange={(event) => {
|
||||
setParentId(event.target.value);
|
||||
setParentSearch('');
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
required={!selectedType?.canBeRoot}
|
||||
>
|
||||
<option value="">
|
||||
{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}
|
||||
</option>
|
||||
{parents.map((parent) =>
|
||||
<option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}
|
||||
</SearchableSelect>
|
||||
</div>
|
||||
|
||||
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <label className="field">
|
||||
<span>Área</span>
|
||||
<SearchableSelect
|
||||
value={operationalAreaId}
|
||||
onChange={(event) => setOperationalAreaId(event.target.value)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<option value="">Sin asignación operativa</option>
|
||||
{operationalAreas.map((area) =>
|
||||
<option key={area.id} value={area.id}>{area.name}</option>)}
|
||||
</SearchableSelect>
|
||||
</label>}
|
||||
</div>}
|
||||
|
||||
<label className="field">
|
||||
<span>Descripción <em>opcional</em></span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
disabled={!canEdit}
|
||||
maxLength={4000}
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="asset-compact-technical">
|
||||
<div>
|
||||
<h3>Datos técnicos</h3>
|
||||
<p className="section-copy">Sólo completá lo que corresponda para este tipo.</p>
|
||||
</div>
|
||||
{renderAttributes()}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{renderSaveActions()}
|
||||
</form>;
|
||||
|
||||
const renderFullForm = () => <form className="panel form-panel" onSubmit={save}>
|
||||
<div className="form-section">
|
||||
<div>
|
||||
<h2>Identificación</h2>
|
||||
<p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Código DH</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value.toUpperCase())}
|
||||
disabled={!canEdit}
|
||||
required
|
||||
maxLength={120}
|
||||
pattern="[A-Z0-9][A-Z0-9._/-]*"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Nombre técnico</span>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Nombre habitual / sobrenombre <em>opcional</em></span>
|
||||
<input
|
||||
value={commonName}
|
||||
onChange={(event) => setCommonName(event.target.value)}
|
||||
disabled={!canEdit}
|
||||
maxLength={200}
|
||||
placeholder="Ej.: tanque grande, ET vieja, batería norte…"
|
||||
/>
|
||||
<small>También se usa en las búsquedas del Inventario.</small>
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Descripción <em>opcional</em></span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
disabled={!canEdit}
|
||||
maxLength={4000}
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div>
|
||||
<h2>Ubicación en la estructura</h2>
|
||||
<p className="section-copy">
|
||||
Elegí qué es y dónde está contenido. La ubicación física es independiente de la Operadora del Área.
|
||||
Los registros ya consolidados cambian de ubicación desde el bloque histórico inferior.
|
||||
</p>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Tipo de elemento</span>
|
||||
<SearchableSelect
|
||||
value={typeId}
|
||||
onChange={(event) => changeType(event.target.value)}
|
||||
disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit}
|
||||
required
|
||||
>
|
||||
<option value="">Seleccionar…</option>
|
||||
{types.filter((type) => type.isActive || type.id === typeId).map((type) =>
|
||||
<option key={type.id} value={type.id}>{type.name}</option>)}
|
||||
</SearchableSelect>
|
||||
</label>
|
||||
|
||||
<div className="field parent-picker">
|
||||
<span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span>
|
||||
<input
|
||||
className="parent-search"
|
||||
value={parentSearch}
|
||||
onChange={(event) => setParentSearch(event.target.value)}
|
||||
disabled={!canEdit || !canDirectContextEdit}
|
||||
placeholder="Buscar planta, batería, estación…"
|
||||
/>
|
||||
<SearchableSelect
|
||||
value={parentId}
|
||||
onChange={(event) => {
|
||||
setParentId(event.target.value);
|
||||
setParentSearch('');
|
||||
}}
|
||||
disabled={!canEdit || !canDirectContextEdit}
|
||||
required={!selectedType?.canBeRoot}
|
||||
>
|
||||
<option value="">
|
||||
{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}
|
||||
</option>
|
||||
{parents.map((parent) =>
|
||||
<option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}
|
||||
</SearchableSelect>
|
||||
<small>Escribí para buscar entre los registros compatibles.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
{canChangeStatus && <label className="field">
|
||||
<span>Estado del dato</span>
|
||||
<SearchableSelect
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}
|
||||
disabled={!canEdit && !canChangeStatus}
|
||||
>
|
||||
{ASSET_STATUSES.map((item) =>
|
||||
<option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</SearchableSelect>
|
||||
<small>Calidad y validación del registro.</small>
|
||||
</label>}
|
||||
|
||||
{editing && canChangeOperationalStatus && <label className="field">
|
||||
<span>Estado operativo</span>
|
||||
<SearchableSelect
|
||||
value={operationalStatus}
|
||||
onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}
|
||||
>
|
||||
{ASSET_OPERATIONAL_STATUSES.map((item) =>
|
||||
<option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</SearchableSelect>
|
||||
<small>Situación física u operativa del elemento.</small>
|
||||
</label>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section">
|
||||
<div>
|
||||
<h2>{editing ? 'Área física' : 'Área y operadora al alta'}</h2>
|
||||
<p className="section-copy">
|
||||
{editing
|
||||
? 'El Inventario pertenece físicamente al Área. La Operadora vigente se administra en las relaciones temporales del Área y no se reescribe dentro del Inventario.'
|
||||
: 'Al crear el registro, la Operadora activa del Área queda guardada únicamente como snapshot histórico de alta.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Área</span>
|
||||
<SearchableSelect
|
||||
value={operationalAreaId}
|
||||
onChange={(event) => {
|
||||
setOperationalAreaId(event.target.value);
|
||||
if (!editing) setOperatorCompanyId('');
|
||||
}}
|
||||
disabled={!canEdit || !canDirectContextEdit}
|
||||
>
|
||||
<option value="">Sin asignación operativa</option>
|
||||
{operationalAreas.map((area) =>
|
||||
<option key={area.id} value={area.id}>{area.name}</option>)}
|
||||
</SearchableSelect>
|
||||
{editing && <small>La Operadora se resuelve por la relación Área↔Empresa vigente.</small>}
|
||||
</label>
|
||||
|
||||
{!editing && <label className="field">
|
||||
<span>Operadora {operationalAreaId && <em>obligatoria</em>}</span>
|
||||
<SearchableSelect
|
||||
value={operatorCompanyId}
|
||||
onChange={(event) => setOperatorCompanyId(event.target.value)}
|
||||
disabled={!canEdit || !operationalAreaId}
|
||||
required={Boolean(operationalAreaId)}
|
||||
>
|
||||
<option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>
|
||||
{operationalCompanies.map((company) =>
|
||||
<option key={company.id} value={company.id}>{company.name}</option>)}
|
||||
</SearchableSelect>
|
||||
</label>}
|
||||
</div>
|
||||
|
||||
{!editing && operationalAreaId && operatorCompanyId && <div className="temporal-notice">
|
||||
<Icon name="check" />
|
||||
<p><strong>Contexto de alta confirmado.</strong> La Operadora seleccionada se conservará como referencia histórica; futuros cambios se harán en la relación temporal del Área.</p>
|
||||
</div>}
|
||||
|
||||
{editing && asset?.operatorCompany && <div className="temporal-notice">
|
||||
<Icon name="layers" />
|
||||
<p><strong>Snapshot histórico de alta:</strong> {asset.operatorCompany.name}. No se modifica desde este registro.</p>
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
<div className="form-section">
|
||||
<div>
|
||||
<h2>Datos técnicos</h2>
|
||||
<p className="section-copy">Campos definidos para el tipo seleccionado.</p>
|
||||
</div>
|
||||
{renderAttributes()}
|
||||
</div>
|
||||
|
||||
{renderSaveActions()}
|
||||
</form>;
|
||||
|
||||
return <section className="narrow-section asset-detail-page">
|
||||
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario">
|
||||
<Link to="/inventarios">Inventarios</Link><span>›</span>
|
||||
<Link to={breadcrumbSection === 'companies' ? '/inventarios?section=companies' : '/inventarios?section=territory'}>{breadcrumbSection === 'companies' ? 'Empresas' : 'Áreas y yacimientos'}</Link>
|
||||
<Link to={breadcrumbSection === 'companies' ? '/inventarios?section=companies' : '/inventarios?section=territory'}>
|
||||
{breadcrumbSection === 'companies' ? 'Empresas' : 'Áreas y yacimientos'}
|
||||
</Link>
|
||||
{lineage.map((item, index) => {
|
||||
const isLast = index === lineage.length - 1;
|
||||
const showAsCurrent = editing ? isLast : false;
|
||||
return <span className="asset-detail-crumb-part" key={item.id}><span>›</span>{showAsCurrent ? <strong>{item.name}</strong> : <Link to={`/inventarios?section=${breadcrumbSection}&parentId=${item.id}`}>{item.name}</Link>}</span>;
|
||||
return <span className="asset-detail-crumb-part" key={item.id}>
|
||||
<span>›</span>
|
||||
{showAsCurrent
|
||||
? <strong>{item.name}</strong>
|
||||
: <Link to={`/inventarios?section=${breadcrumbSection}&parentId=${item.id}`}>{item.name}</Link>}
|
||||
</span>;
|
||||
})}
|
||||
{!editing && <><span>›</span><strong>Nuevo registro</strong></>}
|
||||
</nav>
|
||||
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">INVENTARIO</span><h1>{editing ? asset?.name ?? 'Registro' : contextParent ? `Agregar en ${contextParent.name}` : 'Nuevo registro'}</h1><p>{editing ? `${asset?.type.name} · ${asset?.code}` : contextParent ? `El sistema heredará el contexto disponible de ${contextParent.code}.` : 'Creá una entidad con identidad, jerarquía y atributos propios.'}</p></div><div className="asset-heading-actions">{editing && asset && <div className="heading-statuses"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div>}{editing && id && canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${id}`}><Icon name="plus" />Agregar registro aquí</Link>}</div></div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
{editing && <nav className="asset-detail-tabs">{detailTabs.filter((item) => item.show).map((item) => <Link key={item.key} className={tab === item.key ? 'active' : ''} to={`/inventarios/${id}${item.key === 'summary' ? '' : `?tab=${item.key}`}`}>{item.label}</Link>)}</nav>}
|
||||
<div className="page-heading asset-editor-heading">
|
||||
<div>
|
||||
<span className="eyebrow">INVENTARIO</span>
|
||||
<h1>{editing ? asset?.name ?? 'Registro' : contextParent ? `Agregar en ${contextParent.name}` : 'Nuevo registro'}</h1>
|
||||
<p>
|
||||
{editing
|
||||
? `${asset?.type.name} · ${asset?.code}`
|
||||
: contextParent
|
||||
? `El sistema heredará el contexto disponible de ${contextParent.code}.`
|
||||
: 'Creá una entidad con identidad, jerarquía y atributos propios.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="asset-heading-actions">
|
||||
{editing && asset && <div className="heading-statuses">
|
||||
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>
|
||||
{assetStatusLabel(asset.informationStatus)}
|
||||
</span>
|
||||
<small>{assetOperationalStatusLabel(asset.operationalStatus)}</small>
|
||||
</div>}
|
||||
{editing && id && canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${id}`}>
|
||||
<Icon name="plus" />Agregar registro aquí
|
||||
</Link>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
{editing && <nav className="asset-detail-tabs">
|
||||
{detailTabs.filter((item) => item.show).map((item) =>
|
||||
<Link
|
||||
key={item.key}
|
||||
className={tab === item.key ? 'active' : ''}
|
||||
to={`/inventarios/${id}${item.key === 'summary' ? '' : `?tab=${item.key}`}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>)}
|
||||
</nav>}
|
||||
|
||||
{(!editing || tab === 'summary') && <>
|
||||
{contextParent && !editing && <div className="context-create-banner"><Icon name="layers" /><div><strong>Alta contextual</strong><span>{contextParent.name} · {contextParent.code}</span></div><Link to={`/inventarios/${contextParent.id}`}>Ver padre</Link></div>}
|
||||
<form className="panel form-panel" onSubmit={save}>
|
||||
<div className="form-section"><div><h2>Identificación</h2><p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p></div><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} disabled={!canEdit} required maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" /></label><label className="field"><span>Nombre técnico</span><input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} /></label><label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} disabled={!canEdit} maxLength={200} placeholder="Ej.: tanque grande, ET vieja, batería norte…" /><small>También se usa en las búsquedas del Inventario.</small></label></div><label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canEdit} maxLength={4000} rows={2} /></label></div>
|
||||
{contextParent && !editing && <div className="context-create-banner">
|
||||
<Icon name="layers" />
|
||||
<div><strong>Alta contextual</strong><span>{contextParent.name} · {contextParent.code}</span></div>
|
||||
<Link to={`/inventarios/${contextParent.id}`}>Ver padre</Link>
|
||||
</div>}
|
||||
|
||||
<div className="form-section"><div><h2>Ubicación en la estructura</h2><p className="section-copy">Elegí qué es y dónde está contenido. La ubicación física es independiente de la Operadora del Área. Los registros ya consolidados cambian de ubicación desde el bloque histórico inferior.</p></div><div className="form-grid"><label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => changeType(event.target.value)} disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit} required><option value="">Seleccionar…</option>{types.filter((type) => type.isActive || type.id === typeId).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label><div className="field parent-picker"><span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} disabled={!canEdit || !canDirectContextEdit} placeholder="Buscar planta, batería, estación…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} disabled={!canEdit || !canDirectContextEdit} required={!selectedType?.canBeRoot}><option value="">{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect><small>Escribí para buscar entre los registros compatibles.</small></div></div><div className="form-grid">{canChangeStatus && <label className="field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)} disabled={!canEdit && !canChangeStatus}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Calidad y validación del registro.</small></label>}{editing && canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><SearchableSelect value={operationalStatus} onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Situación física u operativa del elemento.</small></label>}</div></div>
|
||||
{compactStructuralSummary ? renderCompactStructuralForm() : renderFullForm()}
|
||||
|
||||
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section"><div><h2>{editing ? 'Área física' : 'Área y operadora al alta'}</h2><p className="section-copy">{editing ? 'El Inventario pertenece físicamente al Área. La Operadora vigente se administra en las relaciones temporales del Área y no se reescribe dentro del Inventario.' : 'Al crear el registro, la Operadora activa del Área queda guardada únicamente como snapshot histórico de alta.'}</p></div><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); if (!editing) setOperatorCompanyId(''); }} disabled={!canEdit || !canDirectContextEdit}><option value="">Sin asignación operativa</option>{operationalAreas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect>{editing && <small>La Operadora se resuelve por la relación Área↔Empresa vigente.</small>}</label>{!editing && <label className="field"><span>Operadora {operationalAreaId && <em>obligatoria</em>}</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} disabled={!canEdit || !operationalAreaId} required={Boolean(operationalAreaId)}><option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>{operationalCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label>}</div>{!editing && operationalAreaId && operatorCompanyId && <div className="temporal-notice"><Icon name="check" /><p><strong>Contexto de alta confirmado.</strong> La Operadora seleccionada se conservará como referencia histórica; futuros cambios se harán en la relación temporal del Área.</p></div>}{editing && asset?.operatorCompany && <div className="temporal-notice"><Icon name="layers" /><p><strong>Snapshot histórico de alta:</strong> {asset.operatorCompany.name}. No se modifica desde este registro.</p></div>}</div>}
|
||||
{!compactStructuralSummary && editing && id && asset && selectedType && canReadHistory
|
||||
&& <AssetContextHistoryPanel
|
||||
asset={asset}
|
||||
type={selectedType}
|
||||
canManage={canManageContext}
|
||||
onChanged={handleContextChanged}
|
||||
/>}
|
||||
|
||||
<div className="form-section"><div><h2>Datos técnicos</h2><p className="section-copy">Campos definidos para el tipo seleccionado.</p></div>{definitions.length === 0 ? <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div> : <div className="dynamic-attributes">{definitions.map((definition) => { const value = attributeValues[definition.id]; const label = <span>{definition.name}{definition.unit ? ` (${definition.unit})` : ''}{definition.isRequired ? <em>obligatorio</em> : <em>opcional</em>}</span>; if (definition.dataType === 'BOOLEAN') return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event) => setAttribute(definition.id, event.target.checked)} disabled={!canEdit} /><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>; if (definition.dataType === 'SELECT') return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired}><option value="">Seleccionar…</option>{definition.options?.map((option) => <option key={option} value={option}>{option}</option>)}</SearchableSelect></label>; const inputType = definition.dataType === 'NUMBER' ? 'number' : definition.dataType === 'DATE' ? 'date' : definition.dataType === 'DATETIME' ? 'datetime-local' : 'text'; return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired} step={definition.dataType === 'NUMBER' ? 'any' : undefined} maxLength={definition.dataType === 'TEXT' ? 4000 : undefined} /></label>; })}</div>}</div>
|
||||
{canSave && <div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}</button></div>}
|
||||
</form>
|
||||
{editing && id && asset && selectedType && canReadHistory && <AssetContextHistoryPanel asset={asset} type={selectedType} canManage={canManageContext} onChanged={(saved) => { setAsset(saved); setParentId(saved.parent?.id ?? ''); setOperationalAreaId(saved.operationalArea?.id ?? ''); setOperatorCompanyId(saved.operatorCompany?.id ?? ''); setLineage([]); getAssetLineage(saved.id).then(setLineage).catch(() => undefined); setHistoryRefreshKey((current) => current + 1); }} />}
|
||||
{editing && id && asset && canReadRelations && selectedType && selectedType.operationalRole !== 'GENERIC' && <AssetOperationalRelationsPanel assetId={id} role={selectedType.operationalRole} canManage={canManageRelations} />}
|
||||
{editing && id && asset && canReadRelations && selectedType && selectedType.operationalRole !== 'GENERIC'
|
||||
&& <AssetOperationalRelationsPanel assetId={id} role={selectedType.operationalRole} canManage={canManageRelations} />}
|
||||
</>}
|
||||
|
||||
{editing && id && asset && tab === 'findings' && canReadFindingCatalog && <AssetFindingCatalogPanel assetId={id} canManage={canManageFindingCatalog} />}
|
||||
{editing && id && asset && tab === 'dossier' && canReadDossier && <AssetDossierPanel assetId={id} />}
|
||||
{editing && id && asset && tab === 'location' && <Suspense fallback={<div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>}><AssetGeometryEditor assetId={id} assetName={asset.name} canEdit={canEditGeometry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} /></Suspense>}
|
||||
{editing && id && asset && tab === 'registry' && <div className="asset-tab-stack">{canReadRegistry && selectedType && <AssetRegistryPanel assetId={id} assetName={asset.name} role={selectedType.operationalRole} canManage={canManageRegistry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}{canReadProvenance && <AssetProvenancePanel assetId={id} canManage={canManageProvenance} canVerify={canVerifyProvenance} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}</div>}
|
||||
{editing && id && asset && tab === 'files' && canReadMedia && <AssetMediaPanel assetId={id} assetName={asset.name} canManage={canManageMedia} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}
|
||||
{editing && id && tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={id} refreshKey={historyRefreshKey} />}
|
||||
{editing && id && asset && tab === 'findings' && canReadFindingCatalog
|
||||
&& <AssetFindingCatalogPanel assetId={id} canManage={canManageFindingCatalog} />}
|
||||
|
||||
{editing && id && asset && tab === 'dossier' && canReadDossier
|
||||
&& <AssetDossierPanel assetId={id} />}
|
||||
|
||||
{editing && id && asset && tab === 'location'
|
||||
&& <Suspense fallback={<div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>}>
|
||||
<AssetGeometryEditor
|
||||
assetId={id}
|
||||
assetName={asset.name}
|
||||
canEdit={canEditGeometry}
|
||||
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
|
||||
/>
|
||||
</Suspense>}
|
||||
|
||||
{editing && id && asset && tab === 'registry' && <div className="asset-tab-stack">
|
||||
{canReadRegistry && selectedType && <AssetRegistryPanel
|
||||
assetId={id}
|
||||
assetName={asset.name}
|
||||
role={selectedType.operationalRole}
|
||||
canManage={canManageRegistry}
|
||||
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
|
||||
/>}
|
||||
{canReadProvenance && <AssetProvenancePanel
|
||||
assetId={id}
|
||||
canManage={canManageProvenance}
|
||||
canVerify={canVerifyProvenance}
|
||||
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
|
||||
/>}
|
||||
</div>}
|
||||
|
||||
{editing && id && asset && tab === 'files' && canReadMedia
|
||||
&& <AssetMediaPanel
|
||||
assetId={id}
|
||||
assetName={asset.name}
|
||||
canManage={canManageMedia}
|
||||
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
|
||||
/>}
|
||||
|
||||
{editing && id && tab === 'history' && canReadHistory && <div className="asset-tab-stack">
|
||||
{compactStructuralSummary && asset && selectedType && <AssetContextHistoryPanel
|
||||
asset={asset}
|
||||
type={selectedType}
|
||||
canManage={canManageContext}
|
||||
onChanged={handleContextChanged}
|
||||
/>}
|
||||
<AssetHistoryPanel assetId={id} refreshKey={historyRefreshKey} />
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -1,203 +1,305 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { getFindingCatalogAdmin, listAssetTypes } from '../lib/api';
|
||||
import type { AssetType, FindingAdminCatalog } from '../lib/api';
|
||||
import { listInventoryFamiliesAdmin } from '../lib/inventoryStructureApi';
|
||||
import type { InventoryFamily } from '../lib/inventoryStructureApi';
|
||||
import {
|
||||
createInventoryFamily,
|
||||
createInventoryFamilyAttribute,
|
||||
getInventoryFamilyAttributes,
|
||||
listInventoryFamiliesAdmin,
|
||||
updateInventoryFamily,
|
||||
updateInventoryFamilyAttribute,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
import type {
|
||||
InventoryFamily,
|
||||
InventoryFamilyAttribute,
|
||||
InventoryFamilyAttributeDataType,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
|
||||
type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
|
||||
type FamilyLevel = 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
|
||||
type StructuralField = {
|
||||
label: string;
|
||||
detail: string;
|
||||
required?: boolean;
|
||||
relation?: boolean;
|
||||
};
|
||||
|
||||
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string; fields: StructuralField[] }> = [
|
||||
{
|
||||
kind: 'EMPRESA',
|
||||
label: 'Empresa',
|
||||
description: 'Maestro independiente. La Empresa se relaciona directamente con uno o más Yacimientos.',
|
||||
fields: [{ label: 'Nombre', detail: 'Nombre de la Empresa / Operadora.', required: true }],
|
||||
},
|
||||
{
|
||||
kind: 'DEPARTAMENTO',
|
||||
label: 'Departamento',
|
||||
description: 'Raíz territorial. No depende de ningún otro nivel.',
|
||||
fields: [{ label: 'Nombre', detail: 'Nombre del Departamento.', required: true }],
|
||||
},
|
||||
{
|
||||
kind: 'AREA',
|
||||
label: 'Área',
|
||||
description: 'El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento.',
|
||||
fields: [
|
||||
{ label: 'Departamento', detail: 'Relación obligatoria Departamento → Área.', required: true, relation: true },
|
||||
{ label: 'Nombre', detail: 'Nombre del Área.', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'YACIMIENTO',
|
||||
label: 'Yacimiento',
|
||||
description: 'El Yacimiento concentra el contexto operativo: Área, Tipo de concesión y Empresa relacionada.',
|
||||
fields: [
|
||||
{ label: 'Área', detail: 'Relación obligatoria Área → Yacimiento.', required: true, relation: true },
|
||||
{ label: 'Tipo de concesión', detail: 'Explotación o Exploración, según la fuente.', required: true, relation: true },
|
||||
{ label: 'Empresa relacionada', detail: 'Empresa / Operadora asociada directamente al Yacimiento.', required: true, relation: true },
|
||||
{ label: 'Nombre', detail: 'Nombre del Yacimiento. Puede repetirse en otra Área.', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INSTALACION',
|
||||
label: 'Instalación',
|
||||
description: 'Cada Instalación pertenece a un Yacimiento y posee una clasificación técnica.',
|
||||
fields: [
|
||||
{ label: 'Yacimiento', detail: 'Relación obligatoria Yacimiento → Instalación.', required: true, relation: true },
|
||||
{ label: 'Tipo de instalación', detail: 'Clasificación técnica tomada del modelo de Instalaciones.', required: true, relation: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'SUBINSTALACION',
|
||||
label: 'Subinstalación',
|
||||
description: 'Cada Subinstalación pertenece a una Instalación y usa una clasificación compatible con ella.',
|
||||
fields: [
|
||||
{ label: 'Instalación', detail: 'Relación obligatoria Instalación → Subinstalación.', required: true, relation: true },
|
||||
{ label: 'Tipo de subinstalación', detail: 'Clasificación técnica compatible con el Tipo de instalación padre.', required: true, relation: true },
|
||||
],
|
||||
},
|
||||
const FIELD_TYPES: Array<{ value: InventoryFamilyAttributeDataType; label: string }> = [
|
||||
{ value: 'TEXT', label: 'Texto' },
|
||||
{ value: 'NUMBER', label: 'Número' },
|
||||
{ value: 'DATE', label: 'Fecha' },
|
||||
{ value: 'BOOLEAN', label: 'Sí / No' },
|
||||
];
|
||||
|
||||
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
|
||||
function fieldTypeLabel(value: InventoryFamilyAttributeDataType) {
|
||||
return FIELD_TYPES.find((item) => item.value === value)?.label
|
||||
?? (value === 'DATETIME' ? 'Fecha y hora' : value === 'SELECT' ? 'Lista' : value);
|
||||
}
|
||||
|
||||
function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null {
|
||||
if (kind === 'EMPRESA') return types.find((type) => type.operationalRole === 'COMPANY' && type.isActive) ?? null;
|
||||
if (kind === 'AREA') return types.find((type) => type.operationalRole === 'AREA' && type.isActive) ?? null;
|
||||
return types.find((type) => type.code.toLowerCase() === kind.toLowerCase() && type.isActive) ?? null;
|
||||
function fieldCode(name: string) {
|
||||
return name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 100) || `campo_${Date.now()}`;
|
||||
}
|
||||
|
||||
export function AuthoritativeInventoryConfigPage() {
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('asset_types.manage');
|
||||
const [families, setFamilies] = useState<InventoryFamily[]>([]);
|
||||
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
|
||||
const [selectedKind, setSelectedKind] = useState<CanonicalKind>('AREA');
|
||||
const [level, setLevel] = useState<FamilyLevel>('INSTALLATION');
|
||||
const [selectedFamilyId, setSelectedFamilyId] = useState('');
|
||||
const [attributes, setAttributes] = useState<InventoryFamilyAttribute[]>([]);
|
||||
const [newTypeName, setNewTypeName] = useState('');
|
||||
const [newTypeParents, setNewTypeParents] = useState<string[]>([]);
|
||||
const [newFieldName, setNewFieldName] = useState('');
|
||||
const [newFieldType, setNewFieldType] = useState<InventoryFamilyAttributeDataType>('TEXT');
|
||||
const [newFieldRequired, setNewFieldRequired] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const refreshFamilies = async () => {
|
||||
const loaded = await listInventoryFamiliesAdmin();
|
||||
setFamilies(loaded);
|
||||
return loaded;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
|
||||
.then(([loadedTypes, loadedFamilies, loadedCatalog]) => {
|
||||
setTypes(loadedTypes);
|
||||
setFamilies(loadedFamilies);
|
||||
setCatalog(loadedCatalog);
|
||||
})
|
||||
refreshFamilies()
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const level = LEVELS.find((item) => item.kind === selectedKind) ?? LEVELS[0]!;
|
||||
const selectedType = canonicalType(types, selectedKind);
|
||||
const commonAttributes = useMemo(() => {
|
||||
const attributes = selectedType?.attributes.filter((attribute) => attribute.isActive) ?? [];
|
||||
return selectedKind === 'INSTALACION'
|
||||
? attributes.filter((attribute) => attribute.code !== 'tipo_instalacion')
|
||||
: attributes;
|
||||
}, [selectedKind, selectedType]);
|
||||
const installationFamilies = families.filter((family) => family.level === 'INSTALLATION' && family.isActive !== false);
|
||||
const subinstallationFamilies = families.filter((family) => family.level === 'SUBINSTALLATION' && family.isActive !== false);
|
||||
const activeFindings = catalog.items.filter((item) => item.isActive).length;
|
||||
const installationFamilies = useMemo(
|
||||
() => families.filter((family) => family.level === 'INSTALLATION'),
|
||||
[families],
|
||||
);
|
||||
const visibleFamilies = useMemo(
|
||||
() => families.filter((family) => family.level === level),
|
||||
[families, level],
|
||||
);
|
||||
const selectedFamily = families.find((family) => family.id === selectedFamilyId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const first = visibleFamilies.find((family) => family.isActive !== false) ?? visibleFamilies[0] ?? null;
|
||||
setSelectedFamilyId((current) => visibleFamilies.some((family) => family.id === current) ? current : first?.id ?? '');
|
||||
}, [level, visibleFamilies]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFamilyId) {
|
||||
setAttributes([]);
|
||||
return;
|
||||
}
|
||||
getInventoryFamilyAttributes(selectedFamilyId)
|
||||
.then((result) => setAttributes(result.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))))
|
||||
.catch((requestError) => setError(errorMessage(requestError)));
|
||||
}, [selectedFamilyId]);
|
||||
|
||||
const run = async (action: () => Promise<void>, message: string) => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
try {
|
||||
await action();
|
||||
setSuccess(message);
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createType = async () => {
|
||||
if (!newTypeName.trim()) return;
|
||||
if (level === 'SUBINSTALLATION' && newTypeParents.length === 0) {
|
||||
setError('Elegí al menos un Tipo de Instalación que pueda contener esta Subinstalación.');
|
||||
return;
|
||||
}
|
||||
await run(async () => {
|
||||
const created = await createInventoryFamily({
|
||||
level,
|
||||
name: newTypeName.trim(),
|
||||
parentFamilyIds: level === 'SUBINSTALLATION' ? newTypeParents : undefined,
|
||||
});
|
||||
await refreshFamilies();
|
||||
setSelectedFamilyId(created.id);
|
||||
setNewTypeName('');
|
||||
setNewTypeParents([]);
|
||||
}, `${level === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
|
||||
};
|
||||
|
||||
const toggleParent = (parentId: string) => {
|
||||
setNewTypeParents((current) => current.includes(parentId)
|
||||
? current.filter((id) => id !== parentId)
|
||||
: [...current, parentId]);
|
||||
};
|
||||
|
||||
const toggleSelectedParent = async (parentId: string) => {
|
||||
if (!selectedFamily || selectedFamily.level !== 'SUBINSTALLATION') return;
|
||||
const next = selectedFamily.parentFamilyIds.includes(parentId)
|
||||
? selectedFamily.parentFamilyIds.filter((id) => id !== parentId)
|
||||
: [...selectedFamily.parentFamilyIds, parentId];
|
||||
if (next.length === 0) {
|
||||
setError('Una Subinstalación debe quedar habilitada dentro de al menos un Tipo de Instalación.');
|
||||
return;
|
||||
}
|
||||
await run(async () => {
|
||||
await updateInventoryFamily(selectedFamily.id, { parentFamilyIds: next });
|
||||
await refreshFamilies();
|
||||
}, 'Tipos permitidos actualizados.');
|
||||
};
|
||||
|
||||
const createField = async () => {
|
||||
if (!selectedFamily || !newFieldName.trim()) return;
|
||||
await run(async () => {
|
||||
await createInventoryFamilyAttribute(selectedFamily.id, {
|
||||
code: fieldCode(newFieldName),
|
||||
name: newFieldName.trim(),
|
||||
dataType: newFieldType,
|
||||
isRequired: newFieldRequired,
|
||||
sortOrder: attributes.length,
|
||||
});
|
||||
const loaded = await getInventoryFamilyAttributes(selectedFamily.id);
|
||||
setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)));
|
||||
setNewFieldName('');
|
||||
setNewFieldType('TEXT');
|
||||
setNewFieldRequired(false);
|
||||
}, 'Campo agregado.');
|
||||
};
|
||||
|
||||
const toggleField = async (attribute: InventoryFamilyAttribute, key: 'isRequired' | 'isActive') => {
|
||||
if (!selectedFamily) return;
|
||||
await run(async () => {
|
||||
await updateInventoryFamilyAttribute(selectedFamily.id, attribute.id, { [key]: !attribute[key] });
|
||||
const loaded = await getInventoryFamilyAttributes(selectedFamily.id);
|
||||
setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)));
|
||||
}, key === 'isActive' ? 'Visibilidad del campo actualizada.' : 'Obligatoriedad actualizada.');
|
||||
};
|
||||
|
||||
const toggleFamilyActive = async () => {
|
||||
if (!selectedFamily) return;
|
||||
await run(async () => {
|
||||
await updateInventoryFamily(selectedFamily.id, { isActive: selectedFamily.isActive === false });
|
||||
await refreshFamilies();
|
||||
}, selectedFamily.isActive === false ? 'Tipo activado.' : 'Tipo desactivado.');
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
|
||||
|
||||
return <section className="inventory-config-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">ADMINISTRACIÓN · MODELO AUTORITATIVO</span>
|
||||
<h1>Configuración de Inventarios</h1>
|
||||
<p>Relaciones fijas cargadas desde los SQL definitivos. Los vínculos estructurales no son campos de texto editables.</p>
|
||||
<span className="eyebrow">ADMINISTRACIÓN · INVENTARIOS</span>
|
||||
<h1>Tipos y campos</h1>
|
||||
<p>Configuración simple para la puesta a punto. Los usuarios de campo no ven esta pantalla.</p>
|
||||
</div>
|
||||
<Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
<article className="panel" style={{ marginBottom: 18 }}>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">ESTRUCTURA FÍSICA</span>
|
||||
<h2>Jerarquía obligatoria</h2>
|
||||
<p className="section-copy">Empresa es un maestro independiente y se asocia al Yacimiento. El árbol físico queda separado y sin ambigüedades.</p>
|
||||
<span className="eyebrow">ESTRUCTURA FIJA</span>
|
||||
<h2>Departamento → Área → Yacimiento → Instalación → Subinstalación</h2>
|
||||
<p className="section-copy">Un Área puede estar vinculada a varias Empresas. Cada Yacimiento elige una sola Empresa operadora de las vinculadas a su Área.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-browser-levels" aria-label="Jerarquía de Inventarios">
|
||||
<div><span>1</span><strong>Departamento</strong><small>raíz</small></div><i>›</i>
|
||||
<div><span>2</span><strong>Área</strong><small>Departamento</small></div><i>›</i>
|
||||
<div><span>3</span><strong>Yacimiento</strong><small>Área + Empresa + concesión</small></div><i>›</i>
|
||||
<div><span>4</span><strong>Instalación</strong><small>Yacimiento</small></div><i>›</i>
|
||||
<div><span>5</span><strong>Subinstalación</strong><small>Instalación</small></div>
|
||||
</div>
|
||||
<div className="temporal-notice" style={{ marginTop: 14 }}>
|
||||
<Icon name="users" />
|
||||
<p><strong>Empresa:</strong> ya no pertenece al Área. La relación canónica es <strong>Yacimiento → Empresa relacionada</strong>.</p>
|
||||
<div className="temporal-notice">
|
||||
<Icon name="map" />
|
||||
<p><strong>Campos básicos del sistema:</strong> nombre, código, ubicación jerárquica, estado y GPS. No hace falta configurarlos acá.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel" style={{ marginBottom: 18 }}>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">INFORMACIÓN ESTRUCTURAL</span>
|
||||
<h2>{level.label}</h2>
|
||||
<p className="section-copy">{level.description}</p>
|
||||
</div>
|
||||
<span className="tag">Modelo SQL</span>
|
||||
</div>
|
||||
<div className="quick-view-row" style={{ marginBottom: 18 }}>
|
||||
{LEVELS.map((item) => <button type="button" key={item.kind} className={selectedKind === item.kind ? 'active' : ''} onClick={() => setSelectedKind(item.kind)}>{item.label}</button>)}
|
||||
</div>
|
||||
|
||||
<div className="attribute-list">
|
||||
{level.fields.map((field, index) => <div className="attribute-card" key={field.label}>
|
||||
<span className="attribute-order">{index + 1}</span>
|
||||
<span>
|
||||
<strong>{field.label}</strong>
|
||||
<small>{field.detail}</small>
|
||||
</span>
|
||||
<span className="attribute-flags">
|
||||
{field.relation && <span className="tag">Relación</span>}
|
||||
{field.required && <span className="tag">Obligatorio</span>}
|
||||
</span>
|
||||
</div>)}
|
||||
{commonAttributes.map((attribute, index) => <div className="attribute-card" key={attribute.id}>
|
||||
<span className="attribute-order">{level.fields.length + index + 1}</span>
|
||||
<span>
|
||||
<strong>{attribute.name}</strong>
|
||||
<small>{attribute.code} · Campo común del nivel</small>
|
||||
</span>
|
||||
<span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}</span>
|
||||
</div>)}
|
||||
</div>
|
||||
</article>
|
||||
<div className="quick-view-row" style={{ marginBottom: 18 }}>
|
||||
<button type="button" className={level === 'INSTALLATION' ? 'active' : ''} onClick={() => setLevel('INSTALLATION')}>Tipos de Instalación</button>
|
||||
<button type="button" className={level === 'SUBINSTALLATION' ? 'active' : ''} onClick={() => setLevel('SUBINSTALLATION')}>Tipos de Subinstalación</button>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">MODELO TÉCNICO</span><h2>Tipos de Instalación</h2><p className="section-copy">{installationFamilies.length} clasificaciones exactas del SQL.</p></div></div>
|
||||
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
|
||||
{installationFamilies.map((family) => <div className="attribute-card" key={family.id}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.code}</span></div>)}
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">TIPOS</span>
|
||||
<h2>{level === 'INSTALLATION' ? 'Instalaciones' : 'Subinstalaciones'}</h2>
|
||||
<p className="section-copy">Seleccioná un tipo para administrar sus campos.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
|
||||
{visibleFamilies.length === 0 && <p className="muted">Todavía no hay tipos configurados.</p>}
|
||||
{visibleFamilies.map((family) => <button
|
||||
type="button"
|
||||
key={family.id}
|
||||
onClick={() => setSelectedFamilyId(family.id)}
|
||||
className="attribute-card"
|
||||
style={{ width: '100%', textAlign: 'left', borderColor: selectedFamilyId === family.id ? 'var(--primary)' : undefined, opacity: family.isActive === false ? .55 : 1 }}
|
||||
>
|
||||
<span className="asset-symbol"><Icon name="layers" size={16} /></span>
|
||||
<span><strong>{family.name}</strong><small>{family.isActive === false ? 'Oculto' : `${family.technicalAttributeCount ?? 0} campos`}</small></span>
|
||||
<Icon name="chevron" size={16} />
|
||||
</button>)}
|
||||
</div>
|
||||
|
||||
{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 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>}
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">MODELO TÉCNICO</span><h2>Tipos de Subinstalación</h2><p className="section-copy">{subinstallationFamilies.length} clasificaciones, cada una vinculada a su Tipo de instalación.</p></div></div>
|
||||
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
|
||||
{subinstallationFamilies.map((family) => <div className="attribute-card" key={family.id}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin padre'} · {family.findingCount ?? 0} Hallazgos</small></span></div>)}
|
||||
</div>
|
||||
{!selectedFamily ? <div className="inline-empty">Seleccioná un tipo para ver sus campos.</div> : <>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="eyebrow">{selectedFamily.level === 'INSTALLATION' ? 'INSTALACIÓN' : 'SUBINSTALACIÓN'}</span>
|
||||
<h2>{selectedFamily.name}</h2>
|
||||
<p className="section-copy">Campos sencillos que se muestran al cargar este tipo.</p>
|
||||
</div>
|
||||
{canManage && <button type="button" className="button secondary" onClick={() => void toggleFamilyActive()}>{selectedFamily.isActive === false ? 'Activar tipo' : 'Ocultar tipo'}</button>}
|
||||
</div>
|
||||
|
||||
{selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}>
|
||||
<strong>Puede estar dentro de:</strong>
|
||||
<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)} />
|
||||
<span>{family.name}</span>
|
||||
</label>)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="attribute-list">
|
||||
{attributes.length === 0 && <p className="muted">Este tipo no tiene campos específicos. Puede usarse sólo con los datos básicos.</p>}
|
||||
{attributes.map((attribute) => <div className="attribute-card" key={attribute.id} style={{ opacity: attribute.isActive ? 1 : .5 }}>
|
||||
<span className="attribute-order">{attribute.sortOrder + 1}</span>
|
||||
<span>
|
||||
<strong>{attribute.name}</strong>
|
||||
<small>{fieldTypeLabel(attribute.dataType)}{attribute.isRequired ? ' · Obligatorio' : ' · Opcional'}{attribute.isActive ? '' : ' · Oculto'}</small>
|
||||
</span>
|
||||
{canManage && <span className="attribute-flags" style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="button" className="button secondary" disabled={saving} onClick={() => void toggleField(attribute, 'isRequired')}>{attribute.isRequired ? 'Hacer opcional' : 'Hacer obligatorio'}</button>
|
||||
<button type="button" className="button secondary" disabled={saving} onClick={() => void toggleField(attribute, 'isActive')}>{attribute.isActive ? 'Ocultar' : 'Mostrar'}</button>
|
||||
</span>}
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
{canManage && selectedFamily.isActive !== false && <div style={{ marginTop: 22, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
|
||||
<h3 style={{ marginTop: 0 }}>+ Agregar campo</h3>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Nombre</span><input value={newFieldName} onChange={(event) => setNewFieldName(event.target.value)} placeholder="Ej. Capacidad" /></label>
|
||||
<label className="field"><span>Tipo</span><select value={newFieldType} onChange={(event) => setNewFieldType(event.target.value as InventoryFamilyAttributeDataType)}>{FIELD_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}><input type="checkbox" checked={newFieldRequired} onChange={(event) => setNewFieldRequired(event.target.checked)} />Obligatorio</label>
|
||||
<button type="button" className="button primary" disabled={saving || !newFieldName.trim()} onClick={() => void createField()}><Icon name="plus" />Agregar campo</button>
|
||||
<p className="muted" style={{ marginBottom: 0, marginTop: 12 }}>Teléfono, email o identificadores simples se cargan como Texto. GPS es un dato básico del registro y no se configura como campo.</p>
|
||||
</div>}
|
||||
</>}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article className="panel" style={{ marginTop: 18 }}>
|
||||
<div className="panel-heading">
|
||||
<div><span className="eyebrow">HALLAZGOS</span><h2>Catálogo contextual</h2><p className="section-copy">{activeFindings} Hallazgos cargados con sus relaciones exactas a Instalaciones y Subinstalaciones.</p></div>
|
||||
<Link className="button secondary" to="/admin/finding-catalog">Abrir catálogo <Icon name="chevron" /></Link>
|
||||
</div>
|
||||
</article>
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,13 +6,13 @@ import { Icon } from '../components/Icon';
|
||||
import { getAsset } from '../lib/api';
|
||||
import {
|
||||
createInventoryStructure,
|
||||
getInventoryFamilyFindings,
|
||||
getInventoryStructureOptions,
|
||||
listCompaniesForInventoryArea,
|
||||
listInventoryStructureParents,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
import type {
|
||||
InventoryAreaCompanyOption,
|
||||
InventoryFamily,
|
||||
InventoryFamilyFindings,
|
||||
InventoryStructureKind,
|
||||
InventoryStructureOptions,
|
||||
InventoryStructureParent,
|
||||
@@ -26,13 +26,24 @@ const KINDS: Array<{ kind: InventoryStructureKind; label: string }> = [
|
||||
{ kind: 'SUBINSTALACION', label: 'Subinstalación' },
|
||||
{ kind: 'EMPRESA', label: 'Empresa' },
|
||||
];
|
||||
|
||||
const childKindByParentType: Record<string, InventoryStructureKind | undefined> = {
|
||||
departamento: 'AREA', area: 'YACIMIENTO', yacimiento: 'INSTALACION', instalacion: 'SUBINSTALACION',
|
||||
departamento: 'AREA',
|
||||
area: 'YACIMIENTO',
|
||||
yacimiento: 'INSTALACION',
|
||||
instalacion: 'SUBINSTALACION',
|
||||
};
|
||||
const parentLabelByKind: Partial<Record<InventoryStructureKind,string>> = {
|
||||
AREA:'Departamento', YACIMIENTO:'Área', INSTALACION:'Yacimiento', SUBINSTALACION:'Instalación',
|
||||
|
||||
const parentLabelByKind: Partial<Record<InventoryStructureKind, string>> = {
|
||||
AREA: 'Departamento',
|
||||
YACIMIENTO: 'Área',
|
||||
INSTALACION: 'Yacimiento',
|
||||
SUBINSTALACION: 'Instalación',
|
||||
};
|
||||
function kindLabel(kind: InventoryStructureKind) { return KINDS.find((item) => item.kind===kind)?.label ?? kind; }
|
||||
|
||||
function kindLabel(kind: InventoryStructureKind) {
|
||||
return KINDS.find((item) => item.kind === kind)?.label ?? kind;
|
||||
}
|
||||
|
||||
export function InventoryCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -44,102 +55,232 @@ export function InventoryCreatePage() {
|
||||
const [parentSearch, setParentSearch] = useState('');
|
||||
const [parentId, setParentId] = useState('');
|
||||
const [familyId, setFamilyId] = useState('');
|
||||
const [areaCompanies, setAreaCompanies] = useState<InventoryAreaCompanyOption[]>([]);
|
||||
const [operatorCompanyId, setOperatorCompanyId] = useState('');
|
||||
const [concessionTypeId, setConcessionTypeId] = useState('');
|
||||
const [familyFindings, setFamilyFindings] = useState<InventoryFamilyFindings | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [commonName, setCommonName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getInventoryStructureOptions().then(async (loaded) => {
|
||||
setOptions(loaded);
|
||||
if (contextParentId) {
|
||||
const parent = await getAsset(contextParentId);
|
||||
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
|
||||
if (inferred) { setKind(inferred); setParentId(parent.id); }
|
||||
}
|
||||
}).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
getInventoryStructureOptions()
|
||||
.then(async (loaded) => {
|
||||
setOptions(loaded);
|
||||
if (contextParentId) {
|
||||
const parent = await getAsset(contextParentId);
|
||||
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
|
||||
if (inferred) {
|
||||
setKind(inferred);
|
||||
setParentId(parent.id);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [contextParentId]);
|
||||
|
||||
const requiresParent = Boolean(parentLabelByKind[kind]);
|
||||
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
|
||||
const requiresYacimientoContext = kind === 'YACIMIENTO';
|
||||
const requiresCompany = kind === 'YACIMIENTO';
|
||||
const parentLabel = parentLabelByKind[kind] ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!requiresParent) { setParents([]); setParentId(''); return; }
|
||||
if (!requiresParent) {
|
||||
setParents([]);
|
||||
setParentId('');
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
listInventoryStructureParents(kind,parentSearch).then((loaded) => {
|
||||
setParents(loaded);
|
||||
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId);
|
||||
}).catch((requestError) => setError(errorMessage(requestError)));
|
||||
},150);
|
||||
listInventoryStructureParents(kind, parentSearch)
|
||||
.then((loaded) => {
|
||||
setParents(loaded);
|
||||
if (contextParentId && loaded.some((item) => item.id === contextParentId)) setParentId(contextParentId);
|
||||
})
|
||||
.catch((requestError) => setError(errorMessage(requestError)));
|
||||
}, 150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [kind,parentSearch,contextParentId,requiresParent]);
|
||||
}, [kind, parentSearch, contextParentId, requiresParent]);
|
||||
|
||||
const selectedParent = parents.find((item) => item.id===parentId) ?? null;
|
||||
useEffect(() => {
|
||||
if (kind !== 'YACIMIENTO' || !parentId) {
|
||||
setAreaCompanies([]);
|
||||
setOperatorCompanyId('');
|
||||
return;
|
||||
}
|
||||
setOperatorCompanyId('');
|
||||
listCompaniesForInventoryArea(parentId)
|
||||
.then(setAreaCompanies)
|
||||
.catch((requestError) => setError(errorMessage(requestError)));
|
||||
}, [kind, parentId]);
|
||||
|
||||
const selectedParent = parents.find((item) => item.id === parentId) ?? null;
|
||||
const families = useMemo(() => {
|
||||
if (!options) return [] as InventoryFamily[];
|
||||
if (kind==='INSTALACION') return options.installationFamilies;
|
||||
if (kind==='SUBINSTALACION') {
|
||||
const parentFamilyId=selectedParent?.inventoryFamily?.id;
|
||||
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyIds.includes(parentFamilyId)) : [];
|
||||
if (kind === 'INSTALACION') return options.installationFamilies;
|
||||
if (kind === 'SUBINSTALACION') {
|
||||
const parentFamilyId = selectedParent?.inventoryFamily?.id;
|
||||
return parentFamilyId
|
||||
? options.subinstallationFamilies.filter((item) => item.parentFamilyIds.includes(parentFamilyId))
|
||||
: [];
|
||||
}
|
||||
return [];
|
||||
},[options,kind,selectedParent]);
|
||||
const selectedFamily=families.find((item)=>item.id===familyId) ?? null;
|
||||
}, [options, kind, selectedParent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!familyId) { setFamilyFindings(null); return; }
|
||||
getInventoryFamilyFindings(familyId).then(setFamilyFindings).catch((requestError)=>setError(errorMessage(requestError)));
|
||||
},[familyId]);
|
||||
useEffect(() => {
|
||||
if (!requiresFamily) setFamilyId('');
|
||||
if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId('');
|
||||
},[kind,requiresFamily,families,familyId]);
|
||||
useEffect(() => {
|
||||
if (!requiresYacimientoContext) { setOperatorCompanyId(''); setConcessionTypeId(''); }
|
||||
},[requiresYacimientoContext]);
|
||||
if (kind === 'SUBINSTALACION' && familyId && !families.some((item) => item.id === familyId)) setFamilyId('');
|
||||
}, [kind, requiresFamily, families, familyId]);
|
||||
|
||||
const changeKind=(next:InventoryStructureKind) => {
|
||||
setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null);
|
||||
setOperatorCompanyId(''); setConcessionTypeId(''); setError('');
|
||||
const changeKind = (next: InventoryStructureKind) => {
|
||||
setKind(next);
|
||||
setParentId('');
|
||||
setParentSearch('');
|
||||
setFamilyId('');
|
||||
setAreaCompanies([]);
|
||||
setOperatorCompanyId('');
|
||||
setError('');
|
||||
};
|
||||
const save=async(event:FormEvent) => {
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; }
|
||||
if (requiresYacimientoContext && !operatorCompanyId) { setError('Seleccioná la Empresa relacionada del Yacimiento.'); return; }
|
||||
if (requiresYacimientoContext && !concessionTypeId) { setError('Seleccioná el Tipo de concesión del Yacimiento.'); return; }
|
||||
if (requiresFamily && !familyId) { setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`); return; }
|
||||
setSaving(true); setError('');
|
||||
if (requiresParent && !parentId) {
|
||||
setError(`Seleccioná ${parentLabel}.`);
|
||||
return;
|
||||
}
|
||||
if (requiresCompany && !operatorCompanyId) {
|
||||
setError('Seleccioná la Empresa que opera este Yacimiento.');
|
||||
return;
|
||||
}
|
||||
if (requiresFamily && !familyId) {
|
||||
setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const created=await createInventoryStructure({
|
||||
kind,name:name.trim(),parentId:parentId || null,familyId:familyId || null,
|
||||
operatorCompanyId:operatorCompanyId || null,concessionTypeId:concessionTypeId || null,
|
||||
code:code.trim() || null,commonName:commonName.trim() || null,description:description.trim() || null,
|
||||
const created = await createInventoryStructure({
|
||||
kind,
|
||||
name: name.trim(),
|
||||
parentId: parentId || null,
|
||||
familyId: familyId || null,
|
||||
operatorCompanyId: operatorCompanyId || null,
|
||||
code: code.trim() || null,
|
||||
});
|
||||
navigate(`/inventarios/${created.id}`,{replace:true});
|
||||
} catch(requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
navigate(`/inventarios/${created.id}`, { replace: true });
|
||||
} catch (requestError) {
|
||||
setError(errorMessage(requestError));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Preparando alta…" />;
|
||||
|
||||
const disableSave = saving
|
||||
|| !name.trim()
|
||||
|| (requiresParent && !parentId)
|
||||
|| (requiresCompany && !operatorCompanyId)
|
||||
|| (requiresFamily && !familyId);
|
||||
|
||||
return <section className="narrow-section asset-detail-page">
|
||||
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario"><Link to="/inventarios">Inventarios</Link><span>›</span><strong>Nuevo registro</strong></nav>
|
||||
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Modelo fijo: Departamento → Área → Yacimiento → Instalación → Subinstalación. La Empresa y el Tipo de concesión pertenecen al Yacimiento.</p></div></div>
|
||||
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario">
|
||||
<Link to="/inventarios">Inventarios</Link><span>›</span><strong>Nuevo registro</strong>
|
||||
</nav>
|
||||
|
||||
<div className="page-heading asset-editor-heading">
|
||||
<div>
|
||||
<span className="eyebrow">ALTA RÁPIDA</span>
|
||||
<h1>Agregar al Inventario</h1>
|
||||
<p>Elegí qué querés crear, dónde está y completá sólo los datos necesarios.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<form className="panel form-panel" onSubmit={save}>
|
||||
<div className="form-section"><div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Área sólo depende de Departamento. Yacimiento define Área, Empresa relacionada y Tipo de concesión.</p></div><label className="field"><span>Tipo</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label></div>
|
||||
{requiresParent && <div className="form-section"><div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} concreto.</p></div><label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}…`} /></label><label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar…</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label></div>}
|
||||
{requiresYacimientoContext && <div className="form-section"><div><h2>3. Contexto del Yacimiento</h2><p className="section-copy">Estas relaciones pertenecen directamente al Yacimiento y no al Área.</p></div><div className="form-grid"><label className="field"><span>Empresa relacionada <em>obligatorio</em></span><select value={operatorCompanyId} onChange={(event)=>setOperatorCompanyId(event.target.value)} required><option value="">Seleccionar…</option>{options?.companies.map((company)=><option key={company.id} value={company.id}>{company.name}</option>)}</select></label><label className="field"><span>Tipo de concesión <em>obligatorio</em></span><select value={concessionTypeId} onChange={(event)=>setConcessionTypeId(event.target.value)} required><option value="">Seleccionar…</option>{options?.concessionTypes.map((type)=><option key={type.id} value={type.id}>{type.name}</option>)}</select></label></div></div>}
|
||||
{requiresFamily && <div className="form-section"><div><h2>3. Clasificación técnica</h2><p className="section-copy">Define los datos técnicos y Hallazgos aplicables. En Subinstalaciones sólo aparecen clasificaciones compatibles con la Instalación elegida.</p></div>{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene clasificación técnica.</Alert> : <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar…</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}{kind==='SUBINSTALACION' && selectedParent?.inventoryFamily && families.length===0 && <Alert>No hay tipos de Subinstalación compatibles con {selectedParent.inventoryFamily.name}.</Alert>}{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}</div>}
|
||||
<div className="form-section"><div><h2>{requiresYacimientoContext ? '4' : requiresFamily ? '4' : requiresParent ? '3' : '2'}. Nombre</h2><p className="section-copy">El nombre es el dato propio principal de este nivel. Los datos técnicos se completan después, cuando corresponda.</p></div><label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label><details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejalo vacío para generar automáticamente" /></label><label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label></div><label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label></div></details></div>
|
||||
<div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || (requiresYacimientoContext && (!operatorCompanyId || !concessionTypeId)) || (requiresFamily && !familyId)}><Icon name="check" />{saving?'Creando…':`Crear ${kindLabel(kind)}`}</button></div>
|
||||
<div className="form-section">
|
||||
<div>
|
||||
<h2>¿Qué querés crear?</h2>
|
||||
<p className="section-copy">Departamento → Área → Yacimiento → Instalación → Subinstalación.</p>
|
||||
</div>
|
||||
<div className="quick-view-row" aria-label="Tipo de registro">
|
||||
{KINDS.map((item) => <button
|
||||
type="button"
|
||||
key={item.kind}
|
||||
className={kind === item.kind ? 'active' : ''}
|
||||
onClick={() => changeKind(item.kind)}
|
||||
>{item.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requiresParent && <div className="form-section">
|
||||
<div>
|
||||
<h2>¿Dónde está?</h2>
|
||||
<p className="section-copy">Seleccioná el {parentLabel.toLowerCase()} al que pertenece.</p>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Buscar {parentLabel.toLowerCase()}</span>
|
||||
<input value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}…`} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{parentLabel} <em>obligatorio</em></span>
|
||||
<select value={parentId} onChange={(event) => { setParentId(event.target.value); setFamilyId(''); }} required>
|
||||
<option value="">Seleccionar…</option>
|
||||
{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>}
|
||||
|
||||
{requiresCompany && <div className="form-section">
|
||||
<div>
|
||||
<h2>Empresa operadora</h2>
|
||||
<p className="section-copy">Un Área puede tener varias Empresas. Cada Yacimiento queda asociado a una sola de ellas.</p>
|
||||
</div>
|
||||
{!parentId ? <Alert>Primero seleccioná el Área.</Alert> : areaCompanies.length === 0 ? <Alert>El Área elegida todavía no tiene Empresas operadoras vinculadas.</Alert> : <label className="field">
|
||||
<span>Empresa <em>obligatorio</em></span>
|
||||
<select value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} required>
|
||||
<option value="">Seleccionar…</option>
|
||||
{areaCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
</div>}
|
||||
|
||||
{requiresFamily && <div className="form-section">
|
||||
<div>
|
||||
<h2>{kind === 'INSTALACION' ? 'Tipo de instalación' : 'Tipo / función'}</h2>
|
||||
<p className="section-copy">{kind === 'SUBINSTALACION' ? 'Sólo aparecen tipos permitidos dentro de la Instalación seleccionada.' : 'Elegí la clasificación simple que corresponda.'}</p>
|
||||
</div>
|
||||
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene un tipo configurado.</Alert> : <label className="field">
|
||||
<span>{kind === 'INSTALACION' ? 'Tipo' : 'Tipo / función'} <em>obligatorio</em></span>
|
||||
<select value={familyId} onChange={(event) => setFamilyId(event.target.value)} required>
|
||||
<option value="">Seleccionar…</option>
|
||||
{families.map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
{kind === 'SUBINSTALACION' && selectedParent?.inventoryFamily && families.length === 0 && <Alert>No hay tipos de Subinstalación configurados para esta Instalación.</Alert>}
|
||||
</div>}
|
||||
|
||||
<div className="form-section">
|
||||
<div>
|
||||
<h2>Datos básicos</h2>
|
||||
<p className="section-copy">El nombre alcanza para identificar el registro. El código puede generarse automáticamente.</p>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Nombre <em>obligatorio</em></span>
|
||||
<input autoFocus value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Código <em>opcional</em></span>
|
||||
<input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Se genera automáticamente si lo dejás vacío" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<Link className="button secondary" to="/inventarios">Cancelar</Link>
|
||||
<button className="button primary" disabled={disableSave}><Icon name="check" />{saving ? 'Guardando…' : `Crear ${kindLabel(kind)}`}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getAsset } from '../lib/api';
|
||||
import type { AssetDetail } from '../lib/api';
|
||||
import { AssetEditorPage } from './AssetEditorPage';
|
||||
import { CompanyInventoryPage } from './CompanyInventoryPage';
|
||||
import { SimpleInventoryDetailPage, isSimpleInventoryAsset } from './SimpleInventoryDetailPage';
|
||||
import { TerritorialInventoryPage, isTerritorialInventoryAsset } from './TerritorialInventoryPage';
|
||||
|
||||
export function InventoryDetailPage() {
|
||||
@@ -43,6 +44,10 @@ export function InventoryDetailPage() {
|
||||
return <CompanyInventoryPage initialAsset={asset} />;
|
||||
}
|
||||
|
||||
if (isSimpleInventoryAsset(asset) && !advanced) {
|
||||
return <SimpleInventoryDetailPage initialAsset={asset} />;
|
||||
}
|
||||
|
||||
if (isTerritorialInventoryAsset(asset) && !advanced) {
|
||||
return <TerritorialInventoryPage initialAsset={asset} />;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user