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:
parent
2fc49470d6
commit
7777192d20
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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,
|
||||
<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"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -47,11 +47,11 @@ export function AssetPickerDialog({
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
{c.inventory.data && (
|
||||
<AssetPickerList
|
||||
assets={c.assets}
|
||||
catalog={c.catalog.data?.items ?? []}
|
||||
disabled={c.disabled}
|
||||
loading={c.inventory.isPending || c.catalog.isPending}
|
||||
mode={selection.mode ?? "single"}
|
||||
onClearFilters={() => {
|
||||
c.setSearch("");
|
||||
@ -64,6 +64,7 @@ export function AssetPickerDialog({
|
||||
selectedIds={c.selectedIds}
|
||||
sites={selection.sites}
|
||||
templateId={c.templateId}
|
||||
unavailable={c.inventory.isError || c.catalog.isError}
|
||||
unavailableLabels={
|
||||
new Map(
|
||||
selection.options
|
||||
@ -72,7 +73,6 @@ export function AssetPickerDialog({
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<p aria-live="polite" className="text-sm text-muted-foreground">
|
||||
{t("assetPicker.selected", { count: c.selectedIds.length })}
|
||||
</p>
|
||||
|
||||
@ -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,
|
||||
<WorkstationComputerField
|
||||
assets={[]}
|
||||
companyId={companyId}
|
||||
onChange={() => 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, /<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"));
|
||||
}
|
||||
});
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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,20 +74,20 @@ export function WorkstationAssetFields({
|
||||
value={[]}
|
||||
/>
|
||||
{selectedIds.length ? (
|
||||
<ul className="divide-y divide-border/60">
|
||||
<div className="space-y-3 pt-3">
|
||||
<p aria-live="polite" className="text-xs font-medium text-muted-foreground">
|
||||
{t("assetPicker.selected", { count: selectedIds.length })}
|
||||
</p>
|
||||
<ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
|
||||
{selectedIds.map((id) => {
|
||||
const name = assets.find((asset) => asset.id === id)?.values.name ?? "—";
|
||||
const asset = assets.find((item) => item.id === id);
|
||||
const name = asset?.values.name ?? "—";
|
||||
const reason = unavailableReason(id);
|
||||
|
||||
return (
|
||||
<li
|
||||
className="flex min-w-0 items-center justify-between gap-3 py-3 text-sm first:pt-0 last:pb-0"
|
||||
key={id}
|
||||
>
|
||||
<div className="min-w-0 space-y-1 break-words">
|
||||
<p>{name}</p>
|
||||
{reason && <p className="text-destructive">{t(`inventoryErrors.${reason}`)}</p>}
|
||||
</div>
|
||||
<li className="min-w-0" key={id}>
|
||||
<AssetSummaryCard
|
||||
actions={
|
||||
<Button
|
||||
aria-label={t("removeWorkstationAsset", { name })}
|
||||
disabled={disabled}
|
||||
@ -94,10 +98,16 @@ export function WorkstationAssetFields({
|
||||
>
|
||||
<XIcon aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
asset={asset}
|
||||
error={reason ? t(`inventoryErrors.${reason}`) : undefined}
|
||||
template={catalog.find((item) => item.id === asset?.values.templateId)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
|
||||
)}
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor={`${prefix}-computer`} required={required}>
|
||||
<fieldset className="min-w-0 space-y-3">
|
||||
<legend
|
||||
className="mb-2 flex items-center gap-1 text-sm font-medium"
|
||||
id={`${prefix}-computer-label`}
|
||||
>
|
||||
{t("workstationComputer")}
|
||||
</FormLabel>
|
||||
<AssetSelect
|
||||
companyId={companyId}
|
||||
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`}>
|
||||
{required && <RequiredIndicator />}
|
||||
</legend>
|
||||
<p className="text-sm text-muted-foreground" id={`${prefix}-computer-help`}>
|
||||
{t(singleComputer ? "workstationSteps.singleComputer" : "workstationComputerHelp")}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ export function WorkstationEditorDialog({
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
layout="stable"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
|
||||
@ -78,6 +78,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
||||
<WorkstationAssetFields
|
||||
assets={assets}
|
||||
canCreate={!!catalog.data}
|
||||
catalog={catalog.data?.items}
|
||||
companyId={companyId}
|
||||
disabled={inventory.isPending}
|
||||
onAdd={addMany}
|
||||
@ -92,12 +93,12 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
||||
{layout.computer && (
|
||||
<WorkstationComputerField
|
||||
assets={assets}
|
||||
companyId={companyId}
|
||||
onChange={(id) => 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) {
|
||||
<WorkstationIdentityFields form={form} prefix={prefix} showStatus={!isWizard} />
|
||||
)}
|
||||
{layout.review && (
|
||||
<WorkstationSummary assets={assets} lookups={lookups} values={values} />
|
||||
<WorkstationSummary
|
||||
assets={assets}
|
||||
catalog={catalog.data?.items}
|
||||
lookups={lookups}
|
||||
values={values}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
@ -119,6 +125,8 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
||||
</p>
|
||||
)}
|
||||
<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
|
||||
description={t("workstationDescription")}
|
||||
examples={t("workstationExamples")}
|
||||
@ -128,7 +136,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
||||
title={t("helpTitle")}
|
||||
variant="plain"
|
||||
/>
|
||||
</FormBody>
|
||||
</div>
|
||||
<WorkstationFormActions
|
||||
canSubmit={isWizard ? canContinue : assetsReady}
|
||||
onPrevious={previousStep}
|
||||
|
||||
@ -1,20 +1,25 @@
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkstationInput } from "../../../../common";
|
||||
import type { CatalogItemDTO, WorkstationInput } from "../../../../common";
|
||||
import type { AssetDTO } from "../../../../common/inventory";
|
||||
import { AssetSummaryCard } from "../../../assets/shared/ui";
|
||||
import type { WorkstationFormProps } from "../types";
|
||||
|
||||
export function WorkstationSummary({
|
||||
values,
|
||||
assets,
|
||||
catalog = [],
|
||||
lookups,
|
||||
}: {
|
||||
values: WorkstationInput;
|
||||
assets: readonly AssetDTO[];
|
||||
catalog?: readonly CatalogItemDTO[] | undefined;
|
||||
lookups: WorkstationFormProps["lookups"];
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const computer = assets.find((asset) => asset.id === values.computerAssetId);
|
||||
const otherAssetIds = values.assetIds.filter((id) => id !== values.computerAssetId);
|
||||
|
||||
return (
|
||||
<FormSection title={t("formLayout.workstationSummary")} variant="plain">
|
||||
@ -27,24 +32,44 @@ export function WorkstationSummary({
|
||||
<dt className="text-muted-foreground">{t("workstationPerson")}</dt>
|
||||
<dd>{lookups.people.find((person) => person.id === values.personId)?.name ?? "—"}</dd>
|
||||
</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>
|
||||
<dd>
|
||||
{values.assetIds.length > 0 ? (
|
||||
<ul className="list-inside list-disc">
|
||||
{values.assetIds.map((id) => (
|
||||
<li key={id}>{assets.find((asset) => asset.id === id)?.values.name ?? "—"}</li>
|
||||
))}
|
||||
{otherAssetIds.length > 0 ? (
|
||||
<ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
|
||||
{otherAssetIds.map((id) => {
|
||||
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>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</dd>
|
||||
</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>
|
||||
</FormSection>
|
||||
);
|
||||
|
||||
@ -121,6 +121,7 @@ export function EntityTable<T extends { id: string }>({
|
||||
{searchPlaceholder ?? labels.search}
|
||||
</label>
|
||||
<Input
|
||||
data-quick-search=""
|
||||
id={`${id}-search`}
|
||||
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
|
||||
placeholder={searchPlaceholder ?? labels.placeholder}
|
||||
|
||||
@ -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<FormDialogLayout>("content");
|
||||
|
||||
const FormCancelContext = createContext<string | undefined>(undefined);
|
||||
|
||||
@ -32,8 +34,9 @@ export function FormDialog({
|
||||
wide?: boolean;
|
||||
size?: "small" | "medium" | "large";
|
||||
formActions?: boolean;
|
||||
layout?: "content" | "picker";
|
||||
layout?: FormDialogLayout;
|
||||
}) {
|
||||
const popupRef = useRef<HTMLDivElement>(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 (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
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}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-5 pr-14">
|
||||
@ -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"
|
||||
>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user