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.
This commit is contained in:
David Arranz 2026-10-24 04:13:30 +02:00
parent f0fa1df233
commit fa9d95fa9e
44 changed files with 3890 additions and 705 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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/<nombre-en-kebab-case>`:
| API disponible | Contrato implementado |
| --- | --- |
| `CompactTable<T extends { id: string }>` | `title`, `items` readonly, `columns` readonly y `emptyMessage`; opcionales `className` sobre la tabla, `state`, `onRetry` y `retryLabel`. |
| `CompactColumn<T>` | `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<T>` | `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.

View File

@ -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 `<entity>/list/ui`; sus altas, detalles e historiales a sus operaciones

View File

@ -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.

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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 {

View File

@ -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:*",

View File

@ -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<string>();
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,

View File

@ -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 })),
};
}

View File

@ -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
);
});

View File

@ -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);

View File

@ -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);
});

View File

@ -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 };
}

View File

@ -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), []);
});

View File

@ -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,
}));
}

View File

@ -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]
);
});

View File

@ -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<typeof workstationPendingCount>[1],
language: string
): WorkProfileDTO | undefined {
return sortWorkstationProfiles(profiles, language).find(
(profile) => workstationPendingCount([profile], sources) > 0
);
}

View File

@ -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<typeof WorkstationQueryFeedback>["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 && (
<Button onClick={onEdit} size="sm" type="button" variant="outline">
{t("editWorkstation")}
</Button>
);
return (
<DetailPanel
actions={
<Button onClick={onEdit} size="sm" variant="outline">
{t("editWorkstation")}
</Button>
}
actions={available && members.length > 0 ? editAction : undefined}
density="compact"
icon={<MonitorIcon />}
title={t("workstationDashboard.equipment")}
>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
hideable: false,
value: (item) => item.asset?.values.name,
render: (item) => (
<div className="flex min-w-0 flex-col gap-1">
{item.asset ? (
<BrowserLink
className="break-words font-medium"
href={`/companies/${companyId}/assets/${item.id}`}
>
{item.asset.values.name}
</BrowserLink>
) : (
<span role="alert">{t("workstationDetail.missingAsset")}</span>
)}
<span className="text-xs text-muted-foreground">
{item.asset?.specification.templateName ?? "—"}
</span>
{item.id === workstation.values.computerAssetId && (
<Badge className="w-fit" variant="secondary">
{t("workstationDashboard.principal")}
</Badge>
)}
</div>
),
},
{
id: "technical",
label: t("workstationDashboard.technicalStatus"),
value: (item) => (item.asset ? t(item.asset.values.status) : ""),
render: (item) =>
item.asset ? (
<EntityStatus active={item.asset.values.status === "operational"}>
{t(item.asset.values.status)}
</EntityStatus>
) : (
"—"
),
},
{
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 && (
<WorkstationQueryFeedback label={t("workstationDashboard.equipment")} query={query} />
)}
{available && model?.principalState === "outside-composition" && (
<p className="text-sm text-destructive" role="alert">
{t("workstationDashboard.principalOutsideComposition")}
</p>
)}
{available && model?.principalState === "unresolved" && (
<p className="text-sm text-destructive" role="alert">
{t("workstationDashboard.principalUnavailable")}
</p>
)}
{available && members.length > 0 && model?.principalState === "missing" && (
<p className="text-xs text-muted-foreground">{t("workstationDetail.noComputer")}</p>
)}
{available && members.length === 0 && (
<EmptyState
actions={editAction}
className="gap-3 border-0 bg-transparent py-3 @min-[560px]:py-3"
description={t("workstationAssetsEmptyDescription")}
icon={<MonitorIcon />}
title={t("noWorkstationAssets")}
/>
)}
{available && members.length > 0 && (
<CollectionPreview
collapseLabel={t("workstationDashboard.showLess")}
expandLabel={t("workstationDashboard.showAll", { count: members.length })}
items={members}
limit={5}
>
{(items) => (
<CompactList title={t("workstationDashboard.equipment")}>
{items.map(({ id, asset, principal }) => {
if (!asset) {
return (
<CompactListItem
icon={<AssetTemplateIcon icon="box" />}
key={id}
metadata="—"
title={<span role="alert">{t("workstationDetail.missingAsset")}</span>}
/>
);
}
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 (
<CompactListItem
actions={
<>
<Badge variant={technicalVariant}>{t(asset.values.status)}</Badge>
<Badge variant="secondary">
{t(asset.assigned ? "in_use" : "workstationDashboard.unassigned")}
</Badge>
</>
}
icon={<AssetTemplateIcon icon={icon ?? "box"} />}
key={id}
metadata={
<span className="inline-flex flex-wrap items-center gap-2">
<span>{asset.specification.templateName}</span>
{principal && (
<Badge variant="outline">{t("workstationDashboard.principal")}</Badge>
)}
</span>
}
title={
<BrowserLink href={`/companies/${companyId}/assets/${id}`}>
{asset.values.name}
</BrowserLink>
}
/>
);
})}
</CompactList>
)}
</CollectionPreview>
)}
</DetailPanel>
);
}

View File

@ -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 (
<DetailPanel density="compact" icon={<MonitorIcon />} title={t("workstationComputer")}>
{computer ? (
<div className="flex min-w-0 items-start gap-3">
<span className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-muted text-primary">
<MonitorIcon aria-hidden="true" className="size-7" />
</span>
<div className="flex min-w-0 flex-col gap-2">
<BrowserLink
className="break-words font-semibold"
href={`/companies/${companyId}/assets/${computer.id}`}
>
{computer.values.name}
</BrowserLink>
<p className="text-xs text-muted-foreground">{computer.specification.templateName}</p>
<dl className="flex flex-wrap gap-x-5 gap-y-2 text-xs">
<div>
<dt className="text-muted-foreground">
{t("workstationDashboard.technicalStatus")}
</dt>
<dd>
<EntityStatus active={computer.values.status === "operational"}>
{t(computer.values.status)}
</EntityStatus>
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("workstationDashboard.usageStatus")}</dt>
<dd>{t(computer.assigned ? "in_use" : "workstationDashboard.unassigned")}</dd>
</div>
</dl>
<BrowserLink className="text-xs" href={`/companies/${companyId}/assets/${computer.id}`}>
{t("workstationDashboard.openAsset")}
</BrowserLink>
</div>
</div>
) : (
<EmptyState
actions={
<Button onClick={onEdit} size="sm" variant="outline">
{t("editWorkstation")}
</Button>
}
className="gap-3 border-0 bg-transparent py-3 @min-[560px]:py-3"
description={t("workstationDetail.completeComputer")}
icon={<MonitorIcon />}
title={t("workstationDetail.noComputer")}
/>
)}
</DetailPanel>
);
}

