diff --git a/DESIGN.md b/DESIGN.md
index f0c0374..652665a 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -164,7 +164,7 @@ Fuente normativa: [patrón de listados](docs/patron-de-listados-de-entidades.md)
[EntityTable](packages/rdx-ui/src/components/entity-list.tsx) compone bandas, estados, contenido y controles; [useEntityTable](packages/rdx-ui/src/hooks/use-entity-table.ts) gestiona TanStack Table, orden, visibilidad y paginación **locales**. Nombre comienza ascendente salvo criterio explícito; comparación por idioma, sin tildes/mayúsculas y con cifras numéricas. Esto no describe el comportamiento de búsqueda: V07 sigue pendiente.
-Columnas de inventario persisten con clave propia en localStorage; otros consumidores deben habilitar una clave explícita. Identificación y acciones no se ocultan. El selector extenso necesita revisión, V08. La búsqueda y ciertos filtros de rutas se conservan según su contrato; la restauración completa de orden/página no está implementada de forma general, V05. Cancelar un modal debe mantener contexto del listado.
+Columnas de inventario persisten con clave propia en localStorage; otros consumidores deben habilitar una clave explícita. Identificación y acciones no se ocultan. [L02.2](docs/configuracion-columnas-l02-2.md) incorpora búsqueda de columnas por etiqueta/keywords, grupos semánticos opcionales, visibilidad efectiva y restauración inmediata de defaults, conservando preferencias y protecciones (V08). Los listados sencillos no necesitan grupos. La búsqueda y ciertos filtros de rutas se conservan según su contrato; la restauración completa de orden/página no está implementada de forma general, V05. Cancelar un modal debe mantener contexto del listado.
Familias por plantilla usan `ScrollableTabsList`; `min-w-0` y scroll horizontal quedan dentro del listado. `onRowClick` solo selecciona desde zonas no interactivas, conservando enlaces, menús y controles internos. No añadir selección múltiple sin operación consumidora.
@@ -272,7 +272,7 @@ V02, V03, V06 y V09 están **resueltos en los escenarios verificados del sprint
| V05 | Estado/navegación de implementación | Definir restauración completa por vista; orden reproducido, página de activos pendiente por datos |
| V06 | Resuelto: integración de foco del inventario | Ratón/Enter, Escape/Cancelar, cabecera/fila, alternativa y selector anidado verificados |
| V07 | Contrato de búsqueda y consumidores | Acordar normalización por idioma y probar tildes/ñ; no confundir con ordenación |
-| V08 | Evolución del selector de columnas | Diseñar búsqueda/agrupación con API genérica; conservar preferencias |
+| V08 | Resuelto en los escenarios verificados de L02.2 | Búsqueda, grupos opcionales, contador y restauración con preferencias compatibles |
| V09 | Resuelto: etiquetas/i18n de seis listados | Cabeceras breves y motivos conservados; tests es/en y navegador |
| V10 | Jerarquía de ficha | Diseñar resumen de vacíos y grupos sin ocultar relaciones/acciones; validar fixture poblada |
| V11 | Renderizadores y semántica de ausencia | Aplicar pauta de «—» distinguiendo estado de negocio |
diff --git a/docs/auditoria-listados-l01.md b/docs/auditoria-listados-l01.md
index 3cda56d..ca89414 100644
--- a/docs/auditoria-listados-l01.md
+++ b/docs/auditoria-listados-l01.md
@@ -723,3 +723,24 @@ Validación documental: comprobación de rutas de código y documentos, enlaces/
Se consultaron las referencias pertinentes de composición y diferencias Base UI/Radix, no se ejecutaron generadores ni comandos de instalación. `ui-ux-pro-max` está disponible, pero no se aplicó: los problemas concretos quedaron cubiertos por las skills especializadas, los contratos de Metis y la evidencia; no se ejecutó por rutina. `web-design-guidelines` y los auditores especializados a los que algunas skills delegan no están disponibles en el catálogo de esta sesión; no se instalaron ni se afirmó haberlos usado. La referencia `ux-tables/docs/orchestration-policy.md` no existe en la instalación localizada; se trabajó con el SKILL y las fuentes realmente accesibles.
Precedencia aplicada: solicitud L01 y restricciones de AGENTS → DESIGN/patrones especializados → APIs reales → recomendaciones de skills. Se descartó ocultar primarias solo en hover, trasladar la selección al final, imponer 16 px/44 px a todos los controles compactos, cambiar fuentes/colores, introducir SWR/Next o reemplazar tablas por tarjetas. Ninguna recomendación genérica justifica modificar vendor, instalar dependencias o declarar una validación visual que no se ejecutó.
+
+## Seguimiento L02.2 — V08
+
+El 22 de octubre de 2026, [L02.2](configuracion-columnas-l02-2.md) corrige V08 mediante
+búsqueda por etiqueta/keywords, grupos opcionales, contador efectivo y Restaurar columnas.
+Se conservan los IDs, preferencias, protecciones y aplicación inmediata. Activos aporta
+cinco categorías; Recursos, Personas y selectores sencillos no necesitan grupos artificiales.
+
+La reproducción nueva confirma las **75 columnas, 55 dinámicas y ocho visibles** del
+catálogo de esta instalación. Las medidas originales de la sección 9 se conservan como
+línea base. El diálogo nuevo a 320×640 mantiene 608 px exteriores y 336 px útiles de opciones;
+búsqueda/restauración y cierre permanecen accesibles. Marca, compartida por doce plantillas,
+aparece una sola vez y puede localizarse por distintos contextos.
+
+La suite MCP final ejecuta **8/8 pruebas correctas**, con siete resoluciones, es/en,
+restauración/recarga, storage bloqueado, campos nuevos/retirados, teclado y foco. La compañía
+principal desechable contiene 50 personas, 51 activos y 66 recursos, dentro de quince
+compañías sintéticas. Las evidencias y los fallos globales previos figuran en el informe.
+
+**V08 queda resuelto en los escenarios verificados. V05, V07 y V12 permanecen pendientes.**
+No cambia el estado de los demás hallazgos ni se implementa L02.3.
diff --git a/docs/auditoria-ui-ux-visual.md b/docs/auditoria-ui-ux-visual.md
index 35fa5d5..b75df52 100644
--- a/docs/auditoria-ui-ux-visual.md
+++ b/docs/auditoria-ui-ux-visual.md
@@ -493,3 +493,28 @@ El 22 de octubre de 2026 se completa [V01.4](recursos-responsive-v01-4.md), cons
La comprobación final mediante Docker/Chromium y Playwright MCP autenticado ejecuta **28/28 métodos correctos**, incluida regresión de V01.1–V01.4 y V02/V03/V06/V09. Las matrices cubren once resoluciones y ambos lados de los umbrales; no registran overflow horizontal global. Las cuatro suites finales y las matrices no registran errores ni warnings de consola. Se amplían fixtures con 16 recursos sintéticos del catálogo real y se verifican lectura ordinaria, filtros/paginación, relaciones disponibles, edición/cancelación, transiciones y foco; no se realizan borrados ni activaciones.
**V01 pasa de parcialmente resuelto a resuelto en los escenarios verificados.** Los fallos globales previos siguen documentados como tales: calendario vendor en typecheck, lint/check ajenos y dos expectativas SSR de compañías; las comprobaciones dirigidas y build pasan. No se extiende esta conclusión a WCAG completo, otros motores, datos reales o los hallazgos fuera de alcance. El informe V01.4 conserva las regresiones intermedias y sus correcciones, las evidencias locales y la retirada de servicios/sesión propios.
+
+## Seguimiento L02.2 — Configuración de columnas V08
+
+El 22 de octubre de 2026 se completa [L02.2](configuracion-columnas-l02-2.md). **V08 pasa a
+resuelto en los escenarios verificados**: búsqueda con tildes/mayúsculas y distinción ñ/n,
+keywords, cinco grupos en Activos, contador de visibilidad, columnas obligatorias y
+restauración inmediata con persistencia tras recarga. Sin grupos, el configurador continúa
+sirviendo a Recursos, Personas y tablas sencillas. No hay Aplicar/Cancelar ni reordenación.
+
+Se reproduce antes del cambio el catálogo real de 75 columnas y ocho visibles, sin
+búsqueda/grupos, y la preferencia Sede guardada. Se preservan las medidas originales del
+hallazgo. La nueva suite MCP completa obtiene **8/8 pruebas correctas**, cubriendo siete
+resoluciones de 320×640 a 1920×1080, es/en, casos de tres columnas, campo compartido entre
+doce plantillas, storage bloqueado, diálogos anidados, teclado, foco y recarga.
+
+A 320×640 las opciones disponen de 336 px útiles y el diálogo permanece en 608 px exteriores;
+filtrar hasta cero resultados no colapsa la lista ni desplaza búsqueda/cierre fuera del modal.
+Las capturas representativas se abren para inspección, además de medir geometría. Se utiliza
+una única instalación autenticada desechable, con quince compañías y una principal con 50
+personas, 51 activos y 66 recursos. El informe enlaza evidencias y registra los resultados
+técnicos fallidos sin presentarlos como suites globales correctas.
+
+**V05, V07 y V12 siguen pendientes.** La normalización añadida solo corresponde al buscador
+de columnas; no corrige las búsquedas de entidades. No se cambia el estado de otros hallazgos
+ni se acredita WCAG completo o pruebas con datos reales.
diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md
index 6ad85d7..b8875db 100644
--- a/docs/componentes-de-interfaz.md
+++ b/docs/componentes-de-interfaz.md
@@ -1180,6 +1180,29 @@ almacenamiento contenga una preferencia contraria. Los datos inválidos o el alm
bloqueado no impiden utilizar la tabla. Cambiar de clave carga las preferencias de la
nueva vista sin escribir sobre ellas la selección de la anterior.
+### Configuración avanzada de columnas — L02.2
+
+`EntityColumn` admite `groupId?: string` y `keywords?: readonly string[]`.
+`EntityTable.columnGroups` aporta grupos `{ id, label, description? }`, en orden de
+presentación; sus textos pertenecen al consumidor. Los componentes genéricos no conocen
+plantillas, DTO ni endpoints. Sin grupos, el diálogo conserva una lista sencilla.
+
+`EntityTableColumnsDialog` compone FormDialog en modo picker, InputGroup, Checkbox,
+FieldSet/FieldLegend y FormBody. Búsqueda y restauración permanecen fuera del scroll de
+opciones. El resumen refleja visibilidad efectiva; la columna técnica `selection` no se
+expone ni contabiliza. Las obligatorias siguen marcadas/deshabilitadas y tienen descripción
+accesible. Se mantiene aplicación inmediata y Cerrar, sin Aplicar/Cancelar.
+
+`EntityTableConfiguration` transmite opcionalmente `columnGroups`, `columnMetadata`
+(`id`, `groupId?`, `keywords?`) y `onResetColumns`. La integración EntityTable vacía solo sus
+preferencias locales, recuperando defaults actuales aunque las columnas lleguen después.
+El uso directo del diálogo sin callback recurre a `table.resetColumnVisibility()`.
+
+Activos aporta General, Estado y ubicación, Auditoría, Infraestructura y Características del
+catálogo. Un UUID compartido aparece una sola vez; plantillas/secciones y etiquetas históricas
+se incorporan como keywords. Se conserva `asset-inventory-columns` y su formato de booleanos.
+Véase [API, políticas, pruebas y evidencias](configuracion-columnas-l02-2.md).
+
### Navegación de páginas mediante cursor
`CursorPagination` de `@repo/rdx-ui/components/cursor-pagination` presenta anterior,
diff --git a/docs/configuracion-columnas-l02-2.md b/docs/configuracion-columnas-l02-2.md
new file mode 100644
index 0000000..3721651
--- /dev/null
+++ b/docs/configuracion-columnas-l02-2.md
@@ -0,0 +1,210 @@
+# L02.2 — Configuración avanzada de columnas (V08)
+
+Fecha: 22 de octubre de 2026. Segunda entrega de [L01](auditoria-listados-l01.md), sobre la composición de [L02.1](selectores-accesibilidad-l02-1.md).
+
+## 1. Resumen de implementación
+
+**V08 resuelto en los escenarios verificados**, con ocho pruebas MCP específicas y cuatro regresiones conjuntas correctas.
+
+EntityTable incorpora búsqueda local de columnas, agrupación semántica opcional, contador de visibilidad efectiva y restauración inmediata. Activos aporta cinco grupos; Recursos, Personas y los selectores sencillos conservan una presentación sin categorías artificiales. No cambia la ordenación real de columnas ni se añade un borrador Aplicar/Cancelar.
+
+La corrección se limita a V08. V05, V07 y V12 permanecen pendientes; la normalización del selector de columnas no cambia las búsquedas de entidades. No se modifican vendor, rutas, autenticación, contratos HTTP, persistencia del negocio, tokens ni dependencias.
+
+## 2. Arquitectura y API
+
+[EntityColumn](../packages/rdx-ui/src/components/entity-list.tsx) conserva `id`, `label`, `render`, `value`, `defaultSort`, `hideable` y `defaultVisible`. Añade opcionalmente `groupId?: string` y `keywords?: readonly string[]`.
+
+`EntityTable.columnGroups?: readonly EntityColumnGroup[]` recibe grupos `{ id, label, description? }`; su orden en el array es el orden de presentación. El consumidor aporta etiquetas y descripciones traducidas. No se implementa `align` en esta entrega.
+
+```tsx
+
+```
+
+[EntityTableConfiguration](../packages/rdx-ui/src/components/entity-table-configuration.tsx) transmite metadatos y grupos al [diálogo](../packages/rdx-ui/src/components/entity-table-columns-dialog.tsx). Los nuevos parámetros del diálogo/configurador son opcionales. `columnMetadata` es una colección de `{ id, groupId?, keywords? }`, sin DTO ni conceptos del catálogo. El diálogo sigue admitiendo una tabla TanStack y, sin callback específico, restaura mediante `table.resetColumnVisibility()`.
+
+La integración de EntityTable aporta `onResetColumns`, que vacía sus preferencias locales y permite que useEntityTable resuelva los defaults **actuales**, también cuando las columnas llegan después. No se duplica el estado de TanStack ni se usa un efecto para calcular resultados, grupos o contadores.
+
+## 3. Búsqueda
+
+[normalizeColumnSearch/groupColumnOptions](../packages/rdx-ui/src/lib/entity-column-configuration.ts) filtran por etiqueta o keywords, mediante coincidencia parcial inmediata. Recortan espacios externos, ignoran mayúsculas y tildes y conservan la diferencia entre ñ/n, también con Unicode descompuesto.
+
+La consulta solo vive en el diálogo y se limpia al cerrar desde sus acciones. El botón Limpiar devuelve foco al buscador. Sin coincidencias, el estado explica cómo recuperarse; búsqueda, limpieza, restauración y cierre continúan disponibles.
+
+Filtrar no cambia visibilidad, IDs, sort, filtros del inventario ni preferencias. El contador de coincidencias describe opciones encontradas; el resumen superior sigue describiendo columnas visibles del conjunto completo. Esta utilidad podría reutilizarse, tras definir el contrato de entidades en L03; **V07 no queda resuelto**.
+
+## 4. Agrupación
+
+Los grupos de Activos son:
+
+| ID | Castellano | Inglés | Contenido |
+| --- | --- | --- | --- |
+| `general` | General | General | Tipo, nombre, plantilla, puesto, notas y acciones |
+| `state-location` | Estado y ubicación | Status and location | Estado mostrado/técnico, sede y asignación |
+| `audit` | Auditoría | Audit | Fecha de alta y fechas/autores de revisiones |
+| `infrastructure` | Infraestructura | Infrastructure | Funciones, interfaces, direcciones IP/MAC y notas de interfaz |
+| `characteristics` | Características del catálogo | Catalog characteristics | Campos activos de catálogo e instantáneas conservadas |
+
+La primera declaración de un ID repetido de grupo o metadato prevalece. Las opciones se deduplican por ID; los grupos sin coincidencias desaparecen. Columnas sin grupo o con grupo desconocido siguen disponibles al final, bajo «Otras columnas» cuando se han proporcionado categorías. Sin categorías, hay una lista sencilla sin leyenda artificial.
+
+Los metadatos cambian la organización del selector, nunca la posición de las columnas en la tabla.
+
+## 5. Columnas dinámicas
+
+[assetCharacteristicColumns](../modules/assets/src/web/assets/shared/helpers/asset-characteristic-columns.ts) conserva los IDs `characteristic-`, la unión de campos activos con instantáneas y los ordinales para etiquetas iguales de campos diferentes.
+
+Cada campo pertenece al grupo principal estable `characteristics`. No se elige arbitrariamente una plantilla o sección como propietario visual. Keywords reúnen los nombres de todas sus plantillas/secciones, etiquetas históricas, descripción y unidad del campo activo. Un campo compartido mantiene un único checkbox. Los contextos se ordenan y las instantáneas se recorren por ID de activo para mantener una etiqueta histórica determinista cuando no hay definición activa.
+
+El render de celdas continúa utilizando el tipo y la unidad de la instantánea; estos metadatos no cambian valores, sincronización de plantillas ni conservación de campos retirados.
+
+## 6. Visibilidad y obligatoriedad
+
+`hideable:false` y las protecciones de useEntityTable siguen prevaleciendo sobre preferencias incompatibles. En Activos permanecen obligatorias Tipo de activo, Nombre, Puesto de trabajo y Acciones. Se muestran marcadas, deshabilitadas y con «Obligatoria»; una descripción accesible explica que siempre permanecen visibles.
+
+El resumen incluye todas las columnas expuestas, también las obligatorias y Acciones. La columna interna `selection` de pickers se excluye del configurador y su contador: sigue obligatoria en la tabla. Tipo de activo se incluye porque representa información de datos, aunque su cabecera visual sea un icono.
+
+Los totales del catálogo comprobado son **75 columnas: 20 base/adicionales/acciones y 55 características**. Ocho están visibles inicialmente. El contador no representa coincidencias de búsqueda ni filas del inventario.
+
+## 7. Restauración
+
+Restaurar columnas aplica inmediatamente el conjunto inicial definido por `defaultVisible`, la visibilidad predeterminada y las protecciones de cada consumidor. No equivale a mostrar todas las columnas. Se conservan búsqueda y orden del listado y la consulta local del diálogo.
+
+El callback de EntityTable restablece las preferencias a `{}`: cada columna disponible recupera su default actual; las retiradas no reaparecen y las nuevas respetan su definición. Cerrar termina la interacción sin revertir cambios. No se añaden Aplicar/Cancelar ni escrituras de negocio.
+
+## 8. Persistencia
+
+Se mantiene `asset-inventory-columns`, su alcance existente entre compañías del navegador y el formato plano `{ "column-id": boolean }`. La restauración persiste `{}` mediante el escritor existente, sin borrar otras claves ni migrar globalmente preferencias.
+
+Sin preferencia, con valores parciales o con IDs desconocidos, el hook mantiene los defaults y solo presenta columnas reales. Los valores protegidos incompatibles no afectan a la identidad. La prueba de restauración verifica recarga y conservación de una clave ajena. Datos inválidos y storage inaccesible siguen permitiendo operar localmente.
+
+Los listados sin `columnVisibilityStorageKey`, incluidos Recursos y Personas, conservan su configuración local durante su montaje; esta entrega no les añade persistencia.
+
+## 9. Accesibilidad
+
+Se compone FormDialog con Dialog/Base UI, Checkbox, InputGroup y FieldSet/FieldLegend ya instalados. No hay listeners globales ni focus traps propios. El buscador tiene etiqueta accesible, foco inicial y limpieza con retorno de foco. Las categorías tienen leyendas semánticas y las descripciones opcionales están asociadas a su grupo.
+
+Las etiquetas de checkbox excluyen el indicador visual redundante «Obligatoria» de su nombre y lo complementan con una descripción accesible. Tab, Shift+Tab, Enter, Espacio, Escape, cierre y retorno al activador se verifican mediante MCP; el último control conserva foco visible al desplazar las opciones. El diálogo de columnas también se comprueba dentro de un picker sin cerrar el formulario padre.
+
+No se certifica WCAG ni se atribuye a estas pruebas una validación con lector de pantalla.
+
+## 10. Responsive y reproducción inicial
+
+Las medidas previas se tomaron en esta instalación, **antes de cambiar el código funcional**, y se conservan en `before/measurements.json`:
+
+| Resolución | Antes: modal / área con scroll | Después: modal / área de opciones |
+| --- | --- | --- |
+| 320×640 | 288×608; 468 px de área, lista plana | 288×608; 336 px útiles para opciones, búsqueda y restauración fuera del scroll |
+| 1440×900 | 576×868; 728 px de área, lista plana | 576×765; altura exterior estable según el patrón picker |
+
+Antes había 75 checkbox, cero buscadores de texto, cero grupos y foco inicial en Plantilla. Se comprobó ocultar Sede y recargar: `{ "site": false }` se conserva. En móvil había entonces siete visibles por esa elección previa, no por pérdida de un default.
+
+Mediciones de la ejecución final, en píxeles CSS:
+
+| Resolución | Alto exterior | Alto útil de opciones | Filas completas iniciales |
+| --- | --- | --- | --- |
+| 320×640 | 608.0 | 336 | 6 |
+| 390×844 | 812.0 | 540 | 10 |
+| 768×1024 | 870.4 | 626 | 12 |
+| 1024×768 | 652.8 | 409 | 8 |
+| 1366×768 | 652.8 | 409 | 8 |
+| 1440×900 | 765.0 | 521 | 10 |
+| 1920×1080 | 900.0 | 656 | 13 |
+
+Después se verifican 320×640, 390×844, 768×1024, 1024×768, 1366×768, 1440×900 y 1920×1080. FormBody conserva la cadena flex/min-height y desplazamiento interno. Buscar o vaciar resultados no cambia la altura; la lista no colapsa, ni aparece overflow horizontal global. No se cambian tamaños globales de Dialog.
+
+## 11. Pruebas automatizadas
+
+Se amplían las pruebas existentes de rdx-ui y columnas de activos. Cubren normalización, ñ/n, keywords, grupos opcionales/múltiples/vacíos/desconocidos/repetidos, unicidad y contextos compartidos, defaults, campos nuevos/retirados, storage inválido/bloqueado y aislamiento por clave.
+
+[scripts/columns-ui.test.py](../scripts/columns-ui.test.py) utiliza unittest y el cliente MCP stdio existente. [La fixture React](../packages/rdx-ui/src/column-browser-fixture.tsx) se importa exclusivamente desde Vite durante la prueba, sin rutas ni importación de producción: tres columnas, keywords, campo nuevo y campo retirado.
+
+La suite de navegador comprueba catálogo real, siete resoluciones, visibilidad inmediata, filtrado sin mutaciones, reapertura sin filtro, persistencia/recarga/restauración, inglés/español, teclado, scroll y consumidores sencillos/anidados. La fixture de storage bloqueado afecta solo a la clave de prueba.
+
+## 12. Entorno, evidencias y reproducción Playwright
+
+Instancia única de `scripts/visual-audit.py`, MariaDB desechable `metis_desk_test`, usuario ordinario y login real. [visual-audit-volume.py](../scripts/visual-audit-volume.py) amplía exclusivamente la compañía sintética del informe, mediante API HTTP real con sesión y CSRF válidos. Resultado: **15 compañías; 50 personas, 51 activos y 66 recursos en la principal**. Repetir conserva esos recuentos. Incluye nombres extensos/con tildes, activos de distintas plantillas/estados y recursos activos/inactivos; no simula permisos ni identidades de dominio.
+
+```bash
+python3 scripts/visual-audit.py
+# En otra terminal, utilizar la ruta privada que imprime el preparador:
+PYTHONDONTWRITEBYTECODE=1 METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
+ python3 scripts/visual-audit-volume.py
+PYTHONDONTWRITEBYTECODE=1 METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
+ python3 scripts/columns-ui.test.py
+```
+
+Mantener abierto el preparador; cerrarlo al terminar para retirar solo sus servicios y sesión. El guion de tests no inicia entornos ni crea entidades y bloquea escrituras HTTP de negocio. Las preferencias solo afectan al navegador aislado. No copiar el contenido de `session.json` a Git, logs ni mensajes.
+
+Evidencias ignoradas en [.artifacts/playwright/l02-2/](../.artifacts/playwright/l02-2/): capturas antes/después, snapshots, mediciones por ejecución, consola, comprobaciones técnicas e índice con dimensiones y SHA-256. El índice conserva 134 capturas, incluidas ejecuciones intermedias; `verification.json` identifica la ejecución completa final y las regresiones correctas. Se abren e inspeccionan capturas representativas de móvil/escritorio, inglés, búsqueda, campo compartido, foco y paneles antes de cerrar la entrega. MCP se ejecuta realmente por stdio dentro de la imagen Docker fijada del proyecto; no se instala Chromium en WSL.
+
+Capturas representativas inspeccionadas:
+
+- Antes: [escritorio](../.artifacts/playwright/l02-2/before/columns-1440.png) y [móvil](../.artifacts/playwright/l02-2/before/columns-320.png).
+- Después: [escritorio](../.artifacts/playwright/l02-2/after/assets-1440.png) y [320×640](../.artifacts/playwright/l02-2/after/assets-320.png).
+- [Inglés en móvil](../.artifacts/playwright/l02-2/after/assets-english-320.png), [campo compartido único](../.artifacts/playwright/l02-2/after/shared-field-one-option.png) y [foco al final del scroll](../.artifacts/playwright/l02-2/after/scroll-focus-last-320.png).
+- Regresión: [selección/contraste de activos](../.artifacts/playwright/l02-2/regression-verified/contrast-selected-1920.png) y [panel de recursos en W=984](../.artifacts/playwright/l02-2/regression-verified/resource-capacity-984.png).
+
+## 13. Resultados técnicos y regresiones
+
+Resultados realmente ejecutados:
+
+| Comprobación | Resultado |
+| --- | --- |
+| Suite MCP L02.2 | **8/8 correctas**, 160,367 s en la ejecución completa final. |
+| Siete resoluciones | Correctas; entre 336 y 656 px útiles en el área de opciones. |
+| Campo compartido real | Marca, usado por doce plantillas: una opción por ID; localizable por varios contextos. |
+| Teclado y scroll | Último checkbox con foco visible, scrollTop 3064; recorrido de 85 pulsaciones Tab sin salir del diálogo tras asentarse Base UI. |
+| Persistencia y restauración | Aplicación inmediata, recarga, defaults, claves ajenas intactas, campos nuevos/retirados y storage bloqueado: correctos en navegador. |
+| `pnpm --filter @repo/rdx-ui test` | Correcto: 11 archivos. |
+| `pnpm --filter @metis/assets test` | Correcto: 27 archivos; repetido tras ampliar el caso de instantáneas históricas. |
+| `pnpm --filter @metis/resources test` | Correcto: 11 archivos. |
+| Typecheck dirigido rdx-ui, activos, recursos, compañías y web | Correcto en los cinco paquetes. |
+| Biome dirigido y ESLint de espaciado | Correctos sobre los 15 archivos propios TS/TSX/JSON modificados/nuevos (ESLint sobre los TS/TSX). |
+| `pnpm typecheck` | Falla en vendor `calendar.tsx:165`, incompatibilidad previa de `locale`/exactOptionalPropertyTypes. |
+| `pnpm lint` | Falla: 15 errores, un aviso y cuatro informaciones, ajenos al cambio y coincidentes con L02.1. |
+| `pnpm check` | Falla: 168 errores, un aviso y cuatro informaciones previos; comprobación dirigida correcta. |
+| `pnpm test`, MariaDB temporal independiente | Falla en dos expectativas antiguas de compañías; 7 tareas correctas de 8. No se presenta como suite global superada. |
+| `pnpm build` | Correcto, dos tareas; conserva aviso de chunk web superior a 500 kB. |
+| Preparación de volumen repetida | Correcta: 15 compañías, 50 personas, 51 activos y 66 recursos; sin duplicados. |
+| Regresión MCP V01.3/V01.4, V02 y asistentes | **4/4 correctas**, 158,944 s; contraste, ambos presupuestos/paneles y geometría de asistentes. |
+| Consola suite MCP final | Cero errores y advertencias; ninguna escritura HTTP de negocio intentada. |
+
+Las primeras ejecuciones del guion detectaron errores de instrumentación: nombre «Campo» en la fixture de selector, comillas en un localizador y comprobación de foco durante una transferencia transitoria de Base UI. Se corrigieron los tests y se repitió la suite completa. No son defectos funcionales de Metis ni se cuentan como pruebas correctas esos intentos.
+
+Las regresiones antiguas de contraste/capacidad buscaban AUDIT-PORT-01 en la primera página; al preparar 51 activos dejó de estar ahí. Se utiliza la búsqueda real antes de esos dos escenarios. La ejecución conjunta final obtiene **4/4 correctas**, 158,944 s (`browser-regression-verified.log`), con lecturas mediante Locator que esperan la fila/tabla real antes de medir. Las ejecuciones intermedias con errores quedan conservadas y no se presentan como suites correctas. La espera de elementos no modifica la aplicación ni resuelve V12. Las pruebas de asistente y recursos conservan sus operaciones de lectura/cancelación; no guardan cambios.
+
+Durante la revisión se corrigió también la sustitución de parámetros del nuevo preparador de volumen: los placeholders ya no pueden alterar el prefijo de los nombres de compañía. Se normalizaron mediante HTTP las catorce compañías **creadas por esta fixture en la instalación desechable**, sin borrar registros, y se repitió la preparación para verificar los recuentos e idempotencia.
+
+Los fallos globales se comparan con L02.1: `calendar.tsx:165` de vendor en typecheck, 15 errores de lint, 168 de check y las dos expectativas antiguas de compañías («Personas (1)» y aviso de persona sin acceso). No se corrige código ajeno para presentar una suite global verde. El test global usa otra MariaDB nueva y temporal, separada del entorno visual, con cleanup propio.
+
+## 14. Limitaciones y skills
+
+No se introducen reordenación, densidad, alineación semántica, cambios de búsqueda de entidades, restauración completa de contexto ni skeletons. La persistencia sigue compartida entre compañías por el contrato previo; no se transforma en preferencia de servidor ni por usuario autenticado. Los datos voluminosos no constituyen una prueba de carga o rendimiento de producción.
+
+Skill principal: `ux-tables` para personalización y lectura de tablas. Complementarias leídas: `ux-search` para búsqueda secundaria y recuperación, `shadcn` para composición Base UI y componentes instalados, `ux-microcopy` para contadores/obligatoriedad/restauración, `vercel-composition-patterns` para metadatos genéricos sin flags de entidad y `vercel-react-best-practices` para estado derivado sin efectos. Se consultan sus referencias aplicables de estado derivado/eventos, composición y formularios; prevalecen las APIs locales y los patrones Metis.
+
+No se necesita `frontend-design` ni `ui-ux-pro-max` para cambiar jerarquía o resolver otra duda. Los auditores especializados `*-audit` y `docs/orchestration-policy.md` referenciados por las skills no están instalados; no se instalan herramientas ni se presentan esos auditores como ejecutados. No se ejecuta el CLI de shadcn ni se descarga otra versión; se utilizan las primitivas locales sin regenerar vendor.
+
+## 15. Preparación de L02.3
+
+El selector dispone de metadatos opcionales y restauración por consumidor; los listados sin categorías no necesitan migración. L02.3 requiere un encargo propio. **V05, V07 y V12 permanecen pendientes**; no se adelanta reordenación, densidad ni otra entrega del programa.
+
+## Archivos principales
+
+| Área | Archivos modificados o creados |
+| --- | --- |
+| rdx-ui | `components/entity-list.tsx`, `entity-table-configuration.tsx`, `entity-table-columns-dialog.tsx`; nuevo `lib/entity-column-configuration.ts`; `lib/entity-list.ts`; traducciones `locales/es.json` y `en.json`. |
+| Activos | `assets/shared/ui/asset-list.tsx`; helpers `asset-additional-columns.tsx`, `asset-characteristic-columns.ts`; traducciones comunes es/en. |
+| Pruebas | `entity-column-visibility.test.tsx`, `asset-columns.test.tsx`; nueva fixture `column-browser-fixture.tsx`; nuevos `scripts/columns-ui.test.py`, `scripts/visual-audit-volume.py`; adaptación acotada de `scripts/visual-audit-ui.test.py` a volumen y lecturas de DOM mediante Locator. |
+| Documentación | Este informe, DESIGN.md, auditorías L01/visual, patrón de listados, componentes y Playwright MCP. |
+
+No se modifican `useEntityTable`, el contrato de almacenamiento ni EntityTableContent: sus protecciones, resolución de defaults, persistencia y render existentes permiten implementar la mejora desde la composición. No hay cambios en `packages/shadcn-ui`, package.json, lockfiles o AGENTS.md.
+
+Cierre: se detuvo el preparador propio; retiró sus servicios temporales y eliminó `session.json`. Se verificó la ausencia del archivo de sesión. Las capturas e informes sin credenciales permanecen en `.artifacts/playwright/l02-2/`, excluidos de Git.
diff --git a/docs/patron-de-listados-de-entidades.md b/docs/patron-de-listados-de-entidades.md
index 94887fa..00cc313 100644
--- a/docs/patron-de-listados-de-entidades.md
+++ b/docs/patron-de-listados-de-entidades.md
@@ -49,6 +49,25 @@ El pie opcional está fuera de la tabla y solo aparece cuando existe contenido c
Cuando un listado filtra familias mediante pestañas, utilizar `ScrollableTabsList` de `@repo/rdx-ui` dentro de `Tabs`. La barra mantiene una sola fila limitada al ancho del listado, con desplazamiento horizontal, controles laterales solo cuando sean necesarios y la pestaña activa visible. Mantener `min-w-0` en la raíz y el contenido para que ni las etiquetas ni la tabla ensanchen la página. La selección conserva las reglas de URL, filtros y paginación del listado. Véase [pestañas horizontales desplazables](componentes-de-interfaz.md#pestañas-horizontales-desplazables).
+## Configuración de columnas
+
+`EntityTable` reutiliza el diálogo de [L02.2](configuracion-columnas-l02-2.md): búsqueda
+por etiqueta/keywords, contador de visibilidad efectiva y Restaurar columnas, con aplicación
+inmediata y Cerrar. La búsqueda solo filtra opciones; no modifica columnas, filtros de
+datos ni ordenación. Se limpia al terminar la interacción.
+
+El consumidor puede aportar `EntityColumn.groupId`, `keywords` y `columnGroups`
+(`id`, `label`, `description?`), en orden de presentación y con textos traducidos.
+No imponer grupos a listados sencillos ni duplicar un campo compartido. Conservar IDs
+estables y definir un grupo principal determinista; contextos alternativos pueden ser keywords.
+
+Restaurar recupera los defaults actuales del consumidor, no todas las columnas; respeta
+`hideable:false` y persiste mediante la clave propia existente, sin afectar otras tablas.
+Las obligatorias muestran estado deshabilitado e indicación accesible. El resumen cuenta
+columnas expuestas, incluidas obligatorias/acciones, pero excluye `selection`, control interno
+del picker. Usar altura estable, búsqueda y cierre accesibles y scroll interno de opciones;
+comprobar la superficie operativa en móvil. No se incluye reordenación ni densidad.
+
## Celdas sin valor
En cualquier listado, tabla, sublistado o PickerList, una celda sin valor muestra exclusivamente un guion largo (`—`, U+2014), cualquiera que sea la causa de la ausencia. No mostrar «Sin indicar», «Sin valor», «Sin especificar», «Sin asignación», «Sin fecha», «Sin logotipo» ni equivalentes en otros idiomas. El signo es común a es/en y no requiere traducción.
diff --git a/docs/playwright-mcp.md b/docs/playwright-mcp.md
index 4942290..39d6239 100644
--- a/docs/playwright-mcp.md
+++ b/docs/playwright-mcp.md
@@ -207,6 +207,27 @@ Una terminación forzada con SIGKILL o un fallo del daemon puede impedir el clea
La base es desechable, pero una auditoría sigue siendo de lectura salvo que la tarea autorice probar guardados. El conjunto inicial solo contiene una compañía y un activo: otras pantallas pueden necesitar fixtures adicionales explícitas. El usuario ordinario no permite auditar administración de usuarios. La sesión preparada se utiliza con el CLI WSL; no se ha comprobado su carga en extensión o Desktop. Tampoco se ha ejecutado esta modalidad en Docker Desktop o ARM. No hay navegador instalado en WSL ni cambios de seguridad de producción.
+## Volumen sintético para pruebas de listados
+
+Con `visual-audit.py` abierto, ampliar su instalación desechable según AGENTS:
+
+```bash
+PYTHONDONTWRITEBYTECODE=1 METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
+ python3 scripts/visual-audit-volume.py
+```
+
+Utilizar la ruta privada que imprima el preparador. El script comprueba origen local,
+usuario ordinario y la compañía sintética del informe; utiliza HTTP real y CSRF por MCP.
+Reutiliza registros por nombre: prepara 15 compañías y, en la principal, al menos 50
+personas, 50 activos y 50 recursos. No carga `.env`, no crea usuarios ni guarda cookies
+en el informe de volumen. El contenedor del preparador sigue siendo el único propietario
+de esos datos, exclusivamente `metis_desk_test`; cerrarlo los retira con su sesión.
+
+La [suite de columnas L02.2](configuracion-columnas-l02-2.md) lee ese entorno, bloquea
+escrituras de negocio y verifica la configuración mediante preferencias del navegador
+aislado. Esta preparación complementa las fixtures pequeñas de casos límite; no
+convierte el recorrido básico del preparador en una prueba de volumen ni de carga.
+
## Conectividad y origen
La URL del navegador y `APP_ORIGIN` deben coincidir exactamente en esquema, host y puerto. `localhost` y `127.0.0.1` son orígenes distintos aunque alcancen el mismo servicio. Si se usa el origen LAN del proyecto, comprobar ese origen y la configuración de la API; no desactivar sus protecciones.
diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json
index e40216f..f79c86e 100644
--- a/modules/assets/src/common/locales/en.json
+++ b/modules/assets/src/common/locales/en.json
@@ -544,7 +544,14 @@
"ipAddresses": "Interface IP addresses",
"macAddresses": "Interface MAC addresses",
"interfaceNotes": "Interface notes",
- "infrastructureError": "Infrastructure data for the optional columns could not be loaded."
+ "infrastructureError": "Infrastructure data for the optional columns could not be loaded.",
+ "groups": {
+ "general": "General",
+ "stateLocation": "Status and location",
+ "audit": "Audit",
+ "infrastructure": "Infrastructure",
+ "characteristics": "Catalog characteristics"
+ }
},
"directoryMembership": {
"domain": "Windows domain",
diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json
index 3af13a4..d81f2a3 100644
--- a/modules/assets/src/common/locales/es.json
+++ b/modules/assets/src/common/locales/es.json
@@ -544,7 +544,14 @@
"ipAddresses": "Direcciones IP de interfaces",
"macAddresses": "Direcciones MAC de interfaces",
"interfaceNotes": "Notas de interfaces",
- "infrastructureError": "No se pudieron cargar los datos de infraestructura de las columnas opcionales."
+ "infrastructureError": "No se pudieron cargar los datos de infraestructura de las columnas opcionales.",
+ "groups": {
+ "general": "General",
+ "stateLocation": "Estado y ubicación",
+ "audit": "Auditoría",
+ "infrastructure": "Infraestructura",
+ "characteristics": "Características del catálogo"
+ }
},
"directoryMembership": {
"domain": "Dominio Windows",
diff --git a/modules/assets/src/web/assets/shared/helpers/asset-additional-columns.tsx b/modules/assets/src/web/assets/shared/helpers/asset-additional-columns.tsx
index b1476e7..22fc1de 100644
--- a/modules/assets/src/web/assets/shared/helpers/asset-additional-columns.tsx
+++ b/modules/assets/src/web/assets/shared/helpers/asset-additional-columns.tsx
@@ -20,18 +20,21 @@ export function assetAdditionalColumns({
const columns: EntityColumn[] = [
{
id: "technicalStatus",
+ groupId: "state-location",
label: t("assetColumns.technicalStatus"),
value: (asset) => t(asset.values.status),
render: (asset) => t(asset.values.status),
},
{
id: "assigned",
+ groupId: "state-location",
label: t("assetColumns.assigned"),
value: (asset) => asset.assigned,
render: (asset) => t(asset.assigned ? "yes" : "no"),
},
{
id: "notes",
+ groupId: "general",
label: t("notes"),
value: (asset) => asset.values.notes,
render: (asset) =>
@@ -46,6 +49,7 @@ export function assetAdditionalColumns({
return [
{
id: `${event}At`,
+ groupId: "audit",
label: t(`assetColumns.${event}At`),
value: (asset) => revisionOf(asset)?.recordedAt,
render: (asset) => {
@@ -56,6 +60,7 @@ export function assetAdditionalColumns({
},
{
id: `${event}By`,
+ groupId: "audit",
label: t(`assetColumns.${event}By`),
value: (asset) => revisionOf(asset)?.actorName,
render: (asset) => revisionOf(asset)?.actorName,
@@ -64,6 +69,7 @@ export function assetAdditionalColumns({
}),
{
id: "infrastructureRoles",
+ groupId: "infrastructure",
label: t("assetColumns.roles"),
value: (asset) =>
byAsset
@@ -97,6 +103,7 @@ export function assetAdditionalColumns({
return {
id: kind,
+ groupId: "infrastructure",
label: t(`assetColumns.${kind}`),
value: (asset) => lines(asset).join(" · "),
render: (asset) => {
diff --git a/modules/assets/src/web/assets/shared/helpers/asset-characteristic-columns.ts b/modules/assets/src/web/assets/shared/helpers/asset-characteristic-columns.ts
index d2ecb10..887f5d6 100644
--- a/modules/assets/src/web/assets/shared/helpers/asset-characteristic-columns.ts
+++ b/modules/assets/src/web/assets/shared/helpers/asset-characteristic-columns.ts
@@ -19,19 +19,40 @@ export function assetCharacteristicColumns({
t: TFunction<"assets">;
}): EntityColumn[] {
const fields = new Map>();
+ const contexts = new Map>();
- for (const asset of assets) {
+ function addContext(fieldId: string, ...labels: string[]) {
+ const current = contexts.get(fieldId) ?? new Set();
+
+ for (const label of labels) {
+ if (label) {
+ current.add(label);
+ }
+ }
+ contexts.set(fieldId, current);
+ }
+
+ // Las instantáneas retiradas tienen una etiqueta determinista aunque cambie el orden de las filas.
+ for (const asset of [...assets].sort((first, second) => first.id.localeCompare(second.id))) {
for (const field of asset.specification.fields) {
if (!fields.has(field.fieldId)) {
fields.set(field.fieldId, field);
}
+ addContext(
+ field.fieldId,
+ field.name,
+ asset.specification.templateName,
+ asset.specification.sections?.find((section) => section.id === field.sectionId)?.name ?? ""
+ );
}
}
for (const item of catalog) {
if (item.values.kind === "field" && item.effectiveActive) {
fields.set(item.id, { fieldId: item.id, ...item.values });
+ addContext(item.id, item.values.name, item.values.description, item.values.unit);
}
}
+ addTemplateContexts(catalog, addContext);
const collator = new Intl.Collator(locale, { sensitivity: "base", numeric: true });
const sorted = [...fields.values()].sort(
(a, b) => collator.compare(a.name, b.name) || a.fieldId.localeCompare(b.fieldId)
@@ -50,6 +71,9 @@ export function assetCharacteristicColumns({
return {
id: `characteristic-${field.fieldId}`,
+ // Un grupo principal común evita duplicar campos compartidos entre secciones o plantillas.
+ groupId: "characteristics",
+ keywords: [...(contexts.get(field.fieldId) ?? [])].sort(collator.compare),
label: labels.filter((item) => item === label).length > 1 ? `${label} (${ordinal})` : label,
defaultVisible: false,
value: (asset) =>
@@ -66,6 +90,23 @@ export function assetCharacteristicColumns({
});
}
+function addTemplateContexts(
+ catalog: readonly CatalogItemDTO[],
+ addContext: (fieldId: string, ...labels: string[]) => void
+) {
+ for (const item of catalog) {
+ if (item.values.kind === "template") {
+ for (const field of item.values.fields) {
+ addContext(
+ field.fieldId,
+ item.values.name,
+ item.values.sections?.find((section) => section.id === field.sectionId)?.name ?? ""
+ );
+ }
+ }
+ }
+}
+
function formatCharacteristic(
field: AssetCharacteristic | undefined,
value: AssetDTO["values"]["characteristics"][string] | undefined,
diff --git a/modules/assets/src/web/assets/shared/helpers/asset-columns.test.tsx b/modules/assets/src/web/assets/shared/helpers/asset-columns.test.tsx
index 315926e..100c342 100644
--- a/modules/assets/src/web/assets/shared/helpers/asset-columns.test.tsx
+++ b/modules/assets/src/web/assets/shared/helpers/asset-columns.test.tsx
@@ -7,6 +7,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { CatalogItemSchema } from "../../../../common";
import { InfrastructureSchema } from "../../../../common/infrastructure";
+import type { AssetDTO } from "../../../../common/inventory";
import { AssetSchema } from "../../../../common/inventory";
import { assetsEnglish, assetsSpanish } from "../../../shared";
@@ -223,3 +224,75 @@ test("columnas generales y de red es/en: fechas y autores propios, IP/MAC por in
);
}
});
+
+test("los campos compartidos conservan un único ID y todos sus contextos de catálogo e instantánea", async () => {
+ const template = (identity: string, name: string, sectionName: string) =>
+ CatalogItemSchema.parse({
+ id: identity,
+ companyId: null,
+ builtin: false,
+ active: true,
+ effectiveActive: true,
+ overridden: false,
+ bindings: [],
+ values: {
+ kind: "template",
+ name,
+ description: "",
+ sections: [{ id: id(80), name: sectionName, description: "" }],
+ fields: [{ fieldId: id(10), required: false, sectionId: id(80) }],
+ },
+ });
+
+ const catalog = [
+ template(id(81), "Servidor", "Memoria"),
+ template(id(82), "Sobremesa", "Rendimiento"),
+ ];
+ const t = await translator("es");
+ const columns = assetCharacteristicColumns({ assets: [asset, asset], catalog, locale: "es", t });
+ const ram = columns.find((column) => column.id === `characteristic-${id(10)}`);
+
+ assert.equal(columns.filter((column) => column.id === ram?.id).length, 1);
+ assert.equal(ram?.groupId, "characteristics");
+ for (const keyword of ["Servidor", "Sobremesa", "Memoria", "Rendimiento", "Portátil", "RAM"]) {
+ assert.ok(ram?.keywords?.includes(keyword), keyword);
+ }
+ assert.deepEqual(
+ assetCharacteristicColumns({
+ assets: [asset],
+ catalog: [...catalog].reverse(),
+ locale: "es",
+ t,
+ }).map((column) => [column.id, column.label, column.keywords]),
+ columns.map((column) => [column.id, column.label, column.keywords])
+ );
+
+ const historicalAsset = {
+ ...asset,
+ id: id(88),
+ specification: {
+ ...asset.specification,
+ templateName: "Plantilla retirada",
+ fields: asset.specification.fields.map((field) =>
+ field.fieldId === id(10) ? { ...field, name: "Memoria histórica" } : field
+ ),
+ },
+ };
+
+ const historicalColumns = (items: readonly AssetDTO[]) =>
+ assetCharacteristicColumns({ assets: items, catalog: [], locale: "es", t }).map((column) => [
+ column.id,
+ column.label,
+ column.keywords,
+ ]);
+
+ assert.deepEqual(
+ historicalColumns([asset, historicalAsset]),
+ historicalColumns([historicalAsset, asset])
+ );
+ assert.ok(
+ assetCharacteristicColumns({ assets: [historicalAsset, asset], catalog: [], locale: "es", t })
+ .find((column) => column.id === `characteristic-${id(10)}`)
+ ?.keywords?.includes("Memoria histórica")
+ );
+});
diff --git a/modules/assets/src/web/assets/shared/ui/asset-list.tsx b/modules/assets/src/web/assets/shared/ui/asset-list.tsx
index 0d33a6e..455b374 100644
--- a/modules/assets/src/web/assets/shared/ui/asset-list.tsx
+++ b/modules/assets/src/web/assets/shared/ui/asset-list.tsx
@@ -104,10 +104,18 @@ export function AssetList({
value={templateId ?? "all"}
>
{
@@ -123,6 +131,7 @@ export function AssetList({
},
{
id: "name",
+ groupId: "general",
label: t("name"),
value: (asset) => asset.values.name,
hideable: false,
@@ -150,6 +159,7 @@ export function AssetList({
},
{
id: "template",
+ groupId: "general",
label: t("template"),
value: (asset) => asset.specification.templateName,
render: (asset) => (
@@ -158,6 +168,7 @@ export function AssetList({
},
{
id: "state",
+ groupId: "state-location",
label: t("status"),
value: (asset) => t(asset.displayStatus),
render: (asset) => (
@@ -168,10 +179,19 @@ export function AssetList({
),
},
- { id: "site", label: t("site"), value: siteName, render: siteName },
- ...extraColumns.filter((column) => column.id !== "actions"),
+ {
+ id: "site",
+ groupId: "state-location",
+ label: t("site"),
+ value: siteName,
+ render: siteName,
+ },
+ ...extraColumns
+ .filter((column) => column.id !== "actions")
+ .map((column) => ({ ...column, groupId: column.groupId ?? "general" })),
{
id: "registeredOn",
+ groupId: "audit",
label: t("registeredOn"),
value: (asset) => asset.values.registeredOn ?? "",
render: (asset) => {
@@ -186,7 +206,9 @@ export function AssetList({
},
...assetAdditionalColumns({ infrastructure, locale: i18n.language, t }),
...assetCharacteristicColumns({ assets, catalog, locale: i18n.language, t }),
- ...extraColumns.filter((column) => column.id === "actions"),
+ ...extraColumns
+ .filter((column) => column.id === "actions")
+ .map((column) => ({ ...column, groupId: "general" })),
]}
columnVisibilityStorageKey={columnVisibilityStorageKey}
emptyMessage={t("noAssets")}
diff --git a/packages/rdx-ui/src/column-browser-fixture.tsx b/packages/rdx-ui/src/column-browser-fixture.tsx
new file mode 100644
index 0000000..af6efd4
--- /dev/null
+++ b/packages/rdx-ui/src/column-browser-fixture.tsx
@@ -0,0 +1,67 @@
+import { Button } from "@repo/shadcn-ui/components/button";
+import { useState } from "react";
+import { createRoot } from "react-dom/client";
+
+import type { EntityColumn } from "./components/entity-list";
+import { EntityTable } from "./components/entity-list";
+
+/** Fixture importada solo por Vite desde las pruebas MCP; no registra rutas ni endpoints. */
+function ColumnBrowserFixture({ locale }: { locale: string }) {
+ const [catalog, setCatalog] = useState("initial");
+ const english = locale.startsWith("en");
+ const columns: EntityColumn<{ id: string; name: string }>[] = [
+ {
+ id: "name",
+ label: english ? "Name" : "Nombre",
+ hideable: false,
+ value: (item) => item.name,
+ render: (item) => item.name,
+ },
+ ...(catalog === "removed"
+ ? []
+ : [
+ {
+ id: "detail",
+ label: english ? "Notes" : "Notas",
+ keywords: ["revisión técnica"],
+ defaultVisible: false,
+ render: () => "Detalles",
+ },
+ ]),
+ { id: "year", label: english ? "Year" : "Año", value: () => 2026, render: () => 2026 },
+ ...(catalog === "new"
+ ? [
+ {
+ id: "late",
+ label: english ? "New field" : "Campo nuevo",
+ defaultVisible: false,
+ render: () => "Campo posterior",
+ },
+ ]
+ : []),
+ ];
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export function mountColumnFixture(target: HTMLElement, locale: string) {
+ const root = createRoot(target);
+
+ root.render();
+
+ return root;
+}
diff --git a/packages/rdx-ui/src/components/entity-list.tsx b/packages/rdx-ui/src/components/entity-list.tsx
index bfee7f4..ca48a67 100644
--- a/packages/rdx-ui/src/components/entity-list.tsx
+++ b/packages/rdx-ui/src/components/entity-list.tsx
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import { useId } from "react";
import { useEntityTable } from "../hooks/use-entity-table";
+import type { EntityColumnGroup, EntityColumnMetadata } from "../lib/entity-column-configuration";
import { entityListLabels } from "../lib/entity-list";
import { EntityListPagination } from "./entity-list-controls";
@@ -49,7 +50,7 @@ export function EntityListHeader({
);
}
-export type EntityColumn = {
+export type EntityColumn = EntityColumnMetadata & {
id: string;
label: string;
render: (item: T) => ReactNode;
@@ -73,6 +74,7 @@ export function EntityTable({
onClearFilters,
filterKey = "",
columnVisibilityStorageKey,
+ columnGroups,
loading = false,
unavailable = false,
emptyMessage,
@@ -93,6 +95,7 @@ export function EntityTable({
onClearFilters?: () => void;
filterKey?: string;
columnVisibilityStorageKey?: string | undefined;
+ columnGroups?: readonly EntityColumnGroup[];
loading?: boolean;
unavailable?: boolean;
emptyMessage?: string;
@@ -140,7 +143,10 @@ export function EntityTable({
const configuration = (
table.setColumnVisibility({})}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
presentation={layout === "fill" ? "picker" : "full"}
table={table}
diff --git a/packages/rdx-ui/src/components/entity-table-columns-dialog.tsx b/packages/rdx-ui/src/components/entity-table-columns-dialog.tsx
index 75daaa4..f237518 100644
--- a/packages/rdx-ui/src/components/entity-table-columns-dialog.tsx
+++ b/packages/rdx-ui/src/components/entity-table-columns-dialog.tsx
@@ -1,49 +1,173 @@
+import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
+import { Field, FieldLabel, FieldLegend, FieldSet } from "@repo/shadcn-ui/components/field";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupInput,
+} from "@repo/shadcn-ui/components/input-group";
import type { Table as TableModel } from "@tanstack/react-table";
-import { useId } from "react";
+import { useId, useState } from "react";
+import type {
+ EntityColumnGroup,
+ EntityColumnMetadata,
+ EntityColumnOption,
+} from "../lib/entity-column-configuration";
+import { groupColumnOptions } from "../lib/entity-column-configuration";
import { entityListLabels } from "../lib/entity-list";
+import { SearchIcon, XIcon } from "../lib/icons";
-import { FormDialog } from "./form-dialog";
+import { FormBody, FormDialog } from "./form-dialog";
export function EntityTableColumnsDialog({
table,
locale,
open,
onOpenChange,
+ columnGroups = [],
+ columnMetadata = [],
+ onResetColumns,
}: {
table: TableModel;
locale: string;
open: boolean;
onOpenChange: (open: boolean) => void;
+ columnGroups?: readonly EntityColumnGroup[] | undefined;
+ columnMetadata?: readonly (EntityColumnMetadata & { id: string })[] | undefined;
+ onResetColumns?: (() => void) | undefined;
}) {
const id = useId();
const labels = entityListLabels(locale);
+ const [search, setSearch] = useState("");
+ const metadata = new Map([...columnMetadata].reverse().map((column) => [column.id, column]));
+ // La selección es un control interno del picker, no una columna de datos configurable.
+ const options: EntityColumnOption[] = table
+ .getAllLeafColumns()
+ .filter((column) => column.id !== "selection")
+ .map((column) => ({
+ ...metadata.get(column.id),
+ id: column.id,
+ label: String(column.columnDef.header),
+ visible: column.getIsVisible(),
+ hideable: column.getCanHide(),
+ }));
+ const groups = groupColumnOptions(options, columnGroups, search);
+ const visible = options.filter((column) => column.visible).length;
+ const matches = groups.reduce((total, group) => total + group.options.length, 0);
+
+ function changeOpen(nextOpen: boolean) {
+ setSearch("");
+ onOpenChange(nextOpen);
+ }
return (
-