feat: enhance workstation asset management interface

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

View File

@ -332,7 +332,16 @@ TemplateSectionsEditor recibe secciones, referencias de campos, nombres, textos
`FormDialog` admite `size="small" | "medium" | "large"`. Los formularios sencillos mantienen el tamaño pequeño; personas y usuarios emplean el intermedio. `wide` sigue siendo un alias del tamaño grande: 90 % del ancho disponible, con máximo de 1.200 px y márgenes en móvil. No se ensanchan automáticamente las altas sencillas.
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.

View File

@ -57,10 +57,39 @@ el [inventario de modales](inventario-de-modales.md) recoge las implementaciones
el teclado y el arrastre. No introducir un orden visual diferente mediante CSS.
- 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.

View File

@ -69,16 +69,21 @@ El estado mostrado del activo se calcula desde su estado técnico y los puestos
1. Sede y responsable. Si existe una única sede se selecciona automáticamente. El responsable es opcional; las altas relacionadas conservan el formulario.
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.

View File

@ -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"
},

View File

@ -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"
},

View File

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

View File

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

View File

@ -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"));
}
});

View File

@ -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>

View File

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

View File

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

View File

@ -4,11 +4,14 @@ import { XIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory";
import { AssetSelect } from "../../../assets/select/ui/asset-select";
import { AssetSummaryCard } from "../../../assets/shared/ui";
export function WorkstationAssetFields({
assets,
catalog = [],
companyId,
sites,
selectedIds,
@ -21,6 +24,7 @@ export function WorkstationAssetFields({
onCreate,
}: {
assets: readonly AssetDTO[];
catalog?: readonly CatalogItemDTO[] | undefined;
companyId: string;
sites: readonly { id: string; name: string }[];
selectedIds: readonly string[];
@ -70,34 +74,40 @@ export function WorkstationAssetFields({
value={[]}
/>
{selectedIds.length ? (
<ul className="divide-y divide-border/60">
{selectedIds.map((id) => {
const name = assets.find((asset) => asset.id === id)?.values.name ?? "—";
const reason = unavailableReason(id);
<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 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>
<Button
aria-label={t("removeWorkstationAsset", { name })}
disabled={disabled}
onClick={() => onRemove(id)}
size="icon-sm"
type="button"
variant="ghost"
>
<XIcon aria-hidden="true" className="size-4" />
</Button>
</li>
);
})}
</ul>
return (
<li className="min-w-0" key={id}>
<AssetSummaryCard
actions={
<Button
aria-label={t("removeWorkstationAsset", { name })}
disabled={disabled}
onClick={() => onRemove(id)}
size="icon-sm"
type="button"
variant="ghost"
>
<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>
)}

View File

@ -1,54 +1,104 @@
import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { RequiredIndicator } from "@repo/rdx-ui/components/required-indicator";
import { Button } from "@repo/shadcn-ui/components/button";
import { FieldLabel } from "@repo/shadcn-ui/components/field";
import { RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/components/radio-group";
import { useTranslation } from "react-i18next";
import 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>
);
}

View File

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

View File

@ -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}

View File

@ -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>
);

View File

@ -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}

View File

@ -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"
>