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.
|
`FormDialog` admite `size="small" | "medium" | "large"`. Los formularios sencillos mantienen el tamaño pequeño; personas y usuarios emplean el intermedio. `wide` sigue siendo un alias del tamaño grande: 90 % del ancho disponible, con máximo de 1.200 px y márgenes en móvil. No se ensanchan automáticamente las altas sencillas.
|
||||||
|
|
||||||
La cabecera permanece visible. Un formulario modal utiliza una raíz flex con altura mínima cero, `FormBody` para campos, errores y ayuda, y `FormActions` como hermano final fuera del cuerpo desplazable. Los botones permanecen dentro del formulario HTML original: se conservan envío por teclado, validación nativa, estados pendientes y cancelación. La ayuda queda al final del cuerpo, inmediatamente antes de las acciones. Los diálogos de consulta conservan el cuerpo desplazable y su pie externo.
|
`FormDialog layout="stable"` reserva la altura exterior para formularios y asistentes
|
||||||
|
cuyo contenido cambia durante la interacción. Comparte con `layout="picker"` el tamaño
|
||||||
|
adaptado a la ventana: altura dinámica menos 2 rem en móvil y mínimo de 85dvh/900px desde
|
||||||
|
`sm`, limitado siempre por los márgenes. Conserva el espaciado normal del formulario,
|
||||||
|
con `FormBody` desplazable y `scrollbar-gutter: stable`; no distribuye los hijos como una
|
||||||
|
tabla. Cambiar de paso, añadir o retirar activos y desplegar ayuda no redimensiona el
|
||||||
|
modal. La cabecera y las acciones permanecen visibles. `layout="content"` queda para
|
||||||
|
contenido breve que no cambia de tamaño. Véase la [regla normativa de estabilidad](patron-de-dialogos.md#estabilidad-de-altura-del-modal).
|
||||||
|
|
||||||
|
La cabecera permanece visible. Un formulario modal utiliza una raíz flex con altura mínima cero, `FormBody` para campos y errores, y `FormActions` como hermano final fuera del cuerpo desplazable. Los botones permanecen dentro del formulario HTML original: se conservan envío por teclado, validación nativa, estados pendientes y cancelación. La ayuda queda inmediatamente antes de las acciones; en diálogos de altura estable ocupa un bloque anclado abajo fuera del scroll de campos, con altura limitada y desplazamiento propio si se despliega contenido extenso. Los diálogos de consulta conservan el cuerpo desplazable y su pie externo.
|
||||||
|
|
||||||
`FormSection` agrupa datos relacionados mediante título accesible; la variante `card` añade un borde discreto y `plain` conserva el bloque sin marco. Ambas utilizan `FormSectionTitle`. Activos, recursos y plantillas lo utilizan para sus datos principales. Las características se distribuyen en dos bloques por fila cuando el contenedor dispone de al menos 900 px; los controles dentro de cada bloque mantienen una o dos columnas según el espacio. Una plantilla sin secciones puede aprovechar hasta tres columnas de características. Observaciones y ayuda ocupan toda la fila. La disposición responde al espacio del modal, no solo al ancho del navegador.
|
`FormSection` agrupa datos relacionados mediante título accesible; la variante `card` añade un borde discreto y `plain` conserva el bloque sin marco. Ambas utilizan `FormSectionTitle`. Activos, recursos y plantillas lo utilizan para sus datos principales. Las características se distribuyen en dos bloques por fila cuando el contenedor dispone de al menos 900 px; los controles dentro de cada bloque mantienen una o dos columnas según el espacio. Una plantilla sin secciones puede aprovechar hasta tres columnas de características. Observaciones y ayuda ocupan toda la fila. La disposición responde al espacio del modal, no solo al ancho del navegador.
|
||||||
|
|
||||||
@ -474,15 +483,20 @@ No conoce entidades, formularios, consultas ni endpoints.
|
|||||||
`FormProgress` de `@repo/rdx-ui/components/form-progress` presenta pasos numerados, marca el actual con `aria-current="step"`, identifica los completados y lleva el foco al anuncio traducido de progreso. Recibe etiquetas, descripción y textos accesibles del consumidor; no conoce entidades ni consultas. Lo usan los asistentes de recursos, activos y puestos.
|
`FormProgress` de `@repo/rdx-ui/components/form-progress` presenta pasos numerados, marca el actual con `aria-current="step"`, identifica los completados y lleva el foco al anuncio traducido de progreso. Recibe etiquetas, descripción y textos accesibles del consumidor; no conoce entidades ni consultas. Lo usan los asistentes de recursos, activos y puestos.
|
||||||
|
|
||||||
El diálogo de alta de puesto usa tamaño medio y cabecera con icono decorativo; la edición
|
El diálogo de alta de puesto usa tamaño medio y cabecera con icono decorativo; la edición
|
||||||
mantiene tamaño grande. Asociaciones, activos, identificación y resumen utilizan
|
mantiene tamaño grande. Ambos utilizan `layout="stable"`. Asociaciones, activos, identificación y resumen utilizan
|
||||||
`FormSection variant="plain"`, títulos de grupo uniformes y separación de 20 px. Las
|
`FormSection variant="plain"`, títulos de grupo uniformes y separación de 20 px. Las
|
||||||
asociaciones y el resumen pasan a dos columnas según el ancho del contenedor; el nombre
|
asociaciones y el resumen pasan a dos columnas según el ancho del contenedor; el nombre
|
||||||
del alta ocupa todo el ancho. Los activos seleccionados usan filas compactas con separadores,
|
del alta ocupa todo el ancho. Los activos seleccionados utilizan `AssetSummaryCard`, con nombres que pueden partirse
|
||||||
nombres que pueden partirse y botones de retirada con etiqueta accesible. El resumen
|
y botones de retirada con etiqueta accesible. El resumen
|
||||||
muestra `—` para valores ausentes. La ayuda del ordenador se vincula al selector mediante
|
muestra `—` para valores ausentes. La ayuda del ordenador se vincula al selector mediante
|
||||||
`aria-describedby`; el nombre identifica su error junto al campo. La ayuda general queda
|
`aria-describedby`; el nombre identifica su error junto al campo. La ayuda general queda
|
||||||
al final, antes del pie fijo, con navegación por flechas y cancelación deshabilitada durante
|
al final, antes del pie fijo, con navegación por flechas y cancelación deshabilitada durante
|
||||||
el guardado. Se conservan el cuerpo desplazable y la altura según contenido.
|
el guardado. El alta utiliza `layout="stable"`: el cuerpo se desplaza dentro de una altura
|
||||||
|
exterior estable, independiente del paso o del número de activos seleccionados.
|
||||||
|
La ayuda contextual del puesto es hermana de `FormBody`, inmediatamente antes de las
|
||||||
|
acciones: permanece alineada abajo aunque el paso tenga pocos campos. Su contenedor
|
||||||
|
elimina el margen superior de la variante plain, limita su altura al 40 % del formulario
|
||||||
|
y permite scroll propio para ejemplos extensos, manteniendo visible el footer.
|
||||||
|
|
||||||
El nuevo puesto se organiza en sede/responsable, activos, ordenador principal y resumen. Los controles de asociaciones, composición, ordenador y resumen tienen responsabilidades propias y se reutilizan en la edición cuando corresponde. Las acciones Anterior/Siguiente conservan valores; Guardar aparece en el resumen. La sede única y el activo único tienen selección automática. Véase [Puestos de trabajo](puestos-de-trabajo.md#alta-en-cuatro-pasos).
|
El nuevo puesto se organiza en sede/responsable, activos, ordenador principal y resumen. Los controles de asociaciones, composición, ordenador y resumen tienen responsabilidades propias y se reutilizan en la edición cuando corresponde. Las acciones Anterior/Siguiente conservan valores; Guardar aparece en el resumen. La sede única y el activo único tienen selección automática. Véase [Puestos de trabajo](puestos-de-trabajo.md#alta-en-cuatro-pasos).
|
||||||
|
|
||||||
@ -570,7 +584,7 @@ En todos los diálogos de tipo `PickerList`, la columna del checkbox o radio ocu
|
|||||||
|
|
||||||
### Altura estable de los diálogos PickerList
|
### Altura estable de los diálogos PickerList
|
||||||
|
|
||||||
Todos los diálogos `PickerList` usan `FormDialog layout="picker"`. Esta variante fija la altura a la ventana disponible en móvil, con un margen total de 2 rem; en escritorio utiliza el menor valor entre el 85 % de la altura dinámica de la ventana y 900 px, respetando siempre el margen. La altura no depende de carga, errores, búsqueda, número de resultados o tamaño de página. Los formularios de alta y edición conservan la altura por contenido, también cuando se abren desde un picker.
|
Todos los diálogos `PickerList` usan `FormDialog layout="picker"`. Esta variante fija la altura a la ventana disponible en móvil, con un margen total de 2 rem; en escritorio utiliza el menor valor entre el 85 % de la altura dinámica de la ventana y 900 px, respetando siempre el margen. La altura no depende de carga, errores, búsqueda, número de resultados o tamaño de página. Los formularios de alta y edición con contenido variable deben utilizar `layout="stable"`, también cuando se abren desde un picker; las confirmaciones breves pueden conservar `layout="content"`.
|
||||||
|
|
||||||
`FormBody` hereda esta disposición del diálogo y distribuye sus hijos en columna. `EntityTable layout="fill"` ocupa el espacio restante; búsqueda, filtros, información y paginación no se desplazan con los resultados. El contenedor de tabla tiene scroll interno y `scrollbar-gutter: stable`; las cabeceras permanecen visibles al recorrer las filas. El área mantiene su espacio con pocos resultados o ninguno. En ventanas pequeñas, el cuerpo también puede desplazarse para evitar recortar controles. `AssetList` propaga `layout="fill"` por las pestañas y `AssetPickerList` conserva ese comportamiento en selección única y múltiple. `AssetPickerDialog` y `CatalogFieldPickerDialog` aplican esta variante común.
|
`FormBody` hereda esta disposición del diálogo y distribuye sus hijos en columna. `EntityTable layout="fill"` ocupa el espacio restante; búsqueda, filtros, información y paginación no se desplazan con los resultados. El contenedor de tabla tiene scroll interno y `scrollbar-gutter: stable`; las cabeceras permanecen visibles al recorrer las filas. El área mantiene su espacio con pocos resultados o ninguno. En ventanas pequeñas, el cuerpo también puede desplazarse para evitar recortar controles. `AssetList` propaga `layout="fill"` por las pestañas y `AssetPickerList` conserva ese comportamiento en selección única y múltiple. `AssetPickerDialog` y `CatalogFieldPickerDialog` aplican esta variante común.
|
||||||
|
|
||||||
@ -657,3 +671,46 @@ reutiliza `SwitchField`. `SiteDeleteDialog` compone `ConfirmDeleteDialog`, prese
|
|||||||
nombre de la sede y conserva el error de negocio dentro del modal. La caché de detalle
|
nombre de la sede y conserva el error de negocio dentro del modal. La caché de detalle
|
||||||
de compañía se actualiza tras completar cada operación. Los selectores conservan la
|
de compañía se actualiza tras completar cada operación. Los selectores conservan la
|
||||||
sede anterior archivada y excluyen las demás archivadas. Véase [Sedes](sedes.md).
|
sede anterior archivada y excluyen las demás archivadas. Véase [Sedes](sedes.md).
|
||||||
|
|
||||||
|
## Fichas compactas de activos elegidos
|
||||||
|
|
||||||
|
`AssetSummaryCard`, en `assets/shared/ui`, presenta nombre, icono y nombre de plantilla,
|
||||||
|
resumen de características y estado. Reutiliza `Card` de shadcn/ui, `AssetTemplateIcon`,
|
||||||
|
`assetCharacteristicsSummary` y `EntityStatus`. La especificación capturada determina el
|
||||||
|
nombre de plantilla y el resumen; el catálogo aporta únicamente el icono actual, con
|
||||||
|
icono de caja como alternativa. Los valores ausentes muestran `—`; cero y booleanos se
|
||||||
|
conservan con las traducciones habituales.
|
||||||
|
|
||||||
|
El consumidor aporta el activo, la plantilla opcional, acciones mediante `actions` y un
|
||||||
|
aviso mediante `error`. La ficha no carga datos ni decide cómo seleccionar o retirar activos.
|
||||||
|
Permanece en el módulo porque conoce sus DTO; las primitivas visuales siguen siendo
|
||||||
|
independientes del dominio. La usan la selección de activos del puesto y su resumen final.
|
||||||
|
Las tarjetas se distribuyen en una columna o dos desde 560 px de contenedor, igualan altura
|
||||||
|
por fila y mantienen el orden del DOM. En selección, un contador anuncia los cambios y
|
||||||
|
la retirada conserva el nombre accesible del activo. Los errores se presentan junto a
|
||||||
|
la ficha y se distinguen también mediante texto.
|
||||||
|
|
||||||
|
## Elección visual del ordenador principal
|
||||||
|
|
||||||
|
`WorkstationComputerField` reutiliza `AssetSummaryCard` para los activos ya incorporados
|
||||||
|
al puesto. Las fichas se componen con `FieldLabel`, `RadioGroup` y `RadioGroupItem` de
|
||||||
|
shadcn/ui/Base UI. Pulsar cualquier zona de la etiqueta selecciona su radio, sin handlers
|
||||||
|
de click duplicados ni otro modal. El grupo tiene leyenda, ayuda vinculada, nombres
|
||||||
|
accesibles traducidos y selección única; la primitiva gestiona foco, flechas y Espacio.
|
||||||
|
El borde y el radio marcado distinguen la selección sin depender solo del color.
|
||||||
|
La cuadrícula conserva una o dos columnas según el contenedor y la altura estable del
|
||||||
|
modal. No carga datos: recibe activos y catálogo del formulario. El activo único sigue
|
||||||
|
seleccionándose automáticamente y el guardado bloquea los controles. En edición se
|
||||||
|
conserva la acción explícita de quitar la selección cuando no es obligatoria.
|
||||||
|
|
||||||
|
## Foco inicial de los selectores PickerList
|
||||||
|
|
||||||
|
`EntityTable` identifica su input de búsqueda rápida mediante `data-quick-search`.
|
||||||
|
Al abrir un `FormDialog layout="picker"`, `initialFocus` de Base UI apunta a ese input
|
||||||
|
buscándolo dentro del popup, sin afectar a listados de página ni otros modales. La
|
||||||
|
búsqueda permanece montada durante la carga: `AssetPickerDialog` presenta la lista con
|
||||||
|
`loading`/`unavailable`, propagados por `AssetList` hasta `EntityTable`. Así se puede
|
||||||
|
escribir desde la apertura, sin anunciar falsos vacíos. El foco no se reaplica al filtrar
|
||||||
|
o recargar datos, y la primitiva conserva la restauración al cerrar diálogos anidados.
|
||||||
|
La [regla normativa](patron-de-dialogos.md#foco-inicial-en-la-búsqueda-rápida) se aplica
|
||||||
|
a todos los PickerList actuales y futuros, incluidos los de campos de ambos catálogos.
|
||||||
|
|||||||
@ -57,10 +57,39 @@ el [inventario de modales](inventario-de-modales.md) recoge las implementaciones
|
|||||||
el teclado y el arrastre. No introducir un orden visual diferente mediante CSS.
|
el teclado y el arrastre. No introducir un orden visual diferente mediante CSS.
|
||||||
- Cada grupo de campos responde a su propio ancho, también cuando su sección ocupa media
|
- Cada grupo de campos responde a su propio ancho, también cuando su sección ocupa media
|
||||||
fila. El bloque «Sin sección» permanece debajo de las secciones, a ancho completo.
|
fila. El bloque «Sin sección» permanece debajo de las secciones, a ancho completo.
|
||||||
- No convertir formularios sencillos en cuadrículas de secciones ni imponerles altura fija.
|
- No convertir formularios sencillos en cuadrículas de secciones ni imponer alturas
|
||||||
|
absolutas que superen la ventana. La estabilidad de la altura exterior se rige por la
|
||||||
|
regla siguiente; las secciones interiores conservan altura según su contenido.
|
||||||
Los umbrales anteriores corresponden a los componentes citados; otros contenidos deben
|
Los umbrales anteriores corresponden a los componentes citados; otros contenidos deben
|
||||||
elegir su disposición según el espacio que necesitan sus controles.
|
elegir su disposición según el espacio que necesitan sus controles.
|
||||||
|
|
||||||
|
## Estabilidad de altura del modal
|
||||||
|
|
||||||
|
Esta regla se aplica a los cuadros de diálogo actuales y futuros. Al crear o modificar
|
||||||
|
un diálogo, comprobar si su contenido puede variar mientras permanece abierto: pasos,
|
||||||
|
selecciones, altas anidadas, filtros, carga, errores, ayuda plegable o grupos condicionales.
|
||||||
|
En esos casos, la altura exterior debe mantenerse estable durante toda la interacción.
|
||||||
|
No debe crecer, encogerse ni desplazar el pie por cada elección del usuario.
|
||||||
|
|
||||||
|
- Utilizar `FormDialog layout="stable"` para formularios y asistentes con contenido
|
||||||
|
variable, como el alta de puestos. Reservar la altura desde la apertura, también cuando
|
||||||
|
el primer paso sea corto o los datos todavía estén cargando.
|
||||||
|
- La altura se adapta a la ventana: en móvil ocupa la altura dinámica disponible menos
|
||||||
|
2 rem de margen; desde `sm`, el menor valor entre el 85 % de la altura dinámica y 900 px,
|
||||||
|
respetando siempre el margen. Puede reajustarse al cambiar la ventana, orientación o
|
||||||
|
espacio disponible del teclado; no depende del contenido ni se mide con JavaScript.
|
||||||
|
- Conservar cabecera y pie visibles, `min-h-0` en la cadena flex, `FormBody` desplazable
|
||||||
|
y espacio reservado para la barra vertical. El contenido puede cambiar de alto dentro
|
||||||
|
del cuerpo, sin recortar campos, errores o acciones ni reducirlos para hacerlos caber.
|
||||||
|
- Mantener `layout="picker"` para selectores con tabla que ocupa el espacio restante;
|
||||||
|
ya proporciona altura estable y su distribución específica. No usarlo para fijar la
|
||||||
|
altura de formularios que no sean selectores.
|
||||||
|
- `layout="content"` queda para confirmaciones y diálogos breves cuyo contenido no cambia
|
||||||
|
de tamaño. Si incorporan contenido variable, aplicar la variante estable al modificarlos.
|
||||||
|
- Revisar pasos cortos y largos, listas vacías y extensas, carga/error y altas anidadas,
|
||||||
|
tanto en móvil como en escritorio. Deben conservarse la altura exterior y la posición
|
||||||
|
de las acciones, con acceso mediante scroll y teclado a todo el contenido.
|
||||||
|
|
||||||
## Secciones plegables y filas compactas
|
## Secciones plegables y filas compactas
|
||||||
|
|
||||||
- Las secciones extensas o repetidas utilizan la primitiva `Collapsible` mediante
|
- Las secciones extensas o repetidas utilizan la primitiva `Collapsible` mediante
|
||||||
@ -111,6 +140,27 @@ el [inventario de modales](inventario-de-modales.md) recoge las implementaciones
|
|||||||
su radio. Reutilizar `onRowClick` de la tabla; los controles internos ejecutan solo su
|
su radio. Reutilizar `onRowClick` de la tabla; los controles internos ejecutan solo su
|
||||||
propia acción, sin duplicar cambios. Respetar filas deshabilitadas y navegación por teclado.
|
propia acción, sin duplicar cambios. Respetar filas deshabilitadas y navegación por teclado.
|
||||||
|
|
||||||
|
### Foco inicial en la búsqueda rápida
|
||||||
|
|
||||||
|
Regla para todos los diálogos `PickerList` actuales y futuros: al abrirlos, el foco
|
||||||
|
inicial debe situarse en el cuadro de búsqueda rápida, listo para escribir. No debe
|
||||||
|
quedar en las pestañas, filtros, controles de selección o botones del pie.
|
||||||
|
|
||||||
|
- Reutilizar `FormDialog layout="picker"` y la búsqueda de `EntityTable`, que se identifica
|
||||||
|
con `data-quick-search`. `FormDialog` establece el foco mediante `initialFocus` de Base UI,
|
||||||
|
limitado al input del propio modal. Los selectores con búsqueda propia deben mantener
|
||||||
|
el mismo atributo en su input y la búsqueda debe estar montada y habilitada al abrir.
|
||||||
|
- Mantener la búsqueda visible y disponible durante carga, errores y resultados vacíos.
|
||||||
|
No esperar a los datos para montarla ni mostrar un mensaje de vacío mientras se carga.
|
||||||
|
- Aplicar el foco al abrir, también al reabrir; no volver a enfocarlo por cada tecla,
|
||||||
|
filtrado, cambio de pestaña, paginación o actualización de datos. No utilizar temporizadores
|
||||||
|
ni efectos que roben el foco durante la interacción.
|
||||||
|
- Mantener el aislamiento de los diálogos anidados y la restauración de foco de Base UI:
|
||||||
|
cerrar un alta relacionada devuelve el foco a su disparador dentro del picker;
|
||||||
|
cerrar el picker devuelve el foco al control que lo abrió.
|
||||||
|
- Verificar apertura con ratón y teclado, reapertura, carga inicial y altas anidadas.
|
||||||
|
El foco visible y el uso de Tab, flechas y Escape deben seguir funcionando.
|
||||||
|
|
||||||
## Cuerpo, pie y accesibilidad
|
## Cuerpo, pie y accesibilidad
|
||||||
|
|
||||||
- Elegir la anchura del diálogo según su contenido y respetar los márgenes de la ventana.
|
- Elegir la anchura del diálogo según su contenido y respetar los márgenes de la ventana.
|
||||||
@ -120,9 +170,15 @@ el [inventario de modales](inventario-de-modales.md) recoge las implementaciones
|
|||||||
`FormStepActions`, validar antes de avanzar y conservar el borrador al retroceder.
|
`FormStepActions`, validar antes de avanzar y conservar el borrador al retroceder.
|
||||||
Guardar se muestra al final; ayuda, cancelación y errores permanecen dentro del diálogo.
|
Guardar se muestra al final; ayuda, cancelación y errores permanecen dentro del diálogo.
|
||||||
El alta usa tamaño medio y la edición completa conserva tamaño grande.
|
El alta usa tamaño medio y la edición completa conserva tamaño grande.
|
||||||
- Los formularios conservan altura según contenido; la altura estable por resultados es
|
- Los formularios con contenido variable mantienen altura exterior estable según la
|
||||||
la regla de los `PickerList`. No introducir pestañas ni pasos para resolver solo espacio.
|
regla de estabilidad; sus grupos interiores conservan altura según contenido.
|
||||||
- Situar ayuda contextual general al final del cuerpo, inmediatamente antes de `FormActions`.
|
No introducir pestañas ni pasos para resolver solo espacio.
|
||||||
|
- Situar la ayuda contextual general inmediatamente antes de `FormActions`. En los
|
||||||
|
diálogos de altura estable debe quedar anclada abajo, fuera del scroll principal:
|
||||||
|
campos, progreso y errores se desplazan en `FormBody`, mientras la ayuda permanece
|
||||||
|
pegada al pie incluso en pasos cortos. Eliminar márgenes superiores innecesarios y
|
||||||
|
conservar el padding normal junto al footer. Limitar su altura y permitir scroll propio al
|
||||||
|
desplegar ejemplos, para no ocultar los campos ni las acciones en ventanas pequeñas.
|
||||||
Mantener errores junto al contexto que los origina y las acciones dentro del formulario.
|
Mantener errores junto al contexto que los origina y las acciones dentro del formulario.
|
||||||
- Conservar foco, cancelación, cierre por teclado, estados de carga, error, vacío y envío.
|
- Conservar foco, cancelación, cierre por teclado, estados de carga, error, vacío y envío.
|
||||||
No cerrar ni limpiar el formulario antes de confirmar una operación correcta.
|
No cerrar ni limpiar el formulario antes de confirmar una operación correcta.
|
||||||
|
|||||||
@ -69,16 +69,21 @@ El estado mostrado del activo se calcula desde su estado técnico y los puestos
|
|||||||
|
|
||||||
1. Sede y responsable. Si existe una única sede se selecciona automáticamente. El responsable es opcional; las altas relacionadas conservan el formulario.
|
1. Sede y responsable. Si existe una única sede se selecciona automáticamente. El responsable es opcional; las altas relacionadas conservan el formulario.
|
||||||
2. Activos. Un diálogo con listado, búsqueda rápida y pestañas por plantilla permite añadir varios a la vez. Es obligatorio añadir al menos uno; se impiden duplicados, activos ocupados y nuevas asociaciones no operativas.
|
2. Activos. Un diálogo con listado, búsqueda rápida y pestañas por plantilla permite añadir varios a la vez. Es obligatorio añadir al menos uno; se impiden duplicados, activos ocupados y nuevas asociaciones no operativas.
|
||||||
3. Ordenador principal. Se elige en un diálogo de selección única entre los activos seleccionados. Si solo queda uno se asigna automáticamente; al retirar el ordenador se limpia la selección o se asigna el único restante.
|
3. Ordenador principal. Se elige pulsando una de las fichas `AssetSummaryCard` de los activos seleccionados, sin abrir otro diálogo. Solo una tarjeta puede estar marcada; el borde y el radio identifican la elegida. El grupo conserva navegación por teclado mediante flechas y Espacio. Si solo queda uno se asigna automáticamente; al retirar el ordenador se limpia la selección o se asigna el único restante.
|
||||||
4. Resumen. Muestra sede, responsable, composición y ordenador principal. El nombre propuesto por sede/responsable se puede editar antes de Guardar.
|
4. Resumen. Muestra sede, responsable, composición y ordenador principal. El ordenador principal reutiliza `AssetSummaryCard` del paso 3, a ancho completo y con el mismo borde destacado: nombre, plantilla, icono, características y estado, sin controles de selección. La lista de activos excluye el ordenador principal para evitar duplicarlo; si no quedan otros activos muestra `—`. El nombre propuesto por sede/responsable se puede editar antes de Guardar.
|
||||||
|
|
||||||
El diálogo de alta utiliza tamaño medio y cabecera con icono decorativo. Los grupos
|
El diálogo de alta utiliza tamaño medio, cabecera con icono decorativo y
|
||||||
|
`FormDialog layout="stable"`. Su altura exterior depende de la ventana y se conserva
|
||||||
|
al cambiar de paso o añadir/retirar activos. El cuerpo tiene scroll interno y reserva
|
||||||
|
espacio para la barra; cabecera y pie permanecen visibles. Los grupos
|
||||||
reutilizan `FormSection variant="plain"`; asociaciones y resumen responden al ancho
|
reutilizan `FormSection variant="plain"`; asociaciones y resumen responden al ancho
|
||||||
del contenedor, con dos columnas cuando caben y una en espacios estrechos. Los activos
|
del contenedor, con dos columnas cuando caben y una en espacios estrechos. Los activos
|
||||||
seleccionados se muestran como filas compactas, sin tarjetas anidadas. El nombre del
|
seleccionados se muestran con `AssetSummaryCard`: fichas compactas con icono, plantilla,
|
||||||
|
características y estado, en una o dos columnas según el espacio. El resumen reutiliza
|
||||||
|
las mismas fichas sin acciones de retirada. El nombre del
|
||||||
resumen ocupa el ancho completo y los valores ausentes muestran `—`. La edición conserva
|
resumen ocupa el ancho completo y los valores ausentes muestran `—`. La edición conserva
|
||||||
el diálogo grande y los mismos grupos, conforme al [patrón de diálogos](patron-de-dialogos.md).
|
el diálogo grande con altura estable y los mismos grupos, conforme al [patrón de diálogos](patron-de-dialogos.md).
|
||||||
|
|
||||||
Anterior conserva valores y Siguiente valida los requisitos del paso. Guardar solo aparece en el resumen y no se envía el alta desde pasos intermedios. La edición mantiene un formulario único y permite conservar/completar puestos anteriores o desactivados. Cancelar permanece en el pie del modal y la ayuda inmediatamente encima.
|
Anterior conserva valores y Siguiente valida los requisitos del paso. Guardar solo aparece en el resumen y no se envía el alta desde pasos intermedios. La edición mantiene un formulario único y permite conservar/completar puestos anteriores o desactivados. Cancelar permanece en el pie del modal y la ayuda se mantiene anclada inmediatamente encima, fuera del scroll de campos. Los ejemplos extensos tienen desplazamiento propio limitado al espacio disponible.
|
||||||
|
|
||||||
Se elimina `sharedAssetIds` de contratos, dominio, reservas y vistas. La migración explícita `removeSharedWorkstationAssets`, ejecutada durante la preparación del esquema, elimina las asociaciones de tipo compartido tanto actuales como históricas y limpia sus referencias técnicas cuando no existe ninguna asociación exclusiva histórica. No convierte activos compartidos en exclusivos, no borra activos ni revisiones y no genera reservas conflictivas. Es transaccional e idempotente. Las composiciones exclusivas e identidades se conservan.
|
Se elimina `sharedAssetIds` de contratos, dominio, reservas y vistas. La migración explícita `removeSharedWorkstationAssets`, ejecutada durante la preparación del esquema, elimina las asociaciones de tipo compartido tanto actuales como históricas y limpia sus referencias técnicas cuando no existe ninguna asociación exclusiva histórica. No convierte activos compartidos en exclusivos, no borra activos ni revisiones y no genera reservas conflictivas. Es transaccional e idempotente. Las composiciones exclusivas e identidades se conservan.
|
||||||
|
|||||||
@ -386,7 +386,7 @@
|
|||||||
"missingAsset": "Asset unavailable."
|
"missingAsset": "Asset unavailable."
|
||||||
},
|
},
|
||||||
"workstationComputer": "Main computer",
|
"workstationComputer": "Main computer",
|
||||||
"workstationComputerHelp": "Select the computer from the workstation assets. Profiles will be created on it; the other assets are peripherals.",
|
"workstationComputerHelp": "Select the main computer by clicking its card among the workstation assets. Profiles will be created on it; the other assets are peripherals.",
|
||||||
"workstationLifecycle": {
|
"workstationLifecycle": {
|
||||||
"decommission": {
|
"decommission": {
|
||||||
"title": "Deactivate",
|
"title": "Deactivate",
|
||||||
@ -418,9 +418,10 @@
|
|||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
"locationDescription": "Select a site. You can leave the owner unassigned.",
|
"locationDescription": "Select a site. You can leave the owner unassigned.",
|
||||||
"assetsDescription": "Add at least one asset to the workstation.",
|
"assetsDescription": "Add at least one asset to the workstation.",
|
||||||
"computerDescription": "Identify the asset that will host user profiles.",
|
"chooseComputer": "Choose {{name}} as the main computer",
|
||||||
|
"computerDescription": "Click the card of the asset that will host user profiles.",
|
||||||
"summaryDescription": "Review the details and workstation name before saving.",
|
"summaryDescription": "Review the details and workstation name before saving.",
|
||||||
"singleComputer": "The only selected asset is automatically assigned as the main computer.",
|
"singleComputer": "The only asset is automatically selected as the main computer.",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next"
|
"next": "Next"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -386,7 +386,7 @@
|
|||||||
"missingAsset": "Activo no disponible."
|
"missingAsset": "Activo no disponible."
|
||||||
},
|
},
|
||||||
"workstationComputer": "Ordenador principal",
|
"workstationComputer": "Ordenador principal",
|
||||||
"workstationComputerHelp": "Selecciona el ordenador entre los activos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos.",
|
"workstationComputerHelp": "Pulsa la tarjeta del ordenador principal entre los activos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos.",
|
||||||
"workstationLifecycle": {
|
"workstationLifecycle": {
|
||||||
"decommission": {
|
"decommission": {
|
||||||
"title": "Desactivar",
|
"title": "Desactivar",
|
||||||
@ -418,9 +418,10 @@
|
|||||||
"summary": "Resumen",
|
"summary": "Resumen",
|
||||||
"locationDescription": "Selecciona una sede. Puedes dejar el responsable sin asignar.",
|
"locationDescription": "Selecciona una sede. Puedes dejar el responsable sin asignar.",
|
||||||
"assetsDescription": "Añade al menos un activo al puesto.",
|
"assetsDescription": "Añade al menos un activo al puesto.",
|
||||||
"computerDescription": "Identifica el activo que alojará los perfiles de usuario.",
|
"chooseComputer": "Elegir {{name}} como ordenador principal",
|
||||||
|
"computerDescription": "Pulsa la tarjeta del activo que alojará los perfiles de usuario.",
|
||||||
"summaryDescription": "Revisa los datos y el nombre del puesto antes de guardar.",
|
"summaryDescription": "Revisa los datos y el nombre del puesto antes de guardar.",
|
||||||
"singleComputer": "El único activo seleccionado se asigna automáticamente como ordenador principal.",
|
"singleComputer": "El único activo está seleccionado automáticamente como ordenador principal.",
|
||||||
"previous": "Anterior",
|
"previous": "Anterior",
|
||||||
"next": "Siguiente"
|
"next": "Siguiente"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -170,3 +170,38 @@ test("selector único: controles radio y disparador de diálogo; conserva el alt
|
|||||||
assert.ok(trigger.includes("disabled"));
|
assert.ok(trigger.includes("disabled"));
|
||||||
assert.equal(trigger.includes('role="combobox"'), false);
|
assert.equal(trigger.includes('role="combobox"'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("picker: búsqueda rápida disponible durante carga y error, sin anunciar un falso vacío", async () => {
|
||||||
|
for (const language of ["es", "en"]) {
|
||||||
|
for (const loading of [true, false]) {
|
||||||
|
const html = await render(
|
||||||
|
language,
|
||||||
|
<AssetPickerList
|
||||||
|
{...listProps}
|
||||||
|
assets={[]}
|
||||||
|
disabled={() => true}
|
||||||
|
loading={loading}
|
||||||
|
mode="multiple"
|
||||||
|
onToggle={() => undefined}
|
||||||
|
selectedIds={[]}
|
||||||
|
unavailable={!loading}
|
||||||
|
unavailableLabels={new Map()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const search = html.match(/<input\b[^>]*data-quick-search=""[^>]*>/)?.[0];
|
||||||
|
|
||||||
|
assert.ok(search);
|
||||||
|
assert.equal(/\sdisabled(?:=|\s|>)/.test(search), false);
|
||||||
|
assert.equal(
|
||||||
|
html.includes(
|
||||||
|
language === "es"
|
||||||
|
? "Esta compañía todavía no tiene activos."
|
||||||
|
: "This company has no assets yet."
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(html.includes('aria-busy="true"'), loading);
|
||||||
|
assert.ok(html.includes(language === "es" ? "Buscar" : "Search"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -47,32 +47,32 @@ export function AssetPickerDialog({
|
|||||||
{t("retry")}
|
{t("retry")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{c.inventory.data && (
|
<AssetPickerList
|
||||||
<AssetPickerList
|
assets={c.assets}
|
||||||
assets={c.assets}
|
catalog={c.catalog.data?.items ?? []}
|
||||||
catalog={c.catalog.data?.items ?? []}
|
disabled={c.disabled}
|
||||||
disabled={c.disabled}
|
loading={c.inventory.isPending || c.catalog.isPending}
|
||||||
mode={selection.mode ?? "single"}
|
mode={selection.mode ?? "single"}
|
||||||
onClearFilters={() => {
|
onClearFilters={() => {
|
||||||
c.setSearch("");
|
c.setSearch("");
|
||||||
c.setTemplateId(null);
|
c.setTemplateId(null);
|
||||||
}}
|
}}
|
||||||
onSearchChange={c.setSearch}
|
onSearchChange={c.setSearch}
|
||||||
onTemplateChange={(id) => c.setTemplateId(id === "all" ? null : id)}
|
onTemplateChange={(id) => c.setTemplateId(id === "all" ? null : id)}
|
||||||
onToggle={c.toggle}
|
onToggle={c.toggle}
|
||||||
search={c.search}
|
search={c.search}
|
||||||
selectedIds={c.selectedIds}
|
selectedIds={c.selectedIds}
|
||||||
sites={selection.sites}
|
sites={selection.sites}
|
||||||
templateId={c.templateId}
|
templateId={c.templateId}
|
||||||
unavailableLabels={
|
unavailable={c.inventory.isError || c.catalog.isError}
|
||||||
new Map(
|
unavailableLabels={
|
||||||
selection.options
|
new Map(
|
||||||
.filter((option) => option.disabled)
|
selection.options
|
||||||
.map((option) => [option.value, option.label])
|
.filter((option) => option.disabled)
|
||||||
)
|
.map((option) => [option.value, option.label])
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
)}
|
/>
|
||||||
<p aria-live="polite" className="text-sm text-muted-foreground">
|
<p aria-live="polite" className="text-sm text-muted-foreground">
|
||||||
{t("assetPicker.selected", { count: c.selectedIds.length })}
|
{t("assetPicker.selected", { count: c.selectedIds.length })}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -38,7 +38,7 @@ import { AssetInventoryPage } from "../list";
|
|||||||
|
|
||||||
import { assetCharacteristicsSummary } from "./helpers/asset-characteristics-summary";
|
import { assetCharacteristicsSummary } from "./helpers/asset-characteristics-summary";
|
||||||
import { hasMissingAssetFields, suggestedAssetName } from "./helpers/asset-creation-values";
|
import { hasMissingAssetFields, suggestedAssetName } from "./helpers/asset-creation-values";
|
||||||
import { AssetForm } from "./ui";
|
import { AssetForm, AssetSummaryCard } from "./ui";
|
||||||
import { AssetCharacteristicControl } from "./ui/asset-characteristic-control";
|
import { AssetCharacteristicControl } from "./ui/asset-characteristic-control";
|
||||||
|
|
||||||
const companyId = UniqueID.generateNewID().toPrimitive();
|
const companyId = UniqueID.generateNewID().toPrimitive();
|
||||||
@ -826,19 +826,15 @@ test("diseño del puesto: grupos compactos, columnas por contenedor y resumen si
|
|||||||
language,
|
language,
|
||||||
<WorkstationComputerField
|
<WorkstationComputerField
|
||||||
assets={[]}
|
assets={[]}
|
||||||
companyId={companyId}
|
|
||||||
onChange={() => undefined}
|
onChange={() => undefined}
|
||||||
prefix="design"
|
prefix="design"
|
||||||
required
|
required
|
||||||
singleComputer={false}
|
singleComputer={false}
|
||||||
sites={[]}
|
|
||||||
values={values}
|
values={values}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(computer.includes('aria-describedby="design-computer-help"'));
|
||||||
computer.includes('aria-describedby="design-computer-help design-computer-required"')
|
|
||||||
);
|
|
||||||
assert.ok(computer.includes('id="design-computer-help"'));
|
assert.ok(computer.includes('id="design-computer-help"'));
|
||||||
const selection = await render(
|
const selection = await render(
|
||||||
language,
|
language,
|
||||||
@ -857,7 +853,8 @@ test("diseño del puesto: grupos compactos, columnas por contenedor y resumen si
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(selection.includes("divide-y divide-border/60"));
|
assert.ok(selection.includes('data-slot="card"'));
|
||||||
|
assert.ok(selection.includes(language === "es" ? "1 activo seleccionado" : "1 asset selected"));
|
||||||
assert.ok(selection.includes("—"));
|
assert.ok(selection.includes("—"));
|
||||||
assert.match(selection, /<button(?=[^>]*aria-label="[^"]*—[^"]*")(?=[^>]*disabled="")[^>]*>/);
|
assert.match(selection, /<button(?=[^>]*aria-label="[^"]*—[^"]*")(?=[^>]*disabled="")[^>]*>/);
|
||||||
}
|
}
|
||||||
@ -878,3 +875,274 @@ test("diseño del puesto: navegación traducida y acciones bloqueadas durante el
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("ficha compacta de activo: comparte información, traducciones y acciones sin consultas propias", async () => {
|
||||||
|
const catalog = buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none());
|
||||||
|
const template = catalog.items.find((item) => item.values.kind === "template");
|
||||||
|
|
||||||
|
assert.ok(template);
|
||||||
|
const fields = [
|
||||||
|
{
|
||||||
|
fieldId: UniqueID.generateNewID().toPrimitive(),
|
||||||
|
name: "Modelo",
|
||||||
|
type: "text",
|
||||||
|
unit: "",
|
||||||
|
value: "ThinkCentre",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldId: UniqueID.generateNewID().toPrimitive(),
|
||||||
|
name: "Memoria",
|
||||||
|
type: "number",
|
||||||
|
unit: "GB",
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldId: UniqueID.generateNewID().toPrimitive(),
|
||||||
|
name: "Táctil",
|
||||||
|
type: "boolean",
|
||||||
|
unit: "",
|
||||||
|
value: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const values = {
|
||||||
|
name: "Equipo de administración",
|
||||||
|
templateId: template.id,
|
||||||
|
siteId: null,
|
||||||
|
status: "operational",
|
||||||
|
notes: "",
|
||||||
|
characteristics: Object.fromEntries(fields.map((field) => [field.fieldId, field.value])),
|
||||||
|
};
|
||||||
|
const asset = AssetSchema.parse({
|
||||||
|
id: companyId,
|
||||||
|
companyId,
|
||||||
|
values,
|
||||||
|
displayStatus: "available",
|
||||||
|
assigned: false,
|
||||||
|
specification: {
|
||||||
|
templateId: template.id,
|
||||||
|
templateName: template.values.name,
|
||||||
|
fields: fields.map(({ value, ...field }) => ({ ...field, choices: [], required: false })),
|
||||||
|
},
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: companyId,
|
||||||
|
actorId: companyId,
|
||||||
|
actorName: "Técnico",
|
||||||
|
recordedAt: "2026-10-16T12:00:00.000Z",
|
||||||
|
values,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const language of ["es", "en"]) {
|
||||||
|
const html = await render(
|
||||||
|
language,
|
||||||
|
<AssetSummaryCard
|
||||||
|
actions={<button type="button">Quitar</button>}
|
||||||
|
asset={asset}
|
||||||
|
error="Revisar disponibilidad"
|
||||||
|
template={template}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(html.includes(values.name));
|
||||||
|
assert.ok(html.includes(template.values.name));
|
||||||
|
assert.ok(html.includes("ThinkCentre · 0 GB · No"));
|
||||||
|
assert.ok(html.includes(language === "es" ? "Disponible" : "Available"));
|
||||||
|
assert.ok(html.includes('role="alert"'));
|
||||||
|
assert.ok(html.includes("Revisar disponibilidad"));
|
||||||
|
assert.ok(html.includes(">Quitar</button>"));
|
||||||
|
const summary = await render(
|
||||||
|
language,
|
||||||
|
<WorkstationSummary
|
||||||
|
assets={[asset]}
|
||||||
|
catalog={catalog.items}
|
||||||
|
lookups={lookups}
|
||||||
|
values={{
|
||||||
|
name: "Puesto",
|
||||||
|
active: true,
|
||||||
|
siteId: null,
|
||||||
|
personId: null,
|
||||||
|
assetIds: [asset.id],
|
||||||
|
computerAssetId: asset.id,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(summary.includes(values.name));
|
||||||
|
assert.ok(summary.includes("ThinkCentre · 0 GB · No"));
|
||||||
|
const computerLabel = language === "es" ? "Ordenador principal" : "Main computer";
|
||||||
|
const computerSummary = summary.split(`${computerLabel}</dt>`)[1]?.split("</dd>")[0];
|
||||||
|
|
||||||
|
assert.ok(computerSummary);
|
||||||
|
assert.ok(computerSummary.includes('data-slot="card"'));
|
||||||
|
assert.ok(computerSummary.includes(values.name));
|
||||||
|
assert.ok(computerSummary.includes(template.values.name));
|
||||||
|
assert.ok(computerSummary.includes("ThinkCentre · 0 GB · No"));
|
||||||
|
assert.ok(computerSummary.includes(language === "es" ? "Disponible" : "Available"));
|
||||||
|
assert.equal(computerSummary.includes("<input"), false);
|
||||||
|
assert.equal(computerSummary.includes("<button"), false);
|
||||||
|
assert.equal((summary.match(/data-slot="card"/g) ?? []).length, 1);
|
||||||
|
const assetsLabel = language === "es" ? "Activos del puesto" : "Workstation assets";
|
||||||
|
const assetSummary = summary.split(`${assetsLabel}</dt>`)[1]?.split("</dd>")[0];
|
||||||
|
|
||||||
|
assert.ok(assetSummary);
|
||||||
|
assert.ok(assetSummary.includes("—"));
|
||||||
|
assert.equal(assetSummary.includes("<ul"), false);
|
||||||
|
assert.equal(summary.includes(">Quitar</button>"), false);
|
||||||
|
const otherAsset = {
|
||||||
|
...asset,
|
||||||
|
id: UniqueID.generateNewID().toPrimitive(),
|
||||||
|
values: { ...asset.values, name: "Monitor auxiliar" },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const computerAssetId of [asset.id, null]) {
|
||||||
|
const composition = await render(
|
||||||
|
language,
|
||||||
|
<WorkstationSummary
|
||||||
|
assets={[asset, otherAsset]}
|
||||||
|
catalog={catalog.items}
|
||||||
|
lookups={lookups}
|
||||||
|
values={{
|
||||||
|
name: "Puesto",
|
||||||
|
active: true,
|
||||||
|
siteId: null,
|
||||||
|
personId: null,
|
||||||
|
assetIds: [asset.id, otherAsset.id],
|
||||||
|
computerAssetId,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const assetList = composition.split(`${assetsLabel}</dt>`)[1]?.split("</dd>")[0];
|
||||||
|
|
||||||
|
assert.ok(assetList);
|
||||||
|
assert.ok(assetList.includes(otherAsset.values.name));
|
||||||
|
assert.equal(assetList.includes(asset.values.name), computerAssetId === null);
|
||||||
|
assert.equal((composition.match(/data-slot="card"/g) ?? []).length, 2);
|
||||||
|
}
|
||||||
|
const missing = await render(language, <AssetSummaryCard asset={undefined} />);
|
||||||
|
|
||||||
|
assert.equal((missing.match(/—/g) ?? []).length, 3);
|
||||||
|
assert.equal(missing.includes('role="alert"'), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ordenador principal: tarjetas con selección única, etiquetas es/en y solo los activos del puesto", async () => {
|
||||||
|
const templateId = UniqueID.generateNewID().toPrimitive();
|
||||||
|
const assets = ["Ordenador", "Monitor", "Fuera del puesto"].map((name) => {
|
||||||
|
const id = UniqueID.generateNewID().toPrimitive();
|
||||||
|
const values = {
|
||||||
|
name,
|
||||||
|
templateId,
|
||||||
|
siteId: null,
|
||||||
|
status: "operational",
|
||||||
|
notes: "",
|
||||||
|
characteristics: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return AssetSchema.parse({
|
||||||
|
id,
|
||||||
|
companyId,
|
||||||
|
values,
|
||||||
|
assigned: false,
|
||||||
|
displayStatus: "available",
|
||||||
|
specification: {
|
||||||
|
templateId,
|
||||||
|
templateName: "Equipo",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldId: companyId,
|
||||||
|
name: "Serie",
|
||||||
|
type: "text",
|
||||||
|
unit: "",
|
||||||
|
required: false,
|
||||||
|
choices: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
values,
|
||||||
|
actorId: companyId,
|
||||||
|
actorName: "Técnico",
|
||||||
|
recordedAt: "2026-10-16T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const first = assets[0];
|
||||||
|
const second = assets[1];
|
||||||
|
|
||||||
|
assert.ok(first && second);
|
||||||
|
const values = WorkstationInputSchema.parse({
|
||||||
|
name: "Puesto",
|
||||||
|
active: true,
|
||||||
|
siteId: null,
|
||||||
|
personId: null,
|
||||||
|
assetIds: [first.id, second.id],
|
||||||
|
computerAssetId: second.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const language of ["es", "en"]) {
|
||||||
|
const html = await render(
|
||||||
|
language,
|
||||||
|
<WorkstationComputerField
|
||||||
|
assets={assets}
|
||||||
|
onChange={() => undefined}
|
||||||
|
prefix="computer"
|
||||||
|
required
|
||||||
|
singleComputer={false}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal((html.match(/role="radio"/g) ?? []).length, 2);
|
||||||
|
assert.equal((html.match(/aria-checked="true"/g) ?? []).length, 1);
|
||||||
|
assert.ok(html.includes('role="radiogroup"'));
|
||||||
|
assert.ok(html.includes('aria-describedby="computer-computer-help"'));
|
||||||
|
assert.ok(html.includes(`for="computer-computer-${first.id}"`));
|
||||||
|
assert.ok(html.includes(`for="computer-computer-${second.id}"`));
|
||||||
|
assert.ok(
|
||||||
|
html.includes(
|
||||||
|
language === "es"
|
||||||
|
? "Elegir Monitor como ordenador principal"
|
||||||
|
: "Choose Monitor as the main computer"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.ok(html.includes('data-slot="card"'));
|
||||||
|
assert.equal(html.includes("Fuera del puesto"), false);
|
||||||
|
assert.equal(html.includes('aria-haspopup="dialog"'), false);
|
||||||
|
const single = await render(
|
||||||
|
language,
|
||||||
|
<WorkstationComputerField
|
||||||
|
assets={assets}
|
||||||
|
onChange={() => undefined}
|
||||||
|
prefix="single"
|
||||||
|
required
|
||||||
|
singleComputer
|
||||||
|
values={{ ...values, assetIds: [first.id], computerAssetId: first.id }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal((single.match(/role="radio"/g) ?? []).length, 1);
|
||||||
|
assert.ok(single.includes('aria-checked="true"'));
|
||||||
|
assert.ok(single.includes('aria-disabled="true"'));
|
||||||
|
assert.ok(
|
||||||
|
single.includes(language === "es" ? "seleccionado automáticamente" : "automatically selected")
|
||||||
|
);
|
||||||
|
const optional = await render(
|
||||||
|
language,
|
||||||
|
<WorkstationComputerField
|
||||||
|
assets={assets}
|
||||||
|
onChange={() => undefined}
|
||||||
|
prefix="optional"
|
||||||
|
required={false}
|
||||||
|
singleComputer={false}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(optional.includes(language === "es" ? "Quitar selección" : "Clear selection"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -27,6 +27,8 @@ export function AssetList({
|
|||||||
rowClassName = () => "",
|
rowClassName = () => "",
|
||||||
onRowClick,
|
onRowClick,
|
||||||
layout = "content",
|
layout = "content",
|
||||||
|
loading = false,
|
||||||
|
unavailable = false,
|
||||||
}: {
|
}: {
|
||||||
assets: readonly AssetDTO[];
|
assets: readonly AssetDTO[];
|
||||||
catalog: readonly CatalogItemDTO[];
|
catalog: readonly CatalogItemDTO[];
|
||||||
@ -42,6 +44,8 @@ export function AssetList({
|
|||||||
rowClassName?: ((asset: AssetDTO) => string) | undefined;
|
rowClassName?: ((asset: AssetDTO) => string) | undefined;
|
||||||
onRowClick?: ((asset: AssetDTO) => void) | undefined;
|
onRowClick?: ((asset: AssetDTO) => void) | undefined;
|
||||||
layout?: "content" | "fill";
|
layout?: "content" | "fill";
|
||||||
|
loading?: boolean;
|
||||||
|
unavailable?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation("assets");
|
const { t, i18n } = useTranslation("assets");
|
||||||
const templates = new Map(
|
const templates = new Map(
|
||||||
@ -160,6 +164,7 @@ export function AssetList({
|
|||||||
filtersApplied={templateId !== null}
|
filtersApplied={templateId !== null}
|
||||||
items={items}
|
items={items}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
|
loading={loading}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
onClearFilters={onClearFilters}
|
onClearFilters={onClearFilters}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
@ -168,6 +173,7 @@ export function AssetList({
|
|||||||
search={search}
|
search={search}
|
||||||
searchPlaceholder={t("search")}
|
searchPlaceholder={t("search")}
|
||||||
title={t("inventory")}
|
title={t("inventory")}
|
||||||
|
unavailable={unavailable}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@ -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 { AssetEditorDialog } from "./asset-editor-dialog";
|
||||||
export { AssetForm } from "./asset-form";
|
export { AssetForm } from "./asset-form";
|
||||||
|
export { AssetSummaryCard } from "./asset-summary-card";
|
||||||
export { DeleteAssetDialog } from "./delete-asset-dialog";
|
export { DeleteAssetDialog } from "./delete-asset-dialog";
|
||||||
|
|||||||
@ -4,11 +4,14 @@ import { XIcon } from "@repo/rdx-ui/lib/icons";
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { CatalogItemDTO } from "../../../../common";
|
||||||
import type { AssetDTO } from "../../../../common/inventory";
|
import type { AssetDTO } from "../../../../common/inventory";
|
||||||
import { AssetSelect } from "../../../assets/select/ui/asset-select";
|
import { AssetSelect } from "../../../assets/select/ui/asset-select";
|
||||||
|
import { AssetSummaryCard } from "../../../assets/shared/ui";
|
||||||
|
|
||||||
export function WorkstationAssetFields({
|
export function WorkstationAssetFields({
|
||||||
assets,
|
assets,
|
||||||
|
catalog = [],
|
||||||
companyId,
|
companyId,
|
||||||
sites,
|
sites,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@ -21,6 +24,7 @@ export function WorkstationAssetFields({
|
|||||||
onCreate,
|
onCreate,
|
||||||
}: {
|
}: {
|
||||||
assets: readonly AssetDTO[];
|
assets: readonly AssetDTO[];
|
||||||
|
catalog?: readonly CatalogItemDTO[] | undefined;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
sites: readonly { id: string; name: string }[];
|
sites: readonly { id: string; name: string }[];
|
||||||
selectedIds: readonly string[];
|
selectedIds: readonly string[];
|
||||||
@ -70,34 +74,40 @@ export function WorkstationAssetFields({
|
|||||||
value={[]}
|
value={[]}
|
||||||
/>
|
/>
|
||||||
{selectedIds.length ? (
|
{selectedIds.length ? (
|
||||||
<ul className="divide-y divide-border/60">
|
<div className="space-y-3 pt-3">
|
||||||
{selectedIds.map((id) => {
|
<p aria-live="polite" className="text-xs font-medium text-muted-foreground">
|
||||||
const name = assets.find((asset) => asset.id === id)?.values.name ?? "—";
|
{t("assetPicker.selected", { count: selectedIds.length })}
|
||||||
const reason = unavailableReason(id);
|
</p>
|
||||||
|
<ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
|
||||||
|
{selectedIds.map((id) => {
|
||||||
|
const asset = assets.find((item) => item.id === id);
|
||||||
|
const name = asset?.values.name ?? "—";
|
||||||
|
const reason = unavailableReason(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li className="min-w-0" key={id}>
|
||||||
className="flex min-w-0 items-center justify-between gap-3 py-3 text-sm first:pt-0 last:pb-0"
|
<AssetSummaryCard
|
||||||
key={id}
|
actions={
|
||||||
>
|
<Button
|
||||||
<div className="min-w-0 space-y-1 break-words">
|
aria-label={t("removeWorkstationAsset", { name })}
|
||||||
<p>{name}</p>
|
disabled={disabled}
|
||||||
{reason && <p className="text-destructive">{t(`inventoryErrors.${reason}`)}</p>}
|
onClick={() => onRemove(id)}
|
||||||
</div>
|
size="icon-sm"
|
||||||
<Button
|
type="button"
|
||||||
aria-label={t("removeWorkstationAsset", { name })}
|
variant="ghost"
|
||||||
disabled={disabled}
|
>
|
||||||
onClick={() => onRemove(id)}
|
<XIcon aria-hidden="true" className="size-4" />
|
||||||
size="icon-sm"
|
</Button>
|
||||||
type="button"
|
}
|
||||||
variant="ghost"
|
asset={asset}
|
||||||
>
|
error={reason ? t(`inventoryErrors.${reason}`) : undefined}
|
||||||
<XIcon aria-hidden="true" className="size-4" />
|
template={catalog.find((item) => item.id === asset?.values.templateId)}
|
||||||
</Button>
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
|
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,54 +1,104 @@
|
|||||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
import { RequiredIndicator } from "@repo/rdx-ui/components/required-indicator";
|
||||||
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
|
import { FieldLabel } from "@repo/shadcn-ui/components/field";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/components/radio-group";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { WorkstationInput } from "../../../../common";
|
import type { CatalogItemDTO, WorkstationInput } from "../../../../common";
|
||||||
import type { AssetDTO } from "../../../../common/inventory";
|
import type { AssetDTO } from "../../../../common/inventory";
|
||||||
import { AssetSelect } from "../../../assets/select/ui/asset-select";
|
import { AssetSummaryCard } from "../../../assets/shared/ui";
|
||||||
|
|
||||||
export function WorkstationComputerField({
|
export function WorkstationComputerField({
|
||||||
values,
|
values,
|
||||||
assets,
|
assets,
|
||||||
companyId,
|
catalog = [],
|
||||||
sites,
|
|
||||||
prefix,
|
prefix,
|
||||||
singleComputer,
|
singleComputer,
|
||||||
required,
|
required,
|
||||||
|
disabled = false,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
values: WorkstationInput;
|
values: WorkstationInput;
|
||||||
assets: readonly AssetDTO[];
|
assets: readonly AssetDTO[];
|
||||||
companyId: string;
|
catalog?: readonly CatalogItemDTO[] | undefined;
|
||||||
sites: readonly { id: string; name: string }[];
|
|
||||||
prefix: string;
|
prefix: string;
|
||||||
singleComputer: boolean;
|
singleComputer: boolean;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
onChange: (id: string | null) => void;
|
onChange: (id: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<fieldset className="min-w-0 space-y-3">
|
||||||
<FormLabel htmlFor={`${prefix}-computer`} required={required}>
|
<legend
|
||||||
|
className="mb-2 flex items-center gap-1 text-sm font-medium"
|
||||||
|
id={`${prefix}-computer-label`}
|
||||||
|
>
|
||||||
{t("workstationComputer")}
|
{t("workstationComputer")}
|
||||||
</FormLabel>
|
{required && <RequiredIndicator />}
|
||||||
<AssetSelect
|
</legend>
|
||||||
companyId={companyId}
|
<p className="text-sm text-muted-foreground" id={`${prefix}-computer-help`}>
|
||||||
describedBy={`${prefix}-computer-help`}
|
|
||||||
disabled={singleComputer}
|
|
||||||
id={`${prefix}-computer`}
|
|
||||||
label={t("workstationComputer")}
|
|
||||||
onValueChange={(id) => onChange(id || null)}
|
|
||||||
options={assets
|
|
||||||
.filter((asset) => values.assetIds.includes(asset.id))
|
|
||||||
.map((asset) => ({ value: asset.id, label: asset.values.name }))}
|
|
||||||
placeholder={t("noAssignment")}
|
|
||||||
required={required}
|
|
||||||
sites={sites}
|
|
||||||
value={values.computerAssetId ?? ""}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground" id={`${prefix}-computer-help`}>
|
|
||||||
{t(singleComputer ? "workstationSteps.singleComputer" : "workstationComputerHelp")}
|
{t(singleComputer ? "workstationSteps.singleComputer" : "workstationComputerHelp")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<RadioGroup
|
||||||
|
aria-describedby={`${prefix}-computer-help`}
|
||||||
|
aria-labelledby={`${prefix}-computer-label`}
|
||||||
|
className="items-stretch gap-4 @min-[560px]:grid-cols-2"
|
||||||
|
disabled={disabled || singleComputer}
|
||||||
|
name={`${prefix}-computer`}
|
||||||
|
onValueChange={(id) => {
|
||||||
|
if (typeof id === "string" && values.assetIds.includes(id)) {
|
||||||
|
onChange(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required={required}
|
||||||
|
value={values.computerAssetId ?? ""}
|
||||||
|
>
|
||||||
|
{values.assetIds.map((id) => {
|
||||||
|
const asset = assets.find((item) => item.id === id);
|
||||||
|
const selected = values.computerAssetId === id;
|
||||||
|
const inputId = `${prefix}-computer-${id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldLabel
|
||||||
|
className={`block h-full w-full min-w-0 cursor-pointer rounded-lg font-normal ring-2 ring-offset-2 ring-offset-background transition-shadow has-[:focus-visible]:ring-ring has-[[data-disabled]]:cursor-default ${selected ? "ring-primary" : "ring-transparent hover:ring-border"}`}
|
||||||
|
htmlFor={inputId}
|
||||||
|
key={id}
|
||||||
|
>
|
||||||
|
<AssetSummaryCard
|
||||||
|
actions={
|
||||||
|
<RadioGroupItem
|
||||||
|
aria-label={t("workstationSteps.chooseComputer", {
|
||||||
|
name: asset?.values.name ?? "—",
|
||||||
|
})}
|
||||||
|
disabled={!asset}
|
||||||
|
id={inputId}
|
||||||
|
value={id}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
asset={asset}
|
||||||
|
error={asset ? undefined : t("inventoryErrors.ASSET_NOT_FOUND")}
|
||||||
|
template={catalog.find((item) => item.id === asset?.values.templateId)}
|
||||||
|
/>
|
||||||
|
</FieldLabel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
{values.assetIds.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
|
||||||
|
)}
|
||||||
|
{!required && values.computerAssetId && (
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{t("assetPicker.clear")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export function WorkstationEditorDialog({
|
|||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
formActions
|
formActions
|
||||||
|
layout="stable"
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@ -78,6 +78,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
|||||||
<WorkstationAssetFields
|
<WorkstationAssetFields
|
||||||
assets={assets}
|
assets={assets}
|
||||||
canCreate={!!catalog.data}
|
canCreate={!!catalog.data}
|
||||||
|
catalog={catalog.data?.items}
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
disabled={inventory.isPending}
|
disabled={inventory.isPending}
|
||||||
onAdd={addMany}
|
onAdd={addMany}
|
||||||
@ -92,12 +93,12 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
|||||||
{layout.computer && (
|
{layout.computer && (
|
||||||
<WorkstationComputerField
|
<WorkstationComputerField
|
||||||
assets={assets}
|
assets={assets}
|
||||||
companyId={companyId}
|
catalog={catalog.data?.items}
|
||||||
onChange={(id) => form.setValue("computerAssetId", id)}
|
disabled={mutation.isPending}
|
||||||
|
onChange={(id) => form.setValue("computerAssetId", id, { shouldDirty: true })}
|
||||||
prefix={prefix}
|
prefix={prefix}
|
||||||
required={isWizard}
|
required={isWizard}
|
||||||
singleComputer={singleComputer}
|
singleComputer={singleComputer}
|
||||||
sites={lookups.sites}
|
|
||||||
values={values}
|
values={values}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -105,7 +106,12 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
|||||||
<WorkstationIdentityFields form={form} prefix={prefix} showStatus={!isWizard} />
|
<WorkstationIdentityFields form={form} prefix={prefix} showStatus={!isWizard} />
|
||||||
)}
|
)}
|
||||||
{layout.review && (
|
{layout.review && (
|
||||||
<WorkstationSummary assets={assets} lookups={lookups} values={values} />
|
<WorkstationSummary
|
||||||
|
assets={assets}
|
||||||
|
catalog={catalog.data?.items}
|
||||||
|
lookups={lookups}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{Object.keys(form.formState.errors).length > 0 && (
|
{Object.keys(form.formState.errors).length > 0 && (
|
||||||
@ -119,6 +125,8 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<InventoryFeedback error={mutation.error ?? inventory.error ?? catalog.error} />
|
<InventoryFeedback error={mutation.error ?? inventory.error ?? catalog.error} />
|
||||||
|
</FormBody>
|
||||||
|
<div className="min-h-0 max-h-[40%] shrink-0 overflow-y-auto px-1 pb-3 [scrollbar-gutter:stable] [&>aside]:mt-0">
|
||||||
<ContextHelp
|
<ContextHelp
|
||||||
description={t("workstationDescription")}
|
description={t("workstationDescription")}
|
||||||
examples={t("workstationExamples")}
|
examples={t("workstationExamples")}
|
||||||
@ -128,7 +136,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
|||||||
title={t("helpTitle")}
|
title={t("helpTitle")}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
/>
|
/>
|
||||||
</FormBody>
|
</div>
|
||||||
<WorkstationFormActions
|
<WorkstationFormActions
|
||||||
canSubmit={isWizard ? canContinue : assetsReady}
|
canSubmit={isWizard ? canContinue : assetsReady}
|
||||||
onPrevious={previousStep}
|
onPrevious={previousStep}
|
||||||
|
|||||||
@ -1,20 +1,25 @@
|
|||||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { WorkstationInput } from "../../../../common";
|
import type { CatalogItemDTO, WorkstationInput } from "../../../../common";
|
||||||
import type { AssetDTO } from "../../../../common/inventory";
|
import type { AssetDTO } from "../../../../common/inventory";
|
||||||
|
import { AssetSummaryCard } from "../../../assets/shared/ui";
|
||||||
import type { WorkstationFormProps } from "../types";
|
import type { WorkstationFormProps } from "../types";
|
||||||
|
|
||||||
export function WorkstationSummary({
|
export function WorkstationSummary({
|
||||||
values,
|
values,
|
||||||
assets,
|
assets,
|
||||||
|
catalog = [],
|
||||||
lookups,
|
lookups,
|
||||||
}: {
|
}: {
|
||||||
values: WorkstationInput;
|
values: WorkstationInput;
|
||||||
assets: readonly AssetDTO[];
|
assets: readonly AssetDTO[];
|
||||||
|
catalog?: readonly CatalogItemDTO[] | undefined;
|
||||||
lookups: WorkstationFormProps["lookups"];
|
lookups: WorkstationFormProps["lookups"];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
|
const computer = assets.find((asset) => asset.id === values.computerAssetId);
|
||||||
|
const otherAssetIds = values.assetIds.filter((id) => id !== values.computerAssetId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSection title={t("formLayout.workstationSummary")} variant="plain">
|
<FormSection title={t("formLayout.workstationSummary")} variant="plain">
|
||||||
@ -27,24 +32,44 @@ export function WorkstationSummary({
|
|||||||
<dt className="text-muted-foreground">{t("workstationPerson")}</dt>
|
<dt className="text-muted-foreground">{t("workstationPerson")}</dt>
|
||||||
<dd>{lookups.people.find((person) => person.id === values.personId)?.name ?? "—"}</dd>
|
<dd>{lookups.people.find((person) => person.id === values.personId)?.name ?? "—"}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 space-y-1 break-words">
|
<div className="col-span-full min-w-0 space-y-3">
|
||||||
|
<dt className="text-muted-foreground">{t("workstationComputer")}</dt>
|
||||||
|
<dd>
|
||||||
|
{computer ? (
|
||||||
|
<div className="rounded-lg ring-2 ring-primary ring-offset-2 ring-offset-background">
|
||||||
|
<AssetSummaryCard
|
||||||
|
asset={computer}
|
||||||
|
template={catalog.find((item) => item.id === computer.values.templateId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-full min-w-0 space-y-3">
|
||||||
<dt className="text-muted-foreground">{t("workstationAssets")}</dt>
|
<dt className="text-muted-foreground">{t("workstationAssets")}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{values.assetIds.length > 0 ? (
|
{otherAssetIds.length > 0 ? (
|
||||||
<ul className="list-inside list-disc">
|
<ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
|
||||||
{values.assetIds.map((id) => (
|
{otherAssetIds.map((id) => {
|
||||||
<li key={id}>{assets.find((asset) => asset.id === id)?.values.name ?? "—"}</li>
|
const asset = assets.find((item) => item.id === id);
|
||||||
))}
|
|
||||||
|
return (
|
||||||
|
<li className="min-w-0" key={id}>
|
||||||
|
<AssetSummaryCard
|
||||||
|
asset={asset}
|
||||||
|
template={catalog.find((item) => item.id === asset?.values.templateId)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 space-y-1 break-words">
|
|
||||||
<dt className="text-muted-foreground">{t("workstationComputer")}</dt>
|
|
||||||
<dd>{assets.find((asset) => asset.id === values.computerAssetId)?.values.name ?? "—"}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
</dl>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -121,6 +121,7 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
{searchPlaceholder ?? labels.search}
|
{searchPlaceholder ?? labels.search}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
data-quick-search=""
|
||||||
id={`${id}-search`}
|
id={`${id}-search`}
|
||||||
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
|
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
|
||||||
placeholder={searchPlaceholder ?? labels.placeholder}
|
placeholder={searchPlaceholder ?? labels.placeholder}
|
||||||
|
|||||||
@ -7,9 +7,11 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@repo/shadcn-ui/components/dialog";
|
} from "@repo/shadcn-ui/components/dialog";
|
||||||
import { type ReactNode, createContext, useContext } from "react";
|
import { type ReactNode, createContext, useContext, useRef } from "react";
|
||||||
|
|
||||||
const FormLayoutContext = createContext<"content" | "picker">("content");
|
type FormDialogLayout = "content" | "stable" | "picker";
|
||||||
|
|
||||||
|
const FormLayoutContext = createContext<FormDialogLayout>("content");
|
||||||
|
|
||||||
const FormCancelContext = createContext<string | undefined>(undefined);
|
const FormCancelContext = createContext<string | undefined>(undefined);
|
||||||
|
|
||||||
@ -32,8 +34,9 @@ export function FormDialog({
|
|||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
size?: "small" | "medium" | "large";
|
size?: "small" | "medium" | "large";
|
||||||
formActions?: boolean;
|
formActions?: boolean;
|
||||||
layout?: "content" | "picker";
|
layout?: FormDialogLayout;
|
||||||
}) {
|
}) {
|
||||||
|
const popupRef = useRef<HTMLDivElement>(null);
|
||||||
const width = size ?? (wide ? "large" : "small");
|
const width = size ?? (wide ? "large" : "small");
|
||||||
const widths = {
|
const widths = {
|
||||||
small: "sm:max-w-xl",
|
small: "sm:max-w-xl",
|
||||||
@ -41,12 +44,21 @@ export function FormDialog({
|
|||||||
large: "sm:w-[90vw] sm:max-w-[1200px]",
|
large: "sm:w-[90vw] sm:max-w-[1200px]",
|
||||||
};
|
};
|
||||||
|
|
||||||
const height = layout === "picker" ? "h-[calc(100dvh-2rem)] sm:h-[min(85dvh,900px)]" : "";
|
// La altura de los diálogos dinámicos depende de la ventana, nunca de sus resultados o pasos.
|
||||||
|
const height = layout === "content" ? "" : "h-[calc(100dvh-2rem)] sm:h-[min(85dvh,900px)]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={`flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 ${widths[width]} ${height}`}
|
className={`flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 ${widths[width]} ${height}`}
|
||||||
|
initialFocus={
|
||||||
|
layout === "picker"
|
||||||
|
? () =>
|
||||||
|
popupRef.current?.querySelector<HTMLInputElement>("input[data-quick-search]") ??
|
||||||
|
true
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
ref={popupRef}
|
||||||
showCloseButton={true}
|
showCloseButton={true}
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0 border-b px-6 py-5 pr-14">
|
<DialogHeader className="shrink-0 border-b px-6 py-5 pr-14">
|
||||||
@ -105,7 +117,7 @@ export function FormBody({ children }: { children: ReactNode }) {
|
|||||||
className={
|
className={
|
||||||
layout === "picker"
|
layout === "picker"
|
||||||
? "@container flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1 pt-1 pb-5 [scrollbar-gutter:stable] [&>*]:shrink-0"
|
? "@container flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1 pt-1 pb-5 [scrollbar-gutter:stable] [&>*]:shrink-0"
|
||||||
: "@container min-h-0 flex-1 space-y-5 overflow-y-auto px-1 pt-1 pb-5"
|
: `@container min-h-0 flex-1 space-y-5 overflow-y-auto px-1 pt-1 pb-5 ${layout === "stable" ? "[scrollbar-gutter:stable]" : ""}`
|
||||||
}
|
}
|
||||||
data-slot="form-body"
|
data-slot="form-body"
|
||||||
>
|
>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user