diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 6f2a04f..5a1cd92 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -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. -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. @@ -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. 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 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, -nombres que pueden partirse y botones de retirada con etiqueta accesible. El resumen +del alta ocupa todo el ancho. Los activos seleccionados utilizan `AssetSummaryCard`, con nombres que pueden partirse +y botones de retirada con etiqueta accesible. El resumen 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 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). @@ -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 -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. @@ -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 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). + +## 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. diff --git a/docs/patron-de-dialogos.md b/docs/patron-de-dialogos.md index 0a2dd2d..5261fac 100644 --- a/docs/patron-de-dialogos.md +++ b/docs/patron-de-dialogos.md @@ -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. - 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. -- 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 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 - 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 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 - 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. 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. -- Los formularios conservan altura según contenido; la altura estable por resultados es - la regla de los `PickerList`. No introducir pestañas ni pasos para resolver solo espacio. -- Situar ayuda contextual general al final del cuerpo, inmediatamente antes de `FormActions`. +- Los formularios con contenido variable mantienen altura exterior estable según la + regla de estabilidad; sus grupos interiores conservan altura según contenido. + 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. - 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. diff --git a/docs/puestos-de-trabajo.md b/docs/puestos-de-trabajo.md index 3f2715e..f5c3061 100644 --- a/docs/puestos-de-trabajo.md +++ b/docs/puestos-de-trabajo.md @@ -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. 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. -4. Resumen. Muestra sede, responsable, composición y ordenador principal. El nombre propuesto por sede/responsable se puede editar antes de Guardar. +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 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 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 -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. diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index 246a685..ead2079 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -386,7 +386,7 @@ "missingAsset": "Asset unavailable." }, "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": { "decommission": { "title": "Deactivate", @@ -418,9 +418,10 @@ "summary": "Summary", "locationDescription": "Select a site. You can leave the owner unassigned.", "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.", - "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", "next": "Next" }, diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index 3c80e02..619b81b 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -386,7 +386,7 @@ "missingAsset": "Activo no disponible." }, "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": { "decommission": { "title": "Desactivar", @@ -418,9 +418,10 @@ "summary": "Resumen", "locationDescription": "Selecciona una sede. Puedes dejar el responsable sin asignar.", "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.", - "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", "next": "Siguiente" }, diff --git a/modules/assets/src/web/assets/select/asset-selection.test.tsx b/modules/assets/src/web/assets/select/asset-selection.test.tsx index e7d8233..b3b1417 100644 --- a/modules/assets/src/web/assets/select/asset-selection.test.tsx +++ b/modules/assets/src/web/assets/select/asset-selection.test.tsx @@ -170,3 +170,38 @@ test("selector único: controles radio y disparador de diálogo; conserva el alt assert.ok(trigger.includes("disabled")); 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, + true} + loading={loading} + mode="multiple" + onToggle={() => undefined} + selectedIds={[]} + unavailable={!loading} + unavailableLabels={new Map()} + /> + ); + const search = html.match(/]*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")); + } + } +}); diff --git a/modules/assets/src/web/assets/select/ui/asset-picker-dialog.tsx b/modules/assets/src/web/assets/select/ui/asset-picker-dialog.tsx index a79036c..4def2e9 100644 --- a/modules/assets/src/web/assets/select/ui/asset-picker-dialog.tsx +++ b/modules/assets/src/web/assets/select/ui/asset-picker-dialog.tsx @@ -47,32 +47,32 @@ export function AssetPickerDialog({ {t("retry")} )} - {c.inventory.data && ( - { - c.setSearch(""); - c.setTemplateId(null); - }} - onSearchChange={c.setSearch} - onTemplateChange={(id) => c.setTemplateId(id === "all" ? null : id)} - onToggle={c.toggle} - search={c.search} - selectedIds={c.selectedIds} - sites={selection.sites} - templateId={c.templateId} - unavailableLabels={ - new Map( - selection.options - .filter((option) => option.disabled) - .map((option) => [option.value, option.label]) - ) - } - /> - )} + { + c.setSearch(""); + c.setTemplateId(null); + }} + onSearchChange={c.setSearch} + onTemplateChange={(id) => c.setTemplateId(id === "all" ? null : id)} + onToggle={c.toggle} + search={c.search} + selectedIds={c.selectedIds} + sites={selection.sites} + templateId={c.templateId} + unavailable={c.inventory.isError || c.catalog.isError} + unavailableLabels={ + new Map( + selection.options + .filter((option) => option.disabled) + .map((option) => [option.value, option.label]) + ) + } + />

