Compare commits

...
Author SHA1 Message Date
admin 103ecf2fae fix(api): prevent NaN act version during mobile create
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m19s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m5s
2026-09-14 14:06:26 -03:00
admin 23880521d9 fix(api): align health version with package metadata
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m16s
DH V2 CI / Docker / scripts contract (push) Successful in 50s
2026-09-14 10:54:14 -03:00
admin 637c28dc2f fix(web): align visible version with 0.23.0-2
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m1s
2026-09-14 10:37:58 -03:00
admin fc0c62c70a docs(android): pin 0.19.2 server compatibility
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / Docker / scripts contract (push) Canceled after 0s
Production dependency audit / API · production dependencies (push) Canceled after 0s
Production dependency audit / WEB · production dependencies (push) Canceled after 0s
DH V2 CI / WEB · typecheck, build (push) Canceled after 0s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m34s
2026-09-14 10:31:54 -03:00
admin 3ab10b37ea chore(web): bump version for deferred Acta urgency
DH V2 CI / Docker / scripts contract (pull_request) Skipped
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / WEB · typecheck, build (push) Canceled after 0s
DH V2 CI / Docker / scripts contract (push) Canceled after 0s
Production dependency audit / API · production dependencies (push) Canceled after 0s
Production dependency audit / WEB · production dependencies (push) Canceled after 0s
DH V2 CI / API · typecheck, tests, build (pull_request) Failing after 26s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 20s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
2026-09-14 10:30:15 -03:00
admin c6475c851e fix(api): clear legacy draft urgency during migration
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m17s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 32s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 19s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Successful in 28s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 50s
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / WEB · typecheck, build (push) Canceled after 0s
DH V2 CI / Docker / scripts contract (push) Canceled after 0s
Production dependency audit / API · production dependencies (push) Canceled after 0s
Production dependency audit / WEB · production dependencies (push) Canceled after 0s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Canceled after 3m58s
2026-09-14 10:13:02 -03:00
admin b757bf7729 ci(gitea): run planning smoke API requests inside container
DH V2 CI / API · typecheck, tests, build (pull_request) Canceled after 0s
DH V2 CI / WEB · typecheck, build (pull_request) Canceled after 0s
DH V2 CI / Docker / scripts contract (pull_request) Canceled after 0s
Production dependency audit / API · production dependencies (pull_request) Canceled after 0s
Production dependency audit / WEB · production dependencies (pull_request) Canceled after 0s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Canceled after 4m11s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Successful in 27s
2026-09-14 10:09:29 -03:00
admin e30e5d90ea fix(android): align Acta flow and defer urgency to close
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m31s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m30s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 33s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Failing after 1m33s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 1m5s
2026-09-14 09:27:30 -03:00
admin d44ae049f3 test(android): align 0.19.1 release contract and record field review
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m11s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m12s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 30s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Production dependency audit / API · production dependencies (pull_request) Successful in 8s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 48s
2026-09-14 00:04:01 -03:00
admin 78e92970ff fix(android): align GPS and technical inventory field workflow
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m12s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m9s
DH V2 CI / API · typecheck, tests, build (pull_request) Failing after 25s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
DH V2 CI / Docker / scripts contract (pull_request) Skipped
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
2026-09-13 23:48:55 -03:00
admin 7fcb1d42b6 fix(android): harden field session and writes for 0.19.1 candidate
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m25s
2026-09-13 23:35:08 -03:00
admin e55684bf4f fix(web): compact installation parent checkbox options
DH V2 CI / API · typecheck, tests, build (push) Successful in 29s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 58s
2026-09-13 18:37:25 -03:00
admin ddf3b158e7 deploy: serialize Gitea production deployments
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 48s
2026-09-13 17:11:51 -03:00
admin 8564f1933c deploy: add native Gitea production workflow
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 48s
2026-09-13 16:56:54 -03:00
admin ea890cb807 ci(gitea): make API health probe DinD-compatible
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
Production dependency audit / API · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m5s
2026-09-13 16:40:46 -03:00
admin ce27cd01e9 ci(gitea): pin setup-gradle v4.4.3 for act compatibility
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m39s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
DH V2 CI / Docker / scripts contract (push) Failing after 1m38s
2026-09-13 13:03:21 -03:00
admin a155d9e075 Merge PR #59 · reparar Actividad global de Inventarios
Corrige la consulta SQL común del expediente de Actividad y aísla las facetas auxiliares para evitar errores globales.
2026-09-12 12:23:15 -03:00
admin e89d815868 test(api): actualizar contrato de Actividad territorial 2026-09-12 12:14:57 -03:00
admin ff0b6d83db test(api): cubrir fallo global de actividad 2026-09-12 12:11:55 -03:00
admin d894391d97 fix(api): registrar servicio de actividad resiliente 2026-09-12 12:11:40 -03:00
admin c79537def9 fix(api): usar dossier de actividad resiliente 2026-09-12 12:11:27 -03:00
admin 83c9e2ca3e fix(api): aislar y reparar consultas de actividad de inventario 2026-09-12 12:11:02 -03:00
admin 9254583154 Merge PR #58 · reparar Actividad de Yacimiento
Desacopla el dossier territorial del subsistema de fusión y evita que merge-status bloquee la pestaña Actividad.
2026-09-12 11:40:55 -03:00
admin 0b21e6bc50 test(f7): cubrir Actividad de Yacimiento sin dependencia de merge 2026-09-12 11:33:59 -03:00
admin 350e26566f fix(web): no bloquear Actividad por estado de fusión 2026-09-12 11:33:45 -03:00
admin ba79fbf9ae fix(api): desacoplar dossier territorial de merge 2026-09-12 11:32:50 -03:00
admin 77def3b511 Merge PR #56 · simplificación integral de Inventarios
F7 · modelo simple y experiencia cotidiana de Inventarios
2026-09-11 22:20:02 -03:00
admin ba01bc844e test(f7): cover simple inventory detail experience 2026-09-11 22:12:28 -03:00
admin fa6d4bd06a refactor(web): route canonical inventory to simple detail 2026-09-11 22:11:55 -03:00
admin f54e41d9c9 style(web): add simple inventory detail layout 2026-09-11 22:11:46 -03:00
admin 1b769a0ff4 feat(web): simplify canonical inventory details 2026-09-11 22:11:28 -03:00
admin 5695643fa8 fix(inventory): keep operator snapshot audit marker 2026-09-11 21:26:32 -03:00
admin a6e71fa299 test(inventory): move Yacimiento creation contract to F7 Area companies 2026-09-11 21:24:04 -03:00
admin 02091e14e6 test(inventory): align F5 source guards with F7 simple UX 2026-09-11 21:23:47 -03:00
admin 827b172c66 test(inventory): retain historical Area relation invariant 2026-09-11 21:23:28 -03:00
admin 45a1a08b16 test(web): replace obsolete F3 inventory contract with F7 simplicity guard 2026-09-11 21:20:16 -03:00
admin 3b9153f30a fix(inventory): use simple F7 create endpoint 2026-09-11 21:18:46 -03:00
admin 4f90032d00 feat(inventory): register simple inventory service 2026-09-11 21:18:21 -03:00
admin f621677527 feat(inventory): expose simple F7 create endpoint 2026-09-11 21:18:10 -03:00
admin eeffdc441e feat(inventory): add simple F7 create service 2026-09-11 21:17:59 -03:00
admin 351752256e feat(inventory): align Yacimiento context with simple F7 model 2026-09-11 21:17:19 -03:00
admin abe7e40c99 fix(inventory): allow multiple companies per Area 2026-09-11 21:16:20 -03:00
admin 3b7db0a869 docs: define F7 simple inventory model 2026-09-11 21:13:57 -03:00
admin 0213c29465 ux(admin): replace inventory model screen with simple type and field editor 2026-09-11 21:13:39 -03:00
admin fce370b287 ux(inventory): simplify manual create flow 2026-09-11 21:13:00 -03:00
admin 537b0a6f42 refactor(inventory): expose area companies for simple Yacimiento create 2026-09-11 21:12:24 -03:00
admin a57d6633bd Merge PR #55 · activar ficha compacta de Instalación de superficie
Corrige el matcher de tipo para que Instalación de superficie y Subinstalación usen realmente el Resumen compacto.
2026-09-11 14:50:17 -03:00
admin e18e86e918 fix(web): reconocer Instalación de superficie en ficha compacta 2026-09-11 14:44:42 -03:00
admin cadf766d3c Merge PR #54 · simplificar ficha de Instalación/Subinstalación
Compacta el Resumen de Instalaciones y Subinstalaciones, mueve el contexto físico al Historial y mantiene las funciones avanzadas accesibles sin alargar la ficha principal.
2026-09-11 14:32:07 -03:00
admin 1b151fe964 feat(web): simplificar ficha de instalación y subinstalación 2026-09-11 14:26:42 -03:00
admin c748aba650 style(web): compactar resumen de instalaciones 2026-09-11 14:25:43 -03:00
admin b8b578a3f0 Merge pull request #53 from enlineawork/release/android-0.19.0-final-field-flow
release(android): 0.19.0 flujo final de campo
2026-09-11 12:53:16 -03:00
admin 2455a89c3e test(android): fijar selección estructural por código 2026-09-11 12:43:11 -03:00
DH Android Finalizer 2a99183d31 fix(android): usar código estructural para Instalación y Subinstalación 2026-09-11 15:42:40 +00:00
admin 675d01bea7 chore: corregir clasificación estructural Android 2026-09-11 12:42:32 -03:00
admin 26d79f1e4d docs(android): fijar checklist de validación final 0.19.0 2026-09-11 12:37:16 -03:00
DH Android Finalizer 496bb785c1 fix(android): preservar ViewModel estable en release final 2026-09-11 15:36:42 +00:00
admin f513817388 chore: restaurar MainViewModel y aplicar cambios mínimos 0.19.0 2026-09-11 12:36:32 -03:00
admin 92a21d8f15 fix(android): limpiar firma y terminología final de Actas 2026-09-11 12:35:02 -03:00
admin 753fa9915f test(android): actualizar metadata de release 0.19.0 2026-09-11 12:33:09 -03:00
admin ec0ef732f7 test: actualizar contrato Android a 0.19.0 2026-09-11 12:27:42 -03:00
DH Android Finalizer 87adf71114 android-finalizer: finish Spanish visible copy 2026-09-11 15:24:59 +00:00
admin 0a856a1857 chore: completar limpieza de castellano Android 2026-09-11 12:24:51 -03:00
admin 247c652d40 fix(android): eliminar anglicismo visible en firma 2026-09-11 12:24:33 -03:00
admin b5ee17ce95 docs(android): documentar release final 0.19.0 2026-09-11 12:24:21 -03:00
admin cca2d38b98 test(android): fijar flujo final de inspección y castellano 2026-09-11 12:24:06 -03:00
DH Android Finalizer ebce2bb957 android-finalizer: cleanup final and remove temporary workflow 2026-09-11 15:23:15 +00:00
admin 963485618b chore: cerrar finalizador temporal Android 2026-09-11 12:23:06 -03:00
DH Android Finalizer 8f051158c1 android-finalizer: apply final field flow and Spanish UX 2026-09-11 15:21:04 +00:00
admin 2d8425dec5 chore: corregir ejecutor final Android 2026-09-11 12:20:55 -03:00
admin cfae2bb9ac chore: aplicar cierre final Android de campo 2026-09-11 12:19:50 -03:00
admin 5d8949d7d2 fix(android): mostrar estados de inspección en castellano 2026-09-11 12:18:25 -03:00
admin 304adcd9b0 feat(android): centralizar estados visibles en castellano 2026-09-11 12:17:52 -03:00
admin 20dd110928 release(android): preparar 0.19.0 final de campo 2026-09-11 12:17:35 -03:00
admin 80ffb35065 Merge pull request #51 from enlineawork/fix/yacimiento-companies-admin-advanced
fix(web): empresas en yacimientos y acceso avanzado de administrador
2026-09-11 11:01:30 -03:00
admin 6f79abbcff fix(web): mostrar empresas heredadas en yacimientos y proteger avanzado 2026-09-11 10:56:16 -03:00
admin d18375b85b fix(web): reservar ficha avanzada al administrador 2026-09-11 10:55:26 -03:00
admin 85a84e0f63 fix(web): centralizar acceso a administración avanzada 2026-09-11 10:55:08 -03:00
admin 03f42209b9 Merge pull request #50 from enlineawork/feature/territorial-simple-profile
feat(web): simplificar fichas territoriales
2026-09-11 10:39:50 -03:00
admin 74ff64182c feat(web): route territorial records to simplified profile 2026-09-11 10:34:54 -03:00
admin a9e300d6a4 style(web): simplify territorial profile layout 2026-09-11 10:34:42 -03:00
admin 143db53552 feat(web): add simplified territorial inventory profile 2026-09-11 10:34:19 -03:00
admin d9fbd95da3 Merge pull request #49 from enlineawork/feature/company-simple-profile-docs
feat(web): simplificar ficha de empresa y centralizar actas e informes
2026-09-11 10:11:05 -03:00
admin b744a77f49 fix(web): allow media panel without refresh callback 2026-09-11 10:05:34 -03:00
admin 082354320b fix(web): allow history panel without manual refresh key 2026-09-11 10:04:06 -03:00
admin 71dda05c20 feat(web): use contextual inventory detail route 2026-09-11 10:02:49 -03:00
admin b88855860d style(web): simplify company profile presentation 2026-09-11 10:02:35 -03:00
admin ec84f42a74 feat(web): add simplified company workspace 2026-09-11 10:02:20 -03:00
admin b859278ad0 feat(web): route companies to simplified profile 2026-09-11 10:01:32 -03:00
admin 32fc5bc60d Merge pull request #48 from enlineawork/fix/android-actas-picker-modern-ui
fix(android): Actas estables, selector de Instalación y UI moderna 0.18.0
2026-09-11 09:33:54 -03:00
admin 7c7eff3f93 docs: record F6.4 Android modern UI acceptance scope 2026-09-11 08:57:36 -03:00
admin 7e87306c9d docs(android): document modern field release 0.18.0 2026-09-11 08:56:59 -03:00
admin 88906e827b test(api): lock modern Android picker and mobile Act read model 2026-09-11 08:56:26 -03:00
admin 2aa4fd21b1 test(api): follow Android 0.18.0 test cut 2026-09-11 08:56:13 -03:00
admin 3089dd3724 test(android): lock 0.18.0 release metadata 2026-09-11 08:56:00 -03:00
admin d412399d13 chore(android): bump modern field release to 0.18.0 2026-09-11 08:55:51 -03:00
admin c14582e92c fix(android): use lightweight mobile Act list 2026-09-11 08:55:17 -03:00
admin c10248be2c feat(android): route field app through modern theme 2026-09-11 08:54:29 -03:00
admin 7217c11e8b feat(android): modernize field inventory and live Installation picker 2026-09-11 08:54:10 -03:00
admin 95012cf373 feat(android): modernize Actas field workspace 2026-09-11 08:52:56 -03:00
admin fc8c3ba7ef fix(api): register mobile Act read model 2026-09-11 08:47:48 -03:00
admin 95c5f4d0ca fix(api): expose lightweight mobile Act list 2026-09-11 08:47:39 -03:00
admin ca36140255 fix(api): decouple mobile Act list from document workflow 2026-09-11 08:47:25 -03:00
admin ed3c2c295a feat(android): add modern field theme 2026-09-11 08:46:51 -03:00
admin ea46be666f Merge pull request #47 from enlineawork/feature/android-field-fast-subinstallation-rc
feat(android): alta rápida y dinámica de Subinstalaciones en campo
2026-09-11 02:10:10 -03:00
admin 147e26b887 test(android): align API contract with 0.17.0 field APK 2026-09-11 02:02:09 -03:00
admin 9d0ca7ebd8 test(android): lock fast subinstallation field UX 2026-09-11 01:57:31 -03:00
admin f23f29b546 docs(android): describe dynamic field inventory release 2026-09-11 01:57:21 -03:00
admin c2c917e440 test(android): lock 0.17.0 release metadata 2026-09-11 01:57:07 -03:00
admin 51f476ce3e chore(android): bump field UX release to 0.17.0 2026-09-11 01:57:00 -03:00
admin 0a1c382254 feat(android): route inspections through dynamic field flow 2026-09-11 01:56:49 -03:00
admin e3231c5d04 feat(android): add fast field subinstallation flow 2026-09-11 01:56:33 -03:00
95 changed files with 8454 additions and 655 deletions
+10 -2
View File
@@ -48,7 +48,7 @@ jobs:
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0' run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- name: Gradle 8.13 - name: Gradle 8.13
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
with: with:
gradle-version: '8.13' gradle-version: '8.13'
@@ -63,7 +63,7 @@ jobs:
- name: Android lint - name: Android lint
working-directory: android-app working-directory: android-app
run: gradle --no-daemon :app:lintDebug run: gradle --no-daemon :app:lintDebug :app:lintRelease
- name: Print complete lint failures - name: Print complete lint failures
if: failure() if: failure()
@@ -130,6 +130,14 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 30 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 - name: Upload Android diagnostics
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+6 -1
View File
@@ -309,9 +309,14 @@ jobs:
docker compose --env-file .env.example build api docker compose --env-file .env.example build api
docker compose --env-file .env.example up -d 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 api_ready=0
for _ in $(seq 1 30); do 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 api_ready=1
break break
fi fi
+11
View File
@@ -0,0 +1,11 @@
# F6.4 · Android Campo Moderno
Criterios de aceptación:
- el buscador de Instalación padre consulta mientras se escribe y cada resultado se puede seleccionar directamente;
- la selección de Instalación abre la clasificación compatible de Subinstalación;
- Actas utiliza un read-model móvil desacoplado del módulo documental de oficina;
- la APK conserva GPS + foto antes de habilitar Hallazgos sobre Inventario nacido en campo;
- el lifecycle Acta DRAFT → LOCKED → SEALED no cambia;
- la interfaz de campo usa el tema DH moderno y mantiene acceso claro a Actas, Inventario y Hallazgos;
- Android lint/tests/debug/release, API tests/build, WEB build y Docker contract deben quedar verdes.
+16
View File
@@ -0,0 +1,16 @@
# Checklist final Android 0.19.2
La candidata sólo se promueve si el mismo SHA cumple:
- iniciar Inspecció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;
- al cerrar el contenido, exigir Urgente/No urgente, persistir esa decisión y mostrar **Pendiente de firma**;
- 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.
+96 -17
View File
@@ -1,39 +1,89 @@
# DH Inspección Android · release F6.1 # DH Inspección Android · release final de campo 0.19.2
## Candidata vigente ## Candidata vigente
- Fase funcional: **F6.1**. - Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.15.1**. - `versionName`: **0.19.2**.
- `versionCode`: **23**. - `versionCode`: **30**.
- Application ID release: `com.korexlabs.dhinspeccion`. - Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`. - API: `https://dhv2.korexlabs.com/api/v3/`.
- Launcher de presentación: adaptación vectorial local inspirada en el escudo institucional publicado por Gobierno de Mendoza en `mendoza.gov.ar`, para reemplazar el ícono genérico del sistema. - Servidor compatible de esta candidata: **API 0.29.0-2 / WEB 0.23.0-2**.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica. La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
## Procedimiento operativo validado
La APK 0.19.2 fija como recorrido principal de campo:
1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección.
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo. La urgencia todavía no se define.
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 elige **Urgente / No urgente** y luego se usa **Cerrar Acta y dejar pendiente de firma**. La urgencia queda fijada con el cierre; desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
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.
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
## Alta de Instalaciones y Subinstalaciones
El alta móvil queda alineada con el Dashboard y con las validaciones del servidor.
### Instalación
- contexto territorial: Yacimiento/Área de la Inspección;
- clasificación técnica compatible;
- **Nombre técnico** obligatorio;
- Nombre habitual opcional;
- Descripción opcional;
- atributos técnicos definidos por el tipo; sólo los marcados como obligatorios bloquean el guardado;
- código DH generado por el sistema cuando corresponde;
- GPS de la tablet;
- fotografía obligatoria antes de crear Hallazgos.
### Subinstalación
Además de lo anterior requiere elegir primero la **Instalación padre**. El buscador consulta mientras se escribe y cada resultado es seleccionable tocando la tarjeta.
La clasificación y los atributos se obtienen dinámicamente desde el mismo catálogo administrado por el Dashboard; la APK no mantiene listas técnicas paralelas.
## Actas y firma
La terminología visible se simplifica:
- `DRAFT`**En elaboración**;
- `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. 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.
## Castellano e identidad visual
Se revisaron las pantallas activas de campo para evitar exponer estados y opciones internas en inglés. Entre otros cambios: Pasaporte/Otro en documentos, estados de Inspección/Acta/Hallazgo en castellano, Correo electrónico, Huella de integridad y terminología de cierre orientada al usuario.
La pantalla de ingreso y el desbloqueo biométrico utilizan la identidad visual de Mendoza ya incorporada al proyecto.
## Barrera obligatoria ## Barrera obligatoria
Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `Android CI / RC`: Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `Android CI / RC`:
1. validación de identidad, HTTPS y políticas básicas del manifest; 1. validación de identidad, HTTPS y políticas básicas del manifest;
2. Android lint; 2. Android lint;
3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada; 3. pruebas unitarias Android reales, con verificación de que exista al menos una prueba ejecutada;
4. `assembleDebug`; 4. `assembleDebug`;
5. `assembleRelease` para comprobar que la variante productiva compile; 5. `assembleRelease` para comprobar que la variante productiva compile;
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado. 6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
La barrera de lint exige además manejo explícito de la revocación de permisos de ubicación durante una captura GPS y declara la cámara como capacidad de hardware opcional, sin relajar permisos ni desactivar reglas globalmente. Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover cambios que afecten al servidor.
El artefacto de CI contiene:
- APK debug;
- archivo `.sha256`;
- `release-metadata.txt` con fase, versión, versionCode, commit, applicationId, API base y canal.
## Firma release ## Firma release
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`. La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero una APK productiva que deba actualizar `com.korexlabs.dhinspeccion` tiene que firmarse con la clave histórica.
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior. No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
@@ -42,7 +92,36 @@ No se debe crear una clave nueva para resolver una falta de acceso: eso romperí
Antes de distribuir una APK productiva: Antes de distribuir una APK productiva:
- todas las barreras de CI del SHA exacto deben estar verdes; - todas las barreras de CI del SHA exacto deben estar verdes;
- comprobar certificado/huella de firma contra la versión histórica; - comprobar certificado/huella de firma contra la versión histórica si se distribuirá como actualización productiva;
- realizar actualización sobre al menos una tablet con la versión productiva anterior; - realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda;
- ejecutar smoke funcional contra el entorno objetivo: login, lista de inspecciones, inicio, inventario, alta de campo, foto/GPS, Acta, Hallazgo y cierre; - 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. - 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.2 · flujo conceptual corregido
- Se elimina **Inventario de campo** como acción independiente de la pantalla de Inspección.
- El alta o selección de Inventario existe únicamente dentro de **Agregar Hallazgo**.
- El borrador de Acta nace vacío, sin Inventario y sin urgencia predeterminada.
- La urgencia se exige al cerrar el Acta y se persiste en la misma operación que la vuelve inmutable.
- `inspection_acts.urgency` puede quedar pendiente durante el borrador; el bloqueo exige la decisión y calcula el vencimiento desde allí.
+49
View File
@@ -0,0 +1,49 @@
# DH Android 0.19.1 · revisión de candidata
## Idea funcional verificada contra código y API
La app es la herramienta del inspector en campo. Oficina administra territorio, operadoras, clasificaciones, campos y catálogo; la APK consulta esas fuentes. Una Inspección contiene varias Actas, con una sola en elaboración simultánea. El Hallazgo pertenece al Acta y al elemento seleccionado. Cerrar contenido lo deja inmutable y pendiente de firma. Firma del inspector y manifestación/firma o negativa de empresa permiten sellar el Acta. La Inspección exige todas sus Actas activas selladas.
Yacimiento, Instalación y Subinstalación son destinos de Hallazgos. Una Instalación nueva debe crearse dentro de un Yacimiento y una Subinstalación dentro de una Instalación compatible. El inventario nacido en campo necesita GPS y foto antes del Hallazgo.
## Hallazgos corregidos
| Problema observado | Corrección |
| --- | --- |
| Cuatro clientes renovaban por separado la misma sesión | Coordinador compartido; protege rotación, logout y cambio de usuario |
| Cortes de red durante refresh borraban la sesión | Sólo una respuesta 401 de la sesión vigente la invalida |
| Operaciones solapadas liberaban ocupado antes de terminar | Conteo de pendientes y rechazo del segundo guardado |
| Respuestas de búsquedas antiguas sobrescribían resultados recientes | Generación de búsqueda y verificación del ID de inspección |
| Un Acta anterior podía persistir al cambiar de inspección | Limpieza de estado de Acta al abrir otra inspección |
| Coordenadas crudas excedían decimales admitidos | Normalización 6 decimales para coordenadas y 3 para precisión |
| Inventario móvil ocultaba Yacimientos | Selección para Hallazgos y alta de Instalación dentro de Yacimiento |
| Alta de Instalación podía adoptar otro tipo por fallback | Eliminado; pide elegir el Yacimiento padre correcto |
| Datos técnicos por familia no estaban editables | Formulario conectado a technical-values; mismos campos de oficina |
| Fallos de GPS/permisos en evidencia sin mensaje | Error visible y accionable |
| Barras del sistema/teclado podían tapar controles | Insets de sistema y teclado en la raíz |
## Evidencia automática
25 pruebas Android: 8 contratos existentes y 17 pruebas nuevas de comportamiento (sesión 7, concurrencia 3, GPS 3, campos técnicos 4). Lint debug/release y compilación debug/release en workflow Android. CI general comprueba API, WEB, scripts y migraciones; la auditoría de dependencias se ejecuta separadamente. Consultar las ejecuciones del SHA de la entrega, no sólo un resultado anterior.
## Límites de esta candidata
- Requiere conexión. No existe cola persistente offline ni resolución de conflictos sin señal.
- Un timeout de escritura puede ocurrir después de guardar en servidor. Revisar el registro antes de repetir; no se afirma idempotencia general de todas las escrituras.
- Los datos técnicos por familia se completan en un paso separado tras seleccionar/crear el elemento. GPS/foto continúa siendo la barrera de Hallazgos del servidor.
- No se ejecutó aceptación en tablet física: quedan por comprobar cámara, GPS, biometría, rotación/recreación de actividad, firmas y actualización sobre la APK instalada.
- La release compila sin firma. Se requiere la clave histórica y su certificado de referencia para distribuir una actualización productiva. No crear otra clave como sustituto.
- La candidata debug usa com.korexlabs.dhinspeccion.debug y no reemplaza la app productiva. La compatibilidad con una debug anterior depende también de su certificado; no desinstalar una app con información pendiente sin revisar antes.
## Recorrido de aceptación en tablet
1. Ingresar con inspector y cargar inspecciones asignadas.
2. Iniciar una inspección, crear Acta, seleccionar Yacimiento/Instalación/Subinstalación.
3. Crear Instalación bajo Yacimiento y Subinstalación compatible; probar GPS permitido/denegado y foto tomada/cancelada.
4. Completar campos técnicos requeridos, incluyendo No booleano, decimal con coma, fecha y opción de lista; volver a abrir para comprobar persistencia.
5. Crear Hallazgo catalogado y OTROS, adjuntar evidencia.
6. Cerrar contenido, comprobar inmutabilidad y estado Pendiente de firma.
7. Registrar firmas o negativa de empresa; sellar Acta.
8. Crear otra Acta, comprobar que una pendiente impide cerrar la Inspección; completar y cerrar.
9. Cortar conexión y recuperarla: errores visibles, sin pérdida de sesión por red ni falso aviso de guardado.
10. Verificar en Dashboard los mismos registros y documentos.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 25 versionCode = 30
versionName = "0.16.0" versionName = "0.19.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository 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.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.time.Instant import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) { class MainViewModel(application: Application) : AndroidViewModel(application) {
private val operations = OperationTracker()
private var inventorySearchGeneration = 0
private val repository = DhRepository(application) private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application) private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application) private val actsRepository = MobileActsRepository(application)
@@ -84,7 +88,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Ingresá usuario y contraseña." error = "Ingresá usuario y contraseña."
return return
} }
launchBusy { launchBusy(mutation = true) {
session = repository.login(identifier, password) session = repository.login(identifier, password)
notice = "Sesión iniciada." notice = "Sesión iniciada."
loadVisitsInternal() loadVisitsInternal()
@@ -113,7 +117,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun openVisit(id: String) = launchBusy { fun openVisit(id: String) = launchBusy {
visit = repository.visit(id) val loadedVisit = repository.visit(id)
clearActState()
visit = loadedVisit
inventory = emptyList() inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList() fieldTypes = emptyList()
@@ -123,6 +129,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun closeVisitView() { fun closeVisitView() {
if (busy) return
inventorySearchGeneration += 1
visit = null visit = null
inventory = emptyList() inventory = emptyList()
inventoryParentId = null inventoryParentId = null
@@ -135,7 +143,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun startVisit() { fun startVisit() {
val id = visit?.id ?: return val id = visit?.id ?: return
launchBusy { launchBusy(mutation = true) {
visit = repository.startVisit(id) visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada." 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 currentVisit = visit ?: return
val asset = selectedFieldAsset?.asset
if (currentVisit.status != "IN_PROGRESS") { if (currentVisit.status != "IN_PROGRESS") {
error = "La Inspección debe estar en curso para crear un Acta." error = "La Inspección debe estar en curso para crear un Acta."
return 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" }) { if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente." error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
return return
} }
launchBusy { launchBusy(mutation = true) {
val created = actsRepository.create( val created = actsRepository.create(currentVisit.id, currentVisit.code)
currentVisit.id,
asset?.id,
currentVisit.code,
urgency,
)
selectedAct = created selectedAct = created
actClosure = actsRepository.closure(created.id) actClosure = actsRepository.closure(created.id)
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" notice = "${created.code} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
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)
}
} }
} }
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) { fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val generation = ++inventorySearchGeneration
launchBusy { 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 val currentVisit = visit ?: return
launchBusy { launchBusy {
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventorySearchGeneration += 1
inventoryParentId = effectiveParentId inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
@@ -216,7 +211,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun selectExisting(item: FieldInventoryItem) { fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy { launchBusy(mutation = true) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección." notice = "Inventario agregado a la Inspección."
reloadCurrentInventory(visitId) reloadCurrentInventory(visitId)
@@ -241,6 +236,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
latitude: Double, latitude: Double,
longitude: Double, longitude: Double,
accuracyM: Double?, accuracyM: Double?,
description: String? = null,
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") { if (visit?.status != "IN_PROGRESS") {
@@ -251,17 +247,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Elegí una clasificación técnica o la opción Otro / no catalogado." error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
return return
} }
launchBusy { launchBusy(mutation = true) {
val request = CreateFieldInventoryRequest( val request = CreateFieldInventoryRequest(
typeId = type.id, typeId = type.id,
parentId = parentId, parentId = parentId,
familyId = familyId, familyId = familyId,
name = name.trim(), name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() }, commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes, attributes = attributes,
deviceLatitude = latitude, deviceLatitude = FieldCoordinates.latitude(latitude),
deviceLongitude = longitude, deviceLongitude = FieldCoordinates.longitude(longitude),
deviceAccuracyM = accuracyM, deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
deviceCapturedAt = Instant.now().toString(), deviceCapturedAt = Instant.now().toString(),
) )
selectedFieldAsset = repository.createFieldAsset(visitId, request) selectedFieldAsset = repository.createFieldAsset(visitId, request)
@@ -281,7 +278,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Explicá brevemente por qué se trata de un duplicado." error = "Explicá brevemente por qué se trata de un duplicado."
return return
} }
launchBusy { launchBusy(mutation = true) {
val result = repository.mergeFieldAsset( val result = repository.mergeFieldAsset(
visitId = visitId, visitId = visitId,
assetId = source.id, assetId = source.id,
@@ -308,7 +305,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
val asset = selectedFieldAsset?.asset ?: return val asset = selectedFieldAsset?.asset ?: return
launchBusy { launchBusy(mutation = true) {
val response = repository.uploadFieldPhoto( val response = repository.uploadFieldPhoto(
visitId = visitId, visitId = visitId,
assetId = asset.id, assetId = asset.id,
@@ -340,10 +337,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (draft == null) { if (draft == null) {
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos." error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
return return
} }
launchBusy { launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(draft.id, assetId) selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false) loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, assetId, draft.id) loadFindingOptionsInternal(visitId, assetId, draft.id)
@@ -362,7 +359,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return
val actId = selectedDraftAct()?.id val actId = selectedDraftAct()?.id
if (actId == null) { if (actId == null) {
error = "No hay un Acta en borrador seleccionada." error = "No hay un Acta en elaboración seleccionada."
return return
} }
if (description.isBlank()) { if (description.isBlank()) {
@@ -377,7 +374,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "La gravedad debe estar entre 1 y 10." error = "La gravedad debe estar entre 1 y 10."
return return
} }
launchBusy { launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(actId, assetId) selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create( val response = findingsRepository.create(
visitId, visitId,
@@ -408,7 +405,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
) { ) {
launchBusy { launchBusy(mutation = true) {
findingsRepository.uploadObservationPhoto( findingsRepository.uploadObservationPhoto(
findingId = findingId, findingId = findingId,
file = file, file = file,
@@ -440,7 +437,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Completá nombre, documento y cargo del responsable de la empresa." error = "Completá nombre, documento y cargo del responsable de la empresa."
return return
} }
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible( actClosure = actsRepository.setResponsible(
actId, actId,
MobileResponsibleRequest( MobileResponsibleRequest(
@@ -463,7 +460,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de ausencia de al menos 10 caracteres." error = "Indicá un motivo de ausencia de al menos 10 caracteres."
return return
} }
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible( actClosure = actsRepository.setResponsible(
actId, actId,
MobileResponsibleRequest( MobileResponsibleRequest(
@@ -471,21 +468,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
absenceReason = reason.trim(), absenceReason = reason.trim(),
), ),
) )
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta." notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta."
} }
} }
fun prepareSelectedAct() { fun prepareSelectedAct(urgency: String) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { if (urgency !in setOf("URGENT", "NON_URGENT")) {
actClosure = actsRepository.lock(actId) error = "Definí la urgencia antes de cerrar el Acta."
return
}
launchBusy(mutation = true) {
actClosure = actsRepository.lock(actId, urgency)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones." val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "Acta cerrada como $urgencyLabel y pendiente de firma. Su contenido quedó inmutable."
} }
} }
fun reopenSelectedAct() { fun reopenSelectedAct() {
error = "Un Acta bloqueada es inmutable y no puede volver a borrador." error = "Un Acta pendiente de firma es inmutable y no puede volver a edición."
} }
fun signSelectedActAsInspector( fun signSelectedActAsInspector(
@@ -495,7 +497,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
accuracyM: Double?, accuracyM: Double?,
) { ) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM) actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
notice = "Firma del inspector incorporada al Acta." notice = "Firma del inspector incorporada al Acta."
} }
@@ -510,7 +512,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
statement: String?, statement: String?,
) { ) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.signCompany( actClosure = actsRepository.signCompany(
actId, png, latitude, longitude, accuracyM, manifestation, statement, actId, png, latitude, longitude, accuracyM, manifestation, statement,
) )
@@ -532,7 +534,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de al menos 10 caracteres." error = "Indicá un motivo de al menos 10 caracteres."
return return
} }
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.companyOutcome(actId, status, reason) actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = "Negativa a firmar asentada." notice = "Negativa a firmar asentada."
} }
@@ -541,21 +543,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeSelectedAct() { fun closeSelectedAct() {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.seal(actId) actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState() clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección." notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
} }
} }
fun closeInspection() { fun closeInspection() {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy { launchBusy(mutation = true) {
visit = actsRepository.closeVisit(visitId) visit = actsRepository.closeVisit(visitId)
loadVisitsInternal() loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina." notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
} }
} }
@@ -635,17 +637,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fieldFindingEvidence = emptyMap() 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 { viewModelScope.launch {
busy = true
error = null error = null
try { try {
block() block()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
error = DhRepository.humanError(throwable) error = DhRepository.humanError(throwable)
if (repository.currentSession() == null) session = null if (repository.currentSession() == null) session = null
} finally { } 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.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -451,7 +449,6 @@ class SecureSessionStore(context: Context) {
class DhRepository(context: Context) { class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder() private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -530,13 +527,13 @@ class DhRepository(context: Context) {
visitId = visitId, visitId = visitId,
assetId = assetId, assetId = assetId,
file = part, file = part,
latitude = latitude.toString().toRequestBody(text), latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text), longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text), accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text), exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text), exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text), exifCapturedAt = capturedAt.toRequestBody(text),
) )
} }
@@ -548,23 +545,19 @@ class DhRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session.refreshToken) session = refresh(session)
return block(session) return block(session)
} }
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { private suspend fun refresh(previous: StoredSession): StoredSession =
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
companion object { companion object {
fun humanError(error: Throwable): String { 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) { if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull() val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull() val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
@@ -0,0 +1,14 @@
package com.korexlabs.dhinspeccion.data
import java.math.BigDecimal
import java.math.RoundingMode
internal object FieldCoordinates {
private fun normalized(value: Double, scale: Int, min: Double, max: Double): Double {
require(value.isFinite() && value in min..max) { "La ubicación GPS recibida no es válida." }
return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).toDouble()
}
fun latitude(value: Double) = normalized(value, 6, -90.0, 90.0)
fun longitude(value: Double) = normalized(value, 6, -180.0, 180.0)
fun accuracy(value: Double) = normalized(value, 3, 0.0, 100000.0)
}
@@ -4,8 +4,6 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -176,7 +174,6 @@ private interface FieldFindingsApi {
*/ */
class FieldFindingsRepository(context: Context) { class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder() private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -227,9 +224,9 @@ class FieldFindingsRepository(context: Context) {
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text), latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text), longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text), accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text),
) )
} }
@@ -241,18 +238,11 @@ class FieldFindingsRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session.refreshToken) session = refresh(session)
return block(session) return block(session)
} }
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { private suspend fun refresh(previous: StoredSession): StoredSession =
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
} }
@@ -4,8 +4,6 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -37,7 +35,7 @@ data class MobileActSummary(
val title: String, val title: String,
val summary: String, val summary: String,
val observations: String? = null, val observations: String? = null,
val urgency: String = "NON_URGENT", val urgency: String? = null,
val deadlineDays: Int? = null, val deadlineDays: Int? = null,
val deadlineDayType: String? = null, val deadlineDayType: String? = null,
val deadlineBasis: String? = null, val deadlineBasis: String? = null,
@@ -62,7 +60,7 @@ data class MobileActDetail(
val title: String, val title: String,
val summary: String, val summary: String,
val observations: String? = null, val observations: String? = null,
val urgency: String = "NON_URGENT", val urgency: String? = null,
val deadlineDays: Int? = null, val deadlineDays: Int? = null,
val deadlineDayType: String? = null, val deadlineDayType: String? = null,
val deadlineBasis: String? = null, val deadlineBasis: String? = null,
@@ -93,13 +91,16 @@ data class MobileActListResponse(
data class CreateMobileActRequest( data class CreateMobileActRequest(
val occurredAt: String, val occurredAt: String,
val urgency: String,
val title: String, val title: String,
val summary: String, val summary: String,
val observations: String? = null, val observations: String? = null,
val assetIds: List<String>, val assetIds: List<String>,
) )
data class PrepareMobileActRequest(
val urgency: String,
)
data class UpdateMobileActRequest( data class UpdateMobileActRequest(
val assetIds: List<String>, val assetIds: List<String>,
) )
@@ -132,7 +133,7 @@ data class MobileActClosureHeader(
val code: String, val code: String,
val status: String, val status: String,
val visitId: String, val visitId: String,
val urgency: String = "NON_URGENT", val urgency: String? = null,
val deadlineDays: Int? = null, val deadlineDays: Int? = null,
val deadlineDayType: String? = null, val deadlineDayType: String? = null,
val deadlineBasis: String? = null, val deadlineBasis: String? = null,
@@ -207,7 +208,7 @@ data class MobileCloseVisitRequest(
) )
private interface MobileActsApi { private interface MobileActsApi {
@GET("inspection-visits/{visitId}/acts") @GET("inspection-visits/{visitId}/acts/mobile")
suspend fun listActs( suspend fun listActs(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("visitId") visitId: String, @Path("visitId") visitId: String,
@@ -251,6 +252,7 @@ private interface MobileActsApi {
suspend fun lock( suspend fun lock(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("actId") actId: String, @Path("actId") actId: String,
@Body request: PrepareMobileActRequest,
): MobileActClosure ): MobileActClosure
@Multipart @Multipart
@@ -310,7 +312,6 @@ private interface MobileActsApi {
class MobileActsRepository(context: Context) { class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder() private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -329,19 +330,16 @@ class MobileActsRepository(context: Context) {
suspend fun create( suspend fun create(
visitId: String, visitId: String,
assetId: String? = null,
visitCode: String, visitCode: String,
urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session -> ): MobileActDetail = authorized { session ->
api.createAct( api.createAct(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
visitId, visitId,
CreateMobileActRequest( CreateMobileActRequest(
occurredAt = Instant.now().toString(), occurredAt = Instant.now().toString(),
urgency = urgency,
title = "Acta de inspección $visitCode", 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.", 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 +361,8 @@ class MobileActsRepository(context: Context) {
api.responsible("Bearer ${session.accessToken}", actId, request) api.responsible("Bearer ${session.accessToken}", actId, request)
} }
suspend fun lock(actId: String): MobileActClosure = authorized { session -> suspend fun lock(actId: String, urgency: String): MobileActClosure = authorized { session ->
api.lock("Bearer ${session.accessToken}", actId) api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency))
} }
suspend fun signInspector( suspend fun signInspector(
@@ -429,9 +427,9 @@ class MobileActsRepository(context: Context) {
val consent = "true".toRequestBody(text) val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text) val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text) val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text) val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text) val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text) val accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text)
if (company) { if (company) {
api.signCompany( api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt, "Bearer ${session.accessToken}", actId, file, consent, signedAt,
@@ -454,18 +452,11 @@ class MobileActsRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session.refreshToken) session = refresh(session)
return block(session) return block(session)
} }
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { private suspend fun refresh(previous: StoredSession): StoredSession =
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
} }
@@ -4,8 +4,6 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -55,7 +53,6 @@ private interface MobileInspectionOpenApi {
class MobileInspectionOpenRepository(context: Context) { class MobileInspectionOpenRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileInspectionOpenApi = Retrofit.Builder() private val api: MobileInspectionOpenApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -86,18 +83,11 @@ class MobileInspectionOpenRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session.refreshToken) session = refresh(session)
return block(session) return block(session)
} }
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { private suspend fun refresh(previous: StoredSession): StoredSession =
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
} }
@@ -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()
}
}
@@ -0,0 +1,73 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
private val DhLightColors = lightColorScheme(
primary = Color(0xFF075E63),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFD6F0F0),
onPrimaryContainer = Color(0xFF073F43),
secondary = Color(0xFF445E74),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFDCE7F1),
onSecondaryContainer = Color(0xFF243F54),
tertiary = Color(0xFF6A5A42),
background = Color(0xFFF6F8FA),
onBackground = Color(0xFF182025),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF182025),
surfaceVariant = Color(0xFFEDF1F4),
onSurfaceVariant = Color(0xFF455158),
outline = Color(0xFF77848B),
outlineVariant = Color(0xFFD7DEE2),
error = Color(0xFFB3261E),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF6F1713),
)
private val DhDarkColors = darkColorScheme(
primary = Color(0xFF88D4D7),
onPrimary = Color(0xFF00373A),
primaryContainer = Color(0xFF074F53),
onPrimaryContainer = Color(0xFFB7EBED),
secondary = Color(0xFFB6CBE0),
onSecondary = Color(0xFF203646),
secondaryContainer = Color(0xFF344C60),
onSecondaryContainer = Color(0xFFDCE7F1),
background = Color(0xFF101416),
onBackground = Color(0xFFE0E4E6),
surface = Color(0xFF171C1F),
onSurface = Color(0xFFE0E4E6),
surfaceVariant = Color(0xFF263035),
onSurfaceVariant = Color(0xFFC3CDD2),
outline = Color(0xFF8C989E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
)
private val DhShapes = androidx.compose.material3.Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(22.dp),
extraLarge = RoundedCornerShape(28.dp),
)
@Composable
fun DhTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DhDarkColors else DhLightColors,
shapes = DhShapes,
content = content,
)
}
@@ -0,0 +1,972 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class DynamicGeoSnapshot(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
private enum class DynamicInventoryMode {
BROWSE,
PICK_PARENT,
CREATE_INSTALLATION,
CREATE_SUBINSTALLATION,
}
@Composable
fun DynamicVisitRoot(model: MainViewModel) {
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
var actsMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
when {
actsMode -> MobileActsScreen(
model = model,
onBack = { actsMode = false },
onGoInventory = {
actsMode = false
inventoryMode = true
},
)
inventoryMode -> DynamicFieldInventoryScreen(
model = model,
onBack = {
inventoryMode = false
actsMode = true
},
)
else -> DynamicVisitOverview(
model = model,
onInventory = { inventoryMode = true },
onActs = { actsMode = true },
)
}
}
@Composable
private fun DynamicVisitOverview(
model: MainViewModel,
onInventory: () -> Unit,
onActs: () -> Unit,
) {
val visit = model.visit ?: return
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 30.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = { model.closeVisitView() }) { Text("Volver") }
Text(dynamicStatusLabel(visit.status), fontWeight = FontWeight.Bold)
}
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.scopeAsset?.let {
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
visit.plannedStartAt?.let {
Text("Planificada: ${dynamicShortDate(it)}", style = MaterialTheme.typography.bodySmall)
}
visit.instructions?.takeIf { it.isNotBlank() }?.let {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text("Instrucciones", fontWeight = FontWeight.Bold)
Text(it)
}
}
}
DynamicMessageStrip(model)
if (visit.status == "PLANNED") {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
Text("Al iniciar se habilitan Actas, Hallazgos y altas rápidas de campo.")
Button(
onClick = { model.startVisit(); onActs() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
}
}
}
if (visit.status == "IN_PROGRESS") {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Modo campo rápido", fontWeight = FontWeight.Bold)
Text(
"Desde Inventario podés cargar una Subinstalación en tres pasos: elegir Instalación, identificarla y capturar GPS + foto.",
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Nueva subinstalación / inventario")
}
}
}
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Buscar Inventario / Hallazgos")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Text("Ver Actas · ${model.acts.size}")
}
}
DynamicChecklistCard(visit)
Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (visit.planningAssets.none { it.included }) {
Text("Sin elementos planificados.")
}
visit.planningAssets.filter { it.included }.forEach { asset ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp)) {
Text(asset.name, fontWeight = FontWeight.SemiBold)
Text("${asset.code} · ${asset.typeName.orEmpty()}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun DynamicChecklistCard(visit: VisitDetail) {
val checklist = visit.checklist
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Antecedentes antes de salir", fontWeight = FontWeight.Bold)
Text("Vencidos empresa: ${checklist.companyOverdue} · Verificaciones vencidas: ${checklist.verificationOverdue}")
Text("Antecedentes: ${checklist.antecedents} · Próximos controles: ${checklist.upcomingControls}")
if (checklist.stale) {
Text("El checklist requiere revisión/actualización.", color = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
private fun DynamicFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val visit = model.visit ?: return
var modeName by rememberSaveable(visit.id) { mutableStateOf(DynamicInventoryMode.BROWSE.name) }
val mode = runCatching { DynamicInventoryMode.valueOf(modeName) }.getOrDefault(DynamicInventoryMode.BROWSE)
var search by rememberSaveable(visit.id) { mutableStateOf("") }
var parentSearch by rememberSaveable(visit.id) { mutableStateOf("") }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
val attributeValues = remember { mutableStateMapOf<String, String>() }
var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) }
var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var permissionAction by rememberSaveable(visit.id) { mutableStateOf("PHOTO") }
var mergeSearch by rememberSaveable(visit.id) { mutableStateOf("") }
var mergeCandidateId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var mergeReason by rememberSaveable(visit.id) { mutableStateOf("") }
fun resetForm() {
name = ""
commonName = ""
selectedTypeId = null
selectedFamilyId = null
familySearch = ""
attributeValues.clear()
localError = null
}
fun backToBrowse() {
modeName = DynamicInventoryMode.BROWSE.name
parentId = null
parentLabel = ""
parentSearch = ""
resetForm()
model.loadFieldTypes(null)
}
fun startInstallation() {
model.clearSelectedFieldAsset()
modeName = DynamicInventoryMode.CREATE_INSTALLATION.name
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetForm()
model.loadFieldTypes(null)
}
fun startSubinstallation() {
model.clearSelectedFieldAsset()
modeName = DynamicInventoryMode.PICK_PARENT.name
parentId = null
parentLabel = ""
parentSearch = ""
resetForm()
model.searchInventory("", visit.scopeAsset?.id)
}
fun chooseParent(item: FieldInventoryItem) {
parentId = item.id
parentLabel = "${item.name} (${item.code})"
modeName = DynamicInventoryMode.CREATE_SUBINSTALLATION.name
resetForm()
model.loadFieldTypes(item.id)
}
LaunchedEffect(visit.id) {
model.searchInventory("", visit.scopeAsset?.id)
}
LaunchedEffect(model.fieldTypes, modeName) {
if (mode == DynamicInventoryMode.CREATE_INSTALLATION || mode == DynamicInventoryMode.CREATE_SUBINSTALLATION) {
val preferred = model.fieldTypes.firstOrNull { type ->
val normalized = dynamicNormalize(type.structuralKind ?: type.code.ifBlank { type.name })
when (mode) {
DynamicInventoryMode.CREATE_INSTALLATION -> normalized.contains("instalacion") && !normalized.contains("subinstalacion")
DynamicInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
else -> false
}
} ?: model.fieldTypes.firstOrNull()
if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = preferred?.id
selectedFamilyId = null
familySearch = ""
attributeValues.clear()
}
}
}
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
val filteredFamilies = remember(selectedType, familySearch) {
val needle = dynamicNormalize(familySearch)
selectedType?.families.orEmpty().filter { family ->
needle.isBlank() || dynamicNormalize("${family.code} ${family.name}").contains(needle)
}
}
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<DynamicGeoSnapshot?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
val geo = pendingPhotoGeo
if (success && file != null && geo != null) {
runCatching { writeDynamicExif(file, geo) }
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
} else if (!success) {
localError = "La foto quedó pendiente. Podés tomarla cuando estés listo."
}
pendingPhotoFile = null
pendingPhotoGeo = null
}
val beginPhoto: () -> Unit = {
scope.launch {
runCatching { currentDynamicGeo(context) }
.onSuccess { geo ->
val (file, uri) = newDynamicPhoto(context)
pendingPhotoFile = file
pendingPhotoGeo = geo
takePicture.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo obtener el GPS para la fotografía." }
}
}
fun createWithLocation() {
val type = selectedType ?: return
val effectiveParent = when (mode) {
DynamicInventoryMode.CREATE_SUBINSTALLATION -> parentId
else -> null
}
scope.launch {
runCatching { currentDynamicGeo(context) }
.onSuccess { geo ->
pendingAutoPhoto = true
model.createFieldAsset(
type = type,
parentId = effectiveParent,
familyId = selectedFamilyId,
name = name,
commonName = commonName,
attributes = buildDynamicAttributes(type, attributeValues),
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
)
}
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
}
}
val permissionsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val cameraAllowed = result[Manifest.permission.CAMERA] == true || dynamicHasPermission(context, Manifest.permission.CAMERA)
val locationAllowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || dynamicHasLocation(context)
when (permissionAction) {
"CREATE" -> {
if (cameraAllowed && locationAllowed) createWithLocation()
else localError = "Para el alta rápida se necesitan cámara y ubicación."
}
else -> {
if (cameraAllowed && locationAllowed) beginPhoto()
else localError = "Para completar el alta se necesitan cámara y ubicación."
}
}
}
fun requestCreate() {
permissionAction = "CREATE"
if (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(context)) {
createWithLocation()
} else {
permissionsLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
fun requestPhoto() {
permissionAction = "PHOTO"
if (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(context)) {
beginPhoto()
} else {
permissionsLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
val selectedCapture = model.selectedFieldAsset
LaunchedEffect(selectedCapture?.asset?.id, pendingAutoPhoto) {
val capture = selectedCapture?.capture
if (pendingAutoPhoto && capture?.captureRequired == true && capture.creationGpsCaptured && capture.fieldPhotoCount == 0) {
pendingAutoPhoto = false
requestPhoto()
}
}
val mergeSource = selectedCapture?.takeIf {
it.capture.captureRequired && dynamicItemTypeCode(it.asset) in setOf("instalacion", "subinstalacion")
}
val mergeCandidates = remember(model.inventory, mergeSource) {
if (mergeSource == null) emptyList() else model.inventory.filter { candidate ->
candidate.id != mergeSource.asset.id &&
candidate.informationStatus != "INACTIVE" &&
candidate.type?.id == mergeSource.asset.type?.id &&
candidate.parent?.id == mergeSource.asset.parent?.id &&
!candidate.captureRequired
}
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = { if (mode == DynamicInventoryMode.BROWSE) onBack() else backToBrowse() }) {
Text(if (mode == DynamicInventoryMode.BROWSE) "Acta" else "Atrás")
}
Column(horizontalAlignment = Alignment.End) {
Text(
when (mode) {
DynamicInventoryMode.PICK_PARENT -> "Elegir Instalación"
DynamicInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación"
DynamicInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación"
else -> "Inventario de campo"
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Text(visit.scopeAsset?.name ?: visit.code, style = MaterialTheme.typography.bodySmall)
}
}
Column(Modifier.padding(horizontal = 16.dp)) {
DynamicMessageStrip(model)
localError?.let {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
onClick = { localError = null },
) { Text(it, Modifier.padding(12.dp)) }
}
}
if (selectedCapture != null) {
DynamicCaptureCard(
item = selectedCapture.asset,
gps = selectedCapture.capture.creationGpsCaptured,
photos = selectedCapture.capture.fieldPhotoCount,
ready = selectedCapture.capture.readyForFinding,
onPhoto = { requestPhoto() },
onClose = { model.clearSelectedFieldAsset() },
onCreateAnother = {
if (dynamicItemTypeCode(selectedCapture.asset) == "subinstalacion" && selectedCapture.asset.parent != null) {
chooseParent(
FieldInventoryItem(
id = selectedCapture.asset.parent.id,
code = selectedCapture.asset.parent.code,
name = selectedCapture.asset.parent.name,
type = null,
),
)
} else {
startSubinstallation()
}
},
)
}
if (mergeSource != null && !mergeSource.capture.readyForFinding) {
Card(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("¿Ya existía?", fontWeight = FontWeight.Bold)
Text("Antes de seguir, podés fusionar el alta con un registro existente compatible.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = mergeSearch,
onValueChange = { mergeSearch = it },
label = { Text("Buscar posible duplicado") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedButton(
onClick = {
mergeCandidateId = null
model.searchInventory(mergeSearch, mergeSource.asset.parent?.id)
},
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar coincidencias") }
mergeCandidates.take(6).forEach { candidate ->
AssistChip(
onClick = { mergeCandidateId = candidate.id },
label = {
Text(if (mergeCandidateId == candidate.id) "${candidate.code} · ${candidate.name}" else "${candidate.code} · ${candidate.name}")
},
)
}
if (mergeCandidateId != null) {
OutlinedTextField(
value = mergeReason,
onValueChange = { mergeReason = it },
label = { Text("Motivo de la fusión") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
Button(
onClick = { model.mergeCreatedFieldAsset(mergeCandidateId!!, mergeReason) },
enabled = mergeReason.trim().length >= 8 && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Fusionar con el existente") }
}
}
}
}
when (mode) {
DynamicInventoryMode.BROWSE -> {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Alta rápida en campo", fontWeight = FontWeight.Bold)
Text("La Subinstalación se carga guiada, sin navegar por formularios largos.", style = MaterialTheme.typography.bodySmall)
Button(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") {
Text("+ Nueva Subinstalación")
}
OutlinedButton(onClick = { startInstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") {
Text("+ Nueva Instalación")
}
}
}
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar instalación, subinstalación o código") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = { model.searchInventory(search) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar") }
}
Text("Inventario disponible", Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
model.inventory.filter { dynamicItemTypeCode(it) in setOf("instalacion", "subinstalacion") },
key = { it.id },
) { item ->
DynamicInventoryCard(
item = item,
onInspect = { model.selectExisting(item) },
onAddChild = { chooseParent(item) },
)
}
item { Spacer(Modifier.height(30.dp)) }
}
}
DynamicInventoryMode.PICK_PARENT -> {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
DynamicStepHeader(1, "Elegí la Instalación padre")
Text("Solo se muestran Instalaciones del Yacimiento de esta inspección.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = parentSearch,
onValueChange = { parentSearch = it },
label = { Text("Buscar Instalación por nombre o código") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = { model.searchInventory(parentSearch, visit.scopeAsset?.id) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar Instalación") }
}
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
model.inventory.filter { dynamicItemTypeCode(it) == "instalacion" },
key = { it.id },
) { item ->
Card(onClick = { chooseParent(item) }, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall)
Text("Tocar para agregar una Subinstalación", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
}
DynamicInventoryMode.CREATE_INSTALLATION,
DynamicInventoryMode.CREATE_SUBINSTALLATION -> {
val isSubinstallation = mode == DynamicInventoryMode.CREATE_SUBINSTALLATION
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
DynamicStepHeader(if (isSubinstallation) 2 else 1, if (isSubinstallation) "Identificá la Subinstalación" else "Identificá la Instalación")
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold)
if (isSubinstallation) {
OutlinedButton(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth()) {
Text("Cambiar Instalación padre")
}
}
if (model.fieldTypes.isEmpty()) {
Text("No hay un tipo habilitado para esta ubicación.", color = MaterialTheme.colorScheme.error)
}
if (model.fieldTypes.size > 1) {
Text("Nivel de Inventario", fontWeight = FontWeight.Bold)
model.fieldTypes.forEach { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (selectedTypeId == type.id) "${type.name}" else type.name) },
)
}
}
if (selectedType?.familyRequired == true) {
Text("Clasificación técnica *", fontWeight = FontWeight.Bold)
OutlinedTextField(
value = familySearch,
onValueChange = { familySearch = it },
label = { Text("Buscar clasificación") },
supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
filteredFamilies.take(12).forEach { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
}
if (filteredFamilies.size > 12) {
Text("Escribí parte del nombre para reducir la lista.", style = MaterialTheme.typography.bodySmall)
}
selectedFamily?.let { family ->
if (family.isOther) {
Text("Quedará marcado como no catalogado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary)
}
if (family.informationLabels.isNotEmpty()) {
Text("Datos esperados: ${family.informationLabels.joinToString(" · ")}", style = MaterialTheme.typography.bodySmall)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre o código visible *") },
supportingText = { Text("Usá lo que ve el inspector en la placa o en campo.") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = Modifier.fillMaxWidth(),
)
}
HorizontalDivider()
DynamicStepHeader(if (isSubinstallation) 3 else 2, "GPS + foto en un solo paso")
Text(
"Al guardar se captura la ubicación actual y la cámara se abre automáticamente. La foto sigue siendo obligatoria antes de crear Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = { requestCreate() },
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Guardando…" else "Guardar y tomar foto") }
Spacer(Modifier.height(30.dp))
}
}
}
}
}
@Composable
private fun DynamicStepHeader(step: Int, title: String) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
Text(step.toString(), Modifier.padding(horizontal = 10.dp, vertical = 6.dp), fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(8.dp))
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun DynamicCaptureCard(
item: FieldInventoryItem,
gps: Boolean,
photos: Int,
ready: Boolean,
onPhoto: () -> Unit,
onClose: () -> Unit,
onCreateAnother: () -> Unit,
) {
Card(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
colors = CardDefaults.cardColors(
containerColor = if (ready) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(Modifier.weight(1f)) {
Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall)
}
OutlinedButton(onClick = onClose) { Text("Cerrar") }
}
Text("GPS: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) {
Text("Falta la fotografía obligatoria.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto ahora") }
} else {
Text("Alta completa · lista para Hallazgos", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
OutlinedButton(onClick = onCreateAnother, modifier = Modifier.fillMaxWidth()) {
Text("+ Crear otra Subinstalación")
}
}
}
}
}
@Composable
private fun DynamicInventoryCard(
item: FieldInventoryItem,
onInspect: () -> Unit,
onAddChild: () -> Unit,
) {
val typeCode = dynamicItemTypeCode(item)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(item.name, fontWeight = FontWeight.SemiBold)
Text(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación", style = MaterialTheme.typography.bodySmall)
}
Text(item.code, style = MaterialTheme.typography.bodySmall)
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text(
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Usar para Hallazgo")
}
if (typeCode == "instalacion") {
OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Subinstalación")
}
}
}
}
}
@Composable
private fun DynamicMessageStrip(model: MainViewModel) {
val error = model.error
val notice = model.notice
if (error != null || notice != null) {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
colors = CardDefaults.cardColors(
containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer,
),
onClick = { model.clearMessages() },
) {
Text(error ?: notice.orEmpty(), Modifier.padding(12.dp))
}
}
}
private fun dynamicItemTypeCode(item: FieldInventoryItem): String {
val type = item.type ?: return ""
return dynamicNormalize(type.typeName ?: type.name)
}
private fun dynamicNormalize(value: String): String = value.trim().lowercase()
.replace('ó', 'o')
.replace('í', 'i')
.replace('á', 'a')
.replace('é', 'e')
.replace('ú', 'u')
.replace("_", "")
.replace("-", "")
.replace(" ", "")
private fun dynamicStatusLabel(status: String): String = when (status) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun buildDynamicAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceDynamicAttribute(definition, raw)
}.toMap()
private fun coerceDynamicAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun dynamicHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun dynamicHasLocation(context: Context): Boolean =
dynamicHasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
dynamicHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentDynamicGeo(context: Context): DynamicGeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!dynamicHasLocation(context)) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) {
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
} else {
continuation.resume(DynamicGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
private fun newDynamicPhoto(context: Context): Pair<File, Uri> {
val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
?: throw IllegalStateException("No se pudo acceder al almacenamiento de fotografías.")
directory.mkdirs()
val file = File.createTempFile("DH_FAST_${System.currentTimeMillis()}_", ".jpg", directory)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
return file to uri
}
private fun writeDynamicExif(file: File, geo: DynamicGeoSnapshot) {
val now = Instant.now()
val exif = ExifInterface(file)
exif.setLatLong(geo.latitude, geo.longitude)
val formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneId.systemDefault())
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formatter.format(now))
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, formatter.format(now))
exif.saveAttributes()
}
private fun dynamicShortDate(value: String): String = value.replace('T', ' ').take(16)
@@ -75,6 +75,7 @@ fun FieldFindingScreen(model: MainViewModel) {
var severityText by rememberSaveable(asset.id) { mutableStateOf("") } var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn 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 requestedFindingId by remember { mutableStateOf<String?>(null) }
var pendingPhotoFile by remember { mutableStateOf<File?>(null) } var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) } var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
@@ -110,6 +111,7 @@ fun FieldFindingScreen(model: MainViewModel) {
pendingPhotoFindingId = findingId pendingPhotoFindingId = findingId
takePicture.launch(uri) 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 val findingId = requestedFindingId
requestedFindingId = null requestedFindingId = null
if (camera && location && findingId != null) beginPhoto(findingId) 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) { fun requestPhoto(findingId: String) {
@@ -185,13 +188,14 @@ fun FieldFindingScreen(model: MainViewModel) {
Text(asset.name, fontWeight = FontWeight.Bold) Text(asset.name, fontWeight = FontWeight.Bold)
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall) Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text( Text(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}", "GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
} }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.error?.let { model.error?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(it, Modifier.padding(12.dp)) Text(it, Modifier.padding(12.dp))
@@ -210,7 +214,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold) Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall) Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall)
Text( Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}", "Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -3,12 +3,18 @@ package com.korexlabs.dhinspeccion.ui
import android.content.Context import android.content.Context
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
@@ -29,7 +35,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
@@ -37,6 +45,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.korexlabs.dhinspeccion.MainViewModel import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.R
private const val BIOMETRIC_PREFS = "dh_v2_biometric" private const val BIOMETRIC_PREFS = "dh_v2_biometric"
private const val BIOMETRIC_ENABLED = "enabled" private const val BIOMETRIC_ENABLED = "enabled"
@@ -74,8 +83,8 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
if (model.session == null) unlocked = false if (model.session == null) unlocked = false
} }
MaterialTheme { DhTheme {
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize().safeDrawingPadding().imePadding()) {
when { when {
model.session == null -> EnhancedLoginScreen(model) { model.session == null -> EnhancedLoginScreen(model) {
passwordLoginInFlight = true passwordLoginInFlight = true
@@ -91,7 +100,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
}, },
) )
model.fieldFindingOptions != null -> FieldFindingScreen(model) model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> F3VisitRoot(model) model.visit != null -> ModernVisitRoot(model)
else -> MobileHomeScreen(model) else -> MobileHomeScreen(model)
} }
} }
@@ -101,11 +110,17 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
@Composable @Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) { private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") } var identifier by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") } var password by remember { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) } 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)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(170.dp),
contentScale = ContentScale.Fit,
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos") Text("Aplicación de campo · Dirección de Hidrocarburos")
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -113,7 +128,7 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
OutlinedTextField( OutlinedTextField(
value = identifier, value = identifier,
onValueChange = { identifier = it }, onValueChange = { identifier = it },
label = { Text("Usuario o email") }, label = { Text("Usuario o correo electrónico") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
) )
@@ -192,12 +207,18 @@ private fun BiometricUnlockScreen(
LaunchedEffect(Unit) { authenticate() } LaunchedEffect(Unit) { authenticate() }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(140.dp),
contentScale = ContentScale.Fit,
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella") Text("Ingresá con tu huella")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -60,7 +60,7 @@ fun MobileActsScreen(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() 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) { var attendance by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
} }
@@ -137,7 +137,11 @@ fun MobileActsScreen(
} }
model.acts.forEach { act -> model.acts.forEach { act ->
val active = selected?.id == act.id 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( OutlinedButton(
onClick = { model.selectAct(act.id) }, onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -165,23 +169,8 @@ fun MobileActsScreen(
style = MaterialTheme.typography.bodySmall, 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( Text(
if (newActUrgency == "URGENT") { "La urgencia se define al cerrar el Acta, después de completar los Hallazgos.",
"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."
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text( Text(
@@ -189,7 +178,7 @@ fun MobileActsScreen(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Button( Button(
onClick = { model.createAct(newActUrgency) }, onClick = { model.createAct() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") } ) { Text("Crear nueva Acta") }
@@ -203,7 +192,13 @@ fun MobileActsScreen(
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status)) 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") 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) } selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") { if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
@@ -270,10 +265,20 @@ fun MobileActsScreen(
HorizontalDivider() HorizontalDivider()
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al 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 la urgencia según lo constatado.")
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( Button(
onClick = { model.prepareSelectedAct() }, onClick = { model.prepareSelectedAct(closingUrgency) },
enabled = !model.busy && closure?.responsible != null, enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Finalizar y BLOQUEAR Acta") } ) { Text("Finalizar y BLOQUEAR Acta") }
} }
@@ -215,7 +215,7 @@ private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold) Text(visit.code, fontWeight = FontWeight.Bold)
Text(visit.status) Text(visitStatusLabelEs(visit.status))
} }
Text(visit.operatorCompany?.name ?: "Operadora sin definir") Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall) Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
@@ -0,0 +1,565 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Assignment
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class ModernActSignatureGeo(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun ModernMobileActsScreen(
model: MainViewModel,
onBack: () -> Unit,
onGoInventory: () -> Unit,
) {
val visit = model.visit ?: return
val selected = model.selectedAct
val closure = model.actClosure
val context = LocalContext.current
val scope = rememberCoroutineScope()
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
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 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") }
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
LaunchedEffect(visit.id) { model.reloadActs() }
fun signWithGeo(file: File, company: Boolean) {
scope.launch {
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
if (company) {
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
} else {
model.signSelectedActAsInspector(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
)
}
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ModernHeader(title = "Actas", subtitle = visit.code, onBack = onBack)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
tonalElevation = 1.dp,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(
visit.scopeAsset?.name ?: "Yacimiento",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
ModernMessage(model)
if (model.acts.isEmpty()) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(
Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Text("Todavía no hay Actas", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Creá la primera Acta y después incorporá Hallazgos seleccionando el Inventario.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
Text("Actas de esta inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
model.acts.forEach { act ->
val active = selected?.id == act.id
Surface(
onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
tonalElevation = if (active) 1.dp else 0.dp,
border = BorderStroke(
if (active) 2.dp else 1.dp,
if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
),
) {
Row(
Modifier.padding(15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
when (act.status) {
"LOCKED" -> Icons.Filled.Lock
"SEALED" -> Icons.Filled.CheckCircle
else -> Icons.Filled.EditNote
},
contentDescription = null,
tint = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(act.code, fontWeight = FontWeight.Bold)
Text(
"${modernActStatusLabel(act.status)} · ${when (act.urgency) { "URGENT" -> "Urgente"; "NON_URGENT" -> "No urgente"; else -> "Urgencia pendiente" }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"} · ${act.assetCount} elemento${if (act.assetCount == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
)
}
if (active) Icon(Icons.Filled.CheckCircle, "Seleccionada", tint = MaterialTheme.colorScheme.primary)
}
}
}
}
val hasDraftAct = model.acts.any { it.status == "DRAFT" }
if (visit.status == "IN_PROGRESS" && !hasDraftAct) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
Icon(Icons.Filled.Add, null, Modifier.padding(10.dp), tint = MaterialTheme.colorScheme.primary)
}
Spacer(Modifier.width(10.dp))
Column {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Un solo borrador activo por vez", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
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() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(7.dp))
Text(if (model.busy) "Creando…" else "Crear nueva Acta")
}
}
}
}
if (selected != null) {
HorizontalDivider()
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
StatusPill(modernActStatusLabel(selected.status))
}
Text(
when (selected.urgency) {
"URGENT" -> "Urgencia · Urgente"
"NON_URGENT" -> "Urgencia · No urgente"
else -> "Urgencia · Se define al cerrar el Acta"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO", style = MaterialTheme.typography.bodySmall)
}
}
}
when (selected.status) {
"DRAFT" -> {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp))
Text("Agregar Hallazgo")
}
}
}
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
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)
}
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(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(),
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar responsable") }
} else {
OutlinedTextField(
absenceReason,
{ absenceReason = it },
label = { Text("Motivo de ausencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
enabled = !model.busy && absenceReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar ausencia") }
}
}
}
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.width(8.dp))
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia según lo constatado en campo.",
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(closingUrgency) },
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta y dejar pendiente de firma") }
}
}
}
"LOCKED" -> {
val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(8.dp))
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.")
closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) }
HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold)
if (inspectorSigned) {
SuccessLine("Firma del inspector registrada")
} else {
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
SignaturePad(
label = "Firmá como inspector/a",
enabled = !model.busy,
onCaptured = { file -> signWithGeo(file, company = false) },
)
}
HorizontalDivider()
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"
"REFUSED" -> "Negativa a firmar"
else -> humanBackendLabelEs(companyOutcome.status)
}
SuccessLine(detail)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
Text(
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
}
}
} else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") })
}
if (manifestation == "DISSENT") {
OutlinedTextField(
dissentStatement,
{ dissentStatement = it },
label = { Text("Manifestación de disidencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
SignaturePad(
label = "Firma del responsable de empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) },
)
Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
refusalReason,
{ refusalReason = it },
label = { Text("Motivo de negativa") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") }
}
if (inspectorSigned && companyResolved) {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text("Firmar y cerrar Acta")
}
} else {
Text(
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
"SEALED" -> {
ElevatedCard(Modifier.fillMaxWidth()) {
Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Acta firmada y cerrada", fontWeight = FontWeight.Bold)
Text("Desde este cierre se genera el PDF del Acta y el informe INF editable (DOCX) para el Inspector.")
closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) }
}
}
}
}
"CANCELLED" -> {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
Text("Esta Acta fue cancelada y permanece como antecedente.", Modifier.padding(14.dp))
}
}
}
}
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
val activeActs = model.acts.filter { it.status != "CANCELLED" }
val pendingActs = activeActs.filter { it.status != "SEALED" }
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (pendingActs.isEmpty()) {
SuccessLine("Todas las Actas están firmadas y cerradas")
} else {
Text(
"Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = { model.closeInspection() },
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Inspección y salir") }
}
}
}
Spacer(Modifier.height(32.dp))
}
}
@Composable
private fun SuccessLine(text: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Text(text, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
}
}
private fun modernActStatusLabel(status: String): String = actStatusLabelEs(status)
private fun hasModernActLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private suspend fun currentModernActSignatureGeo(context: Context): ModernActSignatureGeo = suspendCancellableCoroutine { continuation ->
if (!hasModernActLocation(context)) {
continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
try {
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(ModernActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
File diff suppressed because it is too large Load Diff
@@ -49,7 +49,7 @@ fun SignaturePad(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold) Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
Text( Text(
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.", "Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora a la huella de integridad del Acta.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Canvas( Canvas(
@@ -0,0 +1,99 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.data.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@Composable
internal fun TechnicalFieldsButton(assetId: String, enabled: Boolean) {
val context = LocalContext.current
val repository = remember { TechnicalRepository(context) }
val scope = rememberCoroutineScope()
var open by rememberSaveable(assetId) { mutableStateOf(false) }
var busy by remember(assetId) { mutableStateOf(false) }
var snapshot by remember(assetId) { mutableStateOf<TechnicalValues?>(null) }
var error by remember(assetId) { mutableStateOf<String?>(null) }
var saved by remember(assetId) { mutableStateOf(false) }
val values = remember(assetId) { mutableStateMapOf<String, String>() }
OutlinedButton(onClick = { open = true }, enabled = enabled,
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp)) {
Text(if (saved) "Datos técnicos guardados · Revisar" else "Completar datos técnicos")
}
if (!open) return
LaunchedEffect(assetId, open) {
busy = true; error = null; snapshot = null
try {
val loaded = repository.get(assetId)
snapshot = loaded
values.clear()
loaded.values.forEach { (id, value) -> values[id] = value?.toString().orEmpty() }
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
AlertDialog(
onDismissRequest = { if (!busy) open = false },
title = { Text(snapshot?.family?.name ?: "Datos técnicos") },
text = {
Column(Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Son los mismos campos de la clasificación administrada en oficina. Los marcados con * son obligatorios.")
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
snapshot?.definitions?.filter { it.isActive }?.forEach { definition ->
val label = definition.name + (if (definition.isRequired) " *" else "") + (definition.unit?.let { " ($it)" } ?: "")
val current = values[definition.id].orEmpty()
if (definition.dataType in setOf("BOOLEAN", "SELECT")) {
Text(label)
val options = if (definition.dataType == "BOOLEAN") listOf("true", "false") else definition.options.orEmpty()
options.forEach { option ->
FilterChip(selected = current == option, enabled = !busy,
onClick = { values[definition.id] = option },
label = { Text(if (definition.dataType == "BOOLEAN") if (option == "true") "" else "No" else option) })
}
if (!definition.isRequired) TextButton(enabled = !busy, onClick = { values[definition.id] = "" }) { Text("Sin dato") }
} else {
OutlinedTextField(value = current, onValueChange = { values[definition.id] = it },
label = { Text(label) }, enabled = !busy, modifier = Modifier.fillMaxWidth(),
supportingText = {
when (definition.dataType) {
"DATE" -> Text("AAAA-MM-DD")
"DATETIME" -> Text("AAAA-MM-DDTHH:MM:SS-03:00")
else -> Unit
}
})
}
}
if (snapshot?.definitions?.none { it.isActive } == true) Text("Esta clasificación no tiene campos técnicos activos.")
}
},
confirmButton = {
TextButton(enabled = !busy && snapshot != null, onClick = {
val current = snapshot ?: return@TextButton
busy = true; error = null
scope.launch {
try {
val payload = current.definitions.filter { it.isActive }.mapNotNull { definition ->
technicalInputValue(definition, values[definition.id].orEmpty())?.let { definition.id to it }
}.toMap()
snapshot = repository.save(assetId, payload)
saved = true; open = false
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
}) { Text("Guardar datos") }
},
dismissButton = { TextButton(enabled = !busy, onClick = { open = false }) { Text("Cerrar") } },
)
}
@@ -0,0 +1,50 @@
package com.korexlabs.dhinspeccion.ui
import java.util.Locale
internal fun visitStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"DRAFT" -> "En elaboración"
"LOCKED" -> "Pendiente de firma"
"SEALED" -> "Firmada y cerrada"
"CLOSED" -> "Cerrada"
"RECTIFIED" -> "Rectificada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun findingStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"OPEN" -> "Abierto"
"PENDING" -> "Pendiente"
"COMPANY_RESPONSE_PENDING" -> "Pendiente de respuesta de la empresa"
"COMPANY_RESPONDED" -> "Respondido por la empresa"
"VERIFICATION_PENDING" -> "Pendiente de verificación"
"VERIFICATION_SCHEDULED" -> "Verificación programada"
"READY_TO_CLOSE" -> "Listo para cierre"
"CLOSED" -> "Cerrado"
"CANCELLED" -> "Cancelado"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun documentTypeLabelEs(type: String): String = when (type.uppercase(Locale.ROOT)) {
"DNI" -> "DNI"
"CUIL" -> "CUIL"
"PASSPORT" -> "Pasaporte"
"OTHER" -> "Otro"
else -> humanBackendLabelEs(type)
}
internal fun humanBackendLabelEs(value: String): String {
val clean = value.trim().replace('_', ' ').replace('-', ' ').lowercase(Locale("es", "AR"))
return clean.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale("es", "AR")) else it.toString() }
}
@@ -0,0 +1,26 @@
package com.korexlabs.dhinspeccion
import java.io.File
import org.junit.Assert.assertTrue
import org.junit.Test
class DynamicFieldFlowContractTest {
private val source = File("src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt").readText()
@Test
fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() {
assertTrue(source.contains("Elegí la Instalación padre"))
assertTrue(source.contains("Clasificación técnica *"))
assertTrue(source.contains("Guardar y tomar foto"))
assertTrue(source.contains("model.loadFieldTypes(item.id)"))
assertTrue(source.contains("parentId = item.id"))
}
@Test
fun fieldCaptureStillRequiresGpsAndPhotoBeforeFindings() {
assertTrue(source.contains("La foto sigue siendo obligatoria antes de crear Hallazgos"))
assertTrue(source.contains("creationGpsCaptured"))
assertTrue(source.contains("fieldPhotoCount == 0"))
assertTrue(source.contains("model.uploadFieldPhoto"))
}
}
@@ -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) { }
}
}
@@ -0,0 +1,57 @@
package com.korexlabs.dhinspeccion
import java.io.File
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FinalFieldFlowContractTest {
private val visit = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt").readText()
private val acts = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt").readText()
private val findings = File("src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt").readText()
private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
private val labels = File("src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt").readText()
@Test
fun inspectionActFindingFlowMatchesFieldProcedure() {
assertTrue(visit.contains("Iniciar inspección"))
assertTrue(acts.contains("Crear nueva Acta"))
assertTrue(acts.contains("Agregar Hallazgo"))
assertTrue(visit.contains("Instalación"))
assertTrue(visit.contains("Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto"))
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\""))
}
@Test
fun fieldCreateKeepsDashboardEquivalentMinimumAndCaptureBarrier() {
assertTrue(visit.contains("Nombre técnico *"))
assertTrue(visit.contains("Nombre habitual (opcional)"))
assertTrue(visit.contains("Descripción (opcional)"))
assertTrue(visit.contains("familyReady"))
assertTrue(visit.contains("attributesReady"))
assertTrue(visit.contains("GPS + foto"))
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
}
@Test
fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
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(labels.contains("\"PASSPORT\" -> \"Pasaporte\""))
assertTrue(labels.contains("\"OTHER\" -> \"Otro\""))
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
assertFalse(acts.contains("Acta BLOQUEADA"))
assertFalse(acts.contains("SELLAR Acta"))
assertFalse(acts.contains("Acta SELLADA"))
assertFalse(acts.contains("Text(\"Email\")"))
}
}
@@ -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 @Test
fun debugBuildKeepsSeparateApplicationIdentity() { fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(25, BuildConfig.VERSION_CODE) assertEquals(30, BuildConfig.VERSION_CODE)
assertEquals("0.16.0-debug", BuildConfig.VERSION_NAME) assertEquals("0.19.2-debug", BuildConfig.VERSION_NAME)
} }
@Test @Test
@@ -0,0 +1,28 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import org.junit.Assert.*
import org.junit.Test
class TechnicalValuesTest {
private fun field(type: String, required: Boolean = false) = TechnicalDefinition("id", "code", "Campo", type, required, options = listOf("A", "B"))
@Test fun decimalCommaAndBooleanFalseKeepTheirTypes() {
assertEquals(12.5, technicalInputValue(field("NUMBER"), "12,5"))
assertEquals(false, technicalInputValue(field("BOOLEAN", true), "false"))
assertEquals(0.0, technicalInputValue(field("NUMBER", true), "0"))
}
@Test fun optionalBlankIsOmittedButRequiredBlankFails() {
assertNull(technicalInputValue(field("TEXT"), " "))
try { technicalInputValue(field("TEXT", true), ""); fail("Required missing") }
catch (_: IllegalArgumentException) { }
}
@Test fun invalidNumbersDatesAndOptionsFailBeforeSave() {
for ((type, value) in listOf("NUMBER" to "NaN", "DATE" to "2026-02-30", "SELECT" to "C", "BOOLEAN" to "quizás")) {
try { technicalInputValue(field(type), value); fail("Invalid value accepted") }
catch (_: IllegalArgumentException) { }
}
}
@Test fun validDatesAndSelectRemainCanonical() {
assertEquals("2026-09-14", technicalInputValue(field("DATE"), "2026-09-14"))
assertEquals("A", technicalInputValue(field("SELECT"), "A"))
assertEquals("2026-09-14T13:00:00Z", technicalInputValue(field("DATETIME"), "2026-09-14T10:00:00-03:00"))
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.29.0-3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.29.0-3",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.29.0-3",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -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 { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
import { InventoryStructureController } from './inventory-structure.controller'; import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service'; import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller'; import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller'; 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 { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller'; import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
import { ActivityDossierService } from './activity-dossier.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service'; import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryBrowserController } from './inventory-browser.controller'; import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service'; import { InventoryBrowserService } from './inventory-browser.service';
@@ -59,11 +61,13 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetTypesService, AssetTypesService,
AssetsService, AssetsService,
InventoryStructureService, InventoryStructureService,
SimpleInventoryStructureService,
InventoryFamilyCatalogService, InventoryFamilyCatalogService,
InventoryTechnicalValuesService, InventoryTechnicalValuesService,
InventoryFunctionService, InventoryFunctionService,
InventoryBrowserService, InventoryBrowserService,
InventoryMergeService, InventoryMergeService,
ActivityDossierService,
MergedInventoryDossierService, MergedInventoryDossierService,
AssetGeometriesService, AssetGeometriesService,
AssetHistoryService, 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 (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
} }
if (dto.relationRole === AreaOrganizationRole.OPERATOR) { // F7: un Área puede estar explotada por varias Empresas simultáneamente.
const [currentOperator] = (await manager.query(` // La unicidad válida es Área + Empresa + rol activo; no existe una única Operadora por Área.
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.`,
});
}
}
const [row] = (await manager.query(` const [row] = (await manager.query(`
INSERT INTO area_company_relations ( INSERT INTO area_company_relations (
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by 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 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 }>; `, [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); const created = await this.loadRelation(manager, row.id);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -181,7 +159,7 @@ export class AssetOperationalRelationsService {
entityId: row.id, entityId: row.id,
afterData: this.auditView(created), afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true } ? { inventoryHierarchyChanged:false, multipleAreaOperatorsAllowed:true }
: undefined, : undefined,
}, manager); }, manager);
return created; 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. // Ending an operator relation never moves, rewrites or blocks existing Inventory.
await manager.query(` await manager.query(`
UPDATE area_company_relations UPDATE area_company_relations
@@ -232,6 +209,7 @@ export class AssetOperationalRelationsService {
afterData: this.auditView(updated), afterData: this.auditView(updated),
metadata: { metadata: {
inventoryHierarchyChanged:false, inventoryHierarchyChanged:false,
operatorSnapshotPreserved:true,
retainedCompatibilitySnapshotCount: before.assignedAssetCount, retainedCompatibilitySnapshotCount: before.assignedAssetCount,
}, },
}, manager); }, manager);
@@ -4,10 +4,14 @@ import { RequirePermissions } from '../authorization/decorators/require-permissi
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto'; import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
import { InventoryStructureService } from './inventory-structure.service'; import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
@Controller('inventory-structure') @Controller('inventory-structure')
export class InventoryStructureController { export class InventoryStructureController {
constructor(private readonly inventoryStructure: InventoryStructureService) {} constructor(
private readonly inventoryStructure: InventoryStructureService,
private readonly simpleInventoryStructure: SimpleInventoryStructureService,
) {}
@Get() @Get()
@RequirePermissions('assets.read') @RequirePermissions('assets.read')
@@ -24,6 +28,17 @@ export class InventoryStructureController {
return this.inventoryStructure.parents(kind, search); 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() @Post()
@RequirePermissions('assets.create') @RequirePermissions('assets.create')
create( create(
@@ -1,10 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ActivityDossierService } from './activity-dossier.service';
import { AssetsService } from './assets.service'; import { AssetsService } from './assets.service';
import { InventoryFunctionService } from './inventory-function.service'; import { InventoryFunctionService } from './inventory-function.service';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>; type LooseRecord = Record<string, any>;
const MERGEABLE_DOSSIER_TYPES = new Set(['instalacion', 'subinstalacion']);
function dedupeById<T extends LooseRecord>(items: T[]): T[] { function dedupeById<T extends LooseRecord>(items: T[]): T[] {
const seen = new Set<string>(); const seen = new Set<string>();
const result: T[] = []; const result: T[] = [];
@@ -29,11 +32,22 @@ function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[
export class MergedInventoryDossierService { export class MergedInventoryDossierService {
constructor( constructor(
private readonly assets: AssetsService, private readonly assets: AssetsService,
private readonly activity: ActivityDossierService,
private readonly merges: InventoryMergeService, private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService, private readonly functions: InventoryFunctionService,
) {} ) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> { 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 mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
const canonical = mergeStatus.canonical as LooseRecord; const canonical = mergeStatus.canonical as LooseRecord;
const requested = mergeStatus.requested as LooseRecord; const requested = mergeStatus.requested as LooseRecord;
@@ -44,7 +58,7 @@ export class MergedInventoryDossierService {
.filter((value, index, all) => all.indexOf(value) === index); .filter((value, index, all) => all.indexOf(value) === index);
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => { 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 identity = dossier.asset as LooseRecord;
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null; const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier }; return { assetId, identity, dossier, functionDossier };
@@ -145,6 +159,9 @@ export class MergedInventoryDossierService {
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT'); const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length; const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').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 { return {
asset: { asset: {
@@ -194,6 +211,7 @@ export class MergedInventoryDossierService {
media, media,
versions, versions,
timeline: timeline.slice(0, 1000), 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];
}
}
@@ -71,8 +71,8 @@ export class InspectionAct extends TimestampedEntity {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
observations!: string | null; observations!: string | null;
@Column({ name: 'urgency', type: 'varchar', length: 24, default: InspectionActUrgency.NON_URGENT }) @Column({ name: 'urgency', type: 'varchar', length: 24, nullable: true })
urgency!: InspectionActUrgency; urgency!: InspectionActUrgency | null;
@Column({ name: 'deadline_days', type: 'integer', nullable: true }) @Column({ name: 'deadline_days', type: 'integer', nullable: true })
deadlineDays!: number | null; deadlineDays!: number | 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'`);
}
}
@@ -3,7 +3,6 @@ import {
ArrayMaxSize, ArrayMaxSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEnum,
IsISO8601, IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
@@ -11,15 +10,11 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class CreateInspectionActDto { export class CreateInspectionActDto {
@IsISO8601({ strict: true }) @IsISO8601({ strict: true })
occurredAt!: string; occurredAt!: string;
@IsEnum(InspectionActUrgency)
urgency!: InspectionActUrgency;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@@ -4,7 +4,6 @@ import {
ArrayMinSize, ArrayMinSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEnum,
IsISO8601, IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
@@ -12,17 +11,12 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class UpdateInspectionActDto { export class UpdateInspectionActDto {
@IsOptional() @IsOptional()
@IsISO8601({ strict: true }) @IsISO8601({ strict: true })
occurredAt?: string; occurredAt?: string;
@IsOptional()
@IsEnum(InspectionActUrgency)
urgency?: InspectionActUrgency;
@IsOptional() @IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsString()
@@ -5,10 +5,16 @@ import {
InspectionVisitActsController, InspectionVisitActsController,
} from './inspection-acts.controller'; } from './inspection-acts.controller';
import { InspectionActsService } from './inspection-acts.service'; import { InspectionActsService } from './inspection-acts.service';
import { MobileInspectionActsController } from './mobile-inspection-acts.controller';
import { MobileInspectionActsService } from './mobile-inspection-acts.service';
@Module({ @Module({
imports: [AuditModule], imports: [AuditModule],
controllers: [InspectionVisitActsController, InspectionActsController], controllers: [
providers: [InspectionActsService], InspectionVisitActsController,
MobileInspectionActsController,
InspectionActsController,
],
providers: [InspectionActsService, MobileInspectionActsService],
}) })
export class InspectionActsModule {} export class InspectionActsModule {}
@@ -74,7 +74,7 @@ export interface InspectionActListItem {
title: string; title: string;
summary: string; summary: string;
observations: string | null; observations: string | null;
urgency: InspectionActUrgency; urgency: InspectionActUrgency | null;
deadlineDays: number | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null; deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null; deadlineBasis: InspectionDeadlineBasis | null;
@@ -314,7 +314,7 @@ export class InspectionActsService {
title: dto.title, title: dto.title,
summary: dto.summary, summary: dto.summary,
observations: dto.observations ?? null, observations: dto.observations ?? null,
urgency: dto.urgency, urgency: null,
deadlineDays: null, deadlineDays: null,
deadlineDayType: null, deadlineDayType: null,
deadlineBasis: null, deadlineBasis: null,
@@ -379,7 +379,6 @@ export class InspectionActsService {
await this.assertVisitAssets(manager, visit.id, nextAssetIds); await this.assertVisitAssets(manager, visit.id, nextAssetIds);
const before = await this.loadView(manager, id); const before = await this.loadView(manager, id);
if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt; 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.title !== undefined) act.title = dto.title;
if (dto.summary !== undefined) act.summary = dto.summary; if (dto.summary !== undefined) act.summary = dto.summary;
if (dto.observations !== undefined) act.observations = dto.observations; if (dto.observations !== undefined) act.observations = dto.observations;
@@ -729,10 +728,13 @@ export class InspectionActsService {
principal: AuthPrincipal, principal: AuthPrincipal,
): Promise<number> { ): Promise<number> {
const [row] = (await manager.query(` const [row] = (await manager.query(`
UPDATE inspection_acts WITH updated AS (
SET current_version = current_version + 1 UPDATE inspection_acts
WHERE id = $1 SET current_version = current_version + 1
RETURNING current_version AS "versionNumber" WHERE id = $1
RETURNING current_version
)
SELECT current_version AS "versionNumber" FROM updated
`, [act.id])) as Array<{ versionNumber: number }>; `, [act.id])) as Array<{ versionNumber: number }>;
const versionNumber = Number(row.versionNumber); const versionNumber = Number(row.versionNumber);
act.currentVersion = versionNumber; act.currentVersion = versionNumber;
@@ -0,0 +1,18 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto';
import { MobileInspectionActsService } from './mobile-inspection-acts.service';
@Controller('inspection-visits/:visitId/acts/mobile')
export class MobileInspectionActsController {
constructor(private readonly acts: MobileInspectionActsService) {}
@Get()
@RequirePermissions('inspection_acts.read')
list(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Query() query: ListInspectionActsQueryDto,
) {
return this.acts.listForVisit(visitId, query);
}
}
@@ -0,0 +1,102 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto';
/**
* Read model intentionally kept small for the field client.
*
* The Android app only needs the Act identity/lifecycle, deadlines and counters to
* open an Inspection. It must not fail because an office-document/report module is
* unavailable or temporarily schema-drifted. The canonical office endpoints keep
* their richer projection in InspectionActsService.
*/
@Injectable()
export class MobileInspectionActsService {
constructor(private readonly dataSource: DataSource) {}
async listForVisit(visitId: string, query: ListInspectionActsQueryDto) {
const [visit] = (await this.dataSource.query(
'SELECT id FROM inspection_visits WHERE id = $1::uuid',
[visitId],
)) as Array<{ id: string }>;
if (!visit) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Visita de inspección no encontrada',
});
}
const conditions = ['act.visit_id = $1::uuid'];
const parameters: unknown[] = [visitId];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(act.code ILIKE ${search} OR act.title ILIKE ${search})`);
}
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
const where = `WHERE ${conditions.join(' AND ')}`;
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total FROM inspection_acts act ${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const limit = add(query.pageSize);
const offset = add((query.page - 1) * query.pageSize);
const data = await this.dataSource.query(`
SELECT
act.id,
act.visit_id AS "visitId",
act.code,
act.status,
act.occurred_at AS "occurredAt",
act.title,
act.summary,
act.observations,
act.urgency,
act.deadline_days AS "deadlineDays",
act.deadline_day_type AS "deadlineDayType",
act.deadline_basis AS "deadlineBasis",
act.deadline_base_at AS "deadlineBaseAt",
act.deadline_at AS "deadlineAt",
act.locked_at AS "lockedAt",
act.locked_sha256 AS "lockedSha256",
act.sealed_at AS "sealedAt",
act.current_version AS "currentVersion",
act.closed_at AS "closedAt",
act.closure_sha256 AS "closureSha256",
COALESCE((
SELECT COUNT(*)::integer
FROM inspection_act_assets link
WHERE link.act_id = act.id AND link.included = true
), 0)::integer AS "assetCount",
COALESCE((
SELECT COUNT(*)::integer
FROM inspection_findings finding
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
), 0)::integer AS "findingCount"
FROM inspection_acts act
${where}
ORDER BY act.act_year DESC, act.act_number DESC
LIMIT ${limit} OFFSET ${offset}
`, parameters);
return {
data,
meta: {
page: query.page,
pageSize: query.pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
},
};
}
}
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class PrepareInspectionActDto {
@IsEnum(InspectionActUrgency)
urgency!: InspectionActUrgency;
}
@@ -17,6 +17,7 @@ import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CloseInspectionActDto } from './dto/close-inspection-act.dto'; 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 { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
import { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; import { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
@@ -52,10 +53,11 @@ export class InspectionClosingController {
@RequirePermissions('inspection_closure.prepare') @RequirePermissions('inspection_closure.prepare')
lock( lock(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: PrepareInspectionActDto,
@CurrentAuth() principal: AuthPrincipal, @CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext, @Req() request: RequestWithContext,
) { ) {
return this.closing.prepare(actId, principal, request); return this.closing.prepare(actId, dto, principal, request);
} }
@Post('signatures/inspector') @Post('signatures/inspector')
@@ -35,6 +35,7 @@ import { assertMobileInspector } from '../inspection-operations/mobile-inspector
import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import { InspectionReportsService } from '../inspection-reports/inspection-reports.service';
import { sha256CanonicalJson } from './canonical-json'; import { sha256CanonicalJson } from './canonical-json';
import type { CloseInspectionActDto } from './dto/close-inspection-act.dto'; 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 { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
@@ -130,7 +131,7 @@ export interface InspectionClosureView {
code: string; code: string;
status: InspectionActStatus; status: InspectionActStatus;
visitId: string; visitId: string;
urgency: InspectionActUrgency; urgency: InspectionActUrgency | null;
deadlineDays: number | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null; deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null; deadlineBasis: InspectionDeadlineBasis | null;
@@ -268,6 +269,7 @@ export class InspectionClosingService {
/** Finaliza el contenido: desde este punto el Acta queda inmutable. */ /** Finaliza el contenido: desde este punto el Acta queda inmutable. */
async prepare( async prepare(
actId: string, actId: string,
dto: PrepareInspectionActDto,
principal: AuthPrincipal, principal: AuthPrincipal,
request: RequestWithContext, request: RequestWithContext,
): Promise<InspectionClosureView> { ): Promise<InspectionClosureView> {
@@ -285,16 +287,16 @@ export class InspectionClosingService {
} }
const policy = await this.deadlinePolicy(manager); const policy = await this.deadlinePolicy(manager);
const deadlineDays = act.urgency === InspectionActUrgency.URGENT const deadlineDays = dto.urgency === InspectionActUrgency.URGENT
? policy.urgentDays ? policy.urgentDays
: policy.nonUrgentDays; : policy.nonUrgentDays;
const deadlineDayType = act.urgency === InspectionActUrgency.URGENT const deadlineDayType = dto.urgency === InspectionActUrgency.URGENT
? policy.urgentDayType ? policy.urgentDayType
: policy.nonUrgentDayType; : policy.nonUrgentDayType;
const deadlineBasis = act.urgency === InspectionActUrgency.URGENT const deadlineBasis = dto.urgency === InspectionActUrgency.URGENT
? InspectionDeadlineBasis.ACT_DATE ? InspectionDeadlineBasis.ACT_DATE
: InspectionDeadlineBasis.GEDO_DATE; : InspectionDeadlineBasis.GEDO_DATE;
const deadlineBaseAt = act.urgency === InspectionActUrgency.URGENT const deadlineBaseAt = dto.urgency === InspectionActUrgency.URGENT
? act.occurredAt ? act.occurredAt
: null; : null;
const deadlineAt = deadlineBaseAt const deadlineAt = deadlineBaseAt
@@ -305,20 +307,22 @@ export class InspectionClosingService {
const [updated] = (await manager.query(` const [updated] = (await manager.query(`
UPDATE inspection_acts UPDATE inspection_acts
SET status='LOCKED', SET status='LOCKED',
deadline_days=$2, urgency=$2,
deadline_day_type=$3, deadline_days=$3,
deadline_basis=$4, deadline_day_type=$4,
deadline_base_at=$5, deadline_basis=$5,
deadline_at=$6, deadline_base_at=$6,
locked_at=$7, deadline_at=$7,
locked_by=$8, locked_at=$8,
locked_by=$9,
current_version=current_version+1, current_version=current_version+1,
updated_by=$8, updated_by=$9,
updated_at=$7 updated_at=$8
WHERE id=$1 WHERE id=$1
RETURNING current_version AS "versionNumber" RETURNING current_version AS "versionNumber"
`, [ `, [
actId, actId,
dto.urgency,
deadlineDays, deadlineDays,
deadlineDayType, deadlineDayType,
deadlineBasis, deadlineBasis,
@@ -1009,7 +1013,7 @@ export class InspectionClosingService {
WHERE act.id=$1 WHERE act.id=$1
`, [actId]) as Array<{ `, [actId]) as Array<{
id: string; code: string; status: InspectionActStatus; visitId: string; 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; deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null;
deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null; deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null;
lockedSha256: string | null; sealedAt: Date | null; currentVersion: number; lockedSha256: string | null; sealedAt: Date | null; currentVersion: number;
+1 -1
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-1'; export const API_VERSION = '0.29.0-3';
export const API_PHASE = 'F6.1'; export const API_PHASE = 'F6.1';
@@ -20,7 +20,7 @@ test('F4 WEB has an explicit LOCKED and SEALED act contract', () => {
assert.match(f4Api, /lockedAt: string \| null/); assert.match(f4Api, /lockedAt: string \| null/);
assert.match(f4Api, /lockedSha256: string \| null/); assert.match(f4Api, /lockedSha256: string \| null/);
assert.match(f4Api, /sealedAt: 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/); 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', () => { 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.match(viewModel, /actsRepository\.seal\(actId\)/);
assert.doesNotMatch(viewModel, /actsRepository\.prepare\(/); assert.doesNotMatch(viewModel, /actsRepository\.prepare\(/);
assert.doesNotMatch(viewModel, /actsRepository\.reopen\(/); assert.doesNotMatch(viewModel, /actsRepository\.reopen\(/);
+5 -1
View File
@@ -1,8 +1,12 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { API_PHASE, API_VERSION } from '../../src/version'; import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6.1 release', () => { test('health metadata reports the current F6.1 release', () => {
assert.equal(API_PHASE, 'F6.1'); assert.equal(API_PHASE, 'F6.1');
assert.equal(API_VERSION, '0.29.0-1'); const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
assert.equal(API_VERSION, pkg.version);
assert.equal(API_VERSION, '0.29.0-3');
}); });
@@ -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/); 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 structure = source('src/asset-master/inventory-structure.service.ts');
const dto = source('src/asset-master/dto/create-inventory-structure.dto.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/); 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'); 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, /Tipos y campos/);
assert.match(configPage, /Tipo de concesión/); assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
assert.match(configPage, /Empresa relacionada/); assert.match(configPage, /Un Área puede estar vinculada a varias Empresas/);
assert.match(configPage, /Yacimiento → Instalación/); assert.match(configPage, /Cada Yacimiento elige una sola Empresa operadora/);
assert.match(configPage, /Instalación → Subinstalación/); assert.match(configPage, /Tipos de Instalación/);
assert.match(configPage, /installationFamilies\.map/); assert.match(configPage, /Tipos de Subinstalación/);
assert.match(configPage, /subinstallationFamilies\.map/); assert.match(configPage, /Puede estar dentro de/);
assert.match(configPage, /Hallazgos asociados/); 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 createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx'); const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
assert.match(createPage, /kind: 'DEPARTAMENTO'/); assert.match(createPage, /kind: 'DEPARTAMENTO'/);
assert.match(createPage, /AREA:'Departamento'/); assert.match(createPage, /AREA: 'Departamento'/);
assert.match(createPage, /Empresa relacionada/); assert.match(createPage, /YACIMIENTO: 'Área'/);
assert.match(createPage, /Tipo de concesión/); assert.match(createPage, /INSTALACION: 'Yacimiento'/);
assert.match(createPage, /Datos opcionales/); assert.match(createPage, /SUBINSTALACION: 'Instalación'/);
assert.match(configPage, /<strong>Departamento<\/strong>/); assert.match(createPage, /listCompaniesForInventoryArea/);
assert.match(configPage, /<strong>Yacimiento<\/strong>/); 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/);
}); });
+2 -2
View File
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => { test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts'); const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 25/); assert.match(gradle, /versionCode = 30/);
assert.match(gradle, /versionName = "0\.16\.0"/); assert.match(gradle, /versionName = "0\.19\.2"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/); assert.match(gradle, /applicationIdSuffix = "\.debug"/);
}); });
@@ -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 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, /const requiresCompany = kind === 'YACIMIENTO'/);
assert.match(page, /Empresa relacionada/); assert.match(page, /listCompaniesForInventoryArea\(parentId\)/);
assert.match(page, /Tipo de concesión/); assert.match(page, /Empresa operadora/);
assert.match(page, /operatorCompanyId:operatorCompanyId \|\| null/); assert.match(page, /operatorCompanyId: operatorCompanyId \|\| null/);
assert.match(page, /concessionTypeId:concessionTypeId \|\| null/); assert.doesNotMatch(page, /Tipo de concesión/);
assert.match(structure, /Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica/); 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/);
}); });
@@ -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.doesNotMatch(dto, /ArrayMinSize\(1\)/);
assert.match(service, /if \(assetIds\.length === 0\) return/); 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', () => { 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', () => { 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 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/MobileActsScreen.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'); const vm = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
assert.match(root, /model\.startVisit\(\); onActs\(\)/); assert.match(root, /model\.startVisit\(\); onActs\(\)/);
assert.match(root, /Text\("Nuevo Hallazgo"/); assert.doesNotMatch(root, /Text\("Inventario de campo"/);
assert.match(root, /Buscar instalación o subinstalación/); assert.match(acts, /Text\("Agregar Hallazgo"\)/);
assert.match(acts, /Text\("\+ Agregar Hallazgo"\)/); assert.match(acts, /model\.createAct\(\)/);
assert.match(acts, /model\.createAct\(newActUrgency\)/); assert.match(acts, /model\.prepareSelectedAct\(closingUrgency\)/);
assert.match(vm, /fun createAct\(urgency: String = "NON_URGENT"\)/); assert.match(vm, /fun createAct\(\)/);
assert.match(vm, /fun prepareSelectedAct\(urgency: String\)/);
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/); 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,43 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function apiFile(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
function repoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6.4 Android Installation picker searches live and selects a concrete parent', () => {
const screen = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
assert.match(screen, /LaunchedEffect\(modeName, parentSearch\)/);
assert.match(screen, /delay\(280\)/);
assert.match(screen, /model\.searchInventory\(parentSearch\.trim\(\), visit\.scopeAsset\?\.id\)/);
assert.match(screen, /ElevatedCard\(onClick = \{ chooseParent\(item\) \}/);
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
});
test('F6.4 Android field shell uses the modern theme and workspace', () => {
const gate = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
const theme = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhTheme.kt');
assert.match(gate, /DhTheme \{/);
assert.match(gate, /model\.visit != null -> ModernVisitRoot\(model\)/);
assert.match(theme, /RoundedCornerShape\(16\.dp\)/);
});
test('F6.4 Android Act list uses a mobile read model independent from office reports', () => {
const android = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
const controller = apiFile('src/inspection-acts/mobile-inspection-acts.controller.ts');
const service = apiFile('src/inspection-acts/mobile-inspection-acts.service.ts');
assert.match(android, /inspection-visits\/\{visitId\}\/acts\/mobile/);
assert.match(controller, /inspection-visits\/:visitId\/acts\/mobile/);
assert.match(service, /FROM inspection_acts act/);
assert.doesNotMatch(service, /inspection_reports/);
assert.doesNotMatch(service, /inspection_document/);
});
@@ -0,0 +1,47 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { ActivityDossierService } from '../../src/asset-master/activity-dossier.service';
test('F7 Actividad no usa DISTINCT con ORDER BY COALESCE en visitas', () => {
const source = readFileSync('src/asset-master/activity-dossier.service.ts', 'utf8');
assert.doesNotMatch(source, /SELECT\s+DISTINCT\s+visit\.id/i);
assert.match(source, /ORDER BY COALESCE\(visit\.actual_started_at, visit\.planned_start_at, visit\.created_at\) DESC/);
assert.match(source, /EXISTS \(\s*SELECT 1\s*FROM inspection_visit_assets/s);
});
test('F7 una faceta auxiliar defectuosa no derriba todo el expediente', async () => {
const dataSource = {
query: async (sql: string) => {
if (sql.includes('FROM assets\n WHERE id = $1')) {
return [{
id: 'bcba740f-cc84-48b3-8d06-88a9c439e25c',
code: 'YAC-0005',
name: 'Agua Botada',
commonName: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
}];
}
if (sql.includes('inspection_finding_communications')) {
throw new Error('simulated optional facet failure');
}
return [];
},
};
const service = new ActivityDossierService(dataSource as never);
const dossier = await service.dossier('bcba740f-cc84-48b3-8d06-88a9c439e25c') as {
asset: { code: string; name: string };
counters: { inspections: number; findings: number };
warnings: string[];
timeline: unknown[];
};
assert.equal(dossier.asset.code, 'YAC-0005');
assert.equal(dossier.asset.name, 'Agua Botada');
assert.equal(dossier.counters.inspections, 0);
assert.equal(dossier.counters.findings, 0);
assert.deepEqual(dossier.warnings, ['communications']);
assert.deepEqual(dossier.timeline, []);
});
@@ -0,0 +1,26 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
const mergedDossier = readFileSync('src/asset-master/merged-inventory-dossier.service.ts', 'utf8');
const dossierPanel = readFileSync('../web-v2/src/features/assets/AssetDossierPanel.tsx', 'utf8');
const simpleDetail = readFileSync('../web-v2/src/pages/SimpleInventoryDetailPage.tsx', 'utf8');
test('F7 mantiene Actividad disponible para Yacimiento', () => {
assert.match(simpleDetail, /kind === 'YACIMIENTO'/);
assert.match(simpleDetail, /setTab\('activity'\)/);
assert.match(simpleDetail, /AssetDossierPanel assetId=\{asset\.id\}/);
});
test('F7 no hace depender el dossier territorial del subsistema de fusión', () => {
assert.match(mergedDossier, /MERGEABLE_DOSSIER_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/);
assert.match(mergedDossier, /if \(!MERGEABLE_DOSSIER_TYPES\.has\(requestedTypeCode\)\)/);
assert.match(mergedDossier, /private readonly activity: ActivityDossierService/);
assert.match(mergedDossier, /return await this\.activity\.dossier\(requestedAssetId\)/);
});
test('F7 la consulta auxiliar de merge nunca bloquea la carga de Actividad', () => {
assert.match(dossierPanel, /getInventoryMergeStatus\(assetId\)\.catch\(\(\) => null\)/);
assert.match(dossierPanel, /getAssetDossier\(assetId\)/);
});
+1 -5
View File
@@ -5,21 +5,18 @@ import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import { CancelInspectionActDto } from '../../src/inspection-acts/dto/cancel-inspection-act.dto'; import { CancelInspectionActDto } from '../../src/inspection-acts/dto/cancel-inspection-act.dto';
import { CreateInspectionActDto } from '../../src/inspection-acts/dto/create-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'; 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, { const dto = plainToInstance(CreateInspectionActDto, {
occurredAt: '2026-08-14T15:30:00.000Z', occurredAt: '2026-08-14T15:30:00.000Z',
urgency: InspectionActUrgency.NON_URGENT,
title: ' Verificación de instalación ', title: ' Verificación de instalación ',
summary: ' Se inspeccionó el activo seleccionado. ', summary: ' Se inspeccionó el activo seleccionado. ',
observations: ' Sin novedades. ', observations: ' Sin novedades. ',
assetIds: [ASSET_ID], assetIds: [ASSET_ID],
}); });
assert.deepEqual(await validate(dto), []); assert.deepEqual(await validate(dto), []);
assert.equal(dto.urgency, InspectionActUrgency.NON_URGENT);
assert.equal(dto.title, 'Verificación de instalación'); assert.equal(dto.title, 'Verificación de instalación');
assert.equal(dto.summary, 'Se inspeccionó el activo seleccionado.'); assert.equal(dto.summary, 'Se inspeccionó el activo seleccionado.');
assert.equal(dto.observations, 'Sin novedades.'); 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 () => { test('inspection act DTO rejects duplicate assets', async () => {
const dto = plainToInstance(CreateInspectionActDto, { const dto = plainToInstance(CreateInspectionActDto, {
occurredAt: '2026-08-14T15:30:00.000Z', occurredAt: '2026-08-14T15:30:00.000Z',
urgency: InspectionActUrgency.NON_URGENT,
title: 'Acta', title: 'Acta',
summary: 'Contenido', summary: 'Contenido',
assetIds: [ASSET_ID, ASSET_ID], assetIds: [ASSET_ID, ASSET_ID],
@@ -5,6 +5,8 @@ import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import { CreateCompanyOutcomeDto } from '../../src/inspection-closing/dto/create-company-outcome.dto'; import { CreateCompanyOutcomeDto } from '../../src/inspection-closing/dto/create-company-outcome.dto';
import { CreateInspectionSignatureDto } from '../../src/inspection-closing/dto/create-inspection-signature.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'; import { UpsertInspectionResponsibleDto } from '../../src/inspection-closing/dto/upsert-inspection-responsible.dto';
test('D5 present responsible requires identity and position', async () => { 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.consentAccepted, true);
assert.equal(dto.latitude, -32.889458); 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);
});
+136
View File
@@ -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.
+33 -11
View File
@@ -4,37 +4,59 @@ set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LAYOUT="$ROOT/web-v2/src/layout/AppLayout.tsx" LAYOUT="$ROOT/web-v2/src/layout/AppLayout.tsx"
APP="$ROOT/web-v2/src/app/App.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" || { test -f "$file" || {
echo "F4 WEB contract: falta $file" echo "F7 WEB contract: falta $file"
exit 1 exit 1
} }
done done
if grep -Fq "label: 'Relevamientos'" "$LAYOUT"; then 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 exit 1
fi fi
if grep -Fq 'path="/relevamiento/*"' "$APP"; then 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 exit 1
fi fi
grep -Fq 'path="/inventarios/nuevo"' "$APP" grep -Fq 'path="/inventarios/nuevo"' "$APP"
grep -Fq 'InventoryCreatePage' "$APP" grep -Fq 'InventoryCreatePage' "$APP"
for kind in AREA YACIMIENTO INSTALACION SUBINSTALACION; do for kind in DEPARTAMENTO AREA YACIMIENTO INSTALACION SUBINSTALACION; do
grep -Fq "kind: '$kind'" "$PAGE" grep -Fq "kind: '$kind'" "$CREATE_PAGE"
done done
if grep -Fq "kind: 'EQUIPO'" "$PAGE"; then if grep -Fq "kind: 'EQUIPO'" "$CREATE_PAGE"; then
echo "F4 WEB contract: el alta guiada no debe restaurar EQUIPO como quinto nivel estructural" echo "F7 WEB contract: EQUIPO no debe restaurarse como nivel estructural"
exit 1 exit 1
fi 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'
+27 -17
View File
@@ -126,11 +126,20 @@ INSERT INTO auth_sessions(
SQL SQL
docker compose --env-file .env.example up -d api 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 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 sleep 2
done 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' TOKEN="$(docker compose --env-file .env.example exec -T api node - <<'NODE'
const jwt = require('jsonwebtoken'); 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" 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" test -n "$DEPARTMENT_ID" && test -n "$AREA_ID" && test -n "$YACIMIENTO_ID" && test -n "$COMPANY_ID"
curl -fsS -H "Authorization: Bearer $TOKEN" \ api_get() {
http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments \ local request_path="$1"
>/tmp/dhv2-departments.json 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 grep -Fq "$DEPARTMENT_ID" /tmp/dhv2-departments.json
curl -fsS -H "Authorization: Bearer $TOKEN" \ api_get "/api/v3/inspection-visits/planning-context/departments/$DEPARTMENT_ID/areas" >/tmp/dhv2-areas.json
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments/$DEPARTMENT_ID/areas" \
>/tmp/dhv2-areas.json
grep -Fq "$AREA_ID" /tmp/dhv2-areas.json grep -Fq "$AREA_ID" /tmp/dhv2-areas.json
curl -fsS -H "Authorization: Bearer $TOKEN" \ api_get "/api/v3/inspection-visits/planning-context/areas/$AREA_ID/yacimientos" >/tmp/dhv2-yacimientos.json
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/areas/$AREA_ID/yacimientos" \
>/tmp/dhv2-yacimientos.json
grep -Fq "$YACIMIENTO_ID" /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')" 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}' \ REQUEST_BODY="{\"operationalAreaId\":\"$AREA_ID\",\"scopeAssetId\":\"$YACIMIENTO_ID\",\"operatorCompanyId\":\"$COMPANY_ID\",\"plannedStartAt\":\"$START_AT\",\"leadInspectorUserId\":\"22222222-2222-4222-8222-222222222222\"}"
-X POST \ POST_RESULT="$(docker compose --env-file .env.example exec -T \
-H "Authorization: Bearer $TOKEN" \ -e REQUEST_TOKEN="$TOKEN" -e REQUEST_BODY="$REQUEST_BODY" api \
-H 'Content-Type: application/json' \ 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); })')"
-d "{\"operationalAreaId\":\"$AREA_ID\",\"scopeAssetId\":\"$YACIMIENTO_ID\",\"operatorCompanyId\":\"$COMPANY_ID\",\"plannedStartAt\":\"$START_AT\",\"leadInspectorUserId\":\"22222222-2222-4222-8222-222222222222\"}" \ HTTP_CODE="$(printf '%s' "$POST_RESULT" | python -c 'import json,sys; print(json.load(sys.stdin)["status"])')"
http://127.0.0.1:3101/api/v3/inspection-visits)" 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 if [ "$HTTP_CODE" != 201 ]; then
echo "ERROR: real inspection create returned HTTP $HTTP_CODE" >&2 echo "ERROR: real inspection create returned HTTP $HTTP_CODE" >&2
cat /tmp/dhv2-created.json >&2 cat /tmp/dhv2-created.json >&2
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP="/var/www/dhv2.korexlabs.com"
BACKUP_ROOT="/root/DH_V2_BACKUPS"
LOCK="/var/lock/dhv2-deploy.lock"
exec 9>"$LOCK"
if ! flock -n 9; then
echo "Otro deploy de DH V2 ya está en curso. No se realiza ninguna modificación."
exit 0
fi
DEPLOY_REF="${DHV2_DEPLOY_REF:-deploy}"
STAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
STAGE="/root/dhv2-gitea-stage-${STAMP}"
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
API_TEST_IMAGE="dhv2-api:gitea-${STAMP}"
WEB_TEST_IMAGE="dhv2-web:gitea-${STAMP}"
PHASE="bootstrap"
PREV_SHA=""
TARGET_SHA=""
EXPECTED_API_VERSION=""
EXPECTED_WEB_VERSION=""
APP_TOUCHED=0
cd "$APP"
exec > >(tee -a "$LOG") 2>&1
cleanup() {
set +e
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
rm -rf "$STAGE"
docker image rm "$API_TEST_IMAGE" "$WEB_TEST_IMAGE" >/dev/null 2>&1 || true
}
publish_status() {
local rc="${1:-1}"
set +e
local outcome="failure"
[ "$rc" -eq 0 ] && outcome="success"
local current="unknown"
current="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
local status_file log_file status_blob log_blob tree commit
status_file="$(mktemp /tmp/dhv2-status.XXXXXX)"
log_file="$(mktemp /tmp/dhv2-log.XXXXXX)"
{
echo "status=$outcome"
echo "exit_code=$rc"
echo "phase=$PHASE"
echo "timestamp=$(date --iso-8601=seconds)"
echo "deploy_ref=$DEPLOY_REF"
echo "previous_sha=${PREV_SHA:-unknown}"
echo "target_sha=${TARGET_SHA:-unknown}"
echo "current_sha=$current"
echo "api_version=${EXPECTED_API_VERSION:-unknown}"
echo "web_version=${EXPECTED_WEB_VERSION:-unknown}"
echo "app_touched=$APP_TOUCHED"
echo "backup=${BACKUP:-unknown}"
} > "$status_file"
tail -n 500 "$LOG" > "$log_file" 2>/dev/null || true
status_blob="$(git hash-object -w "$status_file" 2>/dev/null || true)"
log_blob="$(git hash-object -w "$log_file" 2>/dev/null || true)"
if [ -n "$status_blob" ] && [ -n "$log_blob" ]; then
tree="$(printf '100644 blob %s\tdeploy.log\n100644 blob %s\tstatus.txt\n' "$log_blob" "$status_blob" | git mktree 2>/dev/null || true)"
if [ -n "$tree" ]; then
commit="$(printf 'deploy-status: %s · phase %s\n' "$outcome" "$PHASE" | git -c user.name='DH V2 Deploy Bot' -c user.email='deploy@dhv2.local' commit-tree "$tree" 2>/dev/null || true)"
[ -z "$commit" ] || git push --force origin "$commit:refs/heads/deploy-status" >/dev/null 2>&1 || true
fi
fi
rm -f "$status_file" "$log_file"
}
on_exit() {
local rc=$?
trap - EXIT ERR
cleanup
publish_status "$rc"
exit "$rc"
}
trap on_exit EXIT
rollback() {
local rc=$?
trap - ERR
PHASE="rollback"
echo
echo "============================================================"
echo " DH V2 · DEPLOY FALLÓ · ROLLBACK"
echo "============================================================"
cd "$APP"
if [ "$APP_TOUCHED" -eq 1 ] && [ -n "${PREV_SHA:-}" ]; then
echo "Restaurando aplicación al commit previo: $PREV_SHA"
git reset --hard "$PREV_SHA" || true
docker compose build api web </dev/null || true
docker compose up -d --no-deps --force-recreate api web </dev/null || true
else
echo "El candidato falló antes de modificar producción; no se reconstruye ni reinicia la aplicación activa."
fi
echo
echo "Estado actual:"
docker compose ps -a </dev/null || true
if [ "$APP_TOUCHED" -eq 1 ]; then
echo
echo "Últimos logs:"
docker compose logs --tail=160 api web </dev/null || true
fi
echo
if [ -d "$BACKUP" ]; then
echo "Backup PRE disponible en: $BACKUP"
echo "Las migraciones son forward-only; database-before.dump queda disponible para restauración manual si hiciera falta."
else
echo "No fue necesario crear backup PRE: el fallo ocurrió durante el preflight del candidato, antes de tocar producción."
fi
exit "$rc"
}
trap rollback ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY DESDE GITEA · $DEPLOY_REF"
echo "============================================================"
for cmd in git docker curl tar node; do
command -v "$cmd" >/dev/null || { echo "ERROR: falta $cmd"; false; }
done
[ -d .git ] || { echo "ERROR: $APP no es repositorio Git"; false; }
[ -f .env ] || { echo "ERROR: falta $APP/.env"; false; }
ORIGIN_URL="$(git remote get-url origin)"
EXPECTED_ORIGIN="https://git.korexlabs.com.ar/admin/dh-inspeccion-v2.git"
if [ "$ORIGIN_URL" != "$EXPECTED_ORIGIN" ]; then
echo "ERROR: origin no apunta al Gitea autorizado."
echo "Actual: $ORIGIN_URL"
echo "Esperado: $EXPECTED_ORIGIN"
false
fi
git config --global --get-all safe.directory 2>/dev/null | grep -Fxq "$APP" || git config --global --add safe.directory "$APP"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "ERROR: hay cambios locales versionados en producción."
git status --short
false
fi
PREV_SHA="$(git rev-parse HEAD)"
PHASE="fetch"
git fetch origin "$DEPLOY_REF"
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
echo "Actual: $PREV_SHA"
echo "Objetivo: $TARGET_SHA"
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
echo "Producción ya está en el commit autorizado."
PHASE="complete"
exit 0
fi
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
false
fi
PHASE="candidate-preflight"
rm -rf "$STAGE"
git worktree add --detach "$STAGE" "$TARGET_SHA" >/dev/null
EXPECTED_API_VERSION="$(node -p "require('$STAGE/api-v3/package.json').version")"
EXPECTED_WEB_VERSION="$(node -p "require('$STAGE/web-v2/package.json').version")"
echo "API candidata: $EXPECTED_API_VERSION"
echo "WEB candidata: $EXPECTED_WEB_VERSION"
docker compose --env-file "$APP/.env" -f "$STAGE/docker-compose.yml" config >/dev/null
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
echo
echo "========== TEST API CANDIDATA =========="
docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" </dev/null
docker run --rm \
-v "$STAGE/api-v3/test:/app/test:ro" \
-v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
-v "$STAGE/docker-compose.yml:/docker-compose.yml:ro" \
-v "$STAGE/web-v2:/web-v2:ro" \
-v "$STAGE/android-app:/android-app:ro" \
"$API_TEST_IMAGE" npm test </dev/null
echo
echo "========== BUILD WEB CANDIDATA =========="
docker build -t "$WEB_TEST_IMAGE" "$STAGE/web-v2" </dev/null
PHASE="backup"
echo
echo "========== BACKUP PRE =========="
install -d -m 700 "$BACKUP"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-before.dump"
tar \
--exclude='./.git' \
--exclude='./.env' \
--exclude='*/node_modules' \
--exclude='*/dist' \
--exclude='*.zip' \
--exclude='*.tar.gz' \
--exclude='*.tgz' \
-czf "$BACKUP/source-before.tar.gz" .
install -m 600 .env "$BACKUP/.env"
git rev-parse HEAD > "$BACKUP/previous.sha"
printf '%s\n' "$TARGET_SHA" > "$BACKUP/target.sha"
docker compose ps -a > "$BACKUP/docker-before.txt"
(
cd "$BACKUP"
sha256sum database-before.dump source-before.tar.gz .env previous.sha target.sha docker-before.txt > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="fast-forward"
echo
echo "========== FAST-FORWARD =========="
git log --oneline --no-decorate "$PREV_SHA..$TARGET_SHA"
APP_TOUCHED=1
git merge --ff-only "origin/$DEPLOY_REF"
PHASE="build"
echo
echo "========== BUILD PRODUCCIÓN =========="
docker compose build api migrate web </dev/null
PHASE="migrations"
echo
echo "========== MIGRACIONES =========="
docker compose --profile tools run --rm migrate </dev/null
docker compose --profile tools run --rm migrate npm run migration:show </dev/null | tee "$BACKUP/migrations.txt"
grep -Fq 'Pending migrations: no' "$BACKUP/migrations.txt"
PHASE="recreate"
echo
echo "========== RECREATE API + WEB =========="
docker compose up -d --no-deps --force-recreate api web </dev/null
PHASE="health"
echo
echo "========== HEALTH =========="
HEALTH_OK=0
for _ in $(seq 1 60); do
if curl -fsS --max-time 5 http://127.0.0.1:3101/api/v3/health > "$BACKUP/health.json" 2>/dev/null; then
if grep -Fq '"status":"ok"' "$BACKUP/health.json" \
&& grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json" \
&& grep -Fq '"database":"ok"' "$BACKUP/health.json"; then
HEALTH_OK=1
break
fi
CURRENT_API_VERSION="$(node -e 'try { const h = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(h.version || "unknown")); } catch { process.stdout.write("invalid"); }' "$BACKUP/health.json")"
echo "API respondió pero aún no es la candidata (actual=$CURRENT_API_VERSION, esperada=$EXPECTED_API_VERSION). Reintentando..."
fi
sleep 2
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "ERROR: API candidata no pasó healthcheck/version/database dentro del plazo."
[ ! -f "$BACKUP/health.json" ] || cat "$BACKUP/health.json"
docker compose logs --tail=180 api
false
fi
cat "$BACKUP/health.json"
echo
grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json"
grep -Fq '"database":"ok"' "$BACKUP/health.json"
WEB_CODE="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:8182/)"
[ "$WEB_CODE" = "200" ] || { echo "ERROR: WEB HTTP $WEB_CODE"; false; }
PHASE="verify"
echo
echo "========== VERIFICACIÓN FINAL =========="
docker compose ps -a | tee "$BACKUP/docker-after.txt"
if docker compose ps --status running --services | grep -Fxq api && docker compose ps --status running --services | grep -Fxq web && docker compose ps --status running --services | grep -Fxq db; then
echo "Servicios críticos: OK"
else
echo "ERROR: falta un servicio crítico en ejecución."
false
fi
PHASE="post-backup"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-after.dump"
git rev-parse HEAD > "$BACKUP/deployed.sha"
printf 'API=%s\nWEB=%s\n' "$EXPECTED_API_VERSION" "$EXPECTED_WEB_VERSION" > "$BACKUP/deployed-versions.txt"
(
cd "$BACKUP"
sha256sum database-after.dump deployed.sha deployed-versions.txt health.json migrations.txt docker-after.txt >> SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="complete"
trap - ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY OK"
echo "============================================================"
echo "Commit: $TARGET_SHA"
echo "API: $EXPECTED_API_VERSION"
echo "WEB: $EXPECTED_WEB_VERSION"
echo "Backup: $BACKUP"
echo "============================================================"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-1", "version": "0.23.0-2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-1", "version": "0.23.0-2",
"dependencies": { "dependencies": {
"maplibre-gl": "6.4.1", "maplibre-gl": "6.4.1",
"react": "^19.0.0", "react": "^19.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-1", "version": "0.23.0-2",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+3 -3
View File
@@ -14,7 +14,7 @@ import { AccessDeniedPage, NotFoundPage } from '../pages/SystemPages';
import { UserDetailPage } from '../pages/UserDetailPage'; import { UserDetailPage } from '../pages/UserDetailPage';
import { UsersPage } from '../pages/UsersPage'; import { UsersPage } from '../pages/UsersPage';
import { AssetsPage } from '../pages/AssetsPage'; import { AssetsPage } from '../pages/AssetsPage';
import { AssetEditorPage } from '../pages/AssetEditorPage'; import { InventoryDetailPage } from '../pages/InventoryDetailPage';
import { InventoryCreatePage } from '../pages/InventoryCreatePage'; import { InventoryCreatePage } from '../pages/InventoryCreatePage';
import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage'; import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage';
import { AuthoritativeInventoryConfigPage } from '../pages/AuthoritativeInventoryConfigPage'; import { AuthoritativeInventoryConfigPage } from '../pages/AuthoritativeInventoryConfigPage';
@@ -49,9 +49,9 @@ export function App() {
<Route element={<PermissionRoute permission="assets.read" />}><Route path="/mapa" element={<MapPage />} /></Route> <Route element={<PermissionRoute permission="assets.read" />}><Route path="/mapa" element={<MapPage />} /></Route>
<Route element={<PermissionRoute permission="assets.read" />}> <Route element={<PermissionRoute permission="assets.read" />}>
<Route path="/inventarios" element={<AssetsPage />} /> <Route path="/inventarios" element={<AssetsPage />} />
<Route path="/inventarios/:id" element={<AssetEditorPage />} /> <Route path="/inventarios/:id" element={<InventoryDetailPage />} />
<Route path="/activos" element={<Navigate to="/inventarios" replace />} /> <Route path="/activos" element={<Navigate to="/inventarios" replace />} />
<Route path="/activos/:id" element={<AssetEditorPage />} /> <Route path="/activos/:id" element={<InventoryDetailPage />} />
</Route> </Route>
<Route element={<PermissionRoute permission="assets.change_status" />}><Route path="/inventarios/revision-campo" element={<FieldDiscoveriesPage />} /></Route> <Route element={<PermissionRoute permission="assets.change_status" />}><Route path="/inventarios/revision-campo" element={<FieldDiscoveriesPage />} /></Route>
<Route element={<PermissionRoute permission="assets.create" />}><Route path="/inventarios/nuevo" element={<InventoryCreatePage />} /><Route path="/activos/nuevo" element={<Navigate to="/inventarios/nuevo" replace />} /></Route> <Route element={<PermissionRoute permission="assets.create" />}><Route path="/inventarios/nuevo" element={<InventoryCreatePage />} /><Route path="/activos/nuevo" element={<Navigate to="/inventarios/nuevo" replace />} /></Route>
+26
View File
@@ -0,0 +1,26 @@
import type { AuthUser } from '../lib/api';
function normalizeRole(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
const ADMIN_ROLES = new Set([
'admin',
'administrator',
'administrador',
'superadmin',
'super admin',
'superadministrator',
'super administrator',
'superadministrador',
'super administrador',
]);
export function hasAdministratorRole(user: AuthUser | null) {
return Boolean(user?.roles.some((role) => ADMIN_ROLES.has(normalizeRole(role))));
}
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.23.0-1'; export const APP_VERSION = '0.23.0-2';
export const APP_PHASE = 'F6.1 · Contexto operativo ÁreaOperadora consolidado'; export const APP_PHASE = 'F6.1 · Contexto operativo ÁreaOperadora consolidado';
@@ -81,10 +81,12 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
const load = () => { const load = () => {
setLoading(true); setLoading(true);
setError(''); setError('');
setMergeStatus(null);
Promise.all([ Promise.all([
getAssetDossier(assetId), getAssetDossier(assetId),
getAsset(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]) => { .then(([loadedDossier, loadedAsset, loadedMerge]) => {
setDossier(loadedDossier as ExtendedDossier); setDossier(loadedDossier as ExtendedDossier);
@@ -34,10 +34,10 @@ function supportsFunctionChange(code: string, name: string): boolean {
export function AssetHistoryPanel({ export function AssetHistoryPanel({
assetId, assetId,
refreshKey, refreshKey = 0,
}: { }: {
assetId: string; assetId: string;
refreshKey: number; refreshKey?: number;
}) { }) {
const [versions, setVersions] = useState<AssetVersionSummary[]>([]); const [versions, setVersions] = useState<AssetVersionSummary[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -52,12 +52,12 @@ export function AssetMediaPanel({
assetId, assetId,
assetName, assetName,
canManage, canManage,
onChanged, onChanged = () => undefined,
}: { }: {
assetId: string; assetId: string;
assetName: string; assetName: string;
canManage: boolean; canManage: boolean;
onChanged: () => void; onChanged?: () => void;
}) { }) {
const fileRef = useRef<HTMLInputElement | null>(null); const fileRef = useRef<HTMLInputElement | null>(null);
const [items, setItems] = useState<AssetMedia[]>([]); const [items, setItems] = useState<AssetMedia[]>([]);
@@ -90,7 +90,7 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
</div> </div>
<div className="responsible-summary"> <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>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>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> <div><small>Vencimiento</small><strong>{closure.act.deadlineAt ? formatDate(closure.act.deadlineAt) : 'Todavía no iniciado'}</strong></div>
+2 -2
View File
@@ -53,7 +53,7 @@ export interface InspectionActListItemF4 {
title: string; title: string;
summary: string; summary: string;
observations: string | null; observations: string | null;
urgency: InspectionActUrgencyF4; urgency: InspectionActUrgencyF4 | null;
deadlineDays: number | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayTypeF4 | null; deadlineDayType: InspectionDeadlineDayTypeF4 | null;
deadlineBasis: InspectionDeadlineBasisF4 | null; deadlineBasis: InspectionDeadlineBasisF4 | null;
@@ -107,7 +107,7 @@ export interface InspectionClosureF4 {
code: string; code: string;
status: InspectionActStatusF4; status: InspectionActStatusF4;
visitId: string; visitId: string;
urgency: InspectionActUrgencyF4; urgency: InspectionActUrgencyF4 | null;
deadlineDays: number | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayTypeF4 | null; deadlineDayType: InspectionDeadlineDayTypeF4 | null;
deadlineBasis: InspectionDeadlineBasisF4 | null; deadlineBasis: InspectionDeadlineBasisF4 | null;
+6 -2
View File
@@ -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 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 InventoryStructureOption { id:string; code:string; name:string; }
export interface InventoryAreaCompanyOption extends InventoryStructureOption { commonName?:string|null; typeName?:string; }
export interface InventoryStructureOptions { export interface InventoryStructureOptions {
independentMasters:InventoryStructureLevel[]; independentMasters:InventoryStructureLevel[];
levels: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}; 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}; operatorCompany?:null|{id:string;code:string;name:string};
concessionType?: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');} 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}`:''; 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; 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 getInventoryFamilyFindings(familyId:string){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);}
export function getInventoryFamilyAttributes(familyId:string){return apiRequest<InventoryFamilyAttributes>(`/inventory-families/${familyId}/attributes`);} export function getInventoryFamilyAttributes(familyId:string){return apiRequest<InventoryFamilyAttributes>(`/inventory-families/${familyId}/attributes`);}
export function getInventoryTechnicalValues(assetId:string){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`);} 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 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 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 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)});}
+215
View File
@@ -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;
}
}
+791 -82
View File
@@ -1,11 +1,18 @@
import './AssetEditorPage.css';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; 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 { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; 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 { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { AssetDossierPanel } from '../features/assets/AssetDossierPanel'; import { AssetDossierPanel } from '../features/assets/AssetDossierPanel';
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel'; import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
@@ -15,15 +22,31 @@ import { AssetRegistryPanel } from '../features/assets/AssetRegistryPanel';
import { AssetContextHistoryPanel } from '../features/assets/AssetContextHistoryPanel'; import { AssetContextHistoryPanel } from '../features/assets/AssetContextHistoryPanel';
import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel'; import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel';
import { import {
createAsset, getAsset, getAssetLineage, listAssetParentOptions, listAssetTypes, listCompaniesForArea, createAsset,
listOperationalAreas, updateAsset, updateAssetInformationStatus, updateAssetOperationalStatus, getAsset,
getAssetLineage,
listAssetParentOptions,
listAssetTypes,
listCompaniesForArea,
listOperationalAreas,
updateAsset,
updateAssetInformationStatus,
updateAssetOperationalStatus,
} from '../lib/api'; } from '../lib/api';
import type { import type {
AssetAttributeDefinition, AssetDetail, AssetInformationStatus, AssetOperationalStatus, AssetAttributeDefinition,
AssetLineageItem, AssetListItem, AssetType, OperationalAssetSummary, AssetDetail,
AssetInformationStatus,
AssetOperationalStatus,
AssetLineageItem,
AssetListItem,
AssetType,
OperationalAssetSummary,
} from '../lib/api'; } 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'; type DetailTab = 'summary' | 'dossier' | 'findings' | 'location' | 'registry' | 'files' | 'history';
@@ -35,16 +58,34 @@ function localDateTime(value: unknown): string {
return local.toISOString().slice(0, 16); 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> = {}; const result: Record<string, unknown> = {};
definitions.filter((item) => item.isActive).forEach((definition) => { definitions.filter((item) => item.isActive).forEach((definition) => {
const raw = values[definition.id]; const raw = values[definition.id];
if (definition.dataType === 'BOOLEAN') result[definition.id] = Boolean(raw); 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; 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() { export function AssetEditorPage() {
const { id } = useParams(); const { id } = useParams();
const editing = Boolean(id); const editing = Boolean(id);
@@ -52,7 +93,11 @@ export function AssetEditorPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const contextParentId = !editing ? searchParams.get('parentId') : null; const contextParentId = !editing ? searchParams.get('parentId') : null;
const requestedTab = searchParams.get('tab') as DetailTab | 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 { hasPermission } = useAuth();
const canEdit = editing ? hasPermission('assets.update') : hasPermission('assets.create'); const canEdit = editing ? hasPermission('assets.update') : hasPermission('assets.create');
const canCreate = hasPermission('assets.create'); const canCreate = hasPermission('assets.create');
@@ -104,10 +149,14 @@ export function AssetEditorPage() {
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [historyRefreshKey, setHistoryRefreshKey] = useState(0); 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 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(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -119,13 +168,26 @@ export function AssetEditorPage() {
setTypes(loadedTypes); setTypes(loadedTypes);
setLineage(loadedLineage); setLineage(loadedLineage);
if (loadedAsset) { if (loadedAsset) {
setAsset(loadedAsset); setCode(loadedAsset.code); setName(loadedAsset.name); setCommonName(loadedAsset.commonName ?? ''); setTypeId(loadedAsset.type.id); setAsset(loadedAsset);
setParentId(loadedAsset.parent?.id ?? ''); setOperationalAreaId(loadedAsset.operationalArea?.id ?? ''); setCode(loadedAsset.code);
setOperatorCompanyId(loadedAsset.operatorCompany?.id ?? ''); setDescription(loadedAsset.description ?? ''); setName(loadedAsset.name);
setStatus(loadedAsset.informationStatus); setOperationalStatus(loadedAsset.operationalStatus); setCommonName(loadedAsset.commonName ?? '');
setAttributeValues(Object.fromEntries(loadedAsset.attributes.map((attribute) => [attribute.definitionId, attribute.dataType === 'DATETIME' ? localDateTime(attribute.value) : attribute.value ?? '']))); 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) { } 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); if (first) setTypeId(first.id);
} }
}) })
@@ -135,38 +197,56 @@ export function AssetEditorPage() {
useEffect(() => { useEffect(() => {
if (editing || !contextParentId || types.length === 0) return; if (editing || !contextParentId || types.length === 0) return;
Promise.all([getAsset(contextParentId), getAssetLineage(contextParentId)]).then(([parent, parentLineage]) => { Promise.all([getAsset(contextParentId), getAssetLineage(contextParentId)])
setContextParent(parent); .then(([parent, parentLineage]) => {
setLineage(parentLineage); setContextParent(parent);
const compatible = types.find((type) => type.isActive && type.allowedParentTypes.some((allowed) => allowed.id === parent.type.id)); setLineage(parentLineage);
if (compatible) { const compatible = types.find(
setTypeId(compatible.id); setParentId(parent.id); (type) => type.isActive && type.allowedParentTypes.some((allowed) => allowed.id === parent.type.id),
const parentType = types.find((type) => type.id === parent.type.id); );
if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id); if (compatible) {
else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id); setTypeId(compatible.id);
} setParentId(parent.id);
}).catch((requestError) => setError(errorMessage(requestError))); 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]); }, [editing, contextParentId, types]);
useEffect(() => { useEffect(() => {
if (!typeId) { setParents([]); return; } if (!typeId) {
const timer = window.setTimeout(() => listAssetParentOptions(typeId, id, parentSearch).then(setParents).catch((requestError) => setError(errorMessage(requestError))), 220); setParents([]);
return;
}
const timer = window.setTimeout(
() => listAssetParentOptions(typeId, id, parentSearch)
.then(setParents)
.catch((requestError) => setError(errorMessage(requestError))),
220,
);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [typeId, id, parentSearch]); }, [typeId, id, parentSearch]);
useEffect(() => { useEffect(() => {
if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) { if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) {
setOperationalAreas([]); setOperationalAreas([]);
if (!parentId) { setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); } if (!parentId) {
return;
}
listOperationalAreas(parentId).then((loadedAreas) => {
setOperationalAreas(loadedAreas);
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
setOperationalAreaId(''); setOperationalAreaId('');
if (!editing) setOperatorCompanyId(''); 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]); }, [canReadRelations, selectedType?.operationalRole, parentId, editing]);
useEffect(() => { useEffect(() => {
@@ -174,39 +254,85 @@ export function AssetEditorPage() {
setOperationalCompanies([]); setOperationalCompanies([]);
return; return;
} }
listCompaniesForArea(operationalAreaId).then((loadedCompanies) => { listCompaniesForArea(operationalAreaId)
setOperationalCompanies(loadedCompanies); .then((loadedCompanies) => {
setOperatorCompanyId((current) => loadedCompanies.some((company) => company.id === current) setOperationalCompanies(loadedCompanies);
? current setOperatorCompanyId((current) => loadedCompanies.some((company) => company.id === current)
: loadedCompanies.length === 1 ? (loadedCompanies[0]?.id ?? '') : ''); ? current
}).catch((requestError) => { : loadedCompanies.length === 1 ? (loadedCompanies[0]?.id ?? '') : '');
setOperationalCompanies([]); })
setOperatorCompanyId(''); .catch((requestError) => {
setError(errorMessage(requestError)); setOperationalCompanies([]);
}); setOperatorCompanyId('');
setError(errorMessage(requestError));
});
}, [editing, canReadRelations, operationalAreaId, selectedType?.operationalRole]); }, [editing, canReadRelations, operationalAreaId, selectedType?.operationalRole]);
const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); }; const changeType = (nextTypeId: string) => {
const setAttribute = (definitionId: string, value: unknown) => setAttributeValues((current) => ({ ...current, [definitionId]: value })); 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) => { const save = async (event: FormEvent) => {
event.preventDefault(); if (!selectedType) return; event.preventDefault();
setSaving(true); setError(''); setSuccess(''); if (!selectedType) return;
setSaving(true);
setError('');
setSuccess('');
try { try {
const attributes = normalizeAttributeValues(definitions, attributeValues); const attributes = normalizeAttributeValues(definitions, attributeValues);
let saved: AssetDetail; let saved: AssetDetail;
if (editing && id) { 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 }); if (canEdit) {
else if (asset) saved = asset; else return; saved = await updateAsset(id, {
if (canChangeStatus && saved.informationStatus !== status) saved = await updateAssetInformationStatus(id, status); typeId: canDirectContextEdit ? typeId : undefined,
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) saved = await updateAssetOperationalStatus(id, operationalStatus); code,
setAsset(saved); setSuccess('Registro actualizado correctamente'); setHistoryRefreshKey((current) => current + 1); 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 { } 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 }); navigate(`/inventarios/${saved.id}`, { replace: true });
} }
} catch (requestError) { setError(errorMessage(requestError)); } } catch (requestError) {
finally { setSaving(false); } setError(errorMessage(requestError));
} finally {
setSaving(false);
}
}; };
if (loading) return <LoadingBlock label="Cargando registro…" />; if (loading) return <LoadingBlock label="Cargando registro…" />;
@@ -221,44 +347,627 @@ export function AssetEditorPage() {
{ key: 'history', label: 'Historial', show: editing && canReadHistory }, { key: 'history', label: 'Historial', show: editing && canReadHistory },
] as const; ] 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 ÁreaEmpresa 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"> return <section className="narrow-section asset-detail-page">
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario"> <nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario">
<Link to="/inventarios">Inventarios</Link><span></span> <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) => { {lineage.map((item, index) => {
const isLast = index === lineage.length - 1; const isLast = index === lineage.length - 1;
const showAsCurrent = editing ? isLast : false; 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></>} {!editing && <><span></span><strong>Nuevo registro</strong></>}
</nav> </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') && <> {(!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>} {contextParent && !editing && <div className="context-create-banner">
<form className="panel form-panel" onSubmit={save}> <Icon name="layers" />
<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><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 ÁreaEmpresa 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> {editing && id && asset && canReadRelations && selectedType && selectedType.operationalRole !== 'GENERIC'
{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>} && <AssetOperationalRelationsPanel assetId={id} role={selectedType.operationalRole} canManage={canManageRelations} />}
</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 && tab === 'findings' && canReadFindingCatalog && <AssetFindingCatalogPanel assetId={id} canManage={canManageFindingCatalog} />} {editing && id && asset && tab === 'findings' && canReadFindingCatalog
{editing && id && asset && tab === 'dossier' && canReadDossier && <AssetDossierPanel assetId={id} />} && <AssetFindingCatalogPanel assetId={id} canManage={canManageFindingCatalog} />}
{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 === 'dossier' && canReadDossier
{editing && id && asset && tab === 'files' && canReadMedia && <AssetMediaPanel assetId={id} assetName={asset.name} canManage={canManageMedia} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />} && <AssetDossierPanel assetId={id} />}
{editing && id && tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={id} refreshKey={historyRefreshKey} />}
{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>; </section>;
} }
@@ -1,203 +1,305 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router'; import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { getFindingCatalogAdmin, listAssetTypes } from '../lib/api'; import {
import type { AssetType, FindingAdminCatalog } from '../lib/api'; createInventoryFamily,
import { listInventoryFamiliesAdmin } from '../lib/inventoryStructureApi'; createInventoryFamilyAttribute,
import type { InventoryFamily } from '../lib/inventoryStructureApi'; 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 = { const FIELD_TYPES: Array<{ value: InventoryFamilyAttributeDataType; label: string }> = [
label: string; { value: 'TEXT', label: 'Texto' },
detail: string; { value: 'NUMBER', label: 'Número' },
required?: boolean; { value: 'DATE', label: 'Fecha' },
relation?: boolean; { value: 'BOOLEAN', label: 'Sí / No' },
};
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 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 { function fieldCode(name: string) {
if (kind === 'EMPRESA') return types.find((type) => type.operationalRole === 'COMPANY' && type.isActive) ?? null; return name
if (kind === 'AREA') return types.find((type) => type.operationalRole === 'AREA' && type.isActive) ?? null; .normalize('NFD')
return types.find((type) => type.code.toLowerCase() === kind.toLowerCase() && type.isActive) ?? null; .replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 100) || `campo_${Date.now()}`;
} }
export function AuthoritativeInventoryConfigPage() { export function AuthoritativeInventoryConfigPage() {
const [types, setTypes] = useState<AssetType[]>([]); const { hasPermission } = useAuth();
const canManage = hasPermission('asset_types.manage');
const [families, setFamilies] = useState<InventoryFamily[]>([]); const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG); const [level, setLevel] = useState<FamilyLevel>('INSTALLATION');
const [selectedKind, setSelectedKind] = useState<CanonicalKind>('AREA'); 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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const refreshFamilies = async () => {
const loaded = await listInventoryFamiliesAdmin();
setFamilies(loaded);
return loaded;
};
useEffect(() => { useEffect(() => {
Promise.all([listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin()]) refreshFamilies()
.then(([loadedTypes, loadedFamilies, loadedCatalog]) => {
setTypes(loadedTypes);
setFamilies(loadedFamilies);
setCatalog(loadedCatalog);
})
.catch((requestError) => setError(errorMessage(requestError))) .catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
const level = LEVELS.find((item) => item.kind === selectedKind) ?? LEVELS[0]!; const installationFamilies = useMemo(
const selectedType = canonicalType(types, selectedKind); () => families.filter((family) => family.level === 'INSTALLATION'),
const commonAttributes = useMemo(() => { [families],
const attributes = selectedType?.attributes.filter((attribute) => attribute.isActive) ?? []; );
return selectedKind === 'INSTALACION' const visibleFamilies = useMemo(
? attributes.filter((attribute) => attribute.code !== 'tipo_instalacion') () => families.filter((family) => family.level === level),
: attributes; [families, level],
}, [selectedKind, selectedType]); );
const installationFamilies = families.filter((family) => family.level === 'INSTALLATION' && family.isActive !== false); const selectedFamily = families.find((family) => family.id === selectedFamilyId) ?? null;
const subinstallationFamilies = families.filter((family) => family.level === 'SUBINSTALLATION' && family.isActive !== false);
const activeFindings = catalog.items.filter((item) => item.isActive).length; 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…" />; if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
return <section className="inventory-config-page"> return <section className="inventory-config-page">
<div className="page-heading"> <div className="page-heading">
<div> <div>
<span className="eyebrow">ADMINISTRACIÓN · MODELO AUTORITATIVO</span> <span className="eyebrow">ADMINISTRACIÓN · INVENTARIOS</span>
<h1>Configuración de Inventarios</h1> <h1>Tipos y campos</h1>
<p>Relaciones fijas cargadas desde los SQL definitivos. Los vínculos estructurales no son campos de texto editables.</p> <p>Configuración simple para la puesta a punto. Los usuarios de campo no ven esta pantalla.</p>
</div> </div>
<Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>
</div> </div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<article className="panel" style={{ marginBottom: 18 }}> <article className="panel" style={{ marginBottom: 18 }}>
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<span className="eyebrow">ESTRUCTURA FÍSICA</span> <span className="eyebrow">ESTRUCTURA FIJA</span>
<h2>Jerarquía obligatoria</h2> <h2>Departamento Área Yacimiento Instalación Subinstalación</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> <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> </div>
<div className="asset-browser-levels" aria-label="Jerarquía de Inventarios"> <div className="temporal-notice">
<div><span>1</span><strong>Departamento</strong><small>raíz</small></div><i></i> <Icon name="map" />
<div><span>2</span><strong>Área</strong><small>Departamento</small></div><i></i> <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><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> </div>
</article> </article>
<article className="panel" style={{ marginBottom: 18 }}> <div className="quick-view-row" style={{ marginBottom: 18 }}>
<div className="panel-heading"> <button type="button" className={level === 'INSTALLATION' ? 'active' : ''} onClick={() => setLevel('INSTALLATION')}>Tipos de Instalación</button>
<div> <button type="button" className={level === 'SUBINSTALLATION' ? 'active' : ''} onClick={() => setLevel('SUBINSTALLATION')}>Tipos de Subinstalación</button>
<span className="eyebrow">INFORMACIÓN ESTRUCTURAL</span> </div>
<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="dashboard-grid" style={{ alignItems: 'start' }}> <div className="dashboard-grid" style={{ alignItems: 'start' }}>
<article className="panel"> <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="panel-heading">
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}> <div>
{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>)} <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>
<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>
<article className="panel"> <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> {!selectedFamily ? <div className="inline-empty">Seleccioná un tipo para ver sus campos.</div> : <>
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}> <div className="panel-heading">
{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>
</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> </article>
</div> </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>; </section>;
} }
+437
View File
@@ -0,0 +1,437 @@
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
import {
createAreaCompanyRelation,
endAreaCompanyRelation,
getAssetRegistry,
listAreaCompanyRelations,
listInspectionVisits,
listOperationalAreas,
updateAsset,
upsertOrganizationProfile,
} from '../lib/api';
import type {
AreaCompanyRelation,
AssetDetail,
AssetRegistry,
OperationalAssetSummary,
OrganizationKind,
} from '../lib/api';
import {
listInspectionActsGlobalF4,
type InspectionActListItemF4,
type InspectionActStatusF4,
} from '../lib/inspectionActF4Api';
import {
listInspectionReportsF4,
type InspectionReportDetailF4,
} from '../lib/reportWorkflowApi';
import { formatDate } from '../lib/format';
import './companyInventory.css';
type CompanyTab = 'summary' | 'issued' | 'documents' | 'history';
const organizationLabels: Record<OrganizationKind, string> = {
COMPANY: 'Empresa',
UTE: 'UTE',
PUBLIC_ENTITY: 'Entidad pública',
OTHER: 'Otra organización',
};
function relationRoleLabel(role: AreaCompanyRelation['relationRole']) {
if (role === 'OPERATOR') return 'Operadora';
if (role === 'TECHNICAL_OPERATOR') return 'Operadora técnica';
if (role === 'CONCESSIONAIRE') return 'Concesionaria / titular';
if (role === 'PERMIT_HOLDER') return 'Permisionaria';
if (role === 'PARTICIPANT') return 'Participante';
return 'Otro vínculo';
}
function reportStatusLabel(report: InspectionReportDetailF4) {
if (report.status === 'OFFICIALIZED') return report.gedoIfIdentifier ? `GEDO · ${report.gedoIfIdentifier}` : 'Oficializado en GEDO';
if (report.status === 'WORKING') return 'INF en preparación';
if (report.status === 'CANCELLED') return 'Cancelado';
return 'Informe histórico';
}
function reportStatusClass(report: InspectionReportDetailF4) {
if (report.status === 'OFFICIALIZED') return 'active';
if (report.status === 'CANCELLED') return 'danger';
return 'pending';
}
function areaLabel(items: Array<{ name: string }>) {
if (!items.length) return 'Área sin informar';
const first = items[0];
if (!first) return 'Área sin informar';
return items.length === 1 ? first.name : `${first.name} +${items.length - 1}`;
}
async function loadAllActs(companyId: string): Promise<InspectionActListItemF4[]> {
const statuses: InspectionActStatusF4[] = ['SEALED', 'CLOSED', 'RECTIFIED'];
const groups = await Promise.all(statuses.map(async (status) => {
const collected: InspectionActListItemF4[] = [];
let page = 1;
let totalPages = 1;
do {
const response = await listInspectionActsGlobalF4({ companyId, status, page, pageSize: 100 });
collected.push(...response.data);
totalPages = response.meta.totalPages;
page += 1;
} while (page <= totalPages);
return collected;
}));
return groups.flat().sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
}
async function loadAllReports(companyId: string): Promise<InspectionReportDetailF4[]> {
const collected: InspectionReportDetailF4[] = [];
let page = 1;
let totalPages = 1;
do {
const response = await listInspectionReportsF4({ companyId, page, pageSize: 100 });
collected.push(...response.data);
totalPages = response.meta.totalPages;
page += 1;
} while (page <= totalPages);
return collected
.filter((item) => item.status !== 'CANCELLED')
.sort((a, b) => new Date(b.generatedAt).getTime() - new Date(a.generatedAt).getTime());
}
export function CompanyInventoryPage({ initialAsset }: { initialAsset: AssetDetail }) {
const [params, setParams] = useSearchParams();
const requestedTab = params.get('tab') as CompanyTab | null;
const tab: CompanyTab = requestedTab && ['summary', 'issued', 'documents', 'history'].includes(requestedTab)
? requestedTab
: 'summary';
const { hasPermission } = useAuth();
const canEditAsset = hasPermission('assets.update');
const canReadRegistry = hasPermission('asset_registry.read');
const canManageRegistry = hasPermission('asset_registry.manage');
const canReadRelations = hasPermission('asset_relations.read');
const canManageRelations = hasPermission('asset_relations.manage');
const canReadActs = hasPermission('inspection_acts.read');
const canReadReports = hasPermission('inspection_reports.read');
const canReadInspections = hasPermission('inspections.read');
const canReadMedia = hasPermission('assets.read_media');
const canManageMedia = hasPermission('assets.manage_media');
const canReadHistory = hasPermission('assets.read_history');
const [asset, setAsset] = useState(initialAsset);
const [registry, setRegistry] = useState<AssetRegistry | null>(null);
const [relations, setRelations] = useState<AreaCompanyRelation[]>([]);
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [loadingContext, setLoadingContext] = useState(true);
const [contextError, setContextError] = useState('');
const [success, setSuccess] = useState('');
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [companyForm, setCompanyForm] = useState({
name: initialAsset.name,
commonName: initialAsset.commonName ?? '',
organizationKind: 'COMPANY' as OrganizationKind,
legalName: initialAsset.name,
taxId: '',
notificationEmail: '',
notes: '',
});
const [relationForm, setRelationForm] = useState({ areaId: '', reason: '' });
const [metrics, setMetrics] = useState({ acts: 0, reports: 0, activeInspections: 0 });
const [acts, setActs] = useState<InspectionActListItemF4[]>([]);
const [reports, setReports] = useState<InspectionReportDetailF4[]>([]);
const [documentsLoading, setDocumentsLoading] = useState(false);
const [documentsError, setDocumentsError] = useState('');
const [documentSearch, setDocumentSearch] = useState('');
const activeRelations = useMemo(() => relations.filter((item) => item.active), [relations]);
const loadContext = async () => {
setLoadingContext(true);
setContextError('');
try {
const [loadedRegistry, loadedRelations, loadedAreas] = await Promise.all([
canReadRegistry ? getAssetRegistry(asset.id) : Promise.resolve(null),
canReadRelations ? listAreaCompanyRelations({ companyId: asset.id, includeHistory: true }) : Promise.resolve([]),
canManageRelations ? listOperationalAreas() : Promise.resolve([]),
]);
setRegistry(loadedRegistry);
setRelations(loadedRelations);
setAreas(loadedAreas);
if (loadedRegistry?.organizationProfile) {
const profile = loadedRegistry.organizationProfile;
setCompanyForm({
name: asset.name,
commonName: asset.commonName ?? '',
organizationKind: profile.organizationKind,
legalName: profile.legalName ?? asset.name,
taxId: profile.taxId ?? '',
notificationEmail: profile.notificationEmail ?? '',
notes: profile.notes ?? '',
});
}
} catch (requestError) {
setContextError(errorMessage(requestError));
} finally {
setLoadingContext(false);
}
};
useEffect(() => { void loadContext(); }, [asset.id]);
useEffect(() => {
let cancelled = false;
const loadMetrics = async () => {
try {
const [sealed, closed, rectified, reportPage, planned, inProgress] = await Promise.all([
canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'SEALED', pageSize: 1 }) : Promise.resolve(null),
canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'CLOSED', pageSize: 1 }) : Promise.resolve(null),
canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'RECTIFIED', pageSize: 1 }) : Promise.resolve(null),
canReadReports ? listInspectionReportsF4({ companyId: asset.id, pageSize: 1 }) : Promise.resolve(null),
canReadInspections ? listInspectionVisits({ companyId: asset.id, status: 'PLANNED', pageSize: 1 }) : Promise.resolve(null),
canReadInspections ? listInspectionVisits({ companyId: asset.id, status: 'IN_PROGRESS', pageSize: 1 }) : Promise.resolve(null),
]);
if (cancelled) return;
setMetrics({
acts: (sealed?.meta.total ?? 0) + (closed?.meta.total ?? 0) + (rectified?.meta.total ?? 0),
reports: reportPage?.meta.total ?? 0,
activeInspections: (planned?.meta.total ?? 0) + (inProgress?.meta.total ?? 0),
});
} catch {
if (!cancelled) setMetrics({ acts: 0, reports: 0, activeInspections: 0 });
}
};
void loadMetrics();
return () => { cancelled = true; };
}, [asset.id, canReadActs, canReadReports, canReadInspections]);
useEffect(() => {
if (tab !== 'issued' || (!canReadActs && !canReadReports)) return;
let cancelled = false;
setDocumentsLoading(true);
setDocumentsError('');
Promise.all([
canReadActs ? loadAllActs(asset.id) : Promise.resolve([]),
canReadReports ? loadAllReports(asset.id) : Promise.resolve([]),
])
.then(([loadedActs, loadedReports]) => {
if (cancelled) return;
setActs(loadedActs);
setReports(loadedReports);
})
.catch((requestError) => { if (!cancelled) setDocumentsError(errorMessage(requestError)); })
.finally(() => { if (!cancelled) setDocumentsLoading(false); });
return () => { cancelled = true; };
}, [tab, asset.id, canReadActs, canReadReports]);
const saveCompany = async (event: FormEvent) => {
event.preventDefault();
setSaving(true); setContextError(''); setSuccess('');
try {
let savedAsset = asset;
if (canEditAsset) {
savedAsset = await updateAsset(asset.id, {
name: companyForm.name.trim(),
commonName: companyForm.commonName.trim() || null,
});
setAsset(savedAsset);
}
if (canManageRegistry) {
await upsertOrganizationProfile(asset.id, {
organizationKind: companyForm.organizationKind,
legalName: companyForm.legalName.trim() || null,
taxId: companyForm.taxId.trim() || null,
notificationEmail: companyForm.notificationEmail.trim() || null,
notes: companyForm.notes.trim() || null,
});
}
setEditing(false);
setSuccess('Datos de la empresa actualizados.');
await loadContext();
} catch (requestError) {
setContextError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const addRelation = async (event: FormEvent) => {
event.preventDefault();
if (!relationForm.areaId || relationForm.reason.trim().length < 3) return;
setSaving(true); setContextError(''); setSuccess('');
try {
await createAreaCompanyRelation({
areaId: relationForm.areaId,
companyId: asset.id,
reason: relationForm.reason.trim(),
});
setRelationForm({ areaId: '', reason: '' });
setSuccess('Área vinculada a la empresa.');
await loadContext();
} catch (requestError) {
setContextError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const endRelation = async (relation: AreaCompanyRelation) => {
const reason = window.prompt(`Motivo para finalizar el vínculo con ${relation.area.name}`);
if (!reason?.trim() || reason.trim().length < 3) return;
setSaving(true); setContextError(''); setSuccess('');
try {
await endAreaCompanyRelation(relation.id, reason.trim());
setSuccess('Vínculo finalizado. El historial quedó conservado.');
await loadContext();
} catch (requestError) {
setContextError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const setTab = (next: CompanyTab) => {
const nextParams = new URLSearchParams(params);
next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next);
nextParams.delete('advanced');
setParams(nextParams);
};
const reportByAct = useMemo(() => new Map(reports.map((report) => [report.actId, report])), [reports]);
const filteredActs = useMemo(() => {
const needle = documentSearch.trim().toLocaleLowerCase('es');
if (!needle) return acts;
return acts.filter((act) => [
act.code,
act.title,
act.visit.code,
...act.areas.map((item) => item.name),
reportByAct.get(act.id)?.code ?? '',
reportByAct.get(act.id)?.gedoIfIdentifier ?? '',
].join(' ').toLocaleLowerCase('es').includes(needle));
}, [acts, documentSearch, reportByAct]);
const usedAreaIds = new Set(activeRelations.map((relation) => relation.area.id));
const availableAreas = areas.filter((item) => !usedAreaIds.has(item.id));
const profile = registry?.organizationProfile ?? null;
return <section className="narrow-section company-page">
<nav className="breadcrumb company-breadcrumb" aria-label="Ruta de empresa">
<Link to="/inventarios">Inventarios</Link><span></span>
<Link to="/inventarios?section=companies">Empresas</Link><span></span><strong>{asset.name}</strong>
</nav>
<header className="company-hero">
<div>
<span className="eyebrow">EMPRESA</span>
<h1>{asset.name}</h1>
<p><strong>{asset.code}</strong>{profile?.taxId ? ` · CUIT ${profile.taxId}` : ''}</p>
</div>
<div className="company-hero-actions">
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span>
{(canEditAsset || canManageRegistry) && tab === 'summary' && <button type="button" className="button primary" onClick={() => setEditing((current) => !current)}><Icon name="edit" />{editing ? 'Cerrar edición' : 'Editar empresa'}</button>}
</div>
</header>
<nav className="company-tabs" aria-label="Secciones de la empresa">
<button type="button" className={tab === 'summary' ? 'active' : ''} onClick={() => setTab('summary')}>Resumen</button>
{(canReadActs || canReadReports) && <button type="button" className={tab === 'issued' ? 'active' : ''} onClick={() => setTab('issued')}>Actas e informes</button>}
{(canReadRegistry || canReadMedia) && <button type="button" className={tab === 'documents' ? 'active' : ''} onClick={() => setTab('documents')}>Documentación</button>}
{canReadHistory && <button type="button" className={tab === 'history' ? 'active' : ''} onClick={() => setTab('history')}>Historial</button>}
</nav>
{contextError && <Alert>{contextError}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{tab === 'summary' && <div className="company-stack">
{loadingContext ? <LoadingBlock label="Cargando empresa…" /> : <>
<article className="panel company-card">
<div className="company-card-heading">
<div><span className="eyebrow">IDENTIDAD</span><h2>Datos de la empresa</h2><p>La información que se usa para identificarla y enviar documentación oficial.</p></div>
</div>
{editing ? <form className="company-edit-form" onSubmit={saveCompany}>
<div className="form-grid">
<label className="field"><span>Nombre</span><input value={companyForm.name} disabled={!canEditAsset} onChange={(event) => setCompanyForm({ ...companyForm, name: event.target.value })} required /></label>
<label className="field"><span>Nombre corto / sigla <em>opcional</em></span><input value={companyForm.commonName} disabled={!canEditAsset} onChange={(event) => setCompanyForm({ ...companyForm, commonName: event.target.value })} placeholder="Ej.: EMESA" /></label>
<label className="field"><span>Tipo</span><SearchableSelect value={companyForm.organizationKind} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, organizationKind: event.target.value as OrganizationKind })}>{Object.entries(organizationLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</SearchableSelect></label>
<label className="field"><span>Razón social <em>opcional</em></span><input value={companyForm.legalName} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, legalName: event.target.value })} /></label>
<label className="field"><span>CUIT <em>opcional</em></span><input value={companyForm.taxId} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, taxId: event.target.value })} /></label>
<label className="field"><span>Email oficial para actas <em>opcional</em></span><input type="email" value={companyForm.notificationEmail} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, notificationEmail: event.target.value })} /></label>
</div>
<label className="field"><span>Notas <em>opcional</em></span><textarea rows={2} value={companyForm.notes} disabled={!canManageRegistry} onChange={(event) => setCompanyForm({ ...companyForm, notes: event.target.value })} /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(false)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</button></div>
</form> : <div className="company-data-grid">
<div><small>Razón social</small><strong>{profile?.legalName || asset.name}</strong></div>
<div><small>Tipo</small><strong>{organizationLabels[profile?.organizationKind ?? 'COMPANY']}</strong></div>
<div><small>CUIT</small><strong>{profile?.taxId || 'Sin informar'}</strong></div>
<div><small>Email oficial para actas</small><strong>{profile?.notificationEmail || 'Sin informar'}</strong></div>
</div>}
</article>
<article className="panel company-card">
<div className="company-card-heading">
<div><span className="eyebrow">OPERACIÓN</span><h2>Áreas donde opera</h2><p>Estas relaciones indican dónde participa la empresa. No cambian la jerarquía física del Inventario.</p></div>
<span className="count-pill">{activeRelations.length}</span>
</div>
{activeRelations.length === 0 ? <EmptyState title="Sin áreas vinculadas" text="Todavía no hay áreas operativas asociadas a esta empresa." /> : <div className="company-area-list">{activeRelations.map((relation) => <div className="company-area-row" key={relation.id}>
<Link to={`/inventarios/${relation.area.id}`}><strong>{relation.area.name}</strong><small>{relation.area.code} · {relationRoleLabel(relation.relationRole)}</small></Link>
<div><span className="status-badge active">Activa</span>{canManageRelations && <button type="button" className="button text compact" disabled={saving} onClick={() => void endRelation(relation)}>Finalizar</button>}</div>
</div>)}</div>}
{canManageRelations && <details className="company-inline-action"><summary><Icon name="plus" size={15} />Vincular otra área</summary><form onSubmit={addRelation}><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={relationForm.areaId} onChange={(event) => setRelationForm({ ...relationForm, areaId: event.target.value })} required><option value="">Seleccionar área</option>{availableAreas.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.code}</option>)}</SearchableSelect></label><label className="field"><span>Motivo</span><input value={relationForm.reason} onChange={(event) => setRelationForm({ ...relationForm, reason: event.target.value })} minLength={3} required placeholder="Ej.: operación vigente" /></label></div><button className="button primary" disabled={saving || !relationForm.areaId || relationForm.reason.trim().length < 3}>Vincular área</button></form></details>}
</article>
<article className="panel company-card company-activity-card">
<div className="company-card-heading"><div><span className="eyebrow">INSPECCIONES</span><h2>Actividad de la empresa</h2><p>Acceso rápido a la documentación que ya fue emitida.</p></div></div>
<div className="company-metrics">
<div><small>Actas emitidas</small><strong>{metrics.acts}</strong></div>
<div><small>Informes</small><strong>{metrics.reports}</strong></div>
<div><small>Inspecciones activas</small><strong>{metrics.activeInspections}</strong></div>
</div>
{(canReadActs || canReadReports) && <div className="company-card-actions"><button type="button" className="button primary" onClick={() => setTab('issued')}>Ver actas e informes <Icon name="chevron" /></button></div>}
</article>
</>}
</div>}
{tab === 'issued' && <div className="company-stack">
<article className="panel company-card">
<div className="company-card-heading company-doc-heading"><div><span className="eyebrow">DOCUMENTACIÓN EMITIDA</span><h2>Actas e informes</h2><p>Historial documental de esta empresa. Sólo se muestran Actas ya emitidas y sus Informes asociados.</p></div><div className="company-doc-counts"><span>{acts.length} actas</span><span>{reports.length} informes</span></div></div>
<label className="company-document-search"><Icon name="search" /><input value={documentSearch} onChange={(event) => setDocumentSearch(event.target.value)} placeholder="Buscar por acta, informe, inspección, área o GEDO" /></label>
{documentsError && <Alert>{documentsError}</Alert>}
{documentsLoading ? <LoadingBlock label="Cargando documentación emitida…" /> : filteredActs.length === 0 ? <EmptyState title="Sin documentación emitida" text="Todavía no hay Actas selladas para esta empresa." /> : <div className="table-scroll company-doc-table"><table><thead><tr><th>Fecha</th><th>Área</th><th>Acta</th><th>Inspección</th><th>Informe</th><th>Oficialización</th><th /></tr></thead><tbody>{filteredActs.map((act) => {
const report = reportByAct.get(act.id);
return <tr key={act.id}>
<td>{formatDate(act.occurredAt)}</td>
<td><strong className="table-primary">{areaLabel(act.areas)}</strong></td>
<td><Link className="text-link" to={`/inspecciones/actas/${act.id}`}>{act.code}</Link></td>
<td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
<td>{report ? <Link className="text-link" to={`/informes/${report.id}`}>{report.code}</Link> : <span className="status-badge pending">Pendiente de INF</span>}</td>
<td>{report ? <span className={`status-badge ${reportStatusClass(report)}`}>{reportStatusLabel(report)}</span> : <span className="muted"></span>}</td>
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td>
</tr>;
})}</tbody></table></div>}
</article>
</div>}
{tab === 'documents' && <div className="company-stack">
{loadingContext ? <LoadingBlock label="Cargando documentación…" /> : <>
{canReadRegistry && <article className="panel company-card">
<div className="company-card-heading"><div><span className="eyebrow">RESPALDO</span><h2>Documentos de la empresa</h2><p>Documentos e identificadores que respaldan los datos institucionales.</p></div><Link className="button secondary" to={`/inventarios/${asset.id}?advanced=1&tab=registry`}>Administrar</Link></div>
{registry?.sourceDocuments.length ? <div className="company-document-list">{registry.sourceDocuments.map((document) => <div key={document.linkId}><div><strong>{document.title}</strong><small>{document.documentNumber || document.documentType}{document.issuer ? ` · ${document.issuer}` : ''}</small></div><span className="tag">{document.relationType}</span></div>)}</div> : <div className="inline-empty">Todavía no hay documentos respaldatorios vinculados.</div>}
{registry?.externalIdentifiers.length ? <details className="company-more"><summary>Ver identificadores externos ({registry.externalIdentifiers.length})</summary><div className="company-identifier-list">{registry.externalIdentifiers.map((item) => <div key={item.id}><strong>{item.namespace}</strong><span>{item.value}</span><small>{item.validUntil ? 'Histórico' : 'Vigente'}</small></div>)}</div></details> : null}
</article>}
{canReadMedia && <AssetMediaPanel assetId={asset.id} assetName={asset.name} canManage={canManageMedia} />}
<div className="company-advanced-link"><span>¿Necesitás procedencia, composición UTE u otros datos técnicos?</span><Link to={`/inventarios/${asset.id}?advanced=1&tab=registry`}>Abrir administración avanzada</Link></div>
</>}
</div>}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} />}
</section>;
}
+1 -1
View File
@@ -63,7 +63,7 @@ export function InspectionActEditorPage() {
<div className="responsible-summary"> <div className="responsible-summary">
<div><small>Fecha y hora</small><strong>{formatDate(act.occurredAt)}</strong></div> <div><small>Fecha y hora</small><strong>{formatDate(act.occurredAt)}</strong></div>
<div><small>Título</small><strong>{act.title}</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><small>Hallazgos</small><strong>{act.findingCount}</strong></div>
</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> <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>
+207 -66
View File
@@ -6,13 +6,13 @@ import { Icon } from '../components/Icon';
import { getAsset } from '../lib/api'; import { getAsset } from '../lib/api';
import { import {
createInventoryStructure, createInventoryStructure,
getInventoryFamilyFindings,
getInventoryStructureOptions, getInventoryStructureOptions,
listCompaniesForInventoryArea,
listInventoryStructureParents, listInventoryStructureParents,
} from '../lib/inventoryStructureApi'; } from '../lib/inventoryStructureApi';
import type { import type {
InventoryAreaCompanyOption,
InventoryFamily, InventoryFamily,
InventoryFamilyFindings,
InventoryStructureKind, InventoryStructureKind,
InventoryStructureOptions, InventoryStructureOptions,
InventoryStructureParent, InventoryStructureParent,
@@ -26,13 +26,24 @@ const KINDS: Array<{ kind: InventoryStructureKind; label: string }> = [
{ kind: 'SUBINSTALACION', label: 'Subinstalación' }, { kind: 'SUBINSTALACION', label: 'Subinstalación' },
{ kind: 'EMPRESA', label: 'Empresa' }, { kind: 'EMPRESA', label: 'Empresa' },
]; ];
const childKindByParentType: Record<string, InventoryStructureKind | undefined> = { 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() { export function InventoryCreatePage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -44,102 +55,232 @@ export function InventoryCreatePage() {
const [parentSearch, setParentSearch] = useState(''); const [parentSearch, setParentSearch] = useState('');
const [parentId, setParentId] = useState(''); const [parentId, setParentId] = useState('');
const [familyId, setFamilyId] = useState(''); const [familyId, setFamilyId] = useState('');
const [areaCompanies, setAreaCompanies] = useState<InventoryAreaCompanyOption[]>([]);
const [operatorCompanyId, setOperatorCompanyId] = useState(''); const [operatorCompanyId, setOperatorCompanyId] = useState('');
const [concessionTypeId, setConcessionTypeId] = useState('');
const [familyFindings, setFamilyFindings] = useState<InventoryFamilyFindings | null>(null);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [commonName, setCommonName] = useState('');
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
getInventoryStructureOptions().then(async (loaded) => { getInventoryStructureOptions()
setOptions(loaded); .then(async (loaded) => {
if (contextParentId) { setOptions(loaded);
const parent = await getAsset(contextParentId); if (contextParentId) {
const inferred = childKindByParentType[parent.type.code.toLowerCase()]; const parent = await getAsset(contextParentId);
if (inferred) { setKind(inferred); setParentId(parent.id); } const inferred = childKindByParentType[parent.type.code.toLowerCase()];
} if (inferred) {
}).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); setKind(inferred);
setParentId(parent.id);
}
}
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [contextParentId]); }, [contextParentId]);
const requiresParent = Boolean(parentLabelByKind[kind]); const requiresParent = Boolean(parentLabelByKind[kind]);
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION'; const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const requiresYacimientoContext = kind === 'YACIMIENTO'; const requiresCompany = kind === 'YACIMIENTO';
const parentLabel = parentLabelByKind[kind] ?? ''; const parentLabel = parentLabelByKind[kind] ?? '';
useEffect(() => { useEffect(() => {
if (!requiresParent) { setParents([]); setParentId(''); return; } if (!requiresParent) {
setParents([]);
setParentId('');
return;
}
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
listInventoryStructureParents(kind,parentSearch).then((loaded) => { listInventoryStructureParents(kind, parentSearch)
setParents(loaded); .then((loaded) => {
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId); setParents(loaded);
}).catch((requestError) => setError(errorMessage(requestError))); if (contextParentId && loaded.some((item) => item.id === contextParentId)) setParentId(contextParentId);
},150); })
.catch((requestError) => setError(errorMessage(requestError)));
}, 150);
return () => window.clearTimeout(timer); 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(() => { const families = useMemo(() => {
if (!options) return [] as InventoryFamily[]; if (!options) return [] as InventoryFamily[];
if (kind==='INSTALACION') return options.installationFamilies; if (kind === 'INSTALACION') return options.installationFamilies;
if (kind==='SUBINSTALACION') { if (kind === 'SUBINSTALACION') {
const parentFamilyId=selectedParent?.inventoryFamily?.id; const parentFamilyId = selectedParent?.inventoryFamily?.id;
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyIds.includes(parentFamilyId)) : []; return parentFamilyId
? options.subinstallationFamilies.filter((item) => item.parentFamilyIds.includes(parentFamilyId))
: [];
} }
return []; return [];
},[options,kind,selectedParent]); }, [options, kind, selectedParent]);
const selectedFamily=families.find((item)=>item.id===familyId) ?? null;
useEffect(() => {
if (!familyId) { setFamilyFindings(null); return; }
getInventoryFamilyFindings(familyId).then(setFamilyFindings).catch((requestError)=>setError(errorMessage(requestError)));
},[familyId]);
useEffect(() => { useEffect(() => {
if (!requiresFamily) setFamilyId(''); if (!requiresFamily) setFamilyId('');
if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId(''); if (kind === 'SUBINSTALACION' && familyId && !families.some((item) => item.id === familyId)) setFamilyId('');
},[kind,requiresFamily,families,familyId]); }, [kind, requiresFamily, families, familyId]);
useEffect(() => {
if (!requiresYacimientoContext) { setOperatorCompanyId(''); setConcessionTypeId(''); }
},[requiresYacimientoContext]);
const changeKind=(next:InventoryStructureKind) => { const changeKind = (next: InventoryStructureKind) => {
setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null); setKind(next);
setOperatorCompanyId(''); setConcessionTypeId(''); setError(''); setParentId('');
setParentSearch('');
setFamilyId('');
setAreaCompanies([]);
setOperatorCompanyId('');
setError('');
}; };
const save=async(event:FormEvent) => {
const save = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; } if (requiresParent && !parentId) {
if (requiresYacimientoContext && !operatorCompanyId) { setError('Seleccioná la Empresa relacionada del Yacimiento.'); return; } setError(`Seleccioná ${parentLabel}.`);
if (requiresYacimientoContext && !concessionTypeId) { setError('Seleccioná el Tipo de concesión del Yacimiento.'); return; } return;
if (requiresFamily && !familyId) { setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`); return; } }
setSaving(true); setError(''); 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 { try {
const created=await createInventoryStructure({ const created = await createInventoryStructure({
kind,name:name.trim(),parentId:parentId || null,familyId:familyId || null, kind,
operatorCompanyId:operatorCompanyId || null,concessionTypeId:concessionTypeId || null, name: name.trim(),
code:code.trim() || null,commonName:commonName.trim() || null,description:description.trim() || null, parentId: parentId || null,
familyId: familyId || null,
operatorCompanyId: operatorCompanyId || null,
code: code.trim() || null,
}); });
navigate(`/inventarios/${created.id}`,{replace:true}); navigate(`/inventarios/${created.id}`, { replace: true });
} catch(requestError) { setError(errorMessage(requestError)); } } catch (requestError) {
finally { setSaving(false); } setError(errorMessage(requestError));
} finally {
setSaving(false);
}
}; };
if (loading) return <LoadingBlock label="Preparando alta…" />; 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"> 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> <nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario">
<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> <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>} {error && <Alert>{error}</Alert>}
<form className="panel form-panel" onSubmit={save}> <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> <div className="form-section">
{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>} <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>} <h2>¿Qué querés crear?</h2>
{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>} <p className="section-copy">Departamento Área Yacimiento Instalación Subinstalación.</p>
<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>
<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="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> </form>
</section>; </section>;
} }
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
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() {
const { id } = useParams();
const [params] = useSearchParams();
const { user } = useAuth();
const [asset, setAsset] = useState<AssetDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!id) {
setError('No se encontró el registro solicitado.');
setLoading(false);
return;
}
setLoading(true);
setError('');
getAsset(id)
.then(setAsset)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <LoadingBlock label="Cargando registro…" />;
if (!asset) return <Alert>{error || 'No se pudo cargar el registro.'}</Alert>;
const advancedRequested = params.get('advanced') === '1';
const advancedAllowed = hasAdministratorRole(user);
const advanced = advancedRequested && advancedAllowed;
const isCompany = asset.type.code.toLowerCase() === 'empresa';
if (isCompany && !advanced) {
return <CompanyInventoryPage initialAsset={asset} />;
}
if (isSimpleInventoryAsset(asset) && !advanced) {
return <SimpleInventoryDetailPage initialAsset={asset} />;
}
if (isTerritorialInventoryAsset(asset) && !advanced) {
return <TerritorialInventoryPage initialAsset={asset} />;
}
return <AssetEditorPage />;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,350 @@
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetDossierPanel } from '../features/assets/AssetDossierPanel';
import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
import { AssetTechnicalDataPanel } from '../features/assets/AssetTechnicalDataPanel';
import {
ASSET_OPERATIONAL_STATUSES,
ASSET_STATUSES,
assetOperationalStatusLabel,
assetStatusClass,
assetStatusLabel,
} from '../features/assets/assetPresentation';
import {
getAssetLineage,
listAssetTreeChildren,
updateAsset,
updateAssetInformationStatus,
updateAssetOperationalStatus,
} from '../lib/api';
import type {
AssetDetail,
AssetInformationStatus,
AssetLineageItem,
AssetListItem,
AssetOperationalStatus,
} from '../lib/api';
import { getInventoryTechnicalValues } from '../lib/inventoryStructureApi';
import type { InventoryTechnicalValues } from '../lib/inventoryStructureApi';
import './SimpleInventoryDetailPage.css';
const AssetGeometryEditor = lazy(() =>
import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })),
);
export type SimpleInventoryKind =
| 'DEPARTAMENTO'
| 'AREA'
| 'YACIMIENTO'
| 'INSTALACION'
| 'SUBINSTALACION';
type SimpleTab = 'summary' | 'activity' | 'findings' | 'location' | 'photos' | 'history';
function normalized(value: string | null | undefined): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[_\s]+/g, '-');
}
export function simpleInventoryKind(asset: AssetDetail): SimpleInventoryKind | null {
const code = normalized(asset.type.code);
const name = normalized(asset.type.name);
const values = new Set([code, name]);
if (values.has('departamento')) return 'DEPARTAMENTO';
if (values.has('area')) return 'AREA';
if (values.has('yacimiento')) return 'YACIMIENTO';
if (values.has('subinstalacion')) return 'SUBINSTALACION';
if (
values.has('instalacion')
|| values.has('instalacion-de-superficie')
|| values.has('instalacion-superficie')
) return 'INSTALACION';
return null;
}
export function isSimpleInventoryAsset(asset: AssetDetail): boolean {
return simpleInventoryKind(asset) !== null;
}
const labels: Record<SimpleInventoryKind, string> = {
DEPARTAMENTO: 'Departamento',
AREA: 'Área',
YACIMIENTO: 'Yacimiento',
INSTALACION: 'Instalación',
SUBINSTALACION: 'Subinstalación',
};
const childLabels: Partial<Record<SimpleInventoryKind, string>> = {
DEPARTAMENTO: 'Área',
AREA: 'Yacimiento',
YACIMIENTO: 'Instalación',
INSTALACION: 'Subinstalación',
};
function lineageLabel(item: AssetLineageItem): string {
return item.commonName ? `${item.name} · ${item.commonName}` : item.name;
}
export function SimpleInventoryDetailPage({ initialAsset }: { initialAsset: AssetDetail }) {
const [params, setParams] = useSearchParams();
const { user, hasPermission } = useAuth();
const kind = simpleInventoryKind(initialAsset);
const [asset, setAsset] = useState(initialAsset);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [childrenHasMore, setChildrenHasMore] = useState(false);
const [technical, setTechnical] = useState<InventoryTechnicalValues | null>(null);
const [loadingRelated, setLoadingRelated] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [name, setName] = useState(initialAsset.name);
const [commonName, setCommonName] = useState(initialAsset.commonName ?? '');
const [description, setDescription] = useState(initialAsset.description ?? '');
const [status, setStatus] = useState<AssetInformationStatus>(initialAsset.informationStatus);
const [operationalStatus, setOperationalStatus] = useState<AssetOperationalStatus>(initialAsset.operationalStatus);
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const canEdit = hasPermission('assets.update');
const canCreate = hasPermission('assets.create');
const canChangeStatus = hasPermission('assets.change_status');
const canChangeOperationalStatus = hasPermission('assets.change_operational_status');
const canReadHistory = hasPermission('assets.read_history');
const canReadMedia = hasPermission('assets.read_media');
const canManageMedia = hasPermission('assets.manage_media');
const canEditGeometry = hasPermission('assets.update_geometry');
const canReadFindingCatalog = hasPermission('finding_catalog.read');
const canManageFindingCatalog = hasPermission('finding_catalog.manage');
const canReadDossier = hasPermission('inspections.read')
&& hasPermission('inspection_acts.read')
&& hasPermission('inspection_findings.read')
&& hasPermission('inspection_evidence.read')
&& hasPermission('inspection_communications.read');
const canAdvanced = hasAdministratorRole(user);
const inspectable = kind === 'YACIMIENTO' || kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const technicalLevel = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const hasChildren = kind !== 'SUBINSTALACION';
const requestedTab = params.get('tab') as SimpleTab | null;
const availableTabs = useMemo(() => new Set<SimpleTab>([
'summary',
...(inspectable && canReadDossier ? ['activity' as const] : []),
...(technicalLevel && canReadFindingCatalog ? ['findings' as const] : []),
'location',
...(canReadMedia ? ['photos' as const] : []),
...(canReadHistory ? ['history' as const] : []),
]), [inspectable, technicalLevel, canReadDossier, canReadFindingCatalog, canReadMedia, canReadHistory]);
const tab: SimpleTab = requestedTab && availableTabs.has(requestedTab) ? requestedTab : 'summary';
const loadRelated = async () => {
setLoadingRelated(true);
try {
const [loadedLineage, childPage, loadedTechnical] = await Promise.all([
getAssetLineage(asset.id),
hasChildren
? listAssetTreeChildren({ parentId: asset.id, limit: 100 })
: Promise.resolve({ data: [] as AssetListItem[], meta: { count: 0, hasMore: false } }),
technicalLevel
? getInventoryTechnicalValues(asset.id).catch(() => null)
: Promise.resolve(null),
]);
setLineage(loadedLineage);
setChildren(childPage.data);
setChildrenHasMore(childPage.meta.hasMore);
setTechnical(loadedTechnical);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setLoadingRelated(false);
}
};
useEffect(() => { void loadRelated(); }, [asset.id]);
if (!kind) return <Alert>Este registro no pertenece al modelo simple de Inventarios.</Alert>;
const save = async (event: FormEvent) => {
event.preventDefault();
if (!name.trim()) return;
setSaving(true);
setError('');
setSuccess('');
try {
let saved = asset;
if (canEdit) {
saved = await updateAsset(asset.id, {
name: name.trim(),
commonName: commonName.trim() || null,
description: description.trim() || null,
});
}
if (canChangeStatus && saved.informationStatus !== status) {
saved = await updateAssetInformationStatus(asset.id, status);
}
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) {
saved = await updateAssetOperationalStatus(asset.id, operationalStatus);
}
setAsset(saved);
setName(saved.name);
setCommonName(saved.commonName ?? '');
setDescription(saved.description ?? '');
setStatus(saved.informationStatus);
setOperationalStatus(saved.operationalStatus);
setEditing(false);
setHistoryRefreshKey((current) => current + 1);
setSuccess('Registro actualizado.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const cancelEdit = () => {
setEditing(false);
setName(asset.name);
setCommonName(asset.commonName ?? '');
setDescription(asset.description ?? '');
setStatus(asset.informationStatus);
setOperationalStatus(asset.operationalStatus);
};
const setTab = (next: SimpleTab) => {
const nextParams = new URLSearchParams(params);
next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next);
nextParams.delete('advanced');
setParams(nextParams);
};
const childLabel = childLabels[kind];
const parentLabel = kind === 'AREA'
? 'Departamento'
: kind === 'YACIMIENTO'
? 'Área'
: kind === 'INSTALACION'
? 'Yacimiento'
: kind === 'SUBINSTALACION'
? 'Instalación'
: null;
return <section className="narrow-section simple-inventory-page">
<nav className="breadcrumb simple-inventory-breadcrumb" aria-label="Ruta de Inventario">
<Link to="/inventarios">Inventarios</Link>
{lineage.filter((item) => item.id !== asset.id).map((item) => <span className="simple-inventory-breadcrumb-part" key={item.id}><span></span><Link to={`/inventarios/${item.id}`}>{lineageLabel(item)}</Link></span>)}
<span></span><strong>{asset.name}</strong>
</nav>
<header className="simple-inventory-hero">
<div>
<span className="eyebrow">{labels[kind].toUpperCase()}</span>
<h1>{asset.name}</h1>
<div className="simple-inventory-hero-meta"><strong>{asset.code}</strong>{asset.commonName && <span>{asset.commonName}</span>}</div>
</div>
<div className="simple-inventory-hero-actions">
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span>
{tab === 'summary' && (canEdit || canChangeStatus || canChangeOperationalStatus) && <button type="button" className="button secondary" onClick={() => editing ? cancelEdit() : setEditing(true)}><Icon name="edit" />{editing ? 'Cancelar' : 'Editar'}</button>}
{tab === 'summary' && canCreate && childLabel && <Link className="button primary" to={`/inventarios/nuevo?parentId=${asset.id}`}><Icon name="plus" />Agregar {childLabel.toLowerCase()}</Link>}
</div>
</header>
<div className="simple-inventory-purpose">
<Icon name={inspectable ? 'clipboard' : 'layers'} />
<span>{inspectable
? 'Ficha operativa: identidad, ubicación y actividad de inspección. La información administrativa avanzada queda fuera de la vista cotidiana.'
: 'Este nivel organiza la estructura del Inventario. La vista cotidiana muestra sólo los datos necesarios para ubicar y navegar.'}</span>
</div>
<nav className="simple-inventory-tabs" aria-label="Secciones del Inventario">
<button type="button" className={tab === 'summary' ? 'active' : ''} onClick={() => setTab('summary')}>Resumen</button>
{inspectable && canReadDossier && <button type="button" className={tab === 'activity' ? 'active' : ''} onClick={() => setTab('activity')}>Actividad</button>}
{technicalLevel && canReadFindingCatalog && <button type="button" className={tab === 'findings' ? 'active' : ''} onClick={() => setTab('findings')}>Hallazgos</button>}
<button type="button" className={tab === 'location' ? 'active' : ''} onClick={() => setTab('location')}>Ubicación</button>
{canReadMedia && <button type="button" className={tab === 'photos' ? 'active' : ''} onClick={() => setTab('photos')}>Fotos</button>}
{canReadHistory && <button type="button" className={tab === 'history' ? 'active' : ''} onClick={() => setTab('history')}>Cambios</button>}
</nav>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{tab === 'summary' && <div className="simple-inventory-stack">
<article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">DATOS PRINCIPALES</span><h2>Identificación</h2></div></div>
{editing ? <form className="simple-inventory-form" onSubmit={save}>
<div className="form-grid two">
<label className="field"><span>Nombre <em>obligatorio</em></span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} /></label>
<label className="field"><span>Nombre habitual <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} maxLength={200} /></label>
</div>
<details className="simple-inventory-details">
<summary>Más datos</summary>
<label className="field"><span>Descripción <em>opcional</em></span><textarea rows={3} value={description} onChange={(event) => setDescription(event.target.value)} maxLength={4000} /></label>
</details>
{(canChangeStatus || canChangeOperationalStatus) && <details className="simple-inventory-details">
<summary>Estado y opciones</summary>
<div className="form-grid two">
{canChangeStatus && <label className="field"><span>Estado del dato</span><select value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>}
{canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><select 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>)}</select></label>}
</div>
</details>}
<div className="form-actions"><button type="button" className="button secondary" onClick={cancelEdit}>Cancelar</button><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar cambios'}</button></div>
</form> : <div className="simple-inventory-data-grid">
<div><small>Nombre</small><strong>{asset.name}</strong></div>
<div><small>Código DH</small><strong>{asset.code}</strong></div>
{asset.commonName && <div><small>Nombre habitual</small><strong>{asset.commonName}</strong></div>}
<div><small>Estado operativo</small><strong>{assetOperationalStatusLabel(asset.operationalStatus)}</strong></div>
</div>}
</article>
<article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">UBICACIÓN ACTUAL</span><h2>Dentro del Inventario</h2></div></div>
<div className="simple-inventory-context-grid">
{parentLabel && <div className="simple-inventory-context-card"><small>{parentLabel}</small>{asset.parent ? <Link to={`/inventarios/${asset.parent.id}`}><strong>{asset.parent.name}</strong><span>{asset.parent.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{kind === 'YACIMIENTO' && <div className="simple-inventory-context-card"><small>Empresa operadora</small>{asset.operatorCompany ? <Link to={`/inventarios/${asset.operatorCompany.id}`}><strong>{asset.operatorCompany.name}</strong><span>{asset.operatorCompany.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && <div className="simple-inventory-context-card"><small>Empresa del Yacimiento</small>{asset.operatorCompany ? <Link to={`/inventarios/${asset.operatorCompany.id}`}><strong>{asset.operatorCompany.name}</strong><span>{asset.operatorCompany.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{technicalLevel && <div className="simple-inventory-context-card"><small>Tipo técnico</small><strong>{technical?.family.name ?? 'Sin clasificación técnica'}</strong>{technical?.family.code && <span>{technical.family.code}</span>}</div>}
</div>
</article>
{hasChildren && <article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">CONTENIDO</span><h2>{childLabel ? `${childLabel}s dentro de ${asset.name}` : `Contenido de ${asset.name}`}</h2></div><span className="count-pill">{children.length}{childrenHasMore ? '+' : ''}</span></div>
{loadingRelated ? <LoadingBlock label="Cargando contenido…" /> : children.length === 0 ? <div className="inline-empty">Todavía no hay registros dentro de {asset.name}.</div> : <div className="simple-inventory-child-list">{children.map((child) => <Link key={child.id} to={`/inventarios/${child.id}`} className="simple-inventory-child-row"><span className="asset-symbol"><Icon name="layers" /></span><span><strong>{child.name}</strong><small>{child.type.name} · {child.code}</small></span><span className={`status-badge ${assetStatusClass(child.informationStatus)}`}>{assetStatusLabel(child.informationStatus)}</span><Icon name="chevron" size={16} /></Link>)}</div>}
</article>}
{technicalLevel && <details className="panel simple-inventory-card simple-inventory-technical-details">
<summary><span><small>DATOS TÉCNICOS</small><strong>{technical?.family.name ?? 'Información técnica'}</strong></span><span>Ver / editar</span></summary>
<AssetTechnicalDataPanel assetId={asset.id} canEdit={canEdit} />
</details>}
{!editing && asset.description && <details className="panel simple-inventory-card simple-inventory-extra-details">
<summary>Más datos</summary>
<p>{asset.description}</p>
</details>}
{!editing && <details className="panel simple-inventory-card simple-inventory-extra-details">
<summary>Estado y opciones</summary>
<div className="simple-inventory-data-grid">
<div><small>Estado del dato</small><strong>{assetStatusLabel(asset.informationStatus)}</strong></div>
<div><small>Estado operativo</small><strong>{assetOperationalStatusLabel(asset.operationalStatus)}</strong></div>
</div>
{canAdvanced && <div className="simple-inventory-advanced"><span>Las herramientas históricas y administrativas quedan separadas de esta ficha.</span><Link to={`/inventarios/${asset.id}?advanced=1`}>Administración avanzada</Link></div>}
</details>}
</div>}
{tab === 'activity' && inspectable && canReadDossier && <AssetDossierPanel assetId={asset.id} />}
{tab === 'findings' && technicalLevel && canReadFindingCatalog && <AssetFindingCatalogPanel assetId={asset.id} canManage={canManageFindingCatalog} />}
{tab === 'location' && <Suspense fallback={<LoadingBlock label="Cargando mapa…" />}><AssetGeometryEditor assetId={asset.id} assetName={asset.name} canEdit={canEditGeometry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} /></Suspense>}
{tab === 'photos' && canReadMedia && <AssetMediaPanel assetId={asset.id} assetName={asset.name} canManage={canManageMedia} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} refreshKey={historyRefreshKey} />}
</section>;
}
@@ -0,0 +1,276 @@
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
import {
getAssetLineage,
listAreaCompanyRelations,
listAssetTreeChildren,
listInspectionVisits,
updateAsset,
} from '../lib/api';
import type { AreaCompanyRelation, AssetDetail, AssetLineageItem, AssetListItem } from '../lib/api';
import {
listInspectionActsGlobalF4,
type InspectionActStatusF4,
} from '../lib/inspectionActF4Api';
import { listInspectionReportsF4 } from '../lib/reportWorkflowApi';
import './territorialInventory.css';
type TerritoryTab = 'summary' | 'history';
function normalized(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function tokensForType(type: { code: string; name: string }) {
return new Set(`${normalized(type.code)} ${normalized(type.name)}`.split(' ').filter(Boolean));
}
function typeTokens(asset: AssetDetail) {
return tokensForType(asset.type);
}
export function isTerritorialInventoryAsset(asset: AssetDetail) {
const tokens = typeTokens(asset);
return tokens.has('departamento') || tokens.has('area') || tokens.has('yacimiento');
}
function isOperationalArea(asset: AssetDetail) {
return typeTokens(asset).has('area');
}
function isYacimiento(asset: AssetDetail) {
return typeTokens(asset).has('yacimiento');
}
function lineageArea(lineage: AssetLineageItem[]) {
return [...lineage].reverse().find((item) => tokensForType(item.type).has('area')) ?? null;
}
function relationRoleLabel(role: AreaCompanyRelation['relationRole']) {
if (role === 'OPERATOR') return 'Operadora';
if (role === 'TECHNICAL_OPERATOR') return 'Operadora técnica';
if (role === 'CONCESSIONAIRE') return 'Concesionaria / titular';
if (role === 'PERMIT_HOLDER') return 'Permisionaria';
if (role === 'PARTICIPANT') return 'Participante';
return 'Vinculada';
}
export function TerritorialInventoryPage({ initialAsset }: { initialAsset: AssetDetail }) {
const [params, setParams] = useSearchParams();
const requestedTab = params.get('tab') as TerritoryTab | null;
const tab: TerritoryTab = requestedTab === 'history' ? 'history' : 'summary';
const { user, hasPermission } = useAuth();
const canEdit = hasPermission('assets.update');
const canCreate = hasPermission('assets.create');
const canReadHistory = hasPermission('assets.read_history');
const canReadRelations = hasPermission('asset_relations.read');
const canReadActs = hasPermission('inspection_acts.read');
const canReadReports = hasPermission('inspection_reports.read');
const canReadInspections = hasPermission('inspections.read');
const canAdvanced = hasAdministratorRole(user);
const [asset, setAsset] = useState(initialAsset);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [childrenHasMore, setChildrenHasMore] = useState(false);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [relations, setRelations] = useState<AreaCompanyRelation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [name, setName] = useState(initialAsset.name);
const [description, setDescription] = useState(initialAsset.description ?? '');
const [metrics, setMetrics] = useState({ activeInspections: 0, acts: 0, reports: 0 });
const area = isOperationalArea(asset);
const yacimiento = isYacimiento(asset);
const activeRelations = useMemo(() => relations.filter((item) => item.active), [relations]);
const relationAreaContext = useMemo(() => {
if (area) return { id: asset.id, code: asset.code, name: asset.name };
if (asset.operationalArea) return asset.operationalArea;
const ancestor = lineageArea(lineage);
return ancestor ? { id: ancestor.id, code: ancestor.code, name: ancestor.name } : null;
}, [area, asset.id, asset.code, asset.name, asset.operationalArea, lineage]);
const loadSummary = async () => {
setLoading(true);
setError('');
try {
const loadedLineage = await getAssetLineage(asset.id);
const ancestorArea = lineageArea(loadedLineage);
const relationAreaId = area
? asset.id
: asset.operationalArea?.id ?? ancestorArea?.id ?? null;
const [childPage, loadedRelations] = await Promise.all([
listAssetTreeChildren({ parentId: asset.id, limit: 100 }),
relationAreaId && canReadRelations
? listAreaCompanyRelations({ areaId: relationAreaId, includeHistory: true })
: Promise.resolve([]),
]);
setLineage(loadedLineage);
setChildren(childPage.data);
setChildrenHasMore(childPage.meta.hasMore);
setRelations(loadedRelations);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setLoading(false);
}
};
useEffect(() => { void loadSummary(); }, [asset.id, area, canReadRelations]);
useEffect(() => {
if (!area) {
setMetrics({ activeInspections: 0, acts: 0, reports: 0 });
return;
}
let cancelled = false;
const loadMetrics = async () => {
try {
const actStatuses: InspectionActStatusF4[] = ['SEALED', 'CLOSED', 'RECTIFIED'];
const [planned, inProgress, actPages, reportPage] = await Promise.all([
canReadInspections ? listInspectionVisits({ areaId: asset.id, status: 'PLANNED', pageSize: 1 }) : Promise.resolve(null),
canReadInspections ? listInspectionVisits({ areaId: asset.id, status: 'IN_PROGRESS', pageSize: 1 }) : Promise.resolve(null),
canReadActs
? Promise.all(actStatuses.map((status) => listInspectionActsGlobalF4({ areaId: asset.id, status, pageSize: 1 })))
: Promise.resolve([]),
canReadReports ? listInspectionReportsF4({ areaId: asset.id, pageSize: 1 }) : Promise.resolve(null),
]);
if (cancelled) return;
setMetrics({
activeInspections: (planned?.meta.total ?? 0) + (inProgress?.meta.total ?? 0),
acts: actPages.reduce((total, page) => total + page.meta.total, 0),
reports: reportPage?.meta.total ?? 0,
});
} catch {
if (!cancelled) setMetrics({ activeInspections: 0, acts: 0, reports: 0 });
}
};
void loadMetrics();
return () => { cancelled = true; };
}, [asset.id, area, canReadActs, canReadReports, canReadInspections]);
const save = async (event: FormEvent) => {
event.preventDefault();
if (!canEdit || !name.trim()) return;
setSaving(true); setError(''); setSuccess('');
try {
const saved = await updateAsset(asset.id, {
name: name.trim(),
description: description.trim() || null,
});
setAsset(saved);
setName(saved.name);
setDescription(saved.description ?? '');
setEditing(false);
setSuccess('Datos actualizados.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const setTab = (next: TerritoryTab) => {
const nextParams = new URLSearchParams(params);
next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next);
nextParams.delete('advanced');
setParams(nextParams);
};
return <section className="narrow-section territory-page">
<nav className="breadcrumb territory-breadcrumb" aria-label="Ruta territorial">
<Link to="/inventarios">Inventarios</Link><span></span>
<Link to="/inventarios?section=territory">Áreas y yacimientos</Link><span></span><strong>{asset.name}</strong>
</nav>
<header className="territory-hero">
<div>
<span className="eyebrow">{asset.type.name.toUpperCase()}</span>
<h1>{asset.name}</h1>
<p><strong>{asset.code}</strong></p>
</div>
<div className="territory-hero-actions">
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span>
{canEdit && tab === 'summary' && <button type="button" className="button secondary" onClick={() => setEditing((current) => !current)}><Icon name="edit" />{editing ? 'Cancelar edición' : 'Editar'}</button>}
{canCreate && tab === 'summary' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${asset.id}`}><Icon name="plus" />Agregar elemento</Link>}
</div>
</header>
<div className="territory-purpose-note"><Icon name="map" /><span>Este nivel organiza el territorio y la navegación. Los hallazgos se cargan sobre elementos inspeccionables, no sobre {asset.type.name.toLowerCase()}s.</span></div>
<nav className="territory-tabs" aria-label="Secciones territoriales">
<button type="button" className={tab === 'summary' ? 'active' : ''} onClick={() => setTab('summary')}>Resumen</button>
{canReadHistory && <button type="button" className={tab === 'history' ? 'active' : ''} onClick={() => setTab('history')}>Historial</button>}
</nav>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{tab === 'summary' && <div className="territory-stack">
<article className="panel territory-card">
<div className="territory-card-heading">
<div><span className="eyebrow">DATOS BÁSICOS</span><h2>Identificación</h2></div>
</div>
{editing ? <form className="territory-edit-form" onSubmit={save}>
<label className="field"><span>Nombre</span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} /></label>
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={3} maxLength={4000} /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => { setEditing(false); setName(asset.name); setDescription(asset.description ?? ''); }}>Cancelar</button><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar'}</button></div>
</form> : <div className="territory-data-grid">
<div><small>Tipo</small><strong>{asset.type.name}</strong></div>
<div><small>Código DH</small><strong>{asset.code}</strong></div>
{asset.description && <div className="wide"><small>Descripción</small><strong>{asset.description}</strong></div>}
</div>}
</article>
<article className="panel territory-card">
<div className="territory-card-heading">
<div><span className="eyebrow">CONTENIDO</span><h2>Registros dentro de {asset.name}</h2><p>Acceso directo a los niveles que dependen de este registro.</p></div>
<span className="count-pill">{children.length}{childrenHasMore ? '+' : ''}</span>
</div>
{loading ? <LoadingBlock label="Cargando contenido…" /> : children.length === 0 ? <div className="inline-empty">Todavía no hay registros dentro de {asset.name}.</div> : <div className="territory-child-list">
{children.map((child) => <Link to={`/inventarios/${child.id}`} key={child.id} className="territory-child-row">
<span className="asset-symbol"><Icon name="layers" /></span>
<span><strong>{child.name}</strong><small>{child.type.name} · {child.code}</small></span>
<span className={`status-badge ${assetStatusClass(child.informationStatus)}`}>{assetStatusLabel(child.informationStatus)}</span>
<Icon name="chevron" size={16} />
</Link>)}
</div>}
{childrenHasMore && <div className="territory-card-actions"><Link className="button secondary" to={`/inventarios?section=territory&parentId=${asset.id}`}>Ver contenido completo</Link></div>}
</article>
{relationAreaContext && canReadRelations && <article className="panel territory-card">
<div className="territory-card-heading"><div><span className="eyebrow">OPERACIÓN</span><h2>Empresas vinculadas</h2><p>{area ? 'Quién opera o participa actualmente en esta área.' : yacimiento ? `Empresas que operan o participan en el Área ${relationAreaContext.name}, contexto operativo de este yacimiento.` : `Empresas vinculadas al Área ${relationAreaContext.name}.`}</p></div><span className="count-pill">{activeRelations.length}</span></div>
{loading ? <LoadingBlock label="Cargando empresas…" /> : activeRelations.length === 0 ? <div className="inline-empty">No hay empresas con vínculo vigente en el Área {relationAreaContext.name}.</div> : <div className="territory-company-list">
{activeRelations.map((relation) => <Link to={`/inventarios/${relation.company.id}`} key={relation.id} className="territory-company-row"><span><strong>{relation.company.name}</strong><small>{relationRoleLabel(relation.relationRole)}{!area ? ` · ${relation.area.name}` : ''}</small></span><Icon name="chevron" size={16} /></Link>)}
</div>}
</article>}
{area && (canReadInspections || canReadActs || canReadReports) && <article className="panel territory-card">
<div className="territory-card-heading"><div><span className="eyebrow">ACTIVIDAD</span><h2>Actividad del área</h2><p>Resumen documental y operativo, sin mezclarlo con hallazgos de los elementos inspeccionados.</p></div></div>
<div className="territory-metrics">
{canReadInspections && <div><small>Inspecciones activas</small><strong>{metrics.activeInspections}</strong></div>}
{canReadActs && <div><small>Actas emitidas</small><strong>{metrics.acts}</strong></div>}
{canReadReports && <div><small>Informes</small><strong>{metrics.reports}</strong></div>}
</div>
</article>}
{canAdvanced && <div className="territory-advanced-link"><span>Los datos estructurales, procedencia, ubicación y opciones técnicas quedan fuera de la vista cotidiana.</span><Link to={`/inventarios/${asset.id}?advanced=1`}>Administración avanzada</Link></div>}
</div>}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} />}
</section>;
}
+105
View File
@@ -0,0 +1,105 @@
.company-page { display: grid; gap: 18px; }
.company-breadcrumb { margin-bottom: 0; }
.company-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 4px 0 2px;
}
.company-hero h1 { margin: 6px 0 5px; font-size: clamp(31px, 3.5vw, 44px); line-height: 1.02; letter-spacing: -.045em; }
.company-hero p { margin: 0; color: var(--muted); font-size: 13px; }
.company-hero-actions { display: flex; align-items: center; gap: 12px; }
.company-tabs {
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
padding: 0 0 1px;
border-bottom: 1px solid var(--line);
}
.company-tabs button {
position: relative;
min-height: 43px;
padding: 9px 13px;
border: 0;
color: #566178;
background: transparent;
cursor: pointer;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
}
.company-tabs button:hover { color: var(--ink); }
.company-tabs button.active { color: var(--blue); }
.company-tabs button.active::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--blue); }
.company-stack { display: grid; gap: 15px; }
.company-card { padding: 23px; }
.company-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
.company-card-heading h2 { margin: 4px 0 4px; }
.company-card-heading p { max-width: 680px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.company-card-actions { display: flex; justify-content: flex-end; margin-top: 17px; }
.company-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--line); }
.company-data-grid > div { min-height: 78px; padding: 15px 16px; background: #fff; }
.company-data-grid small, .company-data-grid strong { display: block; }
.company-data-grid small { margin-bottom: 7px; color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
.company-data-grid strong { font-size: 13px; font-weight: 750; }
.company-edit-form { display: grid; gap: 16px; }
.company-area-list { display: grid; gap: 8px; }
.company-area-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 62px; padding: 12px 14px; border: 1px solid #e3e7ee; border-radius: 10px; background: #fafbfc; }
.company-area-row > a { min-width: 0; text-decoration: none; }
.company-area-row > a:hover strong { color: var(--blue); }
.company-area-row strong, .company-area-row small { display: block; }
.company-area-row strong { font-size: 13px; }
.company-area-row small { margin-top: 4px; color: var(--muted); font-size: 10px; }
.company-area-row > div { display: flex; align-items: center; gap: 7px; }
.company-inline-action { margin-top: 13px; border: 1px dashed #cfd7e5; border-radius: 10px; background: #fbfcfe; }
.company-inline-action summary { display: flex; align-items: center; gap: 7px; padding: 13px 14px; cursor: pointer; color: var(--blue); font-size: 12px; font-weight: 750; list-style: none; }
.company-inline-action summary::-webkit-details-marker { display: none; }
.company-inline-action form { display: grid; gap: 13px; padding: 0 14px 14px; }
.company-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.company-metrics > div { display: grid; gap: 6px; padding: 16px; border: 1px solid #e2e7ef; border-radius: 11px; background: #f8faff; }
.company-metrics small { color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.company-metrics strong { color: #173b7a; font-size: 25px; line-height: 1; letter-spacing: -.04em; }
.company-doc-heading { align-items: center; }
.company-doc-counts { display: flex; gap: 7px; }
.company-doc-counts span { padding: 6px 9px; border-radius: 999px; color: #536078; background: #f0f3f8; font-size: 10px; font-weight: 750; }
.company-document-search { display: flex; align-items: center; gap: 9px; width: 100%; margin-bottom: 17px; padding: 0 12px; border: 1px solid #d8dee8; border-radius: 10px; background: white; }
.company-document-search .icon { color: #818ba0; }
.company-document-search input { width: 100%; min-height: 43px; border: 0; outline: 0; background: transparent; font-size: 12px; }
.company-doc-table table { min-width: 870px; }
.company-doc-table td { vertical-align: middle; }
.company-document-list { display: grid; gap: 8px; }
.company-document-list > div { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 13px 14px; border: 1px solid #e2e6ed; border-radius: 10px; background: #fbfcfd; }
.company-document-list strong, .company-document-list small { display: block; }
.company-document-list strong { font-size: 12px; }
.company-document-list small { margin-top: 4px; color: var(--muted); font-size: 10px; }
.company-more { margin-top: 13px; }
.company-more summary { cursor: pointer; color: #59657b; font-size: 11px; font-weight: 750; }
.company-identifier-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; margin-top: 10px; }
.company-identifier-list > div { display: grid; gap: 3px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 9px; background: #fafbfc; }
.company-identifier-list strong { font-size: 10px; color: var(--blue); }
.company-identifier-list span { font-size: 12px; font-weight: 750; }
.company-identifier-list small { color: var(--muted); font-size: 9px; }
.company-advanced-link { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 12px 15px; border: 1px dashed #ccd4e2; border-radius: 10px; color: var(--muted); background: rgba(255,255,255,.55); font-size: 11px; }
.company-advanced-link a { color: var(--blue); font-weight: 750; text-decoration: none; }
@media (max-width: 760px) {
.company-hero { align-items: flex-start; flex-direction: column; }
.company-hero-actions { width: 100%; justify-content: space-between; }
.company-card { padding: 18px; }
.company-card-heading { align-items: flex-start; flex-direction: column; }
.company-data-grid, .company-metrics { grid-template-columns: 1fr; }
.company-area-row { align-items: flex-start; flex-direction: column; }
.company-area-row > div { width: 100%; justify-content: space-between; }
.company-doc-counts { width: 100%; }
.company-advanced-link { align-items: flex-start; flex-direction: column; }
}
+106
View File
@@ -0,0 +1,106 @@
.territory-page { display: grid; gap: 18px; }
.territory-breadcrumb { margin-bottom: 0; }
.territory-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 4px 0 2px;
}
.territory-hero h1 { margin: 6px 0 5px; font-size: clamp(31px, 3.5vw, 44px); line-height: 1.02; letter-spacing: -.045em; }
.territory-hero p { margin: 0; color: var(--muted); font-size: 13px; }
.territory-hero-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.territory-purpose-note {
display: flex;
align-items: center;
gap: 9px;
padding: 11px 13px;
border: 1px solid #dbe4f2;
border-radius: 10px;
color: #536078;
background: #f7f9fd;
font-size: 11px;
line-height: 1.45;
}
.territory-purpose-note .icon { color: var(--blue); flex: 0 0 auto; }
.territory-tabs {
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
padding: 0 0 1px;
border-bottom: 1px solid var(--line);
}
.territory-tabs button {
position: relative;
min-height: 43px;
padding: 9px 13px;
border: 0;
color: #566178;
background: transparent;
cursor: pointer;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
}
.territory-tabs button:hover { color: var(--ink); }
.territory-tabs button.active { color: var(--blue); }
.territory-tabs button.active::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--blue); }
.territory-stack { display: grid; gap: 15px; }
.territory-card { padding: 22px; }
.territory-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.territory-card-heading h2 { margin: 4px 0; }
.territory-card-heading p { max-width: 680px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.territory-card-actions { display: flex; justify-content: flex-end; margin-top: 14px; }
.territory-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--line); }
.territory-data-grid > div { min-height: 74px; padding: 15px 16px; background: #fff; }
.territory-data-grid > div.wide { grid-column: 1 / -1; }
.territory-data-grid small, .territory-data-grid strong { display: block; }
.territory-data-grid small { margin-bottom: 7px; color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
.territory-data-grid strong { font-size: 13px; font-weight: 750; line-height: 1.45; }
.territory-edit-form { display: grid; gap: 15px; }
.territory-child-list, .territory-company-list { display: grid; gap: 8px; }
.territory-child-row, .territory-company-row {
display: flex;
align-items: center;
gap: 12px;
min-height: 60px;
padding: 11px 13px;
border: 1px solid #e2e7ef;
border-radius: 10px;
color: inherit;
background: #fbfcfd;
text-decoration: none;
}
.territory-child-row:hover, .territory-company-row:hover { border-color: #bfd0ee; background: #f7faff; }
.territory-child-row > span:nth-child(2), .territory-company-row > span:first-child { flex: 1; min-width: 0; }
.territory-child-row strong, .territory-child-row small, .territory-company-row strong, .territory-company-row small { display: block; }
.territory-child-row strong, .territory-company-row strong { font-size: 12px; }
.territory-child-row small, .territory-company-row small { margin-top: 4px; color: var(--muted); font-size: 10px; }
.territory-child-row > .icon:last-child, .territory-company-row > .icon:last-child { color: #8b96aa; }
.territory-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.territory-metrics > div { display: grid; gap: 6px; padding: 16px; border: 1px solid #e2e7ef; border-radius: 11px; background: #f8faff; }
.territory-metrics small { color: var(--muted); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.territory-metrics strong { color: #173b7a; font-size: 25px; line-height: 1; letter-spacing: -.04em; }
.territory-advanced-link { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 12px 15px; border: 1px dashed #ccd4e2; border-radius: 10px; color: var(--muted); background: rgba(255,255,255,.55); font-size: 11px; }
.territory-advanced-link a { color: var(--blue); font-weight: 750; text-decoration: none; }
@media (max-width: 760px) {
.territory-hero { align-items: flex-start; flex-direction: column; }
.territory-hero-actions { width: 100%; }
.territory-card { padding: 18px; }
.territory-card-heading { align-items: flex-start; flex-direction: column; }
.territory-data-grid, .territory-metrics { grid-template-columns: 1fr; }
.territory-data-grid > div.wide { grid-column: auto; }
.territory-child-row { align-items: flex-start; flex-wrap: wrap; }
.territory-child-row .status-badge { margin-left: 44px; }
.territory-advanced-link { align-items: flex-start; flex-direction: column; }
}
+52
View File
@@ -1296,3 +1296,55 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
/* D5.6.4 · combobox buscable global */ /* D5.6.4 · combobox buscable global */
.searchable-select{position:relative;width:100%;min-width:0}.searchable-select-native{position:absolute!important;inset:0;width:1px!important;height:1px!important;opacity:0;pointer-events:none}.searchable-select-trigger{display:flex;width:100%;min-height:41px;align-items:center;justify-content:space-between;gap:10px;padding:9px 11px;border:1px solid #d7dce5;border-radius:8px;background:#fff;color:var(--ink);font-size:13px;text-align:left;cursor:pointer;outline:none}.searchable-select-trigger:hover{border-color:#bcc6d6}.searchable-select-trigger:focus-visible{border-color:#6b95ed;box-shadow:0 0 0 3px rgba(40,100,220,.1)}.searchable-select-trigger:disabled{cursor:not-allowed;color:#9199a8;background:#f1f3f6}.searchable-select-trigger>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.searchable-select-trigger .placeholder{color:#7e8796}.searchable-select-trigger .icon{flex:0 0 auto;transform:rotate(90deg)}.searchable-select-popup{position:fixed;z-index:10000;max-height:315px;padding:6px;border:1px solid #ccd4e0;border-radius:10px;background:#fff;box-shadow:0 14px 40px rgba(18,31,53,.18)}.searchable-select-search{display:flex;align-items:center;gap:7px;padding:5px 7px 7px;border-bottom:1px solid var(--line)}.searchable-select-search input{width:100%;min-width:0;height:34px;padding:6px 8px;border:0;outline:0;background:transparent;color:var(--ink);font-size:12px}.searchable-select-options{max-height:245px;overflow:auto;padding-top:4px}.searchable-select-options>button{display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:var(--ink);font-size:12px;text-align:left;cursor:pointer}.searchable-select-options>button:hover,.searchable-select-options>button:focus-visible{background:#f2f5fa;outline:none}.searchable-select-options>button.selected{background:#eef4ff;color:#174ea6;font-weight:750}.searchable-select-options>button:disabled{cursor:not-allowed;color:#a0a7b3;background:transparent}.searchable-select-empty{padding:14px 10px;color:var(--muted);font-size:11px;text-align:center}.select-field>.searchable-select{width:100%}.survey-inline-select.searchable-select{min-width:130px;margin-top:6px;padding:0;border:0;background:transparent}.survey-inline-select.wide.searchable-select{min-width:175px;margin-top:0}.survey-inline-select .searchable-select-trigger{min-height:33px;padding:6px 8px;border-radius:7px;font-size:9px}.operational-context-selectors .searchable-select-trigger{min-height:36px;padding:7px 9px;font-size:10px}.parent-picker .searchable-select-trigger{border-radius:5px 5px 8px 8px} .searchable-select{position:relative;width:100%;min-width:0}.searchable-select-native{position:absolute!important;inset:0;width:1px!important;height:1px!important;opacity:0;pointer-events:none}.searchable-select-trigger{display:flex;width:100%;min-height:41px;align-items:center;justify-content:space-between;gap:10px;padding:9px 11px;border:1px solid #d7dce5;border-radius:8px;background:#fff;color:var(--ink);font-size:13px;text-align:left;cursor:pointer;outline:none}.searchable-select-trigger:hover{border-color:#bcc6d6}.searchable-select-trigger:focus-visible{border-color:#6b95ed;box-shadow:0 0 0 3px rgba(40,100,220,.1)}.searchable-select-trigger:disabled{cursor:not-allowed;color:#9199a8;background:#f1f3f6}.searchable-select-trigger>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.searchable-select-trigger .placeholder{color:#7e8796}.searchable-select-trigger .icon{flex:0 0 auto;transform:rotate(90deg)}.searchable-select-popup{position:fixed;z-index:10000;max-height:315px;padding:6px;border:1px solid #ccd4e0;border-radius:10px;background:#fff;box-shadow:0 14px 40px rgba(18,31,53,.18)}.searchable-select-search{display:flex;align-items:center;gap:7px;padding:5px 7px 7px;border-bottom:1px solid var(--line)}.searchable-select-search input{width:100%;min-width:0;height:34px;padding:6px 8px;border:0;outline:0;background:transparent;color:var(--ink);font-size:12px}.searchable-select-options{max-height:245px;overflow:auto;padding-top:4px}.searchable-select-options>button{display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:var(--ink);font-size:12px;text-align:left;cursor:pointer}.searchable-select-options>button:hover,.searchable-select-options>button:focus-visible{background:#f2f5fa;outline:none}.searchable-select-options>button.selected{background:#eef4ff;color:#174ea6;font-weight:750}.searchable-select-options>button:disabled{cursor:not-allowed;color:#a0a7b3;background:transparent}.searchable-select-empty{padding:14px 10px;color:var(--muted);font-size:11px;text-align:center}.select-field>.searchable-select{width:100%}.survey-inline-select.searchable-select{min-width:130px;margin-top:6px;padding:0;border:0;background:transparent}.survey-inline-select.wide.searchable-select{min-width:175px;margin-top:0}.survey-inline-select .searchable-select-trigger{min-height:33px;padding:6px 8px;border-radius:7px;font-size:9px}.operational-context-selectors .searchable-select-trigger{min-height:36px;padding:7px 9px;font-size:10px}.parent-picker .searchable-select-trigger{border-radius:5px 5px 8px 8px}
.inspection-quick-create{max-width:980px;margin-left:auto;margin-right:auto}.inspection-quick-create .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.inspection-generated-code{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--soft)}.inspection-generated-code small{color:var(--muted);font-weight:700}.inspection-generated-code strong{font-size:15px;letter-spacing:.02em}@media(max-width:760px){.inspection-quick-create .form-grid{grid-template-columns:1fr}} .inspection-quick-create{max-width:980px;margin-left:auto;margin-right:auto}.inspection-quick-create .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.inspection-generated-code{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--soft)}.inspection-generated-code small{color:var(--muted);font-weight:700}.inspection-generated-code strong{font-size:15px;letter-spacing:.02em}@media(max-width:760px){.inspection-quick-create .form-grid{grid-template-columns:1fr}}
/* Parent choices need fixed-size controls even inside a generic form field. */
.inventory-parent-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
gap: 8px;
margin: 10px 0 14px;
}
.inventory-parent-option {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-width: 0;
min-height: 42px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--ink);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
cursor: pointer;
}
.inventory-parent-option input[type="checkbox"] {
flex: 0 0 17px;
width: 17px;
height: 17px;
min-height: 17px;
padding: 0;
margin: 0;
accent-color: var(--blue);
cursor: inherit;
}
.inventory-parent-option > span {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
.inventory-parent-option:has(input:checked) {
border-color: #9db9ed;
background: #eef4ff;
}
.inventory-parent-option:has(input:focus-visible) {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.inventory-parent-option:has(input:disabled) {
cursor: default;
opacity: .65;
}