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:
parent
f0fa1df233
commit
fa9d95fa9e
10
DESIGN.md
10
DESIGN.md
@ -154,6 +154,16 @@ Usar [BrowserLink y rutas existentes](docs/rutas-del-cliente.md) para destinos r
|
||||
|
||||
## 5. Patrones de listados
|
||||
|
||||
Existen dos patrones oficiales de colecciones: **Listado de entidades** para explorar y
|
||||
gestionar conjuntos completos y **Listado compacto** para consultar relaciones en fichas,
|
||||
dashboards y paneles. Elegir por la tarea y las capacidades necesarias, no por un umbral de
|
||||
filas. El [patrón compacto](docs/patron-de-listados-compactos.md) define sus variantes
|
||||
Compact Table y Compact Item List, estados, densidad y acceso al conjunto completo.
|
||||
Su contrato de diseño está vigente; `CompactTable`, `CompactList` y `CompactListItem`
|
||||
ya existen en rdx-ui y se adoptan inicialmente en el dashboard de puesto. No confundirlo con la densidad de
|
||||
`DetailPanel` ni con `EntityTable layout="fill"`. La receta siguiente sigue describiendo
|
||||
exclusivamente el **listado de entidades**, sin cambios en sus bandas ni sus consumidores.
|
||||
|
||||
Fuente normativa: [patrón de listados](docs/patron-de-listados-de-entidades.md). Receta de partida: `EntityListHeader` + `EntityTable`, con acciones de fila y formularios en componentes independientes.
|
||||
|
||||
1. Cabecera: icono, título y subtítulo; alta a la derecha, adaptable al espacio.
|
||||
|
||||
130
docs/auditoria-patrones-de-listado-v2.md
Normal file
130
docs/auditoria-patrones-de-listado-v2.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
343
docs/patron-de-listados-compactos.md
Normal file
343
docs/patron-de-listados-compactos.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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:*",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 })),
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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 };
|
||||
}
|
||||
@ -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), []);
|
||||
});
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
@ -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]
|
||||
);
|
||||
});
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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")
|
||||
)}
|
||||
|
||||
@ -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")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
) : (
|
||||
"—"
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
136
packages/rdx-ui/src/compact-collections.test.tsx
Normal file
136
packages/rdx-ui/src/compact-collections.test.tsx
Normal 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);
|
||||
});
|
||||
42
packages/rdx-ui/src/components/collection-preview.tsx
Normal file
42
packages/rdx-ui/src/components/collection-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
packages/rdx-ui/src/components/compact-list-item.tsx
Normal file
37
packages/rdx-ui/src/components/compact-list-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
packages/rdx-ui/src/components/compact-list.tsx
Normal file
14
packages/rdx-ui/src/components/compact-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
packages/rdx-ui/src/components/compact-table.tsx
Normal file
114
packages/rdx-ui/src/components/compact-table.tsx
Normal 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 : "—";
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
19
packages/rdx-ui/src/lib/collection-cell-content.ts
Normal file
19
packages/rdx-ui/src/lib/collection-cell-content.ts
Normal 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;
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user