{t("assetPicker.selected", { count: c.selectedIds.length })}

diff --git a/modules/assets/src/web/assets/shared/inventory.test.tsx b/modules/assets/src/web/assets/shared/inventory.test.tsx index afe8f18..e25a936 100644 --- a/modules/assets/src/web/assets/shared/inventory.test.tsx +++ b/modules/assets/src/web/assets/shared/inventory.test.tsx @@ -38,7 +38,7 @@ import { AssetInventoryPage } from "../list"; import { assetCharacteristicsSummary } from "./helpers/asset-characteristics-summary"; import { hasMissingAssetFields, suggestedAssetName } from "./helpers/asset-creation-values"; -import { AssetForm } from "./ui"; +import { AssetForm, AssetSummaryCard } from "./ui"; import { AssetCharacteristicControl } from "./ui/asset-characteristic-control"; const companyId = UniqueID.generateNewID().toPrimitive(); @@ -826,19 +826,15 @@ test("diseño del puesto: grupos compactos, columnas por contenedor y resumen si language, undefined} prefix="design" required singleComputer={false} - sites={[]} values={values} /> ); - assert.ok( - computer.includes('aria-describedby="design-computer-help design-computer-required"') - ); + assert.ok(computer.includes('aria-describedby="design-computer-help"')); assert.ok(computer.includes('id="design-computer-help"')); const selection = await render( 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.match(selection, /]*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, + Quitar} + 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")); + const summary = await render( + language, + + ); + + 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}`)[1]?.split("")[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("`)[1]?.split("")[0]; + + assert.ok(assetSummary); + assert.ok(assetSummary.includes("—")); + assert.equal(assetSummary.includes("Quitar"), 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, + + ); + const assetList = composition.split(`${assetsLabel}`)[1]?.split("")[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, ); + + 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, + 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, + 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, + undefined} + prefix="optional" + required={false} + singleComputer={false} + values={values} + /> + ); + + assert.ok(optional.includes(language === "es" ? "Quitar selección" : "Clear selection")); + } +}); 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 ab4db28..40bdeb2 100644 --- a/modules/assets/src/web/assets/shared/ui/asset-list.tsx +++ b/modules/assets/src/web/assets/shared/ui/asset-list.tsx @@ -27,6 +27,8 @@ export function AssetList({ rowClassName = () => "", onRowClick, layout = "content", + loading = false, + unavailable = false, }: { assets: readonly AssetDTO[]; catalog: readonly CatalogItemDTO[]; @@ -42,6 +44,8 @@ export function AssetList({ rowClassName?: ((asset: AssetDTO) => string) | undefined; onRowClick?: ((asset: AssetDTO) => void) | undefined; layout?: "content" | "fill"; + loading?: boolean; + unavailable?: boolean; }) { const { t, i18n } = useTranslation("assets"); const templates = new Map( @@ -160,6 +164,7 @@ export function AssetList({ filtersApplied={templateId !== null} items={items} layout={layout} + loading={loading} locale={i18n.language} onClearFilters={onClearFilters} onRowClick={onRowClick} @@ -168,6 +173,7 @@ export function AssetList({ search={search} searchPlaceholder={t("search")} title={t("inventory")} + unavailable={unavailable} /> diff --git a/modules/assets/src/web/assets/shared/ui/asset-summary-card.tsx b/modules/assets/src/web/assets/shared/ui/asset-summary-card.tsx new file mode 100644 index 0000000..38765c8 --- /dev/null +++ b/modules/assets/src/web/assets/shared/ui/asset-summary-card.tsx @@ -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 ( + + +
+ + + +
+

{asset?.values.name ?? "—"}

+

+ {asset?.specification.templateName ?? "—"} +

+
+ {actions &&
{actions}
} +
+

+ {summary || "—"} +

+ {asset && ( +
+ + {t(asset.displayStatus)} + +
+ )} + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/modules/assets/src/web/assets/shared/ui/index.ts b/modules/assets/src/web/assets/shared/ui/index.ts index a78be9d..b0fd919 100644 --- a/modules/assets/src/web/assets/shared/ui/index.ts +++ b/modules/assets/src/web/assets/shared/ui/index.ts @@ -1,3 +1,4 @@ export { AssetEditorDialog } from "./asset-editor-dialog"; export { AssetForm } from "./asset-form"; +export { AssetSummaryCard } from "./asset-summary-card"; export { DeleteAssetDialog } from "./delete-asset-dialog"; diff --git a/modules/assets/src/web/workstations/shared/ui/workstation-asset-fields.tsx b/modules/assets/src/web/workstations/shared/ui/workstation-asset-fields.tsx index 784c527..a1691df 100644 --- a/modules/assets/src/web/workstations/shared/ui/workstation-asset-fields.tsx +++ b/modules/assets/src/web/workstations/shared/ui/workstation-asset-fields.tsx @@ -4,11 +4,14 @@ import { XIcon } from "@repo/rdx-ui/lib/icons"; import { Button } from "@repo/shadcn-ui/components/button"; import { useTranslation } from "react-i18next"; +import type { CatalogItemDTO } from "../../../../common"; import type { AssetDTO } from "../../../../common/inventory"; import { AssetSelect } from "../../../assets/select/ui/asset-select"; +import { AssetSummaryCard } from "../../../assets/shared/ui"; export function WorkstationAssetFields({ assets, + catalog = [], companyId, sites, selectedIds, @@ -21,6 +24,7 @@ export function WorkstationAssetFields({ onCreate, }: { assets: readonly AssetDTO[]; + catalog?: readonly CatalogItemDTO[] | undefined; companyId: string; sites: readonly { id: string; name: string }[]; selectedIds: readonly string[]; @@ -70,34 +74,40 @@ export function WorkstationAssetFields({ value={[]} /> {selectedIds.length ? ( -
    - {selectedIds.map((id) => { - const name = assets.find((asset) => asset.id === id)?.values.name ?? "—"; - const reason = unavailableReason(id); +
    +

    + {t("assetPicker.selected", { count: selectedIds.length })} +

    +
      + {selectedIds.map((id) => { + const asset = assets.find((item) => item.id === id); + const name = asset?.values.name ?? "—"; + const reason = unavailableReason(id); - return ( -
    • -
      -

      {name}

      - {reason &&

      {t(`inventoryErrors.${reason}`)}

      } -
      - -
    • - ); - })} -
    + return ( +
  • + onRemove(id)} + size="icon-sm" + type="button" + variant="ghost" + > +
  • + ); + })} +
+ ) : (

{t("noWorkstationAssets")}

)} diff --git a/modules/assets/src/web/workstations/shared/ui/workstation-computer-field.tsx b/modules/assets/src/web/workstations/shared/ui/workstation-computer-field.tsx index 8bf252d..17ee1f6 100644 --- a/modules/assets/src/web/workstations/shared/ui/workstation-computer-field.tsx +++ b/modules/assets/src/web/workstations/shared/ui/workstation-computer-field.tsx @@ -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 type { WorkstationInput } from "../../../../common"; +import type { CatalogItemDTO, WorkstationInput } from "../../../../common"; import type { AssetDTO } from "../../../../common/inventory"; -import { AssetSelect } from "../../../assets/select/ui/asset-select"; +import { AssetSummaryCard } from "../../../assets/shared/ui"; export function WorkstationComputerField({ values, assets, - companyId, - sites, + catalog = [], prefix, singleComputer, required, + disabled = false, onChange, }: { values: WorkstationInput; assets: readonly AssetDTO[]; - companyId: string; - sites: readonly { id: string; name: string }[]; + catalog?: readonly CatalogItemDTO[] | undefined; prefix: string; singleComputer: boolean; required: boolean; + disabled?: boolean; onChange: (id: string | null) => void; }) { const { t } = useTranslation("assets"); return ( -
- +
+ {t("workstationComputer")} - - 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 ?? ""} - /> -

+ {required && } + +

{t(singleComputer ? "workstationSteps.singleComputer" : "workstationComputerHelp")}

-
+ { + 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 ( + + + } + asset={asset} + error={asset ? undefined : t("inventoryErrors.ASSET_NOT_FOUND")} + template={catalog.find((item) => item.id === asset?.values.templateId)} + /> + + ); + })} + + {values.assetIds.length === 0 && ( +

{t("noWorkstationAssets")}

+ )} + {!required && values.computerAssetId && ( + + )} + ); } diff --git a/modules/assets/src/web/workstations/shared/ui/workstation-editor-dialog.tsx b/modules/assets/src/web/workstations/shared/ui/workstation-editor-dialog.tsx index 01e6ed1..2b442bf 100644 --- a/modules/assets/src/web/workstations/shared/ui/workstation-editor-dialog.tsx +++ b/modules/assets/src/web/workstations/shared/ui/workstation-editor-dialog.tsx @@ -22,6 +22,7 @@ export function WorkstationEditorDialog({ { if (!open) { onClose(); diff --git a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx index 4d03e63..fe9fb6a 100644 --- a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx +++ b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx @@ -78,6 +78,7 @@ export function WorkstationForm(props: WorkstationFormProps) { form.setValue("computerAssetId", id)} + catalog={catalog.data?.items} + disabled={mutation.isPending} + onChange={(id) => form.setValue("computerAssetId", id, { shouldDirty: true })} prefix={prefix} required={isWizard} singleComputer={singleComputer} - sites={lookups.sites} values={values} /> )} @@ -105,7 +106,12 @@ export function WorkstationForm(props: WorkstationFormProps) { )} {layout.review && ( - + )} {Object.keys(form.formState.errors).length > 0 && ( @@ -119,6 +125,8 @@ export function WorkstationForm(props: WorkstationFormProps) {

)} + +
- +
asset.id === values.computerAssetId); + const otherAssetIds = values.assetIds.filter((id) => id !== values.computerAssetId); return ( @@ -27,24 +32,44 @@ export function WorkstationSummary({
{t("workstationPerson")}
{lookups.people.find((person) => person.id === values.personId)?.name ?? "—"}
-
+
+
{t("workstationComputer")}
+
+ {computer ? ( +
+ item.id === computer.values.templateId)} + /> +
+ ) : ( + "—" + )} +
+
+
{t("workstationAssets")}
- {values.assetIds.length > 0 ? ( -
    - {values.assetIds.map((id) => ( -
  • {assets.find((asset) => asset.id === id)?.values.name ?? "—"}
  • - ))} + {otherAssetIds.length > 0 ? ( +
      + {otherAssetIds.map((id) => { + const asset = assets.find((item) => item.id === id); + + return ( +
    • + item.id === asset?.values.templateId)} + /> +
    • + ); + })}
    ) : ( "—" )}
-
-
{t("workstationComputer")}
-
{assets.find((asset) => asset.id === values.computerAssetId)?.values.name ?? "—"}
-
); diff --git a/packages/rdx-ui/src/components/entity-list.tsx b/packages/rdx-ui/src/components/entity-list.tsx index 47703bd..edb2aa2 100644 --- a/packages/rdx-ui/src/components/entity-list.tsx +++ b/packages/rdx-ui/src/components/entity-list.tsx @@ -121,6 +121,7 @@ export function EntityTable({ {searchPlaceholder ?? labels.search} (onSearchChange ?? setLocalSearch)(event.target.value)} placeholder={searchPlaceholder ?? labels.placeholder} diff --git a/packages/rdx-ui/src/components/form-dialog.tsx b/packages/rdx-ui/src/components/form-dialog.tsx index 39684ba..b9774a5 100644 --- a/packages/rdx-ui/src/components/form-dialog.tsx +++ b/packages/rdx-ui/src/components/form-dialog.tsx @@ -7,9 +7,11 @@ import { DialogHeader, DialogTitle, } 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("content"); const FormCancelContext = createContext(undefined); @@ -32,8 +34,9 @@ export function FormDialog({ wide?: boolean; size?: "small" | "medium" | "large"; formActions?: boolean; - layout?: "content" | "picker"; + layout?: FormDialogLayout; }) { + const popupRef = useRef(null); const width = size ?? (wide ? "large" : "small"); const widths = { small: "sm:max-w-xl", @@ -41,12 +44,21 @@ export function FormDialog({ 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 ( + popupRef.current?.querySelector("input[data-quick-search]") ?? + true + : undefined + } + ref={popupRef} showCloseButton={true} > @@ -105,7 +117,7 @@ export function FormBody({ children }: { children: ReactNode }) { className={ 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 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" >