View File

@ -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 (
<section
aria-label={t(`${kind}.title`)}
className="flex min-w-0 flex-col gap-2 py-3 first:pt-0"
className="flex min-w-0 flex-col gap-2 py-2 first:pt-0"
>
<header className="flex items-center justify-between gap-3">
<h3 className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<h4 className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
{t(`${kind}.title`)}
</h3>
</h4>
<BrowserLink
className="shrink-0 text-xs"
href={`/companies/${companyId}/work-profiles/${profileId}/${FAMILY_PATHS[kind]}`}
@ -61,19 +66,19 @@ export function WorkstationConfigurationFamily({
</BrowserLink>
</header>
<WorkstationQueryFeedback label={t(`${kind}.title`)} query={query} />
{query.data !== undefined && resolved && (
<ul className="flex min-w-0 flex-col gap-3">
{query.data !== undefined && resolved && rows.length > 0 && (
<CompactList title={t(`${kind}.title`)}>
{rows.map((row) => (
<li className="flex min-w-0 flex-col gap-1 pl-6 text-xs" key={`${row.kind}:${row.id}`}>
<div className="flex flex-wrap items-start justify-between gap-2">
<h4 className="min-w-0 break-words text-sm font-medium">{row.name}</h4>
<span
className={`shrink-0 rounded-md px-2 py-0.5 ${row.state === "configured" ? "bg-emerald-500/10 text-emerald-800 dark:text-emerald-300" : "bg-amber-500/10 text-amber-800 dark:text-amber-300"}`}
>
<CompactListItem
actions={
<Badge variant={row.state === "configured" ? "outline" : "secondary"}>
{t(`mail.${row.state}`)}
</span>
</div>
<dl className="flex min-w-0 flex-col gap-1 text-muted-foreground">
</Badge>
}
key={`${row.kind}:${row.id}`}
title={row.name}
>
<dl className="flex min-w-0 flex-col gap-1 text-xs text-muted-foreground">
{row.details.map((detail) => (
<div className="min-w-0 [overflow-wrap:anywhere]" key={detail.label}>
<dt className="inline">{t(detail.label)}: </dt>
@ -87,12 +92,12 @@ export function WorkstationConfigurationFamily({
{row.notes && (
<p className="whitespace-pre-wrap break-words text-muted-foreground">{row.notes}</p>
)}
</li>
</CompactListItem>
))}
</ul>
</CompactList>
)}
{query.data !== undefined && resolved && !query.isError && !rows.length && (
<p className="pl-6 text-xs text-muted-foreground">{t("environment.empty")}</p>
{query.data !== undefined && resolved && !query.isError && !rows.length && emptyMessage && (
<p className="pl-6 text-xs text-muted-foreground">{emptyMessage}</p>
)}
</section>
);

View File

@ -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) => (
<div
className="flex min-h-40 min-w-0 flex-col gap-4 rounded-lg border bg-card p-4"
key={panel}

View File

@ -1,20 +1,17 @@
import { entityListState } from "@repo/rdx-ui/lib/entity-list-state";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import { buildEnvironment } from "../../../work-environments/shared/helpers";
import type { WorkstationDetailController } from "../controllers/use-workstation-detail-controller";
import { workstationPendingCount } from "../helpers/workstation-dashboard";
import { firstPendingWorkstationProfile } from "../helpers/workstation-profile-expansion";
import { WorkstationAssets } from "./workstation-assets";
import { WorkstationComputer } from "./workstation-computer";
import { WorkstationLatestChange } from "./workstation-latest-change";
import { WorkstationPendingConfigurations } from "./workstation-pending-configurations";
import { WorkstationProfileConfigurations } from "./workstation-profile-configurations";
import { WorkstationProfileConfigurationDetails } from "./workstation-profile-configuration-details";
import { WorkstationProfiles } from "./workstation-profiles";
import { WorkstationQueryFeedback } from "./workstation-query-feedback";
import { WorkstationResponsible } from "./workstation-responsible";
import { WorkstationSoftware } from "./workstation-software";
import { WorkstationSupport } from "./workstation-support";
@ -27,8 +24,7 @@ export function WorkstationDetailContent({
controller: WorkstationDetailController;
onHistory: () => 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 (
<div className="flex min-w-0 flex-col gap-4">
<div className="grid min-w-0 items-start gap-4 @min-[900px]:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<WorkstationComputer
<div className="flex min-w-0 flex-col gap-3">
<div className="grid min-w-0 items-start gap-3 @min-[900px]:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<WorkstationAssets
assets={c.inventory.data?.assets ?? []}
catalog={c.catalog.data?.items ?? []}
companyId={company.id}
computer={model.computer}
onEdit={() => c.setEditing(true)}
query={c.inventory}
workstation={workstation}
/>
<div className="min-w-0 @min-[900px]:w-64 @min-[900px]:justify-self-end">
<WorkstationPendingConfigurations
controller={c}
onPending={() => {
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" });
}}
/>
</div>
</div>
<div className="grid min-w-0 items-start gap-4 @min-[1000px]:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<div className="grid min-w-0 items-start gap-3 @min-[1000px]:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<div className="min-w-0">
{model.computer && (
<WorkstationQueryFeedback
@ -108,39 +104,37 @@ export function WorkstationDetailContent({
}
)}
{...(configurations ? { configurations } : {})}
expandedProfileId={c.expandedProfile?.id}
onCreate={() => c.setCreatingProfile(true)}
/>
onToggleProfile={c.profileExpansion.toggle}
selectionUnavailable={c.profileExpansion.state.unavailable}
>
{c.expandedProfile && (
<WorkstationProfileConfigurationDetails
companyId={company.id}
controller={c}
focusRequest={c.profileExpansion.state.focusRequest}
key={c.expandedProfile.id}
onPendingOnly={c.profileExpansion.setPendingOnly}
pendingOnly={c.profileExpansion.state.pendingOnly}
profile={c.expandedProfile}
/>
)}
</WorkstationProfiles>
</div>
<WorkstationResponsible
company={company}
personId={workstation.values.personId}
siteId={workstation.values.siteId}
/>
<WorkstationAssets
assets={c.inventory.data?.assets ?? []}
companyId={company.id}
onEdit={() => c.setEditing(true)}
workstation={workstation}
/>
<WorkstationSoftware
companyId={company.id}
controller={c}
key={model.computer?.id ?? "no-computer"}
/>
<div className="min-w-0 @min-[1000px]:row-span-2">
<WorkstationProfileConfigurations
<div className="flex min-w-0 flex-col gap-3">
<WorkstationSoftware
companyId={company.id}
controller={c}
onPendingOnly={setPendingOnly}
pendingOnly={pendingOnly}
key={model.computer?.id ?? "no-computer"}
/>
<WorkstationSupport company={company} controller={c} />
<WorkstationLatestChange
assets={c.inventory.data?.assets ?? []}
onHistory={onHistory}
workstation={workstation}
/>
</div>
<WorkstationSupport company={company} controller={c} />
<WorkstationLatestChange
assets={c.inventory.data?.assets ?? []}
onHistory={onHistory}
workstation={workstation}
/>
</div>
</div>
);

View File

@ -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 (
<div className="@container flex min-w-0 flex-col gap-4 [--primary:var(--secondary)] [--primary-foreground:var(--secondary-foreground)]">
<div className="@container flex min-w-0 flex-col gap-3">
<Breadcrumb aria-label={t("workstationDashboard.breadcrumb")}>
<BreadcrumbList>
<BreadcrumbItem>
@ -73,17 +74,21 @@ export function WorkstationDetailPage({
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
{inventory.isPending ? (
<WorkstationDashboardSkeleton />
) : (
<WorkstationQueryFeedback label={t("workstationDashboard.detail")} query={inventory} />
{inventory.isPending && <WorkstationDashboardSkeleton />}
{inventory.isError && !workstation && (
<WorkstationAssets
assets={[]}
companyId={company.id}
query={inventory}
workstation={undefined}
/>
)}
{inventory.isSuccess && !workstation && (
<p role="status">{t("workstationDetail.notFound")}</p>
)}
{workstation && (
<>
<header className="flex min-w-0 flex-col gap-3 py-1">
<header className="flex min-w-0 flex-col gap-2 py-1">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1 basis-80">
<WorkstationNameEditor key={workstation.id} workstation={workstation} />
@ -120,13 +125,23 @@ export function WorkstationDetailPage({
"—"
)}
</span>
<span className="inline-flex min-w-0 items-center gap-1.5">
<span className="inline-flex min-w-0 flex-wrap items-center gap-1.5">
<PersonIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="sr-only">{t("workstationDetail.assignedPerson")}: </span>
{responsible ? (
<BrowserLink className="break-words" href={`${root}/people/${responsible.id}`}>
{responsible.name}
</BrowserLink>
<>
<BrowserLink
className="min-w-0 break-words [overflow-wrap:anywhere]"
href={`${root}/people/${responsible.id}`}
>
{responsible.name}
</BrowserLink>
<WorkstationResponsibleContext
company={company}
key={`${company.id}:${workstation.id}:${responsible.id}`}
responsible={responsible}
/>
</>
) : (
t("workstationDashboard.noResponsible")
)}

View File

@ -28,6 +28,7 @@ import { PeopleSection } from "../../../people/list/ui/people-section";
import { companiesEnglish, companiesSpanish } from "../../../shared";
import { profileKeys } from "../../../work-profiles/shared/api";
import { WorkstationAssets } from "./workstation-assets";
import { WorkstationDetailPage } from "./workstation-detail-page";
import { WorkstationProfiles } from "./workstation-profiles";
@ -158,7 +159,7 @@ test("ficha de puesto es/en: cabecera, retorno, bloques vacíos y edición modal
assert.equal(tabs.length, 0);
assert.ok(html.includes(labels.workstationDashboard.viewHistory));
assert.ok(html.includes(labels.workstationDashboard.software));
assert.ok(html.includes(labels.workstationDashboard.responsibleContext));
assert.equal(html.includes(`${labels.workstationDashboard.responsibleContext}</h2>`), false);
assert.ok(html.includes(labels.noWorkstationAssets));
assert.ok(html.includes(labels.workstationDetail.profileUnavailable));
assert.match(
@ -360,10 +361,22 @@ test("ficha: ordenador destacado y periféricos sin repetir, perfiles solo del o
});
const labels = language === "es" ? assetsSpanish : assetsEnglish;
assert.equal(html.includes(labels.workstationDashboard.selectProfile), false);
assert.equal(html.includes(`${labels.workstationDashboard.configurations}</h2>`), false);
assert.equal(html.includes('id="workstation-configurations"'), false);
assert.ok(html.includes('aria-expanded="false"'));
const header = html.slice(html.indexOf("<header"), html.indexOf("</header>"));
assert.ok(header.includes(`href="/companies/${companyId}/people/${person.id}"`));
assert.ok(header.includes(person.name));
assert.ok(
header.includes(
language === "es"
? "Consultar contexto organizativo de Ana"
: "View organizational context for Ana"
)
);
assert.equal(header.includes("Administración y gestión documental"), false);
assert.ok(header.includes("Sede central"));
assert.ok(header.includes(`href="/companies/${companyId}/sites"`));
assert.ok(header.includes(labels.workstationDetail.assignedPerson));
@ -372,13 +385,20 @@ test("ficha: ordenador destacado y periféricos sin repetir, perfiles solo del o
assert.ok(html.includes(`href="/companies/${companyId}/assets/${peripheral.id}"`));
assert.ok(html.indexOf(computer.values.name) < html.indexOf(peripheral.values.name));
assert.ok(html.includes(labels.workstationDashboard.equipment));
assert.equal(html.includes(`${labels.workstationComputer}</h2>`), false);
assert.equal(html.split(computer.values.name).length - 1, 1);
assert.ok(
html.indexOf(labels.workstationDashboard.equipment) <
html.indexOf(labels.workstationDashboard.peopleProfiles)
);
assert.ok(html.includes(labels.workstationDetail.assignedPerson));
assert.ok(html.includes(`href="/companies/${companyId}/work-profiles/${profiles[0]?.id}"`));
assert.equal(
html.includes(`href="/companies/${companyId}/work-profiles/${profiles[1]?.id}"`),
false
);
assert.ok(html.includes("Perfil 51"));
assert.equal(html.includes("Perfil 51"), false);
assert.ok(html.includes(labels.workstationDashboard.archived_one.replace("{{count}}", "1")));
assert.equal(html.includes("Perfil 52"), false);
assert.equal(html.includes(labels.workstationDetail.profileUnavailable), false);
}
@ -392,8 +412,10 @@ test("personas y perfiles: carga y error no se presentan como listados vacíos",
<WorkstationProfiles
canCreate={false}
companyId={companyId}
expandedProfileId={undefined}
onCreate={() => undefined}
onRetry={() => Promise.resolve()}
onToggleProfile={() => undefined}
people={[]}
profiles={[]}
state={{ status: "loading", message: labels.loading }}
@ -405,8 +427,10 @@ test("personas y perfiles: carga y error no se presentan como listados vacíos",
<WorkstationProfiles
canCreate={false}
companyId={companyId}
expandedProfileId={undefined}
onCreate={() => undefined}
onRetry={() => Promise.resolve()}
onToggleProfile={() => undefined}
people={[]}
profiles={[]}
state={{
@ -423,7 +447,8 @@ test("personas y perfiles: carga y error no se presentan como listados vacíos",
assert.ok(profiles.includes('role="alert"'));
assert.ok(profiles.includes(labels.retry));
assert.equal(profiles.includes(labels.workstationDetail.noProfiles), false);
assert.ok(profiles.includes('data-quick-search=""'));
assert.equal(profiles.includes('data-quick-search=""'), false);
assert.ok(profiles.includes('data-slot="compact-table"'));
}
});
@ -652,3 +677,179 @@ test("ruta de activo de compañía abre la página con información, historial,
assert.equal(html.includes('role="dialog"'), false);
}
});
test("equipamiento es/en: badges de principal, técnico y uso conservan su significado", async () => {
for (const language of ["es", "en"]) {
const labels = language === "es" ? assetsSpanish : assetsEnglish;
const computer = asset(40, "PRINCIPAL-AVERÍADO");
const broken = {
...computer,
values: { ...computer.values, status: "broken" as const },
assigned: true,
};
const monitor = asset(41, "MONITOR-SECUNDARIO");
const station = {
...workstation,
values: {
...workstation.values,
computerAssetId: broken.id,
assetIds: [monitor.id, broken.id, monitor.id],
},
};
const html = await render(language, true, {
node: (
<WorkstationAssets
assets={[monitor, broken]}
companyId={companyId}
onEdit={() => undefined}
workstation={station}
/>
),
});
assert.equal(html.split(broken.values.name).length - 1, 1);
assert.equal(html.split(monitor.values.name).length - 1, 1);
assert.ok(html.indexOf(broken.values.name) < html.indexOf(monitor.values.name));
assert.equal(html.split(`>${labels.workstationDashboard.principal}</span>`).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: (
<WorkstationAssets
assets={[]}
companyId={companyId}
query={{
data: undefined,
isPending: false,
isFetching: false,
isError: true,
refetch: async () => 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: (
<WorkstationAssets
assets={[computer, monitor]}
companyId={companyId}
onEdit={() => 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}</span>`), 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: (
<WorkstationProfiles
canCreate
companyId={companyId}
configurations={new Map([[profile.id, []]])}
expandedProfileId={profile.id}
onCreate={() => undefined}
onRetry={() => Promise.resolve()}
onToggleProfile={() => undefined}
people={[person]}
profiles={[profile]}
state={{ status: "ready" }}
>
<section aria-label="Configuraciones de prueba">Detalle único</section>
</WorkstationProfiles>
),
});
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\b[^>]*>(.*?)<\/button>/gs)].every(
(match) => !match[1]?.includes("<a ")
)
);
}
});
test("cabecera es/en: responsable largo o ausente, contexto independiente y seis tarjetas", async () => {
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("<header"), html.indexOf("</header>"));
assert.equal(html.includes(`${labels.workstationDashboard.responsibleContext}</h2>`), false);
assert.equal([...html.matchAll(/<h2\b/g)].length, 6);
if (personId) {
assert.ok(header.includes(longPerson.name));
assert.ok(header.includes('aria-haspopup="dialog"'));
assert.ok(header.includes(`href="/companies/${companyId}/people/${personId}"`));
} else {
assert.ok(header.includes(labels.workstationDashboard.noResponsible));
assert.equal(header.includes('aria-haspopup="dialog"'), false);
}
assert.ok(
[...header.matchAll(/<a\b[^>]*>(.*?)<\/a>/gs)].every(
(match) => !match[1]?.includes("<button")
)
);
}
}
});

View File

@ -0,0 +1,114 @@
import { BrowserLink } from "@metis/core/client";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { useEffect, useId, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { WorkProfileDTO } from "../../../../common/work-profiles";
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 WorkstationProfileConfigurationDetails({
controller: c,
companyId,
pendingOnly,
onPendingOnly,
profile,
focusRequest,
}: {
profile: WorkProfileDTO;
focusRequest: number;
controller: WorkstationDetailController;
companyId: string;
pendingOnly: boolean;
onPendingOnly: (value: boolean) => void;
}) {
const { t } = useTranslation("assets");
const { t: companies } = useTranslation("companies");
const id = useId();
const sectionRef = useRef<HTMLElement>(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 (
<section
aria-labelledby={`${id}-title`}
className="flex min-w-0 scroll-mt-6 flex-col gap-3 border-t border-border pt-3"
id="workstation-configurations"
ref={sectionRef}
tabIndex={-1}
>
<h3 className="break-words text-sm font-semibold" id={`${id}-title`}>
{t("workstationDashboard.profileConfigurations", { name: profile.values.name })}
</h3>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<BrowserLink href={`/companies/${companyId}/work-profiles/${profile.id}`}>
{t("workstationDashboard.openProfile")}
</BrowserLink>
<label className="inline-flex items-center gap-2" htmlFor={`${id}-pending`}>
<Checkbox
checked={pendingOnly}
id={`${id}-pending`}
onCheckedChange={(value) => onPendingOnly(value === true)}
/>
{t("workstationDashboard.pendingOnly")}
</label>
</div>
<WorkstationQueryFeedback
label={t("workstationDashboard.resources")}
query={queries.resources}
/>
<WorkstationQueryFeedback label={t("workstationDashboard.accounts")} query={queries.access} />
{empty && (
<p className="text-xs text-muted-foreground">
{t("workstationDashboard.noConfigurations")}
</p>
)}
<div className="min-w-0 divide-y divide-border">
{(["folders", "mail", "applications", "printers", "certificates"] as const).map((kind) => (
<WorkstationConfigurationFamily
companyId={companyId}
emptyMessage={empty ? null : companies("environment.empty")}
key={kind}
kind={kind}
profileId={profile.id}
query={queries[kind]}
resolved={
kind === "printers" ||
(queries.resources.data !== undefined &&
(kind !== "folders" || queries.access.data !== undefined))
}
rows={rows.filter((row) => row.kind === kind)}
/>
))}
</div>
{model.unlinkedMail > 0 && (
<p className="text-xs text-amber-700 dark:text-amber-400">
{companies("environment.unlinkedMail", { count: model.unlinkedMail })}
</p>
)}
</section>
);
}

View File

@ -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 (
<section className="min-w-0 scroll-mt-6" id="workstation-configurations" tabIndex={-1}>
<DetailPanel
density="compact"
icon={<SettingsIcon />}
title={t("workstationDashboard.configurations")}
>
{c.dashboard?.computer && (
<WorkstationQueryFeedback label={t("workstationDetail.profiles")} query={c.profiles} />
)}
{profile ? (
<>
<SelectWithAction
id={`${id}-profile`}
label={t("workstationDashboard.selectProfile")}
onValueChange={(value) => {
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 && (
<p className="text-xs text-muted-foreground" role="status">
{t("workstationDashboard.selectionChanged")}
</p>
)}
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<BrowserLink href={`/companies/${companyId}/work-profiles/${profile.id}`}>
{t("workstationDashboard.openProfile")}
</BrowserLink>
<label className="inline-flex items-center gap-2" htmlFor={`${id}-pending`}>
<Checkbox
checked={pendingOnly}
id={`${id}-pending`}
onCheckedChange={(value) => onPendingOnly(value === true)}
/>
{t("workstationDashboard.pendingOnly")}
</label>
</div>
<WorkstationQueryFeedback
label={t("workstationDashboard.resources")}
query={queries.resources}
/>
<WorkstationQueryFeedback
label={t("workstationDashboard.accounts")}
query={queries.access}
/>
<div className="min-w-0 divide-y divide-border">
{(["folders", "mail", "applications", "printers", "certificates"] as const).map(
(kind) => (
<WorkstationConfigurationFamily
companyId={companyId}
key={kind}
kind={kind}
profileId={profile.id}
query={queries[kind]}
resolved={
kind === "printers" ||
(queries.resources.data !== undefined &&
(kind !== "folders" || queries.access.data !== undefined))
}
rows={rows.filter((row) => row.kind === kind)}
/>
)
)}
</div>
{model && model.unlinkedMail > 0 && (
<p className="text-xs text-amber-700 dark:text-amber-400">
{companies("environment.unlinkedMail", { count: model.unlinkedMail })}
</p>
)}
</>
) : (
!(c.dashboard?.computer && (c.profiles.isPending || c.profiles.isError)) && (
<p className="text-sm text-muted-foreground">
{t("workstationDashboard.noActiveProfiles")}
</p>
)
)}
</DetailPanel>
</section>
);
}

View File

@ -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 ? (
<span className="inline-flex min-w-0 items-center gap-2">
<PersonAvatar
companyId={companyId}
name={person.name}
personId={person.id}
photoVersion={person.photoVersion}
/>
<BrowserLink
className="min-w-0 [overflow-wrap:anywhere]"
href={`/companies/${companyId}/people/${person.id}`}
>
{person.name}
</BrowserLink>
</span>
) : (
"—"
);
}

View File

@ -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<string, readonly ProfileConfigurationKind[]>;
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<HTMLParagraphElement>(null);
useEffect(() => {
if (selectionUnavailable && document.activeElement === document.body) {
feedbackRef.current?.focus();
}
}, [selectionUnavailable]);
return (
<DetailPanel
actions={
<div className="flex flex-wrap gap-2">
{archivedProfiles.length > 0 && (
<Button
aria-pressed={archived}
onClick={() => setArchived(!archived)}
size="sm"
variant="ghost"
>
{t(archived ? "workstationDashboard.hideArchived" : "workstationDashboard.archived", {
count: archivedProfiles.length,
})}
</Button>
)}
<Button
disabled={!canCreate}
onClick={onCreate}
size="sm"
type="button"
variant="outline"
>
<WorkProfileIcon aria-hidden="true" />
{companies("profiles.new")}
</Button>
</div>
}
density="compact"
icon={<WorkProfileIcon />}
title={t("workstationDashboard.peopleProfiles")}
>
{archivedProfiles.length > 0 && !archived && (
<p className="break-words text-xs text-muted-foreground">
{archivedProfiles.map((profile) => profile.values.name).join(" · ")}
</p>
)}
{!canCreate && (
<p className="text-xs text-muted-foreground">{t("workstationDetail.profileUnavailable")}</p>
)}
<EntityTable
columns={[
{
id: "name",
label: t("workstationDashboard.profile"),
hideable: false,
value: (profile) => profile.values.name,
render: (profile) => (
<BrowserLink
className="font-medium"
href={`/companies/${companyId}/work-profiles/${profile.id}`}
<Collapsible open={Boolean(expandedProfileId)}>
<DetailPanel
actions={
<div className="flex flex-wrap items-center gap-2">
{archivedProfiles.length > 0 && (
<Button
aria-pressed={archived}
onClick={() => setArchived(!archived)}
size="sm"
type="button"
variant="ghost"
>
{profile.values.name}
</BrowserLink>
),
},
{
id: "person",
label: t("workstationDashboard.person"),
value: (profile) =>
people.find((person) => person.id === profile.values.personId)?.name,
render: (profile) => {
const person = people.find((item) => item.id === profile.values.personId);
return person ? (
<span className="inline-flex min-w-0 items-center gap-2">
<PersonAvatar
companyId={companyId}
name={person.name}
personId={person.id}
photoVersion={person.photoVersion}
/>
<BrowserLink href={`/companies/${companyId}/people/${person.id}`}>
{person.name}
</BrowserLink>
</span>
) : (
"—"
);
},
},
{
id: "account",
label: t("workstationDashboard.account"),
value: (profile) => {
const account = accounts.find(
(item) => item.id === profile.values.accountId && item.companyId === companyId
);
return account?.values.kind === "account" ? account.values.login : null;
},
render: (profile) => {
const account = accounts.find(
(item) => item.id === profile.values.accountId && item.companyId === companyId
);
return account?.values.kind === "account" ? (
<BrowserLink href={`/companies/${companyId}/access?kind=account`}>
{account.values.login}
</BrowserLink>
) : (
"—"
);
},
},
{
id: "status",
label: t("status"),
value: (profile) => t(profile.values.active ? "active" : "inactive"),
render: (profile) => (
<EntityStatus active={profile.values.active}>
{t(profile.values.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
id: "configurations",
label: t("workstationDashboard.configurationSummary"),
value: (profile) =>
configurations
?.get(profile.id)
?.map((kind) => companies(`${kind}.title`))
.join(", "),
render: (profile) => (
<div className="flex min-w-0 flex-col gap-1 text-xs">
<BrowserLink href={`/companies/${companyId}/work-profiles/${profile.id}`}>
{configurations
?.get(profile.id)
?.map((kind) => companies(`${kind}.title`))
.join(" · ") || "—"}
</BrowserLink>
{profile.needsReview && (
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-400">
<TriangleAlertIcon aria-hidden="true" className="size-4 shrink-0" />
{t("workstationDetail.needsReview")}
</span>
{t(
archived ? "workstationDashboard.hideArchived" : "workstationDashboard.archived",
{
count: archivedProfiles.length,
}
)}
</div>
),
},
]}
emptyMessage={t("workstationDetail.noProfiles")}
items={visible}
locale={i18n.language}
onRetry={onRetry}
searchPlaceholder={t("workstationDetail.searchProfiles")}
state={state}
title={t("workstationDetail.profiles")}
/>
</DetailPanel>
<span aria-hidden="true">→</span>
</Button>
)}
<Button
disabled={!canCreate}
onClick={onCreate}
size="sm"
type="button"
variant="outline"
>
<WorkProfileIcon aria-hidden="true" />
{companies("profiles.new")}
</Button>
</div>
}
density="compact"
icon={<WorkProfileIcon />}
title={t("workstationDashboard.peopleProfiles")}
>
{!canCreate && (
<p className="text-xs text-muted-foreground">
{t("workstationDetail.profileUnavailable")}
</p>
)}
{selectionUnavailable && (
<p
className="text-xs text-muted-foreground"
ref={feedbackRef}
role="status"
tabIndex={-1}
>
{t("workstationDashboard.profileUnavailable")}
</p>
)}
<CollectionPreview
collapseLabel={t("workstationDashboard.showLess")}
expandLabel={t("workstationDashboard.showAll", { count: visible.length })}
items={visible}
limit={5}
>
{(items) => (
<CompactTable
className="min-w-[36rem] @max-[600px]:min-w-0 @max-[600px]:table-fixed @max-[600px]:[&_tr>:nth-child(2)]:hidden @max-[600px]:[&_tr>:nth-child(3)]:hidden @max-[600px]:[&_tr>:nth-child(4)]:hidden @max-[600px]:[&_tr>:last-child]:w-32"
columns={[
{
id: "name",
label: t("workstationDashboard.profile"),
render: (profile) => {
const account = accounts.find(
(item) => item.id === profile.values.accountId && item.companyId === companyId
);
return (
<div className="flex min-w-0 flex-col gap-2">
<BrowserLink
className="font-medium"
href={`/companies/${companyId}/work-profiles/${profile.id}`}
>
{profile.values.name}
</BrowserLink>
<div className="hidden min-w-0 flex-col gap-2 @max-[600px]:flex">
<WorkstationProfilePerson
companyId={companyId}
person={people.find((item) => item.id === profile.values.personId)}
/>
<span className="text-xs [overflow-wrap:anywhere]">
{t("workstationDashboard.account")}:{" "}
{account?.values.kind === "account" ? (
<BrowserLink href={`/companies/${companyId}/access?kind=account`}>
{account.values.login}
</BrowserLink>
) : (
"—"
)}
</span>
<EntityStatus active={profile.values.active}>
{t(profile.values.active ? "active" : "inactive")}
</EntityStatus>
</div>
</div>
);
},
},
{
id: "person",
label: t("workstationDashboard.person"),
render: (profile) => (
<WorkstationProfilePerson
companyId={companyId}
person={people.find((item) => 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" ? (
<BrowserLink href={`/companies/${companyId}/access?kind=account`}>
{account.values.login}
</BrowserLink>
) : (
"—"
);
},
},
{
id: "status",
label: t("status"),
render: (profile) => (
<span className="whitespace-nowrap">
<EntityStatus active={profile.values.active}>
{t(profile.values.active ? "active" : "inactive")}
</EntityStatus>
</span>
),
},
{
id: "configurations",
label: t("workstationDashboard.configurationSummary"),
render: (profile) => (
<div className="flex min-w-0 flex-col gap-1 text-xs">
<span>
{configurations?.has(profile.id)
? t("workstationDashboard.categoryCount", {
count: configurations.get(profile.id)?.length ?? 0,
})
: "—"}
</span>
{profile.values.active && (
<Button
aria-controls={detailsId}
aria-expanded={expandedProfileId === profile.id}
aria-label={t(
expandedProfileId === profile.id
? "workstationDashboard.collapseProfile"
: "workstationDashboard.expandProfile",
{ name: profile.values.name }
)}
className="w-fit"
onClick={() => onToggleProfile(profile.id)}
size="sm"
type="button"
variant="ghost"
>
{expandedProfileId === profile.id ? (
<ChevronDownIcon aria-hidden="true" />
) : (
<ChevronRightIcon aria-hidden="true" />
)}
{t(
expandedProfileId === profile.id
? "workstationDashboard.hideConfigurations"
: "workstationDashboard.viewConfigurations"
)}
</Button>
)}
{profile.needsReview && (
<span className="inline-flex items-center gap-1">
<TriangleAlertIcon aria-hidden="true" className="size-4 shrink-0" />
{t("workstationDetail.needsReview")}
</span>
)}
</div>
),
},
]}
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")}
/>
)}
</CollectionPreview>
<CollapsibleContent id={detailsId} keepMounted>
{children}
</CollapsibleContent>
</DetailPanel>
</Collapsible>
);
}

View File

@ -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<HTMLHeadingElement>(null);
const retryRef = useRef<HTMLButtonElement>(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 (
<Popover>
<Tooltip>
<PopoverTrigger
render={
<TooltipTrigger
render={<Button aria-label={label} size="icon-sm" type="button" variant="ghost" />}
/>
}
>
<InfoIcon aria-hidden="true" />
</PopoverTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
<PopoverContent
align="start"
className="max-h-[min(70dvh,var(--available-height))] max-w-[calc(100vw-2rem)] overflow-y-auto [overflow-wrap:anywhere]"
>
<div className="flex min-w-0 flex-col gap-1">
<PopoverTitle className="text-sm font-semibold" ref={titleRef} tabIndex={-1}>
{responsible.name}
</PopoverTitle>
<PopoverDescription>{t("workstationDetail.assignedPerson")}</PopoverDescription>
</div>
{query.isFetching && (
<p className="text-xs text-muted-foreground" role="status">
{t("workstationDashboard.refreshing")}
</p>
)}
{query.isError && (
<div className="flex flex-col items-start gap-2 text-xs" role="alert">
<p>{t("workstationDashboard.organizationStale")}</p>
<Button
disabled={query.isFetching}
onClick={() => {
recoveryFocus.current = true;
return query.refetch({ throwOnError: false });
}}
ref={retryRef}
size="sm"
type="button"
variant="outline"
>
{t("retry")}
</Button>
</div>
)}
{assignments.length > 1 && (
<p className="text-xs text-muted-foreground">
{t("workstationDashboard.currentAssignments", { count: assignments.length })}
</p>
)}
<div className="flex min-w-0 flex-col gap-3 divide-y divide-border">
{fields.map((assignment) => (
<div className="min-w-0 pt-3 first:pt-0" key={assignment.id}>
{assignment.isPrimary && (
<Badge variant="outline">{t("workstationDashboard.primaryAssignment")}</Badge>
)}
<dl>
<DetailField density="compact" label={t("workstationDashboard.department")}>
{assignment.department ? (
<BrowserLink href={`/companies/${company.id}/departments`}>
{assignment.department.name}
</BrowserLink>
) : (
"—"
)}
</DetailField>
<DetailField density="compact" label={t("workstationDashboard.position")}>
{assignment.position ?? "—"}
</DetailField>
</dl>
</div>
))}
</div>
<BrowserLink href={`/companies/${company.id}/people/${responsible.id}`}>
{t("workstationDashboard.openResponsiblePerson")}
</BrowserLink>
</PopoverContent>
</Popover>
);
}

View File

@ -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 (
<DetailPanel
density="compact"
icon={<PersonIcon />}
title={t("workstationDashboard.responsibleContext")}
>
<dl>
<DetailField label={t("workstationDetail.assignedPerson")}>
{responsible ? (
<BrowserLink href={`${root}/people/${responsible.id}`}>{responsible.name}</BrowserLink>
) : (
"—"
)}
</DetailField>
<DetailField label={t("workstationDashboard.department")}>
{department ? (
<BrowserLink href={`${root}/departments`}>{department.name}</BrowserLink>
) : (
"—"
)}
</DetailField>
<DetailField label={t("workstationDashboard.position")}>{position ?? "—"}</DetailField>
<DetailField label={t("site")}>
{site ? <BrowserLink href={`${root}/sites`}>{site.name}</BrowserLink> : "—"}
</DetailField>
<DetailField label={t("workstationDashboard.company")}>
<BrowserLink href={root}>{company.name}</BrowserLink>
</DetailField>
</dl>
</DetailPanel>
);
}

View File

@ -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({
<WorkstationQueryFeedback label={t("workstationDashboard.software")} query={query} />
<WorkstationQueryFeedback label={t("workstationDashboard.resources")} query={resources} />
{query.data && (
<ul
aria-label={t("workstationDashboard.software")}
className="max-h-80 divide-y overflow-auto overscroll-contain text-sm"
tabIndex={items.length > 5 ? 0 : undefined}
<CollectionPreview
collapseLabel={t("workstationDashboard.showLess")}
expandLabel={t("workstationDashboard.showAll", { count: items.length })}
items={items}
limit={5}
>
{items.map((item) => (
<li className="flex min-w-0 flex-col gap-1 py-2.5 first:pt-0" key={item.id}>
<BrowserLink
className="break-words font-medium"
href={`/companies/${companyId}/resources/${item.values.resourceId}`}
>
{resource(item.values.resourceId)?.values.name ?? "—"}
</BrowserLink>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<span>
{companies("applications.version")}: {item.values.version || "—"}
</span>
<EntityStatus active={item.values.state === "configured"}>
{companies(`mail.${item.values.state}`)}
</EntityStatus>
</div>
{item.values.licenseResourceId && (
<BrowserLink
className="break-words text-xs"
href={`/companies/${companyId}/resources/${item.values.licenseResourceId}`}
{(visible) => (
<CompactList title={t("workstationDashboard.software")}>
{visible.map((item) => (
<CompactListItem
actions={
<EntityStatus active={item.values.state === "configured"}>
{companies(`mail.${item.values.state}`)}
</EntityStatus>
}
key={item.id}
metadata={
<span>
{companies("applications.version")}: {item.values.version || "—"}
</span>
}
title={
<BrowserLink
href={`/companies/${companyId}/resources/${item.values.resourceId}`}
>
{resource(item.values.resourceId)?.values.name ?? "—"}
</BrowserLink>
}
>
{companies("software.licenseColumn")}:{" "}
{resource(item.values.licenseResourceId)?.values.name ?? "—"}
</BrowserLink>
)}
{item.needsReview && (
<p className="text-xs text-amber-700 dark:text-amber-400">
{companies("software.review")}
</p>
)}
</li>
))}
</ul>
{item.values.licenseResourceId && (
<BrowserLink
className="break-words text-xs"
href={`/companies/${companyId}/resources/${item.values.licenseResourceId}`}
>
{companies("software.licenseColumn")}:{" "}
{resource(item.values.licenseResourceId)?.values.name ?? "—"}
</BrowserLink>
)}
{item.needsReview && (
<p className="text-xs">{companies("software.review")}</p>
)}
</CompactListItem>
))}
</CompactList>
)}
</CollectionPreview>
)}
{query.data && !query.isError && !items.length && (
<p className="text-sm text-muted-foreground">{companies("software.empty")}</p>

View File

@ -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",

View File

@ -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]) => <a href={`/profiles/${item.id}`}>{item.name}</a>,
},
];
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(
<CompactTable
columns={columns}
emptyMessage="No hay perfiles"
items={items.slice(0, size)}
title="Perfiles"
/>
);
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("<footer"), false);
assert.equal(html.includes("Columnas"), false);
assert.equal(html.includes("<select"), false);
assert.equal(html.includes("No hay perfiles"), size === 0);
}
});
test("los valores ausentes comparten criterio con EntityTable y cero permanece visible", () => {
const html = renderToStaticMarkup(
<CompactTable
columns={[
{ id: "name", label: "Valor", render: () => <> </> },
{ 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</td>"));
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(
<CompactTable
columns={columns}
emptyMessage="Vacío real"
items={items.slice(0, 1)}
onRetry={async () => 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(
<CollectionPreview
collapseLabel="Mostrar menos"
expandLabel={`Mostrar todos (${size})`}
items={items.slice(0, size)}
limit={5}
>
{(visible) => (
<CompactList title="Colección">
{visible.map((item) => (
<CompactListItem
key={item.id}
metadata="Metadatos"
title={<a href={`/profiles/${item.id}`}>{item.name}</a>}
/>
))}
</CompactList>
)}
</CollectionPreview>
);
assert.equal((html.match(/<li /g) ?? []).length, Math.min(5, size));
assert.equal(html.includes('aria-expanded="false"'), size > 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(
<EntityTable columns={columns} items={items} title="Perfiles" />
);
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);
});

View File

@ -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<T>({
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 (
<div className="flex min-w-0 flex-col gap-2" data-slot="collection-preview">
<div id={id}>{children(limited && !expanded ? items.slice(0, previewLimit) : items)}</div>
{limited && (
<Button
aria-controls={id}
aria-expanded={expanded}
className="w-fit"
onClick={() => setExpanded(!expanded)}
size="sm"
type="button"
variant="ghost"
>
{expanded ? collapseLabel : expandLabel}
</Button>
)}
</div>
);
}

View File

@ -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 (
<li
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 py-2 first:pt-0 last:pb-0"
data-slot="compact-list-item"
>
{icon && (
<span aria-hidden="true" className="shrink-0 text-muted-foreground [&_svg]:size-5">
{icon}
</span>
)}
<div className="min-w-0 flex-1 basis-36">
<div className="font-medium [overflow-wrap:anywhere]">{title}</div>
{metadata && (
<div className="text-xs text-muted-foreground [overflow-wrap:anywhere]">{metadata}</div>
)}
{children}
</div>
{actions && <div className="flex min-w-0 flex-wrap items-center gap-2">{actions}</div>}
</li>
);
}

View File

@ -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 (
<ul
aria-label={title}
className="min-w-0 divide-y divide-border text-sm"
data-slot="compact-list"
>
{children}
</ul>
);
}

View File

@ -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<T> = {
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<T extends { id: string }>({
title,
className,
items,
columns,
emptyMessage,
state,
onRetry,
retryLabel,
}: {
title: string;
className?: string;
items: readonly T[];
columns: readonly CompactColumn<T>[];
emptyMessage: string;
state?: EntityListState;
onRetry?: () => Promise<unknown>;
retryLabel?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const available = state?.status !== "loading" && state?.status !== "unavailable";
return (
<div className="min-w-0" data-slot="compact-table" ref={ref} tabIndex={-1}>
{state && (
<EntityListStatus
onRetry={retryLabel ? onRetry : undefined}
retryFocus={() => ref.current}
retryLabel={retryLabel ?? ""}
state={state}
/>
)}
<Table
aria-busy={state?.status === "loading" || state?.status === "refreshing" || undefined}
aria-label={title}
className={className}
>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead
className={`px-2 font-medium ${column.align === "end" ? "text-right" : "text-left"}`}
key={column.id}
scope="col"
>
{column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{state?.status === "loading" && (
<TableRow aria-hidden="true">
{columns.map((column) => (
<TableCell className="p-2" key={column.id}>
<Skeleton className="h-5 w-full" />
</TableCell>
))}
</TableRow>
)}
{available &&
items.map((item) => (
<TableRow data-entity-id={item.id} key={item.id}>
{columns.map((column) => (
<TableCell
className={`px-2 py-1.5 whitespace-normal break-words ${column.align === "end" ? "text-right tabular-nums" : "text-left"}`}
key={column.id}
>
{cellContent(column.render(item))}
</TableCell>
))}
</TableRow>
))}
{available && items.length === 0 && (
<TableRow>
<TableCell className="p-2 text-muted-foreground" colSpan={columns.length}>
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
function cellContent(content: ReactNode) {
return hasCellContent(content) ? content : "—";
}

View File

@ -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 (
<div className="grid min-w-0 gap-1 border-b border-border/60 py-2.5 first:pt-0 last:border-0 last:pb-0 @min-[240px]:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] @min-[240px]:gap-3">
<div
className={
density === "compact"
? "grid min-w-0 grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)] gap-3 py-1 first:pt-0 last:pb-0"
: "grid min-w-0 gap-1 border-b border-border/60 py-2.5 first:pt-0 last:border-0 last:pb-0 @min-[240px]:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] @min-[240px]:gap-3"
}
>
<dt className="text-xs leading-5 text-muted-foreground">{label}</dt>
<dd className="min-w-0 whitespace-pre-wrap break-words text-sm font-medium leading-5 [&_a]:cursor-pointer [&_a]:text-primary [&_a]:underline-offset-4 [&_a:hover]:underline [&_a:focus-visible]:outline-2 [&_a:focus-visible]:outline-ring">
{children}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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)