feat: enhance workstation asset management interface

- Updated the asset selection process to allow direct selection of the main computer via `AssetSummaryCard` without opening a dialog.
- Improved the user interface for asset addition, ensuring a more intuitive experience with real-time feedback and error handling.
- Refactored the asset summary display to utilize `AssetSummaryCard`, providing a consistent look and feel across the application.
- Added support for loading states and error handling in asset picker components.
- Enhanced localization strings for clarity and consistency in both English and Spanish.
- Introduced new tests to cover the updated functionalities and ensure robustness.
This commit is contained in:
David Arranz 2026-10-16 11:41:38 +02:00
parent 2fc49470d6
commit 7777192d20
18 changed files with 733 additions and 130 deletions

View File

@ -332,7 +332,16 @@ TemplateSectionsEditor recibe secciones, referencias de campos, nombres, textos
`FormDialog` admite `size="small" | "medium" | "large"`. Los formularios sencillos mantienen el tamaño pequeño; personas y usuarios emplean el intermedio. `wide` sigue siendo un alias del tamaño grande: 90 % del ancho disponible, con máximo de 1.200 px y márgenes en móvil. No se ensanchan automáticamente las altas sencillas. `FormDialog` admite `size="small" | "medium" | "large"`. Los formularios sencillos mantienen el tamaño pequeño; personas y usuarios emplean el intermedio. `wide` sigue siendo un alias del tamaño grande: 90 % del ancho disponible, con máximo de 1.200 px y márgenes en móvil. No se ensanchan automáticamente las altas sencillas.
La cabecera permanece visible. Un formulario modal utiliza una raíz flex con altura mínima cero, `FormBody` para campos, errores y ayuda, y `FormActions` como hermano final fuera del cuerpo desplazable. Los botones permanecen dentro del formulario HTML original: se conservan envío por teclado, validación nativa, estados pendientes y cancelación. La ayuda queda al final del cuerpo, inmediatamente antes de las acciones. Los diálogos de consulta conservan el cuerpo desplazable y su pie externo. `FormDialog layout="stable"` reserva la altura exterior para formularios y asistentes
cuyo contenido cambia durante la interacción. Comparte con `layout="picker"` el tamaño
adaptado a la ventana: altura dinámica menos 2 rem en móvil y mínimo de 85dvh/900px desde
`sm`, limitado siempre por los márgenes. Conserva el espaciado normal del formulario,
con `FormBody` desplazable y `scrollbar-gutter: stable`; no distribuye los hijos como una
tabla. Cambiar de paso, añadir o retirar activos y desplegar ayuda no redimensiona el
modal. La cabecera y las acciones permanecen visibles. `layout="content"` queda para
contenido breve que no cambia de tamaño. Véase la [regla normativa de estabilidad](patron-de-dialogos.md#estabilidad-de-altura-del-modal).
La cabecera permanece visible. Un formulario modal utiliza una raíz flex con altura mínima cero, `FormBody` para campos y errores, y `FormActions` como hermano final fuera del cuerpo desplazable. Los botones permanecen dentro del formulario HTML original: se conservan envío por teclado, validación nativa, estados pendientes y cancelación. La ayuda queda inmediatamente antes de las acciones; en diálogos de altura estable ocupa un bloque anclado abajo fuera del scroll de campos, con altura limitada y desplazamiento propio si se despliega contenido extenso. Los diálogos de consulta conservan el cuerpo desplazable y su pie externo.
`FormSection` agrupa datos relacionados mediante título accesible; la variante `card` añade un borde discreto y `plain` conserva el bloque sin marco. Ambas utilizan `FormSectionTitle`. Activos, recursos y plantillas lo utilizan para sus datos principales. Las características se distribuyen en dos bloques por fila cuando el contenedor dispone de al menos 900 px; los controles dentro de cada bloque mantienen una o dos columnas según el espacio. Una plantilla sin secciones puede aprovechar hasta tres columnas de características. Observaciones y ayuda ocupan toda la fila. La disposición responde al espacio del modal, no solo al ancho del navegador. `FormSection` agrupa datos relacionados mediante título accesible; la variante `card` añade un borde discreto y `plain` conserva el bloque sin marco. Ambas utilizan `FormSectionTitle`. Activos, recursos y plantillas lo utilizan para sus datos principales. Las características se distribuyen en dos bloques por fila cuando el contenedor dispone de al menos 900 px; los controles dentro de cada bloque mantienen una o dos columnas según el espacio. Una plantilla sin secciones puede aprovechar hasta tres columnas de características. Observaciones y ayuda ocupan toda la fila. La disposición responde al espacio del modal, no solo al ancho del navegador.
@ -474,15 +483,20 @@ No conoce entidades, formularios, consultas ni endpoints.
`FormProgress` de `@repo/rdx-ui/components/form-progress` presenta pasos numerados, marca el actual con `aria-current="step"`, identifica los completados y lleva el foco al anuncio traducido de progreso. Recibe etiquetas, descripción y textos accesibles del consumidor; no conoce entidades ni consultas. Lo usan los asistentes de recursos, activos y puestos. `FormProgress` de `@repo/rdx-ui/components/form-progress` presenta pasos numerados, marca el actual con `aria-current="step"`, identifica los completados y lleva el foco al anuncio traducido de progreso. Recibe etiquetas, descripción y textos accesibles del consumidor; no conoce entidades ni consultas. Lo usan los asistentes de recursos, activos y puestos.
El diálogo de alta de puesto usa tamaño medio y cabecera con icono decorativo; la edición El diálogo de alta de puesto usa tamaño medio y cabecera con icono decorativo; la edición
mantiene tamaño grande. Asociaciones, activos, identificación y resumen utilizan mantiene tamaño grande. Ambos utilizan `layout="stable"`. Asociaciones, activos, identificación y resumen utilizan
`FormSection variant="plain"`, títulos de grupo uniformes y separación de 20 px. Las `FormSection variant="plain"`, títulos de grupo uniformes y separación de 20 px. Las
asociaciones y el resumen pasan a dos columnas según el ancho del contenedor; el nombre asociaciones y el resumen pasan a dos columnas según el ancho del contenedor; el nombre
del alta ocupa todo el ancho. Los activos seleccionados usan filas compactas con separadores, del alta ocupa todo el ancho. Los activos seleccionados utilizan `AssetSummaryCard`, con nombres que pueden partirse
nombres que pueden partirse y botones de retirada con etiqueta accesible. El resumen y botones de retirada con etiqueta accesible. El resumen
muestra `—` para valores ausentes. La ayuda del ordenador se vincula al selector mediante muestra `—` para valores ausentes. La ayuda del ordenador se vincula al selector mediante
`aria-describedby`; el nombre identifica su error junto al campo. La ayuda general queda `aria-describedby`; el nombre identifica su error junto al campo. La ayuda general queda
al final, antes del pie fijo, con navegación por flechas y cancelación deshabilitada durante al final, antes del pie fijo, con navegación por flechas y cancelación deshabilitada durante
el guardado. Se conservan el cuerpo desplazable y la altura según contenido. el guardado. El alta utiliza `layout="stable"`: el cuerpo se desplaza dentro de una altura
exterior estable, independiente del paso o del número de activos seleccionados.
La ayuda contextual del puesto es hermana de `FormBody`, inmediatamente antes de las
acciones: permanece alineada abajo aunque el paso tenga pocos campos. Su contenedor
elimina el margen superior de la variante plain, limita su altura al 40 % del formulario
y permite scroll propio para ejemplos extensos, manteniendo visible el footer.
El nuevo puesto se organiza en sede/responsable, activos, ordenador principal y resumen. Los controles de asociaciones, composición, ordenador y resumen tienen responsabilidades propias y se reutilizan en la edición cuando corresponde. Las acciones Anterior/Siguiente conservan valores; Guardar aparece en el resumen. La sede única y el activo único tienen selección automática. Véase [Puestos de trabajo](puestos-de-trabajo.md#alta-en-cuatro-pasos). El nuevo puesto se organiza en sede/responsable, activos, ordenador principal y resumen. Los controles de asociaciones, composición, ordenador y resumen tienen responsabilidades propias y se reutilizan en la edición cuando corresponde. Las acciones Anterior/Siguiente conservan valores; Guardar aparece en el resumen. La sede única y el activo único tienen selección automática. Véase [Puestos de trabajo](puestos-de-trabajo.md#alta-en-cuatro-pasos).
@ -570,7 +584,7 @@ En todos los diálogos de tipo `PickerList`, la columna del checkbox o radio ocu
### Altura estable de los diálogos PickerList ### Altura estable de los diálogos PickerList
Todos los diálogos `PickerList` usan `FormDialog layout="picker"`. Esta variante fija la altura a la ventana disponible en móvil, con un margen total de 2 rem; en escritorio utiliza el menor valor entre el 85 % de la altura dinámica de la ventana y 900 px, respetando siempre el margen. La altura no depende de carga, errores, búsqueda, número de resultados o tamaño de página. Los formularios de alta y edición conservan la altura por contenido, también cuando se abren desde un picker. Todos los diálogos `PickerList` usan `FormDialog layout="picker"`. Esta variante fija la altura a la ventana disponible en móvil, con un margen total de 2 rem; en escritorio utiliza el menor valor entre el 85 % de la altura dinámica de la ventana y 900 px, respetando siempre el margen. La altura no depende de carga, errores, búsqueda, número de resultados o tamaño de página. Los formularios de alta y edición con contenido variable deben utilizar `layout="stable"`, también cuando se abren desde un picker; las confirmaciones breves pueden conservar `layout="content"`.
`FormBody` hereda esta disposición del diálogo y distribuye sus hijos en columna. `EntityTable layout="fill"` ocupa el espacio restante; búsqueda, filtros, información y paginación no se desplazan con los resultados. El contenedor de tabla tiene scroll interno y `scrollbar-gutter: stable`; las cabeceras permanecen visibles al recorrer las filas. El área mantiene su espacio con pocos resultados o ninguno. En ventanas pequeñas, el cuerpo también puede desplazarse para evitar recortar controles. `AssetList` propaga `layout="fill"` por las pestañas y `AssetPickerList` conserva ese comportamiento en selección única y múltiple. `AssetPickerDialog` y `CatalogFieldPickerDialog` aplican esta variante común. `FormBody` hereda esta disposición del diálogo y distribuye sus hijos en columna. `EntityTable layout="fill"` ocupa el espacio restante; búsqueda, filtros, información y paginación no se desplazan con los resultados. El contenedor de tabla tiene scroll interno y `scrollbar-gutter: stable`; las cabeceras permanecen visibles al recorrer las filas. El área mantiene su espacio con pocos resultados o ninguno. En ventanas pequeñas, el cuerpo también puede desplazarse para evitar recortar controles. `AssetList` propaga `layout="fill"` por las pestañas y `AssetPickerList` conserva ese comportamiento en selección única y múltiple. `AssetPickerDialog` y `CatalogFieldPickerDialog` aplican esta variante común.
@ -657,3 +671,46 @@ reutiliza `SwitchField`. `SiteDeleteDialog` compone `ConfirmDeleteDialog`, prese
nombre de la sede y conserva el error de negocio dentro del modal. La caché de detalle nombre de la sede y conserva el error de negocio dentro del modal. La caché de detalle
de compañía se actualiza tras completar cada operación. Los selectores conservan la de compañía se actualiza tras completar cada operación. Los selectores conservan la
sede anterior archivada y excluyen las demás archivadas. Véase [Sedes](sedes.md). sede anterior archivada y excluyen las demás archivadas. Véase [Sedes](sedes.md).
## Fichas compactas de activos elegidos
`AssetSummaryCard`, en `assets/shared/ui`, presenta nombre, icono y nombre de plantilla,
resumen de características y estado. Reutiliza `Card` de shadcn/ui, `AssetTemplateIcon`,
`assetCharacteristicsSummary` y `EntityStatus`. La especificación capturada determina el
nombre de plantilla y el resumen; el catálogo aporta únicamente el icono actual, con
icono de caja como alternativa. Los valores ausentes muestran `—`; cero y booleanos se
conservan con las traducciones habituales.
El consumidor aporta el activo, la plantilla opcional, acciones mediante `actions` y un
aviso mediante `error`. La ficha no carga datos ni decide cómo seleccionar o retirar activos.
Permanece en el módulo porque conoce sus DTO; las primitivas visuales siguen siendo
independientes del dominio. La usan la selección de activos del puesto y su resumen final.
Las tarjetas se distribuyen en una columna o dos desde 560 px de contenedor, igualan altura
por fila y mantienen el orden del DOM. En selección, un contador anuncia los cambios y
la retirada conserva el nombre accesible del activo. Los errores se presentan junto a
la ficha y se distinguen también mediante texto.
## Elección visual del ordenador principal
`WorkstationComputerField` reutiliza `AssetSummaryCard` para los activos ya incorporados
al puesto. Las fichas se componen con `FieldLabel`, `RadioGroup` y `RadioGroupItem` de
shadcn/ui/Base UI. Pulsar cualquier zona de la etiqueta selecciona su radio, sin handlers
de click duplicados ni otro modal. El grupo tiene leyenda, ayuda vinculada, nombres
accesibles traducidos y selección única; la primitiva gestiona foco, flechas y Espacio.
El borde y el radio marcado distinguen la selección sin depender solo del color.
La cuadrícula conserva una o dos columnas según el contenedor y la altura estable del
modal. No carga datos: recibe activos y catálogo del formulario. El activo único sigue
seleccionándose automáticamente y el guardado bloquea los controles. En edición se
conserva la acción explícita de quitar la selección cuando no es obligatoria.
## Foco inicial de los selectores PickerList
`EntityTable` identifica su input de búsqueda rápida mediante `data-quick-search`.
Al abrir un `FormDialog layout="picker"`, `initialFocus` de Base UI apunta a ese input
buscándolo dentro del popup, sin afectar a listados de página ni otros modales. La
búsqueda permanece montada durante la carga: `AssetPickerDialog` presenta la lista con
`loading`/`unavailable`, propagados por `AssetList` hasta `EntityTable`. Así se puede
escribir desde la apertura, sin anunciar falsos vacíos. El foco no se reaplica al filtrar
o recargar datos, y la primitiva conserva la restauración al cerrar diálogos anidados.
La [regla normativa](patron-de-dialogos.md#foco-inicial-en-la-búsqueda-rápida) se aplica
a todos los PickerList actuales y futuros, incluidos los de campos de ambos catálogos.

View File

@ -57,10 +57,39 @@ el [inventario de modales](inventario-de-modales.md) recoge las implementaciones
el teclado y el arrastre. No introducir un orden visual diferente mediante CSS. el teclado y el arrastre. No introducir un orden visual diferente mediante CSS.
- Cada grupo de campos responde a su propio ancho, también cuando su sección ocupa media - Cada grupo de campos responde a su propio ancho, también cuando su sección ocupa media
fila. El bloque «Sin sección» permanece debajo de las secciones, a ancho completo. fila. El bloque «Sin sección» permanece debajo de las secciones, a ancho completo.
- No convertir formularios sencillos en cuadrículas de secciones ni imponerles altura fija. - No convertir formularios sencillos en cuadrículas de secciones ni imponer alturas
absolutas que superen la ventana. La estabilidad de la altura exterior se rige por la
regla siguiente; las secciones interiores conservan altura según su contenido.
Los umbrales anteriores corresponden a los componentes citados; otros contenidos deben Los umbrales anteriores corresponden a los componentes citados; otros contenidos deben
elegir su disposición según el espacio que necesitan sus controles. elegir su disposición según el espacio que necesitan sus controles.
## Estabilidad de altura del modal
Esta regla se aplica a los cuadros de diálogo actuales y futuros. Al crear o modificar
un diálogo, comprobar si su contenido puede variar mientras permanece abierto: pasos,
selecciones, altas anidadas, filtros, carga, errores, ayuda plegable o grupos condicionales.
En esos casos, la altura exterior debe mantenerse estable durante toda la interacción.
No debe crecer, encogerse ni desplazar el pie por cada elección del usuario.
- Utilizar `FormDialog layout="stable"` para formularios y asistentes con contenido
variable, como el alta de puestos. Reservar la altura desde la apertura, también cuando
el primer paso sea corto o los datos todavía estén cargando.
- La altura se adapta a la ventana: en móvil ocupa la altura dinámica disponible menos
2 rem de margen; desde `sm`, el menor valor entre el 85 % de la altura dinámica y 900 px,
respetando siempre el margen. Puede reajustarse al cambiar la ventana, orientación o
espacio disponible del teclado; no depende del contenido ni se mide con JavaScript.
- Conservar cabecera y pie visibles, `min-h-0` en la cadena flex, `FormBody` desplazable
y espacio reservado para la barra vertical. El contenido puede cambiar de alto dentro
del cuerpo, sin recortar campos, errores o acciones ni reducirlos para hacerlos caber.
- Mantener `layout="picker"` para selectores con tabla que ocupa el espacio restante;
ya proporciona altura estable y su distribución específica. No usarlo para fijar la
altura de formularios que no sean selectores.
- `layout="content"` queda para confirmaciones y diálogos breves cuyo contenido no cambia
de tamaño. Si incorporan contenido variable, aplicar la variante estable al modificarlos.
- Revisar pasos cortos y largos, listas vacías y extensas, carga/error y altas anidadas,
tanto en móvil como en escritorio. Deben conservarse la altura exterior y la posición
de las acciones, con acceso mediante scroll y teclado a todo el contenido.
## Secciones plegables y filas compactas ## Secciones plegables y filas compactas
- Las secciones extensas o repetidas utilizan la primitiva `Collapsible` mediante - Las secciones extensas o repetidas utilizan la primitiva `Collapsible` mediante
@ -111,6 +140,27 @@ el [inventario de modales](inventario-de-modales.md) recoge las implementaciones
su radio. Reutilizar `onRowClick` de la tabla; los controles internos ejecutan solo su su radio. Reutilizar `onRowClick` de la tabla; los controles internos ejecutan solo su
propia acción, sin duplicar cambios. Respetar filas deshabilitadas y navegación por teclado. propia acción, sin duplicar cambios. Respetar filas deshabilitadas y navegación por teclado.
### Foco inicial en la búsqueda rápida
Regla para todos los diálogos `PickerList` actuales y futuros: al abrirlos, el foco
inicial debe situarse en el cuadro de búsqueda rápida, listo para escribir. No debe
quedar en las pestañas, filtros, controles de selección o botones del pie.
- Reutilizar `FormDialog layout="picker"` y la búsqueda de `EntityTable`, que se identifica
con `data-quick-search`. `FormDialog` establece el foco mediante `initialFocus` de Base UI,
limitado al input del propio modal. Los selectores con búsqueda propia deben mantener
el mismo atributo en su input y la búsqueda debe estar montada y habilitada al abrir.
- Mantener la búsqueda visible y disponible durante carga, errores y resultados vacíos.
No esperar a los datos para montarla ni mostrar un mensaje de vacío mientras se carga.
- Aplicar el foco al abrir, también al reabrir; no volver a enfocarlo por cada tecla,
filtrado, cambio de pestaña, paginación o actualización de datos. No utilizar temporizadores
ni efectos que roben el foco durante la interacción.
- Mantener el aislamiento de los diálogos anidados y la restauración de foco de Base UI:
cerrar un alta relacionada devuelve el foco a su disparador dentro del picker;
cerrar el picker devuelve el foco al control que lo abrió.
- Verificar apertura con ratón y teclado, reapertura, carga inicial y altas anidadas.
El foco visible y el uso de Tab, flechas y Escape deben seguir funcionando.
## Cuerpo, pie y accesibilidad ## Cuerpo, pie y accesibilidad
- Elegir la anchura del diálogo según su contenido y respetar los márgenes de la ventana. - Elegir la anchura del diálogo según su contenido y respetar los márgenes de la ventana.
@ -120,9 +170,15 @@ el [inventario de modales](inventario-de-modales.md) recoge las implementaciones
`FormStepActions`, validar antes de avanzar y conservar el borrador al retroceder. `FormStepActions`, validar antes de avanzar y conservar el borrador al retroceder.
Guardar se muestra al final; ayuda, cancelación y errores permanecen dentro del diálogo. Guardar se muestra al final; ayuda, cancelación y errores permanecen dentro del diálogo.
El alta usa tamaño medio y la edición completa conserva tamaño grande. El alta usa tamaño medio y la edición completa conserva tamaño grande.
- Los formularios conservan altura según contenido; la altura estable por resultados es - Los formularios con contenido variable mantienen altura exterior estable según la
la regla de los `PickerList`. No introducir pestañas ni pasos para resolver solo espacio. regla de estabilidad; sus grupos interiores conservan altura según contenido.
- Situar ayuda contextual general al final del cuerpo, inmediatamente antes de `FormActions`. No introducir pestañas ni pasos para resolver solo espacio.
- Situar la ayuda contextual general inmediatamente antes de `FormActions`. En los
diálogos de altura estable debe quedar anclada abajo, fuera del scroll principal:
campos, progreso y errores se desplazan en `FormBody`, mientras la ayuda permanece
pegada al pie incluso en pasos cortos. Eliminar márgenes superiores innecesarios y
conservar el padding normal junto al footer. Limitar su altura y permitir scroll propio al
desplegar ejemplos, para no ocultar los campos ni las acciones en ventanas pequeñas.
Mantener errores junto al contexto que los origina y las acciones dentro del formulario. Mantener errores junto al contexto que los origina y las acciones dentro del formulario.
- Conservar foco, cancelación, cierre por teclado, estados de carga, error, vacío y envío. - Conservar foco, cancelación, cierre por teclado, estados de carga, error, vacío y envío.
No cerrar ni limpiar el formulario antes de confirmar una operación correcta. No cerrar ni limpiar el formulario antes de confirmar una operación correcta.

View File

@ -69,16 +69,21 @@ El estado mostrado del activo se calcula desde su estado técnico y los puestos
1. Sede y responsable. Si existe una única sede se selecciona automáticamente. El responsable es opcional; las altas relacionadas conservan el formulario. 1. Sede y responsable. Si existe una única sede se selecciona automáticamente. El responsable es opcional; las altas relacionadas conservan el formulario.
2. Activos. Un diálogo con listado, búsqueda rápida y pestañas por plantilla permite añadir varios a la vez. Es obligatorio añadir al menos uno; se impiden duplicados, activos ocupados y nuevas asociaciones no operativas. 2. Activos. Un diálogo con listado, búsqueda rápida y pestañas por plantilla permite añadir varios a la vez. Es obligatorio añadir al menos uno; se impiden duplicados, activos ocupados y nuevas asociaciones no operativas.
3. Ordenador principal. Se elige en un diálogo de selección única entre los activos seleccionados. Si solo queda uno se asigna automáticamente; al retirar el ordenador se limpia la selección o se asigna el único restante. 3. Ordenador principal. Se elige pulsando una de las fichas `AssetSummaryCard` de los activos seleccionados, sin abrir otro diálogo. Solo una tarjeta puede estar marcada; el borde y el radio identifican la elegida. El grupo conserva navegación por teclado mediante flechas y Espacio. Si solo queda uno se asigna automáticamente; al retirar el ordenador se limpia la selección o se asigna el único restante.
4. Resumen. Muestra sede, responsable, composición y ordenador principal. El nombre propuesto por sede/responsable se puede editar antes de Guardar. 4. Resumen. Muestra sede, responsable, composición y ordenador principal. El ordenador principal reutiliza `AssetSummaryCard` del paso 3, a ancho completo y con el mismo borde destacado: nombre, plantilla, icono, características y estado, sin controles de selección. La lista de activos excluye el ordenador principal para evitar duplicarlo; si no quedan otros activos muestra `—`. El nombre propuesto por sede/responsable se puede editar antes de Guardar.
El diálogo de alta utiliza tamaño medio y cabecera con icono decorativo. Los grupos El diálogo de alta utiliza tamaño medio, cabecera con icono decorativo y
`FormDialog layout="stable"`. Su altura exterior depende de la ventana y se conserva
al cambiar de paso o añadir/retirar activos. El cuerpo tiene scroll interno y reserva
espacio para la barra; cabecera y pie permanecen visibles. Los grupos
reutilizan `FormSection variant="plain"`; asociaciones y resumen responden al ancho reutilizan `FormSection variant="plain"`; asociaciones y resumen responden al ancho
del contenedor, con dos columnas cuando caben y una en espacios estrechos. Los activos del contenedor, con dos columnas cuando caben y una en espacios estrechos. Los activos
seleccionados se muestran como filas compactas, sin tarjetas anidadas. El nombre del seleccionados se muestran con `AssetSummaryCard`: fichas compactas con icono, plantilla,
características y estado, en una o dos columnas según el espacio. El resumen reutiliza
las mismas fichas sin acciones de retirada. El nombre del
resumen ocupa el ancho completo y los valores ausentes muestran `—`. La edición conserva resumen ocupa el ancho completo y los valores ausentes muestran `—`. La edición conserva
el diálogo grande y los mismos grupos, conforme al [patrón de diálogos](patron-de-dialogos.md). el diálogo grande con altura estable y los mismos grupos, conforme al [patrón de diálogos](patron-de-dialogos.md).
Anterior conserva valores y Siguiente valida los requisitos del paso. Guardar solo aparece en el resumen y no se envía el alta desde pasos intermedios. La edición mantiene un formulario único y permite conservar/completar puestos anteriores o desactivados. Cancelar permanece en el pie del modal y la ayuda inmediatamente encima. Anterior conserva valores y Siguiente valida los requisitos del paso. Guardar solo aparece en el resumen y no se envía el alta desde pasos intermedios. La edición mantiene un formulario único y permite conservar/completar puestos anteriores o desactivados. Cancelar permanece en el pie del modal y la ayuda se mantiene anclada inmediatamente encima, fuera del scroll de campos. Los ejemplos extensos tienen desplazamiento propio limitado al espacio disponible.
Se elimina `sharedAssetIds` de contratos, dominio, reservas y vistas. La migración explícita `removeSharedWorkstationAssets`, ejecutada durante la preparación del esquema, elimina las asociaciones de tipo compartido tanto actuales como históricas y limpia sus referencias técnicas cuando no existe ninguna asociación exclusiva histórica. No convierte activos compartidos en exclusivos, no borra activos ni revisiones y no genera reservas conflictivas. Es transaccional e idempotente. Las composiciones exclusivas e identidades se conservan. Se elimina `sharedAssetIds` de contratos, dominio, reservas y vistas. La migración explícita `removeSharedWorkstationAssets`, ejecutada durante la preparación del esquema, elimina las asociaciones de tipo compartido tanto actuales como históricas y limpia sus referencias técnicas cuando no existe ninguna asociación exclusiva histórica. No convierte activos compartidos en exclusivos, no borra activos ni revisiones y no genera reservas conflictivas. Es transaccional e idempotente. Las composiciones exclusivas e identidades se conservan.

View File

@ -386,7 +386,7 @@
"missingAsset": "Asset unavailable." "missingAsset": "Asset unavailable."
}, },
"workstationComputer": "Main computer", "workstationComputer": "Main computer",
"workstationComputerHelp": "Select the computer from the workstation assets. Profiles will be created on it; the other assets are peripherals.", "workstationComputerHelp": "Select the main computer by clicking its card among the workstation assets. Profiles will be created on it; the other assets are peripherals.",
"workstationLifecycle": { "workstationLifecycle": {
"decommission": { "decommission": {
"title": "Deactivate", "title": "Deactivate",
@ -418,9 +418,10 @@
"summary": "Summary", "summary": "Summary",
"locationDescription": "Select a site. You can leave the owner unassigned.", "locationDescription": "Select a site. You can leave the owner unassigned.",
"assetsDescription": "Add at least one asset to the workstation.", "assetsDescription": "Add at least one asset to the workstation.",
"computerDescription": "Identify the asset that will host user profiles.", "chooseComputer": "Choose {{name}} as the main computer",
"computerDescription": "Click the card of the asset that will host user profiles.",
"summaryDescription": "Review the details and workstation name before saving.", "summaryDescription": "Review the details and workstation name before saving.",
"singleComputer": "The only selected asset is automatically assigned as the main computer.", "singleComputer": "The only asset is automatically selected as the main computer.",
"previous": "Previous", "previous": "Previous",
"next": "Next" "next": "Next"
}, },

View File

@ -386,7 +386,7 @@
"missingAsset": "Activo no disponible." "missingAsset": "Activo no disponible."
}, },
"workstationComputer": "Ordenador principal", "workstationComputer": "Ordenador principal",
"workstationComputerHelp": "Selecciona el ordenador entre los activos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos.", "workstationComputerHelp": "Pulsa la tarjeta del ordenador principal entre los activos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos.",
"workstationLifecycle": { "workstationLifecycle": {
"decommission": { "decommission": {
"title": "Desactivar", "title": "Desactivar",
@ -418,9 +418,10 @@
"summary": "Resumen", "summary": "Resumen",
"locationDescription": "Selecciona una sede. Puedes dejar el responsable sin asignar.", "locationDescription": "Selecciona una sede. Puedes dejar el responsable sin asignar.",
"assetsDescription": "Añade al menos un activo al puesto.", "assetsDescription": "Añade al menos un activo al puesto.",
"computerDescription": "Identifica el activo que alojará los perfiles de usuario.", "chooseComputer": "Elegir {{name}} como ordenador principal",
"computerDescription": "Pulsa la tarjeta del activo que alojará los perfiles de usuario.",
"summaryDescription": "Revisa los datos y el nombre del puesto antes de guardar.", "summaryDescription": "Revisa los datos y el nombre del puesto antes de guardar.",
"singleComputer": "El único activo seleccionado se asigna automáticamente como ordenador principal.", "singleComputer": "El único activo está seleccionado automáticamente como ordenador principal.",
"previous": "Anterior", "previous": "Anterior",
"next": "Siguiente" "next": "Siguiente"
}, },

View File

@ -170,3 +170,38 @@ test("selector único: controles radio y disparador de diálogo; conserva el alt
assert.ok(trigger.includes("disabled")); assert.ok(trigger.includes("disabled"));
assert.equal(trigger.includes('role="combobox"'), false); assert.equal(trigger.includes('role="combobox"'), false);
}); });
test("picker: búsqueda rápida disponible durante carga y error, sin anunciar un falso vacío", async () => {
for (const language of ["es", "en"]) {
for (const loading of [true, false]) {
const html = await render(
language,
<AssetPickerList
{...listProps}
assets={[]}
disabled={() => true}
loading={loading}
mode="multiple"
onToggle={() => undefined}
selectedIds={[]}
unavailable={!loading}
unavailableLabels={new Map()}
/>
);
const search = html.match(/<input\b[^>]*data-quick-search=""[^>]*>/)?.[0];
assert.ok(search);
assert.equal(/\sdisabled(?:=|\s|>)/.test(search), false);
assert.equal(
html.includes(
language === "es"
? "Esta compañía todavía no tiene activos."
: "This company has no assets yet."
),
false
);
assert.equal(html.includes('aria-busy="true"'), loading);
assert.ok(html.includes(language === "es" ? "Buscar" : "Search"));
}
}
});

