From fa9d95fa9e227a66a45e278d98e2895d8a9995ad Mon Sep 17 00:00:00 2001 From: David Date: Sat, 24 Oct 2026 04:13:30 +0200 Subject: [PATCH] feat: add workstation equipment and organizational assignment functionalities - Implemented `workstationEquipment` to manage workstation asset assignments, including principal and secondary assets. - Created tests for `workstationEquipment` to ensure correct behavior with various scenarios. - Developed `workstationOrganizationalAssignments` to retrieve current assignments for a responsible person in a company. - Added tests for `workstationOrganizationalAssignments` to validate assignment retrieval logic. - Introduced UI components for displaying workstation profile configurations and responsible person details. - Created compact UI components for displaying lists and tables, enhancing the user interface for asset management. - Added utility functions for handling cell content in tables, ensuring proper rendering of data. --- DESIGN.md | 10 + docs/auditoria-patrones-de-listado-v2.md | 130 +++ docs/componentes-de-interfaz.md | 29 + docs/patron-de-listados-compactos.md | 343 ++++++++ docs/patron-de-listados-de-entidades.md | 21 + docs/workplace-dashboard-implementacion.md | 798 +++++++++++++++++- modules/assets/src/common/locales/en.json | 30 +- modules/assets/src/common/locales/es.json | 30 +- modules/assets/src/web/index.ts | 1 + modules/companies/package.json | 2 +- .../use-workstation-detail-controller.ts | 27 +- .../use-workstation-profile-expansion.ts | 38 + .../helpers/workstation-dashboard.test.ts | 42 +- .../detail/helpers/workstation-dashboard.ts | 22 - .../helpers/workstation-equipment.test.ts | 132 +++ .../detail/helpers/workstation-equipment.ts | 27 + .../helpers/workstation-organization.test.ts | 128 +++ .../helpers/workstation-organization.ts | 36 + .../workstation-profile-expansion.test.ts | 107 +++ .../helpers/workstation-profile-expansion.ts | 73 ++ .../detail/ui/workstation-assets.tsx | 192 +++-- .../detail/ui/workstation-computer.tsx | 72 -- .../ui/workstation-configuration-family.tsx | 41 +- .../ui/workstation-dashboard-skeleton.tsx | 11 +- .../detail/ui/workstation-detail-content.tsx | 88 +- .../detail/ui/workstation-detail-page.tsx | 37 +- .../detail/ui/workstation-detail.test.tsx | 207 ++++- ...kstation-profile-configuration-details.tsx | 114 +++ .../ui/workstation-profile-configurations.tsx | 122 --- .../detail/ui/workstation-profile-person.tsx | 32 + .../detail/ui/workstation-profiles.tsx | 394 +++++---- .../ui/workstation-responsible-context.tsx | 140 +++ .../detail/ui/workstation-responsible.tsx | 60 -- .../detail/ui/workstation-software.tsx | 83 +- packages/rdx-ui/package.json | 2 +- .../rdx-ui/src/compact-collections.test.tsx | 136 +++ .../src/components/collection-preview.tsx | 42 + .../src/components/compact-list-item.tsx | 37 + .../rdx-ui/src/components/compact-list.tsx | 14 + .../rdx-ui/src/components/compact-table.tsx | 114 +++ .../rdx-ui/src/components/detail-field.tsx | 18 +- .../src/components/entity-table-content.tsx | 20 +- .../rdx-ui/src/lib/collection-cell-content.ts | 19 + scripts/workstation-dashboard-ui.test.py | 574 ++++++++++++- 44 files changed, 3890 insertions(+), 705 deletions(-) create mode 100644 docs/auditoria-patrones-de-listado-v2.md create mode 100644 docs/patron-de-listados-compactos.md create mode 100644 modules/companies/src/web/workstations/detail/controllers/use-workstation-profile-expansion.ts create mode 100644 modules/companies/src/web/workstations/detail/helpers/workstation-equipment.test.ts create mode 100644 modules/companies/src/web/workstations/detail/helpers/workstation-equipment.ts create mode 100644 modules/companies/src/web/workstations/detail/helpers/workstation-organization.test.ts create mode 100644 modules/companies/src/web/workstations/detail/helpers/workstation-organization.ts create mode 100644 modules/companies/src/web/workstations/detail/helpers/workstation-profile-expansion.test.ts create mode 100644 modules/companies/src/web/workstations/detail/helpers/workstation-profile-expansion.ts delete mode 100644 modules/companies/src/web/workstations/detail/ui/workstation-computer.tsx create mode 100644 modules/companies/src/web/workstations/detail/ui/workstation-profile-configuration-details.tsx delete mode 100644 modules/companies/src/web/workstations/detail/ui/workstation-profile-configurations.tsx create mode 100644 modules/companies/src/web/workstations/detail/ui/workstation-profile-person.tsx create mode 100644 modules/companies/src/web/workstations/detail/ui/workstation-responsible-context.tsx delete mode 100644 modules/companies/src/web/workstations/detail/ui/workstation-responsible.tsx create mode 100644 packages/rdx-ui/src/compact-collections.test.tsx create mode 100644 packages/rdx-ui/src/components/collection-preview.tsx create mode 100644 packages/rdx-ui/src/components/compact-list-item.tsx create mode 100644 packages/rdx-ui/src/components/compact-list.tsx create mode 100644 packages/rdx-ui/src/components/compact-table.tsx create mode 100644 packages/rdx-ui/src/lib/collection-cell-content.ts diff --git a/DESIGN.md b/DESIGN.md index dc613db..13607af 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -154,6 +154,16 @@ Usar [BrowserLink y rutas existentes](docs/rutas-del-cliente.md) para destinos r ## 5. Patrones de listados +Existen dos patrones oficiales de colecciones: **Listado de entidades** para explorar y +gestionar conjuntos completos y **Listado compacto** para consultar relaciones en fichas, +dashboards y paneles. Elegir por la tarea y las capacidades necesarias, no por un umbral de +filas. El [patrón compacto](docs/patron-de-listados-compactos.md) define sus variantes +Compact Table y Compact Item List, estados, densidad y acceso al conjunto completo. +Su contrato de diseño está vigente; `CompactTable`, `CompactList` y `CompactListItem` +ya existen en rdx-ui y se adoptan inicialmente en el dashboard de puesto. No confundirlo con la densidad de +`DetailPanel` ni con `EntityTable layout="fill"`. La receta siguiente sigue describiendo +exclusivamente el **listado de entidades**, sin cambios en sus bandas ni sus consumidores. + Fuente normativa: [patrón de listados](docs/patron-de-listados-de-entidades.md). Receta de partida: `EntityListHeader` + `EntityTable`, con acciones de fila y formularios en componentes independientes. 1. Cabecera: icono, título y subtítulo; alta a la derecha, adaptable al espacio. diff --git a/docs/auditoria-patrones-de-listado-v2.md b/docs/auditoria-patrones-de-listado-v2.md new file mode 100644 index 0000000..5825547 --- /dev/null +++ b/docs/auditoria-patrones-de-listado-v2.md @@ -0,0 +1,130 @@ +# Auditoría documental de patrones de listado — v2 + +Fecha: 22 de octubre de 2026. Alcance: inspección estática de documentación, skills y +código activo; actualización del contrato documental. Sin cambios funcionales, pruebas, +APIs, dependencias, configuración global ni migraciones de pantallas. + +Seguimiento posterior: los componentes propuestos se han implementado y adoptado +inicialmente en el [refinamiento del dashboard de puesto](workplace-dashboard-implementacion.md#refinamiento-visual-y-adopción-del-listado-compacto). +El contenido siguiente conserva los hallazgos de la fase documental anterior. + +## 1. Fuentes y precedencia + +| Fuente inspeccionada | Papel y resultado | +| --- | --- | +| [AGENTS.md](../AGENTS.md) y [DESIGN.md](../DESIGN.md) | Reglas de proyecto y sistema visual; diseño, APIs reales y patrones especializados prevalecen sobre consejos genéricos de skills. | +| [Patrón de listados de entidades](patron-de-listados-de-entidades.md) | Documento normativo principal del listado completo: cuatro bandas, acciones, ausencias, navegación, estados y excepciones de selectores. Se conserva su contenido original. | +| [Componentes de interfaz](componentes-de-interfaz.md) | Referencia complementaria de APIs y consumidores. No toda descripción histórica equivale a implementación actual. | +| [L01](auditoria-listados-l01.md) | Auditoría transversal, búsqueda/filtrado y deuda V07; sus propuestas no son capacidades implementadas. | +| [L02.1](selectores-accesibilidad-l02-1.md) | Composición y accesibilidad de pickers; su compactación sigue perteneciendo al patrón de selección. | +| [L02.2](configuracion-columnas-l02-2.md) | Columnas: grupos opcionales, búsqueda de opciones, defaults y preferencias. La normalización de opciones no modifica la búsqueda de registros. | +| [L02.3](estados-listados-l02-3.md) | Disponibilidad, carga inicial, caché previa, actualización y reintento en los consumidores adoptados. | +| [L03.1](contexto-listados-l03-1.md) | URL, History, scroll/foco y preferencias; adopción explícita en Activos, Recursos y Personas, sin universalizarla. | +| [V01.2](navegacion-responsive-v01-2.md), [V01.3](inventario-responsive-v01-3.md), [V01.4](recursos-responsive-v01-4.md) | Shell y paneles contextuales por capacidad. Los presupuestos de inventario no son mínimos globales para tarjetas compactas. | +| [Rutas del cliente](rutas-del-cliente.md) y [patrón de diálogos](patron-de-dialogos.md) | Destinos reales, retorno y semántica de interacciones; no crear navegación o modales ficticios. | +| [Implementación del dashboard de puesto](workplace-dashboard-implementacion.md) | Referencia actual del caso principal y verificaciones históricas, que esta auditoría no repite. | + +No se ha localizado un documento independiente que apruebe una normalización general +de búsqueda de entidades. El contrato **propuesto** está en L01, sección «Búsqueda y +filtros», y V07 continúa pendiente en DESIGN.md y el seguimiento L03.1. No confundir +normalización de parámetros URL, comparación para ordenar nombres y búsqueda de columnas. + +La normalización v2 incorpora el [patrón de listados compactos](patron-de-listados-compactos.md) +como referencia normativa complementaria. Su contrato de diseño está definido; su +implementación y adopción permanecen pendientes. + +## 2. Código contrastado y posibilidades de reutilización + +| Código real inspeccionado | Hallazgo | +| --- | --- | +| [EntityTable / EntityListHeader](../packages/rdx-ui/src/components/entity-list.tsx) | Composición completa con búsqueda, configuración, contenido y pie. No ofrece un modo sin herramientas. `layout="fill"` mantiene herramientas y paginación de picker. | +| [useEntityTable](../packages/rdx-ui/src/hooks/use-entity-table.ts) | TanStack Table, filtro `includesString`, ordenación, visibilidad y paginación. El filtro genérico recorta extremos; con `onSearchChange`, el consumidor resuelve el filtrado. Collator se aplica a la columna `name`, no es normalización universal de búsqueda. | +| [EntityTableContent](../packages/rdx-ui/src/components/entity-table-content.tsx) | Tabla semántica, acciones ancladas, fallback de ausencia y filas de datos `h-14`, `px-3 py-3`. Está acoplado al modelo de TanStack y no es una primitiva compacta independiente. | +| [Table vendor](../packages/shadcn-ui/src/components/table.tsx) | Primitivas semánticas reutilizables; texto `text-sm`, celdas `p-2`, cabeceras `h-10`, scroll horizontal propio. Solo lectura, sin modificar vendor. | +| [DetailPanel](../packages/rdx-ui/src/components/detail-panel.tsx) | Contenedor semántico con título, contador y acciones; compact usa `px-4`, `py-4` y `gap-3`. No controla colección, filtros o scroll. | +| [EntityRowActions](../packages/rdx-ui/src/components/entity-row-actions.tsx) y [EntityStatus](../packages/rdx-ui/src/components/entity-list-controls.tsx) | Acciones reutilizables con Base UI y botones icon-sm; el estado visual actual es binario. `primaryAction` es callback de botón, no API de enlace. | +| [EntityListState](../packages/rdx-ui/src/lib/entity-list-state.ts) y [EntityListStatus](../packages/rdx-ui/src/components/entity-list-status.tsx) | Estados presentacionales y reintento desacoplados de consultas; no modelan permisos ni totales parciales. | +| [EmptyState](../packages/rdx-ui/src/components/empty-state.tsx) | Vacío de incorporación con explicación y acciones; no forzar su peso visual para toda colección mínima vacía. | +| [DashboardTable](../modules/companies/src/web/dashboard/shared/ui/dashboard-table.tsx) | Tabla local sencilla con caption, 13 px y padding vertical de 10 px; precedente útil, no contrato compartido. La propuesta compacta toma `text-sm` de las primitivas/ DESIGN, sin copiar sus 13 px como nueva escala. | +| [RemoteOptionList](../packages/rdx-ui/src/components/remote-option-list.tsx) | Selección remota de una página sin volver a ordenar o filtrar el conjunto como si estuviera completo; no es un listado compacto general. | + +La estrategia elegida separa composiciones especializadas sobre Table/listas nativas y +reutiliza estado, acciones y contenedor cuando encajen. No se propone un único componente +con muchos flags ni una nueva dependencia. Las entradas conceptuales y nombres propuestos +están únicamente en el estándar compacto, para evitar duplicar su contrato. + +## 3. Discrepancias y límites registrados + +| ID | Evidencia estática | Tratamiento documental; comportamiento intacto | +| --- | --- | --- | +| LIST-V2-01 | WorkstationProfiles y WorkstationAssets usan EntityTable dentro de DetailPanel compacto | Declarados candidatos a Compact Table y Compact Item List. La densidad de tarjeta no se presenta como adopción del patrón. | +| LIST-V2-02 | «Paneles de consulta y ficha del puesto» describe pestañas Información/Software/Historial y tarjetas de periféricos; el dashboard actual presenta bloques y diálogo de historial | Añadida nota de vigencia hacia el informe de implementación y el apartado actual. Se conservan descripciones históricas, sin convertirlas en una segunda norma. | +| LIST-V2-03 | L01 propone equivalencia de vocales acentuadas; el hook actual usa includesString y los módulos tienen filtros propios | V07 permanece pendiente; no declarar normalización ya aplicada ni modificar búsqueda desde esta tarea. | +| LIST-V2-04 | L03.1 solo habilita contrato navegable completo para tres pilotos | No extender persistencia URL/History a todos los listados ni a resúmenes por afirmación documental. | +| LIST-V2-05 | DashboardTable, listas de software y configuraciones son composiciones locales diferentes | No presentarlas como componentes compartidos ni como validación previa del nuevo contrato visual. | +| LIST-V2-06 | CurrentWorkstations usa bloques div y un texto de ausencia de sede; el estándar pide lista semántica y `—` para valores ausentes | Candidato pendiente de revisión semántica y de ausencias en una futura adopción; no corregido aquí. | + +Estos hallazgos proceden de lectura de código/documentación. No se afirma una reproducción +visual nueva, fallo de interacción o regresión demostrada en navegador. + +## 4. Pantallas candidatas, sin migración + +| Prioridad de estudio | Consumidor real | Patrón candidato y alcance | +| --- | --- | --- | +| Primera referencia | [WorkstationProfiles](../modules/companies/src/web/workstations/detail/ui/workstation-profiles.tsx) | Compact Table; mantener alta, archivados, revisión, enlaces y recuperación. Resolver acceso a todos los perfiles del principal antes de recortar. | +| Primera referencia | [WorkstationAssets](../modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx) | Compact Item List; composición actual del puesto, principal, técnico/uso y edición. | +| Primera referencia | [WorkstationSoftware](../modules/companies/src/web/workstations/detail/ui/workstation-software.tsx) | Compact Item List; normalizar lista actual y conservar gestión completa en diálogo. | +| Primera referencia | [WorkstationConfigurationFamily](../modules/companies/src/web/workstations/detail/ui/workstation-configuration-family.tsx) | Colección contextual por familia/perfil; preservar fuentes, cobertura documental, revisión y gestión. | +| Posterior | [AssetRelatedInformation](../modules/companies/src/web/assets/detail/ui/asset-related-information.tsx) | Perfiles relacionados como Compact Table; la gestión de instalaciones requiere evaluación independiente. | +| Posterior | [CurrentWorkstations](../modules/companies/src/web/people/detail/ui/current-workstations.tsx) | Compact Item List agrupada por responsable/uso; mantener significado de cada relación y referencias. | +| Posterior | [DashboardTable](../modules/companies/src/web/dashboard/shared/ui/dashboard-table.tsx) | Evaluar consumidores reales antes de extraer receta común; no sustituir automáticamente cada tabla del dashboard. | +| Evaluación por dominio | [CompanyResourceRelatedContent](../modules/companies/src/web/resources/detail/ui/company-resource-related-content.tsx) | Evaluar colecciones de relaciones de los componentes compuestos; sus enlaces de navegación no son filas de colección y no se transforman en listas por uniformidad. | + +Los listados principales de Activos, Personas, Recursos, Compañías y los PickerList +permanecen en sus patrones actuales. Ninguna fila de esta tabla autoriza una migración +funcional en esta entrega. + +## 5. Skills verificadas y aplicadas + +Se inspeccionaron `.agents/skills/` y [skills-lock.json](../skills-lock.json): 15 skills +en disco y 15 entradas, sin diferencias de nombres. Las cinco solicitadas están +disponibles; se leyeron sus SKILL.md. El lock registra procedencia y huella, no una +garantía de versión de APIs del proyecto. + +| Skill consultada | Influencia concreta | +| --- | --- | +| [ux-tables](../.agents/skills/ux-tables/SKILL.md), principal | Tabla frente a lista, alineación, atributos comparables, volumen y acceso al texto completo. Se adapta el consejo de acciones solo al hover al requisito Metis de acciones visibles y accesibles. | +| [ux-dashboard](../.agents/skills/ux-dashboard/SKILL.md) | Jerarquía de relaciones y resumen con profundización, sin herramientas ni métricas decorativas. | +| [shadcn](../.agents/skills/shadcn/SKILL.md) | Primitivas y tokens instalados; referencias de composición, estilos y Base/Radix. Se contrastó components.json: base-vega, Tabler y aliases locales. Sin CLI de instalación ni regeneración vendor. | +| [vercel-composition-patterns](../.agents/skills/vercel-composition-patterns/SKILL.md) | Reglas architecture-avoid-boolean-props y patterns-explicit-variants: composiciones diferenciadas en lugar de flags de EntityTable. | +| [vercel-react-best-practices](../.agents/skills/vercel-react-best-practices/SKILL.md) | Regla rerender-derived-state-no-effect: derivar filas/contadores y conservar TanStack Query como fuente remota, sin estado duplicado. No se trasladan recetas específicas de Next/SWR. | + +Se consultó también la [matriz del proyecto](skills-frontend-ui-ux.md). Los auditores +especializados `ux-heuristics-audit`, `ux-accessibility-audit`, `ux-aesthetics-audit`, +`ux-microcopy-audit` y `ux-dark-patterns-audit` citados por las skills no están instalados. +No se instalaron ni invocaron; se revisó el contrato directamente con las guías disponibles. + +## 6. Archivos, decisiones abiertas y verificación + +Archivos documentales de esta entrega: + +1. `docs/patron-de-listados-de-entidades.md`: mapa v2 y alcance sin alterar reglas originales. +2. `docs/patron-de-listados-compactos.md`: estándar complementario completo. +3. `DESIGN.md`: selección de patrón y enlace al nuevo contrato. +4. `docs/componentes-de-interfaz.md`: disponibilidad real, referencias y nota de vigencia. +5. `docs/auditoria-patrones-de-listado-v2.md`: evidencias, discrepancias y candidatos. + +Quedan para implementación la firma final de componentes, prioridades/anchos por consumidor, +acceso completo a perfiles del principal y validación de la receta de densidad con tacto, +texto largo y zoom. V07 mantiene su entrega independiente. No hay una decisión pendiente +que impida usar este estándar como base de diseño; sí debe resolverse la salida al conjunto +completo antes de implementar un resumen recortado. + +Verificación de esta fase: lectura del diff completo, 171 enlaces locales y siete anclas +comprobados en los cinco documentos, sin destinos rotos, y `git diff --check` correcto. +Se verificó que las líneas previas de los tres documentos existentes se conservan en +su orden original. Contraste de recetas con Table, Button, +DetailPanel, EntityTableContent y DESIGN.md. Se comprueba que los cambios solo afectan +a Markdown. No se ejecutan typecheck, lint, tests, build ni Playwright: no hay código o +interfaz modificados. Tampoco se generan fixtures, se toca una base de datos ni se declara +validación visual o accesibilidad práctica del componente futuro. diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 2bf04b6..ddc4a6e 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -201,6 +201,13 @@ El inventario añade altas rápidas de plantilla y sede en los activos. La plant ## Listados de entidades +La normalización v2 distingue este patrón del [listado compacto](patron-de-listados-compactos.md). +`EntityTable` mantiene su contrato completo. `CompactTable`, `CompactList` y +`CompactListItem` están disponibles en `@repo/rdx-ui/components/compact-table`, +`compact-list` y `compact-list-item`; su API y sus límites se definen en el +[contrato implementado](patron-de-listados-compactos.md#11-primera-implementación-compartida). No simular esa +composición ocultando controles de `EntityTable` mediante CSS ni añadir flags para ello. + El estándar se define en [Patrón de listados de entidades](patron-de-listados-de-entidades.md), con inventario de pantallas y excepciones. EntityListHeader, EntityTable, EntityRowActions, EntityAction y EntityStatus viven en @repo/rdx-ui/components/entity-list. EntityTable recibe datos y columnas tipadas; utiliza TanStack Table para búsqueda, ordenación, visibilidad y paginación local. Admite búsqueda y filtros controlados por el módulo, conservando su semántica y ámbito. Las columnas de identificación y acciones quedan visibles. Los menús utilizan Base UI y conservan los callbacks de las acciones originales. No realiza peticiones ni conoce DTO de negocio. @@ -873,6 +880,14 @@ referencias que requieren revisión; el servidor mantiene la validación definit ## Paneles de consulta y ficha del puesto +**Nota de vigencia v2:** las descripciones de pestañas y composición de la ficha de puesto +en este apartado corresponden a una versión anterior. La composición actual se documenta +en [la implementación del dashboard](workplace-dashboard-implementacion.md) y en +«Paneles compactos del dashboard de puesto», al final de este documento. La +[auditoría v2](auditoria-patrones-de-listado-v2.md) registra la discrepancia. El contrato +genérico de `DetailPanel` permanece aplicable; la primera adopción del nuevo patrón +compacto se documenta en el apartado de refinamiento del informe de implementación. + `DetailPanel`, en `@repo/rdx-ui/components/detail-panel`, compone las primitivas `Card`, `CardHeader` y `CardContent` con una cabecera uniforme: título accesible `h2` enlazado mediante un identificador único, icono decorativo, descripción opcional, contador y @@ -1285,6 +1300,12 @@ su retorno al formulario padre. ## Paneles compactos del dashboard de puesto +«Compactos» describe aquí la densidad de los paneles existentes. El +[patrón Listado compacto](patron-de-listados-compactos.md) define una composición de +colecciones distinta, ya adoptada en perfiles mediante `CompactTable` y en equipamiento, +software y familias de configuración mediante `CompactList`/`CompactListItem`. +`CollectionPreview` permite consultar todos los elementos del resumen sin paginación. + `DetailPanel` admite `density="compact"` para resúmenes operativos: conserva la región etiquetada y su contrato de título, icono, descripción, acciones y contenido, con padding interior de 16 px y separación reducida. El valor por defecto mantiene sus recetas. @@ -1296,3 +1317,11 @@ La composición de [la ficha de puesto](workplace-dashboard-implementacion.md) r permite intercalar la acción de historial entre editar y el menú; el diálogo de historial se exporta desde Assets y admite `finalFocus`, con altura estable. Son APIs específicas del módulo consumidor, no nuevos componentes genéricos de RDX UI. + + +### Densidad de campos de consulta + +`DetailField density="compact"` utiliza etiquetas/valores en dos columnas 0,7/1,3, +`gap-3`, padding vertical de 4 px y sin separadores; los textos conservan salto de línea. +El valor predeterminado conserva su receta anterior. Responsable y contexto del dashboard +usa esta variante dentro de un `dl`. No cambia formularios, fichas ni paneles ajenos. diff --git a/docs/patron-de-listados-compactos.md b/docs/patron-de-listados-compactos.md new file mode 100644 index 0000000..6f5b6c0 --- /dev/null +++ b/docs/patron-de-listados-compactos.md @@ -0,0 +1,343 @@ +# Patrón de listados compactos — v2 + +Fecha: 22 de octubre de 2026. **Contrato de diseño vigente; primera implementación compartida adoptada en el dashboard de puesto.** +Complementa el [patrón de listados de entidades](patron-de-listados-de-entidades.md), que +continúa siendo normativo para exploración y gestión. [DESIGN.md](../DESIGN.md) gobierna +tokens, tipografía e identidad; el [patrón de diálogos](patron-de-dialogos.md) gobierna +las interacciones modales. Este documento no crea componentes, rutas, permisos ni APIs. + +## 1. Objetivo y selección del patrón + +Un **Listado compacto (Compact List)** permite reconocer, consultar y actuar sobre +elementos relacionados en una ficha, dashboard, tarjeta o panel contextual. Selecciona +los atributos útiles para esa relación y permite profundizar sin reproducir las +herramientas de una pantalla de exploración. No sustituye un formulario ni un picker. + +Un **Listado de entidades (Entity Listing)** permite encontrar y gestionar registros +de una colección completa mediante las bandas y capacidades del estándar existente. +Activos, Personas, Recursos y Compañías en sus pantallas principales conservan ese patrón. + +| Criterio | Listado de entidades | Listado compacto | +| --- | --- | --- | +| Tarea principal | Encontrar, filtrar, comparar muchos atributos y gestionar | Reconocer relaciones y ejecutar acciones contextuales | +| Ámbito | Colección completa dentro de su ámbito funcional | Relación o resumen de una entidad/contexto explícito | +| Encabezado | Cabecera de pantalla con título, subtítulo y acciones | Título de sección normalmente aportado por el contenedor | +| Composición | Cuatro bandas normativas | Cabecera opcional, filas y salida al conjunto completo cuando proceda | +| Búsqueda/filtros | Herramientas del patrón completo | Ausentes por defecto; búsqueda local solo con justificación | +| Columnas | Configurables, con identidad y acciones protegidas | Seleccionadas por el consumidor, sin configurador | +| Ordenación | Contrato actual de cabeceras y selector | Orden estable del contexto; ordenación local opcional | +| Rango y paginación | Rango superior/inferior, tamaño y paginación | Sin esos controles; ampliación o acceso completo según necesidad | +| Estado navegable | URL/History en consumidores habilitados por L03.1 | Estado local del resumen; conserva retorno al abrir destinos reales | +| Volumen | Preparado para exploración sostenida | Resumen o colección legible con estrategia explícita de acceso completo | +| Presentación | Tabla de entidades | Compact Table o Compact Item List | +| Estado técnico | EntityTable implementada | CompactTable, CompactList y CompactListItem implementados; adopción inicial en puestos | + +Decidir en este orden: + +1. Identificar la pregunta del usuario y la relación que necesita consultar. Si debe + explorar repetidamente el conjunto, aplicar listado de entidades aunque haya pocas filas. +2. Elegir atributos prioritarios y tareas: comparar columnas favorece Compact Table; + reconocer títulos con metadatos favorece Compact Item List. +3. Evaluar crecimiento esperado, frecuencia de uso, acciones y ancho del contenedor. + Definir cómo alcanzar todo el conjunto antes de limitar filas. +4. Si necesita filtros combinados, configuración de columnas, tamaño de página y + paginación recurrente, ofrecer una vista completa con el patrón de entidades. + La tarjeta puede conservar un resumen; no acumular esas capacidades en él. + +Estar en una ficha no obliga a compactar una colección que requiere exploración compleja. +Tener 60 perfiles no obliga a cambiar de patrón: puede mantenerse un resumen compacto, +siempre que el conjunto completo resulte accesible y el alcance visible esté explicado. +Un selector continúa sujeto al patrón de diálogos/PickerList; no pierde búsqueda, +selección o paginación por parecerse visualmente a una lista corta. + +## 2. Estructura y variantes + +El contenedor aporta el ámbito y normalmente el encabezado: título accesible, contador +conocido opcional y acción contextual opcional. No repetir dentro de la colección el +título de `DetailPanel`. El cuerpo contiene filas, estados y acciones. Al final puede +aparecer «Mostrar más» o «Ver todos» con alcance explícito. + +**Compact Table:** comparación de atributos homogéneos. Tabla HTML, nombre accesible, +cabeceras de columna y filas con identidad estable. Las cabeceras aportan significado; +no omitirlas en tablas comparativas ni usar una tabla para maquetar tarjetas. + +| Perfil | Persona | Cuenta | Estado | +| --- | --- | --- | --- | +| Administración | Ángela Muñoz | angela.pruebas | Activo | +| Consultas | Íñigo Fernández | — | Activo | + +**Compact Item List:** lectura lineal de elementos con título, metadatos y acciones. +Usar `ul`/`li`; `ol` solo si la secuencia tiene significado. Por ejemplo, portátil +principal con estado técnico y uso, monitor asociado, o aplicación con versión y estado. +Puede componer `dl` dentro de un elemento para pares etiqueta/valor. Un único elemento +conserva la misma semántica; no requiere una tarjeta nueva para cada fila. + +Ambas variantes comparten reglas de datos, densidad y estados, pero mantienen semánticas +y estructuras independientes. No convertir automáticamente una tabla en lista al crecer +el número de filas. + +## 3. Contrato visual + +Valores orientativos derivados de las primitivas y tokens instalados. La primera receta +compartida se concreta en el contrato implementado al final de este documento. +Los equivalentes en px usan la escala actual; el contenido y la ampliación de texto +pueden aumentar las alturas. Consultar [las fuentes inspeccionadas](auditoria-patrones-de-listado-v2.md). + +| Elemento | Receta orientativa | Criterio | +| --- | --- | --- | +| Contenido principal | Inter, `text-sm` (14 px), título `font-medium` | No reducir todo a 12 o 13 px para ganar densidad | +| Metadatos | `text-xs` (12 px), `text-muted-foreground` | Mantener legibilidad y contraste efectivo | +| Cabecera tabular | `text-sm font-medium`, altura orientativa `h-10` (40 px) | Etiquetas breves; no nuevo estilo de títulos globales | +| Celdas | `px-2 py-2` (8 px) | Menor padding vertical que `EntityTable` (`py-3`); sin alterar vendor | +| Fila simple | Altura natural, alrededor de 40 px de texto; 48 px con acción de 32 px y `py-2` | No fijar altura máxima; aceptar filas multilínea y zoom | +| Elemento de lista | `py-2`, separación título/metadatos `gap-1`; entre grupos `gap-2`/`gap-3` | Ritmo de 4/8/12 px según contenido | +| Acciones | `Button size="sm"` o `size="icon-sm"` (32 px) | Conservar foco y separación; ampliar superficie si el contexto táctil lo exige | +| Separadores | `border-border`, divisores horizontales discretos | Evitar cuadrícula completa, sombras y tarjeta anidada innecesaria | +| Superficie | Heredar `bg-card`/`text-card-foreground` o fondo del contenedor | Sin paleta alternativa ni tokens nuevos de éxito/advertencia | + +Texto a la izquierda; números comparables a la derecha con `tabular-nums`; acciones al +final y alineadas a la derecha. La columna de identidad recibe el espacio flexible +(`min-w-0`); estado y acciones tienen ancho controlado por su contenido. Evitar anchos +mínimos heredados de inventario que hagan inutilizable una tarjeta estrecha. No añadir +un configurador para resolver un exceso de atributos: elegir los esenciales y ofrecer detalle. + +Permitir saltos de línea en nombres largos y valores técnicos. Si se trunca, conservar +el valor completo en el árbol accesible y ofrecer lectura visual completa mediante +expansión, detalle o tooltip accesible también por foco. `title` o hover aislados no +bastan. No recortar cuentas diferentes hasta hacerlas indistinguibles. + +Ausencias en datos: exclusivamente `—`, preservando cero y estados reales. Un error de +resolución de referencias debe explicarse al nivel de la fuente, sin convertirlo en +«sin relaciones». Estado textual además de color; conservar estados técnicos, uso y +revisión por separado. `EntityStatus.active` es binario: no representa por sí solo todos +los estados de configuración o disponibilidad. + +## 4. Capacidades obligatorias y opcionales + +Obligatorio: ámbito reconocible, identidad estable, información prioritaria, semántica +correcta, estados honestos, acciones accesibles y acceso completo a los elementos que +no se muestran. El contador es opcional; si existe, debe indicar si cuenta el conjunto, +los elementos visibles o las coincidencias. Omitir el total cuando se desconoce. + +No componer por defecto barra de búsqueda, barra de filtros, configurador de columnas, +selector de tamaño, paginación, rangos duplicados ni menús de configuración. **Omitirlos +en la composición**, sin controles ocultos por CSS ni elementos invisibles enfocables. + +| Capacidad opcional | Cuándo y condiciones | +| --- | --- | +| Ordenación local | Comparación útil y conjunto local completo; estable para empates, indicador `aria-sort` y botones de cabecera. No confundir ordenación con búsqueda. | +| Búsqueda local | Localización frecuente dentro de la relación; campos y alcance explícitos, limpieza y estado sin coincidencias. No filtrar una página como si fuera el conjunto entero. | +| Expansión de fila | Metadatos secundarios vinculados al elemento; botón con `aria-expanded`/`aria-controls`, contenido asociado y foco estable. | +| Mostrar más | Revelar una porción adicional de la misma colección; explicar cantidad visible si se conoce. Tras activar, no mover foco inesperadamente; al contraer evitar retirar el elemento enfocado. | +| Ver todos | Ruta real con ámbito conservado, o diálogo/panel existente que permita recorrer todo el conjunto. Distinguir navegar de revelar contenido. | +| Scroll interno | Proteger el presupuesto de una tarjeta de consulta repetida; límite de altura responsive, indicador de continuidad, acceso por teclado y foco sin recortes. Evitar scroll anidado innecesario. | +| Límite inicial | Solo con orden y criterio explícitos y una salida que alcance todos los registros. Nunca un `slice` silencioso. | +| Acciones por elemento | Operaciones reales y permitidas; principal visible, secundarias en menú accesible, sin duplicar la principal. No ocultarlas exclusivamente al hover. | +| Selección simple | Alimenta una acción o detalle concreto; radio o control equivalente semántico, nombre y estado visibles. Enlaces y botones internos mantienen su comportamiento. | +| Selección múltiple | Existe una operación colectiva real y se define su alcance y confirmación. Casillas primero, restricciones y selección parcial explícitas; considerar PickerList si se está eligiendo en un formulario. | + +Cada consumidor documentará qué capacidad necesita y por qué. Un nuevo requisito de +exploración compleja debe resolverse en la vista completa antes de ampliar el resumen +con otra barra. Búsqueda y ordenación opcionales no implican persistencia global nueva. + +## 5. Volumen y acceso completo + +Escenarios de diseño, **no umbrales automáticos**: + +| Escenario | Estrategia | +| --- | --- | +| 0 | Vacío real tras respuesta válida, explicación contextual y alta/vinculación si existe y está permitida. No mostrar tabla de controles sin propósito. | +| 1 | Mantener fila o elemento del mismo patrón; tabla si las etiquetas comparables siguen aportando significado. | +| 2–5 | Mostrar normalmente todo; sin herramientas de exploración añadidas por defecto. | +| 6–15 | Evaluar altura, anchura y frecuencia; mostrar todo si resulta legible o ampliar progresivamente con alcance explícito. | +| Decenas/cientos | Resumen inicial determinista y acceso completo mediante expansión, región desplazable o destino completo real. Para investigación sostenida, utilizar listado de entidades en ese destino. | + +Si no existe una ruta que conserve la relación, no inventar un enlace ni enlazar «Ver +todos» a un listado sin ese filtro. Puede mostrarse todo con desplazamiento contenido o +proponerse una interacción local de ampliación para implementación posterior. La ausencia +de destino completo bloquea un recorte que haría inaccesibles registros, no obliga a +inventar contratos HTTP. + +En datos remotos paginados indicar «elementos cargados» o «hay más» según el contrato. +«Cargar más» obtiene páginas reales y conserva orden/identidades; no es paginación local +oculta. Si se necesita cambiar página/tamaño o filtrar todo el servidor, llevar esa tarea +a la vista completa. No derivar total global de `items.length` de una página. + +## 6. Estados y fuentes + +| Situación | Presentación y recuperación | +| --- | --- | +| Carga inicial | Skeleton de geometría plausible, decorativo y sin nombres, acciones o totales inventados; mensaje de carga de la colección. | +| Actualización con datos previos | Conservar filas, selección, expansión y foco; aviso discreto. Si falla, explicar que se muestran últimos datos disponibles y ofrecer reintento dirigido. | +| Vacío real | Cero únicamente confirmado por respuesta válida del ámbito completo; CTA pertinente con permisos. | +| Sin coincidencias | Mantener criterio visible y acción de limpiar búsqueda; no afirmar ausencia de relaciones. | +| Error parcial | Mantener fuentes válidas; identificar la fallida, las limitaciones del resumen y su reintento. No mostrar un total definitivo incompleto. | +| Fuente no disponible | Mensaje contextual y recuperación si procede; no reutilizar vacío. Si no existe caché autorizada, no mostrar filas inventadas. | +| Información incompleta | Conservar registros válidos y `—` en datos ausentes; explicar referencia no resuelta o metadatos pendientes en su contexto. No equiparar ausencia y error. | +| Datos paginados | Identificar alcance cargado, total solo si es conocido y acceso al resto. | +| Ausencia de permisos | Respetar denegación real, retirar datos no autorizados y explicar acceso según contrato del módulo. No usar caché anterior como sustituto de autorización. | + +`EntityListState` existente cubre loading/ready/refreshing/unavailable/stale. No cubre por +sí solo total desconocido, permisos, error de otra fuente o parcialidad de datos: esos +hechos pertenecen al adaptador consumidor. Reutilizar la unión cuando encaje y componer +la información adicional; este estándar no añade variantes a su API. + +Ubicar feedback y reintento fuera de la región horizontal desplazable. Conservar un +destino de foco estable cuando desaparezca el botón de reintento. Usar anuncios +moderados por colección, sin una región live por celda ni toasts repetidos en refetch. +Los datos remotos siguen en TanStack Query; no duplicarlos en estado React. + +## 7. Responsive y accesibilidad + +Adaptar por capacidad real del **contenedor**, sin copiar presupuestos de Activos o +Recursos ni definir un breakpoint global. Mantener identidad, estado y acciones; los +atributos secundarios pueden ir al detalle o a una fila expandida con acceso explícito. +En lista, apilar título, metadatos y acciones conservando orden DOM. En tabla, preferir +salto de línea y columnas prioritarias; usar scroll horizontal contenido cuando la +comparación lo requiera. Si se diseña una alternativa móvil como lista, conservar +etiquetas y relaciones, una sola presentación accesible e identidad/foco estables. + +- Tabla nativa con nombre mediante `caption`, `aria-label` o `aria-labelledby`, `th` + con `scope="col"` y cabecera de fila cuando resulte pertinente. Etiquetas accesibles + para columnas visuales de iconos/acciones, aunque usen `sr-only`. +- Lista nativa con nombre de sección; no simular tabla con `div` ni añadir `role="grid"` + sin implementar su modelo de interacción. No usar la semántica de tabla para composición. +- Enlace para destino real, botón para acciones, expansión y diálogos. Navegación del + cliente mediante `BrowserLink` desde el consumidor; la biblioteca genérica no conoce rutas. +- Acciones por teclado, etiquetas traducidas para iconos funcionales y decorativos + `aria-hidden`. Una fila clicable conserva controles internos y alternativa de teclado. +- Foco visible no recortado, sin dependencia de hover; retorno al origen tras modal y + conservación del registro al ampliar/cargar más. No introducir saltos de foco en refetch. +- Contraste efectivo según DESIGN.md: texto normal 4,5:1 y texto grande 3:1; comprobar + también controles, selección y foco según los criterios aplicables del proyecto. +- Contenido completo legible con teclado y tacto; zoom 200/400 %, nombres largos, + espacios y valores técnicos sin desbordamiento global. La compactación no justifica + reducir controles ni fijar alturas que recorten texto. + +La futura implementación debe verificarse con el [procedimiento Playwright MCP](playwright-mcp.md), +datos sintéticos aislados y volumen de AGENTS.md, además de escenarios específicos +vacíos y pequeños. Las reglas aquí escritas no equivalen a una validación en navegador +ni a cumplimiento WCAG demostrado. + +## 8. Arquitectura de componentes + +**Compartir primitivas y comportamientos transversales; diferenciar composición y capacidades.** +No añadir un modo universal a `EntityTable` con flags para quitar barras. + +| Pieza existente | Reutilización y límite | +| --- | --- | +| Table, TableHeader, TableBody, TableRow, TableHead, TableCell | Base semántica de Compact Table. Componer desde código propio; no editar vendor. | +| DetailPanel | Contenedor opcional con título/acciones/contador; `density="compact"` reduce espaciado, sin gestionar filas ni estados. Evitar doble tarjeta. | +| EntityStatus / Badge | Estado textual y visual cuando la semántica encaja; el booleano de EntityStatus no modela un estado complejo. | +| EntityRowActions / EntityAction | Acciones existentes, menú Base UI y restricciones. La navegación conserva un enlace real, no se transforma en callback de botón por usar primaryAction. | +| EntityListState / EntityListStatus | Disponibilidad, refetch, error y reintento cuando el contrato encaja; el consumidor conserva permisos y parcialidad. | +| EmptyState, Skeleton | Composición de estados; EmptyState tiene presentación de incorporación y no es obligatorio para todo vacío mínimo. | +| BrowserLink y contratos de retorno | El módulo aporta enlaces y contexto real; no acoplar rdx-ui a core o DTO. | +| EntityTableContent / useEntityTable | Acoplados a TanStack y al patrón completo; no reutilizarlos obligando a inicializar paginación/visibilidad que el compacto no necesita. | +| DashboardTable | Precedente local sobre Table; no es todavía API compartida ni receta normativa del compacto. | + +Responsabilidades del patrón; la API mínima implementada se describe en §11. Las +capacidades opcionales anteriores no se consideran implementadas por aparecer aquí: + +| Componente | Responsabilidad y entradas conceptuales | +| --- | --- | +| CompactTable | Tabla comparable: nombre accesible, colección readonly con identidad estable y definición mínima de columnas (ID, etiqueta y render). Alineación y prioridad explícitas cuando proceda. | +| CompactList | Lista semántica de elementos con identidad estable, nombre accesible y contenido compuesto por el consumidor. Sin motor de tabla. | +| CompactListItem | Título, metadatos y acciones mediante composición; independiente de entidades, consultas y traducciones. Extraer solo ante repetición real. | + +Los nombres de ficheros siguen kebab-case y componentes PascalCase. La +implementación genérica pertenece a `@repo/rdx-ui`; +las adaptaciones de entidades viven en su operación conforme a la estructura web vigente. +No crear barrels vacíos, factories, contexto React o un motor de columnas universal de antemano. + +Los renderizadores reciben datos presentacionales, textos traducidos y acciones del módulo, +sin endpoints, ORM ni DTO específicos. No exigir `EntityColumn` con capacidades de exploración +para describir una fila compacta. Selección, búsqueda o ampliación se añaden como composición +explícita cuando haya consumidores; no mediante una colección de flags `hide*`/`show*`. + +La consulta se comparte por query keys existentes. Calcular filtrados, contadores y filas +durante render; solo criterio, selección y expansión necesitan estado local. Sin efectos +para copiar datos remotos, consultas por fila, memoización preventiva ni nuevas dependencias. +No persistir preferencias nuevas ni introducir paginación/virtualización sin necesidad medida. + +## 9. Casos de referencia y ejemplos + +La ficha de Puesto de trabajo es referencia funcional, **no la única especificación visual**. +La tabla conserva el diagnóstico anterior y la transformación adoptada en la primera +implementación, descrita en el informe de refinamiento: + +| Colección | Actual inspeccionado | Propuesta | +| --- | --- | --- | +| Personas y perfiles | WorkstationProfiles usa EntityTable completa con búsqueda, contador, columnas, ordenación y paginación dentro de DetailPanel | Compact Table: perfil, persona, cuenta, estado y acción contextual; resumen de configuraciones solo si aporta a la tarea y cabe. Conservar alta, archivados, revisión, enlaces y estados independientes. | +| Equipamiento | WorkstationAssets usa EntityTable completa | Compact Item List: nombre y enlace, plantilla, condición de principal, estado técnico y uso separados; editar composición sigue siendo una acción explícita. | +| Software del principal | WorkstationSoftware ya presenta ul/li con scroll y gestión en diálogo | Normalizar como Compact Item List preservando versión, licencia, estado, revisión, errores por fuente y gestión existente. No sustituir su diálogo de gestión completa por resumen. | +| Configuraciones por perfil | WorkstationConfigurationFamily presenta listas por familia, selector de perfil y destinos de gestión | Colecciones contextuales con selección de perfil fuera de las filas; conservar familias, estados de configuración, cobertura documental y revisión, sin inferir permisos efectivos. | + +Con 60 perfiles: indicar alcance (por ejemplo, activos y archivados separados), mantener +orden estable y permitir recorrerlos todos. Hasta disponer de una ampliación o destino +real que conserve la relación con el ordenador principal, no limitar el resumen sin salida. +Una ruta de perfiles de compañía sin ese filtro no equivale a «todos los perfiles del puesto». + +| Correcto | Incorrecto | +| --- | --- | +| Tres activos con título, estado técnico, uso y enlaces en lista | EntityTable completa dentro de tarjeta solo para mostrar tres nombres | +| Resumen de perfiles con Mostrar más que permite alcanzar los 60 | Recortar a cinco sin explicar que faltan registros | +| Ver todos abre un destino real con el mismo ámbito | Enlace inventado o listado general presentado como si conservara la relación | +| Herramientas omitidas de la composición | Ocultar buscador y paginación de EntityTable con selectores CSS | +| Reintento de la familia fallida y datos válidos conservados | Fallo de metadatos convertido en «0 configuraciones» | +| Acción visible y menú operable con teclado | Acción disponible únicamente al pasar el ratón | +| Vista completa para filtrar muchas relaciones | Añadir sucesivamente todas las barras del inventario al resumen | + +## 10. Compatibilidad y decisiones de implementación pendientes + +Este patrón no altera los listados de entidades existentes, la excepción de pickers `layout="fill"`, +L03.1, contratos HTTP, i18n es/en, permisos ni políticas de actualización de consultas. +Mantener contexto de compañía y retorno al navegar; el resumen no hereda sort/página +del listado padre ni añade parámetros URL por defecto. Una interacción contextual con +contrato URL propio lo conserva. Modal de alta/edición sigue el patrón de diálogos. + +Para futuras adopciones debe concretarse por consumidor: + +- Prioridad y anchura efectiva de columnas, altura disponible y forma de ampliación. + La receta orientativa de densidad debe validarse con texto largo, tacto y zoom. +- Destino o interacción para consultar todos los perfiles del principal y tratamiento + de archivados; no asumir que existe un filtro de ruta aún no implementado. +- Evoluciones de la API mínima y extracción real de renderizadores/estados comunes. + Los nombres propuestos no comprometen una API universal. +- Búsqueda local si se necesita: V07 sigue pendiente. La normalización del selector de + columnas no aprueba automáticamente la de entidades; conservar la semántica vigente + hasta una entrega específica y documentar los campos buscables. + +La [auditoría v2](auditoria-patrones-de-listado-v2.md) contiene candidatos adicionales, +evidencias estáticas, discrepancias y verificaciones realizadas en esta fase documental. + + +## 11. Primera implementación compartida + +Adoptada en [el refinamiento del dashboard de puesto](workplace-dashboard-implementacion.md#refinamiento-visual-y-adopción-del-listado-compacto). +Primitivas de `@repo/rdx-ui/components/`: + +| API disponible | Contrato implementado | +| --- | --- | +| `CompactTable` | `title`, `items` readonly, `columns` readonly y `emptyMessage`; opcionales `className` sobre la tabla, `state`, `onRetry` y `retryLabel`. | +| `CompactColumn` | `id`, `label`, `render(item)` y `align` opcional: start o end. Sin metadatos de búsqueda, visibilidad o paginación. | +| `CompactList` | `title` accesible y `children`; lista nativa con divisores y tipografía estándar. | +| `CompactListItem` | `title`, `metadata?`, `icon?`, `actions?`, `children?`; identidad flexible y acciones independientes. | +| `CollectionPreview` | `items`, `limit`, `expandLabel`, `collapseLabel` y función hija que recibe los elementos visibles; amplía al conjunto completo cargado. | + +`CompactTable` conserva mensajes de `EntityListState` mediante `EntityListStatus`, reintento +con foco estable fuera del scroll y valores ausentes mediante el mismo helper que +`EntityTable`. El consumidor aporta textos y no debe proporcionar un callback de reintento +sin su etiqueta. El padding implementado de celda es `px-2 py-1.5` (8/6 px), ajustado para +filas de texto y acciones legibles; la altura continúa siendo natural. No hay herramientas +ocultas, motor TanStack, consultas ni dependencia de Workstation. + +`CollectionPreview` muestra inicialmente el límite elegido por la tarea (cinco en el +resumen de puesto) y ofrece Mostrar todos con cantidad y Mostrar menos, con +`aria-expanded`/`aria-controls`. No pagina, no filtra ni presume que los elementos cargados +sean un total remoto. Mantiene el botón y su foco al ampliar o contraer. Límite no entero +positivo implica mostrar todo, evitando recortes inaccesibles. + +Ordenación local, búsqueda, selección y carga de páginas remotas siguen siendo capacidades +de diseño opcionales **no implementadas por estos componentes**. No se añadieron flags a +EntityTable. Las adopciones restantes de la auditoría v2 continúan pendientes. diff --git a/docs/patron-de-listados-de-entidades.md b/docs/patron-de-listados-de-entidades.md index 0561623..24591b2 100644 --- a/docs/patron-de-listados-de-entidades.md +++ b/docs/patron-de-listados-de-entidades.md @@ -2,6 +2,27 @@ Estándar de Metis Desk. Fuente funcional: patrón acordado con el usuario en esta conversación. La referencia de Activos conserva el estilo Vega, los tokens semánticos y la ficha lateral real de la aplicación. +## Dos patrones de presentación de colecciones — v2 + +Desde el 22 de octubre de 2026 se distinguen dos patrones oficiales: + +- **Listado de entidades (Entity Listing):** exploración y gestión de colecciones completas. Este documento sigue siendo su fuente normativa; conserva las cuatro bandas, controles, estados y contratos de contexto existentes. +- **Listado compacto (Compact List):** consulta rápida de relaciones en dashboards, fichas y paneles. Su fuente normativa complementaria es el [patrón de listados compactos](patron-de-listados-compactos.md), que contiene la comparación, criterios de selección y contrato visual y funcional. + +El objetivo de la tarea, los atributos y las capacidades necesarias determinan la elección; +ni el número de filas ni estar dentro de una tarjeta bastan. Las reglas de estructura y +herramientas siguientes corresponden al listado de entidades, con sus excepciones explícitas +de selectores. No se trasladan automáticamente al listado compacto. Las reglas transversales +de valores ausentes, permisos, navegación real, acciones accesibles y conservación de contexto +siguen siendo aplicables a ambos patrones. + +El patrón compacto es **normativo para diseño y dispone de componentes compartidos en +rdx-ui**, adoptados inicialmente en [el dashboard de puesto](workplace-dashboard-implementacion.md#refinamiento-visual-y-adopción-del-listado-compacto). La normalización v2 original fue +documental; esta primera adopción conserva el contrato por defecto de `EntityTable`. `layout="fill"` es la composición de selectores existente y +`DetailPanel density="compact"` compacta la tarjeta; ninguno implementa el nuevo patrón. +La [auditoría v2](auditoria-patrones-de-listado-v2.md) registra fuentes, discrepancias, +candidatos y decisiones pendientes sin alterar las verificaciones históricas de este documento. + Los listados nuevos y sus operaciones deben aplicar la [estructura web por entidades y operaciones](estructura-web-por-entidades-y-operaciones.md). El listado pertenece a `/list/ui`; sus altas, detalles e historiales a sus operaciones diff --git a/docs/workplace-dashboard-implementacion.md b/docs/workplace-dashboard-implementacion.md index e10e618..92e7fff 100644 --- a/docs/workplace-dashboard-implementacion.md +++ b/docs/workplace-dashboard-implementacion.md @@ -2,6 +2,8 @@ Fecha: 22 de octubre de 2026. Ruta real: `/companies/:companyId/workstations/:id`. +**Estado actual:** [WD04.3 — Consolidación de Responsable y contexto](#wd043--consolidación-de-responsable-y-contexto), 24 de octubre de 2026. Los apartados anteriores conservan la evolución de las entregas previas. + ## Resultado y referencias La ficha presenta ordenador principal, configuraciones pendientes, personas y perfiles, responsable y contexto, equipamiento, software, configuraciones por perfil, soporte/acceso remoto y último cambio. El historial completo, edición, renombrado, alta de perfil y ciclo de vida siguen usando las operaciones existentes. @@ -142,7 +144,7 @@ Los siguientes archivos contienen la implementación, sus pruebas o su documenta - [modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.test.ts](../modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.test.ts) - [modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.ts](../modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.ts) - [modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx) -- [modules/companies/src/web/workstations/detail/ui/workstation-computer.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-computer.tsx) +- `modules/companies/src/web/workstations/detail/ui/workstation-computer.tsx` (componente histórico retirado en WD04.1) - [modules/companies/src/web/workstations/detail/ui/workstation-configuration-family.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-configuration-family.tsx) - [modules/companies/src/web/workstations/detail/ui/workstation-dashboard-skeleton.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-dashboard-skeleton.tsx) - [modules/companies/src/web/workstations/detail/ui/workstation-detail-content.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-detail-content.tsx) @@ -151,10 +153,10 @@ Los siguientes archivos contienen la implementación, sus pruebas o su documenta - [modules/companies/src/web/workstations/detail/ui/workstation-latest-change.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-latest-change.tsx) - [modules/companies/src/web/workstations/detail/ui/workstation-pending-configurations.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-pending-configurations.tsx) - `modules/companies/src/web/workstations/detail/ui/workstation-people.tsx` — retirado; responsabilidad integrada en los bloques actuales. -- [modules/companies/src/web/workstations/detail/ui/workstation-profile-configurations.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-profile-configurations.tsx) +- `modules/companies/src/web/workstations/detail/ui/workstation-profile-configurations.tsx` (componente histórico sustituido en WD04.2 por [workstation-profile-configuration-details.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-profile-configuration-details.tsx)) - [modules/companies/src/web/workstations/detail/ui/workstation-profiles.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-profiles.tsx) - [modules/companies/src/web/workstations/detail/ui/workstation-query-feedback.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-query-feedback.tsx) -- [modules/companies/src/web/workstations/detail/ui/workstation-responsible.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-responsible.tsx) +- `modules/companies/src/web/workstations/detail/ui/workstation-responsible.tsx` (componente histórico retirado en WD04.3; su información contextual pasa a [workstation-responsible-context.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-responsible-context.tsx)) - [modules/companies/src/web/workstations/detail/ui/workstation-software-dialog.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-software-dialog.tsx) - [modules/companies/src/web/workstations/detail/ui/workstation-software.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-software.tsx) - [modules/companies/src/web/workstations/detail/ui/workstation-support.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-support.tsx) @@ -203,3 +205,793 @@ instalar paquetes ni dejar bytecode en el repositorio. Se conservan 18 PNG y el informe de medidas en esa carpeta estable. No incluye sesiones ni credenciales. El preparador y su contenedor se han retirado al terminar; se comprobó que la sesión privada ya no existe y las capturas permanecen para revisión local. + +## Refinamiento visual y adopción del listado compacto + +Segunda iteración, 22 de octubre de 2026. Se mantiene el dashboard real y sus contratos. +El refinamiento sustituye la infraestructura de exploración dentro de las tarjetas por +el [patrón compacto](patron-de-listados-compactos.md#11-primera-implementación-compartida). +La captura anterior `S01-1600.png` se conserva y se ha inspeccionado. No se dispone del +mockup original aprobado: se aplica la especificación textual, sin afirmar fidelidad +exacta de píxeles. Los ocho documentos históricos de `auditorias/workplace-dashboard/` +permanecen intactos. + +### Componentes y operaciones + +| Cambio | Resultado | +| --- | --- | +| Nuevos `CompactTable`, `CompactList`, `CompactListItem` en rdx-ui | Primitivas compartidas sobre Table/listas nativas, con identidad estable, nombres accesibles y textos aportados por el consumidor. Sin DTO, consultas ni dependencia de Workstation. | +| Nuevo `CollectionPreview` en rdx-ui | Resumen inicial de cinco elementos en perfiles, equipamiento y software; Mostrar todos/Mostrar menos permite acceder al conjunto cargado completo, sin paginación ni recorte silencioso. | +| `collection-cell-content.ts` | Extrae sin cambiar su lógica el criterio de ausencia de EntityTableContent; ambas tablas conservan `—`, cero, controles y contenido compuesto. | +| `WorkstationProfiles` | Compact Table con Perfil, Persona, Cuenta, Estado y Configuraciones; sin búsqueda, barras, Columnas, ordenación visible, rangos o pie de paginación. Orden estable por nombre e idioma. | +| Archivados | Acción compacta en la cabecera, con flecha decorativa y estado pulsado. Desaparece la línea adicional de nombres archivados. Se mantiene su consulta; el resumen inicial sigue mostrando perfiles activos. | +| Categorías | Etiquetas es/en breves según las familias reales: Correo, Carpeta, Aplicación, Impresora y Certificado. Se mantiene el enlace al perfil completo y sus avisos de revisión. | +| `WorkstationAssets` | Compact Item List con enlace, icono, plantilla, Principal y badges de estado técnico y uso. Miembros deduplicados por identidad; no se añaden impresoras de configuración ni miembros históricos. | +| `WorkstationSoftware` | Lista compartida con versión, licencia, estado y revisión. Conserva Gestionar software y su diálogo con alta, edición, borrado e historial. Sigue siendo software del principal, distinto de aplicaciones por perfil. | +| `WorkstationComputer` | Identidad/icono, estados técnicos/de uso como badges y botón secundario real para abrir el activo. Composición horizontal adaptable sin imágenes externas. | +| `DetailField density="compact"` | Variante opcional sin separadores, padding vertical de 4 px y columnas etiqueta/valor 0,7/1,3; solo Responsable y contexto la adopta. Default anterior intacto. | +| `WorkstationConfigurationFamily` | Adopta CompactList/CompactListItem y Badge. Mantiene nombres, rutas UNC, detalles, estados, revisión y destinos de gestión; reduce el espaciado y conserva metadatos a 12 px. | +| Cabecera y cuadrícula | Separación principal de 16 a 12 px; metadatos flexibles y cabecera con menor separación. Primera banda conserva exclusivamente ordenador y pendientes, con espacio libre intencional. | +| Tokens | Se retira el alias local de primary a secondary de la primera implementación. Acciones y enlaces usan los tokens reales de Metis, sin paleta propia ni cambios globales. | +| Soporte y último cambio | Se conservan sus componentes, subbloques, gestión y algoritmos. Se inspecciona su presentación completa; el soporte sigue siendo registro documental y el remoto se limita a la persona del perfil seleccionado. | + +Se reutilizan DetailPanel, EntityListState/EntityListStatus, Badge, Button, Table, +BrowserLink, PersonAvatar, Skeleton y las primitivas Base UI instaladas. `EntityTable` +no cambia su composición ni comportamiento por defecto; solo comparte el helper de +contenido ausente. No se añaden dependencias, APIs, modelos, migraciones ni consultas. + +### Navegación contextual + +La barra global la compone `WorkspaceSidebar` y la contextual `CompanyFixedNavigation`, +coordinadas por `WorkspaceMobileNavigationProvider` y los presupuestos de capacidad +vigentes. La global persiste `metis-desk:sidebar-collapsed`; `companyHidden` es estado de +sesión del provider. Ambas se usan en las rutas del workspace/compañía, no son un +componente exclusivo de la ficha del puesto. + +Existe un mecanismo oficial: Ocultar secciones libera la columna contextual; la franja +con compañía/sección mantiene el contexto y abre la navegación completa en Sheet. +Fijar secciones permite volver a la columna cuando hay capacidad. Se comprueban ambos, +Escape, teclado y retorno de foco. A 1600 px, el ancho de `main` pasa de 1288 a 1528 px +al ocultarla manualmente y vuelve a 1288 al fijarla. + +No se cambia el shell, ni se fuerza colapso por ruta, ni se añade otra preferencia. La +captura comparable conserva la navegación fija anterior. El modo oculto es una elección +explícita ya existente, evitando cambios inesperados de ancho al abrir la ficha. + +### Medidas antes y después + +Misma instalación desechable, mismos datos S01 y ancho de 1600 px; coordenadas respecto +al viewport con la página al inicio. Capturas a 1600×1000. Son mediciones de estos datos, +no alturas CSS fijas ni garantías para todo contenido. + +| Medida | Antes | Después | +| --- | --- | --- | +| Alto Ordenador principal | 177 px | 113 px | +| Alto Personas y perfiles | 510,5 px | 221,5 px (−57 %) | +| Alto Responsable y contexto | 289 px | 197 px (−32 %) | +| Alto Equipamiento | 462,5 px | 186 px | +| Alto Software | 215 px | 185 px | +| Inicio de Equipamiento y Software | Y=959,5 | Y=618,5 (341 px antes) | +| Inicio de Configuraciones y Soporte | Y=1438 | Y=816,5 | +| Ancho de main, navegación fija | 1288 px | 1288 px | +| Proporción de columnas | 1,6:1 | 1,6:1, aproximadamente 62/38 | +| Separación entre tarjetas | 16 px | 12 px | + +La mejora procede de retirar herramientas y separadores redundantes, no de reducir el +texto principal. Equipamiento y software caben completos en la primera pantalla de +escritorio, además de identidad, primera banda y perfiles/contexto; comienza a verse +la siguiente banda. Las rutas y nombres completos mantienen acceso legible. + +| Ancho | Scroll global antes/después | Alto Perfiles antes/después | Comportamiento | +| --- | --- | --- | --- | +| 1600 | 1600/1600 px | 510,5/221,5 px | Dos columnas, primera banda con separación libre. | +| 1440 | 1440/1440 px | 558,5/261,5 px | Dos columnas; encabezado y nombres se adaptan al espacio. | +| 1280 | 1280/1280 px | 474,5/221,5 px | Bandas adaptadas al contenedor; sin comprimir columnas de comparación. | +| 1024 | 1024/1024 px | 474,5/221,5 px | Navegación responsive existente, contenido apilado cuando corresponde. | +| 768 | 768/768 px | 526,5/221,5 px | Acciones flexibles y flujo vertical. | +| 390 | 390/390 px | 739,5/294,5 px | Tabla con ancho comparativo mínimo de 36 rem y desplazamiento propio; listas apiladas y texto completo consultable. | + +El objetivo orientativo de 160–220 px queda próximo en escritorio con dos perfiles; a +1440 px el encabezado/filas requieren más espacio. Se conserva altura natural, sin +recortar nombres, botones o estados para cumplir una cifra. Los rangos 0/1/2/10/60 se +cubren en pruebas de componente; S04 comprueba 50 activos y diez archivados, 60 perfiles +en total, y 80 miembros alcanzables desde la ampliación. + +### Evidencias y pruebas + +Las capturas están en `.artifacts/playwright/workstation-dashboard-refinement/`, con +subdirectorios `before` y `after`, excluidos de Git. Se copian solo PNG y mediciones +sanitizadas, nunca la sesión. Los enlaces requieren conservar esa carpeta para compartir +el informe con sus imágenes. + +- S01: [antes a 1600](../.artifacts/playwright/workstation-dashboard-refinement/before/S01-1600.png), [después a 1600](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-1600.png), [1440](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-1440.png), [1280](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-1280.png), [1024](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-1024.png), [768](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-768.png), [390](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-390.png). +- Secciones: [Personas y perfiles](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-detail-profiles.png), [Equipamiento](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-detail-equipment.png), [Software](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-detail-software.png), [Configuraciones](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-detail-configurations.png), [Soporte](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-detail-support.png), [Último cambio](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-detail-latest.png). +- [Página completa](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-full-page.png), [S04 denso](../.artifacts/playwright/workstation-dashboard-refinement/after/S04-density-1440.png), [navegación contextual oculta](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-company-navigation-hidden.png), [compañía larga en móvil](../.artifacts/playwright/workstation-dashboard-refinement/after/S01-long-company-390.png). + +La captura completa expande temporalmente los contenedores de scroll solo durante la +captura y restaura estilos/coordenadas en finally. Las medidas responsive se toman con +el layout normal. Las capturas de secciones de antes se obtuvieron antes de modificar +las tarjetas y se conservan junto a las nuevas. Se abren e inspeccionan capturas reales; +una ejecución correcta de tests no se presenta como una revisión visual automática. + +Preparación: setup-playwright-wsl.sh, visual-audit.py, visual-audit-volume.py y +workstation-dashboard-fixtures.py, mediante el servidor MCP Docker oficial fijado en +la configuración del proyecto. Únicamente MariaDB desechable `metis_desk_test`, API y +Vite temporal en puertos libres. Volumen inicial: 15 compañías, principal con 59 personas, +135 activos y 73 recursos; la sustitución de principal crea un activo adicional. El +usuario ordinario entra mediante el formulario real, con CSRF y contratos existentes. + +Validación dirigida: + +- 34 pruebas de colecciones correctas: cinco nuevas y 29 de EntityTable; semántica, + enlaces, ausencia de herramientas, escenarios de volumen, carga/error/caché, ausencias, + cero y contrato completo de EntityTable conservado. +- 20 pruebas dirigidas del dashboard y sus relaciones correctas en es/en. +- 15 métodos interactivos MCP comprobados: los primeros 14 pasan en la ejecución conjunta + final y el caso de compañía larga pasa en ejecución dirigida tras corregir su fixture + para respetar el máximo real de 100 caracteres. Se verifican los seis anchos, todos los + elementos de S04, estados parciales, gestión y guardados reales. Sin excepciones JavaScript + de página; 38 muestras de contraste de texto/enlaces/acciones, mínimo 4,53:1 en el tema claro. +- Tipos de Companies, Assets, rdx-ui y Web correctos; Biome del código/JSON afectado y + ESLint de espaciado correctos. Paridad de claves es/en y sintaxis Python comprobadas. +- `pnpm build`: correcto; conserva el aviso anterior de chunks superiores a 500 kB. +- Checks globales ejecutados: `pnpm typecheck` sigue fallando en vendor calendar.tsx:165; + `pnpm lint` y `pnpm check`, con 15/167 errores previos fuera del cambio. `pnpm test` + mantiene el fallo anterior de companies-page.test.tsx (41/42 ficheros Companies pasan; + los otros siete paquetes completan correctamente). No se corrige código ajeno ni vendor. + +La suite MCP actualiza los recorridos anteriores al contrato compacto, sin eliminar la +cobertura de operaciones: archivados, alta, selector, software, gestión, historial, +renombrado, responsable/composición, errores parciales, caché obsoleta, reintento, conteo, +principal sustituido, autenticación, teclado, foco y contraste. Las pruebas de búsqueda +paginada del resumen se sustituyen por ausencia de esas herramientas y ampliación con +acceso a todos los elementos; los listados completos conservan sus pruebas propias. + +Las primeras ampliaciones del script necesitaron corregir selectores de regiones +anidadas y la etiqueta accesible «Secciones de [compañía]». El perfil creado se retira +siguiendo su ciclo de vida (desactivar antes de eliminar). Fueron ajustes del recorrido, +sin nuevos defectos demostrados del producto. Las modificaciones sintéticas de los +escenarios de escritura se restauran en finally. + +### Skills, diferencias y límites + +Se consultaron las seis skills instaladas solicitadas: frontend-design (composición y +jerarquía), ux-dashboard (relevancia y densidad), ux-tables (comparación/lista y lectura), +shadcn (primitivas, tokens y Base UI), vercel-composition-patterns (composiciones distintas +sin flags de EntityTable) y vercel-react-best-practices (sin duplicar estado remoto ni +consultas por fila). Se revisó su matriz y referencias aplicables. El CLI shadcn no está +expuesto como ejecutable local; se contrastaron las APIs reales instaladas, sin instalar +ni regenerar componentes. No se invocaron auditores especializados ausentes. + +Tabler, Inter, el primario rojo, las primitivas Badge existentes y la navegación del +producto prevalecen sobre cualquier detalle no verificable del mockup. No se han +inventado estados, salud del equipo, conexiones en tiempo real o permisos efectivos. +No se cambia la cobertura documental, el conteo de pendientes, la selección durante +refetch, el algoritmo de diferencias ni los contratos de acceso. + +Continúan fuera de la verificación práctica completa lector de pantalla, zoom 200/400 %, +modo oscuro, rendimiento bajo cientos de filas y cumplimiento WCAG global. Las nuevas +colecciones no implementan búsqueda, ordenación interactiva o paginación remota; esas +capacidades no se afirman por estar contempladas como opcionales en el estándar. + +Cierre de la entrega: 20 archivos de código/JSON pasan Biome y los TS/TSX afectados +pasan ESLint de espaciado; `git diff --check` correcto. Se comprueban 231 enlaces locales +y doce anclas de la documentación afectada, incluidos los destinos de capturas. +Las evidencias conservan 14 PNG anteriores y 28 posteriores, además de mediciones. +El preparador se cerró mediante SIGINT: retiró sus servicios, contenedor y sesión privada; +se comprobó que `session.json` ya no existe. Las capturas sanitizadas permanecen disponibles. + +## WD04.1 — Equipamiento unificado + +Fecha: 23 de octubre de 2026. Alcance limitado a la unificación de la representación de +los miembros del puesto. Se conservan los cambios locales de las entregas anteriores. +Los ocho documentos históricos de auditoría no se modifican. + +### Composición y componentes + +Antes se mostraban una tarjeta de Ordenador principal y otra de Equipamiento que volvía +a incluirlo. Ahora existe una sola tarjeta **Equipamiento**, inmediatamente después de +la cabecera, junto a Configuraciones pendientes. El principal es su primer miembro +válido, con icono de plantilla, nombre enlazado, tipo y badge Principal en la misma +línea de metadatos. Conserva los badges técnico y de uso sin un tamaño especial ni +una tarjeta anidada. Los secundarios mantienen el orden declarado en `assetIds`. + +Después se conservan Personas y perfiles y Responsable y contexto; Configuraciones +por perfil queda a la izquierda de Software y Soporte. Último cambio se sitúa después +de la cuadrícula, a ancho completo. Solo cambia la colocación de esos componentes, +no su presentación interna, sus acciones ni sus cálculos. El shell y la cabecera no +se rediseñan. + +| Archivo / componente | Cambio WD04.1 | +| --- | --- | +| `workstation-detail-content.tsx` | Una única instancia de WorkstationAssets en la primera banda; elimina su ubicación anterior y el render de WorkstationComputer. Recoloca Software y Último cambio según la composición objetivo. | +| `workstation-assets.tsx` | Reutiliza CompactList, CompactListItem, CollectionPreview, DetailPanel, Badge y EmptyState. Añade avisos de consistencia y feedback de inventario. | +| `workstation-equipment.ts` y su test | Selector presentacional de referencias actuales, con resolución por ID/compañía, deduplicación, principal válido primero y diagnóstico de referencias. No sustituye el modelo funcional del dashboard. | +| `workstation-computer.tsx` | Retirado. Búsqueda en el monorepo: solo tenía un consumidor de código, WorkstationDetailContent; los demás resultados eran referencias documentales. WorkstationComputerField del editor es otro componente y se conserva. | +| `use-workstation-detail-controller.ts` | Conserva íntegros computer, perfiles, fuentes y conteo. Reutiliza la consulta de catálogo de activos por compañía para resolver iconos reales. | +| `modules/assets/src/web/index.ts` | Expone el AssetTemplateIcon existente para el consumidor real de Companies; no se duplica la correspondencia entre claves e iconos. | +| `workstation-detail-page.tsx` | En fallo inicial sin datos muestra Equipamiento con error/reintento. Con inventario conocido, el feedback vive en la tarjeta, no en una lista vacía. | +| `workstation-dashboard-skeleton.tsx` | Retira el placeholder de ordenador independiente y conserva un único espacio de equipamiento. | +| Locales es/en de Assets | Dos avisos de principal inconsistente/no disponible; retira la etiqueta openAsset sin consumidores después de eliminar la tarjeta independiente. | +| Tests de detalle, nuevo test de equipamiento y script MCP | Añaden cobertura de unicidad, posición, errores, consistencia, navegación y estados; conservan los recorridos de gestión anteriores. El script de tests de Companies incluye el nuevo fichero. | + +La consulta de catálogo utiliza `catalogKeys.scope(company.id)` y `catalogApi.get`, +compartida y deduplicada por TanStack Query. Se habilita cuando el puesto tiene +miembros; no es una consulta por activo ni depende de que exista principal. Usa el +icono de la plantilla por ID; si los metadatos todavía no están disponibles, utiliza +la caja genérica y conserva el tipo legible de la especificación. No infiere el +principal por nombre, icono, tipo o posición del inventario. No hay endpoints, +contratos HTTP, dependencias, modelos, migraciones o políticas de acceso nuevos. + +### Datos, acciones y estados + +La composición procede exclusivamente de `values.assetIds` actuales, deduplicados. +`computerAssetId` identifica al principal solo cuando esa referencia pertenece a la +composición y resuelve un activo de la compañía. No se incorporan activos históricos +ni impresoras asociadas únicamente a configuraciones de perfil. El selector no modifica +los arrays recibidos; los secundarios conservan un orden determinista independiente +del orden de llegada del inventario. + +Se conserva Editar puesto en la cabecera de Equipamiento como acción contextual de +composición. Abre el mismo editor y utiliza el mismo callback que la acción global; +no crea otro diálogo ni otra operación. En vacío se ofrece una única acción dentro +del estado vacío, sin repetirla también en la cabecera de esa tarjeta. En fallo inicial +sin puesto conocido no se inventa una acción de edición. + +El nombre de cada activo sigue siendo un BrowserLink a su ficha real. Esa navegación +conserva la capacidad anterior de Abrir ficha del activo; no queda un botón redundante +para el principal ni se usan contenedores clicables en lugar de enlaces. + +| Caso | Presentación | +| --- | --- | +| Principal válido | Primera fila, una única vez, badge Principal y estados del activo real. | +| Miembros sin principal | Muestra los miembros, indica discretamente que no hay ordenador principal y conserva edición. No elige el primer portátil como sustituto. | +| Sin activos | EmptyState dentro de Equipamiento con descripción y edición de composición. | +| Inactivo/desmantelado | Solo composición actual; el historial sigue disponible por la operación existente. No recupera miembros de revisiones. | +| Principal fuera de assetIds | Aviso explícito para revisar el puesto, sin incorporarlo ni marcar otro miembro como principal. | +| Referencia ausente o de otra compañía | Aviso de principal no disponible y fila de referencia no resuelta, sin inventar nombre, tipo o estados; no se muestra el badge Principal. | +| Fallo de inventario sin caché | Equipamiento con error recuperable; no muestra vacío, contador cero ni detalles de un puesto desconocido. El resto requiere recuperar los datos del puesto. | +| Refetch / error con datos previos | Mantiene la composición y la ampliación existentes y muestra el feedback de consulta. | + +Estado técnico sigue siendo `asset.values.status`; uso sigue siendo `asset.assigned`. +Un activo averiado puede continuar asignado. No se deriva disponibilidad del estado +técnico ni se modifica la condición de asignación de puestos inactivos. + +`workstationDashboard.computer` y la resolución del controller permanecen. Software, +soporte, perfiles y configuraciones siguen dependiendo del principal confirmado dentro +de assetIds. La nueva función de presentación conserva también referencias no resueltas +para explicarlas; no cambia los miembros resueltos que usa el modelo funcional. La +selección de perfil, el algoritmo de diferencias, cobertura documental y políticas de +Access permanecen intactos. Los nombres del equipo que aparecen como contexto de una +conexión remota no son otra tarjeta ni otro listado de miembros. + +### Responsive y comparación visual + +Se conservaron capturas del estado anterior en esta misma instalación, antes de modificar +las tarjetas. Las posteriores se inspeccionaron visualmente: S01 de escritorio y móvil, +S03, S06, S04 denso, casos sin principal, inconsistencias y fallo/recuperación. Se mantiene +la cuadrícula por contenedor, el espacio libre antes del indicador de pendientes y la +proporción 1,6:1 en escritorio. En móvil Equipamiento ocupa el ancho y Pendientes aparece +después. No hay alturas fijas de filas ni recortes de activos. + +Medidas S01 a 1600×1000, al inicio de la página: + +| Medida | Antes | WD04.1 | +| --- | --- | --- | +| Paneles del dashboard | 9 | 8 | +| Representaciones del principal como miembro/resumen | 2 | 1 | +| Inicio de Equipamiento | Y=618,5 px | Y=228 px | +| Alto de Equipamiento, dos miembros | 186 px | 169 px | +| Ordenador principal separado | 113 px | Retirado | +| Pendientes: inicio / alto | Y=228 / 145 px | Y=228 / 145 px | +| Inicio de Configuraciones por perfil | Y=816,5 px | Y=642,5 px | +| Alto de Personas y perfiles | 221,5 px | 221,5 px | +| Alto de Responsable y contexto | 197 px | 197 px | +| Inicio de Software | Y=618,5 px | Y=642,5 px | + +El objetivo es eliminar duplicación y fragmentación, no forzar una altura total menor. +En escritorio la mayor primera banda y el último cambio a ancho completo dejan la altura +total prácticamente equivalente en esta muestra. En móvil se reduce el recorrido al +retirar el resumen duplicado. Las últimas revisiones cambian durante los guardados de +prueba, por lo que la altura de su resumen también responde a datos reales. + +| Ancho | Alto Equipamiento WD04.1 | Borde inferior de Último cambio antes/después | Overflow global | +| --- | --- | --- | --- | +| 1600 | 169 px | 1770,5 / 1773,5 px | Ninguno | +| 1440 | 169 px | 1862,5 / 1866,5 px | Ninguno | +| 1280 | 169 px | 2827,5 / 2617,5 px | Ninguno | +| 1024 | 169 px | 2877,5 / 2667,5 px | Ninguno | +| 768 | 169 px | 3014,5 / 2852,5 px | Ninguno | +| 390 | 229 px | 3895,5 / 3605,5 px | Ninguno | + +S04 conserva vista inicial de cinco miembros y permite ampliar hasta los 80. El principal +sigue primero y el botón de ampliación conserva foco/estado durante una recarga. No se +introduce paginación ni se hacen inaccesibles los miembros restantes. + +Evidencias sanitizadas, fuera de Git, en `.artifacts/playwright/workstation-dashboard-wd041/`: + +- [Antes S01 1600](../.artifacts/playwright/workstation-dashboard-wd041/before/S01-1600.png), [WD04.1 S01 1600](../.artifacts/playwright/workstation-dashboard-wd041/after/S01-1600.png), [1440](../.artifacts/playwright/workstation-dashboard-wd041/after/S01-1440.png), [1280](../.artifacts/playwright/workstation-dashboard-wd041/after/S01-1280.png), [1024](../.artifacts/playwright/workstation-dashboard-wd041/after/S01-1024.png), [768](../.artifacts/playwright/workstation-dashboard-wd041/after/S01-768.png), [390](../.artifacts/playwright/workstation-dashboard-wd041/after/S01-390.png). +- [Página completa](../.artifacts/playwright/workstation-dashboard-wd041/after/S01-full-page.png), [S03 vacío](../.artifacts/playwright/workstation-dashboard-wd041/after/S03.png), [S06 desmantelado](../.artifacts/playwright/workstation-dashboard-wd041/after/S06.png), [S04 denso](../.artifacts/playwright/workstation-dashboard-wd041/after/S04-density-1440.png). +- [Miembros sin principal](../.artifacts/playwright/workstation-dashboard-wd041/after/S12-members-without-principal.png), [principal fuera de composición](../.artifacts/playwright/workstation-dashboard-wd041/after/S13-principal-outside.png), [referencia no resuelta](../.artifacts/playwright/workstation-dashboard-wd041/after/S13-principal-unresolved.png), [referencia de otra compañía](../.artifacts/playwright/workstation-dashboard-wd041/after/S13-principal-foreign.png), [inventario fallido](../.artifacts/playwright/workstation-dashboard-wd041/after/S14-equipment-unavailable.png), [inventario recuperado](../.artifacts/playwright/workstation-dashboard-wd041/after/S14-equipment-recovered.png). + +Las capturas completas utilizan el procedimiento temporal ya documentado, restaurando +los estilos después. Las medidas normales se registran en measurements.json de cada +fase. Para compartir las imágenes hay que conservar la carpeta de evidencias; no se +copia la sesión privada. + +### Validación WD04.1 + +Entorno: Node 24.20.0 y PNPM 11.25.0. Se reutilizan los preparadores existentes de auditoría +con MariaDB independiente exclusivamente `metis_desk_test`, API/Vite en puertos libres +y usuario ordinario autenticado por el formulario real. Volumen: 15 compañías, principal +con 59 personas, 135 activos y 73 recursos; S04 con 80 miembros y 60 perfiles. El escenario +de sustitución de principal añade un activo temporal más. + +- **29 pruebas dirigidas correctas**, incluidas seis nuevas del selector y tres nuevas de + presentación. Cubren orden principal/secundarios, deduplicación, ámbito, vacío, + desmantelado, falta de principal, inconsistencias, estado técnico/uso, unicidad de la + sección y navegación; se conservan las pruebas previas de relaciones y funcionalidades. +- **20 métodos MCP comprobados.** El pase inicial de 15 tuvo una interrupción de + browser_navigate y un timeout al fijar la navegación contextual; ambos recorridos + pasaron al repetirse sin modificar navegación o Access. El pase complementario de + esos dos y los cinco escenarios nuevos fue correcto (7/7). La captura/medición de los + seis anchos se repitió correctamente con la presentación final. +- Edición real de responsable/composición, renombrado, alta/retirada de perfil, + archivados, selector y refetch, gestión de software, soporte registrado visible, + pendientes y su foco/filtrado, historial, principal sustituido y cobertura desconocida + siguen verificados. Las escrituras sintéticas restauran valores en finally. +- Los casos incoherentes o referencias ajenas se simulan interceptando respuestas GET + de la instalación desechable; no se saltan invariantes para persistirlos. El caso sin + principal se prepara con el update real y se restaura después. El error 503 se retira + y se comprueba recuperación con Reintentar. +- Tipos dirigidos de Assets, Companies y Web correctos. Biome y ESLint dirigidos, + paridad es/en y comprobaciones documentales se ejecutan sobre los archivos afectados. +- `pnpm build` correcto. Permanecen el aviso previo de chunks grandes y los fallos globales + ya existentes: typecheck en calendar.tsx:165 de vendor; lint/check con 15/167 errores + ajenos; test en companies-page.test.tsx. En Companies pasan 42/43 ficheros, incluidos + todos los del dashboard; los otros siete paquetes de tests completan correctamente. + +No se afirma un pase global limpio ni una validación WCAG completa. La muestra de +contraste contiene 36 elementos, mínimo 4,53:1; no hubo excepciones JavaScript de página. +Lector de pantalla, zoom 200/400 %, modo oscuro y rendimiento con más volumen permanecen +fuera de las comprobaciones prácticas de esta entrega. + +Skills aplicadas: ux-dashboard como principal para agrupar la composición sin duplicar +información; frontend-design y ux-tables para jerarquía y densidad; shadcn para reutilizar +las APIs/tokens instalados; composición y React para mantener un único consumidor de +Equipamiento y estado remoto en TanStack Query. Se consultaron la matriz y los criterios +instalados pertinentes, sin instalar skills ni dependencias ni modificar vendor. + +No se integra Configuraciones en Perfiles, no se retira Responsable y contexto, no se +reestructura Soporte y no se modifica la cabecera ni la navegación lateral. Esas tareas +quedan para entregas posteriores. + +Cierre WD04.1: 12 archivos TS/TSX/JSON pasan Biome y los TS/TSX afectados pasan ESLint. +`git diff --check` correcto; 73 enlaces locales y dos anclas del informe comprobados, +paridad es/en y sintaxis Python correctas. Se conservan 14 PNG anteriores y 34 posteriores. +El preparador retiró sus servicios y contenedor al recibir SIGINT; se comprobó la retirada +de session.json. Las capturas y mediciones sanitizadas permanecen disponibles. + + +## WD04.2 — Personas, perfiles y configuraciones unificados + +Fecha: 23 de octubre de 2026. Implementación sobre la composición WD04.1. + +### Arquitectura e interacción + +Antes, Personas y perfiles y Configuraciones por perfil eran dos tarjetas. La segunda +mostraba automáticamente el primer perfil y añadía un selector independiente. Ahora +una única tarjeta combina `WorkstationProfiles` (CompactTable) y +`WorkstationProfileConfigurationDetails` (contenido contextual). Desaparecen la tarjeta +y el selector anteriores. El antiguo componente solo tenía un consumidor; se retira +tras buscar sus referencias. Los ocho entregables históricos de auditoría se conservan. + +La primera banda mantiene Equipamiento y Pendientes. El segundo grid conserva los +umbrales y proporción 1,6:1; la izquierda contiene Personas y perfiles y la derecha +apila Responsable y contexto, Software, Soporte y Último cambio. Las alturas son +independientes. Equipamiento, cabecera, navegación y operaciones de software mantienen +sus componentes existentes; no se implementa WD04.3. + +La tabla conserva Perfil, Persona, Cuenta, Estado y Configuraciones, con sus enlaces +independientes, alta y acceso a archivados. El resumen muestra categorías reales o el +estado confirmado sin configuraciones; una fuente incompleta muestra «—». Cada perfil activo +tiene un botón explícito Ver/Ocultar con nombre accesible que incluye el perfil, +`aria-expanded`, `aria-controls` e indicador de dirección. La fila conserva semántica +HTML de tabla y no se convierte en un control interactivo. + +La expansión empieza cerrada, es única, permite contraer y sustituye el detalle al +abrir otro perfil. Se reutiliza `Collapsible` de Base UI mediante el wrapper vendor, +controlado por la selección; los activadores explícitos están en las celdas y el +panel único aparece debajo de la tabla, dentro de DetailPanel y fuera de su scroll +horizontal. El panel controlado permanece como destino de `aria-controls`; el contenido +de configuración solo se monta para el perfil abierto. No se modifica CompactTable +ni se crea infraestructura genérica de acordeones. + +### Estado y navegación contextual + +`useWorkstationProfileExpansion` conserva exclusivamente contexto, identidad expandida, +filtro, solicitud de foco y aviso de retirada. `reconcileProfileExpansion` restablece +el estado ante cambio de compañía, puesto o principal; conserva selección y filtro +ante refetch/error y solo una respuesta válida puede cerrar un perfil eliminado, +archivado o retirado del host. Muestra un aviso y no abre otro detalle automáticamente. +Soporte conserva su contrato: contexto del perfil expandido cuando lo hay y primer +perfil activo como contexto predeterminado al estar contraídos; su persona sigue +identificada explícitamente. Esto no abre otro detalle tras una retirada. +Las transiciones se comprueban durante render; no se copian datos de consulta mediante +efectos. Los efectos nuevos sincronizan únicamente foco y scroll con el DOM. + +Pendientes sigue contando las cinco familias de todos los perfiles activos. Su acción +elige el primero con pendientes en el mismo orden estable del resumen: nombre por +idioma, comparación numérica y sin distinguir tildes/mayúsculas; ID desempata. Abre el +perfil, activa Solo pendientes y enfoca/desplaza el detalle una vez montado. Cambiar +manualmente de perfil reinicia el filtro. Cero no ofrece esta acción; una fuente fallida +mantiene el total desconocido. El filtro no altera el contador global. + +CollectionPreview sigue ofreciendo cinco filas y Mostrar todos. Si el destino de +pendientes está fuera del resumen, se añade su fila contextual a esas cinco, sin +cargar los detalles de los demás perfiles ni desplegar todo el conjunto. Mostrar todos +permite alcanzar los 50 activos y los 60 perfiles al incluir archivados en S04. Los +archivados conservan estado y enlace a su ficha histórica; no reciben expansión +operativa y no contribuyen a pendientes actuales. + +### Datos, familias, operaciones y fallos parciales + +Se reutilizan las consultas agregadas por compañía y sus claves actuales: inventario, +perfiles, Access, Recursos y las cinco familias. No hay consultas por fila, endpoints, +contratos HTTP, schemas, migraciones ni dependencias nuevos. El perfil se resuelve en +el host actual y la misma compañía; `buildEnvironment` conserva el cálculo del entorno. + +`WorkstationConfigurationFamily` mantiene Carpetas, Correo, Aplicaciones, Impresoras y +Certificados, sus estados, notas, revisión y enlaces Gestionar a las rutas reales del +perfil. Los encabezados pasan a h4 dentro del detalle h3 de la tarjeta h2. Carpetas +conserva ruta UNC, unidad, cuenta y cobertura documental; no se representa acceso SMB +efectivo. Aplicaciones se mantiene separada del software del equipo. Las impresoras +configuradas no se agregan a Equipamiento y los certificados conservan sus fechas y +revisiones calculadas por las fuentes existentes. + +Los perfiles se muestran sin esperar las fuentes secundarias. Al abrir el detalle, +cada familia conserva su indicador de carga, datos anteriores, error y reintento. Un +fallo de Carpetas no oculta perfiles, correo o certificados. Recursos y Access mantienen +su feedback y las familias dependientes no aparentan resolución completa sin sus datos. +El vacío global del perfil solo se anuncia tras confirmar todas las fuentes y mantiene +los cinco enlaces reales de gestión, sin repetir cinco mensajes vacíos. Alta, edición de puesto, historial y gestión de familias +reutilizan las pantallas y diálogos existentes, sin formularios paralelos. + +La expansión es estado local de la ficha. Se conserva durante mutaciones/refetch que +mantienen montado el dashboard, incluidos los diálogos de puesto y alta de perfil. +Las rutas de gestión de familias conservan la navegación existente; abandonar y abrir +de nuevo la ficha inicia sus perfiles contraídos. No se añade persistencia ni otra URL +de selección. + +### Accesibilidad, responsive y criterios consultados + +Botones nativos activables con Enter/Espacio; enlaces de persona, cuenta y perfil +independientes; sin controles anidados. El salto de pendientes enfoca una sección +nombrada y con `tabIndex=-1`. Contraer manualmente conserva el foco del activador. +Si un refetch retira el detalle enfocado y el navegador deja el foco en body, el aviso +recibe el foco. Los errores conservan alertas y acciones de recuperación por fuente. +Todos los textos nuevos están en es/en. + +Se conserva CompactTable. Por debajo de 600 px del contenedor, Perfil reúne persona, +cuenta y estado; Configuraciones conserva el resumen y Ver/Ocultar a la vista. Las +columnas separadas de Persona/Cuenta/Estado se ocultan en esa presentación, conservando +sus datos en la celda de identidad. `WorkstationProfilePerson` comparte la misma +identidad enlazada en ambas distribuciones. No se duplica el detalle expandido. +El scroll de tabla queda contenido si algún contenido excepcional lo requiere. El +detalle utiliza todo el ancho de la tarjeta, permite partir nombres/rutas y no fuerza +alturas. La composición se reduce a una columna por capacidad del contenedor. + +Skill principal consultada: `ux-dashboard`, para reunir perfil y entorno y desplegar +el detalle bajo demanda. Complementarias: `ux-tables` (semántica y acciones explícitas), +`frontend-design` (jerarquía y superficies del sistema existente), `shadcn` (contratos +Base UI instalados), `vercel-composition-patterns` (composición mediante children), +`vercel-react-best-practices` (estado mínimo y consultas agregadas) y `ux-error-recovery` +(recuperación local y conservación del contexto). Se consultaron DESIGN, matriz de +skills, patrón compacto, catálogo de componentes, procedimiento Playwright y documentos +01/02/04/05/06/07 de auditoría. Los auditores especializados `*-audit` y la política de +orquestación citada por las skills no están instalados; no se instalan herramientas. + +### Pruebas y evidencias WD04.2 + +Entorno WSL/bash, Node 24.20.0 y PNPM 11.25.0. Preparador desechable autenticado con +usuario ordinario, MariaDB exclusivamente `metis_desk_test`. Se prepararon 15 compañías; +la principal alcanzó 59 personas, 135 activos y 73 recursos. El caso S04 tiene 80 +miembros y 60 perfiles (50 activos y 10 archivados). El recorrido de sustitución creó +un activo sintético adicional, por lo que algunas capturas muestran 136 activos. No se +poblaron bases reales ni se consultaron secretos locales. + +| Comprobación ejecutada | Resultado | +| --- | --- | +| Cinco archivos de pruebas del detalle: dashboard, relaciones, equipamiento, expansión y renderizado es/en | 35 pruebas correctas | +| `pnpm --filter @metis/companies typecheck`, Assets y Web | Correctos | +| Biome sobre los 15 TS/TSX/JSON afectados; ESLint sobre sus TS/TSX | Correctos | +| `pnpm build` | Correcto; permanece el aviso de tamaño de chunks | +| `pnpm typecheck` global | Bloqueado por el error previo de `calendar.tsx:165` en vendor (`exactOptionalPropertyTypes` y locale) | +| `pnpm lint` / `pnpm check` globales | Persisten los 15 / 167 errores previos, respectivamente; no se modifican los archivos ajenos | +| `pnpm test` global | Siete paquetes correctos; Companies pasa 43/44 archivos. Falla el archivo previo `companies-page.test.tsx` | +| `git diff --check`, sintaxis del runner Python y paridad de claves nuevas es/en | Correctos | + +Las pruebas nuevas verifican transición inicial/abrir/contraer/sustituir, selección +única, conservación en refetch y fallo, invalidación por retirada/archivo y reset por +compañía/puesto/host; orden determinista, pendientes de varios perfiles, aislamiento, +resumen cero y contenido contextual. Las pruebas existentes de `buildEnvironment` +conservan los contratos documentales y de estados en la suite del módulo. + +Playwright MCP completó una ejecución conjunta de **24/24 recorridos**. Después de los +ajustes de vacío y móvil se repitieron los recorridos afectados y se añadieron cuatro: +**28 métodos diferentes comprobados satisfactoriamente entre las ejecuciones**, sin +afirmar una ejecución conjunta única de los 28. La última tanda de cierre pasó 3/3. + +Se comprobó expansión de los dos perfiles S01, contracción, sustitución, vacío, archivados, +pendientes/certificado/filtro, contador global con varios perfiles, destino fuera de las +cinco filas de S04, conservación de selección/filtro/foco, cambio de principal en vivo, +retirada de un perfil abierto con aviso y foco, error recuperable de Carpetas y Recursos, +carga de Certificados dentro del detalle, recuperación y metadatos incompletos. +Cambiar entre perfiles produjo **5 consultas antes y 5 después**, sin consultas por fila. + +Las cinco acciones Gestionar se navegaron hasta sus pantallas reales. Se guardaron notas +de certificado y el nombre de un perfil mediante los formularios existentes, se verificó +su lectura en el dashboard y se restauraron sus valores por HTTP. Alta, edición de +puesto/responsable/composición, historial, software, navegación contextual, enlaces, +teclado y aislamiento de sesión conservaron sus recorridos de regresión. Las mutaciones +sintéticas añaden revisiones aunque se restauren los valores actuales. + +Se midieron las seis anchuras (1600, 1440, 1280, 1024, 768 y 390), contraído y expandido, +sin overflow horizontal global. A 390, la tabla final mide **326 px tanto de contenido +como de viewport**, con scrollLeft 0: persona, estado y expansión visibles. Tab recorre +perfil → persona → cuenta → expansión; Espacio abre el detalle. Enter, Espacio, foco +visible y retorno desde diálogos se verificaron en los recorridos de teclado. Se +midieron 30 muestras de texto/acciones: contraste mínimo observado **4,53:1** en tema +claro, no una certificación global de accesibilidad. No hubo errores JavaScript no +capturados en página; los 503 de los escenarios de fallo fueron deliberados. + +Las primeras iteraciones de pruebas necesitaron adaptar selectores al nuevo detalle, +esperar el render tras refetch, usar el contrato real de certificado al restaurar y +liberar la petición retenida mediante su Route. El retorno contextual del perfil vuelve +al dashboard; para editar se utiliza el enlace al listado completo y su búsqueda. La +prueba móvil detectó 2 px de overflow interno, corregidos ampliando la columna de acciones +y permitiendo ajustar el nombre. Esos casos se repitieron hasta obtener los resultados +indicados; no se silencian sus fallos anteriores como si hubieran pasado a la primera. + +### Comparación visual y capturas WD04.2 + +Se conservan **14 PNG anteriores y 49 posteriores**, incluidas iteraciones y escenarios +funcionales, con `measurements.json` y huellas SHA-256 en los directorios ignorados de +Playwright. Las capturas se abrieron y revisaron: escritorio, móvil, detalle integrado, +vacío compacto y carga parcial. No se guardan sesiones en estos directorios estables. + +| Aspecto | Antes | WD04.2 | +| --- | --- | --- | +| Tarjetas del dashboard | 8 | 7 | +| Selector independiente de perfil | 1 | 0 | +| Relación visual de configuraciones | Tarjeta separada | Detalle contextual dentro de Personas y perfiles | +| Detalles de configuración montados | Primer perfil seleccionado por defecto | Ninguno inicialmente; uno al expandir | +| Vacío de perfil | Cinco mensajes por familia | Una explicación y cinco accesos de gestión | +| Móvil | Control fuera del ancho inicial de la tabla | Identidad agrupada y expansión visible | +| Columnas inferiores | Filas del grid compartidas | Dos pilas de altura independiente | + +En las capturas iniciales de 390, el borde inferior del último bloque pasa de 3642 a +2818 px; a 1280, de 2638 a 1930 px. Son muestras de la presentación inicial contraída, +no una promesa de reducción fija: el detalle expandido crece con sus datos y las pruebas +de guardado modifican el historial mostrado en Último cambio. No se igualan alturas ni +se recorta contenido para obtener esas diferencias. + +- [Antes a 1600](../.artifacts/playwright/workstation-dashboard-wd042/before/S01-1600.png), [después a 1600](../.artifacts/playwright/workstation-dashboard-wd042/after/S01-1600.png) y [página completa](../.artifacts/playwright/workstation-dashboard-wd042/after/S01-full-page.png). +- [Detalle integrado](../.artifacts/playwright/workstation-dashboard-wd042/after/S01-integrated-expanded.png), [perfil vacío](../.artifacts/playwright/workstation-dashboard-wd042/after/S01-profile-without-configurations.png) y [pendientes](../.artifacts/playwright/workstation-dashboard-wd042/after/S01-configurations.png). +- [Móvil final](../.artifacts/playwright/workstation-dashboard-wd042/after/S01-mobile-profile-controls.png), [móvil expandido](../.artifacts/playwright/workstation-dashboard-wd042/after/S01-mobile-expanded-final.png), [1440](../.artifacts/playwright/workstation-dashboard-wd042/after/S01-1440.png), [1280](../.artifacts/playwright/workstation-dashboard-wd042/after/S01-1280.png), [1024](../.artifacts/playwright/workstation-dashboard-wd042/after/S01-1024.png) y [768](../.artifacts/playwright/workstation-dashboard-wd042/after/S01-768.png). +- [Carga del detalle](../.artifacts/playwright/workstation-dashboard-wd042/after/WD042-detail-loading.png), [Recursos no disponibles](../.artifacts/playwright/workstation-dashboard-wd042/after/WD042-resources-unavailable.png), [archivo durante refetch](../.artifacts/playwright/workstation-dashboard-wd042/after/WD042-profile-archived-while-open.png), [certificado guardado](../.artifacts/playwright/workstation-dashboard-wd042/after/WD042-certificate-saved.png) y [pendiente fuera del resumen S04](../.artifacts/playwright/workstation-dashboard-wd042/after/S04-pending-outside-preview.png). + +### Limitaciones y cierre WD04.2 + +No se certifica WCAG, uso con lector de pantalla, zoom, tema oscuro ni dispositivos +físicos: las evidencias corresponden a Chromium/MCP y los tamaños indicados. Las lecturas +agregadas siguen siendo independientes y no forman una fotografía transaccional. La +selección local se restablece al abandonar la ficha; no se introduce persistencia de +expansión entre rutas. + +No se modificaron vendor, los ocho documentos históricos, Equipamiento WD04.1, +Responsable y contexto, Soporte, Software, cabecera ni navegación global. Los cambios de +composición solo recolocan sus consumidores. Se conservan los cambios previos del árbol +sin crear commits ni revertir trabajo ajeno. Las incidencias globales anteriores quedan +fuera de WD04.2 y se declaran arriba. + +El preparador recibió SIGINT y retiró exclusivamente sus servicios y MariaDB desechable. +Se comprobó que `session.json` ya no existe. Las capturas y mediciones sanitizadas +permanecen en los directorios estables indicados. + + +## WD04.3 — Consolidación de Responsable y contexto + +Fecha: 24 de octubre de 2026. Cambios limitados a la presentación y resolución de su +contexto, sobre Equipamiento WD04.1 y Perfiles/configuraciones WD04.2 efectivamente +implementados. Se conservan los cambios previos del árbol de trabajo. + +### Presentación y componentes + +Se retira la tarjeta Responsable y contexto. La cabecera conserva breadcrumb, nombre +editable, estado, compañía, sede, responsable y sus acciones originales. Compañía, +sede y persona mantienen enlaces reales independientes; no se anidan botones dentro +de enlaces ni se hace clicable la banda completa. El nombre del responsable permanece +visible y permite ajuste de línea, incluido el caso largo. Sin responsable se muestra +«Sin responsable», sin inferirlo de los usuarios de perfiles. + +`WorkstationResponsibleContext` añade junto al nombre un activador de información con +nombre accesible traducido y Tooltip. Popover, Tooltip y Button se componen mediante +`render` según Base UI instalado, con un único botón nativo. El Popover presenta nombre, +rol Responsable del puesto, departamento y cargo vigentes, y Abrir ficha de persona. +No añade una segunda fila fija con información organizativa secundaria. + +`workstation-detail-content.tsx` elimina el render/import del panel anterior; Software, +Soporte y Último cambio ocupan la pila derecha desde el inicio del segundo grid. No se +estiran tarjetas ni se reserva un hueco. La proporción y los umbrales responsive existentes +se conservan. La carga inicial pasa a seis placeholders. Tras buscar referencias en el +monorepo, se retiran `WorkstationResponsible` y el resolver anterior, que solo utilizaban +ese panel y sus pruebas. + +### Relaciones organizativas y asignaciones múltiples + +El responsable procede exclusivamente de Workstation.personId y de la compañía de la +ficha. Sede procede de siteId del puesto; no se obtiene del activo ni de la persona. +La persona responsable sigue separada de los perfiles y del contexto remoto de Soporte. +No se modifican DTOs, relaciones, asignaciones, validaciones de dominio ni persistencia. + +`workstationOrganizationalAssignments` deriva las relaciones desde CompanyDTO: +misma compañía/persona y periodo semiabierto `startDate <= hoy < endDate`, o sin fecha +de fin. Conserva el criterio de fecha UTC del resolver anterior. No filtra relaciones +vigentes arbitrariamente por el estado actual de departamento/persona: una relación +existente puede conservarse conforme al contrato del dominio. + +Se muestran **todas las asignaciones vigentes**, con las principales primero y una +etiqueta Principal; fecha de inicio e ID estabilizan el orden restante. De ese modo no +se descartan las secundarias ni se elige la primera de varias sin principal. Si hay más +de una, se anuncia su cantidad. Tampoco se oculta una segunda principal: el modelo puede +admitir periodos finitos solapados en departamentos diferentes. La presentación no +introduce otra política de elección ni modifica esas relaciones. + +Sin asignación vigente, departamento y cargo muestran «—». El cargo procede de la +asignación presentada. El campo general Person.position continúa en la ficha de persona; +no se usa para atribuir un cargo a una relación organizativa inexistente. Esta decisión +sustituye el fallback del panel anterior y evita presentar información genérica como si +perteneciera a una asignación vigente. + +### Fuente, carga, fallo y actualización + +Personas, departamentos y asignaciones llegan **juntos en CompanyDTO**, mediante la +query centralizada `companyKeys.detail(company.id)`. No existe endpoint independiente +de asignaciones ni estado de carga separado. El contexto observa esa misma query con +`enabled: false`: no inicia otra lectura al montarse o abrirse; su reintento usa la API +existente y actualiza la caché compartida. Los campos se derivan de las props actuales, +sin copiarlos a estado local. + +Durante refetch se conservan los datos anteriores y se indica Actualizando dentro del +Popover. Si falla, cabecera y responsable siguen visibles y el contexto muestra aviso +y Reintentar. Tras recuperar la fuente, desaparece el aviso y el foco pasa al título +contextual después del commit, mediante un efecto exclusivo de sincronización de foco. +Si el reintento vuelve a fallar, el foco retorna a Reintentar. Cambiar responsable/compañía/puesto restablece el Popover por identidad; +actualizar datos de la misma persona conserva su contexto. + +**Límite del contrato actual:** CompanyDetail monta esta ficha cuando dispone de compañía. +Por ello los casos de compañía inicialmente ausente o asignaciones inicialmente cargando +por separado no se pueden reproducir como fuentes independientes. Se conserva ese contrato +y su feedback global; no se fabrica una compañía parcial ni se reestructura el shell para +simularlos. Sí se verifican refetch, fallo y recuperación de la fuente organizativa real +con datos previos. Sin responsable no se monta el contexto ni se resuelven sus asignaciones. + +### Accesibilidad, responsive y skills + +Se reutilizan las primitivas instaladas, sin cambiar vendor. La información contextual +se abre explícitamente con ratón, Enter o Espacio; Escape cierra y devuelve el foco al +activador. Título/descripción nombran el contenido y el botón mantiene relación ARIA con +el Popover. Tooltip explica el icono también al foco. El enlace a persona es independiente +y el detalle tiene un segundo acceso explícito a su ficha. + +La misma cabecera se adapta por flex-wrap. El Popover conserva el posicionamiento y las +colisiones de Base UI, anchura limitada al viewport y altura/desplazamiento limitados al +espacio disponible. Nombres y valores largos pueden partirse sin truncamiento irreversible. +Los vacíos son «—» y los estados son textuales. Todo texto nuevo mantiene es/en. + +Skills consultadas: ux-dashboard para jerarquía/detalle progresivo; ux-navigation para +metadatos, enlaces y teclado; frontend-design para continuidad visual y densidad; shadcn +para APIs reales de Popover/Tooltip y render; vercel-composition-patterns para encapsular +la interacción; vercel-react-best-practices para derivación y caché compartida; +ui-ux-pro-max, mediante búsqueda local «popover keyboard focus», para indicadores de foco +visibles y no ocultos. Se revisaron +la matriz, skills-lock y sus APIs/referencias aplicables. No existe auditor especializado +de accesibilidad instalado; se utilizan las verificaciones disponibles de navegador, sin +instalar skills, dependencias, ni aplicar cambios globales de navegación, color o tipografía. + +### Validaciones y comparación WD04.3 + +Se ejecutaron las comprobaciones desde WSL/bash con Node 24.20.0 y PNPM 11.25.0. +La instalación autenticada desechable utiliza exclusivamente `metis_desk_test`, con +usuario ordinario y volumen reproducible: 15 compañías, 59 personas, 135 activos y 73 +recursos; S04 incluye 80 miembros y 60 perfiles (50 activos y 10 archivados). Los casos +organizativos múltiples/largos/históricos se simulan sobre respuestas GET de esa compañía +en el navegador, sin poblar bases reales ni alterar sus relaciones persistidas. + +| Validación ejecutada | Resultado | +| --- | --- | +| Pruebas dirigidas de dashboard, organización, relaciones, equipamiento, expansión y renderizado es/en | 42 pruebas correctas | +| Typechecks de Companies, Assets y Web | Correctos | +| Biome de los 12 TS/TSX/JSON afectados y ESLint de sus TS/TSX | Correctos | +| `pnpm build` | Correcto; permanece aviso de tamaño de chunks | +| `pnpm typecheck` global | Persiste el error previo del vendor `calendar.tsx:165` sobre locale y exactOptionalPropertyTypes | +| `pnpm lint` / `pnpm check` | Persisten 15 / 167 errores previos, sin corregir archivos ajenos | +| `pnpm test` global | Siete paquetes correctos; Companies pasa 44/45 archivos, con fallo previo en `companies-page.test.tsx` | + +Las pruebas nuevas cubren responsable presente/ausente y nombre largo, seis tarjetas, +enlaces separados, periodo semiabierto, histórico/futuro, varias asignaciones, principal, +principales ambiguas, ausencia, cambio de datos/persona/compañía y referencias incompletas. +El renderizado SSR es/en verifica la cabecera; los estados de fuente se verifican con su +query real en navegador, dado el contrato atómico descrito arriba. + +Se completaron **20 métodos distintos de Playwright MCP** en recorridos seleccionados, +no una única ejecución conjunta: el primer bloque pasó 4/4; el bloque de regresión pasó +15/16 y su caso restante (selector de Sin responsable) pasó tras corregir la selección. +La recuperación organizativa final pasó tres veces consecutivas, incluida una repetición +2/2. Durante la revisión se ajustaron el tamaño heredado del título contextual y la +sincronización del foco posterior al commit; también se corrigió el bloqueo de peticiones +del propio harness para liberar las rutas dentro de la misma operación MCP. + +Se comprobaron S01 completo, S02 sin responsable, S04 de alta densidad, S05 con fallos y +S06 desmantelado; edición y renombrado persistidos y restaurados, cambio de responsable, +historial, navegación a compañía/sede/persona, software, soporte, equipamiento, pendientes, +perfiles, expansión y configuraciones. Las operaciones de guardado restauran sus valores, +aunque generan revisiones de historial en la base desechable. + +El contexto se probó en 1600, 1440, 1280, 1024, 768 y 390 px: Enter/Espacio, Escape, +retorno de foco, relación ARIA, controles independientes, geometría dentro del viewport y +sin nuevas peticiones al abrirlo. Los casos largos no presentan scroll horizontal global. +Se verificaron datos conservados durante refetch, fallo recuperable, Reintentar, foco al +título tras recuperación y actualización del cargo manteniendo abierto el Popover y el +foco de su enlace. También se comprobaron ausencia de asignación, histórico/futuro, +secundarias y dos principales vigentes sin ocultar ninguna. + +El muestreo de contraste observado en navegador obtuvo mínimo **4,648:1** en los nueve +textos del Popover y **4,531:1** en los controles/enlaces muestreados de la pantalla. +Las ejecuciones finales no registraron excepciones JavaScript de página. Estos muestreos +y las pruebas de teclado no equivalen a una certificación integral de accesibilidad. + +### Comparación visual WD04.3 + +Las capturas iniciales comparan el mismo escenario S01 antes de los guardados de regresión. +Se pasa de siete a seis tarjetas y desaparece la repetición informativa de compañía, +sede y responsable dentro de Responsable y contexto. La cabecera mantiene cuatro grupos +de metadatos y el contexto secundario se consulta bajo demanda. + +| Ancho | Software: Y antes / después | Soporte: Y antes / después | +| --- | --- | --- | +| 1600 | 618 / 421 px | 815 / 618 px | +| 1440 | 658 / 421 px | 888 / 651 px | +| 1280 | 876 / 678 px | 1072 / 876 px | +| 1024 | 926 / 728 px | 1122 / 926 px | +| 768 | 1126 / 958 px | 1324 / 1154 px | +| 390 | 1574 / 1282 px | 1824 / 1532 px | + +Las alturas responden al contenido; no se promete un desplazamiento fijo para otros +nombres o datos. No se incorporan indicadores para rellenar espacios ni se igualan +artificialmente las columnas. + +### Evidencias y limitaciones finales + +Evidencias locales sin credenciales: **13 PNG anteriores y 51 posteriores**, más mediciones +por escenario, en `.artifacts/playwright/workstation-dashboard-wd043/`. Se inspeccionaron +visualmente las capturas de escritorio, móvil, asignaciones múltiples, valores largos, +fallo y actualización. Ejemplos: + +- [Antes, 1600 px](../.artifacts/playwright/workstation-dashboard-wd043/before/S01-1600.png) y [después, 1600 px](../.artifacts/playwright/workstation-dashboard-wd043/after/S01-1600.png). +- [Página completa posterior](../.artifacts/playwright/workstation-dashboard-wd043/after/S01-full-page.png). +- Contexto a [1600](../.artifacts/playwright/workstation-dashboard-wd043/after/S01-organization-1600.png), [1440](../.artifacts/playwright/workstation-dashboard-wd043/after/S01-organization-1440.png), [1280](../.artifacts/playwright/workstation-dashboard-wd043/after/S01-organization-1280.png), [1024](../.artifacts/playwright/workstation-dashboard-wd043/after/S01-organization-1024.png), [768](../.artifacts/playwright/workstation-dashboard-wd043/after/S01-organization-768.png) y [390 px](../.artifacts/playwright/workstation-dashboard-wd043/after/S01-organization-390.png). +- [Varias asignaciones](../.artifacts/playwright/workstation-dashboard-wd043/after/WD043-organization-multiple.png), [dos principales](../.artifacts/playwright/workstation-dashboard-wd043/after/WD043-organization-ambiguous.png), [solo histórico/futuro](../.artifacts/playwright/workstation-dashboard-wd043/after/WD043-organization-historical.png), [sin asignaciones](../.artifacts/playwright/workstation-dashboard-wd043/after/WD043-organization-empty.png) y [valores largos en móvil](../.artifacts/playwright/workstation-dashboard-wd043/after/WD043-organization-long.png). +- [Fallo recuperable](../.artifacts/playwright/workstation-dashboard-wd043/after/WD043-organizational-source-error.png), [refetch conservando datos](../.artifacts/playwright/workstation-dashboard-wd043/after/WD043-organization-refreshing.png) y [cargo actualizado sin cerrar](../.artifacts/playwright/workstation-dashboard-wd043/after/WD043-organization-updated.png). +- [Sin responsable](../.artifacts/playwright/workstation-dashboard-wd043/after/WD043-withoutResponsible.png), [desmantelado](../.artifacts/playwright/workstation-dashboard-wd043/after/WD043-retired.png) y [mediciones posteriores](../.artifacts/playwright/workstation-dashboard-wd043/after/measurements.json). + +Las capturas son estados observados, no una garantía de instantánea transaccional entre +fuentes. Los casos de carga inicial independiente quedan sujetos al contrato atómico +CompanyDTO descrito arriba. No se ejecutaron lector de pantalla, zoom, tema oscuro ni +un auditor WCAG especializado. Persisten los fallos globales ajenos indicados en la tabla. +No se modificaron vendor ni los ocho documentos históricos de auditoría. + +El preparador autenticado y sus servicios temporales se cerraron al terminar; el archivo +de sesión se retiró. Se conservaron únicamente las evidencias sanitizadas en la ruta +estable indicada. No se instalaron dependencias ni se cambió configuración global. diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index d3f0721..11376c0 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -698,7 +698,6 @@ "noResponsible": "No responsible person", "technicalStatus": "Technical status", "usageStatus": "Usage status", - "openAsset": "Open asset details", "responsibleContext": "Responsibility and context", "department": "Department", "position": "Position", @@ -747,6 +746,33 @@ "person": "Person", "account": "Account", "configurationSummary": "Configurations", - "unassigned": "No active workstation" + "showAll": "Show all ({{count}})", + "showLess": "Show less", + "categories": { + "mail": "Mail", + "folders": "Folder", + "applications": "Application", + "printers": "Printer", + "certificates": "Certificate" + }, + "principalOutsideComposition": "The specified main computer is not part of the current equipment. Review the workstation.", + "principalUnavailable": "The main computer is unavailable in this company’s inventory. Review the workstation.", + "unassigned": "No active workstation", + "profileConfigurations": "Configurations for {{name}}", + "noConfigurations": "This profile has no additional configurations recorded.", + "profileUnavailable": "The open profile has been archived or no longer belongs to this computer. Select another profile to view its configurations.", + "categoryCount_zero": "No configurations", + "categoryCount_one": "{{count}} category", + "categoryCount_other": "{{count}} categories", + "expandProfile": "Show configurations for {{name}}", + "collapseProfile": "Hide configurations for {{name}}", + "viewConfigurations": "View", + "hideConfigurations": "Hide", + "openResponsibleContext": "View organizational context for {{name}}", + "organizationStale": "The organizational context could not be updated. Previous data is shown.", + "currentAssignments_one": "{{count}} current assignment", + "currentAssignments_other": "{{count}} current assignments", + "primaryAssignment": "Primary", + "openResponsiblePerson": "Open person details" } } diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index 7ebca05..e39b4d9 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -698,7 +698,6 @@ "noResponsible": "Sin responsable", "technicalStatus": "Estado técnico", "usageStatus": "Estado de uso", - "openAsset": "Abrir ficha del activo", "responsibleContext": "Responsable y contexto", "department": "Departamento", "position": "Cargo", @@ -747,6 +746,33 @@ "person": "Persona", "account": "Cuenta", "configurationSummary": "Configuraciones", - "unassigned": "Sin puesto activo" + "showAll": "Mostrar todos ({{count}})", + "showLess": "Mostrar menos", + "categories": { + "mail": "Correo", + "folders": "Carpeta", + "applications": "Aplicación", + "printers": "Impresora", + "certificates": "Certificado" + }, + "principalOutsideComposition": "El ordenador principal indicado no pertenece a la composición actual. Revisa el puesto.", + "principalUnavailable": "El ordenador principal no está disponible en el inventario de esta compañía. Revisa el puesto.", + "unassigned": "Sin puesto activo", + "profileConfigurations": "Configuraciones de {{name}}", + "noConfigurations": "Este perfil no tiene configuraciones adicionales registradas.", + "profileUnavailable": "El perfil abierto se ha archivado o ya no pertenece a este ordenador. Selecciona otro perfil para consultar sus configuraciones.", + "categoryCount_zero": "Sin configuraciones", + "categoryCount_one": "{{count}} categoría", + "categoryCount_other": "{{count}} categorías", + "expandProfile": "Mostrar configuraciones de {{name}}", + "collapseProfile": "Ocultar configuraciones de {{name}}", + "viewConfigurations": "Ver", + "hideConfigurations": "Ocultar", + "openResponsibleContext": "Consultar contexto organizativo de {{name}}", + "organizationStale": "No se ha podido actualizar el contexto organizativo. Se muestran los datos anteriores.", + "currentAssignments_one": "{{count}} asignación vigente", + "currentAssignments_other": "{{count}} asignaciones vigentes", + "primaryAssignment": "Principal", + "openResponsiblePerson": "Abrir ficha de persona" } } diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts index 0fb8e98..e13c89c 100644 --- a/modules/assets/src/web/index.ts +++ b/modules/assets/src/web/index.ts @@ -5,6 +5,7 @@ export { AssetSummaryCard } from "./assets/shared/ui/asset-summary-card"; export { BrandCatalogPage } from "./brands"; export { AssetCatalogPage } from "./catalog"; export { catalogApi, catalogKeys } from "./catalog/shared/api"; +export { AssetTemplateIcon } from "./catalog-templates/shared/ui/asset-template-icon"; export { DomainDirectoryMemberships } from "./directory-memberships/detail/ui/domain-directory-memberships"; export { InfrastructurePage } from "./infrastructure/list/ui/infrastructure-page"; export { diff --git a/modules/companies/package.json b/modules/companies/package.json index 8f944ac..6870cfa 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/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/workstation-dashboard.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/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/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/workstations/detail/helpers/workstation-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" }, "dependencies": { "@metis/core": "workspace:*", diff --git a/modules/companies/src/web/workstations/detail/controllers/use-workstation-detail-controller.ts b/modules/companies/src/web/workstations/detail/controllers/use-workstation-detail-controller.ts index d7ac62e..3bf7937 100644 --- a/modules/companies/src/web/workstations/detail/controllers/use-workstation-detail-controller.ts +++ b/modules/companies/src/web/workstations/detail/controllers/use-workstation-detail-controller.ts @@ -1,4 +1,4 @@ -import { inventoryApi, inventoryKeys } from "@metis/assets/client"; +import { catalogApi, catalogKeys, inventoryApi, inventoryKeys } from "@metis/assets/client"; import { useListQuery } from "@metis/core/client"; import { useState } from "react"; @@ -8,6 +8,7 @@ import { profileApi, profileKeys } from "../../../work-profiles/shared/api"; import { workstationDashboard, workstationPendingCount } from "../helpers/workstation-dashboard"; import { useWorkstationConfigurationQueries } from "./use-workstation-configuration-queries"; +import { useWorkstationProfileExpansion } from "./use-workstation-profile-expansion"; export function useWorkstationDetailController(company: CompanyDTO, workstationId: string) { const [creatingProfile, setCreatingProfile] = useState(false); @@ -19,6 +20,11 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI const workstation = inventory.data?.workstations.find( (item) => item.id === workstationId && item.companyId === company.id ); + const catalog = useListQuery({ + queryKey: catalogKeys.scope(company.id), + queryFn: () => catalogApi.get(company.id), + enabled: Boolean(workstation?.values.assetIds.length), + }); const computer = inventory.data?.assets.find( (item) => item.companyId === company.id && @@ -31,7 +37,6 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI enabled: Boolean(computer), }); const configurationQueries = useWorkstationConfigurationQueries(company.id, Boolean(computer)); - const [selectedProfileId, setSelectedProfileId] = useState(); const [viewingHistory, setViewingHistory] = useState(false); const dashboard = workstation ? workstationDashboard( @@ -41,9 +46,16 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI profiles.data?.profiles ?? [] ) : undefined; - const selectedProfile = - dashboard?.activeProfiles.find((item) => item.id === selectedProfileId) ?? - dashboard?.activeProfiles[0]; + const profileExpansion = useWorkstationProfileExpansion( + `${company.id}:${workstationId}:${computer?.id ?? "no-computer"}`, + dashboard?.activeProfiles ?? [], + profiles.data !== undefined && !profiles.isError + ); + const expandedProfile = dashboard?.activeProfiles.find( + (item) => item.id === profileExpansion.state.profileId + ); + // Soporte conserva su contexto predeterminado cuando no hay detalle abierto. + const selectedProfile = expandedProfile ?? dashboard?.activeProfiles[0]; const sources: EnvironmentSources = { assets: inventory.data?.assets ?? [], workstations: inventory.data?.workstations ?? [], @@ -64,11 +76,12 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI return { dashboard, + catalog, configurationQueries, sources, selectedProfile, - selectedProfileId, - setSelectedProfileId, + expandedProfile, + profileExpansion, viewingHistory, setViewingHistory, pendingCount, diff --git a/modules/companies/src/web/workstations/detail/controllers/use-workstation-profile-expansion.ts b/modules/companies/src/web/workstations/detail/controllers/use-workstation-profile-expansion.ts new file mode 100644 index 0000000..d12c1ff --- /dev/null +++ b/modules/companies/src/web/workstations/detail/controllers/use-workstation-profile-expansion.ts @@ -0,0 +1,38 @@ +import { useState } from "react"; + +import type { WorkProfileDTO } from "../../../../common/work-profiles"; +import { + collapsedProfileExpansion, + reconcileProfileExpansion, + toggleProfileExpansion, +} from "../helpers/workstation-profile-expansion"; + +export function useWorkstationProfileExpansion( + context: string, + profiles: readonly WorkProfileDTO[], + authoritative: boolean +) { + const [stored, setStored] = useState(() => collapsedProfileExpansion(context)); + const state = reconcileProfileExpansion(stored, context, profiles, authoritative); + + // Ajuste durante render para no exponer un perfil de otro host ni copiar datos remotos a estado. + if (state !== stored) { + setStored(state); + } + + return { + state, + toggle: (profileId: string) => + setStored((previous) => toggleProfileExpansion(previous, profileId)), + openPending: (profileId: string) => + setStored((previous) => ({ + ...previous, + profileId, + pendingOnly: true, + focusRequest: previous.focusRequest + 1, + unavailable: false, + })), + setPendingOnly: (pendingOnly: boolean) => + setStored((previous) => ({ ...previous, pendingOnly })), + }; +} diff --git a/modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.test.ts b/modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.test.ts index d96cf78..5373d19 100644 --- a/modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.test.ts +++ b/modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.test.ts @@ -11,10 +11,10 @@ import { WorkProfilesResponseSchema } from "../../../../common/work-profiles"; import { workstationDashboard, - workstationOrganization, workstationPendingCount, workstationRevisionChanges, } from "./workstation-dashboard"; +import { firstPendingWorkstationProfile } from "./workstation-profile-expansion"; const fixture: unknown = JSON.parse( await readFile( @@ -182,6 +182,34 @@ test("pendientes: solo pending, perfiles activos, compañía correcta y deduplic assert.equal(workstationPendingCount(data.profiles.profiles, { ...input, applications }), 1); assert.equal(workstationPendingCount([], { ...input, applications }), 0); + const otherProfile = data.profiles.profiles[1]; + + assert.ok(otherProfile); + const existing = applications[0]; + + assert.ok(existing); + const multiple = { + ...input, + applications: [ + ...applications, + { + ...existing, + id: "019a0a00-0000-7000-8000-000000000890", + values: { ...existing.values, profileId: otherProfile.id }, + }, + ], + }; + + assert.equal(workstationPendingCount(data.profiles.profiles, multiple), 2); + assert.equal( + firstPendingWorkstationProfile([otherProfile, profile, archived], multiple, "es")?.id, + profile.id + ); + assert.equal(firstPendingWorkstationProfile([archived], multiple, "es"), undefined); + assert.equal( + firstPendingWorkstationProfile([otherProfile], { ...input, applications: [] }, "es"), + undefined + ); }); test("último cambio: orden cronológico sin mutación, altas de miembros y campos realmente cambiados", () => { @@ -203,15 +231,3 @@ test("último cambio: orden cronológico sin mutación, altas de miembros y camp undefined ); }); - -test("contexto organizativo: cargo y departamento vigentes, sin adoptar asignaciones futuras", () => { - const context = workstationOrganization(data.company, station.values.personId, "2026-10-22"); - - assert.equal(context.department?.name, "Administración y gestión documental"); - assert.equal(context.position, "Responsable de documentación"); - assert.equal(workstationOrganization(data.company, null, "2026-10-22").department, undefined); - assert.equal( - workstationOrganization(data.company, station.values.personId, "2026-08-01").department, - undefined - ); -}); diff --git a/modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.ts b/modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.ts index 6a6dc09..2b9a19a 100644 --- a/modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.ts +++ b/modules/companies/src/web/workstations/detail/helpers/workstation-dashboard.ts @@ -64,28 +64,6 @@ export function workstationPendingCount( return pending.size; } -export function workstationOrganization( - company: CompanyDTO, - personId: string | null, - today: string -) { - const assignments = company.assignments.filter( - (item) => - item.companyId === company.id && - item.personId === personId && - item.startDate <= today && - (item.endDate === null || today < item.endDate) - ); - const assignment = assignments.find((item) => item.isPrimary) ?? assignments[0]; - - return { - department: company.departments.find( - (item) => item.id === assignment?.departmentId && item.companyId === company.id - ), - position: assignment?.position ?? company.people.find((item) => item.id === personId)?.position, - }; -} - export function workstationRevisionChanges(revisions: WorkstationDTO["history"]) { const ordered = [...revisions].sort((a, b) => a.recordedAt.localeCompare(b.recordedAt)); const latest = ordered.at(-1); diff --git a/modules/companies/src/web/workstations/detail/helpers/workstation-equipment.test.ts b/modules/companies/src/web/workstations/detail/helpers/workstation-equipment.test.ts new file mode 100644 index 0000000..5066d4b --- /dev/null +++ b/modules/companies/src/web/workstations/detail/helpers/workstation-equipment.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +import { InventoryResponseSchema } from "@metis/assets/common"; +import { z } from "zod/v4"; + +import { workstationEquipment } from "./workstation-equipment"; + +const raw: unknown = JSON.parse( + await readFile( + new URL( + "../../../../../../../docs/auditorias/workplace-dashboard/07-datos-ejemplo.json", + import.meta.url + ), + "utf8" + ) +); +const { inventory } = z + .object({ sources: z.object({ inventory: InventoryResponseSchema }) }) + .parse(raw).sources; +const station = inventory.workstations[0]; + +assert.ok(station); +const principal = inventory.assets.find((asset) => asset.id === station.values.computerAssetId); + +assert.ok(principal); + +test("equipamiento: principal válido primero, miembros únicos y secundarios en orden declarado", () => { + const secondary = inventory.assets.filter((asset) => asset.id !== principal.id).slice(0, 2); + const values = { + ...station.values, + assetIds: [ + ...secondary.map((asset) => asset.id), + principal.id, + secondary[0]?.id ?? principal.id, + ], + }; + const model = workstationEquipment( + station.companyId, + { ...station, values }, + [...inventory.assets].reverse() + ); + + assert.equal(model.principalState, "ready"); + assert.deepEqual( + model.members.map((member) => member.id), + [principal.id, ...secondary.map((asset) => asset.id)] + ); + assert.equal(model.members.filter((member) => member.principal).length, 1); + assert.equal(model.members[0]?.asset?.id, principal.id); + assert.deepEqual( + values.assetIds.slice(0, 2), + secondary.map((asset) => asset.id) + ); +}); + +test("equipamiento: activos sin principal no adquieren un principal por su tipo o posición", () => { + const model = workstationEquipment( + station.companyId, + { ...station, values: { ...station.values, computerAssetId: null } }, + inventory.assets + ); + + assert.equal(model.principalState, "missing"); + assert.equal(model.members.length, station.values.assetIds.length); + assert.ok(model.members.every((member) => !member.principal)); +}); + +test("equipamiento: principal fuera de composición se advierte sin añadirlo a miembros", () => { + const ids = station.values.assetIds.filter((id) => id !== principal.id); + const model = workstationEquipment( + station.companyId, + { ...station, values: { ...station.values, assetIds: ids } }, + inventory.assets + ); + + assert.equal(model.principalState, "outside-composition"); + assert.deepEqual( + model.members.map((member) => member.id), + ids + ); + assert.ok(model.members.every((member) => !member.principal)); +}); + +test("equipamiento: referencia ausente o de otra compañía no inventa datos ni marca principal", () => { + for (const assets of [ + inventory.assets.filter((asset) => asset.id !== principal.id), + inventory.assets.map((asset) => + asset.id === principal.id ? { ...asset, companyId: station.id } : asset + ), + ]) { + const model = workstationEquipment(station.companyId, station, assets); + + assert.equal(model.principalState, "unresolved"); + assert.equal(model.members.find((member) => member.id === principal.id)?.asset, undefined); + assert.equal(model.members.find((member) => member.id === principal.id)?.principal, false); + } + assert.equal(workstationEquipment(station.id, station, inventory.assets).members.length, 0); +}); + +test("equipamiento: vacío y desmantelado no recuperan miembros del historial", () => { + const dismantled = inventory.workstations.find( + (item) => !item.values.active && item.values.assetIds.length === 0 + ); + + assert.ok(dismantled); + assert.ok(dismantled.history.some((revision) => revision.values.assetIds.length > 0)); + for (const item of [ + dismantled, + { ...station, values: { ...station.values, assetIds: [], computerAssetId: null } }, + ]) { + assert.deepEqual(workstationEquipment(station.companyId, item, inventory.assets).members, []); + } +}); + +test("equipamiento: averiado sigue asignado y conserva ambos estados originales", () => { + const broken = { + ...principal, + values: { ...principal.values, status: "broken" as const }, + assigned: true, + }; + const model = workstationEquipment( + station.companyId, + station, + inventory.assets.map((asset) => (asset.id === broken.id ? broken : asset)) + ); + + assert.equal(model.members[0]?.asset?.values.status, "broken"); + assert.equal(model.members[0]?.asset?.assigned, true); + assert.equal(model.members[0]?.principal, true); +}); diff --git a/modules/companies/src/web/workstations/detail/helpers/workstation-equipment.ts b/modules/companies/src/web/workstations/detail/helpers/workstation-equipment.ts new file mode 100644 index 0000000..53a42cd --- /dev/null +++ b/modules/companies/src/web/workstations/detail/helpers/workstation-equipment.ts @@ -0,0 +1,27 @@ +import type { AssetDTO, WorkstationDTO } from "@metis/assets/common"; + +/** Conserva el orden declarado de secundarios; el principal válido es un miembro, nunca un añadido. */ +export function workstationEquipment( + companyId: string, + workstation: WorkstationDTO, + assets: readonly AssetDTO[] +) { + const ids = workstation.companyId === companyId ? [...new Set(workstation.values.assetIds)] : []; + const principalId = workstation.values.computerAssetId; + const members = ids + .map((id) => { + const asset = assets.find((item) => item.id === id && item.companyId === companyId); + + return { id, asset, principal: Boolean(asset && id === principalId) }; + }) + .sort((first, second) => Number(second.principal) - Number(first.principal)); + const principalState: "ready" | "missing" | "outside-composition" | "unresolved" = principalId + ? ids.includes(principalId) + ? members.some((member) => member.principal) + ? "ready" + : "unresolved" + : "outside-composition" + : "missing"; + + return { members, principalState }; +} diff --git a/modules/companies/src/web/workstations/detail/helpers/workstation-organization.test.ts b/modules/companies/src/web/workstations/detail/helpers/workstation-organization.test.ts new file mode 100644 index 0000000..e13bb5d --- /dev/null +++ b/modules/companies/src/web/workstations/detail/helpers/workstation-organization.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +import { z } from "zod/v4"; + +import { CompanySchema } from "../../../../common/dto/shared/company.dto"; + +import { workstationOrganizationalAssignments } from "./workstation-organization"; + +const fixture: unknown = JSON.parse( + await readFile( + new URL( + "../../../../../../../docs/auditorias/workplace-dashboard/07-datos-ejemplo.json", + import.meta.url + ), + "utf8" + ) +); +const company = z.object({ sources: z.object({ company: CompanySchema }) }).parse(fixture) + .sources.company; +const assignment = company.assignments[0]; + +assert.ok(assignment); +const personId = assignment.personId; +const today = "2026-10-22"; + +test("organización: resuelve departamento y cargo vigentes del responsable", () => { + const rows = workstationOrganizationalAssignments(company, personId, today); + + assert.equal(rows.length, 1); + assert.equal(rows[0]?.department?.name, "Administración y gestión documental"); + assert.equal(rows[0]?.position, "Responsable de documentación"); +}); + +test("organización: sin responsable o asignación no infiere datos desde perfiles ni cargo genérico de persona", () => { + assert.deepEqual(workstationOrganizationalAssignments(company, null, today), []); + assert.deepEqual( + workstationOrganizationalAssignments({ ...company, assignments: [] }, personId, today), + [] + ); +}); + +test("organización: periodos semiabiertos, históricos y futuros", () => { + const source = { + ...company, + assignments: [{ ...assignment, startDate: "2026-10-22", endDate: "2026-10-23" }], + }; + + assert.equal(workstationOrganizationalAssignments(source, personId, "2026-10-21").length, 0); + assert.equal(workstationOrganizationalAssignments(source, personId, "2026-10-22").length, 1); + assert.equal(workstationOrganizationalAssignments(source, personId, "2026-10-23").length, 0); +}); + +test("organización: varias vigentes conservan todas, principal primero y orden estable sin mutar", () => { + const secondary = { + ...assignment, + id: "019a0a00-0000-7000-8000-000000000998", + isPrimary: false, + position: "Otra función", + }; + const source = { ...company, assignments: [secondary, assignment] }; + const rows = workstationOrganizationalAssignments(source, personId, today); + + assert.equal(rows.length, 2); + assert.equal(rows[0]?.isPrimary, true); + assert.equal(rows[1]?.position, "Otra función"); + assert.equal(source.assignments[0], secondary); + assert.equal( + workstationOrganizationalAssignments( + { ...source, assignments: source.assignments.map((item) => ({ ...item, isPrimary: false })) }, + personId, + today + ).length, + 2 + ); +}); + +test("organización: dos principales no ocultan información seleccionando una arbitrariamente", () => { + const other = { ...assignment, id: "019a0a00-0000-7000-8000-000000000997" }; + + assert.equal( + workstationOrganizationalAssignments( + { ...company, assignments: [assignment, other] }, + personId, + today + ).length, + 2 + ); +}); + +test("organización: aislamiento de compañía y persona, y referencias incompletas", () => { + const foreign = "019a0a00-0000-7000-8000-000000000999"; + + assert.deepEqual( + workstationOrganizationalAssignments( + { ...company, assignments: [{ ...assignment, companyId: foreign }] }, + personId, + today + ), + [] + ); + assert.deepEqual( + workstationOrganizationalAssignments({ ...company, id: foreign }, personId, today), + [] + ); + assert.deepEqual(workstationOrganizationalAssignments(company, foreign, today), []); + assert.equal( + workstationOrganizationalAssignments({ ...company, departments: [] }, personId, today)[0] + ?.department, + undefined + ); +}); + +test("organización: cambios y refetch se derivan de la fuente actual", () => { + const before = workstationOrganizationalAssignments(company, personId, today); + + assert.equal( + workstationOrganizationalAssignments( + { ...company, assignments: [{ ...assignment, position: "Cargo actualizado" }] }, + personId, + today + )[0]?.position, + "Cargo actualizado" + ); + assert.equal(before[0]?.position, "Responsable de documentación"); + assert.deepEqual(workstationOrganizationalAssignments(company, null, today), []); +}); diff --git a/modules/companies/src/web/workstations/detail/helpers/workstation-organization.ts b/modules/companies/src/web/workstations/detail/helpers/workstation-organization.ts new file mode 100644 index 0000000..6d1f01d --- /dev/null +++ b/modules/companies/src/web/workstations/detail/helpers/workstation-organization.ts @@ -0,0 +1,36 @@ +import type { CompanyDTO } from "../../../../common"; + +/** Presenta las asignaciones vigentes del responsable sin inferir una única relación. */ +export function workstationOrganizationalAssignments( + company: CompanyDTO, + personId: string | null, + today: string +) { + if (!company.people.some((person) => person.id === personId && person.companyId === company.id)) { + return []; + } + + return company.assignments + .filter( + (item) => + item.companyId === company.id && + item.personId === personId && + item.startDate <= today && + (item.endDate === null || today < item.endDate) + ) + .sort( + (a, b) => + Number(b.isPrimary) - Number(a.isPrimary) || + a.startDate.localeCompare(b.startDate) || + a.id.localeCompare(b.id) + ) + .map((assignment) => ({ + id: assignment.id, + isPrimary: assignment.isPrimary, + department: company.departments.find( + (department) => + department.id === assignment.departmentId && department.companyId === company.id + ), + position: assignment.position, + })); +} diff --git a/modules/companies/src/web/workstations/detail/helpers/workstation-profile-expansion.test.ts b/modules/companies/src/web/workstations/detail/helpers/workstation-profile-expansion.test.ts new file mode 100644 index 0000000..b52dc19 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/helpers/workstation-profile-expansion.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { WorkProfileSchema } from "../../../../common/work-profiles"; + +import { + collapsedProfileExpansion, + reconcileProfileExpansion, + sortWorkstationProfiles, + toggleProfileExpansion, +} from "./workstation-profile-expansion"; + +const context = "company:workstation:host"; +const values = { + name: "Perfil 2", + assetId: "0199b200-0000-7000-8000-000000000002", + personId: "0199b200-0000-7000-8000-000000000003", + active: true, + accountId: null, + notes: null, +}; +const profile = WorkProfileSchema.parse({ + id: "0199b200-0000-7000-8000-000000000004", + companyId: "0199b200-0000-7000-8000-000000000001", + values, + needsReview: false, + history: [ + { + id: "0199b200-0000-7000-8000-000000000005", + actorId: "0199b200-0000-7000-8000-000000000001", + actorName: "Pruebas", + recordedAt: "2026-10-23T10:00:00.000Z", + values, + }, + ], +}); + +test("expansión: contraída inicialmente, abre, sustituye y cierra una única selección", () => { + const initial = collapsedProfileExpansion(context); + + assert.equal(initial.profileId, undefined); + const open = toggleProfileExpansion(initial, profile.id); + + assert.equal(open.profileId, profile.id); + assert.equal(toggleProfileExpansion(open, profile.id).profileId, undefined); + assert.equal(toggleProfileExpansion(open, "other").profileId, "other"); + assert.equal(toggleProfileExpansion({ ...open, pendingOnly: true }, "other").pendingOnly, false); +}); + +test("refetch conserva identidad de estado, selección y filtro, incluso con error o fuentes incompletas", () => { + const open = { + ...toggleProfileExpansion(collapsedProfileExpansion(context), profile.id), + pendingOnly: true, + }; + + assert.equal(reconcileProfileExpansion(open, context, [profile], true), open); + assert.equal(reconcileProfileExpansion(open, context, [], false), open); +}); + +test("retirada o archivo cierra con feedback, sin elegir otro perfil ni reabrirse automáticamente", () => { + const open = toggleProfileExpansion(collapsedProfileExpansion(context), profile.id); + + for (const profiles of [[], [{ ...profile, values: { ...values, active: false } }]]) { + const closed = reconcileProfileExpansion(open, context, profiles, true); + + assert.equal(closed.profileId, undefined); + assert.equal(closed.unavailable, true); + assert.equal(reconcileProfileExpansion(closed, context, [profile], true), closed); + } +}); + +test("cambio de compañía, puesto u ordenador restablece expansión y filtro", () => { + const open = { + ...toggleProfileExpansion(collapsedProfileExpansion(context), profile.id), + pendingOnly: true, + }; + + for (const next of [ + "other:workstation:host", + "company:other:host", + "company:workstation:other", + ]) { + assert.deepEqual( + reconcileProfileExpansion(open, next, [profile], false), + collapsedProfileExpansion(next) + ); + } +}); + +test("orden estable y numérico del resumen y los pendientes, sin mutar datos remotos", () => { + const other = { + ...profile, + id: "0199b200-0000-7000-8000-000000000006", + values: { ...values, name: "Perfil 10" }, + }; + const input = [other, profile]; + + assert.deepEqual( + sortWorkstationProfiles(input, "es").map((p) => p.values.name), + ["Perfil 2", "Perfil 10"] + ); + assert.equal(input[0], other); + assert.deepEqual( + sortWorkstationProfiles([other, { ...profile, values: other.values }], "es").map((p) => p.id), + [profile.id, other.id] + ); +}); diff --git a/modules/companies/src/web/workstations/detail/helpers/workstation-profile-expansion.ts b/modules/companies/src/web/workstations/detail/helpers/workstation-profile-expansion.ts new file mode 100644 index 0000000..edffea9 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/helpers/workstation-profile-expansion.ts @@ -0,0 +1,73 @@ +import type { WorkProfileDTO } from "../../../../common/work-profiles"; + +import { workstationPendingCount } from "./workstation-dashboard"; + +export interface WorkstationProfileExpansion { + context: string; + profileId: string | undefined; + pendingOnly: boolean; + focusRequest: number; + unavailable: boolean; +} + +export function collapsedProfileExpansion(context: string): WorkstationProfileExpansion { + return { context, profileId: undefined, pendingOnly: false, focusRequest: 0, unavailable: false }; +} + +/** Un refetch conserva la selección; solo una respuesta válida puede retirarla. */ +export function reconcileProfileExpansion( + state: WorkstationProfileExpansion, + context: string, + profiles: readonly WorkProfileDTO[], + authoritative: boolean +): WorkstationProfileExpansion { + if (state.context !== context) { + return collapsedProfileExpansion(context); + } + if ( + state.profileId && + authoritative && + !profiles.some((profile) => profile.id === state.profileId && profile.values.active) + ) { + return { ...state, profileId: undefined, pendingOnly: false, unavailable: true }; + } + + return state; +} + +export function toggleProfileExpansion( + state: WorkstationProfileExpansion, + profileId: string +): WorkstationProfileExpansion { + return { + ...state, + profileId: state.profileId === profileId ? undefined : profileId, + pendingOnly: false, + focusRequest: 0, + unavailable: false, + }; +} + +/** Mismo orden para el resumen y el salto a pendientes, incluidos empates de nombre. */ +export function sortWorkstationProfiles( + profiles: readonly WorkProfileDTO[], + language: string +): WorkProfileDTO[] { + return [...profiles].sort( + (a, b) => + a.values.name.localeCompare(b.values.name, language, { + sensitivity: "base", + numeric: true, + }) || a.id.localeCompare(b.id) + ); +} + +export function firstPendingWorkstationProfile( + profiles: readonly WorkProfileDTO[], + sources: Parameters[1], + language: string +): WorkProfileDTO | undefined { + return sortWorkstationProfiles(profiles, language).find( + (profile) => workstationPendingCount([profile], sources) > 0 + ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx index 35adb18..5c66d9d 100644 --- a/modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx @@ -1,108 +1,136 @@ -import type { AssetDTO, WorkstationDTO } from "@metis/assets/common"; +import { AssetTemplateIcon } from "@metis/assets/client"; +import type { AssetDTO, CatalogItemDTO, WorkstationDTO } from "@metis/assets/common"; import { BrowserLink } from "@metis/core/client"; +import { CollectionPreview } from "@repo/rdx-ui/components/collection-preview"; +import { CompactList } from "@repo/rdx-ui/components/compact-list"; +import { CompactListItem } from "@repo/rdx-ui/components/compact-list-item"; import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; -import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list"; +import { EmptyState } from "@repo/rdx-ui/components/empty-state"; import { MonitorIcon } from "@repo/rdx-ui/lib/icons"; import { Badge } from "@repo/shadcn-ui/components/badge"; import { Button } from "@repo/shadcn-ui/components/button"; +import type { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; +import { workstationEquipment } from "../helpers/workstation-equipment"; + +import { WorkstationQueryFeedback } from "./workstation-query-feedback"; + export function WorkstationAssets({ companyId, + catalog = [], + query, workstation, assets, onEdit, }: { companyId: string; - workstation: WorkstationDTO; + workstation: WorkstationDTO | undefined; + catalog?: readonly CatalogItemDTO[]; + query?: ComponentProps["query"]; assets: readonly AssetDTO[]; - onEdit: () => void; + onEdit?: () => void; }) { - const { t, i18n } = useTranslation("assets"); - const members = [...new Set(workstation.values.assetIds)] - .map((id) => ({ - id, - asset: assets.find((item) => item.id === id && item.companyId === companyId), - })) - .sort( - (a, b) => - Number(b.id === workstation.values.computerAssetId) - - Number(a.id === workstation.values.computerAssetId) - ); + const { t } = useTranslation("assets"); + const model = workstation ? workstationEquipment(companyId, workstation, assets) : undefined; + const members = model?.members ?? []; + const available = Boolean(workstation && (!query || query.data !== undefined)); + const editAction = onEdit && ( + + ); return ( - {t("editWorkstation")} - - } + actions={available && members.length > 0 ? editAction : undefined} density="compact" icon={} title={t("workstationDashboard.equipment")} > - item.asset?.values.name, - render: (item) => ( -
- {item.asset ? ( - - {item.asset.values.name} - - ) : ( - {t("workstationDetail.missingAsset")} - )} - - {item.asset?.specification.templateName ?? "—"} - - {item.id === workstation.values.computerAssetId && ( - - {t("workstationDashboard.principal")} - - )} -
- ), - }, - { - id: "technical", - label: t("workstationDashboard.technicalStatus"), - value: (item) => (item.asset ? t(item.asset.values.status) : ""), - render: (item) => - item.asset ? ( - - {t(item.asset.values.status)} - - ) : ( - "—" - ), - }, - { - id: "usage", - label: t("workstationDashboard.usageStatus"), - value: (item) => - item.asset - ? t(item.asset.assigned ? "in_use" : "workstationDashboard.unassigned") - : "", - render: (item) => - item.asset - ? t(item.asset.assigned ? "in_use" : "workstationDashboard.unassigned") - : "—", - }, - ]} - emptyMessage={t("noWorkstationAssets")} - items={members} - locale={i18n.language} - searchPlaceholder={t("workstationDashboard.searchEquipment")} - title={t("workstationDashboard.equipment")} - /> + {query && ( + + )} + {available && model?.principalState === "outside-composition" && ( +

+ {t("workstationDashboard.principalOutsideComposition")} +

+ )} + {available && model?.principalState === "unresolved" && ( +

+ {t("workstationDashboard.principalUnavailable")} +

+ )} + {available && members.length > 0 && model?.principalState === "missing" && ( +

{t("workstationDetail.noComputer")}

+ )} + {available && members.length === 0 && ( + } + title={t("noWorkstationAssets")} + /> + )} + {available && members.length > 0 && ( + + {(items) => ( + + {items.map(({ id, asset, principal }) => { + if (!asset) { + return ( + } + key={id} + metadata="—" + title={{t("workstationDetail.missingAsset")}} + /> + ); + } + const template = catalog.find((item) => item.id === asset.values.templateId); + const icon = + template?.values.kind === "template" ? template.values.icon : undefined; + const technicalVariant = + asset.values.status === "operational" ? "outline" : "destructive"; + + return ( + + {t(asset.values.status)} + + {t(asset.assigned ? "in_use" : "workstationDashboard.unassigned")} + + + } + icon={} + key={id} + metadata={ + + {asset.specification.templateName} + {principal && ( + {t("workstationDashboard.principal")} + )} + + } + title={ + + {asset.values.name} + + } + /> + ); + })} + + )} + + )}
); } diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-computer.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-computer.tsx deleted file mode 100644 index 47ac752..0000000 --- a/modules/companies/src/web/workstations/detail/ui/workstation-computer.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import type { AssetDTO } from "@metis/assets/common"; -import { BrowserLink } from "@metis/core/client"; -import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; -import { EmptyState } from "@repo/rdx-ui/components/empty-state"; -import { EntityStatus } from "@repo/rdx-ui/components/entity-list"; -import { MonitorIcon } from "@repo/rdx-ui/lib/icons"; -import { Button } from "@repo/shadcn-ui/components/button"; -import { useTranslation } from "react-i18next"; - -export function WorkstationComputer({ - computer, - companyId, - onEdit, -}: { - computer: AssetDTO | undefined; - companyId: string; - onEdit: () => void; -}) { - const { t } = useTranslation("assets"); - - return ( - } title={t("workstationComputer")}> - {computer ? ( -
- - -
- - {computer.values.name} - -

{computer.specification.templateName}

-
-
-
- {t("workstationDashboard.technicalStatus")} -
-
- - {t(computer.values.status)} - -
-
-
-
{t("workstationDashboard.usageStatus")}
-
{t(computer.assigned ? "in_use" : "workstationDashboard.unassigned")}
-
-
- - {t("workstationDashboard.openAsset")} - -
-
- ) : ( - - {t("editWorkstation")} - - } - className="gap-3 border-0 bg-transparent py-3 @min-[560px]:py-3" - description={t("workstationDetail.completeComputer")} - icon={} - title={t("workstationDetail.noComputer")} - /> - )} -
- ); -} diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-configuration-family.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-configuration-family.tsx index 80d58ec..803bc4f 100644 --- a/modules/companies/src/web/workstations/detail/ui/workstation-configuration-family.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workstation-configuration-family.tsx @@ -1,5 +1,8 @@ import { BrowserLink } from "@metis/core/client"; +import { CompactList } from "@repo/rdx-ui/components/compact-list"; +import { CompactListItem } from "@repo/rdx-ui/components/compact-list-item"; import { FileKeyIcon, FolderIcon, LayersIcon, MailIcon, PrinterIcon } from "@repo/rdx-ui/lib/icons"; +import { Badge } from "@repo/shadcn-ui/components/badge"; import type { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; @@ -32,6 +35,7 @@ export function WorkstationConfigurationFamily({ companyId, profileId, resolved, + emptyMessage, }: { kind: ProfileConfigurationKind; rows: readonly EnvironmentRow[]; @@ -39,6 +43,7 @@ export function WorkstationConfigurationFamily({ companyId: string; profileId: string; resolved: boolean; + emptyMessage: string | null; }) { const { t } = useTranslation("companies"); const Icon = FAMILY_ICONS[kind]; @@ -46,13 +51,13 @@ export function WorkstationConfigurationFamily({ return (
-

+

+
- {query.data !== undefined && resolved && ( -
    + {query.data !== undefined && resolved && rows.length > 0 && ( + {rows.map((row) => ( -
  • -
    -

    {row.name}

    - + {t(`mail.${row.state}`)} - -
    -
    + + } + key={`${row.kind}:${row.id}`} + title={row.name} + > +
    {row.details.map((detail) => (
    {t(detail.label)}:
    @@ -87,12 +92,12 @@ export function WorkstationConfigurationFamily({ {row.notes && (

    {row.notes}

    )} -
  • + ))} -
+ )} - {query.data !== undefined && resolved && !query.isError && !rows.length && ( -

{t("environment.empty")}

+ {query.data !== undefined && resolved && !query.isError && !rows.length && emptyMessage && ( +

{emptyMessage}

)}
); diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-dashboard-skeleton.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-dashboard-skeleton.tsx index c4e5ba4..ab7d589 100644 --- a/modules/companies/src/web/workstations/detail/ui/workstation-dashboard-skeleton.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workstation-dashboard-skeleton.tsx @@ -19,16 +19,7 @@ export function WorkstationDashboardSkeleton() { aria-hidden="true" className="grid min-w-0 gap-4 @min-[1000px]:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]" > - {[ - "computer", - "pending", - "profiles", - "responsible", - "equipment", - "software", - "configurations", - "support", - ].map((panel) => ( + {["equipment", "pending", "profiles", "software", "support", "latest"].map((panel) => (
void; }) { - const { t } = useTranslation("assets"); - const [pendingOnly, setPendingOnly] = useState(false); + const { t, i18n } = useTranslation("assets"); const workstation = c.workstation; const model = c.dashboard; @@ -46,34 +42,34 @@ export function WorkstationDetailContent({ ); return ( -
-
- +
+ c.setEditing(true)} + query={c.inventory} + workstation={workstation} />
{ - const profile = model.activeProfiles.find( - (item) => workstationPendingCount([item], c.sources) > 0 + const profile = firstPendingWorkstationProfile( + model.activeProfiles, + c.sources, + i18n.language ); if (profile) { - c.setSelectedProfileId(profile.id); + c.profileExpansion.openPending(profile.id); } - setPendingOnly(true); - const section = document.getElementById("workstation-configurations"); - - section?.focus(); - section?.scrollIntoView({ block: "start" }); }} />
-
+
{model.computer && ( c.setCreatingProfile(true)} - /> + onToggleProfile={c.profileExpansion.toggle} + selectionUnavailable={c.profileExpansion.state.unavailable} + > + {c.expandedProfile && ( + + )} +
- - c.setEditing(true)} - workstation={workstation} - /> - -
- + + +
- -
); diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-detail-page.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-detail-page.tsx index bc16ad6..858618c 100644 --- a/modules/companies/src/web/workstations/detail/ui/workstation-detail-page.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workstation-detail-page.tsx @@ -24,9 +24,10 @@ import type { CompanyDTO } from "../../../../common"; import { WorkProfileEditorDialog } from "../../../work-profiles/shared"; import { useWorkstationDetailController } from "../controllers/use-workstation-detail-controller"; +import { WorkstationAssets } from "./workstation-assets"; import { WorkstationDashboardSkeleton } from "./workstation-dashboard-skeleton"; import { WorkstationDetailContent } from "./workstation-detail-content"; -import { WorkstationQueryFeedback } from "./workstation-query-feedback"; +import { WorkstationResponsibleContext } from "./workstation-responsible-context"; export function WorkstationDetailPage({ company, @@ -51,7 +52,7 @@ export function WorkstationDetailPage({ }; return ( -
+
@@ -73,17 +74,21 @@ export function WorkstationDetailPage({ - {inventory.isPending ? ( - - ) : ( - + {inventory.isPending && } + {inventory.isError && !workstation && ( + )} {inventory.isSuccess && !workstation && (

{t("workstationDetail.notFound")}

)} {workstation && ( <> -
+
@@ -120,13 +125,23 @@ export function WorkstationDetailPage({ "—" )} - + `).length - 1, 1); + assert.ok(html.includes(labels.broken)); + assert.ok(html.includes(labels.in_use)); + assert.ok(html.includes(`href="/companies/${companyId}/assets/${broken.id}"`)); + assert.ok(html.includes(`href="/companies/${companyId}/assets/${monitor.id}"`)); + } +}); + +test("equipamiento: inventario fallido no se presenta como vacío y permite reintentar", async () => { + for (const language of ["es", "en"]) { + const labels = language === "es" ? assetsSpanish : assetsEnglish; + const html = await render(language, true, { + node: ( + undefined, + }} + workstation={undefined} + /> + ), + }); + + assert.ok(html.includes('role="alert"')); + assert.ok(html.includes(labels.retry)); + assert.equal(html.includes(labels.noWorkstationAssets), false); + assert.equal(html.includes('data-slot="compact-list"'), false); + assert.equal(html.includes(labels.editWorkstation), false); + } +}); + +test("equipamiento: principal inconsistente se explica y no se incorpora como miembro válido", async () => { + const computer = asset(40, "FUERA-DE-COMPOSICIÓN"); + const monitor = asset(41, "MONITOR-ACTUAL"); + const station = { + ...workstation, + values: { ...workstation.values, computerAssetId: computer.id, assetIds: [monitor.id] }, + }; + const html = await render("es", true, { + node: ( + undefined} + workstation={station} + /> + ), + }); + + assert.ok(html.includes(assetsSpanish.workstationDashboard.principalOutsideComposition)); + assert.ok(html.includes(monitor.values.name)); + assert.equal(html.includes(computer.values.name), false); + assert.equal(html.includes(`>${assetsSpanish.workstationDashboard.principal}`), false); +}); + +test("personas y perfiles es/en: expansión contextual, enlaces independientes y resumen cero real", async () => { + const profile = userProfile(50, companyId, person.id, true); + + for (const language of ["es", "en"]) { + const html = await render(language, true, { + node: ( + undefined} + onRetry={() => Promise.resolve()} + onToggleProfile={() => undefined} + people={[person]} + profiles={[profile]} + state={{ status: "ready" }} + > +
Detalle único
+
+ ), + }); + + assert.ok(html.includes('aria-expanded="true"')); + assert.ok(html.includes("aria-controls=")); + assert.ok( + html.includes( + language === "es" + ? "Ocultar configuraciones de Perfil 50" + : "Hide configurations for Perfil 50" + ) + ); + assert.ok(html.includes(language === "es" ? "Sin configuraciones" : "No configurations")); + assert.equal(html.split("Detalle único").length - 1, 1); + assert.ok(html.includes(`href="/companies/${companyId}/work-profiles/${profile.id}"`)); + assert.ok(html.includes(`href="/companies/${companyId}/people/${person.id}"`)); + assert.ok( + [...html.matchAll(/]*>(.*?)<\/button>/gs)].every( + (match) => !match[1]?.includes(" { + const computer = asset(40, "PC-PRINCIPAL"); + const longPerson = { + ...person, + name: "Ángela Muñoz de la Peña Fernández del Río — responsable de documentación y atención interna", + }; + const station = { + ...workstation, + values: { ...workstation.values, computerAssetId: computer.id, assetIds: [computer.id] }, + }; + + for (const language of ["es", "en"]) { + for (const personId of [person.id, null]) { + const html = await render(language, true, { + assets: [computer], + people: [longPerson], + workstation: { ...station, values: { ...station.values, personId } }, + }); + const labels = language === "es" ? assetsSpanish : assetsEnglish; + const header = html.slice(html.indexOf("")); + + assert.equal(html.includes(`${labels.workstationDashboard.responsibleContext}`), false); + assert.equal([...html.matchAll(/]*>(.*?)<\/a>/gs)].every( + (match) => !match[1]?.includes(" void; +}) { + const { t } = useTranslation("assets"); + const { t: companies } = useTranslation("companies"); + const id = useId(); + const sectionRef = useRef(null); + const model = buildEnvironment(profile, c.sources); + const rows = model.rows.filter((row) => !pendingOnly || row.state === "pending"); + const queries = c.configurationQueries; + const complete = [ + queries.folders, + queries.mail, + queries.applications, + queries.printers, + queries.certificates, + queries.resources, + queries.access, + ].every((query) => query.data !== undefined && !query.isError); + + const empty = complete && model.rows.length === 0; + + // Sincronización con el DOM tras montar el destino solicitado desde pendientes. + useEffect(() => { + if (focusRequest > 0) { + sectionRef.current?.focus(); + sectionRef.current?.scrollIntoView({ block: "start" }); + } + }, [focusRequest]); + + return ( +
+

+ {t("workstationDashboard.profileConfigurations", { name: profile.values.name })} +

+
+ + {t("workstationDashboard.openProfile")} + + +
+ + + {empty && ( +

+ {t("workstationDashboard.noConfigurations")} +

+ )} +
+ {(["folders", "mail", "applications", "printers", "certificates"] as const).map((kind) => ( + row.kind === kind)} + /> + ))} +
+ {model.unlinkedMail > 0 && ( +

+ {companies("environment.unlinkedMail", { count: model.unlinkedMail })} +

+ )} +
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-profile-configurations.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-profile-configurations.tsx deleted file mode 100644 index d0663f0..0000000 --- a/modules/companies/src/web/workstations/detail/ui/workstation-profile-configurations.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { BrowserLink } from "@metis/core/client"; -import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; -import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; -import { SettingsIcon } from "@repo/rdx-ui/lib/icons"; -import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; -import { useId } from "react"; -import { useTranslation } from "react-i18next"; - -import { buildEnvironment } from "../../../work-environments/shared/helpers"; -import type { WorkstationDetailController } from "../controllers/use-workstation-detail-controller"; - -import { WorkstationConfigurationFamily } from "./workstation-configuration-family"; -import { WorkstationQueryFeedback } from "./workstation-query-feedback"; - -export function WorkstationProfileConfigurations({ - controller: c, - companyId, - pendingOnly, - onPendingOnly, -}: { - controller: WorkstationDetailController; - companyId: string; - pendingOnly: boolean; - onPendingOnly: (value: boolean) => void; -}) { - const { t } = useTranslation("assets"); - const { t: companies } = useTranslation("companies"); - const id = useId(); - const profile = c.selectedProfile; - const model = profile ? buildEnvironment(profile, c.sources) : undefined; - const rows = model?.rows.filter((row) => !pendingOnly || row.state === "pending") ?? []; - const queries = c.configurationQueries; - - return ( -
- } - title={t("workstationDashboard.configurations")} - > - {c.dashboard?.computer && ( - - )} - {profile ? ( - <> - { - if (value) { - c.setSelectedProfileId(value); - } - }} - options={(c.dashboard?.activeProfiles ?? []).map((item) => ({ - value: item.id, - label: item.values.name, - }))} - placeholder={t("workstationDashboard.selectProfile")} - value={profile.id} - /> - {c.selectedProfileId && c.selectedProfileId !== profile.id && ( -

- {t("workstationDashboard.selectionChanged")} -

- )} -
- - {t("workstationDashboard.openProfile")} - - -
- - -
- {(["folders", "mail", "applications", "printers", "certificates"] as const).map( - (kind) => ( - row.kind === kind)} - /> - ) - )} -
- {model && model.unlinkedMail > 0 && ( -

- {companies("environment.unlinkedMail", { count: model.unlinkedMail })} -

- )} - - ) : ( - !(c.dashboard?.computer && (c.profiles.isPending || c.profiles.isError)) && ( -

- {t("workstationDashboard.noActiveProfiles")} -

- ) - )} -
-
- ); -} diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-profile-person.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-profile-person.tsx new file mode 100644 index 0000000..d3b53c6 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workstation-profile-person.tsx @@ -0,0 +1,32 @@ +import { BrowserLink } from "@metis/core/client"; + +import type { PersonDTO } from "../../../../common"; +import { PersonAvatar } from "../../../people/shared"; + +/** Identidad de persona compartida por las dos distribuciones de la tabla de perfiles. */ +export function WorkstationProfilePerson({ + companyId, + person, +}: { + companyId: string; + person: PersonDTO | undefined; +}) { + return person ? ( + + + + {person.name} + + + ) : ( + "—" + ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-profiles.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-profiles.tsx index 92365c0..048485a 100644 --- a/modules/companies/src/web/workstations/detail/ui/workstation-profiles.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workstation-profiles.tsx @@ -1,17 +1,28 @@ import { BrowserLink } from "@metis/core/client"; +import { CollectionPreview } from "@repo/rdx-ui/components/collection-preview"; +import { CompactTable } from "@repo/rdx-ui/components/compact-table"; import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; -import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list"; +import { EntityStatus } from "@repo/rdx-ui/components/entity-list"; import type { EntityListState } from "@repo/rdx-ui/lib/entity-list-state"; -import { TriangleAlertIcon, WorkProfileIcon } from "@repo/rdx-ui/lib/icons"; +import { + ChevronDownIcon, + ChevronRightIcon, + TriangleAlertIcon, + WorkProfileIcon, +} from "@repo/rdx-ui/lib/icons"; import { Button } from "@repo/shadcn-ui/components/button"; -import { useState } from "react"; +import { Collapsible, CollapsibleContent } from "@repo/shadcn-ui/components/collapsible"; +import type { ReactNode } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { PersonDTO } from "../../../../common"; import type { AccessItemDTO } from "../../../../common/dto/shared/access.dto"; import type { WorkProfileDTO } from "../../../../common/work-profiles"; -import { PersonAvatar } from "../../../people/shared"; import type { ProfileConfigurationKind } from "../../../work-environments/shared/helpers"; +import { sortWorkstationProfiles } from "../helpers/workstation-profile-expansion"; + +import { WorkstationProfilePerson } from "./workstation-profile-person"; export function WorkstationProfiles({ companyId, @@ -23,6 +34,10 @@ export function WorkstationProfiles({ accounts = [], configurations, state, + expandedProfileId, + onToggleProfile, + children, + selectionUnavailable = false, }: { companyId: string; canCreate: boolean; @@ -33,163 +48,236 @@ export function WorkstationProfiles({ accounts?: readonly AccessItemDTO[]; configurations?: ReadonlyMap; state: EntityListState; + expandedProfileId: string | undefined; + onToggleProfile: (profileId: string) => void; + selectionUnavailable?: boolean; + children?: ReactNode; }) { const { t, i18n } = useTranslation("assets"); const { t: companies } = useTranslation("companies"); - const [archived, setArchived] = useState(false); const archivedProfiles = profiles.filter((profile) => !profile.values.active); - const visible = profiles.filter((profile) => archived || profile.values.active); + const detailsId = useId(); + const visible = sortWorkstationProfiles( + profiles.filter((profile) => archived || profile.values.active), + i18n.language + ); + const expandedProfile = visible.find((profile) => profile.id === expandedProfileId); + const feedbackRef = useRef(null); + + useEffect(() => { + if (selectionUnavailable && document.activeElement === document.body) { + feedbackRef.current?.focus(); + } + }, [selectionUnavailable]); return ( - - {archivedProfiles.length > 0 && ( - - )} - -
- } - density="compact" - icon={} - title={t("workstationDashboard.peopleProfiles")} - > - {archivedProfiles.length > 0 && !archived && ( -

- {archivedProfiles.map((profile) => profile.values.name).join(" · ")} -

- )} - {!canCreate && ( -

{t("workstationDetail.profileUnavailable")}

- )} - profile.values.name, - render: (profile) => ( - + + {archivedProfiles.length > 0 && ( + + )} + +
+ } + density="compact" + icon={} + title={t("workstationDashboard.peopleProfiles")} + > + {!canCreate && ( +

+ {t("workstationDetail.profileUnavailable")} +

+ )} + {selectionUnavailable && ( +

+ {t("workstationDashboard.profileUnavailable")} +

+ )} + + {(items) => ( + { + const account = accounts.find( + (item) => item.id === profile.values.accountId && item.companyId === companyId + ); + + return ( +
+ + {profile.values.name} + +
+ item.id === profile.values.personId)} + /> + + {t("workstationDashboard.account")}:{" "} + {account?.values.kind === "account" ? ( + + {account.values.login} + + ) : ( + "—" + )} + + + {t(profile.values.active ? "active" : "inactive")} + +
+
+ ); + }, + }, + { + id: "person", + label: t("workstationDashboard.person"), + render: (profile) => ( + item.id === profile.values.personId)} + /> + ), + }, + { + id: "account", + label: t("workstationDashboard.account"), + render: (profile) => { + const account = accounts.find( + (item) => item.id === profile.values.accountId && item.companyId === companyId + ); + + return account?.values.kind === "account" ? ( + + {account.values.login} + + ) : ( + "—" + ); + }, + }, + { + id: "status", + label: t("status"), + render: (profile) => ( + + + {t(profile.values.active ? "active" : "inactive")} + + + ), + }, + { + id: "configurations", + label: t("workstationDashboard.configurationSummary"), + render: (profile) => ( +
+ + {configurations?.has(profile.id) + ? t("workstationDashboard.categoryCount", { + count: configurations.get(profile.id)?.length ?? 0, + }) + : "—"} + + {profile.values.active && ( + + )} + {profile.needsReview && ( + + + )} +
+ ), + }, + ]} + emptyMessage={t("workstationDetail.noProfiles")} + items={ + expandedProfile && !items.some((profile) => profile.id === expandedProfile.id) + ? [...items, expandedProfile] + : items + } + onRetry={onRetry} + retryLabel={t("retry")} + state={state} + title={t("workstationDetail.profiles")} + /> + )} +
+ + {children} + + + ); } diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-responsible-context.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-responsible-context.tsx new file mode 100644 index 0000000..04d4155 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workstation-responsible-context.tsx @@ -0,0 +1,140 @@ +import { BrowserLink } from "@metis/core/client"; +import { DetailField } from "@repo/rdx-ui/components/detail-field"; +import { InfoIcon } from "@repo/rdx-ui/lib/icons"; +import { Badge } from "@repo/shadcn-ui/components/badge"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverTitle, + PopoverTrigger, +} from "@repo/shadcn-ui/components/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components/tooltip"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import type { CompanyDTO, PersonDTO } from "../../../../common"; +import { getCompany } from "../../../companies/detail/api"; +import { companyKeys } from "../../../companies/shared/queries"; +import { workstationOrganizationalAssignments } from "../helpers/workstation-organization"; + +export function WorkstationResponsibleContext({ + company, + responsible, +}: { + company: CompanyDTO; + responsible: PersonDTO; +}) { + const { t } = useTranslation("assets"); + const label = t("workstationDashboard.openResponsibleContext", { name: responsible.name }); + const titleRef = useRef(null); + const retryRef = useRef(null); + const recoveryFocus = useRef(false); + // Observa la fuente ya cargada por CompanyDetail; abrir el contexto no inicia otra petición. + const query = useQuery({ + queryKey: companyKeys.detail(company.id), + queryFn: () => getCompany(company.id), + enabled: false, + }); + // El foco se devuelve después del commit que retira el botón de error. + useEffect(() => { + if (recoveryFocus.current && !query.isFetching) { + recoveryFocus.current = false; + (query.isError ? retryRef.current : titleRef.current)?.focus(); + } + }, [query.isError, query.isFetching]); + + const assignments = workstationOrganizationalAssignments( + company, + responsible.id, + new Date().toISOString().slice(0, 10) + ); + const fields = assignments.length + ? assignments + : [{ id: "empty", isPrimary: false, department: undefined, position: null }]; + + return ( + + + } + /> + } + > + + {label} + + +
+ + {responsible.name} + + {t("workstationDetail.assignedPerson")} +
+ {query.isFetching && ( +

+ {t("workstationDashboard.refreshing")} +

+ )} + {query.isError && ( +
+

{t("workstationDashboard.organizationStale")}

+ +
+ )} + {assignments.length > 1 && ( +

+ {t("workstationDashboard.currentAssignments", { count: assignments.length })} +

+ )} +
+ {fields.map((assignment) => ( +
+ {assignment.isPrimary && ( + {t("workstationDashboard.primaryAssignment")} + )} +
+ + {assignment.department ? ( + + {assignment.department.name} + + ) : ( + "—" + )} + + + {assignment.position ?? "—"} + +
+
+ ))} +
+ + {t("workstationDashboard.openResponsiblePerson")} + +
+
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-responsible.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-responsible.tsx deleted file mode 100644 index b7fd830..0000000 --- a/modules/companies/src/web/workstations/detail/ui/workstation-responsible.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { BrowserLink } from "@metis/core/client"; -import { DetailField } from "@repo/rdx-ui/components/detail-field"; -import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; -import { PersonIcon } from "@repo/rdx-ui/lib/icons"; -import { useTranslation } from "react-i18next"; - -import type { CompanyDTO } from "../../../../common"; -import { workstationOrganization } from "../helpers/workstation-dashboard"; - -export function WorkstationResponsible({ - company, - personId, - siteId, -}: { - company: CompanyDTO; - personId: string | null; - siteId: string | null; -}) { - const { t } = useTranslation("assets"); - const root = `/companies/${company.id}`; - const responsible = company.people.find((person) => person.id === personId); - const site = company.sites.find((item) => item.id === siteId); - const { department, position } = workstationOrganization( - company, - personId, - new Date().toISOString().slice(0, 10) - ); - - return ( - } - title={t("workstationDashboard.responsibleContext")} - > -
- - {responsible ? ( - {responsible.name} - ) : ( - "—" - )} - - - {department ? ( - {department.name} - ) : ( - "—" - )} - - {position ?? "—"} - - {site ? {site.name} : "—"} - - - {company.name} - -
-
- ); -} diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-software.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-software.tsx index 89c5fef..b99cde1 100644 --- a/modules/companies/src/web/workstations/detail/ui/workstation-software.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workstation-software.tsx @@ -1,4 +1,7 @@ import { BrowserLink, useListQuery } from "@metis/core/client"; +import { CollectionPreview } from "@repo/rdx-ui/components/collection-preview"; +import { CompactList } from "@repo/rdx-ui/components/compact-list"; +import { CompactListItem } from "@repo/rdx-ui/components/compact-list-item"; import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; import { EntityStatus } from "@repo/rdx-ui/components/entity-list"; import { LayersIcon } from "@repo/rdx-ui/lib/icons"; @@ -58,44 +61,52 @@ export function WorkstationSoftware({ {query.data && ( -
    5 ? 0 : undefined} + - {items.map((item) => ( -
  • - - {resource(item.values.resourceId)?.values.name ?? "—"} - -
    - - {companies("applications.version")}: {item.values.version || "—"} - - - {companies(`mail.${item.values.state}`)} - -
    - {item.values.licenseResourceId && ( - ( + + {visible.map((item) => ( + + {companies(`mail.${item.values.state}`)} + + } + key={item.id} + metadata={ + + {companies("applications.version")}: {item.values.version || "—"} + + } + title={ + + {resource(item.values.resourceId)?.values.name ?? "—"} + + } > - {companies("software.licenseColumn")}:{" "} - {resource(item.values.licenseResourceId)?.values.name ?? "—"} - - )} - {item.needsReview && ( -

    - {companies("software.review")} -

    - )} -
  • - ))} -
+ {item.values.licenseResourceId && ( + + {companies("software.licenseColumn")}:{" "} + {resource(item.values.licenseResourceId)?.values.name ?? "—"} + + )} + {item.needsReview && ( +

{companies("software.review")}

+ )} + + ))} + + )} + )} {query.data && !query.isError && !items.length && (

{companies("software.empty")}

diff --git a/packages/rdx-ui/package.json b/packages/rdx-ui/package.json index e560aa0..5287328 100644 --- a/packages/rdx-ui/package.json +++ b/packages/rdx-ui/package.json @@ -11,7 +11,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/selection-components.test.tsx src/entity-list.test.tsx src/entity-column-visibility.test.tsx src/time-line.test.tsx src/count-badge.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts src/remote-option-list.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/selection-components.test.tsx src/entity-list.test.tsx src/compact-collections.test.tsx src/entity-column-visibility.test.tsx src/time-line.test.tsx src/count-badge.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts src/remote-option-list.test.tsx" }, "dependencies": { "@base-ui/react": "1.8.0", diff --git a/packages/rdx-ui/src/compact-collections.test.tsx b/packages/rdx-ui/src/compact-collections.test.tsx new file mode 100644 index 0000000..0baadbd --- /dev/null +++ b/packages/rdx-ui/src/compact-collections.test.tsx @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import { CollectionPreview } from "./components/collection-preview"; +import { CompactList } from "./components/compact-list"; +import { CompactListItem } from "./components/compact-list-item"; +import { CompactTable } from "./components/compact-table"; +import { EntityTable } from "./components/entity-list"; +import type { EntityListState } from "./lib/entity-list-state"; + +const items = Array.from({ length: 60 }, (_, index) => ({ + id: String(index), + name: `Perfil ${index}`, +})); +const columns = [ + { + id: "name", + label: "Perfil", + render: (item: (typeof items)[number]) =>
{item.name}, + }, +]; + +test("la tabla compacta conserva semántica y enlaces sin herramientas de exploración", () => { + for (const size of [0, 1, 2, 10, 60]) { + const html = renderToStaticMarkup( + + ); + + assert.ok(html.includes('aria-label="Perfiles"')); + assert.ok(html.includes('scope="col"')); + assert.equal((html.match(/data-entity-id=/g) ?? []).length, size); + assert.equal(html.includes("data-quick-search"), false); + assert.equal(html.includes(" { + const html = renderToStaticMarkup( + <> }, + { id: "zero", label: "Total", align: "end", render: () => 0 }, + ]} + emptyMessage="Vacío" + items={items.slice(0, 1)} + title="Valores" + /> + ); + + assert.ok(html.includes("—")); + assert.ok(html.includes(">0")); + assert.ok(html.includes("tabular-nums")); +}); + +test("carga y error sin datos no afirman vacío; caché obsoleta conserva registros", () => { + for (const state of [ + { status: "loading", message: "Cargando perfiles" }, + { status: "unavailable", message: "No disponible", retrying: false }, + { status: "stale", message: "Últimos datos", retrying: false }, + { status: "refreshing", message: "Actualizando" }, + ] satisfies EntityListState[]) { + const html = renderToStaticMarkup( + undefined} + retryLabel="Reintentar" + state={state} + title="Perfiles" + /> + ); + + assert.ok(html.includes(state.message)); + assert.equal(html.includes("Vacío real"), false); + assert.equal( + html.includes("/profiles/0"), + state.status === "stale" || state.status === "refreshing" + ); + assert.equal( + html.includes("Reintentar"), + state.status === "stale" || state.status === "unavailable" + ); + } +}); + +test("la ampliación identifica el contenido controlado sin añadir controles a colecciones pequeñas", () => { + for (const size of [0, 1, 2, 10, 60]) { + const html = renderToStaticMarkup( + + {(visible) => ( + + {visible.map((item) => ( + {item.name}} + /> + ))} + + )} + + ); + + assert.equal((html.match(/
  • 5); + assert.equal(html.includes("aria-controls="), size > 5); + } +}); + +test("EntityTable mantiene búsqueda, columnas y paginación con su contrato anterior", () => { + const html = renderToStaticMarkup( + + ); + + assert.ok(html.includes("data-quick-search")); + assert.ok(html.includes("Columnas")); + assert.ok(html.includes('aria-label="Página siguiente"')); + assert.ok(html.includes("Mostrando 1-10 de 60")); + assert.equal((html.match(/data-entity-id=/g) ?? []).length, 10); +}); diff --git a/packages/rdx-ui/src/components/collection-preview.tsx b/packages/rdx-ui/src/components/collection-preview.tsx new file mode 100644 index 0000000..dff8367 --- /dev/null +++ b/packages/rdx-ui/src/components/collection-preview.tsx @@ -0,0 +1,42 @@ +import { Button } from "@repo/shadcn-ui/components/button"; +import type { ReactNode } from "react"; +import { useId, useState } from "react"; + +/** Resumen local con ampliación explícita: ningún elemento queda inaccesible. */ +export function CollectionPreview({ + items, + limit, + expandLabel, + collapseLabel, + children, +}: { + items: readonly T[]; + limit: number; + expandLabel: string; + collapseLabel: string; + children: (items: readonly T[]) => ReactNode; +}) { + const [expanded, setExpanded] = useState(false); + const id = useId(); + const previewLimit = Number.isSafeInteger(limit) && limit > 0 ? limit : items.length; + const limited = items.length > previewLimit; + + return ( +
    +
    {children(limited && !expanded ? items.slice(0, previewLimit) : items)}
    + {limited && ( + + )} +
    + ); +} diff --git a/packages/rdx-ui/src/components/compact-list-item.tsx b/packages/rdx-ui/src/components/compact-list-item.tsx new file mode 100644 index 0000000..2065e29 --- /dev/null +++ b/packages/rdx-ui/src/components/compact-list-item.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; + +/** Identidad y metadatos flexibles con acciones independientes y accesibles. */ +export function CompactListItem({ + title, + metadata, + icon, + actions, + children, +}: { + title: ReactNode; + metadata?: ReactNode; + icon?: ReactNode; + actions?: ReactNode; + children?: ReactNode; +}) { + return ( +
  • + {icon && ( + + )} +
    +
    {title}
    + {metadata && ( +
    {metadata}
    + )} + {children} +
    + {actions &&
    {actions}
    } +
  • + ); +} diff --git a/packages/rdx-ui/src/components/compact-list.tsx b/packages/rdx-ui/src/components/compact-list.tsx new file mode 100644 index 0000000..e7ed050 --- /dev/null +++ b/packages/rdx-ui/src/components/compact-list.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +/** Colección lineal; el consumidor compone elementos con CompactListItem. */ +export function CompactList({ title, children }: { title: string; children: ReactNode }) { + return ( +
      + {children} +
    + ); +} diff --git a/packages/rdx-ui/src/components/compact-table.tsx b/packages/rdx-ui/src/components/compact-table.tsx new file mode 100644 index 0000000..6394af6 --- /dev/null +++ b/packages/rdx-ui/src/components/compact-table.tsx @@ -0,0 +1,114 @@ +import { Skeleton } from "@repo/shadcn-ui/components/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@repo/shadcn-ui/components/table"; +import type { ReactNode } from "react"; +import { useRef } from "react"; + +import { hasCellContent } from "../lib/collection-cell-content"; +import type { EntityListState } from "../lib/entity-list-state"; + +import { EntityListStatus } from "./entity-list-status"; + +export type CompactColumn = { + id: string; + label: string; + render: (item: T) => ReactNode; + align?: "start" | "end"; +}; + +/** Comparación contextual sin herramientas de exploración ni estado remoto propio. */ +export function CompactTable({ + title, + className, + items, + columns, + emptyMessage, + state, + onRetry, + retryLabel, +}: { + title: string; + className?: string; + items: readonly T[]; + columns: readonly CompactColumn[]; + emptyMessage: string; + state?: EntityListState; + onRetry?: () => Promise; + retryLabel?: string; +}) { + const ref = useRef(null); + const available = state?.status !== "loading" && state?.status !== "unavailable"; + + return ( +
    + {state && ( + ref.current} + retryLabel={retryLabel ?? ""} + state={state} + /> + )} + + + + {columns.map((column) => ( + + {column.label} + + ))} + + + + {state?.status === "loading" && ( + + )} + {available && + items.map((item) => ( + + {columns.map((column) => ( + + {cellContent(column.render(item))} + + ))} + + ))} + {available && items.length === 0 && ( + + + {emptyMessage} + + + )} + +
    +
    + ); +} + +function cellContent(content: ReactNode) { + return hasCellContent(content) ? content : "—"; +} diff --git a/packages/rdx-ui/src/components/detail-field.tsx b/packages/rdx-ui/src/components/detail-field.tsx index ddefb25..f447677 100644 --- a/packages/rdx-ui/src/components/detail-field.tsx +++ b/packages/rdx-ui/src/components/detail-field.tsx @@ -1,9 +1,23 @@ import type { ReactNode } from "react"; /** Etiqueta y valor responden al ancho de su ficha, no al ancho total del navegador. */ -export function DetailField({ label, children }: { label: ReactNode; children: ReactNode }) { +export function DetailField({ + label, + children, + density = "default", +}: { + label: ReactNode; + children: ReactNode; + density?: "default" | "compact"; +}) { return ( -
    +
    {label}
    {children} diff --git a/packages/rdx-ui/src/components/entity-table-content.tsx b/packages/rdx-ui/src/components/entity-table-content.tsx index caa6806..cda5281 100644 --- a/packages/rdx-ui/src/components/entity-table-content.tsx +++ b/packages/rdx-ui/src/components/entity-table-content.tsx @@ -7,9 +7,8 @@ import { TableRow, } from "@repo/shadcn-ui/components/table"; import type { Table as TableModel } from "@tanstack/react-table"; -import type { ReactNode } from "react"; -import { Fragment, isValidElement } from "react"; +import { hasCellContent } from "../lib/collection-cell-content"; import type { EntityListState } from "../lib/entity-list-state"; import type { EntityColumn } from "./entity-list"; @@ -192,20 +191,3 @@ function entityColumnClass(columnId: string, pinned: boolean, header: boolean) { ? `${nameWidth} bg-background font-medium` : `h-14 ${nameWidth} max-w-lg whitespace-normal break-words px-3 py-3`; } - -function hasCellContent(content: ReactNode): boolean { - if (content === null || content === undefined || typeof content === "boolean") { - return false; - } - if (typeof content === "string") { - return content.trim().length > 0; - } - if (Array.isArray(content)) { - return content.some(hasCellContent); - } - if (isValidElement<{ children?: ReactNode }>(content) && content.type === Fragment) { - return hasCellContent(content.props.children); - } - - return true; -} diff --git a/packages/rdx-ui/src/lib/collection-cell-content.ts b/packages/rdx-ui/src/lib/collection-cell-content.ts new file mode 100644 index 0000000..8090c91 --- /dev/null +++ b/packages/rdx-ui/src/lib/collection-cell-content.ts @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { Fragment, isValidElement } from "react"; + +export function hasCellContent(content: ReactNode): boolean { + if (content === null || content === undefined || typeof content === "boolean") { + return false; + } + if (typeof content === "string") { + return content.trim().length > 0; + } + if (Array.isArray(content)) { + return content.some(hasCellContent); + } + if (isValidElement<{ children?: ReactNode }>(content) && content.type === Fragment) { + return hasCellContent(content.props.children); + } + + return true; +} diff --git a/scripts/workstation-dashboard-ui.test.py b/scripts/workstation-dashboard-ui.test.py index e2f40ea..b5e4b1c 100644 --- a/scripts/workstation-dashboard-ui.test.py +++ b/scripts/workstation-dashboard-ui.test.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Pruebas MCP del dashboard real sobre fixtures HTTP de la instalación desechable.""" import contextlib +import base64 +import hashlib import json import os from pathlib import Path @@ -30,8 +32,11 @@ class WorkstationDashboardTests(unittest.TestCase): raise RuntimeError("Los escenarios no pertenecen a la instalación desechable activa.") cls.origin = cls.fixture["origin"] cls.base = f"/companies/{cls.fixture['companyId']}" - cls.output = state.parent / "workstation-dashboard" - cls.output.mkdir(exist_ok=True) + phase = os.environ.get("METIS_DASHBOARD_CAPTURE_PHASE", "after") + if phase not in ("before", "after"): + raise RuntimeError("Fase de captura desconocida.") + cls.output = state.parent / "workstation-dashboard-wd043" / phase + cls.output.mkdir(parents=True, exist_ok=True) cls.evidence = json.loads((cls.output / "measurements.json").read_text()) if (cls.output / "measurements.json").exists() else [] cls.stack = contextlib.ExitStack() cls.addClassCleanup(cls.stack.close) @@ -75,20 +80,38 @@ class WorkstationDashboardTests(unittest.TestCase): def capture(self, name): self.record(name, AUDIT["capture"](self.client, self.output, name)) + def expand_primary(self): + self.code("await page.getByRole('button',{name:'Mostrar configuraciones de Ángela · administración',exact:true}).click();await page.locator('#workstation-configurations').waitFor();return true;") + def test_01_responsive_and_density(self): 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.navigate() self.code("await page.getByRole('link',{name:'Gestor documental de ejemplo',exact:true}).first().waitFor();return true;") - values = self.code("""return await page.evaluate(()=>({width:innerWidth,documentWidth:document.documentElement.scrollWidth,pending:document.querySelector('[data-pending-count]').textContent,headings:[...document.querySelectorAll('main h2')].map(x=>x.textContent.trim())}));""") + values = self.code("""return await page.evaluate(()=>({width:innerWidth,documentWidth:document.documentElement.scrollWidth,pending:document.querySelector('[data-pending-count]').textContent,headings:[...document.querySelectorAll('main h2')].map(x=>x.textContent.trim()),mainWidth:document.querySelector('main').getBoundingClientRect().width,sections:[...document.querySelectorAll('main section[aria-labelledby]')].map(e=>({title:e.querySelector('h2')?.textContent.trim(),...Object.fromEntries(['x','y','width','height'].map(k=>[k,e.getBoundingClientRect()[k]]))}))}));""") + equipment=self.code("const region=page.getByRole('region',{name:'Equipamiento',exact:true});return {members:await region.locator('[data-slot=compact-list-item]').count(),principalBadges:await region.getByText('Principal',{exact:true}).count(),firstHref:await region.locator('[data-slot=compact-list-item]').first().getByRole('link').getAttribute('href')};") + self.assertEqual(equipment['members'],2) + self.assertEqual(equipment['principalBadges'],1) + self.assertEqual(equipment['firstHref'],self.base+'/assets/'+self.fixture['host']) + values['equipment']=equipment self.record(f"geometry-{width}", values) self.assertLessEqual(values["documentWidth"], width) self.assertEqual(values["pending"], "1") + self.assertNotIn("Ordenador principal", values["headings"]) + self.assertEqual(values["headings"].count("Equipamiento"),1) + self.assertEqual(values["headings"][0],"Equipamiento") self.assertNotIn("Activos asociados", values["headings"]) + self.assertNotIn("Responsable y contexto", values["headings"]) + self.assertEqual(len(values["headings"]),6) self.assertNotIn("Perfiles activos", values["headings"]) self.capture(f"S01-{width}") + if width == 1600: + for label, suffix in [("Personas y perfiles","profiles"),("Equipamiento","equipment"),("Software del ordenador principal","software"),("Soporte y acceso remoto","support"),("Último cambio","latest")]: + self.code("await page.getByRole('region',{name:"+json.dumps(label)+",exact:true}).first().scrollIntoViewIfNeeded();return true;") + self.capture("S01-detail-"+suffix) if width == 390: - self.code("await page.locator('#workstation-configurations').getByRole('heading',{name:'Configuraciones por perfil',exact:true}).scrollIntoViewIfNeeded();return true;") + self.expand_primary() + self.code("await page.locator('#workstation-configurations').scrollIntoViewIfNeeded();return true;") self.capture('S01-configurations-390') self.tool("browser_resize", {"width":1440,"height":900}) self.navigate("dense") @@ -96,20 +119,20 @@ class WorkstationDashboardTests(unittest.TestCase): self.capture("S04-density-1440") self.assertEqual(self.code("return await page.locator('main').evaluate(e=>e.scrollWidth<=e.clientWidth);"), True) - def test_02_profiles_refetch_and_search(self): + def test_02_profiles_refetch_and_compact_summary(self): self.tool("browser_resize", {"width":1440,"height":900}) self.navigate() self.code("await page.getByRole('button',{name:'1 perfil archivado',exact:true}).click();await page.getByRole('link',{name:'Ángela · perfil archivado del mismo ordenador',exact:true}).waitFor();return true;") self.code("await page.getByRole('button',{name:'Ocultar archivados',exact:true}).click();return true;") - self.code("await page.getByRole('combobox',{name:'Seleccionar perfil',exact:true}).click();await page.getByRole('option',{name:'Íñigo · consultas de documentación',exact:true}).click();return true;") + self.code("await page.getByRole('button',{name:'Mostrar configuraciones de Íñigo · consultas de documentación',exact:true}).click();return true;") self.assertFalse(self.code("return await page.locator('#workstation-configurations').getByText('Instalación pendiente de completar.',{exact:true}).isVisible();")) - value = self.code("""const selector=page.getByRole('combobox',{name:'Seleccionar perfil',exact:true});await selector.focus();await page.context().setOffline(true);const response=page.waitForResponse(r=>r.url().includes('/api/work-profiles/')&&r.status()===200);await page.context().setOffline(false);await response;return {selection:await selector.textContent(),focused:await selector.evaluate(e=>e===document.activeElement)};""") + value = self.code("""const selector=page.getByRole('button',{name:'Ocultar configuraciones de Íñigo · consultas de documentación',exact:true});await selector.focus();await page.context().setOffline(true);const response=page.waitForResponse(r=>r.url().includes('/api/work-profiles/')&&r.status()===200);await page.context().setOffline(false);await response;return {selection:await page.locator('#workstation-configurations h3').textContent(),focused:await selector.evaluate(e=>e===document.activeElement)};""") self.assertIn("Íñigo", value["selection"]) self.assertTrue(value["focused"]) self.record("S07-refetch", value) - self.code("const region=page.getByRole('region',{name:'Personas y perfiles',exact:true});await region.locator('input[data-quick-search]').fill('SIN-COINCIDENCIAS-XYZ');return true;") - self.assertFalse(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).getByRole('link',{name:'Ángela · administración',exact:true}).isVisible();")) - self.capture("S08-no-results") + self.assertEqual(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).locator('input[data-quick-search],footer').count();"),0) + self.code("await page.getByRole('region',{name:'Personas y perfiles',exact:true}).getByRole('link',{name:'Ángela · administración',exact:true}).waitFor();return true;") + self.capture("S08-compact-summary") def test_03_history_edit_software_and_navigation(self): self.navigate() @@ -162,6 +185,7 @@ class WorkstationDashboardTests(unittest.TestCase): self.navigate(pending=False) self.code("await page.getByText('No se puede confirmar el total de pendientes.',{exact:true}).waitFor();return true;") self.assertTrue(self.code("return await page.getByRole('link',{name:'Ángela · administración',exact:true}).isVisible();")) + self.expand_primary() self.assertTrue(self.code("return await page.locator('#workstation-configurations').getByText('Instalación pendiente de completar.',{exact:true}).isVisible();")) self.capture("S05-folders-only-error") finally: @@ -191,13 +215,16 @@ class WorkstationDashboardTests(unittest.TestCase): catalog = self.api('/asset-catalog/'+company) template = next(x for x in catalog['items'] if x['values'].get('name')=='Portátil' and x['values']['kind']=='template') replacement = self.api('/assets/'+company,{'values':{'name':'DASH-S10-SWAP','templateId':template['id'],'siteId':None,'status':'operational','notes':'Fixture S10 desechable.','characteristics':{}}})['asset'] + self.navigate() + self.expand_primary() values = dict(original['values'],computerAssetId=replacement['id'],assetIds=[replacement['id']]) self.api('/assets/'+company+'/workstations/'+original['id']+'/update',{'values':values}) try: - self.navigate() + self.code("await page.context().setOffline(true);await page.context().setOffline(false);return true;") self.code("await page.getByRole('link',{name:'DASH-S10-SWAP',exact:true}).first().waitFor();return true;") self.assertFalse(self.code("return await page.getByRole('link',{name:'Ángela · administración',exact:true}).isVisible();")) self.assertEqual(self.code("return await page.locator('[data-pending-count]').textContent();"),'0') + self.assertEqual(self.code("return await page.locator('#workstation-configurations').count();"),0) self.capture('S10-replaced-computer') finally: self.api('/assets/'+company+'/workstations/'+original['id']+'/update',{'values':original['values']}) @@ -209,6 +236,7 @@ class WorkstationDashboardTests(unittest.TestCase): try: self.assertEqual(updated['accessAccountCoverage'],'unknown') self.navigate() + self.expand_primary() self.code("await page.locator('#workstation-configurations').scrollIntoViewIfNeeded();return true;") label=json.loads((ROOT/'modules/companies/src/common/locales/es.json').read_text())['companies']['folders']['accountCoverage']['unknown'] self.code("await page.locator('#workstation-configurations').getByText("+json.dumps(label)+",{exact:false}).waitFor();return true;") @@ -230,25 +258,33 @@ class WorkstationDashboardTests(unittest.TestCase): self.code("await page.getByRole('button',{name:'Reintentar',exact:true}).first().click();await page.locator('[data-pending-count=\"1\"]').waitFor();return true;") - def test_10_keyboard_contrast_and_pagination(self): + def test_10_keyboard_contrast_and_expansion(self): self.tool('browser_resize',{'width':1440,'height':900}) self.navigate() self.code("await page.getByRole('button',{name:'Ver historial',exact:true}).focus();await page.keyboard.press('Enter');await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;") self.assertTrue(self.code("return await page.getByRole('button',{name:'Ver historial',exact:true}).evaluate(e=>e.matches(':focus-visible'));")) - self.code("await page.getByRole('combobox',{name:'Seleccionar perfil',exact:true}).focus();await page.keyboard.press('ArrowDown');await page.getByRole('option').first().waitFor();await page.keyboard.press('End');await page.keyboard.press('Enter');return true;") - self.assertIn('Íñigo',self.code("return await page.getByRole('combobox',{name:'Seleccionar perfil',exact:true}).textContent();")) + self.code("await page.getByRole('button',{name:'Mostrar configuraciones de Íñigo · consultas de documentación',exact:true}).focus();await page.keyboard.press('Enter');return true;") + self.assertIn('Íñigo',self.code("return await page.locator('#workstation-configurations h3').textContent();")) + self.code("await page.keyboard.press('Space');return true;") + self.assertEqual(self.code("return await page.locator('#workstation-configurations').count();"),0) ratios=self.code("""return await page.evaluate(()=>{ const rgb=color=>{const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const context=canvas.getContext('2d');context.fillStyle=color;context.fillRect(0,0,1,1);return [...context.getImageData(0,0,1,1).data].slice(0,3)}; const lum=c=>c.map(v=>v/255).map(v=>v<=0.04045?v/12.92:((v+0.055)/1.055)**2.4).reduce((s,v,i)=>s+v*[0.2126,0.7152,0.0722][i],0); - return [...document.querySelectorAll('main header button,main a,[data-pending-count]')].filter(e=>e.getBoundingClientRect().width>0).map(e=>{const colors=[];for(let n=e;n;n=n.parentElement){colors.unshift(getComputedStyle(n).backgroundColor)}const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const context=canvas.getContext('2d');context.fillStyle='#fff';context.fillRect(0,0,1,1);for(const color of colors){context.fillStyle=color;context.fillRect(0,0,1,1)}const bg=[...context.getImageData(0,0,1,1).data].slice(0,3);const fg=rgb(getComputedStyle(e).color);const values=[lum(bg),lum(fg)].sort((a,b)=>a-b);return {label:e.textContent.trim()||e.getAttribute('aria-label'),ratio:(values[1]+0.05)/(values[0]+0.05)}}); + return [...document.querySelectorAll('main header button,main a,main button[aria-expanded][aria-controls],[data-pending-count]')].filter(e=>e.getBoundingClientRect().width>0).map(e=>{const colors=[];for(let n=e;n;n=n.parentElement){colors.unshift(getComputedStyle(n).backgroundColor)}const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const context=canvas.getContext('2d');context.fillStyle='#fff';context.fillRect(0,0,1,1);for(const color of colors){context.fillStyle=color;context.fillRect(0,0,1,1)}const bg=[...context.getImageData(0,0,1,1).data].slice(0,3);const fg=rgb(getComputedStyle(e).color);const values=[lum(bg),lum(fg)].sort((a,b)=>a-b);return {label:e.textContent.trim()||e.getAttribute('aria-label'),ratio:(values[1]+0.05)/(values[0]+0.05)}}); });""") self.record('text-contrast',ratios) self.assertTrue(all(x['ratio']>=4.5 for x in ratios),ratios) self.navigate('dense') - self.code("const region=page.getByRole('region',{name:'Personas y perfiles',exact:true});await region.getByRole('button',{name:'Página siguiente',exact:true}).click();return true;") - self.assertTrue(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).getByRole('button',{name:'Página 2',exact:true}).evaluate(e=>e.getAttribute('aria-current')==='page');")) - self.record('S04-pagination',{'profilePage':2}) - + self.assertEqual(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).locator('tbody tr[data-entity-id]').count();"),5) + self.code("const region=page.getByRole('region',{name:'Personas y perfiles',exact:true});const button=region.getByRole('button',{name:'Mostrar todos (50)',exact:true});await button.focus();await page.keyboard.press('Enter');return true;") + self.assertEqual(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).locator('tbody tr[data-entity-id]').count();"),50) + self.assertTrue(self.code("return await page.getByRole('button',{name:'Mostrar menos',exact:true}).first().evaluate(e=>e===document.activeElement);")) + self.code("await page.getByRole('button',{name:'10 perfiles archivados',exact:true}).click();return true;") + self.assertEqual(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).locator('tbody tr[data-entity-id]').count();"),60) + self.code("await page.getByRole('region',{name:'Personas y perfiles',exact:true}).getByRole('button',{name:'Mostrar menos',exact:true}).click();return true;") + self.assertEqual(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).locator('tbody tr[data-entity-id]').count();"),5) + self.code("await page.getByRole('region',{name:'Equipamiento',exact:true}).getByRole('button',{name:'Mostrar todos (80)',exact:true}).click();return true;") + self.assertEqual(self.code("return await page.getByRole('region',{name:'Equipamiento',exact:true}).locator('[data-slot=compact-list-item]').count();"),80) def test_11_save_editor_and_rename(self): company=self.fixture['companyId'] @@ -271,5 +307,505 @@ class WorkstationDashboardTests(unittest.TestCase): self.api('/assets/'+company+'/workstations/'+original['id']+'/update',{'values':original['values']}) + def test_12_context_navigation_and_full_page(self): + self.tool('browser_resize',{'width':1600,'height':1000}) + self.navigate() + before=self.code("return await page.locator('main').evaluate(e=>e.getBoundingClientRect().width);") + self.code("const button=page.getByRole('button',{name:'Ocultar secciones',exact:true});await button.focus();await page.keyboard.press('Enter');return true;") + self.code("await page.locator('[data-workspace-navigation-trigger=company]').waitFor();return true;") + after=self.code("return await page.locator('main').evaluate(e=>e.getBoundingClientRect().width);") + self.assertGreater(after,before) + self.code("const button=page.locator('[data-workspace-navigation-trigger=company]');await button.focus();await page.keyboard.press('Enter');await page.getByRole('dialog').waitFor();return true;") + self.assertTrue(self.code("return await page.getByRole('dialog').getByRole('link',{name:/^Personas/}).isVisible();")) + self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;") + self.assertTrue(self.code("return await page.locator('[data-workspace-navigation-trigger=company]').evaluate(e=>e===document.activeElement);")) + self.capture('S01-company-navigation-hidden') + self.record('company-navigation-width',{'before':before,'after':after,'automaticCollapse':False}) + self.code("await page.locator('[data-workspace-navigation-trigger=company]').click();await page.getByRole('dialog').getByRole('button',{name:'Fijar secciones',exact:true}).click();return true;") + self.code("await page.getByRole('button',{name:'Ocultar secciones',exact:true}).waitFor();return true;") + self.assertEqual(self.code("return await page.locator('main').evaluate(e=>e.getBoundingClientRect().width);"),before) + self.expand_primary() + # Solo para capturar todo el contenido: se restauran estilos y scroll al terminar. + self.code("await page.locator('main').evaluate(e=>{const saved=[];for(let n=e;n;n=n.parentElement){saved.push({element:n,style:n.getAttribute('style'),top:n.scrollTop});n.style.height='auto';n.style.maxHeight='none';n.style.overflow='visible';n.scrollTop=0;}window.__metisCaptureLayout=saved;return true;});return true;") + try: + result=self.tool('browser_take_screenshot',{'type':'png','fullPage':True,'scale':'css'}) + part=next(x for x in result['content'] if x.get('type')=='image') + data=base64.b64decode(part['data'],validate=True) + (self.output/'S01-full-page.png').write_bytes(data) + self.record('S01-full-page',{'file':'S01-full-page.png','sha256':hashlib.sha256(data).hexdigest()}) + finally: + self.code("await page.evaluate(()=>{for(const saved of window.__metisCaptureLayout){if(saved.style===null)saved.element.removeAttribute('style');else saved.element.setAttribute('style',saved.style);saved.element.scrollTop=saved.top;}delete window.__metisCaptureLayout;});return true;") + + def test_13_save_responsible_and_composition(self): + company=self.fixture['companyId'] + inventory=self.api('/assets/'+company) + original=next(x for x in inventory['workstations'] if x['id']==self.fixture['main']) + secondary=next(x for x in self.api('/work-profiles/'+company)['profiles'] if x['id']==self.fixture['secondaryProfile']) + person=next(x for x in self.api('/companies/'+company)['company']['people'] if x['id']==secondary['values']['personId']) + peripheral=next(x for x in inventory['assets'] if x['id'] in original['values']['assetIds'] and x['id']!=original['values']['computerAssetId']) + self.navigate() + try: + self.code("await page.getByRole('button',{name:'Editar puesto',exact:true}).first().click();await page.getByRole('dialog').getByRole('combobox',{name:'Responsable del puesto',exact:true}).click();await page.getByRole('option',{name:"+json.dumps(person['name'])+",exact:true}).click();return true;") + self.code("await page.getByRole('dialog').getByRole('button',{name:"+json.dumps('Retirar '+peripheral['values']['name']+' del puesto')+",exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:'Guardar',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;") + saved=next(x for x in self.api('/assets/'+company)['workstations'] if x['id']==original['id']) + self.assertEqual(saved['values']['personId'],person['id']) + self.assertNotIn(peripheral['id'],saved['values']['assetIds']) + self.assertEqual(saved['values']['computerAssetId'],original['values']['computerAssetId']) + self.assertTrue(self.code("return await page.locator('main header').first().getByRole('link',{name:"+json.dumps(person['name'])+",exact:true}).isVisible();")) + self.record('responsible-and-composition-save',{'saved':True,'mainPreserved':True}) + finally: + self.api('/assets/'+company+'/workstations/'+original['id']+'/update',{'values':original['values']}) + + def test_14_create_profile_from_compact_header(self): + company=self.fixture['companyId'] + profile_name='DASH-REFINEMENT-UI-CREATED' + def cleanup(): + for profile in self.api('/work-profiles/'+company)['profiles']: + if profile['values']['name']==profile_name: + if profile['values']['active']: + self.api('/work-profiles/'+company+'/'+profile['id']+'/deactivate',{}) + self.api('/work-profiles/'+company+'/'+profile['id']+'/delete',{}) + + cleanup() + self.navigate() + try: + self.code("await page.getByRole('button',{name:'Nuevo perfil',exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:'Siguiente',exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:'Seleccionar persona',exact:true}).click();return true;") + self.code("const picker=page.getByRole('dialog').last();await picker.locator('input[data-quick-search]').fill('DASH-PERSON-ACTIVE');await picker.getByRole('radio').first().click();await picker.getByRole('button',{name:'Seleccionar',exact:true}).click();return true;") + self.code("await page.getByRole('dialog').getByRole('button',{name:'Siguiente',exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:'Siguiente',exact:true}).click();await page.getByRole('dialog').getByRole('textbox',{name:'Nombre del perfil',exact:true}).fill("+json.dumps(profile_name)+");await page.getByRole('dialog').getByRole('button',{name:'Guardar',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;") + created=next(x for x in self.api('/work-profiles/'+company)['profiles'] if x['values']['name']==profile_name) + self.assertEqual(created['values']['assetId'],self.fixture['host']) + self.assertTrue(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).getByRole('link',{name:"+json.dumps(profile_name)+",exact:true}).isVisible();")) + self.record('profile-created-from-compact-header',{'created':True,'mainComputerMatched':True}) + finally: + self.code("await page.keyboard.press('Escape');return true;") + cleanup() + + + + def test_15_long_company_name_and_mobile_table(self): + company=self.fixture['companyId'] + long_name='Compañía internacional de documentación técnica, investigación y soporte informático especializado' + self.tool('browser_resize',{'width':390,'height':844}) + self.code("await page.route('**/api/companies/"+company+"',async route=>{const response=await route.fetch();const data=await response.json();data.company.name="+json.dumps(long_name)+";await route.fulfill({response,json:data});});return true;") + try: + self.navigate() + self.code("await page.locator('main header').first().scrollIntoViewIfNeeded();return true;") + self.assertTrue(self.code("return await page.locator('main header').first().getByRole('link',{name:"+json.dumps(long_name)+",exact:true}).isVisible();")) + self.assertTrue(self.code("return await page.evaluate(()=>document.documentElement.scrollWidth===innerWidth);")) + self.capture('S01-long-company-390') + self.code("await page.getByRole('region',{name:'Personas y perfiles',exact:true}).scrollIntoViewIfNeeded();const table=page.locator('[data-slot=compact-table] [data-slot=table-container]');await table.evaluate(e=>e.scrollLeft=e.scrollWidth);return true;") + self.assertTrue(self.code("return await page.getByRole('columnheader',{name:'Configuraciones',exact:true}).isVisible();")) + self.capture('S01-profiles-390') + finally: + self.code("await page.unroute('**/api/companies/"+company+"');return true;") + + + def test_16_equipment_without_principal(self): + company=self.fixture['companyId'] + original=next(x for x in self.api('/assets/'+company)['workstations'] if x['id']==self.fixture['main']) + self.api('/assets/'+company+'/workstations/'+original['id']+'/update',{'values':dict(original['values'],computerAssetId=None)}) + try: + self.tool('browser_resize',{'width':1440,'height':900}) + self.navigate() + equipment="page.getByRole('region',{name:'Equipamiento',exact:true})" + self.assertEqual(self.code('return await '+equipment+".locator('[data-slot=compact-list-item]').count();"),2) + self.assertEqual(self.code('return await '+equipment+".getByText('Principal',{exact:true}).count();"),0) + label=json.loads((ROOT/'modules/assets/src/common/locales/es.json').read_text())['workstationDetail']['noComputer'] + self.assertTrue(self.code('return await '+equipment+'.getByText('+json.dumps(label)+',{exact:true}).isVisible();')) + self.assertFalse(self.code("return await page.getByRole('button',{name:'Gestionar software',exact:true}).isVisible();")) + self.capture('S12-members-without-principal') + finally: + self.api('/assets/'+company+'/workstations/'+original['id']+'/update',{'values':original['values']}) + + def test_17_inconsistent_or_unresolved_principal(self): + company=self.fixture['companyId'] + labels=json.loads((ROOT/'modules/assets/src/common/locales/es.json').read_text()) + for scenario in ['outside','unresolved','foreign']: + patch=("station.values.computerAssetId="+json.dumps(self.fixture['lab'])+";" if scenario=='outside' else + "data.assets=data.assets.filter(asset=>asset.id!=="+json.dumps(self.fixture['host'])+");" if scenario=='unresolved' else + "data.assets=data.assets.map(asset=>asset.id==="+json.dumps(self.fixture['host'])+"?{...asset,companyId:"+json.dumps(self.fixture['otherCompany'])+"}:asset);") + self.code("await page.route('**/api/assets/"+company+"',async route=>{const response=await route.fetch();const data=await response.json();const station=data.workstations.find(x=>x.id==="+json.dumps(self.fixture['main'])+");"+patch+"await route.fulfill({response,json:data});});return true;") + try: + self.navigate() + region="page.getByRole('region',{name:'Equipamiento',exact:true})" + label=labels['workstationDashboard']['principalOutsideComposition' if scenario=='outside' else 'principalUnavailable'] + self.code('await '+region+'.getByText('+json.dumps(label)+',{exact:true}).waitFor();return true;') + self.assertEqual(self.code('return await '+region+".getByText('Principal',{exact:true}).count();"),0) + self.assertEqual(self.code('return await '+region+".locator('[data-slot=compact-list-item]').count();"),2) + self.capture('S13-principal-'+scenario) + finally: + self.code("await page.unroute('**/api/assets/"+company+"');return true;") + + def test_18_equipment_inventory_failure_and_recovery(self): + company=self.fixture['companyId'] + labels=json.loads((ROOT/'modules/assets/src/common/locales/es.json').read_text()) + self.tool('browser_resize',{'width':1440,'height':900}) + self.code("await page.route('**/api/assets/"+company+"',route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));return true;") + try: + self.tool('browser_navigate',{'url':self.origin+self.base+'/workstations/'+self.fixture['main']}) + self.code("await page.getByRole('region',{name:'Equipamiento',exact:true}).getByRole('button',{name:'Reintentar',exact:true}).waitFor();return true;") + region="page.getByRole('region',{name:'Equipamiento',exact:true})" + self.assertFalse(self.code('return await '+region+'.getByText('+json.dumps(labels['noWorkstationAssets'])+',{exact:true}).isVisible();')) + self.capture('S14-equipment-unavailable') + finally: + self.code("await page.unroute('**/api/assets/"+company+"');return true;") + self.code("await page.getByRole('region',{name:'Equipamiento',exact:true}).getByRole('button',{name:'Reintentar',exact:true}).click();await page.getByRole('region',{name:'Equipamiento',exact:true}).getByText('Principal',{exact:true}).waitFor();return true;") + self.assertEqual(self.code("return await page.getByRole('heading',{name:'Equipamiento',exact:true}).count();"),1) + self.capture('S14-equipment-recovered') + + + def test_19_equipment_expansion_survives_refetch(self): + self.tool('browser_resize',{'width':1440,'height':900}) + self.navigate('dense') + self.code("const region=page.getByRole('region',{name:'Equipamiento',exact:true});await region.getByRole('button',{name:'Mostrar todos (80)',exact:true}).click();const button=region.getByRole('button',{name:'Mostrar menos',exact:true});await button.focus();await page.context().setOffline(true);const response=page.waitForResponse(r=>r.url().includes('/api/assets/')&&r.status()===200);await page.context().setOffline(false);await response;return true;") + values=self.code("const region=page.getByRole('region',{name:'Equipamiento',exact:true});return {members:await region.locator('[data-slot=compact-list-item]').count(),focused:await region.getByRole('button',{name:'Mostrar menos',exact:true}).evaluate(e=>e===document.activeElement)};") + self.assertEqual(values['members'],80) + self.assertTrue(values['focused']) + self.record('equipment-refetch',values) + + def test_20_equipment_links_keyboard_and_return(self): + company=self.fixture['companyId'] + inventory=self.api('/assets/'+company) + station=next(x for x in inventory['workstations'] if x['id']==self.fixture['main']) + for asset_id in station['values']['assetIds']: + self.navigate() + asset=next(x for x in inventory['assets'] if x['id']==asset_id) + self.code("const link=page.getByRole('region',{name:'Equipamiento',exact:true}).getByRole('link',{name:"+json.dumps(asset['values']['name'])+",exact:true});await link.focus();await page.keyboard.press('Enter');await page.locator('main h1').waitFor();return true;") + self.assertIn('/assets/'+asset_id,self.code('return page.url();')) + self.code("await page.goBack();await page.getByRole('region',{name:'Equipamiento',exact:true}).waitFor();return true;") + self.assertIn('/workstations/'+self.fixture['main'],self.code('return page.url();')) + self.assertEqual(self.code("return await page.getByRole('region',{name:'Equipamiento',exact:true}).getByText('Principal',{exact:true}).count();"),1) + + def test_21_integrated_single_expansion(self): + self.tool('browser_resize',{'width':1440,'height':900}) + self.navigate() + self.assertEqual(self.code("return await page.locator('#workstation-configurations').count();"),0) + self.assertEqual(self.code("return await page.getByRole('combobox',{name:'Seleccionar perfil',exact:true}).count();"),0) + self.assertEqual(self.code("return await page.getByRole('heading',{name:'Configuraciones por perfil',exact:true}).count();"),0) + self.expand_primary() + values=self.code("const region=page.getByRole('region',{name:'Personas y perfiles',exact:true});const button=region.getByRole('button',{name:'Ocultar configuraciones de Ángela · administración',exact:true});return {details:await region.locator('#workstation-configurations').count(),families:await region.locator('#workstation-configurations section').count(),expanded:await button.getAttribute('aria-expanded'),target:await page.locator('[id='+JSON.stringify(await button.getAttribute('aria-controls'))+']').count()};") + self.assertEqual(values,{'details':1,'families':5,'expanded':'true','target':1}) + self.capture('S01-integrated-expanded') + self.code("await page.getByRole('button',{name:'Ocultar configuraciones de Ángela · administración',exact:true}).click();return true;") + self.assertEqual(self.code("return await page.locator('#workstation-configurations').count();"),0) + self.code("await page.getByRole('button',{name:'Mostrar configuraciones de Íñigo · consultas de documentación',exact:true}).click();return true;") + self.assertTrue(self.code("return await page.getByText('Este perfil no tiene configuraciones adicionales registradas.',{exact:true}).isVisible();")) + self.assertEqual(self.code("return await page.locator('#workstation-configurations a').count();"),6) + self.capture('S01-profile-without-configurations') + self.expand_primary() + self.assertEqual(self.code("return await page.locator('#workstation-configurations').count();"),1) + self.assertEqual(self.code("return await page.locator('button[aria-expanded=true][aria-controls]').filter({hasText:'Ocultar'}).count();"),1) + self.code("await page.getByRole('button',{name:'1 perfil archivado',exact:true}).click();return true;") + archived=self.code("const row=page.getByRole('link',{name:'Ángela · perfil archivado del mismo ordenador',exact:true}).locator('xpath=ancestor::tr');return {buttons:await row.getByRole('button').count(),link:await row.getByRole('link').first().getAttribute('href')};") + self.assertEqual(archived['buttons'],0) + self.assertIn('/work-profiles/',archived['link']) + self.record('WD042-single-expansion',values) + + def test_22_removed_profile_closes_without_fallback(self): + self.navigate() + self.expand_primary() + company=self.fixture['companyId'] + profile=self.fixture['primaryProfile'] + self.code("await page.route('**/api/work-profiles/"+company+"',async route=>{const response=await route.fetch();const body=await response.json();body.profiles=body.profiles.map(p=>p.id==="+json.dumps(profile)+"?{...p,values:{...p.values,active:false}}:p);await route.fulfill({response,json:body})});return true;") + try: + self.code("await page.locator('#workstation-configurations').focus();await page.context().setOffline(true);await page.context().setOffline(false);await page.getByText('El perfil abierto se ha archivado o ya no pertenece a este ordenador. Selecciona otro perfil para consultar sus configuraciones.',{exact:true}).waitFor();return true;") + self.assertEqual(self.code("return await page.locator('#workstation-configurations').count();"),0) + self.assertFalse(self.code("return await page.getByRole('button',{name:'Mostrar configuraciones de Íñigo · consultas de documentación',exact:true}).getAttribute('aria-expanded')==='true';")) + self.code("await page.waitForFunction(()=>document.activeElement?.getAttribute('role')==='status');return true;") + self.capture('WD042-profile-archived-while-open') + finally: + self.code("await page.unroute('**/api/work-profiles/"+company+"');return true;") + + def test_23_real_certificate_save_and_family_routes(self): + company=self.fixture['companyId'] + original=next(x for x in self.api('/certificate-configurations/'+company)['configurations'] if x['values']['profileId']==self.fixture['primaryProfile']) + self.navigate() + self.expand_primary() + routes=self.code("return await page.locator('#workstation-configurations section a').evaluateAll(links=>links.map(link=>link.getAttribute('href')));") + expected=['folder-mappings','mail-configurations','application-configurations','printer-configurations','certificate-configurations'] + self.assertEqual([x.rsplit('/',1)[-1] for x in routes],expected) + try: + self.code("await page.locator('#workstation-configurations section').filter({has:page.getByRole('heading',{name:'Certificados del perfil',exact:true})}).getByRole('link',{name:'Gestionar',exact:true}).click();return true;") + self.code("await page.getByRole('button',{name:'Editar',exact:true}).first().click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('textbox',{name:'Notas',exact:true}).fill('Guardado real WD04.2');await page.getByRole('dialog').getByRole('button',{name:'Guardar',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;") + current=next(x for x in self.api('/certificate-configurations/'+company)['configurations'] if x['id']==original['id']) + self.assertEqual(current['values']['notes'],'Guardado real WD04.2') + self.navigate() + self.expand_primary() + self.code("await page.locator('#workstation-configurations').getByText('Guardado real WD04.2',{exact:true}).waitFor();return true;") + self.capture('WD042-certificate-saved') + self.record('WD042-family-routes',routes) + finally: + current=next(x for x in self.api('/certificate-configurations/'+company)['configurations'] if x['id']==original['id']) + if current['values']!=original['values']: + self.api('/certificate-configurations/'+company+'/'+original['id']+'/update',{'values':original['values']}) + + + def test_24_multiple_pending_and_dense_context(self): + company=self.fixture['companyId'] + original=next(x for x in self.api('/certificate-configurations/'+company)['configurations'] if x['values']['profileId']==self.fixture['primaryProfile']) + profiles=self.api('/work-profiles/'+company)['profiles'] + station=next(x for x in self.api('/assets/'+company)['workstations'] if x['id']==self.fixture['dense']) + dense=sorted((x for x in profiles if x['values']['active'] and x['values']['assetId']==station['values']['computerAssetId']),key=lambda x:x['values']['name'])[-1] + created=[] + try: + for profile_id in [self.fixture['secondaryProfile'],dense['id']]: + result=self.api('/certificate-configurations/'+company,{'values':dict(original['values'],profileId=profile_id,notes='Pendiente sintético WD04.2')}) + created.append(result['configuration']) + self.navigate() + self.assertEqual(self.code("return await page.locator('[data-pending-count]').textContent();"),'2') + self.code("await page.getByRole('button',{name:'Ver configuraciones pendientes',exact:true}).click();return true;") + self.assertIn('Ángela',self.code("return await page.locator('#workstation-configurations h3').textContent();")) + self.code("await page.getByRole('button',{name:'Mostrar configuraciones de Íñigo · consultas de documentación',exact:true}).click();return true;") + self.assertTrue(self.code("return await page.locator('#workstation-configurations').getByText('Pendiente sintético WD04.2',{exact:true}).isVisible();")) + self.assertEqual(self.code("return await page.locator('[data-pending-count]').textContent();"),'2') + self.navigate('dense') + self.code("await page.getByRole('button',{name:'Ver configuraciones pendientes',exact:true}).click();return true;") + self.assertIn(dense['values']['name'],self.code("return await page.locator('#workstation-configurations h3').textContent();")) + self.assertEqual(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).locator('tbody tr[data-entity-id]').count();"),6) + self.assertEqual(self.code("return await page.locator('#workstation-configurations').count();"),1) + self.assertTrue(self.code("return await page.locator('#workstation-configurations').evaluate(e=>e===document.activeElement);")) + self.capture('S04-pending-outside-preview') + finally: + for configuration in created: + self.api('/certificate-configurations/'+company+'/'+configuration['id']+'/delete',{}) + + + def test_25_loading_metadata_and_aggregate_queries(self): + company=self.fixture['companyId'] + self.code("page.__wd042Requests=[];page.on('request',r=>{const path=new URL(r.url()).pathname;if(['folder-mappings','mail-configurations','application-configurations','printer-configurations','certificate-configurations'].some(kind=>path.startsWith('/api/'+kind+'/')))page.__wd042Requests.push(path)});await page.route('**/api/certificate-configurations/"+company+"',route=>{page.__heldCertificateRoute=route});return true;") + try: + self.navigate(pending=False) + self.expand_primary() + self.code("await page.locator('#workstation-configurations').getByRole('status',{name:'Cargando Certificados del perfil',exact:true}).waitFor();return true;") + self.assertEqual(self.code("return await page.locator('[data-pending-count]').count();"),0) + self.assertTrue(self.code("return await page.getByRole('region',{name:'Personas y perfiles',exact:true}).getByRole('link',{name:'Ángela · administración',exact:true}).isVisible();")) + self.capture('WD042-detail-loading') + finally: + self.code("if(page.__heldCertificateRoute){await page.__heldCertificateRoute.continue();delete page.__heldCertificateRoute}await page.unroute('**/api/certificate-configurations/"+company+"');return true;") + self.code("await page.locator('[data-pending-count]').waitFor();return true;") + before=self.code("return page.__wd042Requests.length;") + self.code("await page.getByRole('button',{name:'Mostrar configuraciones de Íñigo · consultas de documentación',exact:true}).click();await page.getByRole('button',{name:'Mostrar configuraciones de Ángela · administración',exact:true}).click();return true;") + after=self.code("return page.__wd042Requests.length;") + self.assertEqual(before,after) + self.record('WD042-aggregate-queries',{'beforeExpansionChanges':before,'afterExpansionChanges':after}) + self.code("await page.route('**/api/resources/"+company+"',route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));return true;") + try: + self.navigate() + self.expand_primary() + self.code("await page.locator('#workstation-configurations').getByRole('alert').waitFor();return true;") + self.assertTrue(self.code("return await page.locator('#workstation-configurations').getByText('IMP-RED-01 · Impresora de planta compartida',{exact:true}).isVisible();")) + self.assertEqual(self.code("return await page.locator('#workstation-configurations').getByRole('region',{name:'Certificados del perfil',exact:true}).locator('[data-slot=compact-list-item]').count();"),0) + self.capture('WD042-resources-unavailable') + finally: + self.code("await page.unroute('**/api/resources/"+company+"');return true;") + self.code("await page.locator('#workstation-configurations').getByRole('button',{name:'Reintentar',exact:true}).click();await page.locator('#workstation-configurations').getByText('Instalación pendiente de completar.',{exact:true}).waitFor();return true;") + + def test_26_expanded_geometry_and_dialog_refetch(self): + 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.navigate() + self.expand_primary() + self.code("await page.locator('#workstation-configurations').getByText('Instalación pendiente de completar.',{exact:true}).waitFor();return true;") + values=self.code("return await page.evaluate(()=>({width:innerWidth,documentWidth:document.documentElement.scrollWidth,mainHeight:document.querySelector('main').scrollHeight,cards:[...document.querySelectorAll('main section[aria-labelledby]')].filter(e=>e.querySelector('h2')).map(e=>({title:e.querySelector('h2').textContent.trim(),height:e.getBoundingClientRect().height}))}));") + self.assertLessEqual(values['documentWidth'],width) + self.record('WD042-expanded-geometry-'+str(width),values) + self.code("await page.locator('#workstation-configurations').scrollIntoViewIfNeeded();return true;") + self.capture('S01-expanded-'+str(width)) + self.tool('browser_resize',{'width':1440,'height':900}) + self.navigate() + self.code("await page.getByRole('button',{name:'Ver configuraciones pendientes',exact:true}).click();await page.getByRole('button',{name:'Editar puesto',exact:true}).first().click();await page.getByRole('dialog').getByRole('button',{name:'Guardar',exact:true}).click();await page.getByRole('dialog').waitFor({state:'hidden'});return true;") + self.assertEqual(self.code("return await page.locator('#workstation-configurations').count();"),1) + self.assertTrue(self.code("return await page.getByRole('checkbox',{name:'Solo pendientes',exact:true}).isChecked();")) + self.code("await page.context().setOffline(true);const response=page.waitForResponse(r=>r.url().includes('/api/certificate-configurations/')&&r.status()===200);await page.context().setOffline(false);await response;return true;") + self.assertTrue(self.code("return await page.getByRole('checkbox',{name:'Solo pendientes',exact:true}).isChecked();")) + self.assertIn('Ángela',self.code("return await page.locator('#workstation-configurations h3').textContent();")) + + + def test_27_family_navigation_and_profile_edit(self): + self.tool('browser_resize',{'width':1440,'height':900}) + company=self.fixture['companyId'] + original=next(p for p in self.api('/work-profiles/'+company)['profiles'] if p['id']==self.fixture['primaryProfile']) + titles=['Carpetas del perfil','Correos en dispositivos','Aplicaciones del perfil','Impresoras del perfil','Certificados del perfil'] + for title in titles: + self.navigate() + self.expand_primary() + self.code("await page.locator('#workstation-configurations section').filter({has:page.getByRole('heading',{name:"+json.dumps(title)+",exact:true})}).getByRole('link',{name:'Gestionar',exact:true}).click();await page.getByRole('heading',{name:"+json.dumps(title)+",exact:true}).waitFor();return true;") + self.navigate() + self.code("await page.getByRole('region',{name:'Personas y perfiles',exact:true}).getByRole('link',{name:'Ángela · administración',exact:true}).click();await page.locator('a[href='+JSON.stringify("+json.dumps(self.base+'/work-profiles')+")+']').first().click();return true;") + try: + self.code("await page.locator('input[data-quick-search]').fill('Ángela · administración');return true;") + self.code("await page.locator('tr[data-entity-id='+JSON.stringify("+json.dumps(original['id'])+")+']').getByRole('button',{name:'Editar',exact:true}).click();await page.getByRole('dialog').waitFor();return true;") + self.code("await page.getByRole('dialog').getByRole('textbox',{name:'Nombre del perfil',exact:true}).fill('Ángela · edición WD04.2');return true;") + self.code("await page.getByRole('dialog').getByRole('button',{name:'Guardar',exact:true}).click();return true;") + self.code("await page.getByRole('dialog').waitFor({state:'hidden'});return true;") + current=next(p for p in self.api('/work-profiles/'+company)['profiles'] if p['id']==original['id']) + self.assertEqual(current['values']['name'],'Ángela · edición WD04.2') + self.navigate() + self.code("await page.getByRole('button',{name:'Mostrar configuraciones de Ángela · edición WD04.2',exact:true}).click();return true;") + self.assertIn('edición WD04.2',self.code("return await page.locator('#workstation-configurations h3').textContent();")) + self.record('WD042-profile-edited',{'saved':True,'sameProfileId':current['id']==original['id']}) + finally: + current=next(p for p in self.api('/work-profiles/'+company)['profiles'] if p['id']==original['id']) + if current['values']!=original['values']: + self.api('/work-profiles/'+company+'/'+original['id']+'/update',{'values':original['values']}) + + + def test_28_mobile_profile_controls_remain_visible(self): + self.tool('browser_resize',{'width':390,'height':844}) + self.navigate() + self.code("await page.getByRole('region',{name:'Personas y perfiles',exact:true}).scrollIntoViewIfNeeded();return true;") + values=self.code("const region=page.getByRole('region',{name:'Personas y perfiles',exact:true});const button=region.getByRole('button',{name:'Mostrar configuraciones de Ángela · administración',exact:true});const box=await button.boundingBox();const table=region.locator('[data-slot=table-container]');return {buttonX:box.x,buttonRight:box.x+box.width,width:await page.evaluate(()=>innerWidth),table:await table.evaluate(e=>({width:e.clientWidth,scroll:e.scrollWidth,left:e.scrollLeft})),person:await region.getByRole('link',{name:'Ángela Muñoz de la Peña',exact:true}).isVisible(),status:await region.getByText('Activo',{exact:true}).evaluateAll(elements=>elements.filter(e=>e.checkVisibility()).length)};") + self.assertGreaterEqual(values['buttonX'],0) + self.assertLessEqual(values['buttonRight'],values['width']) + self.assertLessEqual(values['table']['scroll'],values['table']['width']) + self.assertEqual(values['table']['left'],0) + self.assertTrue(values['person']) + self.assertGreaterEqual(values['status'],2) + self.capture('S01-mobile-profile-controls') + focus=self.code("const region=page.getByRole('region',{name:'Personas y perfiles',exact:true});await region.getByRole('link',{name:'Ángela · administración',exact:true}).focus();await page.keyboard.press('Tab');const person=await page.evaluate(()=>document.activeElement?.getAttribute('href'));await page.keyboard.press('Tab');const account=await page.evaluate(()=>document.activeElement?.getAttribute('href'));await page.keyboard.press('Tab');const expansion=await page.evaluate(()=>document.activeElement?.getAttribute('aria-label'));await page.keyboard.press('Space');return {person,account,expansion};") + self.assertIn('/people/',focus['person']) + self.assertTrue(focus['account'].endswith('/access?kind=account')) + self.assertEqual(focus['expansion'],'Mostrar configuraciones de Ángela · administración') + self.assertEqual(self.code("return await page.locator('#workstation-configurations').count();"),1) + self.capture('S01-mobile-expanded-final') + self.record('WD042-mobile-tab-order',focus) + self.assertTrue(self.code("return await page.evaluate(()=>document.documentElement.scrollWidth===innerWidth);")) + self.record('WD042-mobile-controls',values) + + def open_organization(self): + self.code("await page.locator('main header').first().getByRole('button',{name:/^Consultar contexto organizativo de /}).click();await page.locator('[data-slot=popover-content]').waitFor();return true;") + + def test_29_organization_responsive_and_keyboard(self): + self.code("page.__organizationRequests=[];page.on('request',r=>{if(new URL(r.url()).pathname==="+json.dumps('/api/companies/'+self.fixture['companyId'])+")page.__organizationRequests.push(r.method())});return true;") + 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.navigate() + before=self.code("return page.__organizationRequests.length;") + self.code("const trigger=page.locator('main header').first().getByRole('button',{name:/^Consultar contexto organizativo de /});await trigger.focus();await page.keyboard.press('Enter');await page.locator('[data-slot=popover-content]').waitFor();return true;") + values=self.code("const popup=page.locator('[data-slot=popover-content]');const trigger=page.locator('main header').first().getByRole('button',{name:/^Consultar contexto organizativo de /});return {box:await popup.boundingBox(),width:await page.evaluate(()=>innerWidth),height:await page.evaluate(()=>innerHeight),expanded:await trigger.getAttribute('aria-expanded'),controls:await trigger.getAttribute('aria-controls'),role:await popup.getAttribute('role'),nested:await page.locator('main header a button,main header button a').count(),fields:await popup.locator('dd').allTextContents()};") + self.assertEqual(values['expanded'],'true') + self.assertEqual(values['role'],'dialog') + self.assertTrue(values['controls']) + self.assertEqual(values['nested'],0) + self.assertIn('Administración y gestión documental',values['fields']) + self.assertIn('Responsable de documentación',values['fields']) + self.assertGreaterEqual(values['box']['x'],0) + self.assertGreaterEqual(values['box']['y'],0) + self.assertLessEqual(values['box']['x']+values['box']['width'],width) + self.assertLessEqual(values['box']['y']+values['box']['height'],height) + self.record('WD043-popup-'+str(width),values) + self.capture('S01-organization-'+str(width)) + self.code("await page.keyboard.press('Escape');await page.locator('[data-slot=popover-content]').waitFor({state:'hidden'});return true;") + self.assertTrue(self.code("return await page.locator('main header').first().getByRole('button',{name:/^Consultar contexto organizativo de /}).evaluate(e=>e===document.activeElement);")) + self.code("await page.keyboard.press('Space');await page.locator('[data-slot=popover-content]').waitFor();await page.keyboard.press('Escape');return true;") + self.assertEqual(before,self.code("return page.__organizationRequests.length;")) + + def test_30_without_responsible_and_decommissioned(self): + for key in ['withoutResponsible','retired']: + self.navigate(key) + self.assertTrue(self.code("return await page.locator('main header').first().getByText(/^Responsable del puesto: *Sin responsable$/).isVisible();")) + self.assertEqual(self.code("return await page.getByRole('button',{name:/^Consultar contexto organizativo de /}).count();"),0) + self.assertEqual(self.code("return await page.getByRole('heading',{name:'Responsable y contexto',exact:true}).count();"),0) + self.capture('WD043-'+key) + self.code("await page.getByRole('button',{name:'Ver historial',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');return true;") + + def test_31_organization_stale_retry_and_refresh(self): + company=self.fixture['companyId'] + self.tool('browser_resize',{'width':1440,'height':900}) + self.navigate() + self.open_organization() + self.code("await page.locator('[data-slot=popover-content]').getByRole('link',{name:'Abrir ficha de persona',exact:true}).focus();await page.route('**/api/companies/"+company+"',route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:'INTERNAL_ERROR'})}));return true;") + try: + self.code("await page.context().setOffline(true);await page.context().setOffline(false);await page.locator('[data-slot=popover-content]').getByRole('alert').waitFor();return true;") + self.assertTrue(self.code("return await page.locator('[data-slot=popover-content]').getByText('Responsable de documentación',{exact:true}).isVisible();")) + self.assertTrue(self.code("return await page.locator('[data-slot=popover-content]').getByRole('link',{name:'Abrir ficha de persona',exact:true}).evaluate(e=>e===document.activeElement);")) + self.capture('WD043-organizational-source-error') + finally: + self.code("await page.unroute('**/api/companies/"+company+"');return true;") + self.code("await page.locator('[data-slot=popover-content]').getByRole('button',{name:'Reintentar',exact:true}).click();return true;") + self.code("await page.locator('[data-slot=popover-content]').getByRole('alert').waitFor({state:'hidden'});return true;") + self.record('WD043-retry-focus',self.code("return await page.evaluate(()=>({tag:document.activeElement?.tagName,slot:document.activeElement?.getAttribute('data-slot'),text:document.activeElement?.textContent}));")) + self.code("await page.waitForFunction(()=>document.activeElement?.matches('[data-slot=popover-title]'));return true;") + refresh=self.code("const pattern='**/api/companies/"+company+"';const releases=[];let holding=true;await page.route(pattern,async route=>{if(holding)await new Promise(resolve=>releases.push(resolve));await route.continue()});try{const response=page.waitForResponse(r=>new URL(r.url()).pathname==="+json.dumps('/api/companies/'+company)+"&&r.status()===200);await page.context().setOffline(true);await page.context().setOffline(false);await page.locator('[data-slot=popover-content]').getByRole('status').waitFor();const retained=await page.locator('[data-slot=popover-content]').getByText('Responsable de documentación',{exact:true}).isVisible();holding=false;for(const release of releases)release();await response;await page.locator('[data-slot=popover-content]').getByRole('status').waitFor({state:'hidden'});await page.keyboard.press('Escape');return {retained,recovered:true}}finally{holding=false;for(const release of releases)release();await page.unroute(pattern)}") + self.assertTrue(refresh['retained']) + self.assertTrue(refresh['recovered']) + self.record('WD043-refresh-retained',refresh) + + source=self.api('/companies/'+company)['company'] + next(x for x in source['assignments'] if x['position']=='Responsable de documentación')['position']='Cargo organizativo actualizado WD04.3' + self.open_organization() + self.code("await page.locator('[data-slot=popover-content]').getByRole('link',{name:'Abrir ficha de persona',exact:true}).focus();await page.route('**/api/companies/"+company+"',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({company:"+json.dumps(source)+"})}));return true;") + try: + self.code("await page.context().setOffline(true);await page.context().setOffline(false);await page.locator('[data-slot=popover-content]').getByText('Cargo organizativo actualizado WD04.3',{exact:true}).waitFor();return true;") + self.assertTrue(self.code("return await page.locator('[data-slot=popover-content]').getByRole('link',{name:'Abrir ficha de persona',exact:true}).evaluate(e=>e===document.activeElement);")) + self.capture('WD043-organization-updated') + ratios=self.code("""return await page.evaluate(()=>{ + const rgb=color=>{const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const context=canvas.getContext('2d');context.fillStyle=color;context.fillRect(0,0,1,1);return [...context.getImageData(0,0,1,1).data].slice(0,3)}; + const lum=c=>c.map(v=>v/255).map(v=>v<=0.04045?v/12.92:((v+0.055)/1.055)**2.4).reduce((s,v,i)=>s+v*[0.2126,0.7152,0.0722][i],0); + return [...document.querySelectorAll('[data-slot=popover-content] h2,[data-slot=popover-content] p,[data-slot=popover-content] dt,[data-slot=popover-content] dd,[data-slot=popover-content] a,[data-slot=popover-content] button,[data-slot=popover-content] [data-slot=badge]')].filter(e=>e.getBoundingClientRect().width>0).map(e=>{const colors=[];for(let n=e;n;n=n.parentElement){colors.unshift(getComputedStyle(n).backgroundColor)}const canvas=document.createElement('canvas');canvas.width=canvas.height=1;const context=canvas.getContext('2d');context.fillStyle='#fff';context.fillRect(0,0,1,1);for(const color of colors){context.fillStyle=color;context.fillRect(0,0,1,1)}const bg=[...context.getImageData(0,0,1,1).data].slice(0,3);const fg=rgb(getComputedStyle(e).color);const values=[lum(bg),lum(fg)].sort((a,b)=>a-b);return {label:e.textContent.trim()||e.getAttribute('aria-label'),ratio:(values[1]+0.05)/(values[0]+0.05)}}); + });""") + self.assertTrue(all(x['ratio']>=4.5 for x in ratios),ratios) + self.record('WD043-popup-contrast',ratios) + finally: + self.code("await page.unroute('**/api/companies/"+company+"');return true;") + + def test_32_multiple_historical_empty_and_long_organization(self): + company=self.fixture['companyId'] + original=self.api('/companies/'+company)['company'] + station=next(x for x in self.api('/assets/'+company)['workstations'] if x['id']==self.fixture['main']) + person_id=station['values']['personId'] + assignment=next(x for x in original['assignments'] if x['personId']==person_id) + import copy + for mode in ['empty','historical','multiple','ambiguous','long']: + data=copy.deepcopy(original) + data['assignments']=[x for x in data['assignments'] if x['personId']!=person_id] + if mode=='historical': + data['assignments'].extend([dict(assignment,startDate='2000-01-01',endDate='2001-01-01'),dict(assignment,id='019a0a00-0000-7000-8000-000000000701',startDate='2098-01-01',endDate='2099-01-01')]) + if mode in ['multiple','ambiguous']: + department=dict(data['departments'][0],id='019a0a00-0000-7000-8000-000000000702',name='Departamento secundario de pruebas') + data['departments'].append(department) + data['assignments'].extend([dict(assignment,endDate='2099-01-01' if mode=='ambiguous' else None),dict(assignment,id='019a0a00-0000-7000-8000-000000000703',departmentId=department['id'],isPrimary=mode=='ambiguous',position='Coordinación documental secundaria',endDate='2099-01-01' if mode=='ambiguous' else None)]) + if mode=='long': + data['name']='Compañía internacional de documentación técnica, investigación y soporte informático especializado' + next(x for x in data['people'] if x['id']==person_id)['name']='Ángela Muñoz de la Peña Fernández del Río — documentación técnica y atención administrativa' + next(x for x in data['sites'] if x['id']==station['values']['siteId'])['name']='Sede administrativa y técnica central — documentación, investigación y soporte de operaciones' + data['departments'][0]['name']='Administración, documentación técnica y coordinación de servicios de soporte corporativos' + data['assignments'].append(dict(assignment,position='Responsable de documentación, coordinación técnica y atención administrativa de operaciones')) + self.tool('browser_resize',{'width':390 if mode=='long' else 1440,'height':844 if mode=='long' else 900}) + self.code("await page.route('**/api/companies/"+company+"',route=>route.fulfill({status:200,contentType:'application/json',body:JSON.stringify({company:"+json.dumps(data)+"})}));return true;") + try: + self.navigate() + self.open_organization() + result=self.code("const popup=page.locator('[data-slot=popover-content]');return {fields:await popup.locator('dd').allTextContents(),primary:await popup.getByText('Principal',{exact:true}).count(),box:await popup.boundingBox(),width:await page.evaluate(()=>innerWidth),overflow:await page.evaluate(()=>document.documentElement.scrollWidth-innerWidth)};") + if mode in ['empty','historical']: + self.assertEqual(result['fields'],['—','—']) + elif mode in ['multiple','ambiguous']: + self.assertIn('Coordinación documental secundaria',result['fields']) + self.assertEqual(result['primary'],2 if mode=='ambiguous' else 1) + self.assertEqual(len(result['fields']),4) + else: + self.assertEqual(result['overflow'],0) + self.assertGreaterEqual(result['box']['x'],0) + self.assertLessEqual(result['box']['x']+result['box']['width'],390) + self.record('WD043-'+mode,result) + self.capture('WD043-organization-'+mode) + finally: + self.code("await page.unroute('**/api/companies/"+company+"');return true;") + + def test_33_header_links(self): + self.tool('browser_resize',{'width':1440,'height':900}) + company=self.fixture['companyId'] + source=self.api('/assets/'+company) + station=next(x for x in source['workstations'] if x['id']==self.fixture['main']) + for path in [self.base,self.base+'/sites',self.base+'/people/'+station['values']['personId']]: + self.navigate() + self.code("await page.locator('main header').first().locator('a[href='+JSON.stringify("+json.dumps(path)+")+']').click();await page.waitForURL(url=>url.pathname==="+json.dumps(path)+");return true;") + self.assertTrue(self.code("return new URL(page.url()).pathname==="+json.dumps(path)+";")) + self.navigate() + self.open_organization() + self.code("await page.locator('[data-slot=popover-content]').getByRole('link',{name:'Abrir ficha de persona',exact:true}).click();return true;") + self.assertTrue(self.code("return new URL(page.url()).pathname==="+json.dumps(self.base+'/people/'+station['values']['personId'])+";")) + + if __name__ == "__main__": unittest.main(verbosity=2)