From 91f1d07e975e25723fe28358f6fa7b6b2dde07cc Mon Sep 17 00:00:00 2001 From: David Date: Sun, 25 Oct 2026 13:46:06 +0100 Subject: [PATCH] Add tests for current operating systems and workplace asset management - Implement `current-operating-systems-ui.test.py` to validate the functionality of current operating systems in the UI, including tests for source and states, computer changes, responsive design, error handling, and version saving. - Create `workplace-add-asset.test.py` to test the addition of assets to workstations, covering scenarios such as conflict resolution, responsive design on mobile, error handling during asset creation, and HTTP guards for asset management. --- .../global-applications.integration.test.ts | 116 +++++++ docs/aplicaciones-disponibles-por-compania.md | 66 ++++ ...a-sistemas-operativos-activos-y-puestos.md | 4 + ...decision-so-actual-en-activos-y-puestos.md | 110 +++++++ docs/componentes-de-interfaz.md | 28 ++ docs/mapa-visual-del-puesto.md | 105 +++++++ docs/puestos-de-trabajo.md | 6 + docs/software-del-ordenador.md | 8 + modules/assets/package.json | 2 +- .../workstation-concurrency.test.ts | 102 ++++++ .../api/application/workstation.use-cases.ts | 11 +- .../infrastructure/http/workstation.routes.ts | 14 +- modules/assets/src/common/locales/en.json | 17 +- modules/assets/src/common/locales/es.json | 17 +- .../assets/detail/types/asset-detail-props.ts | 1 + .../assets/detail/ui/asset-detail-content.tsx | 3 +- .../assets/detail/ui/asset-detail-page.tsx | 3 + .../assets/detail/ui/asset-detail.test.tsx | 9 + .../src/web/assets/detail/ui/asset-detail.tsx | 3 +- .../use-asset-picker-controller.ts | 30 +- .../controllers/use-asset-picker-data.ts | 32 ++ modules/assets/src/web/index.ts | 1 + .../src/web/shared/api/inventory-request.ts | 8 +- .../controllers/use-workstation-asset-add.ts | 157 ++++++++++ .../ui/workstation-asset-add-actions.tsx | 49 +++ .../ui/workstation-asset-add-dialog.tsx | 108 +++++++ .../rename/api/rename-workstation.api.ts | 7 +- .../shared/api/save-workstation.api.ts | 13 +- .../use-workstation-form-controller.ts | 52 ++-- .../workstation-asset-availability.test.ts | 70 +++++ .../helpers/workstation-asset-availability.ts | 35 +++ .../shared/ui/workstation-form.tsx | 9 +- .../shared/ui/workstation-save-feedback.tsx | 30 ++ modules/companies/package.json | 2 +- modules/companies/src/common/locales/en.json | 11 + modules/companies/src/common/locales/es.json | 11 + .../detail/ui/asset-operating-systems.tsx | 14 + .../companies/detail/ui/company-inventory.tsx | 4 + .../detail/ui/current-operating-systems.tsx | 90 ++++++ .../shared/current-operating-systems.test.tsx | 292 ++++++++++++++++++ .../helpers/current-operating-systems.ts | 74 +++++ .../shared/queries/use-asset-software.ts | 30 ++ .../shared/helpers/workspace-routes.ts | 7 +- .../workspace/shared/workspace-routes.test.ts | 20 ++ .../controllers/use-workplace-map-model.ts | 18 +- .../controllers/use-workstation-software.ts | 32 +- .../helpers/workplace-map-layout.test.ts | 34 ++ .../detail/helpers/workplace-map-layout.ts | 51 ++- .../detail/helpers/workplace-map-model.ts | 32 +- .../detail/ui/workplace-add-asset-node.tsx | 34 ++ .../detail/ui/workplace-compact-map.tsx | 11 +- .../detail/ui/workplace-map-canvas.tsx | 10 +- .../detail/ui/workplace-map-presentation.tsx | 33 ++ .../detail/ui/workplace-visual-map-card.tsx | 54 +++- .../detail/ui/workstation-detail-content.tsx | 11 +- .../detail/ui/workstation-detail-page.tsx | 1 + .../detail/ui/workstation-detail.test.tsx | 39 ++- .../application/resource-reference-service.ts | 5 + .../http/resource-application.routes.ts | 19 ++ .../persistence/resource-reference-reader.ts | 34 ++ .../resource-application.response.dto.ts | 10 + .../dto/shared/application-reference.dto.ts | 7 + modules/resources/src/common/locales/en.json | 6 + modules/resources/src/common/locales/es.json | 6 + .../shared/api/application-references-api.ts | 24 +- .../queries/application-reference-keys.ts | 7 +- .../queries/global-applications-keys.ts | 1 + .../use-company-applications-controller.ts | 87 ++++++ .../use-resources-page-controller.ts | 21 ++ .../list/ui/company-applications-list.tsx | 199 ++++++++++++ .../list/ui/resource-inventory-table.tsx | 169 ++++++++++ .../web/resources/list/ui/resources-page.tsx | 173 ++--------- .../shared/api/resource-related-query-keys.ts | 3 + .../shared/ui/resource-editor-dialog.tsx | 3 + scripts/company-applications-ui-audit.py | 79 +++++ scripts/current-operating-systems-ui.test.py | 226 ++++++++++++++ scripts/global-applications-ui-audit.py | 2 +- scripts/visual-audit.py | 6 + scripts/workplace-add-asset.test.py | 158 ++++++++++ scripts/workplace-visual-map.test.py | 3 +- 80 files changed, 3093 insertions(+), 296 deletions(-) create mode 100644 docs/aplicaciones-disponibles-por-compania.md create mode 100644 docs/architecture/decision-so-actual-en-activos-y-puestos.md create mode 100644 modules/assets/src/api/application/workstation-concurrency.test.ts create mode 100644 modules/assets/src/web/assets/select/controllers/use-asset-picker-data.ts create mode 100644 modules/assets/src/web/workstations/associate-assets/controllers/use-workstation-asset-add.ts create mode 100644 modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-actions.tsx create mode 100644 modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-dialog.tsx create mode 100644 modules/assets/src/web/workstations/shared/helpers/workstation-asset-availability.test.ts create mode 100644 modules/assets/src/web/workstations/shared/helpers/workstation-asset-availability.ts create mode 100644 modules/assets/src/web/workstations/shared/ui/workstation-save-feedback.tsx create mode 100644 modules/companies/src/web/assets/detail/ui/asset-operating-systems.tsx create mode 100644 modules/companies/src/web/software-installations/detail/ui/current-operating-systems.tsx create mode 100644 modules/companies/src/web/software-installations/shared/current-operating-systems.test.tsx create mode 100644 modules/companies/src/web/software-installations/shared/helpers/current-operating-systems.ts create mode 100644 modules/companies/src/web/software-installations/shared/queries/use-asset-software.ts create mode 100644 modules/companies/src/web/workstations/detail/ui/workplace-add-asset-node.tsx create mode 100644 modules/companies/src/web/workstations/detail/ui/workplace-map-presentation.tsx create mode 100644 modules/resources/src/web/resources/list/controllers/use-company-applications-controller.ts create mode 100644 modules/resources/src/web/resources/list/ui/company-applications-list.tsx create mode 100644 modules/resources/src/web/resources/list/ui/resource-inventory-table.tsx create mode 100644 scripts/company-applications-ui-audit.py create mode 100644 scripts/current-operating-systems-ui.test.py create mode 100644 scripts/workplace-add-asset.test.py diff --git a/apps/server/src/global-applications.integration.test.ts b/apps/server/src/global-applications.integration.test.ts index 4e9c5908..b051d59c 100644 --- a/apps/server/src/global-applications.integration.test.ts +++ b/apps/server/src/global-applications.integration.test.ts @@ -9,9 +9,11 @@ import { GlobalApplicationCapabilitiesSchema, GlobalApplicationTemplateResponseSchema, InventoryResponseSchema, + ListCompanyApplicationsResponseSchema, ListGlobalApplicationHistoryResponseSchema, ListGlobalApplicationsResponseSchema, ResolveApplicationReferencesResponseSchema, + ResourceResponseSchema, } from "@metis/resources/common"; import { createResourceModule, migrateResourceScope } from "@metis/resources/module"; import { QueryTypes } from "sequelize"; @@ -173,6 +175,120 @@ test("R2.3: catálogo global autorizado, historia, idempotencia, privacidad y co let product = GetGlobalApplicationAdminResponseSchema.parse(await created.json()).resource; const originalRevisionId = product.revisionId; + await t.test( + "catálogo de compañía combina ámbitos, pagina y permite altas locales aisladas", + async () => { + const path = `/resource-application-candidates/${companyId}/catalog`; + const firstResponse = await ordinary.request(`${path}?pageSize=20`); + + assert.equal(firstResponse.status, 200); + const first = ListCompanyApplicationsResponseSchema.parse(await firstResponse.json()); + const ids = first.items.map((item) => item.id); + let cursor = first.nextCursor; + + while (cursor) { + const response = await ordinary.request( + `${path}?pageSize=20&cursor=${encodeURIComponent(cursor)}` + ); + + assert.equal(response.status, 200); + const page = ListCompanyApplicationsResponseSchema.parse(await response.json()); + + ids.push(...page.items.map((item) => item.id)); + cursor = page.nextCursor; + } + assert.equal(ids.length, 51); + assert.equal(new Set(ids).size, 51); + assert.ok(ids.includes(creationId)); + assert.ok(locals.every((id) => ids.includes(id))); + const foreign = ListCompanyApplicationsResponseSchema.parse( + await ( + await ordinary.request(`/resource-application-candidates/${other}/catalog?pageSize=100`) + ).json() + ); + + assert.equal(foreign.items.length, 2); + assert.ok(foreign.items.some((item) => item.id === creationId && item.scope === "global")); + assert.ok( + foreign.items.every((item) => item.scope === "global" || item.companyId === other) + ); + assert.equal((await anonymous.request(path)).status, 401); + assert.equal( + ( + await ordinary.request( + `/resource-application-candidates/${UniqueID.generateNewID().toPrimitive()}/catalog` + ) + ).status, + 404 + ); + assert.equal((await ordinary.request(`${path}?unknown=value`)).status, 400); + assert.equal( + ( + await ordinary.request( + `/resource-application-candidates/${other}/catalog?pageSize=20&cursor=${encodeURIComponent(first.nextCursor ?? "invalid")}` + ) + ).status, + 400 + ); + for (const item of first.items) { + assert.deepEqual( + Object.keys(item).sort(), + ["id", "templateId", "name", "active", "kind", "scope", "companyId", "provider"].sort() + ); + } + const createdLocal = await ordinary.request(`/resources/${companyId}`, { + values: { + name: "Aplicación exclusiva Ñ", + templateId: template.templateId, + active: true, + notes: "Privada", + characteristics: {}, + }, + }); + + assert.equal(createdLocal.status, 201); + const local = ResourceResponseSchema.parse(await createdLocal.json()).resource; + const found = ListCompanyApplicationsResponseSchema.parse( + await (await ordinary.request(`${path}?search=exclusiva`)).json() + ); + + assert.equal(found.items.length, 1); + assert.equal(found.items[0]?.scope, "company"); + assert.equal(found.items[0]?.companyId, companyId); + const absent = ListCompanyApplicationsResponseSchema.parse( + await ( + await ordinary.request( + `/resource-application-candidates/${other}/catalog?search=exclusiva` + ) + ).json() + ); + + assert.equal(absent.items.length, 0); + assert.equal( + (await ordinary.request(`/resources/${companyId}/${local.id}/status`, { active: false })) + .status, + 200 + ); + const inactive = ListCompanyApplicationsResponseSchema.parse( + await (await ordinary.request(`${path}?active=false`)).json() + ); + + assert.deepEqual( + inactive.items.map((item) => item.id), + [local.id] + ); + const active = ListCompanyApplicationsResponseSchema.parse( + await (await ordinary.request(`${path}?active=true&search=exclusiva`)).json() + ); + + assert.equal(active.items.length, 0); + assert.equal( + (await ordinary.request(`/resources/${companyId}/${local.id}/delete`, {})).status, + 200 + ); + } + ); + await t.test( "scope y plantilla persistidos, autorización real y proyección mínima", async () => { diff --git a/docs/aplicaciones-disponibles-por-compania.md b/docs/aplicaciones-disponibles-por-compania.md new file mode 100644 index 00000000..9013154a --- /dev/null +++ b/docs/aplicaciones-disponibles-por-compania.md @@ -0,0 +1,66 @@ +# Aplicaciones disponibles por compañía + +En Recursos, la pestaña **Aplicación** combina el catálogo global con las aplicaciones +particulares de la compañía. El origen distingue productos homónimos, sin fusionarlos +ni copiar los globales. El resto de pestañas y el inventario particular mantienen sus +contratos de compañía. + +El botón **Añadir aplicación de compañía** abre el formulario existente con la plantilla +de aplicación preseleccionada. El servidor guarda el recurso únicamente en la compañía +de contexto. Las globales abren su ficha de catálogo; las particulares conservan sus +acciones de consulta, edición, activación, desactivación y borrado. + +`GET /api/resource-application-candidates/:companyId/catalog` exige sesión y valida la +existencia de la compañía antes de consultar datos. Devuelve resúmenes públicos de +aplicaciones globales y propias, sin notas, historial ni datos de otras compañías. +Utiliza los criterios de búsqueda, estado, orden y paginación del catálogo global, +con cursor ligado a compañía y filtros. Las plantillas propias con uso `application` +también se incluyen; se excluyen sistemas operativos y licencias. + +La UI utiliza `CursorEntityTable`, mantiene los criterios en la URL y reinicia la página +al cambiar búsqueda, orden, estado o tamaño. El proveedor ausente se representa con `—`. +Los cambios locales invalidan el catálogo de esa compañía y sus candidatos; los cambios +globales invalidan los catálogos combinados. No se guarda una segunda copia del estado remoto. + +Los selectores existentes `ResourceApplicationSelect` y `SoftwareApplicationSelect` +consultan candidatos activos de ambos ámbitos, conservando sus reglas por propósito: +configuraciones de perfil solo aceptan aplicaciones; instalaciones admiten además SO locales. +Para asociar globales deben estar habilitados `RESOURCE_GLOBAL_CATALOG_ENABLED`, +`RESOURCE_GLOBAL_SOFTWARE_ENABLED`, `RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED` y +`RESOURCE_GLOBAL_PROFILE_ENABLED`, tras migrar el esquema. Consultar no equivale a habilitar +asociaciones y las lecturas históricas permanecen disponibles con los gates cerrados. + +La integración se comprueba con `python3 scripts/resource-scope-test.py --catalog`, +`--software` y `--profiles`. El recorrido de interfaz se reproduce mediante +`python3 scripts/visual-audit.py --company-applications --check`: utiliza exclusivamente +una instalación desechable `metis_desk_test`, con 15 compañías, al menos 50 personas, +activos, perfiles, puestos y aplicaciones propias en la principal, y 60 aplicaciones globales. +No utiliza datos reales ni credenciales del entorno habitual. + +Skills consultadas: `ux-tables` para procedencia, acciones y paginación; `shadcn` para +integrar los componentes instalados; `rdx-solid` para contratos, aislamiento y pruebas; +`vercel-react-best-practices` para derivar datos y mantener el estado remoto en TanStack Query. +Los auditores especializados no están instalados; las verificaciones disponibles se +realizan mediante Playwright MCP, sin instalar dependencias adicionales. + +## Verificación de la entrega + +- Typecheck dirigido de Resources, Server y Companies: correcto. +- Biome y ESLint sobre los archivos del cambio, sintaxis Python y `git diff --check`: correctos. +- Pruebas de Resources: 105 correctas. Prueba de rutas: correcta. +- Integraciones desechables: catálogo 12, software 13 y perfiles 11 comprobaciones correctas. +- Playwright MCP: alta local desde el formulario, aislamiento entre compañías, búsqueda, + paginación, vacío, error/reintento, selectores de software y perfil, Escape y retorno del foco. + Cuatro anchos: 1440, 1280, 768 y 390 px; capturas amplias y estrechas inspeccionadas. + Informe reproducible: `.artifacts/playwright/authenticated-dhutc3tz/company-applications-report.json`. +- `pnpm build`: correcto; se conserva el aviso de tamaño de chunks. +- `pnpm typecheck` global: bloqueado por el tipo opcional `locale` de + `packages/shadcn-ui/src/components/calendar.tsx`, fuera del alcance y vendor. +- `pnpm test` completo, sobre una base desechable: falla en las aserciones de textos + de compañía/persona de `companies-page.test.tsx`, ajenas a los recursos de aplicaciones. +- `pnpm lint` y `pnpm check` globales: fallos de estilo en código concurrente de fotos + de personas y procesos de Companies. No se han modificado esos archivos para esta tarea. + +En la instalación habitual se han habilitado los gates existentes y reiniciado el entorno. +La comprobación final de solo lectura en Abeto Design devuelve las 40 aplicaciones +globales y mantiene sus 107 buzones. No se han creado aplicaciones de prueba en la base habitual. diff --git a/docs/architecture/auditoria-sistemas-operativos-activos-y-puestos.md b/docs/architecture/auditoria-sistemas-operativos-activos-y-puestos.md index 21abac1d..1d6dc9ad 100644 --- a/docs/architecture/auditoria-sistemas-operativos-activos-y-puestos.md +++ b/docs/architecture/auditoria-sistemas-operativos-activos-y-puestos.md @@ -331,3 +331,7 @@ No se ejecutaron `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm check` ni `pn Volumen realmente utilizado: fixtures pequeños en memoria definidos por las pruebas, sin carga de compañías/activos/personas/recursos en BD. El escenario aislado de 15 compañías y al menos 50 personas, activos y recursos de una principal queda pendiente para una futura validación funcional/visual; no se declara ejecutado aquí. Tampoco se midieron rendimiento, accesibilidad o navegación real. Entrega autorizada: un informe Markdown. **No se implementó ninguna recomendación, modelo, endpoint, migración o cambio de componente.** + +## 17. Decisión posterior a la auditoría + +El 25 de octubre de 2026 el usuario aprobó implementar el resumen derivado del SO actual en activos y puestos, reutilizando SoftwareInstallation y Resources. El alcance, implementación y validaciones posteriores se documentan en [Decisión aprobada — SO actual en activos y puestos](decision-so-actual-en-activos-y-puestos.md). Esta sección no modifica retrospectivamente los hallazgos ni las validaciones originales del informe. diff --git a/docs/architecture/decision-so-actual-en-activos-y-puestos.md b/docs/architecture/decision-so-actual-en-activos-y-puestos.md new file mode 100644 index 00000000..010a21bd --- /dev/null +++ b/docs/architecture/decision-so-actual-en-activos-y-puestos.md @@ -0,0 +1,110 @@ +# Decisión aprobada — SO actual en activos y puestos + +Fecha: 25 de octubre de 2026. Decisión funcional aprobada por el usuario para esta intervención. Complementa la [auditoría arquitectónica](auditoria-sistemas-operativos-activos-y-puestos.md), cuyos hallazgos originales se conservan. + +## Decisión y alcance + +El SO de fábrica permanece en las características de Assets. El producto SO sigue siendo un recurso particular `os` de Resources; su instalación y revisiones pertenecen a `SoftwareInstallation` de Companies. La ficha de activo y la del puesto muestran una proyección compartida de esas instalaciones. No se añaden persistencia, entidades, migraciones, estados, derechos de licencia, requisitos de perfil ni catálogo global de SO. + +La selección exige simultáneamente compañía y activo correctos, estado `configured` y referencia pública resuelta de tipo `os`. Se excluyen `pending`, `removal_pending`, `removed` y `never_configured`. Un producto inactivo que el contrato permite resolver conserva su información y el aviso de revisión del registro; no se relajan permisos ni pertenencia. + +Se admiten todos los productos configurados diferentes que permite la unicidad existente por pareja activo/recurso. El orden es por nombre con locale explícito `en` y UUID como desempate. Se muestra la versión de instalación salvo cuando el texto completo ya figura en el nombre, delimitado por espacios o signos y comparado sin diferencias de mayúsculas, espacios o representación Unicode. No se confunde `1` con `11` ni `24.04` con `24X04`. La comparación afecta solo a la presentación: no cambia nombres, ediciones ni versiones guardadas y no incorpora reglas por fabricante. + +## Presentación + +- Activo: tarjeta **Sistema operativo actual** después de sus características técnicas, antes de notas e información adicional. Los campos originales de identificación y características conservan su orden. Companies aporta la tarjeta al slot `technicalSummary` de la API web pública de Assets, que no importa Companies. +- Puesto: la misma tarjeta antes del bloque de gestión de software, calculada a partir del principal validado `computerAssetId`. Cambiarlo selecciona otras instalaciones sin mover registros, licencias o historial. +- Varios productos: título plural y lista completa con enlaces; una o dos columnas según capacidad del contenedor. No se reduce a una etiqueta que oculte productos. +- Sin SO configurado: **No registrado / Not recorded**. No significa ausencia física de SO. Los campos `factory_os` y `factory_preloaded_os` no participan en esta lectura. +- Carga inicial o resolución necesaria pendiente: skeleton con estado accesible. Error de instalaciones o de resolución necesaria: mensaje con Reintentar, incluso si hay caché previa; no se presenta como vacío ni como dato vigente confirmado. +- Referencia configurada no resoluble o sin nombre utilizable: aviso de referencia de software no disponible, conservando los SO que sí se resuelven. Sin metadatos no se puede saber si la referencia desconocida era SO o aplicación: se evita afirmar «No registrado» en ese caso. + +El criterio es independiente de la plantilla del activo. Funciona en portátil, sobremesa, servidor sin puesto y plantilla propia; cualquier otro activo puede mostrar un SO si existe una instalación explícita `os`. No se infiere SO a partir del firmware, el nombre o la categoría. Para activos sin registros se aplica el mismo estado «No registrado», sin una nueva taxonomía de elegibilidad. + +La gestión conserva el panel y formulario de instalaciones de la ficha del activo y el diálogo existente del puesto, incluyendo estados, versión, licencia e historial. No se incorpora otro formulario. El mapa sigue mostrando software abierto con su estado, incluidas configuraciones pendientes: **no es una lista de SO actuales**. Comparte la selección de ámbito/activo y conserva una tarjeta por instalación; la lista completa de SO actuales permanece en el resumen. + +## Código y fronteras + +| Área | Archivos y responsabilidad | +| --- | --- | +| Selección pura | `modules/companies/src/web/software-installations/shared/helpers/current-operating-systems.ts`: ámbito, activo, estados, tipo, referencia no disponible, orden y versión visible. | +| Lectura compartida | `software-installations/shared/queries/use-asset-software.ts`: `softwareKeys.company`, `softwareApi.get` y `useSoftwareReferences` público de Resources. No copia estado remoto ni usa efectos de sincronización. | +| Presentación | `software-installations/detail/ui/current-operating-systems.tsx`: tarjeta común con DetailPanel, Skeleton, Button y BrowserLink ya instalados; traducciones es/en y tokens semánticos. | +| Integración de activo | `assets/detail/ui/asset-operating-systems.tsx` y `companies/detail/ui/company-inventory.tsx` en Companies; slot ReactNode de `AssetDetailPage`/`AssetDetailContent`/`AssetDetail` en Assets. | +| Integración de puesto | `use-workstation-software.ts`, `workstation-detail-content.tsx` y `use-workplace-map-model.ts`; el mapa resuelve los nombres con las mismas referencias públicas y reutiliza `getAssetSoftwareInstallations` en `workplace-map-model.ts`. | +| Pruebas | `current-operating-systems.test.tsx`, regresiones de ficha de activo/puesto y `scripts/current-operating-systems-ui.test.py`. La suite nueva se añade al comando de Companies. | + +Las rutas de servidor, DTO HTTP, dominio y repositorios permanecen sin cambios de esta intervención. Se consume el resolver por propósito `software-installation`, que admite SO particulares y aplicaciones según sus garantías vigentes. El árbol ya contenía trabajo concurrente en Resources y continuó cambiando durante la tarea; esos cambios no se atribuyen a esta decisión ni se revierten. + +La caché se comparte con la gestión existente. Guardar una instalación invalida la query de compañía y las referencias por propósito mediante el formulario existente; ambos resúmenes se recalculan durante render. El cambio de principal filtra inmediatamente por el nuevo UUID y resuelve sus productos. Los identificadores de instalación, licencia y revisiones no cambian por ese cambio de contexto. + +## Validación técnica + +| Comprobación ejecutada | Resultado | +| --- | --- | +| Selección dirigida con `node --import tsx --test --test-isolation=none` | 75/75: nueva suite SO, panel/formulario software, ficha de puesto, mapa/layout, ficha de activo y dominio SoftwareInstallation. | +| `pnpm --filter @metis/assets test` | 27 archivos correctos; el runner agrupa por archivo. | +| `pnpm --filter @metis/companies test` | 49/50 archivos correctos. El archivo `companies-page.test.tsx` falla en expectativas de contador de personas y ayuda de formulario de persona, fuera de este cambio. Su ejecución individual confirma esas dos expectativas; no se modifican para ocultarlas. | +| `python3 scripts/resource-scope-test.py --software --regressions` | 13/13 de software HTTP/MariaDB y 50/50 regresiones; contenedor propio `metis_desk_test` retirado. | +| Typechecks dirigidos de Assets, Companies y Web | Correctos. | +| Biome y ESLint dirigidos a archivos propios | Correctos. | +| `pnpm build` | Correcto; conserva aviso de bundle web mayor de 500 kB. | +| `pnpm typecheck` global | Falla en vendor `packages/shadcn-ui/src/components/calendar.tsx:165`, TS2375 por `locale`; vendor sin cambios propios. | +| `pnpm lint` y `pnpm check` globales | Mantienen diagnósticos fuera de los archivos propios, incluidas convenciones/skills y código previo/concurrente. No se declara el árbol global validado. | + +No se ejecuta un `pnpm test` global que pudiera consumir configuración de BD habitual. La integración SQL usa el runner desechable; las suites web/dominio dirigidas no usan BD. Los resultados históricos de la auditoría anterior no se reutilizan como pruebas nuevas. + +## Evidencia interactiva y reproducción + +Entorno preparado con `bash scripts/setup-playwright-wsl.sh` y `python3 scripts/visual-audit.py`, siguiendo [Playwright MCP](../playwright-mcp.md). Se usa el servidor MCP Docker fijado en `.codex/config.toml` y el cliente stdio existente; no se instala Chromium ni se crea configuración global. Origen Vite temporal verificado: `http://localhost:36037`. Se inicia sesión por el formulario real como operador ordinario y solo se persiste sobre la BD desechable `metis_desk_test`. + +La preparación reproducible utiliza `visual-audit-volume.py`, `workstation-dashboard-fixtures.py` y después `current-operating-systems-ui.test.py`, pasando `METIS_PLAYWRIGHT_STORAGE_STATE` únicamente como indica el procedimiento. Esta última comprueba ruta privada y origen del preparador, conserva fixtures solo dentro de ese entorno y no permite elegir una BD existente. Las credenciales/cookies no se incluyen en documentación, capturas ni Git. + +Volumen preparado: 15 compañías, 59 personas, al menos 143 activos y 76 recursos en la principal; puesto de densidad con 80 miembros y 60 perfiles. Los casos adicionales de SO incluyen portátil con FreeDOS original, sobremesa Windows, servidor sin puesto, plantilla de activo propia, activo sin SO de fábrica, fábrica sin instalación actual, pendiente, retirada, dos SO, aplicación convencional y un segundo ordenador. + +La regresión MCP del mapa (`scripts/workplace-visual-map.test.py`) pasa **7 recorridos**, incluyendo seis anchos, densidad, navegación/gestión, errores, principal, refresco, contraste y movimiento reducido. Sus evidencias se conservan bajo el directorio de la ejecución desechable; no se confunde su software abierto con el resumen nuevo. Tras compartir también los nombres del resolver, se repitieron estados parciales y refresco; el último caso pasa con la fixture coherente actualizada. + +El recorrido nuevo completo pasa **7/7 pruebas MCP** (`ui-final.log`), con las siguientes evidencias: + +| Comprobación | Evidencia observada | +| --- | --- | +| OSUI-01–07, 12–14, 17–18 | Origen FreeDOS/Windows independiente, configurado frente a pendiente/retirado, dos SO sin aplicaciones, servidor sin puesto y plantilla propia, leídos por API real y comprobados en la ficha. | +| OSUI-08 | Sustitución del principal: el resumen pasa al otro SO y las instalaciones/licencias/revisiones antes y después son idénticas por HTTP. | +| OSUI-09/15 | Guardado en el formulario existente: aparece `Versión 25H2` en el resumen sin recargar. | +| OSUI-10/11 | Respuesta de referencia `unavailable` y HTTP 500 interceptados por MCP: aviso/error con reintento, sin «No registrado»; al retirar la interceptación se recupera. | +| Responsive y temas | Activo y puesto: 320, 390, 768, 1024, 1280 y 1600 px, en claro y oscuro; 24 combinaciones con dos productos y nombres largos, sin overflow de la tarjeta. | +| Contraste local | Mínimo medido de texto/enlaces: 4,648:1 en claro y 4,749:1 en oscuro, sobre fondos efectivos de la tarjeta. No es una certificación de toda la aplicación. | +| Teclado/foco | Los enlaces del resumen reciben foco y Tab recorre ambos, con outline visible. | +| Carga | Skeleton con nombre accesible, capturado durante el reintento inicial de un HTTP 503 sintético; después se retira la interceptación y se resuelve la lectura real. | +| OSUI-16 | Selector real de idioma: tarjeta traducida a inglés; además, render es/en en las pruebas dirigidas. | +| Zoom | Reflow con CSS `zoom: 2` y sin overflow de la tarjeta. No se ha controlado ni se declara probado el zoom nativo de la interfaz del navegador. | + +Directorio local excluido de Git: `.artifacts/playwright/authenticated-2jopd8ii/current-operating-systems/`. `manifest.json` registra dimensiones y SHA-256 de las capturas; `measurements.json` y `ui-final.log` conservan los resultados. Muestras abiertas e inspeccionadas: `asset-light-320.png`, `asset-dark-1600.png`, `workstation-dark-320.png`, `saved-version.png`, `workstation-english.png`, `workstation-loading.png` y `asset-error.png`. Las rutas no contienen sesión: el archivo privado de autenticación vive separado y se elimina al cerrar el preparador. + +La regresión del mapa se amplía para que los nombres sintéticos largos coincidan tanto en inventario como en el resolver público; no se simulan dos nombres contradictorios del mismo producto. Las primeras iteraciones del procedimiento de carga fallaron por manejo de rutas MCP y una etiqueta incorrecta del selector de idioma; se corrigió el procedimiento y el recorrido completo final pasa. No se alteró código de producción para ocultar esos fallos de prueba. + +Las capturas nuevas corresponden a la tarjeta de SO dentro del viewport indicado, no a una auditoría de toda la página. Se abren muestras representativas para revisión visual. No se certifica accesibilidad global, comportamiento de lectores de pantalla ni cumplimiento WCAG completo. Las limitaciones generales de las fichas ajenas al resumen —por ejemplo acciones extensas de otros paneles en móvil— no se corrigen en esta intervención. + +## Skills consultadas + +Principal: `vercel-composition-patterns`, según la matriz del proyecto, para componente compartido y composición desde Companies mediante un slot de Assets. `vercel-react-best-practices` orienta cálculo derivado durante render y reutilización de la caché TanStack existente, sin sustituirla por SWR. `shadcn` se aplica consultando las APIs locales reales de DetailPanel, Skeleton y Button; el intento `pnpm dlx shadcn@latest docs skeleton button` falla por DNS `EAI_AGAIN`, por lo que no se atribuye lectura de documentación remota. No se modifica vendor ni se instala ninguna dependencia del proyecto. + +`rdx-solid` mantiene la solución dentro de los agregados y contratos actuales. La consulta puntual de `ui-ux-pro-max` sobre textos largos respalda `min-w-0` y wrapping de tokens, subordinados a DESIGN. El contraste, foco y responsive se contrastan mediante navegador, no se deducen de las skills. No falta ninguna skill necesaria para la implementación; no se instalan auditores adicionales. + +## Limitaciones y siguientes decisiones + +Se conserva la consulta completa de instalaciones de compañía y el límite de una pareja abierta por activo/producto. No se añaden instancias de arranque, procedencia/verificación persistida, ausencia confirmada, reconciliación con perfiles ni capacidad de licencias. La duplicidad de fábrica del servidor queda pendiente para otra intervención. + +La clasificación de una referencia inaccesible es desconocida; el aviso es deliberadamente de software y no inventa un SO. Las ediciones permanecen en el recurso y no se reconstruyen versiones comerciales desde nombres. La auditoría de historia inmutable, snapshots de producto/licencia y mejora de consultas por volumen también permanecen fuera del alcance aprobado. + +## Cierre del entorno + +El preparador se cerró con SIGINT tras identificar exclusivamente su proceso por los archivos de esta ejecución. Confirmó la retirada de servicios, contenedor/base y sesión temporales. Las 30 capturas y los logs de evidencia permanecen sin credenciales. El código de salida del preparador fue cero; no se detuvo ni modificó la base habitual. + +## Revalidación posterior del árbol de trabajo + +El 25 de octubre de 2026 se revisó de nuevo la integración conservada en el árbol de trabajo. No se volvió a implementar el resumen ni se modificó código de producción. La selección de compañía/activo, `configured` y recurso `os`, los estados de interfaz y la composición de activo/puesto siguen presentes. + +Se ejecutaron con `node --import tsx --test --test-isolation=none` las suites `current-operating-systems.test.tsx`, `software.test.tsx`, `asset-detail.test.tsx` y `software-installation.test.ts`: **19/19 casos correctos**. Se verificaron también los hashes de las 30 capturas conservadas y la ausencia del archivo privado de sesión; no se repitió el recorrido de navegador ni la integración SQL en esta revalidación. + +`pnpm --filter @metis/companies typecheck` registra ahora un fallo en el trabajo concurrente de asociación de activos: `modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-dialog.tsx:90`, TS2322. La llamada a `ContextHelp` aporta `children`, pero su contrato requiere `label`, `title` y `description`. Ese archivo no pertenece a la implementación del resumen y no se modifica en esta revisión. El resultado anterior de los typechecks dirigidos corresponde a su ejecución previa; no acredita que el árbol concurrente actual esté libre de errores. diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 935e6ec0..475e15ee 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -1491,6 +1491,15 @@ vendor ni el significado de los estados de negocio. Incluye las celdas de accion ### Selección de aplicaciones por propósito — R2.5 +La pestaña **Aplicación** de Recursos de compañía utiliza `CompanyApplicationsList` y +`CursorEntityTable` para consultar las aplicaciones globales y las propias, sin copiarlas +al inventario particular. Presenta origen, proveedor y estado, con búsqueda y filtros +remotos. Las globales enlazan al catálogo; las particulares conservan edición, estado y +borrado en su compañía. El alta contextual reutiliza `ResourceEditorDialog` con +`initialKind="application"`. Las invalidaciones de recursos locales y del catálogo global +actualizan tanto este listado como los candidatos de los selectores. Véase +[aplicaciones disponibles por compañía](aplicaciones-disponibles-por-compania.md). + `ResourceApplicationSelect` y su picker privado comparten búsqueda, cursores, procedencia y selección provisional entre instalaciones y configuraciones de perfil. El consumidor aporta `purpose`; Resources filtra los candidatos. El @@ -1599,3 +1608,22 @@ compartida, composición del badge y contratos con semántica de cada pantalla. #### Refinamiento del mapa — Iteración 2 El mapa diferencia dimensiones de principal, secundarios y software. `WorkplaceSoftwareHeadingNode` rotula el grupo sin representar un recurso ni recibir foco; `WorkplaceMapCoverage` indica elementos fuera del preview solo con una fuente conocida y conduce a las colecciones existentes. La leyenda se limita a Principal, Asociado al puesto y Software del principal. La altura se deriva de los bounds de dagre; un refetch sin cambio de geometría conserva el canvas. Contratos y evidencia comparativa: [refinamiento visual del mapa](mapa-visual-del-puesto.md#refinamiento-visual--iteración-2). + +## Resumen de SO actual en activo y puesto + +`CurrentOperatingSystems`, en Companies (`software-installations/detail/ui`), compone DetailPanel, BrowserLink, Skeleton y Button existentes para mostrar el resultado de `useAssetSoftware` y `getCurrentOperatingSystems`. Maneja cero, uno o varios productos, versiones, referencias no disponibles y error recuperable. El componente es específico del software; no se incorpora lógica de dominio a rdx-ui ni a vendor. + +Assets expone el slot presentacional `technicalSummary` de su ficha después de las características técnicas. Companies aporta `AssetOperatingSystems` desde la composición de `CompanyInventory`, manteniendo Assets independiente de consultas/DTO de instalaciones. El puesto consume el mismo componente con su lectura del principal. Se conservan formularios, transiciones y caché existentes. Contrato funcional y evidencias: [SO actual](architecture/decision-so-actual-en-activos-y-puestos.md). + +### Asociación de activos desde el mapa — WD04.4 + +`WorkplaceAddAssetNode` compone un botón neutro discontinuo, compartido por React Flow y +`WorkplaceCompactMap`. Su identidad `presentation:add-asset` participa únicamente en +Dagre: no tiene aristas de negocio ni modifica contadores. `WorkplaceMapPresentation` +selecciona canvas o lectura compacta por el ancho efectivo. + +Assets publica `WorkstationAssetAddDialog`, con `AssetPickerList`, el asistente real +`AssetEditorDialog` y confirmación separada de la asociación. `WorkstationSaveFeedback` +unifica la recuperación de revisión obsoleta con el editor tradicional. El módulo +consumidor aporta autorización, compañía, puesto y lookups; los nodos solo reciben +callbacks. Contratos, pruebas y limitaciones: [WD04.4](mapa-visual-del-puesto.md#wd044--asociación-de-activos-desde-el-mapa-visual). diff --git a/docs/mapa-visual-del-puesto.md b/docs/mapa-visual-del-puesto.md index c1c66895..3e06aab0 100644 --- a/docs/mapa-visual-del-puesto.md +++ b/docs/mapa-visual-del-puesto.md @@ -237,3 +237,108 @@ La primera banda prioriza la misma jerarquía del mockup, pero mantiene los toke La leyenda se sitúa debajo para no competir con el nodo principal ni reducir el ancho útil. El reparto bilateral depende de capacidad; las ventanas intermedias conservan una columna de asociados a la izquierda. Se mantienen los límites 2/3 y el resto se consulta en sus colecciones. Quedan fuera de la validación lectores de pantalla concretos, ampliación exclusiva de texto al 200 %, alto contraste del sistema y modo oscuro. La medición de textos en claro y las pruebas de teclado no equivalen a certificar WCAG. Una mejora futura puede medir alturas a partir del contenido ampliado y ampliar esa matriz de accesibilidad; esta entrega no añade un motor de layout, virtualización, persistencia o edición de diagramas. + +## WD04.4 — Asociación de activos desde el mapa visual + +### Objetivo y equivalencia funcional + +El mapa ofrece «Añadir activo» como otra entrada a la edición de la composición de Workstation. Utiliza `saveWorkstation`, el mismo `POST /api/assets/:companyId/workstations/:id/update`, el mapper de referencias y la transacción de inventario existentes. Conserva nombre, estado, sede, responsable, miembros anteriores y ordenador principal. La ausencia de principal se conserva como la ausencia opcional que devuelve actualmente el contrato; añadir un ordenador no lo convierte en principal. + +No se añaden entidades, tablas, migraciones, endpoints del diagrama ni dependencias. Las asociaciones, restricciones e historial siguen perteneciendo a Assets. No se modifican las auditorías históricas ni Resources Global Scope. + +### Componentes y composición + +| Unidad | Responsabilidad | +| --- | --- | +| `WorkplaceAddAssetNode` | Botón semántico con Plus, borde discontinuo y tokens neutros. Nombre accesible «Añadir activo al puesto» y texto secundario «Seleccionar o crear». Reutilizado en canvas y presentación compacta. | +| `WorkplaceMapPresentation` | Selecciona React Flow o `WorkplaceCompactMap` según el ancho real; mantiene la carga diferida del canvas. | +| `workplaceMapLayout` | Incluye `presentation:add-asset` en Dagre junto al equipamiento. Los enlaces internos de distribución no se devuelven como aristas. El software queda debajo de todo el conjunto de equipamiento. | +| `WorkstationAssetAddDialog` | Diálogo Picker List de Assets, exportado mediante su API pública. Companies aporta puesto, compañía, lookups y destino del foco. | +| `useWorkstationAssetAdd` | Selección, snapshot de revisión, alta relacionada, guardado y recuperación de lectura. Los nodos no conocen HTTP ni consultas. | +| `WorkstationAssetAddActions` | Pie de creación, cancelación y confirmación; bloquea acciones durante las operaciones pendientes. | +| `workstationAssetUnavailable` | Regla de disponibilidad extraída del editor existente y compartida con el nuevo flujo. | +| `WorkstationSaveFeedback` | Error y recuperación explícita de conflictos, compartidos por el picker y el editor tradicional. | + +El nodo de acción no tiene identidad de Asset, no entra en el adaptador de activos, en `assetIds`, en los contadores ni en el historial. No tiene estados técnicos ni enlace de ficha. El límite visual continúa siendo principal + dos asociados y tres elementos de software; la acción se añade aparte. «Ver todo el equipamiento» conserva acceso a los miembros ocultos. + +Dagre conserva un layout determinista, sin coordenadas persistidas. La distribución admite dos lados desde 720 px efectivos, evitando alargar innecesariamente el mapa al incorporar la acción. El canvas conserva la política existente de cámara estable: se recalcula cuando cambian dimensiones o composición visible, sin ejecutar `fitView` por cada refetch o cambio de estado del diálogo. Continúan desactivados drag, conexiones, reconexiones y borrado. + +### Picker, búsqueda y alta de activos + +Se reutilizan `AssetPickerList`, `AssetList`, `useAssetPickerData` (consultas extraídas del controller existente y compartidas por ambas selecciones), `FormDialog layout="picker"`, `PickerHelp` y `AssetEditorDialog`/`AssetForm` con su asistente real. No se duplica el formulario ni se omite la plantilla. La lista utiliza las consultas y cachés existentes de inventario, catálogo e infraestructura. El selector actual de activos es local sobre la respuesta existente del inventario; mantiene sus filtros por plantilla, búsqueda, columnas y paginación. No se introduce una descarga alternativa ni se sustituye un selector remoto por otro local. + +Solo se ofrecen activos de la compañía actual y se excluyen los miembros del snapshot abierto. Los activos no operativos u ocupados por otro puesto activo aparecen bloqueados conforme a las mismas reglas del editor. En puestos inactivos se mantiene la disponibilidad que ya admite el dominio. Se respeta el máximo de 200 miembros; el nodo deshabilitado informa del límite y la gestión existente permite retirar equipamiento. + +El alta se abre anidada: cancelarla conserva búsqueda y selección y devuelve el foco a «Crear activo nuevo». Al guardar, el activo permanece en inventario y vuelve seleccionado al picker. La búsqueda utiliza su nombre real para hacerlo visible incluso cuando el inventario tiene varias páginas. La asociación exige después «Añadir al puesto». Cancelar esa asociación no elimina el activo creado. + +### Revisión, persistencia e historial + +La inspección detectó que el guardado bloqueaba la compañía y las reservas, pero no verificaba la revisión que el usuario había editado. Se incorpora una precondición opcional `If-Match: ""` al guardado existente, sin cambiar el cuerpo `{ values }`. Se compara con la última revisión **dentro de la transacción y después de adquirir los bloqueos existentes**. Si difiere, devuelve `409 WORKSTATION_CHANGED` antes de modificar la composición o generar historial. + +El picker conserva la revisión de apertura aunque haya refetch en segundo plano. El editor tradicional captura también su revisión al abrir y el renombrado envía la revisión que acaba de leer. «Actualizar puesto y revisar selección» recupera el estado actual; el usuario revisa y confirma de nuevo. No hay rebase automático ni overwrite silencioso. Los clientes que no envían la cabecera conservan el contrato previo; no se ha convertido en una precondición obligatoria para integraciones existentes. + +Una asociación válida sigue creando la revisión normal de Workstation. El servidor continúa validando pertenencia a compañía, duplicados, estado, reservas y máximo de miembros. No se escribe el nodo de acción ni se cambia el principal. La comprobación unitaria de concurrencia verifica también que un guardado semánticamente idéntico con la revisión actual no añade historial. + +### Permisos y estados + +Se comprobó `createAssetModule`, `registerWorkstationRoutes` y `requireAuthenticated`: la edición de Workstation utiliza actualmente la misma sesión autenticada que la consulta del inventario; no existe un permiso de composición distinto ni un rol de lectura de puestos. Se reutiliza ese límite real, sin inventar permisos ni excluir arbitrariamente al usuario ordinario. El consumidor aporta `canEdit` y el mapa oculta la acción cuando es falso o el inventario no está disponible. El servidor sigue siendo la autoridad. Las pruebas SSR cubren `canEdit=false` y el preparador/recorrido HTTP comprueban `401` sin sesión. **No se afirma una prueba con un rol de solo lectura que el contrato actual no ofrece.** + +El puesto sin activos conserva su información de vacío y ofrece la acción tanto en desktop como en móvil. Sin principal se representan los miembros existentes, sin fabricar un ordenador central. Los errores parciales del dashboard mantienen su degradación anterior. + +| Situación | Recuperación | +| --- | --- | +| Fallo de lectura del selector | Estado de error de la lista y Reintentar, manteniendo abierto el diálogo y la búsqueda. | +| Fallo de validación/asociación | Mensaje anunciable, selección conservada y posibilidad de reintentar; el mapa no se modifica de forma optimista. | +| Cambio concurrente | Rechazo del servidor y actualización explícita antes de confirmar otra selección. | +| Fallo del formulario de alta | El formulario real conserva su validación y su gestión de errores. | +| Alta correcta y asociación fallida/cancelada | El activo permanece en inventario; no se vuelve a crear ni se elimina como compensación. | +| Asociación confirmada y refetch fallido | Se informa de que se guardó, el diálogo permanece abierto y Reintentar ejecuta exclusivamente la lectura. | + +El guardado correcto invalida únicamente `inventoryKeys.company(companyId)` y obtiene el inventario actual antes de cerrar. Se regeneran el modelo, los contadores y Dagre desde la respuesta leída. El retorno de foco utiliza el disparador original si sigue conectado o el botón actual del mapa si React Flow lo ha reemplazado. + +### Responsive y accesibilidad + +En menos de 640 px efectivos se utiliza la presentación compacta con la misma acción antes del software. Se comprobaron 1600, 1440, 1280, 1024, 768 y 390 px, incluyendo el ancho real con las navegaciones actuales. Los diálogos mantienen altura estable, búsqueda enfocada, selección en la primera columna y scroll interno; no aparece overflow horizontal global. + +El botón es accesible mediante ratón, Enter y Espacio, con foco visible, estado disabled y texto de límite. Los errores usan `role="alert"`; la lectura posterior al guardado informa mediante `role="status"`. Base UI gestiona Escape, foco inicial y aislamiento/restauración entre diálogos; no se añaden listeners o focus traps manuales. La acción tiene `pointer-events-auto` para seguir recibiendo clics con los nodos de React Flow no editables. + +Skills consultadas: `ux-inputs-and-forms` como principal para el flujo; `shadcn`, `frontend-design`, `vercel-composition-patterns` y `vercel-react-best-practices` para reutilización de primitivas, composición y estado. Se conservan los criterios de `ux-dashboard`/`ux-tables` aplicados en las iteraciones anteriores. No están disponibles los auditores especializados sugeridos por las skills; se utilizan las comprobaciones reales de teclado y navegador disponibles, sin instalar dependencias. + +### Pruebas y evidencias WD04.4 + +Instalación desechable preparada con `scripts/visual-audit.py`, exclusivamente `metis_desk_test`, autenticada mediante el formulario real con un usuario ordinario. `visual-audit-volume.py` y `workstation-dashboard-fixtures.py`: 15 compañías; compañía principal con 59 personas, 135 activos y 73 recursos antes de las altas del recorrido, además de un puesto con 80 miembros y 60 perfiles. Las altas de WD04.4 amplían ese inventario; no se accede a bases habituales. + +Las pruebas nuevas de revisión y disponibilidad se incluyen en `pnpm --filter @metis/assets test`. Se amplían las pruebas de layout y ficha para acción autorizada/oculta, vacío, separación de contadores y aristas, ausencia de solapamientos y determinismo. `scripts/workplace-add-asset.test.py` extiende el recorrido MCP existente y utiliza HTTP real para verificar composición, principal, historial, aislamiento y conflictos. + +| Comprobación ejecutada | Resultado | +| --- | --- | +| `pnpm --filter @metis/assets typecheck` | Correcto. | +| `pnpm --filter @metis/companies typecheck` | Correcto. | +| `pnpm --filter @metis/web typecheck` | Correcto. | +| `pnpm --filter @metis/assets test` | 29 archivos de pruebas aprobados, incluidos selector, formulario, Workstation, revisión y disponibilidad. | +| `pnpm --filter @metis/companies test` | 49 archivos aprobados; persiste el fallo previo de `companies-page.test.tsx`, ajeno a WD04.4. Las pruebas de mapa, ficha, perfiles y software pasan. | +| Pruebas dirigidas nuevas de revisión, disponibilidad, layout y ficha | Correctas. Se cubren autorización visual es/en, vacío y separación de la acción respecto al equipamiento. | +| Biome sobre los 30 archivos TS/TSX/JSON de esta intervención | Correcto, sin avisos. | +| ESLint sobre sus archivos TS/TSX | Correcto. | +| `pnpm build` | Correcto; conserva el aviso de tamaño del chunk principal. | +| `pnpm typecheck` global | Bloqueado por el error previo de `locale` opcional en `packages/shadcn-ui/src/components/calendar.tsx:165`. No se modifica vendor. | +| `pnpm lint` / `pnpm check` globales | Fallan por deuda previa de código/formato/importaciones fuera de esta intervención. Las comprobaciones dirigidas quedan limpias. | +| `pnpm test` global | Se detiene por el fallo previo de `modules/resources/src/api/application/resource-scope.test.ts`. | +| Regresión `workplace-visual-map.test.py` | **7 recorridos aprobados**, 172,755 s: seis anchos, densidad, enlaces, gestión, soporte, parciales, principal único, cobertura/refetch, contraste y movimiento reducido. | +| `workplace-add-asset.test.py` | **4 recorridos aprobados**, 95,685 s: asociación/cancelación/conflicto; seis anchos y móvil; alta/cancelación/error/reintento/refetch; límite visual y restricciones HTTP/MariaDB. | +| `git diff --check` | Correcto. | + +Los recorridos verifican Enter, Espacio, búsqueda enfocada, selección por teclado, Escape y retorno de foco. La asociación móvil conserva la ausencia de principal. Tras el fallo de lectura posterior a un guardado, el contador instrumentado verifica **una sola escritura**. En la comprobación HTTP se observan `INVALID_MEMBERS`, `ASSET_IN_WORKSTATION`, `ASSET_NOT_FOUND` y `401` sin sesión. Los cuatro recorridos WD04.4 terminan sin errores JavaScript de página no capturados. + +Evidencias locales revisadas visualmente, bajo `.artifacts/playwright/authenticated-c1voaeu5/workstation-dashboard-wd043/`: + +- `visual-map-iteration-2/after/map-{1600,1440,1280,1024,768,390}.png`: mapa con acción. Anchos reales 915, 755, 887, 872, 688 y 326 px; canvas de 424, 405, 424, 424 y 468 px, y presentación compacta en móvil. Sin overflow global. +- `wd044-add-asset/picker-{1600,1440,1280,1024,768,390}.png`: selector en los seis tamaños. +- `wd044-add-asset/S01-associated.png`, `S02-S10-mobile-associated.png`, `S04-create-form.png`, `S04-created-selected.png`, `S06-conflict.png`, `S07-save-error.png`, `S07-saved-refresh-error.png`, `S08-limit.png`. +- Ambos directorios conservan `measurements.json` con geometría, resultados y hashes de las capturas. Las capturas quedan fuera de Git; no se versiona ni se copia la sesión privada. + +Para repetir: iniciar `python3 scripts/visual-audit.py`, mantener abierto su entorno, ejecutar `visual-audit-volume.py` y `workstation-dashboard-fixtures.py` con el `METIS_PLAYWRIGHT_STORAGE_STATE` anunciado por el preparador, y después `workplace-visual-map.test.py` y `workplace-add-asset.test.py`. Ejecutar la regresión antes de los recorridos de asociación, que modifican las composiciones sintéticas. Cerrar el preparador al terminar retira exclusivamente su instalación y borra la sesión. + +Limitaciones: no existe un rol de solo lectura de Workstation con el que ejecutar S09 como usuario autenticado; se cubren la prop de autorización y el rechazo anónimo real. El formulario de alta conserva sus validaciones y errores existentes, pero esta auditoría no fuerza un fallo HTTP de creación. Las comprobaciones globales no están verdes por los fallos previos indicados. Se observaron avisos de claves duplicadas en flujos anteriores del inventario durante la preparación/regresión; ya aparecían al preparar la sesión, antes del flujo WD04.4, y no se amplía el alcance para corregirlos. + +El preparador de esta ejecución se cerró al finalizar: instalación temporal retirada y ausencia de `session.json` verificada; las capturas se conservan. diff --git a/docs/puestos-de-trabajo.md b/docs/puestos-de-trabajo.md index 508ca560..eb325b81 100644 --- a/docs/puestos-de-trabajo.md +++ b/docs/puestos-de-trabajo.md @@ -178,3 +178,9 @@ criterio que la ficha de persona: no incluye puestos usados solo mediante un per responsabilidades históricas, puestos desactivados ni registros de otra compañía. La ausencia de puestos muestra `—`; el inventario se consulta una vez por compañía mediante la caché compartida, con estado de carga y recuperación explícita de errores. + +## Sistema operativo actual del principal + +La ficha muestra un resumen compartido con la del activo: todas las instalaciones `configured` del principal con referencia resuelta de tipo `os`. Los estados pendientes o retirados no se presentan como SO actual. Sin registro aparece «No registrado»; carga, error recuperable y referencias no disponibles se distinguen. No se deduce el dato del SO de fábrica ni se copia en Workstation, WorkProfile o ApplicationConfiguration. + +Cambiar el ordenador principal selecciona inmediatamente las instalaciones del nuevo activo sin transferir software, licencias o historial. La gestión existente sigue disponible, y el mapa continúa mostrando el conjunto de software abierto con sus estados. La lista completa de SO actuales está en el resumen, incluso cuando hay varios productos. Véase la [decisión y validación](architecture/decision-so-actual-en-activos-y-puestos.md). diff --git a/docs/software-del-ordenador.md b/docs/software-del-ordenador.md index b4d51dae..a492a3a4 100644 --- a/docs/software-del-ordenador.md +++ b/docs/software-del-ordenador.md @@ -46,3 +46,11 @@ El uso `license` permite reconocer también plantillas propias y copias de la pl Se comprueban referencias de otra compañía o de tipo incorrecto, inactividad/caducidad, idempotencia, desvinculación, revisiones anteriores, migración repetida y enlaces/traducciones es/en. La limpieza de la base exclusiva de pruebas elimina primero las instalaciones y revisiones para permitir ejecuciones consecutivas sin desactivar claves foráneas. La elegibilidad del activo utiliza el indicador explícito `notRetired`. Un equipo en uso o en mantenimiento puede conservar y documentar software; la asignación exclusiva a un puesto aplica una regla distinta, definida en [Inventario de activos](inventario-de-activos.md). + +## Resumen del sistema operativo actual + +La ficha del activo muestra el resumen tras sus características técnicas; la ficha del puesto muestra la misma información de su ordenador principal antes de la gestión de software. Ambas consumen el mismo selector y caché. Se consideran únicamente instalaciones de ese activo y compañía en estado `configured` cuyo recurso pueda resolverse como `os`. Se muestran todos los productos, ordenados de forma estable, con versión cuando no esté ya incluida literalmente en su nombre. No se leen los campos del SO de fábrica ni se guardan copias en activo/puesto. + +Sin SO configurado se muestra «No registrado», que no confirma ausencia física. La carga usa skeleton; los errores tienen reintento y no se convierten en vacío. Una referencia configurada no resoluble muestra un aviso, ya que tampoco se puede determinar su tipo. Se conservan las reglas de ámbito y resolución de productos inactivos del backend. + +El resumen se aplica también a servidores sin puesto y plantillas de activo propias: no infiere SO desde firmware ni depende de UUID de plantilla. La gestión sigue en el panel/formulario existente de instalaciones de la ficha del activo y en el diálogo del puesto. El mapa conserva el software abierto y sus estados, incluyendo pendientes; el resumen es la proyección específica de SO configurados. Véase la [decisión aprobada y sus pruebas](architecture/decision-so-actual-en-activos-y-puestos.md). diff --git a/modules/assets/package.json b/modules/assets/package.json index e1f134ce..44e5a4bc 100644 --- a/modules/assets/package.json +++ b/modules/assets/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/asset-template-propagation.test.ts src/api/domain/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/assets/shared/helpers/asset-columns.test.tsx src/web/assets/detail/ui/asset-detail.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-membership.test.ts src/api/infrastructure/persistence/directory-membership-provider.test.ts src/web/directory-memberships/shared/directory-memberships.test.tsx src/api/domain/network-share-publication.test.ts src/web/network-share-publications/shared/network-share-ui.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/asset-template-propagation.test.ts src/api/domain/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/assets/shared/helpers/asset-columns.test.tsx src/web/assets/detail/ui/asset-detail.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-membership.test.ts src/api/infrastructure/persistence/directory-membership-provider.test.ts src/web/directory-memberships/shared/directory-memberships.test.tsx src/api/domain/network-share-publication.test.ts src/web/network-share-publications/shared/network-share-ui.test.tsx src/api/application/workstation-concurrency.test.ts src/web/workstations/shared/helpers/workstation-asset-availability.test.ts" }, "dependencies": { "@hookform/resolvers": "5.2.2", diff --git a/modules/assets/src/api/application/workstation-concurrency.test.ts b/modules/assets/src/api/application/workstation-concurrency.test.ts new file mode 100644 index 00000000..c4e28242 --- /dev/null +++ b/modules/assets/src/api/application/workstation-concurrency.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { AssetCatalog } from "../domain/asset-catalog.aggregate"; +import { WorkstationValues } from "../domain/value-objects/workstation-values.vo"; +import { Workstation } from "../domain/workstation.aggregate"; + +import type { IAssetRepository, InventoryContext } from "./asset.repository"; +import { WorkstationUseCases } from "./workstation.use-cases"; + +test("WD04.4: revisión obsoleta rechazada antes de reemplazar composición; revisión actual conserva historial", async () => { + const company = UniqueID.generateNewID(); + const actor = UniqueID.generateNewID(); + const values = WorkstationValues.create({ + name: "Puesto vacío", + active: false, + siteId: null, + personId: null, + computerAssetId: null, + assetIds: [], + }); + + assert.ok(values.isSuccess); + const created = Workstation.create(company, values.data, actor, "2026-01-01T10:00:00.000Z"); + + assert.ok(created.isSuccess); + let current = created.data; + + const context = (): InventoryContext => ({ + workstations: [current], + assets: [], + catalog: AssetCatalog.create(), + brands: [], + relationships: [], + sites: [], + people: [], + actorNames: new Map(), + }); + + const unused = () => { + throw new Error("Operación fuera del caso de prueba"); + }; + + const repository: IAssetRepository = { + get: unused, + save: unused, + remove: unused, + removeWorkstation: unused, + saveRelationship: unused, + removeRelationship: unused, + saveWorkstation: async (companyId, action) => { + assert.ok(companyId.equals(company)); + const result = action(context()); + + if (!result.isSuccess) { + return Result.fail(result.error); + } + current = result.data; + + return Result.ok({ workstation: current, actorNames: new Map() }); + }, + }; + const useCases = new WorkstationUseCases(repository); + const revision = current.history.at(-1)?.id.toPrimitive(); + const original = current; + const next = { ...values.data.toPrimitive(), name: "Otro usuario" }; + const updated = await useCases.save( + company.toPrimitive(), + current.id.toPrimitive(), + next, + actor.toPrimitive(), + revision + ); + + assert.ok(updated.isSuccess); + assert.equal(current.history.length, 2); + const rejected = await useCases.save( + company.toPrimitive(), + current.id.toPrimitive(), + original.values.toPrimitive(), + actor.toPrimitive(), + revision + ); + + assert.ok(!rejected.isSuccess); + assert.equal(rejected.error.code, "WORKSTATION_CHANGED"); + assert.equal(current.values.name.toPrimitive(), "Otro usuario"); + assert.equal(current.history.length, 2); + const same = await useCases.save( + company.toPrimitive(), + current.id.toPrimitive(), + next, + actor.toPrimitive(), + current.history.at(-1)?.id.toPrimitive() + ); + + assert.ok(same.isSuccess); + assert.equal(current.history.length, 2); +}); diff --git a/modules/assets/src/api/application/workstation.use-cases.ts b/modules/assets/src/api/application/workstation.use-cases.ts index aa816eda..106f7b35 100644 --- a/modules/assets/src/api/application/workstation.use-cases.ts +++ b/modules/assets/src/api/application/workstation.use-cases.ts @@ -40,7 +40,13 @@ export class WorkstationUseCases { }); } - save(companyId: string, id: string | undefined, input: WorkstationInput, actorId: string) { + save( + companyId: string, + id: string | undefined, + input: WorkstationInput, + actorId: string, + expectedRevisionId?: string + ) { const company = UniqueID.create(companyId); const actor = UniqueID.create(actorId); const identity = id ? UniqueID.create(id) : undefined; @@ -57,6 +63,9 @@ export class WorkstationUseCases { if (id && !previous) { return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND")); } + if (expectedRevisionId && previous?.history.at(-1)?.id.toPrimitive() !== expectedRevisionId) { + return Result.fail(new AssetCatalogError("WORKSTATION_CHANGED")); + } const values = mapWorkstationInput(input, context, previous); if (!values.isSuccess) { diff --git a/modules/assets/src/api/infrastructure/http/workstation.routes.ts b/modules/assets/src/api/infrastructure/http/workstation.routes.ts index 5c10e504..c8ec8d26 100644 --- a/modules/assets/src/api/infrastructure/http/workstation.routes.ts +++ b/modules/assets/src/api/infrastructure/http/workstation.routes.ts @@ -26,6 +26,11 @@ export function registerWorkstationRoutes( path.endsWith("update") ? AssetParamsSchema : InventoryParamsSchema ).safeParse(request.params); const body = SaveWorkstationRequestSchema.safeParse(request.body); + const revision = z + .string() + .regex(/^"[0-9a-f-]{36}"$/i) + .optional() + .safeParse(request.get("if-match")); const actor = z .object({ userId: z.uuid() }) .safeParse("session" in request ? request.session : undefined); @@ -34,6 +39,7 @@ export function registerWorkstationRoutes( !( params.success && body.success && + revision.success && actor.success && z.strictObject({}).safeParse(request.query).success ) @@ -46,7 +52,13 @@ export function registerWorkstationRoutes( "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; useCases - .save(params.data.companyId, id, body.data.values, actor.data.userId) + .save( + params.data.companyId, + id, + body.data.values, + actor.data.userId, + revision.data?.slice(1, -1) + ) .then((result) => { if (!result.isSuccess) { fail(response, result.error.code); diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index e570fd28..c1af521d 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -154,7 +154,8 @@ "FORBIDDEN": "Sign in again.", "UNAUTHENTICATED": "Sign in again.", "ASSET_ASSIGNED": "Unlink the asset from active workstations before retiring it.", - "ASSET_NOT_OPERATIONAL": "Only operational assets can be added to an active workstation." + "ASSET_NOT_OPERATIONAL": "Only operational assets can be added to an active workstation.", + "WORKSTATION_CHANGED": "Another user changed this workstation. Refresh and review your selection before saving." }, "workstations": "Workstations", "newWorkstation": "New workstation", @@ -843,5 +844,19 @@ "currentAssignments_other": "{{count}} current assignments", "primaryAssignment": "Primary", "openResponsiblePerson": "Open person details" + }, + "workstationAdd": { + "title": "Add asset to workstation", + "action": "Add asset", + "subtitle": "Select or create", + "description": "Select an asset belonging to this company to add it to the workstation.", + "confirm": "Add to workstation", + "create": "Create new asset", + "help": "Assets already associated are excluded. Creating an asset adds it to inventory; confirm its association afterwards. The main computer is preserved.", + "limit": "This workstation has reached its 200 asset limit. Manage equipment to remove an asset.", + "reload": "Refresh workstation and review selection", + "savedRefreshError": "The asset was associated, but the map could not be refreshed. Retry the refresh; saving again is unnecessary.", + "refreshing": "Asset associated. Refreshing the map…", + "helpLabel": "About asset association" } } diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index d4f98928..fdc632b7 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -154,7 +154,8 @@ "FORBIDDEN": "Vuelve a iniciar sesión.", "UNAUTHENTICATED": "Vuelve a iniciar sesión.", "ASSET_ASSIGNED": "Desvincula el activo de los puestos activos antes de retirarlo.", - "ASSET_NOT_OPERATIONAL": "Solo se pueden incorporar activos operativos a un puesto activo." + "ASSET_NOT_OPERATIONAL": "Solo se pueden incorporar activos operativos a un puesto activo.", + "WORKSTATION_CHANGED": "Otro usuario ha modificado el puesto. Actualiza los datos y revisa la selección antes de guardar." }, "workstations": "Puestos de trabajo", "newWorkstation": "Nuevo puesto", @@ -843,5 +844,19 @@ "currentAssignments_other": "{{count}} asignaciones vigentes", "primaryAssignment": "Principal", "openResponsiblePerson": "Abrir ficha de persona" + }, + "workstationAdd": { + "title": "Añadir activo al puesto", + "action": "Añadir activo", + "subtitle": "Seleccionar o crear", + "description": "Selecciona un activo de esta compañía para incorporarlo al puesto.", + "confirm": "Añadir al puesto", + "create": "Crear activo nuevo", + "help": "Los activos ya asociados no aparecen. Crear un activo lo registra en inventario; confirma después su asociación. El ordenador principal se conserva.", + "limit": "El puesto ha alcanzado el límite de 200 activos. Gestiona el equipamiento para retirar alguno.", + "reload": "Actualizar puesto y revisar selección", + "savedRefreshError": "El activo se ha asociado, pero no se pudo actualizar el mapa. Reintenta la actualización; no es necesario volver a guardar.", + "refreshing": "Activo asociado. Actualizando el mapa…", + "helpLabel": "Ayuda sobre la asociación" } } diff --git a/modules/assets/src/web/assets/detail/types/asset-detail-props.ts b/modules/assets/src/web/assets/detail/types/asset-detail-props.ts index d6996e3b..0b88fb71 100644 --- a/modules/assets/src/web/assets/detail/types/asset-detail-props.ts +++ b/modules/assets/src/web/assets/detail/types/asset-detail-props.ts @@ -8,4 +8,5 @@ export type AssetDetailProps = { lookups: InventoryLookups; compact?: boolean; children?: ReactNode; + technicalSummary?: ReactNode; }; diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail-content.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail-content.tsx index 803c12cb..3d991ec8 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-detail-content.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-detail-content.tsx @@ -19,6 +19,7 @@ export function AssetDetailContent({ assets, relationships, children, + technicalSummary, }: AssetDetailProps & { assets: readonly AssetDTO[]; relationships: readonly RelationshipDTO[]; @@ -46,7 +47,7 @@ export function AssetDetailContent({ value={tab} /> - + ReactNode; + technicalSummary?: (asset: AssetDTO) => ReactNode; }) { const { t } = useTranslation("assets"); const { searchParams } = useBrowserSearchParams(); @@ -107,6 +109,7 @@ export function AssetDetailPage({ assets={c.inventory.data?.assets ?? []} lookups={lookups} relationships={c.inventory.data?.relationships ?? []} + technicalSummary={technicalSummary?.(asset)} > {relatedContent?.(asset)} diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail.test.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail.test.tsx index 18d4172b..daab63bc 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-detail.test.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-detail.test.tsx @@ -166,6 +166,7 @@ test("ficha de activo es/en: página con dos pestañas, información actual, rel companyId={companyId} lookups={lookups} relatedContent={() =>

Contenido relacionado

} + technicalSummary={() =>

Resumen técnico aportado por el consumidor

} /> ); @@ -180,6 +181,14 @@ test("ficha de activo es/en: página con dos pestañas, información actual, rel assert.ok(html.includes("Modelo actual")); assert.ok(html.includes("Fábrica")); assert.ok(html.includes("Contenido relacionado")); + assert.ok(html.includes("Resumen técnico aportado por el consumidor")); + assert.ok( + html.indexOf("Modelo actual") < html.indexOf("Resumen técnico aportado por el consumidor") + ); + assert.ok( + html.indexOf("Resumen técnico aportado por el consumidor") < + html.indexOf("Contenido relacionado") + ); assert.equal(html.includes("Notas antiguas"), false); assert.equal(html.includes('role="dialog"'), false); assert.equal(html.includes("assetDetail."), false); diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx index d4a6cdfe..ca801d7a 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx @@ -13,7 +13,7 @@ import { useAssetDetailController } from "../controllers"; import type { AssetDetailProps } from "../types"; export function AssetDetail(props: AssetDetailProps) { - const { asset, lookups, compact = false, children } = props; + const { asset, lookups, compact = false, children, technicalSummary } = props; const { workstations } = useAssetDetailController(props); const { t, i18n } = useTranslation("assets"); @@ -126,6 +126,7 @@ export function AssetDetail(props: AssetDetailProps) { {characteristics} )} + {technicalSummary} {asset.values.notes && ( void) { const [selectedIds, setSelectedIds] = useState( props.mode === "multiple" ? props.value : props.value ? [props.value] : [] ); const [search, setSearch] = useState(""); const [templateId, setTemplateId] = useState(null); - const inventory = useListQuery({ - queryKey: inventoryKeys.company(props.companyId), - queryFn: () => getAssetInventory(props.companyId), - }); - const catalog = useListQuery({ - queryKey: catalogKeys.scope(props.companyId), - queryFn: () => catalogApi.get(props.companyId), - }); - const infrastructure = useListQuery({ - queryKey: infrastructureKeys.company(props.companyId), - queryFn: () => infrastructureApi.get(props.companyId), - }); - const { state, denied } = useAssetCollectionState([inventory, catalog]); + const { inventory, catalog, infrastructure, state, denied, retry } = useAssetPickerData( + props.companyId + ); const options = new Map(props.options.map((option) => [option.value, option])); const assets = (inventory.data?.assets ?? []).filter( (asset) => asset.companyId === props.companyId && options.has(asset.id) @@ -79,9 +62,6 @@ export function useAssetPickerController(props: AssetSelectProps, onClose: () => onClose(); }; - const retry = () => - Promise.all([inventory.refetch(), catalog.refetch(), infrastructure.refetch()]); - return { inventory, state, diff --git a/modules/assets/src/web/assets/select/controllers/use-asset-picker-data.ts b/modules/assets/src/web/assets/select/controllers/use-asset-picker-data.ts new file mode 100644 index 00000000..ef3dd128 --- /dev/null +++ b/modules/assets/src/web/assets/select/controllers/use-asset-picker-data.ts @@ -0,0 +1,32 @@ +import { useListQuery } from "@metis/core/client"; + +import { catalogApi, catalogKeys } from "../../../catalog/shared/api"; +import { + infrastructureApi, + infrastructureKeys, +} from "../../../infrastructure/shared/api/infrastructure-api"; +import { useAssetCollectionState } from "../../../shared/hooks/use-asset-collection-state"; +import { inventoryKeys } from "../../../shared/queries"; +import { getAssetInventory } from "../../list/api"; + +/** Consultas y disponibilidad compartidas; cada operación conserva su selección y confirmación. */ +export function useAssetPickerData(companyId: string) { + const inventory = useListQuery({ + queryKey: inventoryKeys.company(companyId), + queryFn: () => getAssetInventory(companyId), + }); + const catalog = useListQuery({ + queryKey: catalogKeys.scope(companyId), + queryFn: () => catalogApi.get(companyId), + }); + const infrastructure = useListQuery({ + queryKey: infrastructureKeys.company(companyId), + queryFn: () => infrastructureApi.get(companyId), + }); + const { state, denied } = useAssetCollectionState([inventory, catalog]); + + const retry = () => + Promise.all([inventory.refetch(), catalog.refetch(), infrastructure.refetch()]); + + return { inventory, catalog, infrastructure, state, denied, retry }; +} diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts index ebc201a5..878a57f1 100644 --- a/modules/assets/src/web/index.ts +++ b/modules/assets/src/web/index.ts @@ -20,6 +20,7 @@ export { inventoryKeys } from "./shared/queries"; export { AssetSupport } from "./support-connections"; export { AssetSupportOverview } from "./support-connections/list/ui/asset-support-overview"; export { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations"; +export { WorkstationAssetAddDialog } from "./workstations/associate-assets/ui/workstation-asset-add-dialog"; export { WorkstationHistory } from "./workstations/history/ui/workstation-history"; export { WorkstationHistoryDialog } from "./workstations/history/ui/workstation-history-dialog"; export { WorkstationNameEditor } from "./workstations/rename/ui/workstation-name-editor"; diff --git a/modules/assets/src/web/shared/api/inventory-request.ts b/modules/assets/src/web/shared/api/inventory-request.ts index 3a339dbb..e694a2b8 100644 --- a/modules/assets/src/web/shared/api/inventory-request.ts +++ b/modules/assets/src/web/shared/api/inventory-request.ts @@ -3,11 +3,15 @@ import { z } from "zod/v4"; import { InventoryApiError } from "./inventory-api-error"; -export async function inventoryRequest(path: string, body?: unknown) { +export async function inventoryRequest( + path: string, + body?: unknown, + headers?: Record +) { const response = await csrfFetch(`/api/assets/${path}`, { method: body === undefined ? "GET" : "POST", credentials: "same-origin", - headers: body === undefined ? {} : { "content-type": "application/json" }, + headers: { ...(body === undefined ? {} : { "content-type": "application/json" }), ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); const data: unknown = await response.json(); diff --git a/modules/assets/src/web/workstations/associate-assets/controllers/use-workstation-asset-add.ts b/modules/assets/src/web/workstations/associate-assets/controllers/use-workstation-asset-add.ts new file mode 100644 index 00000000..a2c4c365 --- /dev/null +++ b/modules/assets/src/web/workstations/associate-assets/controllers/use-workstation-asset-add.ts @@ -0,0 +1,157 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRef, useState } from "react"; + +import type { WorkstationDTO } from "../../../../common"; +import { getAssetInventory } from "../../../assets/list/api"; +import { useAssetPickerData } from "../../../assets/select/controllers/use-asset-picker-data"; +import { InventoryApiError } from "../../../shared/api/inventory-api-error"; +import { inventoryKeys } from "../../../shared/queries"; +import { saveWorkstation } from "../../shared/api"; +import { workstationAssetUnavailable } from "../../shared/helpers/workstation-asset-availability"; + +export function useWorkstationAssetAdd({ + companyId, + workstation, + onClose, +}: { + companyId: string; + workstation: WorkstationDTO; + onClose: () => void; +}) { + const client = useQueryClient(); + // La revisión pertenece a la selección abierta, no a los refetch en segundo plano. + const [base, setBase] = useState(workstation); + const [creating, setCreating] = useState(false); + const [createdId, setCreatedId] = useState(); + const [saved, setSaved] = useState(false); + const [refreshError, setRefreshError] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const saving = useRef(false); + const data = useAssetPickerData(companyId); + const inventory = data.inventory.data; + const [search, setSearch] = useState(""); + const [templateId, setTemplateId] = useState(null); + const members = new Set(base.values.assetIds); + const candidates = (inventory?.assets ?? []).filter( + (asset) => asset.companyId === companyId && !members.has(asset.id) + ); + const reasons = new Map( + candidates.map((asset) => [ + asset.id, + workstationAssetUnavailable( + asset.id, + base.values.active, + base, + inventory?.assets ?? [], + inventory?.workstations ?? [] + ), + ]) + ); + const picker = { + ...data, + assets: data.denied ? [] : candidates, + search, + setSearch, + templateId, + setTemplateId, + disabled: (id: string) => !candidates.some((asset) => asset.id === id) || !!reasons.get(id), + }; + const [selection, setSelection] = useState(); + const selectedId = selection ?? createdId; + const selected = picker.assets.find((asset) => asset.id === selectedId); + const atLimit = base.values.assetIds.length >= 200; + const ready = + picker.state.status === "ready" && !picker.inventory.isError && !picker.catalog.isError; + const canConfirm = ready && !!selected && !reasons.get(selected.id) && !atLimit && !saved; + + const refresh = async () => { + setRefreshing(true); + setRefreshError(false); + try { + await client.invalidateQueries({ + queryKey: inventoryKeys.company(companyId), + refetchType: "none", + }); + await client.fetchQuery({ + queryKey: inventoryKeys.company(companyId), + queryFn: () => getAssetInventory(companyId), + staleTime: 0, + }); + onClose(); + } catch { + // El guardado ya se confirmó: reintentar solo la lectura, nunca duplicar la escritura. + setRefreshError(true); + } finally { + setRefreshing(false); + } + }; + + const mutation = useMutation({ + mutationFn: async () => { + if (!(canConfirm && selectedId) || saving.current) { + throw new InventoryApiError("INVALID_MEMBERS"); + } + saving.current = true; + try { + return await saveWorkstation( + companyId, + { + ...base.values, + assetIds: [...base.values.assetIds, selectedId], + }, + base.id, + base.history.at(-1)?.id + ); + } finally { + saving.current = false; + } + }, + onSuccess: async () => { + setSaved(true); + await refresh(); + }, + }); + + const reload = async () => { + const result = await picker.inventory.refetch(); + const latest = result.data?.workstations.find((item) => item.id === base.id); + + if (!result.isError && latest) { + setBase(latest); + setSelection(undefined); + setCreatedId(undefined); + mutation.reset(); + } + }; + + const assetCreated = (id: string) => { + setCreatedId(id); + setSelection(undefined); + const created = client + .getQueryData>>(inventoryKeys.company(companyId)) + ?.assets.find((asset) => asset.id === id); + + picker.setSearch(created?.values.name ?? ""); + picker.setTemplateId(null); + setCreating(false); + }; + + return { + picker, + selectedId, + setSelection, + reasons, + atLimit, + canConfirm, + creating, + setCreating, + assetCreated, + mutation, + saved, + refreshError, + refreshing, + refresh, + reload, + busy: mutation.isPending || refreshing, + }; +} diff --git a/modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-actions.tsx b/modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-actions.tsx new file mode 100644 index 00000000..997eea99 --- /dev/null +++ b/modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-actions.tsx @@ -0,0 +1,49 @@ +import { FormActions } from "@repo/rdx-ui/components/form-dialog"; +import { PlusIcon } from "@repo/rdx-ui/lib/icons"; +import { Button } from "@repo/shadcn-ui/components/button"; +import type { RefObject } from "react"; +import { useTranslation } from "react-i18next"; + +import type { useWorkstationAssetAdd } from "../controllers/use-workstation-asset-add"; + +export function WorkstationAssetAddActions({ + controller: c, + createButton, +}: { + controller: ReturnType; + createButton: RefObject; +}) { + const { t } = useTranslation("assets"); + + return ( + c.setCreating(true)} + ref={createButton} + type="button" + variant="outline" + > + + ); +} diff --git a/modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-dialog.tsx b/modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-dialog.tsx new file mode 100644 index 00000000..bc927f8c --- /dev/null +++ b/modules/assets/src/web/workstations/associate-assets/ui/workstation-asset-add-dialog.tsx @@ -0,0 +1,108 @@ +import { FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog"; +import { PickerHelp } from "@repo/rdx-ui/components/picker-help"; +import type { ComponentProps } from "react"; +import { useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import type { WorkstationDTO } from "../../../../common"; +import { AssetPickerList } from "../../../assets/select/ui/asset-picker-list"; +import { AssetEditorDialog } from "../../../assets/shared/ui/asset-editor-dialog"; +import type { WorkstationFormProps } from "../../shared/types"; +import { WorkstationSaveFeedback } from "../../shared/ui/workstation-save-feedback"; +import { useWorkstationAssetAdd } from "../controllers/use-workstation-asset-add"; + +import { WorkstationAssetAddActions } from "./workstation-asset-add-actions"; + +export function WorkstationAssetAddDialog({ + companyId, + workstation, + lookups, + onClose, + finalFocus, +}: { + companyId: string; + workstation: WorkstationDTO; + lookups: WorkstationFormProps["lookups"]; + onClose: () => void; + finalFocus?: ComponentProps["finalFocus"]; +}) { + const { t } = useTranslation("assets"); + const c = useWorkstationAssetAdd({ companyId, workstation, onClose }); + const p = c.picker; + const createButton = useRef(null); + + return ( + { + if (!(open || c.busy)) { + onClose(); + } + }} + open + title={t("workstationAdd.title")} + wide + > + +

{t("workstationAdd.description")}

+ c.busy || c.saved || c.atLimit || p.disabled(id)} + infrastructure={p.infrastructure.data?.items ?? []} + loading={p.inventory.isPending || p.catalog.isPending} + mode="single" + onClearFilters={() => { + p.setSearch(""); + p.setTemplateId(null); + }} + onRetry={p.denied ? undefined : p.retry} + onSearchChange={p.setSearch} + onTemplateChange={(id) => p.setTemplateId(id === "all" ? null : id)} + onToggle={c.setSelection} + search={p.search} + selectedIds={c.selectedId ? [c.selectedId] : []} + sites={lookups.sites} + state={p.state} + templateId={p.templateId} + unavailable={p.inventory.isError || p.catalog.isError} + unavailableLabels={ + new Map( + [...c.reasons] + .filter(([, reason]) => !!reason) + .map(([id, reason]) => [id, t(`inventoryErrors.${reason}`)]) + ) + } + /> + {c.atLimit &&

{t("workstationAdd.limit")}

} + + {c.refreshError &&

{t("workstationAdd.savedRefreshError")}

} + {c.saved && !c.refreshError &&

{t("workstationAdd.refreshing")}

} +
+ + + {c.creating && ( + c.setCreating(false)} + onSaved={c.assetCreated} + /> + )} +
+ ); +} diff --git a/modules/assets/src/web/workstations/rename/api/rename-workstation.api.ts b/modules/assets/src/web/workstations/rename/api/rename-workstation.api.ts index 826300b9..b13fdc52 100644 --- a/modules/assets/src/web/workstations/rename/api/rename-workstation.api.ts +++ b/modules/assets/src/web/workstations/rename/api/rename-workstation.api.ts @@ -22,5 +22,10 @@ export async function renameWorkstation(companyId: string, id: string, name: str return { workstation }; } - return saveWorkstation(companyId, { ...workstation.values, name: parsed.data }, id); + return saveWorkstation( + companyId, + { ...workstation.values, name: parsed.data }, + id, + workstation.history.at(-1)?.id + ); } diff --git a/modules/assets/src/web/workstations/shared/api/save-workstation.api.ts b/modules/assets/src/web/workstations/shared/api/save-workstation.api.ts index a8b40a48..1b4adafc 100644 --- a/modules/assets/src/web/workstations/shared/api/save-workstation.api.ts +++ b/modules/assets/src/web/workstations/shared/api/save-workstation.api.ts @@ -2,8 +2,17 @@ import type { WorkstationInput } from "../../../../common/workstations"; import { WorkstationResponseSchema } from "../../../../common/workstations"; import { inventoryRequest } from "../../../shared/api/inventory-request"; -export async function saveWorkstation(companyId: string, values: WorkstationInput, id?: string) { +export async function saveWorkstation( + companyId: string, + values: WorkstationInput, + id?: string, + expectedRevisionId?: string +) { return WorkstationResponseSchema.parse( - await inventoryRequest(`${companyId}/workstations${id ? `/${id}/update` : ""}`, { values }) + await inventoryRequest( + `${companyId}/workstations${id ? `/${id}/update` : ""}`, + { values }, + expectedRevisionId ? { "if-match": `"${expectedRevisionId}"` } : undefined + ) ); } diff --git a/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts b/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts index a779178e..98c49b4e 100644 --- a/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts +++ b/modules/assets/src/web/workstations/shared/controllers/use-workstation-form-controller.ts @@ -7,11 +7,11 @@ import { useTranslation } from "react-i18next"; import type { WorkstationInput } from "../../../../common"; import { WorkstationInputSchema } from "../../../../common"; -import { canAssignAsset } from "../../../../common/inventory"; import { getAssetInventory } from "../../../assets/list/api"; import { catalogApi, catalogKeys } from "../../../catalog/shared/api"; import { inventoryKeys } from "../../../shared/queries"; import { saveWorkstation } from "../api"; +import { workstationAssetUnavailable } from "../helpers/workstation-asset-availability"; import { workstationCreationReady, workstationMainComputer } from "../helpers/workstation-creation"; import type { WorkstationFormProps } from "../types"; import { suggestedWorkstationName } from "../workstation-name"; @@ -28,6 +28,7 @@ export function useWorkstationFormController({ const client = useQueryClient(); const mounted = useRef(true); const isWizard = !workstation; + const [expectedRevisionId, setExpectedRevisionId] = useState(workstation?.history.at(-1)?.id); const [step, setStep] = useState<1 | 2 | 3 | 4>(1); const activeSites = lookups.sites.filter((site) => site.active); const sourceAsset = initialAsset?.companyId === companyId ? initialAsset : undefined; @@ -71,32 +72,15 @@ export function useWorkstationFormController({ queryFn: () => catalogApi.get(companyId), }); const assets = inventory.data?.assets ?? []; - const occupied = new Set( - (inventory.data?.workstations ?? []) - .filter((item) => item.values.active && item.id !== workstation?.id) - .flatMap((item) => item.values.assetIds) - ); - const unavailableReason = (id: string) => { - if (!values.active) { - return undefined; - } - const asset = assets.find((item) => item.id === id); - - if (!asset) { - return "ASSET_NOT_FOUND"; - } - const retained = workstation?.values.active && workstation.values.assetIds.includes(id); - - if (!(retained || canAssignAsset(asset.values.status, false))) { - return "ASSET_NOT_OPERATIONAL"; - } - if (occupied.has(id)) { - return "ASSET_IN_WORKSTATION"; - } - - return undefined; - }; + const unavailableReason = (id: string) => + workstationAssetUnavailable( + id, + values.active, + workstation, + assets, + inventory.data?.workstations ?? [] + ); const changeAssets = (ids: string[]) => { form.setValue("assetIds", ids, { shouldDirty: true }); @@ -143,7 +127,8 @@ export function useWorkstationFormController({ }; const mutation = useMutation({ - mutationFn: (input: WorkstationInput) => saveWorkstation(companyId, input, workstation?.id), + mutationFn: (input: WorkstationInput) => + saveWorkstation(companyId, input, workstation?.id, expectedRevisionId), onSuccess: async (response) => { await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }); if (mounted.current) { @@ -151,6 +136,18 @@ export function useWorkstationFormController({ } }, }); + + const reload = async () => { + const result = await inventory.refetch(); + const latest = result.data?.workstations.find((item) => item.id === workstation?.id); + + if (!result.isError && latest) { + form.reset(latest.values); + setExpectedRevisionId(latest.history.at(-1)?.id); + mutation.reset(); + } + }; + const assetsReady = !!inventory.data && values.assetIds.every((id) => !unavailableReason(id)); const canContinue = assetsReady && workstationCreationReady(step, values); const save = form.handleSubmit((input) => { @@ -212,6 +209,7 @@ export function useWorkstationFormController({ remove, selectAssociation, mutation, + reload, submit, siteCreated, personCreated, diff --git a/modules/assets/src/web/workstations/shared/helpers/workstation-asset-availability.test.ts b/modules/assets/src/web/workstations/shared/helpers/workstation-asset-availability.test.ts new file mode 100644 index 00000000..eb704836 --- /dev/null +++ b/modules/assets/src/web/workstations/shared/helpers/workstation-asset-availability.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { AssetSchema, WorkstationSchema } from "../../../../common"; + +import { workstationAssetUnavailable } from "./workstation-asset-availability"; + +const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`; + +const actor = { actorId: id(8), actorName: "Técnico", recordedAt: "2026-01-01T10:00:00.000Z" }; +const values = { + name: "Puesto", + active: true, + siteId: null, + personId: null, + computerAssetId: null, + assetIds: [id(3)], +}; +const workstation = WorkstationSchema.parse({ + id: id(1), + companyId: id(2), + values, + history: [{ id: id(9), values, ...actor }], +}); +const assetValues = { + name: "Monitor", + templateId: id(4), + siteId: null, + status: "broken", + notes: "", + characteristics: {}, +}; +const asset = AssetSchema.parse({ + id: id(3), + companyId: id(2), + values: assetValues, + displayStatus: "broken", + assigned: false, + specification: { + templateId: id(4), + templateName: "Monitor", + fields: [ + { fieldId: id(11), name: "Serie", type: "text", unit: "", choices: [], required: false }, + ], + }, + history: [{ id: id(10), values: assetValues, ...actor }], +}); + +test("WD04.4: comparte reglas de disponibilidad, retención, ocupación y puesto inactivo", () => { + assert.equal( + workstationAssetUnavailable(id(3), true, undefined, [asset], []), + "ASSET_NOT_OPERATIONAL" + ); + assert.equal(workstationAssetUnavailable(id(3), true, workstation, [asset], []), undefined); + assert.equal( + workstationAssetUnavailable(id(3), false, undefined, [asset], [workstation]), + undefined + ); + assert.equal( + workstationAssetUnavailable(id(99), true, workstation, [asset], []), + "ASSET_NOT_FOUND" + ); + const available = { ...asset, values: { ...asset.values, status: "operational" as const } }; + + assert.equal( + workstationAssetUnavailable(id(3), true, undefined, [available], [workstation]), + "ASSET_IN_WORKSTATION" + ); + assert.equal(workstationAssetUnavailable(id(3), true, undefined, [available], []), undefined); +}); diff --git a/modules/assets/src/web/workstations/shared/helpers/workstation-asset-availability.ts b/modules/assets/src/web/workstations/shared/helpers/workstation-asset-availability.ts new file mode 100644 index 00000000..9422893c --- /dev/null +++ b/modules/assets/src/web/workstations/shared/helpers/workstation-asset-availability.ts @@ -0,0 +1,35 @@ +import type { AssetDTO, WorkstationDTO } from "../../../../common"; +import { canAssignAsset } from "../../../../common/inventory"; + +/** Misma disponibilidad para edición tradicional y asociación desde el mapa. */ +export function workstationAssetUnavailable( + id: string, + active: boolean, + workstation: WorkstationDTO | undefined, + assets: readonly AssetDTO[], + workstations: readonly WorkstationDTO[] +) { + if (!active) { + return undefined; + } + const asset = assets.find((item) => item.id === id); + + if (!asset) { + return "ASSET_NOT_FOUND"; + } + const retained = workstation?.values.active && workstation.values.assetIds.includes(id); + + if (!(retained || canAssignAsset(asset.values.status, false))) { + return "ASSET_NOT_OPERATIONAL"; + } + if ( + workstations.some( + (item) => + item.values.active && item.id !== workstation?.id && item.values.assetIds.includes(id) + ) + ) { + return "ASSET_IN_WORKSTATION"; + } + + return undefined; +} diff --git a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx index 511952c5..abdb8dc3 100644 --- a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx +++ b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx @@ -5,7 +5,6 @@ import { useId } from "react"; import { useTranslation } from "react-i18next"; import { WorkstationAssetCreateDialog } from "../../../assets"; -import { InventoryFeedback } from "../../../shared"; import { useWorkstationFormController } from "../controllers"; import type { WorkstationFormProps } from "../types"; @@ -14,6 +13,7 @@ import { WorkstationAssociationFields } from "./workstation-association-fields"; import { WorkstationComputerField } from "./workstation-computer-field"; import { WorkstationFormActions } from "./workstation-form-actions"; import { WorkstationIdentityFields } from "./workstation-identity-fields"; +import { WorkstationSaveFeedback } from "./workstation-save-feedback"; import { WorkstationSummary } from "./workstation-summary"; export function WorkstationForm(props: WorkstationFormProps) { @@ -37,6 +37,7 @@ export function WorkstationForm(props: WorkstationFormProps) { remove, selectAssociation, mutation, + reload, submit, siteCreated, personCreated, @@ -128,7 +129,11 @@ export function WorkstationForm(props: WorkstationFormProps) { {t("loading")}

)} - + Promise; +}) { + const { t } = useTranslation("assets"); + const conflict = error instanceof InventoryApiError && error.code === "WORKSTATION_CHANGED"; + + return ( + <> + + {conflict && ( + + )} + + ); +} diff --git a/modules/companies/package.json b/modules/companies/package.json index 3b8ade9d..ed7ba76d 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/web/people/list/people-search.test.tsx src/web/companies/list/company-filters.test.tsx src/web/shared/helpers/company-list-query-state.test.ts src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/infrastructure/shared/helpers/printer-usage.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/helpers/workplace-map-layout.test.ts src/web/workstations/detail/helpers/workstation-dashboard.test.ts src/web/workstations/detail/helpers/workstation-equipment.test.ts src/web/workstations/detail/helpers/workstation-profile-expansion.test.ts src/web/people/shared/helpers/person-organization.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx src/api/application/directory-context-plan.test.ts src/web/access/list/helpers/access-environment-filter.test.ts src/api/domain/folder-mapping-account.test.ts src/api/application/local-identity-plan.test.ts src/web/access/list/helpers/access-resource-filter.test.ts src/web/folder-mappings/shared/helpers/folder-account-options.test.ts src/web/local-identity-environments/shared/local-identities-ui.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/web/people/list/people-search.test.tsx src/web/companies/list/company-filters.test.tsx src/web/shared/helpers/company-list-query-state.test.ts src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/infrastructure/shared/helpers/printer-usage.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/helpers/workplace-map-layout.test.ts src/web/workstations/detail/helpers/workstation-dashboard.test.ts src/web/workstations/detail/helpers/workstation-equipment.test.ts src/web/workstations/detail/helpers/workstation-profile-expansion.test.ts src/web/people/shared/helpers/person-organization.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx src/web/software-installations/shared/current-operating-systems.test.tsx src/api/application/directory-context-plan.test.ts src/web/access/list/helpers/access-environment-filter.test.ts src/api/domain/folder-mapping-account.test.ts src/api/application/local-identity-plan.test.ts src/web/access/list/helpers/access-resource-filter.test.ts src/web/folder-mappings/shared/helpers/folder-account-options.test.ts src/web/local-identity-environments/shared/local-identities-ui.test.tsx" }, "dependencies": { "@dagrejs/dagre": "3.1.2", diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index 2a295c22..9a7bfd81 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -962,6 +962,17 @@ "notice": "Current records, including closed ones. Each link opens their history. This is not a migration plan or use in other companies.", "error": "The total from this source cannot be confirmed. Other sources retain their results.", "retry": "Retry" + }, + "currentOs": { + "title": "Current operating system", + "titlePlural": "Current operating systems", + "description": "Installations declared as configured on this asset. This does not imply automatic technical verification.", + "version": "Version {{version}}", + "notRecorded": "Not recorded", + "unavailable": "Software reference unavailable. Its operating system classification cannot be determined.", + "error": "Unable to load the current operating system.", + "retry": "Retry", + "loading": "Loading current operating system" } }, "access": { diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index 070539a8..46cfae0d 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -962,6 +962,17 @@ "notice": "Registros actuales, incluidos los cerrados. Cada enlace permite consultar su historial. No representa un plan de migración ni uso de otras compañías.", "error": "No se puede confirmar el total de esta fuente. Las otras fuentes conservan sus resultados.", "retry": "Reintentar" + }, + "currentOs": { + "title": "Sistema operativo actual", + "titlePlural": "Sistemas operativos actuales", + "description": "Instalaciones declaradas como configuradas en este activo. No implica una verificación técnica automática.", + "version": "Versión {{version}}", + "notRecorded": "No registrado", + "unavailable": "Referencia de software no disponible. No se puede determinar si corresponde a un sistema operativo.", + "error": "No se ha podido consultar el sistema operativo actual.", + "retry": "Reintentar", + "loading": "Cargando sistema operativo actual" } }, "access": { diff --git a/modules/companies/src/web/assets/detail/ui/asset-operating-systems.tsx b/modules/companies/src/web/assets/detail/ui/asset-operating-systems.tsx new file mode 100644 index 00000000..239dbcbf --- /dev/null +++ b/modules/companies/src/web/assets/detail/ui/asset-operating-systems.tsx @@ -0,0 +1,14 @@ +import { CurrentOperatingSystems } from "../../../software-installations/detail/ui/current-operating-systems"; +import { useAssetSoftware } from "../../../software-installations/shared/queries/use-asset-software"; + +export function AssetOperatingSystems({ + companyId, + assetId, +}: { + companyId: string; + assetId: string; +}) { + const software = useAssetSoftware(companyId, assetId); + + return ; +} diff --git a/modules/companies/src/web/companies/detail/ui/company-inventory.tsx b/modules/companies/src/web/companies/detail/ui/company-inventory.tsx index 547372e5..d2e03bc1 100644 --- a/modules/companies/src/web/companies/detail/ui/company-inventory.tsx +++ b/modules/companies/src/web/companies/detail/ui/company-inventory.tsx @@ -3,6 +3,7 @@ import type { ComponentProps } from "react"; import { useState } from "react"; import type { CompanyDTO } from "../../../../common"; +import { AssetOperatingSystems } from "../../../assets/detail/ui/asset-operating-systems"; import { AssetRelatedInformation } from "../../../assets/detail/ui/asset-related-information"; import { AssetPrinterUsage } from "../../../infrastructure/shared/ui/asset-printer-usage"; import { WorkstationDetailPage } from "../../../workstations/detail/ui/workstation-detail-page"; @@ -53,6 +54,9 @@ export function CompanyInventory({ key={initialId} lookups={lookups} relatedContent={(asset) => } + technicalSummary={(asset) => ( + + )} /> ) : ( ; +}) { + const { t } = useTranslation("companies"); + const { + query, + references, + operatingSystems: { systems, unavailable, requiresResolution }, + } = software; + const failed = query.isError || (requiresResolution && references.isError); + const loading = query.isPending || (requiresResolution && references.isPending); + + return ( + } + title={t(systems.length > 1 ? "currentOs.titlePlural" : "currentOs.title")} + > +
+ {failed ? ( +
+

{t("currentOs.error")}

+ +
+ ) : loading ? ( +
+ + +
+ ) : ( +
+ {systems.length > 0 && ( +
    + {systems.map((system) => ( +
  • + + {system.name} + + {system.version && ( +

    + {t("currentOs.version", { version: system.version })} +

    + )} + {system.needsReview &&

    {t("software.review")}

    } +
  • + ))} +
+ )} + {unavailable && ( +

+ {t("currentOs.unavailable")} +

+ )} + {!(systems.length || unavailable) && ( +

{t("currentOs.notRecorded")}

+ )} +
+ )} +
+
+ ); +} diff --git a/modules/companies/src/web/software-installations/shared/current-operating-systems.test.tsx b/modules/companies/src/web/software-installations/shared/current-operating-systems.test.tsx new file mode 100644 index 00000000..c145e891 --- /dev/null +++ b/modules/companies/src/web/software-installations/shared/current-operating-systems.test.tsx @@ -0,0 +1,292 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { applicationReferenceKeys } from "@metis/resources/client"; +import type { SoftwareReference } from "@metis/resources/common"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createInstance } from "i18next"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nextProvider } from "react-i18next"; + +import type { SoftwareInstallationInput } from "../../../common/software-installations"; +import { SoftwareInstallationSchema } from "../../../common/software-installations"; +import { AssetOperatingSystems } from "../../assets/detail/ui/asset-operating-systems"; +import { companiesEnglish, companiesSpanish } from "../../shared"; + +import { softwareKeys } from "./api/software-api"; +import { getCurrentOperatingSystems } from "./helpers/current-operating-systems"; + +const id = (value: number) => `0199b200-0000-7000-8000-${String(value).padStart(12, "0")}`; + +const companyId = id(1); +const assetId = id(2); +const windows: SoftwareReference = { + id: id(3), + templateId: id(4), + companyId, + scope: "company", + kind: "os", + active: true, + name: "Windows 11 Pro", +}; +const ubuntu: SoftwareReference = { ...windows, id: id(5), name: "Ubuntu 24.04 LTS" }; +const application: SoftwareReference = { + ...windows, + id: id(6), + name: "Editor", + kind: "application", +}; + +function installation( + values: Partial = {}, + identity = id(7), + company = companyId +) { + const input = { + assetId, + resourceId: windows.id, + version: "24H2", + state: "configured", + licenseResourceId: null, + notes: null, + ...values, + }; + + return SoftwareInstallationSchema.parse({ + id: identity, + companyId: company, + values: input, + needsReview: false, + history: [ + { + id: id(8), + actorId: id(9), + actorName: "Técnica", + recordedAt: "2026-10-25T10:00:00.000Z", + values: input, + }, + ], + }); +} + +async function render({ + language = "es", + configurations = [installation()], + references = [windows], + asset = assetId, + failure = false, + pending = false, + referenceFailure = false, +}: { + language?: string; + configurations?: ReturnType[]; + references?: SoftwareReference[]; + asset?: string; + failure?: boolean; + pending?: boolean; + referenceFailure?: boolean; +} = {}) { + const i18n = createInstance(); + + await i18n.init({ + lng: language, + resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } }, + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } }, + }); + const ids = [ + ...new Set( + configurations + .filter( + (item) => + item.companyId === companyId && + item.values.assetId === asset && + !["removed", "never_configured"].includes(item.values.state) + ) + .map((item) => item.values.resourceId) + ), + ].sort(); + const referenceKey = applicationReferenceKeys.references(companyId, ids, "software-installation"); + + if (!pending) { + client.setQueryData(softwareKeys.company(companyId), { configurations }); + } + client.setQueryData(referenceKey, references); + if (failure || referenceFailure) { + await assert.rejects( + client.fetchQuery({ + queryKey: failure ? softwareKeys.company(companyId) : referenceKey, + staleTime: 0, + queryFn: () => Promise.reject(new Error("Error sintético")), + retry: false, + }) + ); + } + try { + return renderToStaticMarkup( + + + + + + ); + } finally { + client.clear(); + } +} + +test("OSUI-01/02/17: SO actual desde instalación, independiente de cualquier valor de fábrica", () => { + const result = getCurrentOperatingSystems([installation()], [windows], companyId, assetId); + + assert.deepEqual( + result.systems.map((item) => [item.name, item.version]), + [["Windows 11 Pro", "24H2"]] + ); + assert.equal(result.unavailable, false); + assert.equal(JSON.stringify(result).includes("FreeDOS"), false); +}); + +test("OSUI-03/04/05/18: vacío, pendiente y retirado no son SO actual", async () => { + for (const configurations of [ + [], + [installation({ state: "pending" })], + [installation({ state: "removed" })], + [installation({ state: "removal_pending" })], + [installation({ state: "never_configured" })], + ]) { + const html = await render({ configurations }); + + assert.ok(html.includes("No registrado")); + assert.equal(html.includes("Windows 11 Pro"), false); + } +}); + +test("OSUI-06/07: varios SO, orden determinista y aplicaciones excluidas", async () => { + const configurations = [ + installation(), + installation({ resourceId: ubuntu.id }, id(10)), + installation({ resourceId: application.id }, id(11)), + ]; + const references = [windows, application, ubuntu]; + const first = getCurrentOperatingSystems(configurations, references, companyId, assetId); + const second = getCurrentOperatingSystems( + [...configurations].reverse(), + [...references].reverse(), + companyId, + assetId + ); + + assert.deepEqual(first, second); + assert.deepEqual( + first.systems.map((item) => item.name), + [ubuntu.name, windows.name] + ); + const html = await render({ configurations, references }); + + assert.ok(html.includes("Sistemas operativos actuales")); + assert.ok(html.includes(windows.name) && html.includes(ubuntu.name)); + assert.equal(html.includes(">Editor<"), false); +}); + +test("OSUI-08/12: cambiar de activo conserva solo su SO, sin aceptar compañías ajenas", async () => { + const configurations = [ + installation(), + installation({ assetId: id(20), resourceId: ubuntu.id }, id(21)), + installation({}, id(22), id(99)), + ]; + const html = await render({ configurations, references: [windows, ubuntu], asset: id(20) }); + + assert.ok(html.includes(ubuntu.name)); + assert.equal(html.includes(windows.name), false); + assert.equal( + getCurrentOperatingSystems( + [installation()], + [{ ...windows, companyId: id(99) }], + companyId, + assetId + ).systems.length, + 0 + ); +}); + +test("OSUI-09: versión de instalación y redundancia literal sin reglas de fabricante", async () => { + assert.ok( + (await render({ configurations: [installation({ version: "25H2" })] })).includes("Versión 25H2") + ); + const result = getCurrentOperatingSystems( + [installation({ resourceId: ubuntu.id, version: "24.04 LTS" })], + [ubuntu], + companyId, + assetId + ); + + assert.equal(result.systems[0]?.version, null); + assert.equal( + getCurrentOperatingSystems( + [installation({ version: "24.04" })], + [{ ...windows, name: "Sistema de prueba (24.04)" }], + companyId, + assetId + ).systems[0]?.version, + null + ); + assert.equal( + getCurrentOperatingSystems( + [installation({ version: "24.04" })], + [{ ...windows, name: "Sistema de prueba 24X04" }], + companyId, + assetId + ).systems[0]?.version, + "24.04" + ); + assert.equal( + getCurrentOperatingSystems([installation({ version: "1" })], [windows], companyId, assetId) + .systems[0]?.version, + "1" + ); +}); + +test("OSUI-10: referencia no resoluble no significa ausencia; inactiva resoluble conserva información", async () => { + const unavailable = await render({ references: [] }); + + assert.ok(unavailable.includes("Referencia de software no disponible")); + assert.equal(unavailable.includes("No registrado"), false); + assert.ok((await render({ references: [{ ...windows, active: false }] })).includes(windows.name)); +}); + +test("OSUI-11: error con datos previos, error de resolver y carga no se presentan como vacío", async () => { + for (const options of [{ failure: true }, { referenceFailure: true }, { pending: true }]) { + const html = await render(options); + + assert.equal(html.includes("No registrado"), false); + assert.ok(html.includes(options.pending ? "Cargando sistema operativo actual" : "Reintentar")); + assert.equal(html.includes(windows.name), false); + } +}); + +test("OSUI-13/14: servidor sin puesto y plantilla propia consumen la misma lectura por activo", async () => { + const server = id(30); + const custom = id(31); + + for (const asset of [server, custom]) { + const html = await render({ asset, configurations: [installation({ assetId: asset })] }); + + assert.ok(html.includes(windows.name)); + assert.ok(html.includes(`/companies/${companyId}/resources/${windows.id}`)); + } +}); + +test("OSUI-16: presentación es/en y versión opcional", async () => { + for (const { language, title, missing } of [ + { language: "es", title: "Sistema operativo actual", missing: "No registrado" }, + { language: "en", title: "Current operating system", missing: "Not recorded" }, + ]) { + assert.ok((await render({ language })).includes(title)); + assert.ok((await render({ language, configurations: [] })).includes(missing)); + } + assert.equal( + (await render({ configurations: [installation({ version: null })] })).includes("Versión "), + false + ); +}); diff --git a/modules/companies/src/web/software-installations/shared/helpers/current-operating-systems.ts b/modules/companies/src/web/software-installations/shared/helpers/current-operating-systems.ts new file mode 100644 index 00000000..cc2c5475 --- /dev/null +++ b/modules/companies/src/web/software-installations/shared/helpers/current-operating-systems.ts @@ -0,0 +1,74 @@ +import type { SoftwareReference } from "@metis/resources/common"; + +import type { SoftwareInstallationDTO } from "../../../../common/software-installations"; + +/** Selecciona el software abierto de una unidad sin mezclar compañías ni destinos. */ +export function getAssetSoftwareInstallations( + installations: readonly SoftwareInstallationDTO[], + companyId: string, + assetId: string | undefined +) { + return installations.filter( + (item) => + item.companyId === companyId && + item.values.assetId === assetId && + !["removed", "never_configured"].includes(item.values.state) + ); +} + +/** SO declarado como configurado; una referencia desconocida nunca acredita ausencia de SO. */ +export function getCurrentOperatingSystems( + installations: readonly SoftwareInstallationDTO[], + references: readonly SoftwareReference[], + companyId: string, + assetId: string | undefined +) { + const products = new Map( + references + .filter( + (item) => + item.name.trim().length > 0 && (item.scope === "global" || item.companyId === companyId) + ) + .map((item) => [item.id, item]) + ); + const configured = getAssetSoftwareInstallations(installations, companyId, assetId).filter( + (item) => item.values.state === "configured" + ); + + const systems = configured + .flatMap((item) => { + const product = products.get(item.values.resourceId); + + if (product?.kind !== "os") { + return []; + } + const version = item.values.version; + + const normalize = (value: string) => + value.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " "); + + const literalVersion = normalize(version ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const versionInName = + version && + new RegExp(`(^|[^\\p{L}\\p{N}])${literalVersion}($|[^\\p{L}\\p{N}])`, "u").test( + normalize(product.name) + ); + + return [ + { + id: item.id, + resourceId: product.id, + name: product.name, + version: versionInName ? null : version, + needsReview: item.needsReview, + }, + ]; + }) + .sort((a, b) => a.name.localeCompare(b.name, "en") || a.id.localeCompare(b.id, "en")); + + return { + systems, + requiresResolution: configured.length > 0, + unavailable: configured.some((item) => !products.has(item.values.resourceId)), + }; +} diff --git a/modules/companies/src/web/software-installations/shared/queries/use-asset-software.ts b/modules/companies/src/web/software-installations/shared/queries/use-asset-software.ts new file mode 100644 index 00000000..4b6f3c8c --- /dev/null +++ b/modules/companies/src/web/software-installations/shared/queries/use-asset-software.ts @@ -0,0 +1,30 @@ +import { useListQuery } from "@metis/core/client"; +import { useSoftwareReferences } from "@metis/resources/client"; + +import { softwareApi, softwareKeys } from "../api/software-api"; +import { + getAssetSoftwareInstallations, + getCurrentOperatingSystems, +} from "../helpers/current-operating-systems"; + +/** Comparte caché y resolución pública entre la ficha del activo, el puesto y su mapa. */ +export function useAssetSoftware(companyId: string, assetId: string | undefined) { + const query = useListQuery({ + queryKey: softwareKeys.company(companyId), + queryFn: () => softwareApi.get(companyId), + enabled: Boolean(assetId), + }); + const items = getAssetSoftwareInstallations(query.data?.configurations ?? [], companyId, assetId); + const references = useSoftwareReferences( + companyId, + items.map((item) => item.values.resourceId) + ); + const operatingSystems = getCurrentOperatingSystems( + items, + references.references, + companyId, + assetId + ); + + return { query, items, references, operatingSystems }; +} diff --git a/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts b/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts index be0c346d..0a393ccd 100644 --- a/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts +++ b/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts @@ -164,15 +164,18 @@ function globalApplicationQueryMatches( ): boolean { const globalApplications = location.area === "catalogs" && location.catalog === "global-applications"; + const companyApplications = + location.area === "company" && location.section === "resources" && query.template !== undefined; + const applicationCatalog = globalApplications || companyApplications; if ( (query.active !== undefined || query.cursor !== undefined || query.pageSize !== undefined) && - !globalApplications + !applicationCatalog ) { return false; } if ( - globalApplications && + applicationCatalog && ((query.q?.length ?? 0) > 100 || (query.page !== undefined && !/^[1-9][0-9]{0,5}$/u.test(query.page))) ) { diff --git a/modules/companies/src/web/workspace/shared/workspace-routes.test.ts b/modules/companies/src/web/workspace/shared/workspace-routes.test.ts index 1f0c9eb2..7a46dce0 100644 --- a/modules/companies/src/web/workspace/shared/workspace-routes.test.ts +++ b/modules/companies/src/web/workspace/shared/workspace-routes.test.ts @@ -12,6 +12,26 @@ const COMPANY_ID = "019b8838-44af-776d-a406-af74452b6f26"; const ENTITY_ID = "019b8838-44af-776d-a406-af74452b6f27"; const TEMPLATE_ID = "01a1276d-2138-711e-b3d2-99258a02a80f"; +test("el catálogo de aplicaciones de compañía conserva cursor, filtros y retorno a ficha", () => { + const query = `template=${TEMPLATE_ID}&q=Ágil&active=false&pageSize=25&page=2&cursor=abc`; + + for (const suffix of ["", `/${ENTITY_ID}`]) { + assert.deepEqual(parseWorkspaceUrl(`/companies/${COMPANY_ID}/resources${suffix}?${query}`), { + area: "company", + id: COMPANY_ID, + section: "resources", + ...(suffix ? { entityId: ENTITY_ID } : {}), + }); + } + for (const path of [ + `/companies/${COMPANY_ID}/resources?cursor=abc`, + `/companies/${COMPANY_ID}/assets?${query}`, + `/companies/${COMPANY_ID}/resources?template=${TEMPLATE_ID}&page=NaN`, + ]) { + assert.deepEqual(parseWorkspaceUrl(path), { area: "invalid-route" }); + } +}); + test("las funcionalidades archivadas no tienen rutas activas", () => { for (const path of [ "/tickets", diff --git a/modules/companies/src/web/workstations/detail/controllers/use-workplace-map-model.ts b/modules/companies/src/web/workstations/detail/controllers/use-workplace-map-model.ts index e68ae1b6..5e5cb439 100644 --- a/modules/companies/src/web/workstations/detail/controllers/use-workplace-map-model.ts +++ b/modules/companies/src/web/workstations/detail/controllers/use-workplace-map-model.ts @@ -23,18 +23,22 @@ export function useWorkplaceMapModel(companyId: string, c: WorkstationDetailCont catalog: catalog?.items ?? [], installations: installations?.configurations ?? [], softwareReference: (id) => { - const global = references?.find((item) => item.id === id && item.scope === "global"); + const reference = references?.find( + (item) => + item.id === id && (item.scope === "global" || item.companyId === companyId) + ); const local = resources?.resources.find( (item) => item.id === id && item.companyId === companyId ); return { - name: global?.name ?? local?.values.name ?? "—", - href: global - ? `/catalogs/global-applications/${id}` - : local - ? `/companies/${companyId}/resources/${id}` - : undefined, + name: reference?.name ?? local?.values.name ?? "—", + href: + reference?.scope === "global" + ? `/catalogs/global-applications/${id}` + : reference || local + ? `/companies/${companyId}/resources/${id}` + : undefined, }; }, }) diff --git a/modules/companies/src/web/workstations/detail/controllers/use-workstation-software.ts b/modules/companies/src/web/workstations/detail/controllers/use-workstation-software.ts index b7b5267a..e23496a3 100644 --- a/modules/companies/src/web/workstations/detail/controllers/use-workstation-software.ts +++ b/modules/companies/src/web/workstations/detail/controllers/use-workstation-software.ts @@ -1,7 +1,4 @@ -import { useListQuery } from "@metis/core/client"; -import { useSoftwareReferences } from "@metis/resources/client"; - -import { softwareApi, softwareKeys } from "../../../software-installations/shared/api/software-api"; +import { useAssetSoftware } from "../../../software-installations/shared/queries/use-asset-software"; import type { useWorkstationConfigurationQueries } from "./use-workstation-configuration-queries"; @@ -11,29 +8,8 @@ export function useWorkstationSoftware( computerId: string | undefined, resources: ReturnType["resources"] ) { - const query = useListQuery({ - queryKey: softwareKeys.company(companyId), - queryFn: () => softwareApi.get(companyId), - enabled: Boolean(computerId), - }); - const items = - query.data?.configurations.filter( - (item) => - item.companyId === companyId && - item.values.assetId === computerId && - !["removed", "never_configured"].includes(item.values.state) - ) ?? []; - const references = useSoftwareReferences( - companyId, - resources.data - ? items - .filter( - (item) => - !resources.data.resources.some((resource) => resource.id === item.values.resourceId) - ) - .map((item) => item.values.resourceId) - : [] - ); + const software = useAssetSoftware(companyId, computerId); + const { references } = software; const globalReference = (id: string) => references.references.find((item) => item.id === id && item.scope === "global"); @@ -41,5 +17,5 @@ export function useWorkstationSoftware( const resource = (id: string) => resources.data?.resources.find((item) => item.id === id && item.companyId === companyId); - return { query, items, references, globalReference, resource }; + return { ...software, globalReference, resource }; } diff --git a/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.test.ts b/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.test.ts index ef09d752..1265f516 100644 --- a/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.test.ts +++ b/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.test.ts @@ -188,3 +188,37 @@ test("preview: conserva límites y publica solo cobertura real de fuentes conoci [] ); }); + +test("WD04.4: acción estable de presentación, fuera de las relaciones y contadores, sin solapamientos", () => { + const action = { onAdd: () => undefined, disabled: false }; + + for (const width of [640, 720, 900, 1200]) { + for (const count of [0, 1, 2]) { + for (const withPrincipal of [false, true]) { + const preview = workplaceMapPreview(mapFixture(count, 3, withPrincipal), { + equipmentKnown: true, + softwareKnown: true, + }); + const original = workplaceMapLayout(preview.nodes, width); + const layout = workplaceMapLayout(preview.nodes, width, action); + + assertNoOverlap(layout.nodes); + assert.equal(layout.nodes.filter((node) => node.type === "addAsset").length, 1); + assert.equal( + layout.nodes.find((node) => node.type === "addAsset")?.id, + "presentation:add-asset" + ); + assert.equal(layout.edges.length, original.edges.length); + assert.ok( + layout.edges.every( + (edge) => + !(edge.source.includes("presentation:") || edge.target.includes("presentation:")) + ) + ); + assert.deepEqual(layout.bounds, workplaceMapLayout(preview.nodes, width, action).bounds); + } + } + } + assert.equal(workplaceMapLayout([], 900).nodes.length, 0); + assert.equal(workplaceMapLayout([], 900, action).nodes.length, 1); +}); diff --git a/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.ts b/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.ts index df38d676..e142f048 100644 --- a/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.ts +++ b/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.ts @@ -2,6 +2,8 @@ import dagre from "@dagrejs/dagre"; import type { Edge, Node } from "@xyflow/react"; import { Position } from "@xyflow/react"; +import type { WorkplaceAddAssetAction } from "../ui/workplace-add-asset-node"; + import type { WorkplaceMapNode } from "./workplace-map-model"; export const principalSize = { width: 264, height: 192 }; @@ -10,14 +12,26 @@ export const equipmentSize = { width: 212, height: 120 }; export const softwareSize = { width: 204, height: 132 }; +export type WorkplaceActionNode = Node; + +const actionSize = { width: 212, height: 96 }; + const softwareHeadingSize = { width: 280, height: 28 }; -type WorkplaceLayoutNode = (WorkplaceMapNode | Node, "softwareHeading">) & { +type WorkplaceLayoutNode = ( + | WorkplaceMapNode + | WorkplaceActionNode + | Node, "softwareHeading"> +) & { width: number; height: number; }; -function nodeSize(node: WorkplaceMapNode) { +function nodeSize(node: WorkplaceMapNode | WorkplaceActionNode) { + if (node.type === "addAsset") { + return actionSize; + } + return node.type === "equipment" ? node.data.principal ? principalSize @@ -26,7 +40,7 @@ function nodeSize(node: WorkplaceMapNode) { } function equipmentLayout( - equipment: WorkplaceMapNode[], + equipment: (WorkplaceMapNode | WorkplaceActionNode)[], principal: WorkplaceMapNode | undefined, availableWidth: number ) { @@ -39,7 +53,7 @@ function equipmentLayout( } if (principal) { for (const [index, node] of peripherals.entries()) { - if (availableWidth >= 820 && index % 2 === 1) { + if (availableWidth >= 720 && index % 2 === 1) { lateral.setEdge(principal.id, node.id); } else { lateral.setEdge(node.id, principal.id); @@ -52,9 +66,28 @@ function equipmentLayout( } /** Dagre distribuye equipamiento y software; el rótulo y la cámara se derivan de sus bounds. */ -export function workplaceMapLayout(nodes: WorkplaceMapNode[], availableWidth = 900) { +export function workplaceMapLayout( + nodes: WorkplaceMapNode[], + availableWidth = 900, + addAsset?: WorkplaceAddAssetAction +) { const principal = nodes.find((node) => node.type === "equipment" && node.data.principal); - const equipment = nodes.filter((node) => node.type === "equipment"); + const action: WorkplaceActionNode[] = addAsset + ? [ + { + id: "presentation:add-asset", + type: "addAsset", + data: addAsset, + position: { x: 0, y: 0 }, + draggable: false, + selectable: false, + focusable: false, + ariaRole: "presentation", + }, + ] + : []; + const displayNodes = [...nodes, ...action]; + const equipment = displayNodes.filter((node) => node.type !== "software"); const software = nodes.filter((node) => node.type === "software"); const lateral = equipmentLayout(equipment, principal, availableWidth); const lower = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); @@ -80,9 +113,9 @@ export function workplaceMapLayout(nodes: WorkplaceMapNode[], availableWidth = 9 const lowerTop = Math.min( ...software.map((node) => lower.node(node.id).y - softwareSize.height / 2) ); - const positioned: WorkplaceLayoutNode[] = nodes.map((node) => { + const positioned: WorkplaceLayoutNode[] = displayNodes.map((node) => { const size = nodeSize(node); - const equipmentNode = node.type === "equipment"; + const equipmentNode = node.type !== "software"; const position = equipmentNode ? lateral.node(node.id) : lower.node(node.id); const x = position.x + @@ -154,6 +187,6 @@ export function workplaceMapLayout(nodes: WorkplaceMapNode[], availableWidth = 9 nodes: normalized, edges, bounds, - canvasHeight: nodes.length ? Math.ceil(bounds.height * scale) + 32 : 0, + canvasHeight: displayNodes.length ? Math.ceil(bounds.height * scale) + 32 : 0, }; } diff --git a/modules/companies/src/web/workstations/detail/helpers/workplace-map-model.ts b/modules/companies/src/web/workstations/detail/helpers/workplace-map-model.ts index 17b00941..9442d463 100644 --- a/modules/companies/src/web/workstations/detail/helpers/workplace-map-model.ts +++ b/modules/companies/src/web/workstations/detail/helpers/workplace-map-model.ts @@ -2,6 +2,7 @@ import type { AssetDTO, CatalogItemDTO, WorkstationDTO } from "@metis/assets/com import type { Node } from "@xyflow/react"; import type { SoftwareInstallationDTO } from "../../../../common/software-installations"; +import { getAssetSoftwareInstallations } from "../../../software-installations/shared/helpers/current-operating-systems"; import { workstationEquipment } from "./workstation-equipment"; @@ -63,23 +64,20 @@ export function workplaceMapModel({ }); const principal = nodes.find((node) => node.type === "equipment" && node.data.principal); const software: WorkplaceMapNode[] = principal - ? installations - .filter( - (item) => - item.companyId === companyId && - `asset:${item.values.assetId}` === principal.id && - !["removed", "never_configured"].includes(item.values.state) - ) - .map((item) => ({ - id: `software:${item.id}`, - type: "software", - position: { x: 0, y: 0 }, - data: { - ...softwareReference(item.values.resourceId), - state: item.values.state, - needsReview: item.needsReview, - }, - })) + ? getAssetSoftwareInstallations( + installations, + companyId, + principal.id.slice("asset:".length) + ).map((item) => ({ + id: `software:${item.id}`, + type: "software", + position: { x: 0, y: 0 }, + data: { + ...softwareReference(item.values.resourceId), + state: item.values.state, + needsReview: item.needsReview, + }, + })) : []; return { equipment: nodes, software, principal, principalState: equipment.principalState }; diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-add-asset-node.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-add-asset-node.tsx new file mode 100644 index 00000000..db6acede --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-add-asset-node.tsx @@ -0,0 +1,34 @@ +import { PlusIcon } from "@repo/rdx-ui/lib/icons"; +import { Button } from "@repo/shadcn-ui/components/button"; +import type { MouseEventHandler } from "react"; +import { useTranslation } from "react-i18next"; + +export type WorkplaceAddAssetAction = { + onAdd: MouseEventHandler; + disabled: boolean; +}; + +/** Acción de presentación compartida por canvas y lectura compacta; nunca representa un Asset. */ +export function WorkplaceAddAssetNode({ data }: { data: WorkplaceAddAssetAction }) { + const { t } = useTranslation("assets"); + + return ( + + ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-compact-map.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-compact-map.tsx index dbf1a848..28786eca 100644 --- a/modules/companies/src/web/workstations/detail/ui/workplace-compact-map.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workplace-compact-map.tsx @@ -2,11 +2,19 @@ import { useTranslation } from "react-i18next"; import type { WorkplaceMapNode } from "../helpers/workplace-map-model"; +import type { WorkplaceAddAssetAction } from "./workplace-add-asset-node"; +import { WorkplaceAddAssetNode } from "./workplace-add-asset-node"; import { WorkplaceEquipmentCard } from "./workplace-equipment-card"; import { WorkplaceSoftwareCard } from "./workplace-software-card"; /** Alternativa de lectura sin escalado: conserva las mismas identidades y enlaces del canvas. */ -export function WorkplaceCompactMap({ nodes }: { nodes: readonly WorkplaceMapNode[] }) { +export function WorkplaceCompactMap({ + nodes, + addAsset, +}: { + nodes: readonly WorkplaceMapNode[]; + addAsset?: WorkplaceAddAssetAction | undefined; +}) { const { t } = useTranslation("assets"); const principal = nodes.find((node) => node.type === "equipment" && node.data.principal); const peripherals = nodes.filter( @@ -30,6 +38,7 @@ export function WorkplaceCompactMap({ nodes }: { nodes: readonly WorkplaceMapNod )} + {addAsset && } {software.length > 0 && ( <>

{t("workstationDashboard.software")}

diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-map-canvas.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-map-canvas.tsx index dfb1c84c..73c0a287 100644 --- a/modules/companies/src/web/workstations/detail/ui/workplace-map-canvas.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workplace-map-canvas.tsx @@ -7,12 +7,15 @@ import { useTranslation } from "react-i18next"; import { workplaceMapLayout } from "../helpers/workplace-map-layout"; import type { WorkplaceMapNode } from "../helpers/workplace-map-model"; +import type { WorkplaceAddAssetAction } from "./workplace-add-asset-node"; +import { WorkplaceAddAssetNode } from "./workplace-add-asset-node"; import { WorkplaceEquipmentNode } from "./workplace-equipment-node"; import { WorkplaceRelationshipEdge } from "./workplace-relationship-edge"; import { WorkplaceSoftwareHeadingNode } from "./workplace-software-heading-node"; import { WorkplaceSoftwareNode } from "./workplace-software-node"; const nodeTypes = { + addAsset: WorkplaceAddAssetNode, equipment: WorkplaceEquipmentNode, software: WorkplaceSoftwareNode, softwareHeading: WorkplaceSoftwareHeadingNode, @@ -22,12 +25,17 @@ const edgeTypes = { relationship: WorkplaceRelationshipEdge }; export default function WorkplaceMapCanvas({ nodes, availableWidth, + addAsset, }: { nodes: WorkplaceMapNode[]; availableWidth: number; + addAsset?: WorkplaceAddAssetAction | undefined; }) { const { t } = useTranslation("assets"); - const layout = useMemo(() => workplaceMapLayout(nodes, availableWidth), [nodes, availableWidth]); + const layout = useMemo( + () => workplaceMapLayout(nodes, availableWidth, addAsset), + [nodes, availableWidth, addAsset] + ); const geometry = layout.nodes .map((node) => `${node.id}:${node.position.x}:${node.position.y}:${node.width}:${node.height}`) diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-map-presentation.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-map-presentation.tsx new file mode 100644 index 00000000..7ddaadac --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-map-presentation.tsx @@ -0,0 +1,33 @@ +import { Skeleton } from "@repo/shadcn-ui/components/skeleton"; +import { Suspense, lazy } from "react"; + +import type { WorkplaceMapNode } from "../helpers/workplace-map-model"; + +import type { WorkplaceAddAssetAction } from "./workplace-add-asset-node"; +import { WorkplaceCompactMap } from "./workplace-compact-map"; + +const WorkplaceMapCanvas = lazy(() => import("./workplace-map-canvas")); + +/** Selecciona la presentación por ancho real, conservando las mismas acciones y datos. */ +export function WorkplaceMapPresentation({ + nodes, + availableWidth, + addAsset, +}: { + nodes: WorkplaceMapNode[]; + availableWidth: number; + addAsset: WorkplaceAddAssetAction | undefined; +}) { + if (!(nodes.length || addAsset)) { + return null; + } + if (availableWidth < 640) { + return ; + } + + return ( + }> + + + ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-visual-map-card.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-visual-map-card.tsx index f24e1f26..19407646 100644 --- a/modules/companies/src/web/workstations/detail/ui/workplace-visual-map-card.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workplace-visual-map-card.tsx @@ -1,32 +1,34 @@ +import { WorkstationAssetAddDialog } from "@metis/assets/client"; import { SoftwareReferenceFeedback } from "@metis/resources/client"; import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; import { MonitorIcon } from "@repo/rdx-ui/lib/icons"; import { Button } from "@repo/shadcn-ui/components/button"; -import { Skeleton } from "@repo/shadcn-ui/components/skeleton"; -import type { MouseEventHandler } from "react"; -import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react"; +import type { ComponentProps, MouseEvent, MouseEventHandler } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useWorkplaceMapModel } from "../controllers/use-workplace-map-model"; import type { WorkstationDetailController } from "../controllers/use-workstation-detail-controller"; import { workplaceMapPreview } from "../helpers/workplace-map-preview"; -import { WorkplaceCompactMap } from "./workplace-compact-map"; import { WorkplaceMapCoverage } from "./workplace-map-coverage"; import { WorkplaceMapLegend } from "./workplace-map-legend"; +import { WorkplaceMapPresentation } from "./workplace-map-presentation"; import { WorkstationAssets } from "./workstation-assets"; import { WorkstationEquipmentGuidance } from "./workstation-equipment-guidance"; import { WorkstationQueryFeedback } from "./workstation-query-feedback"; -const WorkplaceMapCanvas = lazy(() => import("./workplace-map-canvas")); - export function WorkplaceVisualMapCard({ companyId, controller: c, onEdit, onViewSoftware, + lookups, + canEdit, }: { companyId: string; + lookups: ComponentProps["lookups"]; + canEdit: boolean; controller: WorkstationDetailController; onEdit: MouseEventHandler; onViewSoftware: () => void; @@ -34,6 +36,8 @@ export function WorkplaceVisualMapCard({ const { t } = useTranslation("assets"); const container = useRef(null); const [width, setWidth] = useState(0); + const [adding, setAdding] = useState(false); + const addReturn = useRef(null); const [expanded, setExpanded] = useState(false); const { query, references } = c.software; const model = useWorkplaceMapModel(companyId, c); @@ -63,7 +67,17 @@ export function WorkplaceVisualMapCard({ return null; } const { peripherals, nodes: visible } = preview; - const compact = width < 640; + + const addAsset = + canEdit && c.workstation && equipmentKnown + ? { + disabled: c.workstation.values.assetIds.length >= 200, + onAdd: (event: MouseEvent) => { + addReturn.current = event.currentTarget; + setAdding(true); + }, + } + : undefined; return ( - {visible.length > 0 && - (compact ? ( - - ) : ( - }> - - - ))} + {model.principal && ( <> @@ -116,6 +123,15 @@ export function WorkplaceVisualMapCard({ )} )} + {adding && c.workstation && ( + mapAddFinalFocus(addReturn.current, container.current)} + lookups={lookups} + onClose={() => setAdding(false)} + workstation={c.workstation} + /> + )} ); } + +function mapAddFinalFocus(trigger: HTMLButtonElement | null, container: HTMLDivElement | null) { + if (trigger?.isConnected) { + return trigger; + } + + return container?.querySelector("[data-workplace-add]") ?? true; +} diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-detail-content.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-detail-content.tsx index 190f32b5..b3db7bd9 100644 --- a/modules/companies/src/web/workstations/detail/ui/workstation-detail-content.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workstation-detail-content.tsx @@ -1,9 +1,11 @@ +import type { WorkstationAssetAddDialog } from "@metis/assets/client"; import { entityListState } from "@repo/rdx-ui/lib/entity-list-state"; -import type { MouseEventHandler } from "react"; +import type { ComponentProps, MouseEventHandler } from "react"; import { useRef } from "react"; import { useTranslation } from "react-i18next"; import type { CompanyDTO } from "../../../../common"; +import { CurrentOperatingSystems } from "../../../software-installations/detail/ui/current-operating-systems"; import { buildEnvironment } from "../../../work-environments/shared/helpers"; import type { WorkstationDetailController } from "../controllers/use-workstation-detail-controller"; import { workstationProfileAvailability } from "../helpers/workstation-profile-availability"; @@ -24,8 +26,10 @@ export function WorkstationDetailContent({ controller: c, onHistory, onEdit, + lookups, }: { company: CompanyDTO; + lookups: ComponentProps["lookups"]; controller: WorkstationDetailController; onHistory: () => void; onEdit: MouseEventHandler; @@ -63,9 +67,11 @@ export function WorkstationDetailContent({
{ softwarePanel.current?.scrollIntoView({ block: "center" }); @@ -141,6 +147,9 @@ export function WorkstationDetailContent({
+ {model.computer && ( + + )}
diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-detail.test.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-detail.test.tsx index b460a102..2970df4f 100644 --- a/modules/companies/src/web/workstations/detail/ui/workstation-detail.test.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workstation-detail.test.tsx @@ -31,12 +31,14 @@ import { WorkstationResponsibleContext } from "../../../people/shared"; import { companiesEnglish, companiesSpanish } from "../../../shared"; import { softwareKeys } from "../../../software-installations/shared/api/software-api"; import { profileKeys } from "../../../work-profiles/shared/api"; +import { useWorkstationDetailController } from "../controllers/use-workstation-detail-controller"; import { workplaceMapLayout } from "../helpers/workplace-map-layout"; import { workplaceMapModel } from "../helpers/workplace-map-model"; import { WorkplaceMapCoverage } from "./workplace-map-coverage"; import { WorkplaceMapLegend } from "./workplace-map-legend"; import { WorkplaceSoftwareCard } from "./workplace-software-card"; +import { WorkplaceVisualMapCard } from "./workplace-visual-map-card"; import { WorkstationAssets } from "./workstation-assets"; import { WorkstationConfigurationItem } from "./workstation-configuration-item"; import { WorkstationDetailPage } from "./workstation-detail-page"; @@ -878,7 +880,7 @@ test("cabecera es/en: responsable largo o ausente, contexto independiente y mapa assert.equal(header.includes(`href="/companies/${companyId}"`), false); assert.equal(header.includes(company.name), false); assert.equal(html.includes(`${labels.workstationDashboard.responsibleContext}`), false); - assert.equal([...html.matchAll(/ undefined, + onCreateSite: () => undefined, + }} + onEdit={() => undefined} + onViewSoftware={() => undefined} + /> + ); +} + +test("WD04.4 es/en: acción autorizada, vacío accionable y lectura sin permiso", async () => { + for (const language of ["es", "en"]) { + const labels = language === "es" ? assetsSpanish : assetsEnglish; + const allowed = await render(language, true, { node: }); + const readonly = await render(language, true, { node: }); + + assert.ok(allowed.includes(`aria-label="${labels.workstationAdd.title}"`)); + assert.ok(allowed.includes('data-workplace-add="true"')); + assert.equal(readonly.includes("data-workplace-add"), false); + assert.ok(readonly.includes(labels.workplaceMap.title)); + assert.equal(allowed.includes("data-workplace-equipment"), false); + assert.equal(allowed.includes('role="dialog"'), false); + } +}); diff --git a/modules/resources/src/api/application/resource-reference-service.ts b/modules/resources/src/api/application/resource-reference-service.ts index d1d3c385..ea9ee06d 100644 --- a/modules/resources/src/api/application/resource-reference-service.ts +++ b/modules/resources/src/api/application/resource-reference-service.ts @@ -6,6 +6,7 @@ import type { GlobalApplicationDetail, ListApplicationCandidatesRequestDTO, ListApplicationCandidatesResponseDTO, + ListCompanyApplicationsResponseDTO, ListGlobalApplicationsRequestDTO, ListGlobalApplicationsResponseDTO, ResolveApplicationReferencesRequestDTO, @@ -40,6 +41,10 @@ export interface IResourceReferenceSession { } export interface IResourceReferenceReader { + applications( + company: UniqueID, + query: ListGlobalApplicationsRequestDTO + ): Promise>; capabilities( company: UniqueID ): Promise>; diff --git a/modules/resources/src/api/infrastructure/http/resource-application.routes.ts b/modules/resources/src/api/infrastructure/http/resource-application.routes.ts index b3959c4c..1fd68cb9 100644 --- a/modules/resources/src/api/infrastructure/http/resource-application.routes.ts +++ b/modules/resources/src/api/infrastructure/http/resource-application.routes.ts @@ -5,6 +5,7 @@ import { z } from "zod/v4"; import { ListApplicationCandidatesRequestSchema, + ListGlobalApplicationsRequestSchema, ReferenceCompanyParamsSchema, ResolveApplicationReferencesRequestSchema, ResourceClientContractSchema, @@ -29,6 +30,24 @@ export function registerResourceApplicationRoutes( ) { const candidates = Router(); + candidates.get("/:companyId/catalog", (request, response, next) => { + const params = ReferenceCompanyParamsSchema.safeParse(request.params); + const query = ListGlobalApplicationsRequestSchema.safeParse(request.query); + const company = params.success ? UniqueID.create(params.data.companyId) : undefined; + + if (!(company?.isSuccess && query.success)) { + fail(response, "INVALID_REQUEST"); + + return; + } + reader + .applications(company.data, query.data) + .then((result) => + result.isSuccess ? response.json(result.data) : fail(response, result.error.code) + ) + .catch(next); + }); + candidates.get("/:companyId/capabilities", (request, response, next) => { const params = ReferenceCompanyParamsSchema.safeParse(request.params); diff --git a/modules/resources/src/api/infrastructure/persistence/resource-reference-reader.ts b/modules/resources/src/api/infrastructure/persistence/resource-reference-reader.ts index 23f824c0..9c1cba95 100644 --- a/modules/resources/src/api/infrastructure/persistence/resource-reference-reader.ts +++ b/modules/resources/src/api/infrastructure/persistence/resource-reference-reader.ts @@ -10,9 +10,11 @@ import type { ResolveApplicationReferencesRequestDTO, } from "../../../common"; import { + CompanyApplicationSummarySchema, GlobalApplicationDetailSchema, GlobalApplicationSummarySchema, ListApplicationCandidatesResponseSchema, + ListCompanyApplicationsResponseSchema, ResolveApplicationReferencesRequestSchema, ResolveApplicationReferencesResponseSchema, ResourceCandidateSchema, @@ -213,6 +215,38 @@ export class ResourceReferenceReader implements IResourceReferenceReader, IGloba }); } + applications(company: UniqueID, query: ListGlobalApplicationsRequestDTO) { + return this.database.transaction(async (transaction) => { + if (!(await this.companyExists(transaction, company))) { + return Result.fail(new ResourceReferenceError("COMPANY_NOT_FOUND")); + } + const metadata = await readResourceReferenceMetadata( + this.database, + transaction, + await this.schema.read(), + { companyId: company.toPrimitive() } + ); + const items = metadata.flatMap(({ reference, provider }) => + reference?.kind === "application" && + (query.active === undefined || reference.active === query.active) + ? [CompanyApplicationSummarySchema.parse({ ...reference, provider })] + : [] + ); + const page = paginateResourceMetadata(items, { + ...query, + context: JSON.stringify([ + "company-applications", + company.toPrimitive(), + query.active ?? null, + ]), + }); + + return page.isSuccess + ? Result.ok(ListCompanyApplicationsResponseSchema.parse(page.data)) + : page; + }); + } + list(query: ListGlobalApplicationsRequestDTO) { return this.database.transaction(async (transaction) => { const rows = await readResourceReferenceMetadata( diff --git a/modules/resources/src/common/dto/response/resource-application.response.dto.ts b/modules/resources/src/common/dto/response/resource-application.response.dto.ts index 41b227b2..f2d84359 100644 --- a/modules/resources/src/common/dto/response/resource-application.response.dto.ts +++ b/modules/resources/src/common/dto/response/resource-application.response.dto.ts @@ -8,6 +8,7 @@ import { import { ApplicationCandidateSchema, ApplicationReferenceSchema, + CompanyApplicationSummarySchema, GlobalApplicationDetailSchema, GlobalApplicationSummarySchema, GlobalResourceAdminSchema, @@ -15,6 +16,15 @@ import { SoftwareReferenceSchema, } from "../shared/application-reference.dto"; +export const ListCompanyApplicationsResponseSchema = z.strictObject({ + items: z.array(CompanyApplicationSummarySchema), + nextCursor: z.string().nullable(), +}); + +export type ListCompanyApplicationsResponseDTO = z.infer< + typeof ListCompanyApplicationsResponseSchema +>; + export const ApplicationReferenceCapabilitiesSchema = z.strictObject({ softwareSelection: z.boolean(), softwareAssociation: z.boolean(), diff --git a/modules/resources/src/common/dto/shared/application-reference.dto.ts b/modules/resources/src/common/dto/shared/application-reference.dto.ts index f349465f..a832c8a5 100644 --- a/modules/resources/src/common/dto/shared/application-reference.dto.ts +++ b/modules/resources/src/common/dto/shared/application-reference.dto.ts @@ -38,6 +38,13 @@ export const SoftwareReferenceSchema = z.union([ LocalOsReferenceSchema, ]); +export const CompanyApplicationSummarySchema = z.union([ + ApplicationReferenceSchema.options[0].extend({ provider: z.string().nullable() }), + ApplicationReferenceSchema.options[1].extend({ provider: z.string().nullable() }), +]); + +export type CompanyApplicationSummary = z.infer; + export const ApplicationCandidateSchema = z.union([ ApplicationReferenceSchema.options[0].extend({ active: z.literal(true), diff --git a/modules/resources/src/common/locales/en.json b/modules/resources/src/common/locales/en.json index fa4ef2fe..9850e279 100644 --- a/modules/resources/src/common/locales/en.json +++ b/modules/resources/src/common/locales/en.json @@ -381,5 +381,11 @@ "provider-missing": "Provider is missing on one or both resources; matching manufacturers are not assumed.", "inactive-global": "Inactive global: unavailable for new associations." } + }, + "companyApplications": { + "title": "Available applications", + "origin": "Origin", + "create": "Add company application", + "description": "Includes global applications and applications belonging to this company. New applications are saved only in this company." } } diff --git a/modules/resources/src/common/locales/es.json b/modules/resources/src/common/locales/es.json index 5a16e4f6..6e6f527e 100644 --- a/modules/resources/src/common/locales/es.json +++ b/modules/resources/src/common/locales/es.json @@ -381,5 +381,11 @@ "provider-missing": "Falta proveedor en uno o ambos recursos; no se presume coincidencia de fabricante.", "inactive-global": "Global inactivo: no elegible para nuevas asociaciones." } + }, + "companyApplications": { + "title": "Aplicaciones disponibles", + "origin": "Origen", + "create": "Añadir aplicación de compañía", + "description": "Incluye las aplicaciones globales y las propias de esta compañía. Las nuevas aplicaciones se guardan solo en esta compañía." } } diff --git a/modules/resources/src/web/application-references/shared/api/application-references-api.ts b/modules/resources/src/web/application-references/shared/api/application-references-api.ts index 344c8fa5..05bfe367 100644 --- a/modules/resources/src/web/application-references/shared/api/application-references-api.ts +++ b/modules/resources/src/web/application-references/shared/api/application-references-api.ts @@ -1,10 +1,15 @@ import { csrfFetch } from "@metis/core/client"; import { z } from "zod/v4"; -import type { ResourcePurpose, SoftwareReference } from "../../../../common"; +import type { + ListGlobalApplicationsRequestDTO, + ResourcePurpose, + SoftwareReference, +} from "../../../../common"; import { ApplicationReferenceCapabilitiesSchema, ListApplicationCandidatesResponseSchema, + ListCompanyApplicationsResponseSchema, ResolveApplicationReferencesResponseSchema, } from "../../../../common"; @@ -37,6 +42,23 @@ async function request(path: string, signal: AbortSignal, body?: unknown) { } export const applicationReferencesApi = { + catalog: async ( + company: string, + query: ListGlobalApplicationsRequestDTO, + signal: AbortSignal + ) => { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + params.set(key, String(value)); + } + } + + return ListCompanyApplicationsResponseSchema.parse( + await request(`candidates/${encodeURIComponent(company)}/catalog?${params}`, signal) + ); + }, capabilities: async (company: string, signal: AbortSignal) => ApplicationReferenceCapabilitiesSchema.parse( await request(`candidates/${encodeURIComponent(company)}/capabilities`, signal) diff --git a/modules/resources/src/web/application-references/shared/queries/application-reference-keys.ts b/modules/resources/src/web/application-references/shared/queries/application-reference-keys.ts index 7d7b41c8..8dfdbfe2 100644 --- a/modules/resources/src/web/application-references/shared/queries/application-reference-keys.ts +++ b/modules/resources/src/web/application-references/shared/queries/application-reference-keys.ts @@ -1,6 +1,11 @@ -import type { ResourcePurpose } from "../../../../common"; +import type { ListGlobalApplicationsRequestDTO, ResourcePurpose } from "../../../../common"; export const applicationReferenceKeys = { + catalog: (company: string, query: ListGlobalApplicationsRequestDTO) => [ + "company-application-catalog", + company, + query, + ], capabilities: (company: string, purpose: ResourcePurpose = "software-installation") => [ "resource-application-candidates", company, diff --git a/modules/resources/src/web/global-applications/shared/queries/global-applications-keys.ts b/modules/resources/src/web/global-applications/shared/queries/global-applications-keys.ts index 22144e21..ccdf1508 100644 --- a/modules/resources/src/web/global-applications/shared/queries/global-applications-keys.ts +++ b/modules/resources/src/web/global-applications/shared/queries/global-applications-keys.ts @@ -34,6 +34,7 @@ export const globalApplicationKeys = { export async function invalidateGlobalApplication(client: QueryClient, id: string) { await Promise.all([ + client.invalidateQueries({ queryKey: ["company-application-catalog"] }), client.invalidateQueries({ queryKey: ["resource-application-coincidences"] }), client.invalidateQueries({ queryKey: ["resource-global-applications", "list"] }), client.invalidateQueries({ queryKey: globalApplicationKeys.detail(id) }), diff --git a/modules/resources/src/web/resources/list/controllers/use-company-applications-controller.ts b/modules/resources/src/web/resources/list/controllers/use-company-applications-controller.ts new file mode 100644 index 00000000..ff497735 --- /dev/null +++ b/modules/resources/src/web/resources/list/controllers/use-company-applications-controller.ts @@ -0,0 +1,87 @@ +import { navigateTo, useBrowserSearchParams, useListQuery } from "@metis/core/client"; +import { useState } from "react"; + +import { ListGlobalApplicationsRequestSchema } from "../../../../common"; +import { + ApplicationReferenceApiError, + applicationReferencesApi, +} from "../../../application-references/shared/api/application-references-api"; +import { applicationReferenceKeys } from "../../../application-references/shared/queries/application-reference-keys"; + +export function useCompanyApplicationsController(companyId: string) { + const { searchParams } = useBrowserSearchParams(); + const search = searchParams.get("q") ?? ""; + const state = searchParams.get("active") ?? "all"; + const pageIndex = Math.max(0, Number(searchParams.get("page") ?? "1") - 1); + const [previous, setPrevious] = useState<(string | undefined)[]>([]); + const parsed = ListGlobalApplicationsRequestSchema.safeParse({ + search, + ...(state === "all" ? {} : { active: state }), + sort: searchParams.get("sort") ?? "name", + direction: searchParams.get("direction") ?? "asc", + pageSize: searchParams.get("pageSize") ?? "25", + ...(searchParams.get("cursor") ? { cursor: searchParams.get("cursor") } : {}), + }); + const queryData = parsed.success ? parsed.data : undefined; + const query = useListQuery({ + queryKey: queryData + ? applicationReferenceKeys.catalog(companyId, queryData) + : ["company-application-catalog", companyId, "invalid"], + queryFn: ({ signal }) => { + if (!queryData) { + throw new ApplicationReferenceApiError("INVALID_REQUEST"); + } + + return applicationReferencesApi.catalog(companyId, queryData, signal); + }, + enabled: Boolean(queryData), + placeholderData: (previousData, previousQuery) => + previousQuery?.queryKey[1] === companyId ? previousData : undefined, + }); + + const update = (values: Record, reset = true) => { + const next = new URLSearchParams(searchParams); + + if (reset) { + next.delete("cursor"); + next.delete("page"); + setPrevious([]); + } + for (const [key, value] of Object.entries(values)) { + if (value === undefined || value === "") { + next.delete(key); + } else { + next.set(key, value); + } + } + navigateTo(`/companies/${companyId}/resources${next.size ? `?${next}` : ""}`, { + replace: reset, + }); + }; + + return { + query, + denied: + query.error instanceof ApplicationReferenceApiError && + ["FORBIDDEN", "UNAUTHENTICATED"].includes(query.error.code), + suffix: searchParams.size ? `?${searchParams}` : "", + queryData, + invalid: !parsed.success, + search, + state, + pageIndex, + update, + nextPage: () => { + if (query.data?.nextCursor && !query.isPlaceholderData) { + setPrevious([...previous, queryData?.cursor]); + update({ cursor: query.data.nextCursor, page: String(pageIndex + 2) }, false); + } + }, + previousPage: () => { + const cursor = previous.at(-1); + + setPrevious(previous.slice(0, -1)); + update({ cursor, page: String(previous.length ? Math.max(1, pageIndex) : 1) }, false); + }, + }; +} diff --git a/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts b/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts index 245520d5..947d7dcf 100644 --- a/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts +++ b/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts @@ -125,6 +125,9 @@ export function useResourcesPageController({ const templateId = templates.some((item) => item.id === requestedTemplateId) ? requestedTemplateId : null; + const applicationTemplate = + templates.find((item) => item.id === templateId)?.builtinKey === "template:application"; + const creationKind: "application" | undefined = applicationTemplate ? "application" : undefined; useEffect(() => { if ( @@ -138,6 +141,9 @@ export function useResourcesPageController({ params.set("template", templateId); } else { params.delete("template"); + params.delete("cursor"); + params.delete("active"); + params.delete("pageSize"); } }); } @@ -146,6 +152,11 @@ export function useResourcesPageController({ const setTemplateId = (value: string) => setSearchParams((params) => { params.delete("page"); + params.delete("cursor"); + params.delete("active"); + params.delete("sort"); + params.delete("direction"); + params.delete("pageSize"); if (value === "all") { params.delete("template"); } else { @@ -182,6 +193,13 @@ export function useResourcesPageController({ deleteId, setDeleteId, statusMutation, + toggleResourceStatus: (id: string) => { + const resource = resources.find((item) => item.id === id); + + if (resource) { + statusMutation.mutate(resource); + } + }, resourceDeleted: (id: string) => { if (selectedId === id) { if (routed) { @@ -206,6 +224,9 @@ export function useResourcesPageController({ catalog, templates, templateId, + applicationTemplate, + creationKind, + creationLabel: applicationTemplate ? "companyApplications.create" : "newResource", setTemplateId, clearFilters, edited, diff --git a/modules/resources/src/web/resources/list/ui/company-applications-list.tsx b/modules/resources/src/web/resources/list/ui/company-applications-list.tsx new file mode 100644 index 00000000..c26533f9 --- /dev/null +++ b/modules/resources/src/web/resources/list/ui/company-applications-list.tsx @@ -0,0 +1,199 @@ +import { BrowserLink, navigateTo } from "@metis/core/client"; +import { CursorEntityTable } from "@repo/rdx-ui/components/cursor-entity-table"; +import type { EntityColumn } from "@repo/rdx-ui/components/entity-list"; +import { EntityAction, EntityRowActions, EntityStatus } from "@repo/rdx-ui/components/entity-list"; +import { EntityListStatus } from "@repo/rdx-ui/components/entity-list-status"; +import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; +import { entityListState } from "@repo/rdx-ui/lib/entity-list-state"; +import { EyeIcon, PencilIcon } from "@repo/rdx-ui/lib/icons"; +import { Badge } from "@repo/shadcn-ui/components/badge"; +import { useId } from "react"; +import { useTranslation } from "react-i18next"; + +import type { CompanyApplicationSummary } from "../../../../common"; +import { useCompanyApplicationsController } from "../controllers/use-company-applications-controller"; + +export function CompanyApplicationsList({ + companyId, + onEdit, + canEdit, + onView, + onToggleStatus, + onDelete, + canChangeStatus, +}: { + companyId: string; + onEdit: (id: string) => void; + canEdit: boolean; + onView: (id: string) => void; + onToggleStatus: (id: string) => void; + onDelete: (id: string) => void; + canChangeStatus: boolean; +}) { + const controller = useCompanyApplicationsController(companyId); + const { query, queryData, pageIndex, search } = controller; + const { t, i18n } = useTranslation("resources"); + const prefix = useId(); + + if (controller.denied) { + return ( + + ); + } + const rows = query.data?.items ?? []; + const pageSize = queryData?.pageSize ?? 25; + const state = entityListState( + { + available: Boolean(query.data), + fetching: query.isFetching, + failed: query.isError || controller.invalid, + }, + { + loading: t("global.loading"), + refreshing: t("global.refreshing"), + unavailable: t("global.unavailable"), + stale: t("global.stale"), + } + ); + + const href = (item: CompanyApplicationSummary) => + item.scope === "global" + ? `/catalogs/global-applications/${item.id}` + : `/companies/${companyId}/resources/${item.id}${controller.suffix}`; + + const columns: EntityColumn[] = [ + { + id: "name", + label: t("name"), + hideable: false, + value: (item) => item.name, + render: (item) => {item.name}, + }, + { + id: "origin", + label: t("companyApplications.origin"), + render: (item) => ( + + {t( + item.scope === "global" ? "applicationSelection.global" : "applicationSelection.company" + )} + + ), + }, + { id: "provider", label: t("global.provider"), render: (item) => item.provider ?? "—" }, + { + id: "active", + label: t("status"), + render: (item) => ( + {t(item.active ? "active" : "inactive")} + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + , + onClick: () => navigateTo(href(item)), + } + : { + label: t("edit"), + icon: , + disabled: !canEdit, + onClick: () => onEdit(item.id), + } + } + > + {item.scope === "company" && ( + <> + onView(item.id)}>{t("viewResource")} + onToggleStatus(item.id)}> + {t(item.active ? "resourceLifecycle.deactivate" : "resourceLifecycle.activate")} + + onDelete(item.id)} + > + {t("resourceLifecycle.delete")} + + + )} + + ), + }, + ]; + + return ( +
+

{t("companyApplications.description")}

+ + controller.update({ active: active === "all" ? undefined : active }) + } + options={[ + { value: "all", label: t("global.all") }, + { value: "true", label: t("global.activeFilter") }, + { value: "false", label: t("global.inactiveFilter") }, + ]} + placeholder={t("global.all")} + value={controller.state} + /> + } + filtersApplied={controller.state !== "all"} + items={rows} + labels={{ + range: t("global.range", { + from: rows.length ? pageIndex * pageSize + 1 : 0, + to: rows.length ? pageIndex * pageSize + rows.length : 0, + }), + page: t("global.page", { page: pageIndex + 1 }), + previous: t("global.previous"), + next: t("global.next"), + pageSize: t("global.pageSize"), + clear: t("global.clear"), + retry: t("global.retry"), + }} + locale={i18n.language} + onClearFilters={() => controller.update({ q: undefined, active: undefined })} + onRetry={() => query.refetch()} + onSearchChange={(q) => controller.update({ q })} + onSortingChange={(sorting) => + controller.update({ + sort: sorting[0]?.id ?? "name", + direction: sorting[0]?.desc ? "desc" : "asc", + }) + } + pagination={{ + index: pageIndex, + size: pageSize, + hasNext: Boolean(query.data?.nextCursor) && !query.isPlaceholderData, + onNext: controller.nextPage, + onPrevious: controller.previousPage, + onSizeChange: (size) => controller.update({ pageSize: String(size) }), + }} + search={search} + searchLabel={t("global.search")} + sorting={[{ id: queryData?.sort ?? "name", desc: queryData?.direction === "desc" }]} + state={state} + title={t("companyApplications.title")} + /> +
+ ); +} diff --git a/modules/resources/src/web/resources/list/ui/resource-inventory-table.tsx b/modules/resources/src/web/resources/list/ui/resource-inventory-table.tsx new file mode 100644 index 00000000..bd3f36a0 --- /dev/null +++ b/modules/resources/src/web/resources/list/ui/resource-inventory-table.tsx @@ -0,0 +1,169 @@ +import { BrowserLink } from "@metis/core/client"; +import { + EntityAction, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/rdx-ui/components/entity-list"; +import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary"; +import { entitySelectionRowClass } from "@repo/rdx-ui/lib/entity-selection-row"; +import { PencilIcon } from "@repo/rdx-ui/lib/icons"; +import type { ComponentProps } from "react"; +import { useTranslation } from "react-i18next"; + +import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon"; +import type { useResourcesPageController } from "../controllers"; + +export function ResourceInventoryTable({ + controller, + state, + navigation, +}: { + controller: ReturnType; + state: ComponentProps["state"]; + navigation: ComponentProps["navigation"]; +}) { + const { + query, + catalog, + templateId, + clearFilters, + visible, + entityHref, + detail, + selected, + detailOpen, + openEditor, + setViewId, + canChangeStatus, + statusMutation, + setDeleteId, + denied, + selectResource, + setSearch, + search, + } = controller; + const { t, i18n } = useTranslation("resources"); + const canMutate = query.isSuccess && catalog.isSuccess; + const tabValue = templateId ?? "all"; + + return ( + item.values.name, + hideable: false, + render: (item) => { + const summary = characteristicsSummary( + item.specification.fields, + item.values.characteristics, + { yes: t("yes"), no: t("no") } + ); + + return ( +
+ detail.setVisible(true)} + > + {item.values.name} + + {summary && ( +

+ {summary} +

+ )} +
+ ); + }, + }, + { + id: "template", + label: t("template"), + value: (item) => item.specification.templateName, + render: (item) => { + const template = catalog.data?.items.find( + (template) => template.id === item.values.templateId + ); + + return ( + + + {item.specification.templateName} + + ); + }, + }, + { + id: "state", + label: t("status"), + value: (item) => t(item.values.active ? "active" : "inactive"), + render: (item) => ( + + {t(item.values.active ? "active" : "inactive")} + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + ), + }, + ]} + columnsDisabled={!catalog.data} + emptyMessage={t("empty")} + filterKey={tabValue} + filtersApplied={templateId !== null} + items={visible} + locale={i18n.language} + navigation={navigation} + onClearFilters={clearFilters} + onRetry={denied ? undefined : () => query.refetch()} + onRowClick={(item) => selectResource(item.id)} + onSearchChange={setSearch} + rowClassName={(item) => entitySelectionRowClass(item.id === selected?.id && detailOpen)} + search={search} + searchPlaceholder={t("search")} + state={state} + title={t("inventory")} + /> + ); +} diff --git a/modules/resources/src/web/resources/list/ui/resources-page.tsx b/modules/resources/src/web/resources/list/ui/resources-page.tsx index 136be704..2ce115fb 100644 --- a/modules/resources/src/web/resources/list/ui/resources-page.tsx +++ b/modules/resources/src/web/resources/list/ui/resources-page.tsx @@ -1,17 +1,9 @@ -import { BrowserLink, EntityNotFound, useListLocation, useListReturn } from "@metis/core/client"; -import { - EntityAction, - EntityListHeader, - EntityRowActions, - EntityStatus, - EntityTable, -} from "@repo/rdx-ui/components/entity-list"; +import { EntityNotFound, useListLocation, useListReturn } from "@metis/core/client"; +import { EntityListHeader } from "@repo/rdx-ui/components/entity-list"; import { EntityListStatus } from "@repo/rdx-ui/components/entity-list-status"; import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list"; -import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary"; import { contextualDetailColumns } from "@repo/rdx-ui/lib/contextual-detail-layout"; -import { entitySelectionRowClass } from "@repo/rdx-ui/lib/entity-selection-row"; -import { LayersIcon, PencilIcon } from "@repo/rdx-ui/lib/icons"; +import { LayersIcon } from "@repo/rdx-ui/lib/icons"; import { Button } from "@repo/shadcn-ui/components/button"; import { Skeleton } from "@repo/shadcn-ui/components/skeleton"; import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs"; @@ -20,7 +12,6 @@ import { useTranslation } from "react-i18next"; import { ApplicationCoincidencesDialog } from "../../../application-coincidences/review/ui/application-coincidences-dialog"; import { useApplicationCoincidenceReview } from "../../../application-coincidences/shared/use-application-coincidence-review"; -import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon"; import { InventoryFeedback } from "../../../shared"; import { ResourceDetailDialog, ResourceSelectionPanel } from "../../detail"; import { DeleteResourceDialog } from "../../lifecycle/ui/delete-resource-dialog"; @@ -30,11 +21,13 @@ import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget"; import { resourceListQueryState } from "../helpers/resource-list-query-state"; import type { ResourcesPageProps } from "../types"; +import { CompanyApplicationsList } from "./company-applications-list"; +import { ResourceInventoryTable } from "./resource-inventory-table"; + export function ResourcesPage(props: ResourcesPageProps) { + const controller = useResourcesPageController(props); const { companyId, relatedContent } = props; const { - search, - setSearch, editor, setEditor, openEditor, @@ -44,13 +37,13 @@ export function ResourcesPage(props: ResourcesPageProps) { catalog, templates, templateId, + applicationTemplate, + creationKind, + creationLabel, setTemplateId, - clearFilters, edited, viewed, - entityHref, missingView, - visible, denied, deleting, selected, @@ -60,9 +53,8 @@ export function ResourcesPage(props: ResourcesPageProps) { deleteId, setDeleteId, statusMutation, - canChangeStatus, resourceDeleted, - } = useResourcesPageController(props); + } = controller; const review = useApplicationCoincidenceReview(query.data?.resources, viewed); const coincidenceParent = review.parent; const { t, i18n } = useTranslation("resources"); @@ -144,7 +136,7 @@ export function ResourcesPage(props: ResourcesPageProps) { )} {" "} } @@ -181,129 +173,23 @@ export function ResourcesPage(props: ResourcesPageProps) {
@@ -326,6 +212,7 @@ export function ResourcesPage(props: ResourcesPageProps) { setEditor(undefined)} onSaved={(id) => { diff --git a/modules/resources/src/web/resources/shared/api/resource-related-query-keys.ts b/modules/resources/src/web/resources/shared/api/resource-related-query-keys.ts index aec751db..ff635d98 100644 --- a/modules/resources/src/web/resources/shared/api/resource-related-query-keys.ts +++ b/modules/resources/src/web/resources/shared/api/resource-related-query-keys.ts @@ -3,6 +3,9 @@ import { inventoryKeys } from "./inventory-api"; /** Listados que consumen valores o proyecciones de un recurso. */ export function resourceRelatedQueryKeys(companyId: string) { return [ + ["company-application-catalog", companyId], + ["resource-application-candidates", companyId], + ["resource-application-references", companyId], inventoryKeys.company(companyId), ["mailboxes"], ["mail-configurations"], diff --git a/modules/resources/src/web/resources/shared/ui/resource-editor-dialog.tsx b/modules/resources/src/web/resources/shared/ui/resource-editor-dialog.tsx index aedd6463..2b4c9a32 100644 --- a/modules/resources/src/web/resources/shared/ui/resource-editor-dialog.tsx +++ b/modules/resources/src/web/resources/shared/ui/resource-editor-dialog.tsx @@ -12,6 +12,7 @@ export function ResourceEditorDialog({ onClose, finalFocus, onSaved, + initialKind, }: { resource: ComponentProps["resource"]; companyId: string; @@ -19,6 +20,7 @@ export function ResourceEditorDialog({ onClose: () => void; finalFocus?: ComponentProps["finalFocus"]; onSaved: (id: string) => void; + initialKind?: ComponentProps["initialKind"]; }) { const { t } = useTranslation("resources"); @@ -45,6 +47,7 @@ export function ResourceEditorDialog({ { page.setDefaultTimeout(20000); page.setDefaultNavigationTimeout(20000); ' + source + ' }'}) + except RuntimeError: + capture(client, directory, 'company-applications-failure') + raise + + with browser(context['config']) as client: + tool(client, 'browser_resize', {'width': 1440, 'height': 1000}) + tool(client, 'browser_navigate', {'url': origin + '/login'}) + code(client, "await page.getByRole('button',{name:'Entrar',exact:true}).waitFor();") + tool(client, 'browser_fill_form', {'fields': [ + {'name': 'Correo electrónico', 'type': 'textbox', 'target': 'input[type=email]', 'value': fixture['email']}, + {'name': 'Contraseña', 'type': 'textbox', 'target': 'input[type=password]', 'value': fixture['password']}]}) + tool(client, 'browser_click', {'target': 'button[type=submit]'}) + code(client, "await page.locator('[data-slot=workspace]').waitFor({timeout:30000});") + tool(client, 'browser_navigate', {'url': url}) + code(client, "await page.getByRole('button',{name:'Añadir aplicación de compañía',exact:true}).waitFor(); await page.getByRole('link',{name:'Aplicación particular Ágil 1',exact:true}).waitFor(); if(await page.getByText('Nota administrativa que no debe aparecer',{exact:true}).count())throw Error('Se han expuesto notas globales');") + screenshots.append(capture(client, directory, 'company-applications-1440')) + checks.extend(['ordinary-user', 'company-applications-tab', 'public-metadata-only']) + code(client, "await page.getByRole('button',{name:'Siguiente',exact:true}).click(); await page.waitForURL(/page=2/); await page.getByRole('button',{name:'Anterior',exact:true}).click(); await page.waitForURL(/page=1/);") + checks.append('cursor-navigation') + code(client, "await page.getByRole('textbox',{name:'Buscar por nombre o proveedor',exact:true}).fill('Global Ñ'); await page.getByRole('link',{name:'Global Ñ — Aplicación de prueba 01',exact:true}).waitFor(); await page.getByText('Global',{exact:true}).first().waitFor();") + screenshots.append(capture(client, directory, 'company-applications-global')) + checks.extend(['global-origin', 'global-navigation', 'remote-search']) + code(client, "await page.getByRole('textbox',{name:'Buscar por nombre o proveedor',exact:true}).fill('zzzx-inexistente'); await page.getByText('No hay coincidencias. Cambia la búsqueda o vuelve al inicio.',{exact:true}).waitFor();") + checks.append('empty-search') + tool(client, 'browser_navigate', {'url': url}) + code(client, "await page.getByRole('button',{name:'Añadir aplicación de compañía',exact:true}).click(); await page.getByRole('dialog',{name:'Nuevo recurso',exact:true}).waitFor(); await page.getByRole('button',{name:'Siguiente',exact:true}).click(); await page.getByRole('button',{name:'Siguiente',exact:true}).click(); await page.getByRole('textbox',{name:'Nombre',exact:true}).fill('Aplicación exclusiva creada desde compañía'); await page.getByRole('button',{name:'Guardar',exact:true}).click(); await page.getByRole('dialog',{name:'Nuevo recurso',exact:true}).waitFor({state:'hidden'}); ") + checks.append('local-create-dialog') + code(client, "const path=" + json.dumps(origin + '/api/resource-application-candidates/') + "; const list=await (await page.request.get(path+'" + company + "/catalog?search=exclusiva')).json(); if(list.items.length!==1 || list.items[0].scope!=='company'||list.items[0].companyId!=='" + company + "')throw Error('Alta fuera de compañía'); const foreign=await (await page.request.get(path+'" + fixture['otherCompanies'][0] + "/catalog?search=exclusiva')).json(); if(foreign.items.length)throw Error('Contaminación entre compañías');") + checks.append('local-creation-isolated') + tool(client, 'browser_navigate', {'url': url}) + code(client, "await page.getByRole('link',{name:'Aplicación exclusiva creada desde compañía',exact:true}).waitFor();") + for width in [1280, 768, 390]: + tool(client, 'browser_resize', {'width': width, 'height': 844}) + code(client, "await page.getByRole('button',{name:'Añadir aplicación de compañía',exact:true}).waitFor(); if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1))throw Error('Desbordamiento horizontal de página');") + screenshots.append(capture(client, directory, f'company-applications-{width}')) + checks.append('responsive-four-widths') + tool(client, 'browser_resize', {'width': 1440, 'height': 1000}) + code(client, "await page.route('**/api/resource-application-candidates/*/catalog?**',r=>r.fulfill({status:500,contentType:'application/json',json:{code:'INTERNAL_ERROR'}}));") + tool(client, 'browser_navigate', {'url': url + '&q=fallo'}) + code(client, "await page.getByRole('button',{name:'Reintentar',exact:true}).waitFor(); await page.unroute('**/api/resource-application-candidates/*/catalog?**'); await page.getByRole('button',{name:'Reintentar',exact:true}).click();") + checks.extend(['read-error', 'retry']) + tool(client, 'browser_navigate', {'url': origin + f'/companies/{company}/assets/{fixture["softwareAssets"][0]}'}) + code(client, "await page.getByRole('button',{name:'Añadir instalación',exact:true}).click(); await page.getByRole('button',{name:'Software',exact:true}).click(); await page.getByRole('dialog',{name:'Seleccionar software',exact:true}).waitFor(); await page.getByRole('textbox',{name:'Buscar por nombre o proveedor',exact:true}).fill('Global Ñ'); await page.getByRole('row').filter({hasText:'Global Ñ'}).first().waitFor(); await page.keyboard.press('Escape'); await page.keyboard.press('Escape');") + checks.append('software-selector-global') + tool(client, 'browser_navigate', {'url': origin + f'/companies/{company}/work-profiles/{fixture["configurationProfiles"][0]}/application-configurations'}) + code(client, "await page.getByRole('button',{name:'Configurar aplicación',exact:true}).click(); await page.getByRole('button',{name:'Aplicación',exact:true}).click(); await page.getByRole('dialog',{name:'Seleccionar aplicación',exact:true}).waitFor(); await page.getByRole('textbox',{name:'Buscar por nombre o proveedor',exact:true}).fill('exclusiva'); await page.getByRole('row').filter({hasText:'Aplicación exclusiva creada desde compañía'}).waitFor(); await page.keyboard.press('Escape'); if((await page.evaluate(()=>document.activeElement?.getAttribute('aria-label')))!=='Aplicación')throw Error('Foco perdido'); await page.keyboard.press('Escape');") + checks.extend(['profile-selector-local', 'keyboard-escape', 'focus-return']) + report = {'checks': checks, 'screenshots': screenshots, 'companies': 15, + 'principal': {'people': 50, 'assets': 51, 'profiles': 50, 'workstations': 50, 'localApplications': 51}, + 'globalApplications': 60, 'database': 'metis_desk_test'} + (directory / 'company-applications-report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n') + print('Listado y selectores de compañía verificados. Evidencia: ' + str(directory.relative_to(context['ROOT'])), flush=True) diff --git a/scripts/current-operating-systems-ui.test.py b/scripts/current-operating-systems-ui.test.py new file mode 100644 index 00000000..0dab20aa --- /dev/null +++ b/scripts/current-operating-systems-ui.test.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Pruebas MCP del SO actual sobre el entorno privado desechable de visual-audit.py.""" +import base64 +import hashlib +import json +import sys +from pathlib import Path +import runpy +import unittest + +ROOT = Path(__file__).resolve().parent.parent +BASE = runpy.run_path(str(ROOT / 'scripts/workstation-dashboard-ui.test.py')) + + +class CurrentOperatingSystemsTests(BASE['WorkstationDashboardTests']): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.output = cls.output.parent.parent / 'current-operating-systems' + cls.output.mkdir(exist_ok=True) + evidence = cls.output / "measurements.json" + cls.evidence = json.loads(evidence.read_text()) if evidence.exists() else [] + existing = cls.output / 'fixtures.json' + if existing.exists(): + cls.os = json.loads(existing.read_text()) + cls.code('''const origin=__ORIGIN__;async function get(path){const r=await page.request.get(origin+'/api'+path);if(!r.ok())throw Error('GET '+path);return r.json()}const {csrfToken}=await get('/auth/session');async function post(path,data){const r=await page.request.post(origin+'/api'+path,{data,headers:{origin,'x-csrf-token':csrfToken,'X-Metis-Resources-Contract':'2'}});if(!r.ok())throw Error('POST '+path+': '+r.status());return r.json()}page.__osApi={get,post};return true;'''.replace('__ORIGIN__',json.dumps(cls.origin))) + return + cls.os = cls.code(''' + const origin=__ORIGIN__,company=__COMPANY__; + async function get(path){const r=await page.request.get(origin+'/api'+path);if(!r.ok())throw Error('GET '+path+': '+r.status());return r.json()} + const {csrfToken}=await get('/auth/session'); + async function post(path,data){const r=await page.request.post(origin+'/api'+path,{data,headers:{origin,'x-csrf-token':csrfToken,'X-Metis-Resources-Contract':'2'}});if(!r.ok())throw Error('POST '+path+': '+r.status());return r.json()} + const catalog=(await get('/asset-catalog/'+company)).items; + const rc=(await get('/resource-catalog/'+company)).items; + const laptop=catalog.find(x=>x.builtinKey==='template:laptop'); + const desktop=catalog.find(x=>x.builtinKey==='template:desktop'); + const server=catalog.find(x=>x.builtinKey==='template:server'); + const factory=catalog.find(x=>x.builtinKey==='field:factory_os').id; + const custom=(await post('/asset-catalog/'+company,{values:{kind:'template',name:'OSUI — plantilla propia',description:'Solo datos sintéticos',fields:laptop.values.fields.map(x=>({fieldId:x.fieldId,required:false}))}})).items.find(x=>x.values.name==='OSUI — plantilla propia'); + async function asset(name,template,original){return (await post('/assets/'+company,{values:{name,templateId:template.id,siteId:null,status:'operational',notes:'Prueba sintética de SO actual',characteristics:original?{[factory]:original}:{}}})).asset} + async function resource(name,kind){return (await post('/resources/'+company,{values:{name,templateId:rc.find(x=>x.builtinKey==='template:'+kind).id,active:true,notes:'Prueba sintética',characteristics:{}}})).resource} + const win=await resource('Windows 11 Pro','os'),ubuntu=await resource('Ubuntu 24.04 LTS — edición de ingeniería y documentación con un nombre extenso de prueba','os'); + const application=await resource('OSUI Editor de documentos','application'); + const main=await asset('OSUI — portátil con FreeDOS de fábrica',laptop,'FreeDOS'); + const same=await asset('OSUI — sobremesa original Windows',desktop,'Windows 11 Pro'); + const physicalServer=await asset('OSUI — servidor sin puesto',server); + const own=await asset('OSUI — activo de plantilla propia',custom); + const empty=await asset('OSUI — Windows de fábrica sin instalación',laptop,'Windows 11 Pro'); + const pending=await asset('OSUI — pendiente',laptop); + const removed=await asset('OSUI — retirado',laptop); + const other=await asset('OSUI — segundo ordenador',laptop); + async function install(a,r,state='configured',version='24H2'){return (await post('/software-installations/'+company,{values:{assetId:a.id,resourceId:r.id,licenseResourceId:null,version,state,notes:null}})).configuration} + const current=await install(main,win); + await install(main,application); + for(const a of [same,physicalServer,own])await install(a,win); + await install(pending,win,'pending'); + let closed=await install(removed,win); + for(const state of ['removal_pending','removed'])closed=(await post('/software-installations/'+company+'/'+closed.id+'/update',{values:{...closed.values,state}})).configuration; + await install(other,ubuntu,'configured','24.04 LTS'); + const station=(await post('/assets/'+company+'/workstations',{values:{name:'OSUI — puesto de comprobación',active:true,siteId:null,personId:null,computerAssetId:main.id,assetIds:[main.id]}})).workstation; + page.__osApi={get,post}; + return {main:main.id,same:same.id,server:physicalServer.id,custom:own.id,empty:empty.id,pending:pending.id,removed:removed.id,other:other.id,station,current,win,ubuntu,application}; + '''.replace('__ORIGIN__',json.dumps(cls.origin)).replace('__COMPANY__',json.dumps(cls.fixture['companyId']))) + (cls.output/'fixtures.json').write_text(json.dumps(cls.os,ensure_ascii=False,indent=2)+'\n') + + def capture(self,name): + self.settle() + encoded=self.code("return (await page.locator('[data-current-operating-systems]').locator('xpath=ancestor::section[1]').screenshot()).toString('base64');") + data=base64.b64decode(encoded,validate=True) + path=self.output/(name+'.png') + path.write_bytes(data) + self.record(name,{'path':str(path.relative_to(ROOT)),'sha256':hashlib.sha256(data).hexdigest(),'bytes':len(data),'scope':'current operating system card'}) + + def open_asset(self,key): + self.tool('browser_navigate',{'url':self.origin+self.base+'/assets/'+self.os[key]}) + self.code("await page.locator('[data-current-operating-systems]').waitFor();return true;") + + def ready(self,name='Windows 11 Pro'): + self.code('await page.locator("[data-current-operating-systems]").getByRole("link",{name:'+json.dumps(name)+',exact:true}).waitFor({timeout:20000});return true;') + + def summary(self): + return self.code("return await page.locator('[data-current-operating-systems]').innerText();") + + def test_os_01_source_and_states(self): + for key in ['main','same','server','custom']: + self.open_asset(key) + self.ready() + text=self.summary() + self.assertIn('Windows 11 Pro',text) + self.assertNotIn('FreeDOS',text) + self.assertNotIn(self.os['application']['values']['name'],text) + for key in ['empty','pending','removed']: + self.open_asset(key) + self.code("await page.locator('[data-current-operating-systems]').getByText('No registrado',{exact:true}).waitFor();return true;") + self.assertNotIn('Windows 11 Pro',self.summary()) + self.record('OSUI-01-05-07-12-14-17-18',{'source':'SoftwareInstallation','factoryIndependent':True,'serverWithoutWorkstation':True,'customTemplate':True}) + + def test_os_02_change_computer(self): + self.code("await page.__osApi.post('/assets/'+"+json.dumps(self.fixture['companyId'])+"+'/workstations/'+"+json.dumps(self.os['station']['id'])+"+'/update',{values:"+json.dumps(self.os['station']['values'])+"});return true;") + self.tool('browser_navigate',{'url':self.origin+self.base+'/workstations/'+self.os['station']['id']}) + self.ready() + result=self.code(''' + const {get,post}=page.__osApi; + const before=await get('/software-installations/'+__COMPANY__); + const station=__STATION__,other=__OTHER__; + await post('/assets/'+__COMPANY__+'/workstations/'+station.id+'/update',{values:{...station.values,computerAssetId:other,assetIds:[other]}}); + return {before,after:await get('/software-installations/'+__COMPANY__)}; + '''.replace('__COMPANY__',json.dumps(self.fixture['companyId'])).replace('__STATION__',json.dumps(self.os['station'])).replace('__OTHER__',json.dumps(self.os['other']))) + self.assertEqual(result['before'],result['after']) + self.code("await page.reload();return true;") + self.ready(self.os['ubuntu']['values']['name']) + self.assertNotIn('Windows 11 Pro',self.summary()) + self.record('OSUI-08',{'newPrincipal':True,'installationsAndHistoryUnchanged':True}) + + def test_os_03_multiple_responsive_and_themes(self): + self.open_asset('main') + self.ready() + self.code(''' + const existing=await page.__osApi.get('/software-installations/'+__COMPANY__);if(!existing.configurations.some(x=>x.values.assetId===__ASSET__&&x.values.resourceId===__RESOURCE__))await page.__osApi.post('/software-installations/'+__COMPANY__,{values:{assetId:__ASSET__,resourceId:__RESOURCE__,licenseResourceId:null,version:'24.04 LTS',state:'configured',notes:null}}); + await page.reload();return true; + '''.replace('__COMPANY__',json.dumps(self.fixture['companyId'])).replace('__ASSET__',json.dumps(self.os['main'])).replace('__RESOURCE__',json.dumps(self.os['ubuntu']['id']))) + for theme in ['light','dark']: + for width,height in [(320,800),(390,844),(768,1024),(1024,768),(1280,900),(1600,1000)]: + self.tool('browser_resize',{'width':width,'height':height}) + self.open_asset('main') + self.code("await page.evaluate(theme=>document.documentElement.classList.toggle('dark',theme==='dark'),"+json.dumps(theme)+");return true;") + self.ready(self.os['ubuntu']['values']['name']) + self.code("await page.locator('[data-current-operating-systems]').scrollIntoViewIfNeeded();return true;") + self.ready(self.os['ubuntu']['values']['name']) + measurements=self.code("return await page.locator('[data-current-operating-systems]').evaluate(e=>({viewport:innerWidth,overflow:e.scrollWidth>e.clientWidth,links:e.querySelectorAll('a').length,text:e.innerText,colors:[...e.querySelectorAll('a,p')].map(x=>({text:x.textContent,color:getComputedStyle(x).color,background:getComputedStyle(x.closest('[data-slot=card]')).backgroundColor}))}));") + self.assertFalse(measurements['overflow']) + self.assertEqual(measurements['links'],2) + self.assertNotIn('Versión 24.04 LTS',measurements['text']) + self.record(f'OSUI-06-{theme}-{width}',measurements) + self.capture(f'asset-{theme}-{width}') + keyboard=self.code("const link=page.locator('[data-current-operating-systems] a').first();await link.focus();await page.keyboard.press('Tab');return await page.locator('[data-current-operating-systems] a').last().evaluate(e=>({focused:e===document.activeElement,outline:getComputedStyle(e).outlineStyle}));") + self.assertTrue(keyboard['focused']) + self.assertEqual(keyboard['outline'],'solid') + self.record('keyboard',keyboard) + self.code("await page.evaluate(()=>{document.documentElement.classList.remove('dark');document.documentElement.style.zoom='2'});return true;") + self.capture('asset-zoom-200') + self.assertFalse(self.code("return await page.locator('[data-current-operating-systems]').evaluate(e=>e.scrollWidth>e.clientWidth);")) + self.code("await page.evaluate(()=>document.documentElement.style.removeProperty('zoom'));return true;") + + def test_os_04_read_failures(self): + self.open_asset('main') + self.ready() + path='**/api/software-installations/'+self.fixture['companyId'] + self.addCleanup(self.code, 'await page.unroute('+json.dumps(path)+');return true;') + self.code("await page.route("+json.dumps(path)+",route=>route.fulfill({status:500,json:{code:'INTERNAL_ERROR'}}));await page.reload();return true;") + self.code("await page.locator('[data-current-operating-systems]').getByRole('button',{name:'Reintentar',exact:true}).waitFor({timeout:20000});return true;") + self.assertNotIn('No registrado',self.summary()) + self.capture('asset-error') + self.code("await page.unroute("+json.dumps(path)+");await page.locator('[data-current-operating-systems]').getByRole('button',{name:'Reintentar',exact:true}).click();return true;") + self.ready() + refs='**/api/resource-application-references/**/resolve' + self.addCleanup(self.code, 'await page.unroute('+json.dumps(refs)+');return true;') + self.code("await page.route("+json.dumps(refs)+",async route=>{const r=await route.fetch();const data=await r.json();await route.fulfill({response:r,json:{...data,items:data.items.map(x=>({id:x.id,status:'unavailable'}))}})});await page.reload();return true;") + self.code("await page.locator('[data-current-operating-systems]').getByText(/Referencia de software no disponible/).waitFor();return true;") + self.assertNotIn('No registrado',self.summary()) + self.capture('asset-unavailable-reference') + self.code("await page.unroute("+json.dumps(refs)+");return true;") + self.record('OSUI-10-11',{'errorIsNotEmpty':True,'retryRecovers':True,'unavailableReferenceIsNotEmpty':True}) + + def test_os_05_save_version_and_invalidation(self): + self.open_asset('same') + self.ready() + self.code("await page.getByRole('region',{name:'Software del ordenador',exact:true}).getByRole('button',{name:/Editar/}).first().click();return true;") + self.code("const dialog=page.getByRole('dialog');await dialog.getByLabel('Versión', {exact:true}).fill('25H2');await dialog.getByRole('button',{name:'Guardar',exact:true}).click();await dialog.waitFor({state:'hidden'});return true;") + self.code("await page.locator('[data-current-operating-systems]').getByText('Versión 25H2',{exact:true}).waitFor();return true;") + self.capture('saved-version') + self.record('OSUI-09-15',{'savedThroughExistingForm':True,'summaryUpdatedWithoutReload':True}) + + + def test_os_06_workstation_loading_language_and_contrast(self): + self.code("await page.__osApi.post('/assets/'+"+json.dumps(self.fixture['companyId'])+"+'/workstations/'+"+json.dumps(self.os['station']['id'])+"+'/update',{values:"+json.dumps(self.os['station']['values'])+"});return true;") + for theme in ['light','dark']: + for width,height in [(320,800),(390,844),(768,1024),(1024,768),(1280,900),(1600,1000)]: + self.tool('browser_resize',{'width':width,'height':height}) + self.tool('browser_navigate',{'url':self.origin+self.base+'/workstations/'+self.os['station']['id']}) + self.ready(self.os['ubuntu']['values']['name']) + self.code("await page.evaluate(theme=>document.documentElement.classList.toggle('dark',theme==='dark'),"+json.dumps(theme)+");return true;") + self.code("await page.locator('[data-current-operating-systems]').scrollIntoViewIfNeeded();return true;") + self.ready(self.os['ubuntu']['values']['name']) + measurements=self.code("return await page.locator('[data-current-operating-systems]').evaluate(e=>({viewport:innerWidth,overflow:e.scrollWidth>e.clientWidth,links:e.querySelectorAll('a').length,text:e.innerText}));") + self.record(f'workstation-{theme}-{width}',measurements) + self.assertFalse(measurements['overflow']) + self.assertEqual(measurements['links'],2,measurements['text']) + self.capture(f'workstation-{theme}-{width}') + ratios=self.code("""return await page.locator('[data-current-operating-systems]').locator('xpath=ancestor::section[1]').evaluate(root=>{ + const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const ctx=canvas.getContext('2d',{willReadFrequently:true}); + const rgba=color=>{ctx.clearRect(0,0,1,1);ctx.fillStyle=color;ctx.fillRect(0,0,1,1);return [...ctx.getImageData(0,0,1,1).data].map((x,i)=>i===3?x/255:x)}; + const over=(a,b)=>{const alpha=a[3]+b[3]*(1-a[3]);return [...a.slice(0,3).map((v,i)=>(v*a[3]+b[i]*b[3]*(1-a[3]))/alpha),alpha]}; + const bg=e=>e?over(rgba(getComputedStyle(e).backgroundColor),bg(e.parentElement)):[255,255,255,1]; + const luminance=c=>c.slice(0,3).map(x=>x/255).map(x=>x<=.04045?x/12.92:((x+.055)/1.055)**2.4).reduce((a,x,i)=>a+x*[.2126,.7152,.0722][i],0); + const ratio=(a,b)=>{const l=[luminance(a),luminance(b)].sort((a,b)=>b-a);return (l[0]+.05)/(l[1]+.05)}; + return [...root.querySelectorAll('h2,a,p')].map(e=>({text:e.textContent,ratio:ratio(over(rgba(getComputedStyle(e).color),bg(e)),bg(e))})); + });""") + self.record('contrast-'+theme,ratios) + for item in ratios: + self.assertGreaterEqual(item['ratio'],4.5,item['text']) + + def test_os_07_loading_and_language(self): + self.tool('browser_resize',{'width':1600,'height':1000}) + self.tool('browser_navigate',{'url':self.origin+self.base+'/workstations/'+self.os['station']['id']}) + self.ready(self.os['ubuntu']['values']['name']) + self.code("await page.evaluate(()=>document.documentElement.classList.remove('dark'));return true;") + path='**/api/software-installations/'+self.fixture['companyId'] + result=self.code("await page.route("+json.dumps(path)+",route=>route.fulfill({status:503,json:{code:'INTERNAL_ERROR'}}));try{await page.reload();await page.locator('[data-current-operating-systems]').getByRole('status',{name:'Cargando sistema operativo actual',exact:true}).waitFor();return (await page.locator('[data-current-operating-systems]').locator('xpath=ancestor::section[1]').screenshot()).toString('base64')}finally{await page.unroute("+json.dumps(path)+")}") + self.assertIsInstance(result,str,result) + data=base64.b64decode(result,validate=True) + screenshot=self.output/'workstation-loading.png' + screenshot.write_bytes(data) + self.record('workstation-loading',{'path':str(screenshot.relative_to(ROOT)),'sha256':hashlib.sha256(data).hexdigest(),'bytes':len(data)}) + self.ready() + self.code("await page.getByRole('button',{name:'Configuración',exact:true}).click();await page.getByRole('dialog').getByRole('combobox',{name:'Idioma',exact:true}).click();await page.getByRole('option',{name:'English',exact:true}).click();await page.getByRole('heading',{name:'Settings',exact:true}).waitFor();await page.keyboard.press('Escape');return true;") + self.code("await page.getByRole('heading',{name:'Current operating systems',exact:true}).waitFor();return true;") + self.capture('workstation-english') + self.record('OSUI-16-browser',{'language':'en','loadingDistinctFromEmpty':True}) + + +if __name__=='__main__': + names=sys.argv[1:] or [name for name in dir(CurrentOperatingSystemsTests) if name.startswith('test_os_')] + suite=unittest.TestSuite(CurrentOperatingSystemsTests(name) for name in names) + result=unittest.TextTestRunner(verbosity=2).run(suite) + raise SystemExit(not result.wasSuccessful()) diff --git a/scripts/global-applications-ui-audit.py b/scripts/global-applications-ui-audit.py index d1cf9304..b5b8493e 100644 --- a/scripts/global-applications-ui-audit.py +++ b/scripts/global-applications-ui-audit.py @@ -49,7 +49,7 @@ def run(context, verifier=None): if context['options'].global_software: environment['RESOURCE_GLOBAL_SOFTWARE_ENABLED'] = 'true' environment['RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED'] = 'true' - if context['options'].application_coincidences: + if context['options'].application_coincidences or context['options'].company_applications: environment['RESOURCE_GLOBAL_PROFILE_ENABLED'] = 'true' environment['RESOURCE_GLOBAL_SOFTWARE_ENABLED'] = 'true' environment['RESOURCE_GLOBAL_SOFTWARE_CANDIDATES_ENABLED'] = 'true' diff --git a/scripts/visual-audit.py b/scripts/visual-audit.py index ebfcd274..5a418e56 100644 --- a/scripts/visual-audit.py +++ b/scripts/visual-audit.py @@ -303,6 +303,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true", help="Verifica y retira el entorno; sin esta opción espera hasta Ctrl+C") parser.add_argument("--global-applications", action="store_true", help="Recorrido R2.3 con catálogo habilitado solo tras migración desechable") + parser.add_argument("--company-applications", action="store_true", help="Listado y selectores globales/locales de compañía en una instalación desechable") parser.add_argument("--application-coincidences", action="store_true", help="Recorrido R2.6 contextual y no destructivo") parser.add_argument("--global-profiles", action="store_true", help="Recorrido R2.5 con aplicaciones globales por perfil en una instancia desechable") parser.add_argument("--global-software", action="store_true", help="Recorrido R2.4 con instalaciones y selección global en una instancia desechable") @@ -346,6 +347,11 @@ def main(): "DB_NAME": "metis_desk_test", "DB_USER": "visual_audit", "DB_PASSWORD": database_password, "SESSION_SECRET": secrets.token_urlsafe(48), "NODE_ENV": "test", "APP_ORIGIN": origin, "PORT": str(api_port), "API_PROXY_TARGET": api.base} + if options.company_applications: + runner = runpy.run_path(str(ROOT / "scripts/global-applications-ui-audit.py"))["run"] + verifier = runpy.run_path(str(ROOT / "scripts/company-applications-ui-audit.py"))["verify"] + runner({**globals(), **locals()}, verifier=verifier) + return if options.application_coincidences: runner = runpy.run_path(str(ROOT / "scripts/global-applications-ui-audit.py"))["run"] verifier = runpy.run_path(str(ROOT / "scripts/application-coincidences-ui-audit.py"))["verify"] diff --git a/scripts/workplace-add-asset.test.py b/scripts/workplace-add-asset.test.py new file mode 100644 index 00000000..ce0deb5f --- /dev/null +++ b/scripts/workplace-add-asset.test.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""WD04.4: asociación real por HTTP y UI en metis_desk_test desechable mediante MCP.""" +import json +from pathlib import Path +import runpy +import unittest + +ROOT = Path(__file__).resolve().parent.parent +BASE = runpy.run_path(str(ROOT / 'scripts/workplace-visual-map.test.py')) + + +class WorkplaceAddAssetTests(BASE['WorkplaceVisualMapTests']): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.output = cls.output.parents[1] / 'wd044-add-asset' + cls.output.mkdir(parents=True, exist_ok=True) + evidence = cls.output / 'measurements.json' + cls.evidence = json.loads(evidence.read_text()) if evidence.exists() else [] + cls.code("page.__wd044={};return true;") + + @classmethod + def code(cls, body): + result = super().code("try {" + body + "} catch(error) { return {wd044Error: String(error.message).split(String.fromCharCode(10))[0]}; }") + if isinstance(result, dict) and 'wd044Error' in result: + raise RuntimeError(result['wd044Error']) + return result + + def prepare(self, key='main'): + self.tool('browser_resize', {'width':1440,'height':900}) + self.map_ready(key, software=key=='main') + # API real, autenticación ordinaria de la sesión privada del preparador. + self.code("""page.__wd044.get=async path=>{const r=await page.request.get(new URL('/api'+path,page.url()).href);if(!r.ok())throw Error('GET fixture '+r.status());return r.json()}; + page.__wd044.post=async(path,data,revision)=>{const session=await page.__wd044.get('/auth/session');const r=await page.request.post(new URL('/api'+path,page.url()).href,{data,headers:{'x-csrf-token':session.csrfToken,'origin':new URL(page.url()).origin,...(revision?{'if-match':'"'+revision+'"'}:{})}});return {status:r.status(),body:await r.json()}};return true;""") + + def open_picker(self, keyboard=False): + key = 'Space' if keyboard == 'space' else 'Enter' + opening = "await b.focus();await b.press(" + json.dumps(key) + ");" if keyboard else "await b.click();" + self.code("const b=page.getByRole('button',{name:'Añadir activo al puesto',exact:true});" + opening + "await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).waitFor();return true;") + + def choose(self, name): + self.code("const d=page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true});await d.locator('[data-quick-search]').fill("+json.dumps(name)+");await d.getByRole('radio',{name:"+json.dumps('Seleccionar '+name)+",exact:true}).press('Space');return true;") + + def test_01_existing_cancel_conflict(self): + self.prepare() + data=self.code("""const cid="""+json.dumps(self.fixture['companyId'])+""";const inv=await page.__wd044.get('/assets/'+cid);const w=inv.workstations.find(x=>x.id==="""+json.dumps(self.fixture['main'])+""");const occupied=new Set(inv.workstations.filter(x=>x.values.active).flatMap(x=>x.values.assetIds));const candidate=inv.assets.find(x=>x.values.status==='operational'&&!occupied.has(x.id));page.__wd044.before=w;page.__wd044.candidate=candidate;return {name:candidate.values.name,previous:w.values.assetIds,principal:w.values.computerAssetId,history:w.history.length};""") + self.open_picker(keyboard=True) + self.assertTrue(self.code("return await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).locator('[data-quick-search]').evaluate(e=>e===document.activeElement);")) + self.assertEqual(self.code("return await page.getByRole('radio',{name:'Seleccionar PORT-ADM-01 · Documentación y gestión',exact:true}).count();"),0) + self.choose(data['name']) + self.code("await page.keyboard.press('Escape');return true;") + self.assertTrue(self.code("return await page.getByRole('button',{name:'Añadir activo al puesto',exact:true}).evaluate(e=>e===document.activeElement);")) + self.open_picker() + self.choose(data['name']) + # Otro usuario cambia el nombre mientras la selección permanece abierta. + conflict=self.code("const w=page.__wd044.before;return await page.__wd044.post('/assets/'+w.companyId+'/workstations/'+w.id+'/update',{values:{...w.values,name:'Administración — cambio concurrente WD04.4'}},w.history.at(-1).id);") + self.assertEqual(conflict['status'],200) + self.code("await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('alert').filter({hasText:'Otro usuario ha modificado'}).waitFor();return true;") + self.capture('S06-conflict') + self.code("await page.getByRole('button',{name:'Actualizar puesto y revisar selección',exact:true}).click();await page.getByRole('button',{name:'Añadir al puesto',exact:true}).waitFor({state:'visible'});return true;") + self.choose(data['name']) + self.code("await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).waitFor({state:'hidden'});return true;") + result=self.code("const b=page.__wd044.before;const inv=await page.__wd044.get('/assets/'+b.companyId);const w=inv.workstations.find(x=>x.id===b.id);return {ids:w.values.assetIds,principal:w.values.computerAssetId,history:w.history.length,name:w.values.name,actionPersisted:w.values.assetIds.some(x=>x.includes('presentation:'))};") + self.assertEqual(len(result['ids']),len(data['previous'])+1) + self.assertTrue(set(data['previous']).issubset(result['ids'])) + self.assertEqual(result['principal'],data['principal']) + self.assertEqual(result['history'],data['history']+2) + self.assertFalse(result['actionPersisted']) + self.record('S01-S03-S05-S06',result) + self.capture('S01-associated') + + def test_02_responsive_empty_mobile(self): + self.prepare('empty') + for width,height in [(1600,1000),(1440,900),(1280,900),(1024,768),(768,1024),(390,844)]: + self.tool('browser_resize',{'width':width,'height':height}) + self.settle() + self.open_picker(keyboard='space' if width == 1600 else True) + self.capture('picker-'+str(width)) + geometry=self.code("return await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,searchFocused:document.activeElement.matches('[data-quick-search]'),dialogWidth:document.querySelector('[role=dialog]').getBoundingClientRect().width}));") + self.assertLessEqual(geometry['scrollWidth'],width) + self.assertTrue(geometry['searchFocused']) + self.record('responsive-'+str(width),geometry) + self.code("await page.keyboard.press('Escape');return true;") + self.assertTrue(self.code("return await page.getByRole('button',{name:'Añadir activo al puesto',exact:true}).evaluate(e=>e===document.activeElement);")) + # Asociación móvil en puesto sin principal, preservando null. + candidate=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');const occupied=new Set(inv.workstations.filter(w=>w.values.active).flatMap(w=>w.values.assetIds));const a=inv.assets.find(a=>a.values.status==='operational'&&!occupied.has(a.id));return a.values.name;") + before_count=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');return inv.workstations.find(w=>w.id==='"+self.fixture['empty']+"').values.assetIds.length;") + self.open_picker() + self.choose(candidate) + self.code("await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).waitFor({state:'hidden'});return true;") + result=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');const w=inv.workstations.find(w=>w.id==='"+self.fixture['empty']+"');return {principal:w.values.computerAssetId??null,count:w.values.assetIds.length};") + self.assertIsNone(result['principal']) + self.assertEqual(result['count'],before_count+1) + self.capture('S02-S10-mobile-associated') + + + def test_03_create_cancel_save_error_refresh(self): + self.prepare('withoutResponsible') + created_name=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');page.__wd044.newName='MON-WD044 '+(inv.assets.filter(a=>a.values.name.startsWith('MON-WD044')).length+1);return page.__wd044.newName;") + self.open_picker() + self.code("const d=page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true});await d.locator('[data-quick-search]').fill('WD044 selección conservada');await d.getByRole('button',{name:'Crear activo nuevo',exact:true}).click();await page.getByRole('dialog',{name:'Nuevo activo',exact:true}).waitFor();return true;") + self.capture('S04-create-form') + self.code("await page.keyboard.press('Escape');return true;") + self.assertEqual(self.code("return await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).locator('[data-quick-search]').inputValue();"),'WD044 selección conservada') + self.code("await page.getByRole('button',{name:'Crear activo nuevo',exact:true}).click();const d=page.getByRole('dialog',{name:'Nuevo activo',exact:true});await d.getByRole('combobox',{name:'Plantilla',exact:true}).click();await page.getByRole('option',{name:'Monitor',exact:true}).click();await d.getByRole('button',{name:'Siguiente',exact:true}).click();await d.getByRole('button',{name:'Siguiente',exact:true}).click();await d.getByRole('textbox',{name:'Nombre',exact:true}).fill(page.__wd044.newName);await d.getByRole('button',{name:'Guardar',exact:true}).click();await d.waitFor({state:'hidden'});return true;") + self.assertTrue(self.code("return await page.getByRole('radio',{name:'Seleccionar '+page.__wd044.newName,exact:true}).isChecked();")) + result=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');const a=inv.assets.find(a=>a.values.name===page.__wd044.newName);page.__wd044.created=a;return {created:!!a,assigned:inv.workstations.some(w=>w.values.assetIds.includes(a.id))};") + self.assertTrue(result['created']) + self.assertFalse(result['assigned']) + self.capture('S04-created-selected') + self.code("await page.keyboard.press('Escape');return true;") + self.open_picker() + self.choose(created_name) + # Fallo de escritura: el activo ya creado permanece y se reintenta la misma asociación. + self.code("await page.route('**/workstations/*/update',route=>route.fulfill({status:500,json:{code:'INTERNAL_ERROR'}}));await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).getByRole('alert').waitFor();return true;") + self.capture('S07-save-error') + self.code("await page.unroute('**/workstations/*/update');page.__wd044.writes=0;page.__wd044.failRead=false;await page.route('**/workstations/*/update',async route=>{page.__wd044.writes++;const response=await route.fetch();if(response.ok())page.__wd044.failRead=true;await route.fulfill({response})});await page.route('**/api/assets/"+self.fixture['companyId']+"',async route=>{if(page.__wd044.failRead)await route.fulfill({status:500,json:{code:'INTERNAL_ERROR'}});else await route.fallback()});await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('alert').filter({hasText:'El activo se ha asociado'}).waitFor();return true;") + self.capture('S07-saved-refresh-error') + self.code("page.__wd044.failRead=false;await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).getByRole('button',{name:'Reintentar',exact:true}).click();await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).waitFor({state:'hidden'});return true;") + self.assertEqual(self.code("return page.__wd044.writes;"),1) + self.code("await page.unroute('**/workstations/*/update');await page.unroute('**/api/assets/"+self.fixture['companyId']+"');return true;") + result=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');return {assetStillExists:inv.assets.some(a=>a.id===page.__wd044.created.id),associated:inv.workstations.find(w=>w.id==='"+self.fixture['withoutResponsible']+"').values.assetIds.includes(page.__wd044.created.id)};") + self.assertTrue(result['assetStillExists']) + self.assertTrue(result['associated']) + self.record('S04-S05-S07',result) + + + + def test_04_visual_limit_and_http_guards(self): + self.prepare('dense') + before=self.code("const inv=await page.__wd044.get('/assets/"+self.fixture['companyId']+"');const w=inv.workstations.find(w=>w.id==='"+self.fixture['dense']+"');const occupied=new Set(inv.workstations.filter(w=>w.values.active).flatMap(w=>w.values.assetIds));const a=inv.assets.find(a=>a.values.status==='operational'&&!occupied.has(a.id));page.__wd044.denseBefore=w;return {name:a.values.name,count:w.values.assetIds.length,principal:w.values.computerAssetId,history:w.history.length};") + self.open_picker() + self.choose(before['name']) + self.code("await page.getByRole('button',{name:'Añadir al puesto',exact:true}).click();await page.getByRole('dialog',{name:'Añadir activo al puesto',exact:true}).waitFor({state:'hidden'});return true;") + result=self.code("const b=page.__wd044.denseBefore;const inv=await page.__wd044.get('/assets/'+b.companyId);const w=inv.workstations.find(w=>w.id===b.id);page.__wd044.denseCurrent=w;return {count:w.values.assetIds.length,principal:w.values.computerAssetId,history:w.history.length,coverage:await page.locator('[data-workplace-coverage=equipment]').innerText(),realNodes:await page.locator('[data-workplace-map] article[data-workplace-equipment]').count(),actions:await page.locator('[data-workplace-add]').count()};") + self.assertEqual(result['count'],before['count']+1) + self.assertEqual(result['principal'],before['principal']) + self.assertEqual(result['history'],before['history']+1) + self.assertEqual(result['realNodes'],3) + self.assertEqual(result['actions'],1) + self.assertIn(str(before['count']+1),result['coverage']) + self.record('S08-limit-counters',result) + self.capture('S08-limit') + guards=self.code("""const w=page.__wd044.denseCurrent;const path='/assets/'+w.companyId+'/workstations/'+w.id+'/update';const duplicate=await page.__wd044.post(path,{values:{...w.values,assetIds:[...w.values.assetIds,w.values.assetIds[0]]}},w.history.at(-1).id); + const occupied=await page.__wd044.post(path,{values:{...w.values,assetIds:[...w.values.assetIds,"""+json.dumps(self.fixture['host'])+"""]}},w.history.at(-1).id); + const other=await page.__wd044.post('/assets/"""+self.fixture['otherCompany']+"""/workstations',{values:{name:'Aislamiento WD04.4',active:false,siteId:null,personId:null,computerAssetId:null,assetIds:[]}}); + const foreign=await page.__wd044.post('/assets/"""+self.fixture['otherCompany']+"""/workstations/'+other.body.workstation.id+'/update',{values:{...other.body.workstation.values,assetIds:[w.values.assetIds[0]]}},other.body.workstation.history.at(-1).id); + const anonymousContext=await page.context().browser().newContext();let anonymous;try{const response=await anonymousContext.request.get(new URL('/api/assets/"""+self.fixture['companyId']+"""',page.url()).href);anonymous=response.status()}finally{await anonymousContext.close()}; + return {duplicate:{status:duplicate.status,code:duplicate.body.code},occupied:{status:occupied.status,code:occupied.body.code},foreign:{status:foreign.status,code:foreign.body.code},anonymous};""") + self.assertEqual(guards['duplicate']['code'],'INVALID_MEMBERS') + self.assertEqual(guards['occupied']['code'],'ASSET_IN_WORKSTATION') + self.assertEqual(guards['foreign']['code'],'ASSET_NOT_FOUND') + self.assertEqual(guards['anonymous'],401) + self.record('HTTP-MariaDB-guards',guards) + + +if __name__ == '__main__': + unittest.main(defaultTest=['WorkplaceAddAssetTests.test_01_existing_cancel_conflict','WorkplaceAddAssetTests.test_02_responsive_empty_mobile','WorkplaceAddAssetTests.test_03_create_cancel_save_error_refresh','WorkplaceAddAssetTests.test_04_visual_limit_and_http_guards']) diff --git a/scripts/workplace-visual-map.test.py b/scripts/workplace-visual-map.test.py index e67b5ef5..28eee09f 100644 --- a/scripts/workplace-visual-map.test.py +++ b/scripts/workplace-visual-map.test.py @@ -153,9 +153,10 @@ class WorkplaceVisualMapTests(BASE['WorkstationDashboardTests']): def test_software_coverage_states_and_refetch(self): self.tool('browser_resize', {'width':1600,'height':1000}) self.map_ready() - self.addCleanup(self.code, "await page.unroute('**/api/software-installations/**');await page.unroute('**/api/resources/**');return true;") + self.addCleanup(self.code, "await page.unroute('**/api/software-installations/**');await page.unroute('**/api/resources/**');await page.unroute('**/api/resource-application-references/**/resolve');return true;") prepared = self.code("""const res=await page.request.get('"""+self.origin+"""/api/resources/"""+self.fixture['companyId']+"""');if(res.status()!==200)throw Error('Recursos sintéticos no disponibles');const inventory=await res.json();page.__mapApplications=inventory.resources.filter(r=>r.values.name.startsWith('AUDIT-VOLUME-APP-')).slice(0,8);if(page.__mapApplications.length!==8)throw Error('Se requieren ocho aplicaciones sintéticas'); await page.route('**/api/resources/**',async route=>{const response=await route.fetch();const data=await response.json();for(const resource of data.resources??[]){if(page.__mapApplications.some(r=>r.id===resource.id))resource.values.name='Suite de documentación y administración — '+resource.values.name+' — nombre largo para teclado';}await route.fulfill({response,json:data});}); + await page.route('**/api/resource-application-references/**/resolve',async route=>{const response=await route.fetch();const data=await response.json();for(const item of data.items??[]){if(item.status==='resolved'&&page.__mapApplications.some(r=>r.id===item.id))item.reference.name='Suite de documentación y administración — '+item.reference.name+' — nombre largo para teclado';}await route.fulfill({response,json:data});}); page.__mapSoftwareFailure=false; await page.route('**/api/software-installations/**',async route=>{if(page.__mapSoftwareFailure){await route.fulfill({status:500,json:{code:'INTERNAL_ERROR'}});return;}const response=await route.fetch();const data=await response.json();const base=data.configurations.find(c=>c.values.assetId==='"""+self.fixture['host']+"""');if(!base)throw Error('Falta instalación base');const configurations=page.__mapApplications.map((resource,index)=>{const state=['configured','pending','removal_pending'][index%3];const values={...base.values,resourceId:resource.id,licenseResourceId:null,state};return {...base,id:'0199b200-0000-7000-8000-'+String(800+index).padStart(12,'0'),values,needsReview:state==='removal_pending'||!resource.values.active,history:base.history.map(revision=>({...revision,values}))};});await route.fulfill({response,json:{...data,configurations:[...data.configurations.filter(c=>c.values.assetId!==base.values.assetId),...configurations]}});});return {installations:page.__mapApplications.length};""") self.record('software-scenario', prepared)