View File

@ -47,32 +47,32 @@ export function AssetPickerDialog({
{t("retry")} {t("retry")}
</Button> </Button>
)} )}
{c.inventory.data && ( <AssetPickerList
<AssetPickerList assets={c.assets}
assets={c.assets} catalog={c.catalog.data?.items ?? []}
catalog={c.catalog.data?.items ?? []} disabled={c.disabled}
disabled={c.disabled} loading={c.inventory.isPending || c.catalog.isPending}
mode={selection.mode ?? "single"} mode={selection.mode ?? "single"}
onClearFilters={() => { onClearFilters={() => {
c.setSearch(""); c.setSearch("");
c.setTemplateId(null); c.setTemplateId(null);
}} }}
onSearchChange={c.setSearch} onSearchChange={c.setSearch}
onTemplateChange={(id) => c.setTemplateId(id === "all" ? null : id)} onTemplateChange={(id) => c.setTemplateId(id === "all" ? null : id)}
onToggle={c.toggle} onToggle={c.toggle}
search={c.search} search={c.search}
selectedIds={c.selectedIds} selectedIds={c.selectedIds}
sites={selection.sites} sites={selection.sites}
templateId={c.templateId} templateId={c.templateId}
unavailableLabels={ unavailable={c.inventory.isError || c.catalog.isError}
new Map( unavailableLabels={
selection.options new Map(
.filter((option) => option.disabled) selection.options
.map((option) => [option.value, option.label]) .filter((option) => option.disabled)
) .map((option) => [option.value, option.label])
} )
/> }
)} />
<p aria-live="polite" className="text-sm text-muted-foreground"> <p aria-live="polite" className="text-sm text-muted-foreground">
{t("assetPicker.selected", { count: c.selectedIds.length })} {t("assetPicker.selected", { count: c.selectedIds.length })}
</p> </p>

View File

@ -38,7 +38,7 @@ import { AssetInventoryPage } from "../list";
import { assetCharacteristicsSummary } from "./helpers/asset-characteristics-summary"; import { assetCharacteristicsSummary } from "./helpers/asset-characteristics-summary";
import { hasMissingAssetFields, suggestedAssetName } from "./helpers/asset-creation-values"; import { hasMissingAssetFields, suggestedAssetName } from "./helpers/asset-creation-values";
import { AssetForm } from "./ui"; import { AssetForm, AssetSummaryCard } from "./ui";
import { AssetCharacteristicControl } from "./ui/asset-characteristic-control"; import { AssetCharacteristicControl } from "./ui/asset-characteristic-control";
const companyId = UniqueID.generateNewID().toPrimitive(); const companyId = UniqueID.generateNewID().toPrimitive();
@ -826,19 +826,15 @@ test("diseño del puesto: grupos compactos, columnas por contenedor y resumen si
language, language,
<WorkstationComputerField <WorkstationComputerField
assets={[]} assets={[]}
companyId={companyId}
onChange={() => undefined} onChange={() => undefined}
prefix="design" prefix="design"
required required
singleComputer={false} singleComputer={false}
sites={[]}
values={values} values={values}
/> />
); );
assert.ok( assert.ok(computer.includes('aria-describedby="design-computer-help"'));
computer.includes('aria-describedby="design-computer-help design-computer-required"')
);
assert.ok(computer.includes('id="design-computer-help"')); assert.ok(computer.includes('id="design-computer-help"'));
const selection = await render( const selection = await render(
language, language,
@ -857,7 +853,8 @@ test("diseño del puesto: grupos compactos, columnas por contenedor y resumen si
/> />
); );
assert.ok(selection.includes("divide-y divide-border/60")); assert.ok(selection.includes('data-slot="card"'));
assert.ok(selection.includes(language === "es" ? "1 activo seleccionado" : "1 asset selected"));
assert.ok(selection.includes("—")); assert.ok(selection.includes("—"));
assert.match(selection, /<button(?=[^>]*aria-label="[^"]*—[^"]*")(?=[^>]*disabled="")[^>]*>/); assert.match(selection, /<button(?=[^>]*aria-label="[^"]*—[^"]*")(?=[^>]*disabled="")[^>]*>/);
} }
@ -878,3 +875,274 @@ test("diseño del puesto: navegación traducida y acciones bloqueadas durante el
} }
} }
}); });
test("ficha compacta de activo: comparte información, traducciones y acciones sin consultas propias", async () => {
const catalog = buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none());
const template = catalog.items.find((item) => item.values.kind === "template");
assert.ok(template);
const fields = [
{
fieldId: UniqueID.generateNewID().toPrimitive(),
name: "Modelo",
type: "text",
unit: "",
value: "ThinkCentre",
},
{
fieldId: UniqueID.generateNewID().toPrimitive(),
name: "Memoria",
type: "number",
unit: "GB",
value: 0,
},
{
fieldId: UniqueID.generateNewID().toPrimitive(),
name: "Táctil",
type: "boolean",
unit: "",
value: false,
},
];
const values = {
name: "Equipo de administración",
templateId: template.id,
siteId: null,
status: "operational",
notes: "",
characteristics: Object.fromEntries(fields.map((field) => [field.fieldId, field.value])),
};
const asset = AssetSchema.parse({
id: companyId,
companyId,
values,
displayStatus: "available",
assigned: false,
specification: {
templateId: template.id,
templateName: template.values.name,
fields: fields.map(({ value, ...field }) => ({ ...field, choices: [], required: false })),
},
history: [
{
id: companyId,
actorId: companyId,
actorName: "Técnico",
recordedAt: "2026-10-16T12:00:00.000Z",
values,
},
],
});
for (const language of ["es", "en"]) {
const html = await render(
language,
<AssetSummaryCard
actions={<button type="button">Quitar</button>}
asset={asset}
error="Revisar disponibilidad"
template={template}
/>
);
assert.ok(html.includes(values.name));
assert.ok(html.includes(template.values.name));
assert.ok(html.includes("ThinkCentre · 0 GB · No"));
assert.ok(html.includes(language === "es" ? "Disponible" : "Available"));
assert.ok(html.includes('role="alert"'));
assert.ok(html.includes("Revisar disponibilidad"));
assert.ok(html.includes(">Quitar</button>"));
const summary = await render(
language,
<WorkstationSummary
assets={[asset]}
catalog={catalog.items}
lookups={lookups}
values={{
name: "Puesto",
active: true,
siteId: null,
personId: null,
assetIds: [asset.id],
computerAssetId: asset.id,
}}
/>
);
assert.ok(summary.includes(values.name));
assert.ok(summary.includes("ThinkCentre · 0 GB · No"));
const computerLabel = language === "es" ? "Ordenador principal" : "Main computer";
const computerSummary = summary.split(`${computerLabel}</dt>`)[1]?.split("</dd>")[0];
assert.ok(computerSummary);
assert.ok(computerSummary.includes('data-slot="card"'));
assert.ok(computerSummary.includes(values.name));
assert.ok(computerSummary.includes(template.values.name));
assert.ok(computerSummary.includes("ThinkCentre · 0 GB · No"));
assert.ok(computerSummary.includes(language === "es" ? "Disponible" : "Available"));
assert.equal(computerSummary.includes("<input"), false);
assert.equal(computerSummary.includes("<button"), false);
assert.equal((summary.match(/data-slot="card"/g) ?? []).length, 1);
const assetsLabel = language === "es" ? "Activos del puesto" : "Workstation assets";
const assetSummary = summary.split(`${assetsLabel}</dt>`)[1]?.split("</dd>")[0];
assert.ok(assetSummary);
assert.ok(assetSummary.includes("—"));
assert.equal(assetSummary.includes("<ul"), false);
assert.equal(summary.includes(">Quitar</button>"), false);
const otherAsset = {
...asset,
id: UniqueID.generateNewID().toPrimitive(),
values: { ...asset.values, name: "Monitor auxiliar" },
};
for (const computerAssetId of [asset.id, null]) {
const composition = await render(
language,
<WorkstationSummary
assets={[asset, otherAsset]}
catalog={catalog.items}
lookups={lookups}
values={{
name: "Puesto",
active: true,
siteId: null,
personId: null,
assetIds: [asset.id, otherAsset.id],
computerAssetId,
}}
/>
);
const assetList = composition.split(`${assetsLabel}</dt>`)[1]?.split("</dd>")[0];
assert.ok(assetList);
assert.ok(assetList.includes(otherAsset.values.name));
assert.equal(assetList.includes(asset.values.name), computerAssetId === null);
assert.equal((composition.match(/data-slot="card"/g) ?? []).length, 2);
}
const missing = await render(language, <AssetSummaryCard asset={undefined} />);
assert.equal((missing.match(/—/g) ?? []).length, 3);
assert.equal(missing.includes('role="alert"'), false);
}
});
test("ordenador principal: tarjetas con selección única, etiquetas es/en y solo los activos del puesto", async () => {
const templateId = UniqueID.generateNewID().toPrimitive();
const assets = ["Ordenador", "Monitor", "Fuera del puesto"].map((name) => {
const id = UniqueID.generateNewID().toPrimitive();
const values = {
name,
templateId,
siteId: null,
status: "operational",
notes: "",
characteristics: {},
};
return AssetSchema.parse({
id,
companyId,
values,
assigned: false,
displayStatus: "available",
specification: {
templateId,
templateName: "Equipo",
fields: [
{
fieldId: companyId,
name: "Serie",
type: "text",
unit: "",
required: false,
choices: [],
},
],
},
history: [
{
id,
values,
actorId: companyId,
actorName: "Técnico",
recordedAt: "2026-10-16T12:00:00.000Z",
},
],
});
});
const first = assets[0];
const second = assets[1];
assert.ok(first && second);
const values = WorkstationInputSchema.parse({
name: "Puesto",
active: true,
siteId: null,
personId: null,
assetIds: [first.id, second.id],
computerAssetId: second.id,
});
for (const language of ["es", "en"]) {
const html = await render(
language,
<WorkstationComputerField
assets={assets}
onChange={() => undefined}
prefix="computer"
required
singleComputer={false}
values={values}
/>
);
assert.equal((html.match(/role="radio"/g) ?? []).length, 2);
assert.equal((html.match(/aria-checked="true"/g) ?? []).length, 1);
assert.ok(html.includes('role="radiogroup"'));
assert.ok(html.includes('aria-describedby="computer-computer-help"'));
assert.ok(html.includes(`for="computer-computer-${first.id}"`));
assert.ok(html.includes(`for="computer-computer-${second.id}"`));
assert.ok(
html.includes(
language === "es"
? "Elegir Monitor como ordenador principal"
: "Choose Monitor as the main computer"
)
);
assert.ok(html.includes('data-slot="card"'));
assert.equal(html.includes("Fuera del puesto"), false);
assert.equal(html.includes('aria-haspopup="dialog"'), false);
const single = await render(
language,
<WorkstationComputerField
assets={assets}
onChange={() => undefined}
prefix="single"
required
singleComputer
values={{ ...values, assetIds: [first.id], computerAssetId: first.id }}
/>
);
assert.equal((single.match(/role="radio"/g) ?? []).length, 1);
assert.ok(single.includes('aria-checked="true"'));
assert.ok(single.includes('aria-disabled="true"'));
assert.ok(
single.includes(language === "es" ? "seleccionado automáticamente" : "automatically selected")
);
const optional = await render(
language,
<WorkstationComputerField
assets={assets}
onChange={() => undefined}
prefix="optional"
required={false}
singleComputer={false}
values={values}
/>
);
assert.ok(optional.includes(language === "es" ? "Quitar selección" : "Clear selection"));
}
});

View File

@ -27,6 +27,8 @@ export function AssetList({
rowClassName = () => "", rowClassName = () => "",
onRowClick, onRowClick,
layout = "content", layout = "content",
loading = false,
unavailable = false,
}: { }: {
assets: readonly AssetDTO[]; assets: readonly AssetDTO[];
catalog: readonly CatalogItemDTO[]; catalog: readonly CatalogItemDTO[];
@ -42,6 +44,8 @@ export function AssetList({
rowClassName?: ((asset: AssetDTO) => string) | undefined; rowClassName?: ((asset: AssetDTO) => string) | undefined;
onRowClick?: ((asset: AssetDTO) => void) | undefined; onRowClick?: ((asset: AssetDTO) => void) | undefined;
layout?: "content" | "fill"; layout?: "content" | "fill";
loading?: boolean;
unavailable?: boolean;
}) { }) {
const { t, i18n } = useTranslation("assets"); const { t, i18n } = useTranslation("assets");
const templates = new Map( const templates = new Map(
@ -160,6 +164,7 @@ export function AssetList({
filtersApplied={templateId !== null} filtersApplied={templateId !== null}
items={items} items={items}
layout={layout} layout={layout}
loading={loading}
locale={i18n.language} locale={i18n.language}
onClearFilters={onClearFilters} onClearFilters={onClearFilters}
onRowClick={onRowClick} onRowClick={onRowClick}
@ -168,6 +173,7 @@ export function AssetList({
search={search} search={search}
searchPlaceholder={t("search")} searchPlaceholder={t("search")}
title={t("inventory")} title={t("inventory")}
unavailable={unavailable}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@ -0,0 +1,66 @@
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory";
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
import { assetCharacteristicsSummary } from "../helpers/asset-characteristics-summary";
/** Presentación de un activo sin consultas ni conocimiento de la operación que lo selecciona. */
export function AssetSummaryCard({
asset,
template,
actions,
error,
}: {
asset: AssetDTO | undefined;
template?: CatalogItemDTO | undefined;
actions?: ReactNode;
error?: string | undefined;
}) {
const { t } = useTranslation("assets");
const summary = asset ? assetCharacteristicsSummary(asset, { yes: t("yes"), no: t("no") }) : "";
const icon = template?.values.kind === "template" ? template.values.icon : undefined;
return (
<Card
className={`h-full min-w-0 rounded-lg shadow-none ${error ? "bg-destructive/5 ring-destructive/30" : "bg-muted/15 ring-border/70"}`}
size="sm"
>
<CardContent className="h-full gap-3">
<div className="flex min-w-0 items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-primary/8 text-primary">
<AssetTemplateIcon className="size-5" icon={icon ?? "box"} />
</span>
<div className="min-w-0 flex-1 space-y-1 break-words">
<p className="font-semibold leading-snug">{asset?.values.name ?? "—"}</p>
<p className="text-xs text-muted-foreground">
{asset?.specification.templateName ?? "—"}
</p>
</div>
{actions && <div className="shrink-0 -mt-1 -mr-1">{actions}</div>}
</div>
<p
className="line-clamp-2 break-words text-xs leading-relaxed text-muted-foreground"
title={summary || undefined}
>
{summary || "—"}
</p>
{asset && (
<div className="mt-auto border-t border-border/60 pt-3 text-xs">
<EntityStatus active={asset.values.status === "operational"}>
{t(asset.displayStatus)}
</EntityStatus>
</div>
)}
{error && (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
)}
</CardContent>
</Card>
);
}

View File

@ -1,3 +1,4 @@
export { AssetEditorDialog } from "./asset-editor-dialog"; export { AssetEditorDialog } from "./asset-editor-dialog";
export { AssetForm } from "./asset-form"; export { AssetForm } from "./asset-form";
export { AssetSummaryCard } from "./asset-summary-card";
export { DeleteAssetDialog } from "./delete-asset-dialog"; export { DeleteAssetDialog } from "./delete-asset-dialog";

View File

@ -4,11 +4,14 @@ import { XIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory"; import type { AssetDTO } from "../../../../common/inventory";
import { AssetSelect } from "../../../assets/select/ui/asset-select"; import { AssetSelect } from "../../../assets/select/ui/asset-select";
import { AssetSummaryCard } from "../../../assets/shared/ui";
export function WorkstationAssetFields({ export function WorkstationAssetFields({
assets, assets,
catalog = [],
companyId, companyId,
sites, sites,
selectedIds, selectedIds,
@ -21,6 +24,7 @@ export function WorkstationAssetFields({
onCreate, onCreate,
}: { }: {
assets: readonly AssetDTO[]; assets: readonly AssetDTO[];
catalog?: readonly CatalogItemDTO[] | undefined;
companyId: string; companyId: string;
sites: readonly { id: string; name: string }[]; sites: readonly { id: string; name: string }[];
selectedIds: readonly string[]; selectedIds: readonly string[];
@ -70,34 +74,40 @@ export function WorkstationAssetFields({
value={[]} value={[]}
/> />
{selectedIds.length ? ( {selectedIds.length ? (
<ul className="divide-y divide-border/60"> <div className="space-y-3 pt-3">
{selectedIds.map((id) => { <p aria-live="polite" className="text-xs font-medium text-muted-foreground">
const name = assets.find((asset) => asset.id === id)?.values.name ?? "—"; {t("assetPicker.selected", { count: selectedIds.length })}
const reason = unavailableReason(id); </p>
<ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
{selectedIds.map((id) => {
const asset = assets.find((item) => item.id === id);
const name = asset?.values.name ?? "—";
const reason = unavailableReason(id);
return ( return (
<li <li className="min-w-0" key={id}>
className="flex min-w-0 items-center justify-between gap-3 py-3 text-sm first:pt-0 last:pb-0" <AssetSummaryCard
key={id} actions={
> <Button
<div className="min-w-0 space-y-1 break-words"> aria-label={t("removeWorkstationAsset", { name })}
<p>{name}</p> disabled={disabled}
{reason && <p className="text-destructive">{t(`inventoryErrors.${reason}`)}</p>} onClick={() => onRemove(id)}
</div> size="icon-sm"
<Button type="button"
aria-label={t("removeWorkstationAsset", { name })} variant="ghost"
disabled={disabled} >
onClick={() => onRemove(id)} <XIcon aria-hidden="true" className="size-4" />
size="icon-sm" </Button>
type="button" }
variant="ghost" asset={asset}
> error={reason ? t(`inventoryErrors.${reason}`) : undefined}
<XIcon aria-hidden="true" className="size-4" /> template={catalog.find((item) => item.id === asset?.values.templateId)}
</Button> />
</li> </li>
); );
})} })}
</ul> </ul>
</div>
) : ( ) : (
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p> <p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
)} )}

View File

@ -1,54 +1,104 @@
import { FormLabel } from "@repo/rdx-ui/components/form-label"; import { RequiredIndicator } from "@repo/rdx-ui/components/required-indicator";
import { Button } from "@repo/shadcn-ui/components/button";
import { FieldLabel } from "@repo/shadcn-ui/components/field";
import { RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/components/radio-group";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { WorkstationInput } from "../../../../common"; import type { CatalogItemDTO, WorkstationInput } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory"; import type { AssetDTO } from "../../../../common/inventory";
import { AssetSelect } from "../../../assets/select/ui/asset-select"; import { AssetSummaryCard } from "../../../assets/shared/ui";
export function WorkstationComputerField({ export function WorkstationComputerField({
values, values,
assets, assets,
companyId, catalog = [],
sites,
prefix, prefix,
singleComputer, singleComputer,
required, required,
disabled = false,
onChange, onChange,
}: { }: {
values: WorkstationInput; values: WorkstationInput;
assets: readonly AssetDTO[]; assets: readonly AssetDTO[];
companyId: string; catalog?: readonly CatalogItemDTO[] | undefined;
sites: readonly { id: string; name: string }[];
prefix: string; prefix: string;
singleComputer: boolean; singleComputer: boolean;
required: boolean; required: boolean;
disabled?: boolean;
onChange: (id: string | null) => void; onChange: (id: string | null) => void;
}) { }) {
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
return ( return (
<div className="space-y-2"> <fieldset className="min-w-0 space-y-3">
<FormLabel htmlFor={`${prefix}-computer`} required={required}> <legend
className="mb-2 flex items-center gap-1 text-sm font-medium"
id={`${prefix}-computer-label`}
>
{t("workstationComputer")} {t("workstationComputer")}
</FormLabel> {required && <RequiredIndicator />}
<AssetSelect </legend>
companyId={companyId} <p className="text-sm text-muted-foreground" id={`${prefix}-computer-help`}>
describedBy={`${prefix}-computer-help`}
disabled={singleComputer}
id={`${prefix}-computer`}
label={t("workstationComputer")}
onValueChange={(id) => onChange(id || null)}
options={assets
.filter((asset) => values.assetIds.includes(asset.id))
.map((asset) => ({ value: asset.id, label: asset.values.name }))}
placeholder={t("noAssignment")}
required={required}
sites={sites}
value={values.computerAssetId ?? ""}
/>
<p className="text-xs text-muted-foreground" id={`${prefix}-computer-help`}>
{t(singleComputer ? "workstationSteps.singleComputer" : "workstationComputerHelp")} {t(singleComputer ? "workstationSteps.singleComputer" : "workstationComputerHelp")}
</p> </p>
</div> <RadioGroup
aria-describedby={`${prefix}-computer-help`}
aria-labelledby={`${prefix}-computer-label`}
className="items-stretch gap-4 @min-[560px]:grid-cols-2"
disabled={disabled || singleComputer}
name={`${prefix}-computer`}
onValueChange={(id) => {
if (typeof id === "string" && values.assetIds.includes(id)) {
onChange(id);
}
}}
required={required}
value={values.computerAssetId ?? ""}
>
{values.assetIds.map((id) => {
const asset = assets.find((item) => item.id === id);
const selected = values.computerAssetId === id;
const inputId = `${prefix}-computer-${id}`;
return (
<FieldLabel
className={`block h-full w-full min-w-0 cursor-pointer rounded-lg font-normal ring-2 ring-offset-2 ring-offset-background transition-shadow has-[:focus-visible]:ring-ring has-[[data-disabled]]:cursor-default ${selected ? "ring-primary" : "ring-transparent hover:ring-border"}`}
htmlFor={inputId}
key={id}
>
<AssetSummaryCard
actions={
<RadioGroupItem
aria-label={t("workstationSteps.chooseComputer", {
name: asset?.values.name ?? "—",
})}
disabled={!asset}
id={inputId}
value={id}
/>
}
asset={asset}
error={asset ? undefined : t("inventoryErrors.ASSET_NOT_FOUND")}
template={catalog.find((item) => item.id === asset?.values.templateId)}
/>
</FieldLabel>
);
})}
</RadioGroup>
{values.assetIds.length === 0 && (
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
)}
{!required && values.computerAssetId && (
<Button
disabled={disabled}
onClick={() => onChange(null)}
size="sm"
type="button"
variant="ghost"
>
{t("assetPicker.clear")}
</Button>
)}
</fieldset>
); );
} }

View File

@ -22,6 +22,7 @@ export function WorkstationEditorDialog({
<FormDialog <FormDialog
cancelLabel={t("cancel")} cancelLabel={t("cancel")}
formActions formActions
layout="stable"
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
onClose(); onClose();

View File

@ -78,6 +78,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
<WorkstationAssetFields <WorkstationAssetFields
assets={assets} assets={assets}
canCreate={!!catalog.data} canCreate={!!catalog.data}
catalog={catalog.data?.items}
companyId={companyId} companyId={companyId}
disabled={inventory.isPending} disabled={inventory.isPending}
onAdd={addMany} onAdd={addMany}
@ -92,12 +93,12 @@ export function WorkstationForm(props: WorkstationFormProps) {
{layout.computer && ( {layout.computer && (
<WorkstationComputerField <WorkstationComputerField
assets={assets} assets={assets}
companyId={companyId} catalog={catalog.data?.items}
onChange={(id) => form.setValue("computerAssetId", id)} disabled={mutation.isPending}
onChange={(id) => form.setValue("computerAssetId", id, { shouldDirty: true })}
prefix={prefix} prefix={prefix}
required={isWizard} required={isWizard}
singleComputer={singleComputer} singleComputer={singleComputer}
sites={lookups.sites}
values={values} values={values}
/> />
)} )}
@ -105,7 +106,12 @@ export function WorkstationForm(props: WorkstationFormProps) {
<WorkstationIdentityFields form={form} prefix={prefix} showStatus={!isWizard} /> <WorkstationIdentityFields form={form} prefix={prefix} showStatus={!isWizard} />
)} )}
{layout.review && ( {layout.review && (
<WorkstationSummary assets={assets} lookups={lookups} values={values} /> <WorkstationSummary
assets={assets}
catalog={catalog.data?.items}
lookups={lookups}
values={values}
/>
)} )}
</fieldset> </fieldset>
{Object.keys(form.formState.errors).length > 0 && ( {Object.keys(form.formState.errors).length > 0 && (
@ -119,6 +125,8 @@ export function WorkstationForm(props: WorkstationFormProps) {
</p> </p>
)} )}
<InventoryFeedback error={mutation.error ?? inventory.error ?? catalog.error} /> <InventoryFeedback error={mutation.error ?? inventory.error ?? catalog.error} />
</FormBody>
<div className="min-h-0 max-h-[40%] shrink-0 overflow-y-auto px-1 pb-3 [scrollbar-gutter:stable] [&>aside]:mt-0">
<ContextHelp <ContextHelp
description={t("workstationDescription")} description={t("workstationDescription")}
examples={t("workstationExamples")} examples={t("workstationExamples")}
@ -128,7 +136,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
title={t("helpTitle")} title={t("helpTitle")}
variant="plain" variant="plain"
/> />
</FormBody> </div>
<WorkstationFormActions <WorkstationFormActions
canSubmit={isWizard ? canContinue : assetsReady} canSubmit={isWizard ? canContinue : assetsReady}
onPrevious={previousStep} onPrevious={previousStep}

View File

@ -1,20 +1,25 @@
import { FormSection } from "@repo/rdx-ui/components/form-section"; import { FormSection } from "@repo/rdx-ui/components/form-section";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { WorkstationInput } from "../../../../common"; import type { CatalogItemDTO, WorkstationInput } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory"; import type { AssetDTO } from "../../../../common/inventory";
import { AssetSummaryCard } from "../../../assets/shared/ui";
import type { WorkstationFormProps } from "../types"; import type { WorkstationFormProps } from "../types";
export function WorkstationSummary({ export function WorkstationSummary({
values, values,
assets, assets,
catalog = [],
lookups, lookups,
}: { }: {
values: WorkstationInput; values: WorkstationInput;
assets: readonly AssetDTO[]; assets: readonly AssetDTO[];
catalog?: readonly CatalogItemDTO[] | undefined;
lookups: WorkstationFormProps["lookups"]; lookups: WorkstationFormProps["lookups"];
}) { }) {
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
const computer = assets.find((asset) => asset.id === values.computerAssetId);
const otherAssetIds = values.assetIds.filter((id) => id !== values.computerAssetId);
return ( return (
<FormSection title={t("formLayout.workstationSummary")} variant="plain"> <FormSection title={t("formLayout.workstationSummary")} variant="plain">
@ -27,24 +32,44 @@ export function WorkstationSummary({
<dt className="text-muted-foreground">{t("workstationPerson")}</dt> <dt className="text-muted-foreground">{t("workstationPerson")}</dt>
<dd>{lookups.people.find((person) => person.id === values.personId)?.name ?? "—"}</dd> <dd>{lookups.people.find((person) => person.id === values.personId)?.name ?? "—"}</dd>
</div> </div>
<div className="min-w-0 space-y-1 break-words"> <div className="col-span-full min-w-0 space-y-3">
<dt className="text-muted-foreground">{t("workstationComputer")}</dt>
<dd>
{computer ? (
<div className="rounded-lg ring-2 ring-primary ring-offset-2 ring-offset-background">
<AssetSummaryCard
asset={computer}
template={catalog.find((item) => item.id === computer.values.templateId)}
/>
</div>
) : (
"—"
)}
</dd>
</div>
<div className="col-span-full min-w-0 space-y-3">
<dt className="text-muted-foreground">{t("workstationAssets")}</dt> <dt className="text-muted-foreground">{t("workstationAssets")}</dt>
<dd> <dd>
{values.assetIds.length > 0 ? ( {otherAssetIds.length > 0 ? (
<ul className="list-inside list-disc"> <ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
{values.assetIds.map((id) => ( {otherAssetIds.map((id) => {
<li key={id}>{assets.find((asset) => asset.id === id)?.values.name ?? "—"}</li> const asset = assets.find((item) => item.id === id);
))}
return (
<li className="min-w-0" key={id}>
<AssetSummaryCard
asset={asset}
template={catalog.find((item) => item.id === asset?.values.templateId)}
/>
</li>
);
})}
</ul> </ul>
) : ( ) : (
"—" "—"
)} )}
</dd> </dd>
</div> </div>
<div className="min-w-0 space-y-1 break-words">
<dt className="text-muted-foreground">{t("workstationComputer")}</dt>
<dd>{assets.find((asset) => asset.id === values.computerAssetId)?.values.name ?? "—"}</dd>
</div>
</dl> </dl>
</FormSection> </FormSection>
); );

View File

@ -121,6 +121,7 @@ export function EntityTable<T extends { id: string }>({
{searchPlaceholder ?? labels.search} {searchPlaceholder ?? labels.search}
</label> </label>
<Input <Input
data-quick-search=""
id={`${id}-search`} id={`${id}-search`}
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)} onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
placeholder={searchPlaceholder ?? labels.placeholder} placeholder={searchPlaceholder ?? labels.placeholder}

View File

@ -7,9 +7,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@repo/shadcn-ui/components/dialog"; } from "@repo/shadcn-ui/components/dialog";
import { type ReactNode, createContext, useContext } from "react"; import { type ReactNode, createContext, useContext, useRef } from "react";
const FormLayoutContext = createContext<"content" | "picker">("content"); type FormDialogLayout = "content" | "stable" | "picker";
const FormLayoutContext = createContext<FormDialogLayout>("content");
const FormCancelContext = createContext<string | undefined>(undefined); const FormCancelContext = createContext<string | undefined>(undefined);
@ -32,8 +34,9 @@ export function FormDialog({
wide?: boolean; wide?: boolean;
size?: "small" | "medium" | "large"; size?: "small" | "medium" | "large";
formActions?: boolean; formActions?: boolean;
layout?: "content" | "picker"; layout?: FormDialogLayout;
}) { }) {
const popupRef = useRef<HTMLDivElement>(null);
const width = size ?? (wide ? "large" : "small"); const width = size ?? (wide ? "large" : "small");
const widths = { const widths = {
small: "sm:max-w-xl", small: "sm:max-w-xl",
@ -41,12 +44,21 @@ export function FormDialog({
large: "sm:w-[90vw] sm:max-w-[1200px]", large: "sm:w-[90vw] sm:max-w-[1200px]",
}; };
const height = layout === "picker" ? "h-[calc(100dvh-2rem)] sm:h-[min(85dvh,900px)]" : ""; // La altura de los diálogos dinámicos depende de la ventana, nunca de sus resultados o pasos.
const height = layout === "content" ? "" : "h-[calc(100dvh-2rem)] sm:h-[min(85dvh,900px)]";
return ( return (
<Dialog onOpenChange={onOpenChange} open={open}> <Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent <DialogContent
className={`flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 ${widths[width]} ${height}`} className={`flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 ${widths[width]} ${height}`}
initialFocus={
layout === "picker"
? () =>
popupRef.current?.querySelector<HTMLInputElement>("input[data-quick-search]") ??
true
: undefined
}
ref={popupRef}
showCloseButton={true} showCloseButton={true}
> >
<DialogHeader className="shrink-0 border-b px-6 py-5 pr-14"> <DialogHeader className="shrink-0 border-b px-6 py-5 pr-14">
@ -105,7 +117,7 @@ export function FormBody({ children }: { children: ReactNode }) {
className={ className={
layout === "picker" layout === "picker"
? "@container flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1 pt-1 pb-5 [scrollbar-gutter:stable] [&>*]:shrink-0" ? "@container flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1 pt-1 pb-5 [scrollbar-gutter:stable] [&>*]:shrink-0"
: "@container min-h-0 flex-1 space-y-5 overflow-y-auto px-1 pt-1 pb-5" : `@container min-h-0 flex-1 space-y-5 overflow-y-auto px-1 pt-1 pb-5 ${layout === "stable" ? "[scrollbar-gutter:stable]" : ""}`
} }
data-slot="form-body" data-slot="form-body"
> >