feat: enhance user management and resource catalog UI with icons, improve localization, and add new components
- Added UsersIcon to UserManagement for better visual representation. - Updated Spanish localization for "noValue" to use a dash instead of "Sin datos". - Introduced LayoutTemplateIcon in ResourceCatalogPage for improved UI. - Integrated LayersIcon in ResourcesPage to enhance user experience. - Updated package.json test scripts to include new test files. - Enhanced DetailSection component to support accordion functionality. - Modified EntityListHeader to accept an icon prop for better UI consistency. - Added new CountBadge and TimeLine components for better data representation. - Implemented asset workstation action logic and UI components for asset management. - Created tests for CountBadge and TimeLine components to ensure functionality.
This commit is contained in:
parent
df44fdceb4
commit
68980d9131
@ -108,7 +108,7 @@
|
||||
"retired": "Retired",
|
||||
"noAssets": "This company has no assets yet.",
|
||||
"noAssignment": "Unassigned",
|
||||
"noValue": "Not specified",
|
||||
"noValue": "—",
|
||||
"characteristics": "Characteristics",
|
||||
"history": "History",
|
||||
"recordedBy": "Recorded by {{name}}",
|
||||
@ -413,4 +413,4 @@
|
||||
"clear": "Clear selection",
|
||||
"required": "Required"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -108,7 +108,7 @@
|
||||
"retired": "Retirado",
|
||||
"noAssets": "Esta compañía todavía no tiene activos.",
|
||||
"noAssignment": "Sin asignar",
|
||||
"noValue": "Sin indicar",
|
||||
"noValue": "—",
|
||||
"characteristics": "Características",
|
||||
"history": "Historial",
|
||||
"recordedBy": "Registrado por {{name}}",
|
||||
@ -413,4 +413,4 @@
|
||||
"clear": "Quitar selección",
|
||||
"required": "Obligatorio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -827,7 +827,7 @@ si el consumidor todavía no conoce el resultado. El contenido responde al ancho
|
||||
propio contenedor. No incorpora scroll, acciones ni estado de apertura por su cuenta.
|
||||
|
||||
La ficha del puesto reutiliza el panel en activos, personas y perfiles. Se organiza en
|
||||
Resumen, Perfiles y Software mediante `Tabs` y `ScrollableTabsList`. El resumen destaca
|
||||
Información, Software e Historial mediante `Tabs` y `ScrollableTabsList`. Información destaca
|
||||
el ordenador principal y presenta el resto como tarjetas de periféricos sin duplicarlo.
|
||||
Reutiliza `AssetSummaryCard`, que acepta contenido opcional para `name` y permite enlazar
|
||||
el nombre desde el consumidor sin depender de una librería de rutas. Las tarjetas
|
||||
@ -837,7 +837,7 @@ accesible desde el nombre. Las listas usan `ul`/`li` y una o dos columnas según
|
||||
El panel lateral presenta las personas con avatar y su relación; el responsable aparece
|
||||
primero y cada persona se muestra una sola vez. Los perfiles pertenecen exclusivamente
|
||||
al ordenador principal, incluidos los archivados que ya se mostraban en la ficha.
|
||||
Su pestaña reutiliza `EntityTable` con búsqueda por nombre o persona, ordenación,
|
||||
Su panel dentro de Información reutiliza `EntityTable` con búsqueda por nombre o persona, ordenación,
|
||||
paginación, estados y avisos de revisión; omite repetir el mismo dispositivo por fila.
|
||||
La tabla conserva su estado al cambiar a otra pestaña. El software se monta al abrir
|
||||
su pestaña y permanece montado después, conservando sus filtros al cambiar de sección.
|
||||
@ -872,3 +872,141 @@ vigentes y el nuevo nombre. No añade endpoints ni cambia las reglas del agregad
|
||||
historial. Si el nombre ya coincide, evita una escritura. La respuesta actualiza la caché
|
||||
de inventario y la invalidación posterior consulta el estado definitivo. Se mantiene el
|
||||
tratamiento habitual de CSRF y de errores de inventario.
|
||||
|
||||
|
||||
## Iconos de Personas y Perfiles de usuario
|
||||
|
||||
`PersonIcon`, del catálogo `@repo/rdx-ui/lib/icons`, utiliza `IconUsers` de Tabler,
|
||||
la silueta de dos personas que antes identificaba los perfiles de usuario. Se reutiliza
|
||||
en navegación, listados y altas de personas, indicadores, actividad, selección y resumen
|
||||
de personas y responsables de puestos.
|
||||
|
||||
`WorkProfileIcon` utiliza `IconIdBadge2`, una credencial con una silueta individual,
|
||||
para representar Perfiles de usuario en navegación, listados, alta/edición, paneles del
|
||||
puesto y actividad. Los controles que seleccionan una persona dentro del formulario
|
||||
de perfil conservan `PersonIcon`, porque representan la relación con esa entidad.
|
||||
`UsersIcon` mantiene sus consumidores de usuarios de la aplicación. Los estados
|
||||
específicos, como persona inactiva, conservan su variante visual. Los iconos decorativos
|
||||
no sustituyen el nombre accesible.
|
||||
|
||||
|
||||
## Iconos en las cabeceras de listados
|
||||
|
||||
`EntityListHeader` admite `icon` como contenido React opcional, independiente de
|
||||
entidades o consultas. Lo sitúa junto al título `h2` en un área de 24 px, sin encogerse
|
||||
y oculta para lectores de pantalla. La descripción sigue debajo y las acciones a la
|
||||
derecha se adaptan al ancho disponible.
|
||||
|
||||
Los listados de activos, recursos, compañías, personas, puestos, perfiles, usuarios,
|
||||
sedes, departamentos, marcas, catálogos, accesos, procesos y configuraciones presentan
|
||||
el mismo icono de entidad en el título y en su botón de alta, manteniendo textos,
|
||||
permisos, estados deshabilitados y callbacks. El consumidor aporta ambos iconos desde
|
||||
el catálogo compartido; no se infieren a partir de las traducciones ni se añaden a
|
||||
acciones secundarias como ayuda o reintento. Véase el [patrón de listados](patron-de-listados-de-entidades.md).
|
||||
|
||||
|
||||
## Línea temporal vertical
|
||||
|
||||
`TimeLine`, en `@repo/rdx-ui/components/time-line`, presenta una lista ordenada accesible
|
||||
con línea vertical, hitos y tarjetas adaptables. Recibe `label`, `emptyMessage` e `items`;
|
||||
cada evento aporta `id`, `title`, `dateTime`, `dateLabel`, `meta` y `content` opcionales.
|
||||
El consumidor decide el orden, traduce textos y formatea las fechas. `time` conserva
|
||||
la fecha ISO y las líneas e hitos son decorativos. El componente no conoce entidades,
|
||||
DTO, endpoints ni consultas. Un array vacío muestra el mensaje de estado aportado.
|
||||
|
||||
`WorkstationHistory` compone la línea temporal con revisiones del puesto en orden
|
||||
inverso al registro y presenta el alta y las actualizaciones con autor y valores de
|
||||
cada revisión. Se comparte entre la pestaña Historial de la ficha y el diálogo del
|
||||
listado. Información reúne activos, personas y el panel de perfiles; mantiene montada
|
||||
su tabla para conservar filtros y paginación al cambiar de pestaña.
|
||||
|
||||
|
||||
## Tipo de activo en la primera columna
|
||||
|
||||
`AssetList` presenta una columna `icon` compacta, no ocultable ni ordenable, antes de
|
||||
Nombre. `AssetTemplateIndicator` utiliza el icono actual de la plantilla y un tooltip
|
||||
de shadcn/ui con su nombre registrado; el foco de teclado también permite consultarlo.
|
||||
Si el catálogo o el icono no están disponibles, usa la caja como alternativa y conserva
|
||||
el nombre de la instantánea. La columna Plantilla mantiene únicamente su texto.
|
||||
|
||||
`EntityTable` reserva el identificador `icon` para una columna visual compacta con
|
||||
cabecera solo accesible. En selectores, `selection` sigue siendo la primera columna:
|
||||
selección, icono, nombre y demás datos. La nueva columna no altera la ordenación
|
||||
inicial por nombre, la búsqueda, la selección de filas ni las acciones ancladas.
|
||||
|
||||
|
||||
## Acciones de puesto desde un activo
|
||||
|
||||
La columna Puesto del inventario compone `AssetWorkstationAction`, fuera del menú de
|
||||
fila: muestra el nombre de cada puesto asociado como enlace a su ficha o inicia el
|
||||
alta del puesto con un ordenador disponible
|
||||
(portátil, sobremesa o servidor). `WorkstationEditorDialog` y `WorkstationForm` admiten
|
||||
`initialAsset` para inicializar un alta y `onSaved` conserva la navegación al resultado.
|
||||
La edición mantiene los valores del puesto y tiene prioridad sobre esta inicialización.
|
||||
El formulario conserva sus cuatro pasos, validación, altura estable y ayuda anclada;
|
||||
no se crea otro flujo de persistencia ni un formulario incrustado en el listado.
|
||||
|
||||
|
||||
## Secciones desplegables en la ficha lateral del activo
|
||||
|
||||
La ficha lateral utiliza `Accordion` de shadcn/ui (Base UI) con `multiple` y
|
||||
`keepMounted`: se pueden abrir varias secciones a la vez y plegarlas conserva el
|
||||
contenido y los estados internos de relaciones y asistencia. Todas las secciones
|
||||
existentes empiezan abiertas. El acordeón se reinicia por UUID al seleccionar otro
|
||||
activo, sin cambiar el desplazamiento ni las acciones del panel.
|
||||
|
||||
`DetailSection.accordionValue` integra cada grupo en el acordeón padre con el mismo
|
||||
título común y campos de consulta; sin esa propiedad mantiene su presentación actual
|
||||
con `details`. `FieldSections` con `layout="rows"` y
|
||||
`sectionPresentation="accordion"` aplica este criterio a los grupos históricos de
|
||||
la plantilla y al grupo sin sección, sin duplicar campos ni mostrar grupos vacíos.
|
||||
Las especificaciones antiguas sin grupos ofrecen un único desplegable Características.
|
||||
Ubicación/asignación, observaciones, relaciones y asistencia también pertenecen al
|
||||
mismo acordeón. El detalle completo conserva su presentación y su historial.
|
||||
|
||||
|
||||
## Distribución de columnas del inventario
|
||||
|
||||
En el inventario, tras el icono y Nombre, las columnas se ordenan como Plantilla,
|
||||
Estado, Sede, Puesto de trabajo y Fecha de alta; Acciones permanece al final y anclada.
|
||||
Plantilla y fecha usan texto secundario; estado y fecha evitan saltos internos, mientras
|
||||
los nombres de puesto pueden partirse para ajustarse al ancho. Las fechas ausentes
|
||||
siguen mostrando `—`. El enlace del puesto presenta su nombre real y «Crear puesto de
|
||||
trabajo» permanece visible cuando corresponde.
|
||||
|
||||
|
||||
Al guardar un activo desde el inventario, la selección del resultado reutiliza
|
||||
`selectAsset`, igual que el clic de fila, conservando los parámetros `template` y `q`
|
||||
de la URL. El cierre del editor no restablece la pestaña Todo ni elimina la búsqueda.
|
||||
|
||||
|
||||
El alta de puesto desde Personas reutiliza el flujo de `CompanyInventory`, incluidas
|
||||
las altas rápidas relacionadas. `initialPersonId` recorre la página y el diálogo hasta
|
||||
los valores iniciales del formulario; solo preselecciona una persona activa de sus
|
||||
lookups. Se aplica como valor inicial editable, sin efectos de sincronización que
|
||||
puedan sobrescribir cambios manuales y sin modificar un puesto en edición.
|
||||
|
||||
|
||||
La tabla de Personas añade «Puestos bajo su responsabilidad» con `ul`/`li` y enlaces
|
||||
`BrowserLink` a las fichas de los puestos activos. Reutiliza `personWorkstations` y la
|
||||
consulta compartida de inventario (`useListQuery`, clave por compañía), sin peticiones
|
||||
por fila ni consultas de perfiles. La columna permite ordenar por los nombres de los
|
||||
puestos, conserva `—` cuando no hay relación y muestra carga/error de inventario con
|
||||
reintento junto a la tabla, sin ocultar los datos de las personas.
|
||||
|
||||
|
||||
## Contadores en la navegación de compañía
|
||||
|
||||
`CountBadge`, de `@repo/rdx-ui/components/count-badge`, reutiliza `Badge` de shadcn/ui
|
||||
con ancho mínimo, cifra tabular y formato numérico según `locale`. Recibe `count`,
|
||||
`loadingLabel`, `errorLabel`, `failed` y `className`. Conserva cero; un total desconocido
|
||||
muestra `—`, con etiqueta accesible de carga o error. Si falla una actualización y hay
|
||||
un total anterior, lo conserva con el aviso en el título.
|
||||
|
||||
La navegación de compañía utiliza el contador para Personas, Departamentos, Sedes,
|
||||
Activos, Recursos, Puestos de trabajo y Perfiles de usuario. Las cifras se alinean a
|
||||
la derecha y la entrada activa mantiene los colores semánticos. Cuenta todos los
|
||||
registros que presenta cada listado, incluidos los inactivos, sin aplicar sus filtros.
|
||||
Los inventarios y perfiles usan `useListQuery` con las claves existentes por compañía:
|
||||
comparten caché e invalidaciones de altas, cambios y bajas, sin nuevas rutas HTTP ni
|
||||
estado remoto duplicado. Antes de cargar la compañía, esas consultas están deshabilitadas.
|
||||
|
||||
@ -12,6 +12,16 @@ respectivas. Los formularios y diálogos comunes de alta y edición pertenecen a
|
||||
|
||||
La pantalla se divide en cabecera, contenido y pie opcional. La cabecera muestra título y subtítulo breve a la izquierda y acciones globales a la derecha. El alta es la acción primaria, normalmente «+ Añadir [entidad]», y abre un modal; se conservan las etiquetas actuales cuando ya describen una operación concreta. Nunca mostrar el formulario junto al listado.
|
||||
|
||||
El título y el botón de alta muestran el mismo icono de entidad antes del texto, tomado
|
||||
del catálogo `@repo/rdx-ui/lib/icons` y coherente con la navegación. Activos utiliza
|
||||
`BoxIcon`, Recursos `LayersIcon`, Personas `PersonIcon`, Perfiles de usuario
|
||||
`WorkProfileIcon` y Puestos `MonitorIcon`.
|
||||
`EntityListHeader.icon` presenta el icono del título a 24 px, sin encogerse; el botón
|
||||
reutiliza el tamaño de icono de `Button`. Ambos son decorativos (`aria-hidden`),
|
||||
conservando el texto y el nombre accesible. No sustituir el icono de entidad por un
|
||||
signo «+» ni añadir ambos. Esta pauta se aplica también a catálogos, configuraciones
|
||||
y sublistados con alta propia; las demás acciones conservan su representación.
|
||||
|
||||
El contenido contiene una tabla y cuatro bandas, en este orden:
|
||||
|
||||
1. Búsqueda rápida a la izquierda y filtros propios de la entidad a la derecha. El texto indica los campos buscables. Cuando hay criterios aplicados, Limpiar filtros restaura los valores iniciales de la pantalla; no cambia el ámbito de compañía ni el perfil desde el que se ha navegado.
|
||||
@ -140,3 +150,12 @@ Verificación: 204 pruebas correctas, tipos y compilación correctos; los cinco
|
||||
Los formularios que seleccionan activos usan un diálogo con el mismo `AssetList` del inventario. Reutiliza `EntityTable`, búsqueda, pestañas por plantilla, columnas y paginación. La selección provisional no cambia el formulario hasta confirmar, ni altera la URL o filtros del listado padre. Cada consumidor restringe los candidatos y declara selección única o múltiple con límite. Véase [componentes de interfaz](componentes-de-interfaz.md#selección-de-activos-en-diálogo).
|
||||
|
||||
Tickets, intervenciones y sus plantillas se conservan en [el archivo de funcionalidades](funcionalidades-archivadas.md), fuera del inventario activo.
|
||||
|
||||
|
||||
### Columna visual del tipo de activo
|
||||
|
||||
En `AssetList`, el icono de plantilla ocupa una columna `icon` compacta antes de Nombre,
|
||||
con cabecera accesible y tooltip con el nombre de plantilla, disponible con puntero y
|
||||
teclado. La columna Plantilla conserva solo el nombre. La columna visual no se ordena
|
||||
ni se oculta y no sustituye la ordenación inicial por Nombre. En `PickerList`, la
|
||||
selección permanece siempre por delante del icono.
|
||||
|
||||
@ -64,7 +64,7 @@ vigentes para no enviar sede, responsable o composición capturados al abrir el
|
||||
La respuesta confirmada actualiza la caché de inventario y después invalida esa consulta;
|
||||
no se invalidan perfiles ni software porque sus referencias no cambian.
|
||||
|
||||
La ficha separa Resumen, Perfiles y Software con pestañas accesibles y desplazables en
|
||||
La ficha separa Información, Software e Historial con pestañas accesibles y desplazables en
|
||||
espacios estrechos. Bajo el nombre, la cabecera muestra estado, sede y responsable si
|
||||
existe, con enlaces a sede y persona. A la derecha aparece Editar puesto y un menú de
|
||||
acciones con Desactivar y Eliminar definitivamente, manteniendo sus confirmaciones.
|
||||
@ -72,11 +72,12 @@ Nuevo perfil se sitúa en la cabecera del panel Perfiles y se habilita únicamen
|
||||
ordenador principal incluido en su composición y no retirado; el bloqueo explica cómo
|
||||
completar el puesto.
|
||||
|
||||
El resumen destaca el ordenador principal y muestra los demás activos como periféricos
|
||||
Información reúne activos, personas y perfiles. Destaca el ordenador principal y muestra
|
||||
los demás activos como periféricos
|
||||
mediante `AssetSummaryCard`, sin duplicados. Sus nombres enlazan las fichas de activos,
|
||||
que conservan el detalle técnico completo. Las personas se presentan con avatar, con el
|
||||
responsable primero y una única aparición por persona. La carga o el error de perfiles
|
||||
no se confunde con ausencia de personas. La pestaña Perfiles reúne los del ordenador
|
||||
no se confunde con ausencia de personas. El panel Perfiles en Información reúne los del ordenador
|
||||
principal, incluidos los archivados, mediante la tabla común con búsqueda, ordenación
|
||||
y paginación. Conserva los enlaces y avisos de revisión, sin repetir el mismo ordenador
|
||||
en cada fila. El software se carga al abrir su pestaña y mantiene la gestión existente.
|
||||
@ -85,6 +86,17 @@ Los paneles de activos, personas y perfiles reutilizan `DetailPanel` de `@repo/r
|
||||
El diseño responde al ancho disponible y mantiene accesibles contenidos y acciones en
|
||||
móvil y escritorio. Véase [Componentes de interfaz](componentes-de-interfaz.md).
|
||||
|
||||
|
||||
La pestaña Historial muestra las revisiones registradas del puesto, de más reciente a
|
||||
más antigua: fecha y hora, autor, nombre, estado, sede, responsable, ordenador principal
|
||||
y activos de cada revisión. Los nombres de las referencias se resuelven con los datos
|
||||
disponibles; las referencias ausentes muestran `—`. Las instantáneas mantienen sus
|
||||
identificadores y valores originales; no se reconstruye un historial de cambios de
|
||||
nombre de activos o sedes que el contrato no registra. `WorkstationHistory` comparte
|
||||
la presentación entre la ficha y el diálogo del listado mediante `TimeLine` de
|
||||
`@repo/rdx-ui/components/time-line`. No se añaden consultas ni nuevas escrituras.
|
||||
|
||||
|
||||
## Desactivación y eliminación
|
||||
|
||||
El listado y la ficha completa ofrecen **Desactivar** y **Eliminar definitivamente**, mediante un mismo diálogo reutilizable con confirmación y resultado de servidor.
|
||||
@ -123,3 +135,46 @@ el diálogo grande con altura estable y los mismos grupos, conforme al [patrón
|
||||
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.
|
||||
|
||||
|
||||
## Crear y consultar puestos desde el inventario
|
||||
|
||||
La columna Puesto del inventario muestra un botón visible «Crear puesto de trabajo» para portátiles, PC de
|
||||
sobremesa y servidores operativos y sin asignación. Este acceso rápido identifica
|
||||
estos tipos mediante el icono configurado en la plantilla (`laptop`, `desktop`,
|
||||
`server`); no utiliza nombres traducidos ni cambia las restricciones del dominio.
|
||||
Si falta el catálogo o no se reconoce el tipo, no ofrece crear.
|
||||
|
||||
La acción abre el mismo formulario de cuatro pasos, con el activo incorporado como
|
||||
ordenador principal y su sede preseleccionada si está activa. Conserva la propuesta
|
||||
de nombre por sede y ordenador; permite revisar sede, responsable y composición antes
|
||||
de guardar. Cancelar no crea puestos. Guardar utiliza el caso de uso existente,
|
||||
invalida el inventario y abre la ficha del nuevo puesto; no transforma ni duplica el activo.
|
||||
|
||||
Si el activo está en la composición vigente de un puesto, la columna muestra su nombre
|
||||
como enlace a la ficha independientemente de su papel como ordenador principal o periférico y
|
||||
de su estado técnico. Incluye puestos inactivos que todavía conservan composición;
|
||||
si hay varios, cada uno conserva su propio enlace. La acción de crear sigue visible
|
||||
fuera del menú de edición y eliminación. Las asociaciones que solo aparecen en
|
||||
revisiones históricas no se consideran vigentes. Se preservan las comprobaciones de
|
||||
compañía, reservas y estado operativo del formulario y del servidor ante concurrencia.
|
||||
|
||||
|
||||
## Alta desde una persona
|
||||
|
||||
El menú de cada persona activa ofrece «Nuevo puesto de trabajo». La acción navega al
|
||||
listado de puestos de la compañía y abre el mismo formulario de alta mediante la
|
||||
navegación de creación existente, aportando `personId` como contexto. El diálogo y
|
||||
el formulario reciben `initialPersonId`: el responsable comienza con esa persona si
|
||||
está activa y aparece en los datos de la compañía. El usuario puede cambiarlo o
|
||||
retirarlo. Un identificador desconocido o de una persona inactiva no se preselecciona.
|
||||
La edición conserva siempre el responsable guardado del puesto. Se mantienen las
|
||||
altas relacionadas de sede y persona, los cuatro pasos y las validaciones de servidor.
|
||||
|
||||
|
||||
El listado de Personas muestra la columna «Puestos bajo su responsabilidad», con un
|
||||
enlace por puesto activo cuyo responsable actual sea esa persona. Reutiliza el mismo
|
||||
criterio que la ficha de persona: no incluye puestos usados solo mediante un perfil,
|
||||
responsabilidades históricas, puestos desactivados ni registros de otra compañía.
|
||||
La ausencia de puestos muestra `—`; el inventario se consulta una vez por compañía
|
||||
mediante la caché compartida, con estado de carga y recuperación explícita de errores.
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
|
||||
@ -97,6 +97,12 @@
|
||||
"deletingAsset": "Deleting asset…",
|
||||
"deleteAssetError": "The asset could not be deleted. Please try again.",
|
||||
"viewAsset": "Details and history",
|
||||
"assetWorkstation": {
|
||||
"create": "Create workstation",
|
||||
"view": "View workstation",
|
||||
"viewNamed": "View workstation: {{name}}"
|
||||
},
|
||||
"assetType": "Asset type",
|
||||
"template": "Template",
|
||||
"site": "Site",
|
||||
"person": "Assigned person",
|
||||
@ -108,7 +114,7 @@
|
||||
"retired": "Retired",
|
||||
"noAssets": "This company has no assets yet.",
|
||||
"noAssignment": "Unassigned",
|
||||
"noValue": "Not specified",
|
||||
"noValue": "—",
|
||||
"characteristics": "Characteristics",
|
||||
"history": "History",
|
||||
"recordedBy": "Recorded by {{name}}",
|
||||
@ -386,7 +392,11 @@
|
||||
"profilePerson": "Has a profile on the main computer",
|
||||
"needsReview": "References awaiting review",
|
||||
"missingAsset": "Asset unavailable.",
|
||||
"overview": "Overview",
|
||||
"information": "Information",
|
||||
"created": "Workstation created",
|
||||
"updated": "Workstation updated",
|
||||
"noHistory": "No workstation revisions.",
|
||||
"historyHint": "Revisions from newest to oldest, with the data recorded at each change.",
|
||||
"software": "Computer software",
|
||||
"sections": "Workstation sections",
|
||||
"peripherals": "Other assets and peripherals",
|
||||
@ -466,4 +476,4 @@
|
||||
"fieldsDescription": "Complete the template details. Fields marked with * are required.",
|
||||
"nameDescription": "Review the suggested name, technical status, site and registration date. Add notes if you need to provide more context."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -97,6 +97,12 @@
|
||||
"deletingAsset": "Eliminando activo…",
|
||||
"deleteAssetError": "No se ha podido eliminar el activo. Inténtalo de nuevo.",
|
||||
"viewAsset": "Ficha e historial",
|
||||
"assetWorkstation": {
|
||||
"create": "Crear puesto de trabajo",
|
||||
"view": "Ver puesto de trabajo",
|
||||
"viewNamed": "Ver puesto: {{name}}"
|
||||
},
|
||||
"assetType": "Tipo de activo",
|
||||
"template": "Plantilla",
|
||||
"site": "Sede",
|
||||
"person": "Persona asignada",
|
||||
@ -108,7 +114,7 @@
|
||||
"retired": "Retirado",
|
||||
"noAssets": "Esta compañía todavía no tiene activos.",
|
||||
"noAssignment": "Sin asignar",
|
||||
"noValue": "Sin indicar",
|
||||
"noValue": "—",
|
||||
"characteristics": "Características",
|
||||
"history": "Historial",
|
||||
"recordedBy": "Registrado por {{name}}",
|
||||
@ -386,7 +392,11 @@
|
||||
"profilePerson": "Con perfil en el ordenador principal",
|
||||
"needsReview": "Referencias pendientes de revisión",
|
||||
"missingAsset": "Activo no disponible.",
|
||||
"overview": "Resumen",
|
||||
"information": "Información",
|
||||
"created": "Alta del puesto",
|
||||
"updated": "Actualización del puesto",
|
||||
"noHistory": "No hay revisiones del puesto.",
|
||||
"historyHint": "Revisiones de más reciente a más antigua, con los datos registrados en cada cambio.",
|
||||
"software": "Software del ordenador",
|
||||
"sections": "Secciones del puesto de trabajo",
|
||||
"peripherals": "Otros activos y periféricos",
|
||||
@ -466,4 +476,4 @@
|
||||
"fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
|
||||
"nameDescription": "Revisa el nombre propuesto, el estado técnico, la sede y la fecha de alta. Añade notas si necesitas aportar más contexto."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,30 @@ export function AssetDetail(props: AssetDetailProps) {
|
||||
? t(value ? "yes" : "no")
|
||||
: String(value);
|
||||
|
||||
const characteristics = (
|
||||
<FieldSections
|
||||
items={asset.specification.fields}
|
||||
layout="rows"
|
||||
renderItem={(field) => (
|
||||
<DetailField
|
||||
key={field.fieldId}
|
||||
label={
|
||||
<>
|
||||
{field.name}
|
||||
{field.unit ? ` (${field.unit})` : ""}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{display(asset.values.characteristics[field.fieldId])}
|
||||
</DetailField>
|
||||
)}
|
||||
sectionPresentation={compact ? "accordion" : "details"}
|
||||
sections={asset.specification.sections}
|
||||
ungroupedLabel={t("sections.ungrouped")}
|
||||
values
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="@container space-y-5">
|
||||
{!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>}
|
||||
@ -44,7 +68,10 @@ export function AssetDetail(props: AssetDetailProps) {
|
||||
</dl>
|
||||
</DetailSection>
|
||||
)}
|
||||
<DetailSection title={t("detailSections.assignment")}>
|
||||
<DetailSection
|
||||
accordionValue={compact ? "assignment" : undefined}
|
||||
title={t("detailSections.assignment")}
|
||||
>
|
||||
<dl>
|
||||
<DetailField label={t("registeredOn")}>
|
||||
{formatAssetRegistrationDate(asset.values.registeredOn, i18n.language) ?? t("noValue")}
|
||||
@ -75,33 +102,23 @@ export function AssetDetail(props: AssetDetailProps) {
|
||||
)}
|
||||
</dl>
|
||||
</DetailSection>
|
||||
<div>
|
||||
{!asset.specification.sections?.length && (
|
||||
<FormSectionTitle>{t("characteristics")}</FormSectionTitle>
|
||||
)}
|
||||
<FieldSections
|
||||
items={asset.specification.fields}
|
||||
layout="rows"
|
||||
renderItem={(field) => (
|
||||
<DetailField
|
||||
key={field.fieldId}
|
||||
label={
|
||||
<>
|
||||
{field.name}
|
||||
{field.unit ? ` (${field.unit})` : ""}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{display(asset.values.characteristics[field.fieldId])}
|
||||
</DetailField>
|
||||
{compact && !asset.specification.sections?.length ? (
|
||||
<DetailSection accordionValue="characteristics" title={t("characteristics")}>
|
||||
{characteristics}
|
||||
</DetailSection>
|
||||
) : (
|
||||
<div>
|
||||
{!asset.specification.sections?.length && (
|
||||
<FormSectionTitle>{t("characteristics")}</FormSectionTitle>
|
||||
)}
|
||||
sections={asset.specification.sections}
|
||||
ungroupedLabel={t("sections.ungrouped")}
|
||||
values
|
||||
/>
|
||||
</div>
|
||||
{characteristics}
|
||||
</div>
|
||||
)}
|
||||
{asset.values.notes && (
|
||||
<DetailSection title={t("detailSections.notes")}>
|
||||
<DetailSection
|
||||
accordionValue={compact ? "notes" : undefined}
|
||||
title={t("detailSections.notes")}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{asset.values.notes}</p>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { DetailSection } from "@repo/rdx-ui/components/detail-section";
|
||||
import { SelectionPanel } from "@repo/rdx-ui/components/selection-panel";
|
||||
import { Accordion } from "@repo/shadcn-ui/components/accordion";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -71,24 +72,39 @@ export function AssetSelectionPanel({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AssetDetail asset={asset} compact lookups={lookups}>
|
||||
<DetailSection title={t("relations")}>
|
||||
<AssetRelationships
|
||||
assetId={asset.id}
|
||||
compact
|
||||
companyId={companyId}
|
||||
sites={lookups.sites}
|
||||
/>
|
||||
</DetailSection>
|
||||
<DetailSection title={t("support.title")}>
|
||||
<AssetSupport
|
||||
assetId={asset.id}
|
||||
compact
|
||||
companyId={companyId}
|
||||
retired={asset.values.status === "retired"}
|
||||
/>
|
||||
</DetailSection>
|
||||
</AssetDetail>
|
||||
<Accordion
|
||||
defaultValue={[
|
||||
"assignment",
|
||||
"characteristics",
|
||||
"ungrouped",
|
||||
"notes",
|
||||
"relations",
|
||||
"support",
|
||||
...(asset.specification.sections?.map((section) => section.id) ?? []),
|
||||
]}
|
||||
keepMounted
|
||||
key={asset.id}
|
||||
multiple
|
||||
>
|
||||
<AssetDetail asset={asset} compact lookups={lookups}>
|
||||
<DetailSection accordionValue="relations" title={t("relations")}>
|
||||
<AssetRelationships
|
||||
assetId={asset.id}
|
||||
compact
|
||||
companyId={companyId}
|
||||
sites={lookups.sites}
|
||||
/>
|
||||
</DetailSection>
|
||||
<DetailSection accordionValue="support" title={t("support.title")}>
|
||||
<AssetSupport
|
||||
assetId={asset.id}
|
||||
compact
|
||||
companyId={companyId}
|
||||
retired={asset.values.status === "retired"}
|
||||
/>
|
||||
</DetailSection>
|
||||
</AssetDetail>
|
||||
</Accordion>
|
||||
</SelectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
378
modules/assets/src/web/assets/list/asset-workstation.test.tsx
Normal file
378
modules/assets/src/web/assets/list/asset-workstation.test.tsx
Normal file
@ -0,0 +1,378 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createInstance } from "i18next";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { CatalogItemSchema, WorkstationSchema } from "../../../common";
|
||||
import type { AssetTemplateIconKey } from "../../../common/asset-template-icons";
|
||||
import { AssetSchema } from "../../../common/inventory";
|
||||
import { catalogKeys } from "../../catalog/shared/api";
|
||||
import { assetsEnglish, assetsSpanish } from "../../shared";
|
||||
import { inventoryKeys } from "../../shared/queries";
|
||||
import { useWorkstationFormController } from "../../workstations/shared/controllers/use-workstation-form-controller";
|
||||
import type { WorkstationFormProps } from "../../workstations/shared/types";
|
||||
|
||||
import { assetWorkstationAction } from "./helpers/asset-workstation-action";
|
||||
import { AssetInventoryPage } from "./ui/inventory-page";
|
||||
|
||||
const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`;
|
||||
|
||||
const companyId = id(1);
|
||||
const values = {
|
||||
name: "PC de Ana",
|
||||
templateId: id(2),
|
||||
siteId: id(3),
|
||||
status: "operational",
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
};
|
||||
const asset = AssetSchema.parse({
|
||||
id: id(4),
|
||||
companyId,
|
||||
values,
|
||||
assigned: false,
|
||||
displayStatus: "available",
|
||||
specification: {
|
||||
templateId: id(2),
|
||||
templateName: "Ordenador",
|
||||
fields: [
|
||||
{ fieldId: id(5), name: "Modelo", type: "text", unit: "", choices: [], required: false },
|
||||
],
|
||||
},
|
||||
history: [
|
||||
{
|
||||
id: id(6),
|
||||
values,
|
||||
actorId: id(7),
|
||||
actorName: "David",
|
||||
recordedAt: "2026-10-16T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function template(icon?: AssetTemplateIconKey) {
|
||||
return CatalogItemSchema.parse({
|
||||
id: id(2),
|
||||
companyId: null,
|
||||
builtin: false,
|
||||
active: true,
|
||||
effectiveActive: true,
|
||||
overridden: false,
|
||||
bindings: [],
|
||||
values: {
|
||||
kind: "template",
|
||||
name: "Ordenador",
|
||||
description: "",
|
||||
icon,
|
||||
fields: [{ fieldId: id(5), required: false }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function station(active = true) {
|
||||
const stationValues = {
|
||||
name: "Puesto existente",
|
||||
siteId: id(3),
|
||||
personId: null,
|
||||
assetIds: [asset.id, id(8)],
|
||||
computerAssetId: id(8),
|
||||
active,
|
||||
};
|
||||
|
||||
return WorkstationSchema.parse({
|
||||
id: id(9),
|
||||
companyId,
|
||||
values: stationValues,
|
||||
history: [
|
||||
{
|
||||
id: id(10),
|
||||
values: stationValues,
|
||||
actorId: id(7),
|
||||
actorName: "David",
|
||||
recordedAt: "2026-10-16T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
test("acciones: crear solo para portátiles, sobremesa y servidores disponibles", () => {
|
||||
for (const icon of ["laptop", "desktop", "server"] satisfies AssetTemplateIconKey[]) {
|
||||
assert.equal(assetWorkstationAction(asset, [], template(icon)).kind, "create");
|
||||
}
|
||||
for (const icon of ["printer", "monitor", "box"] satisfies AssetTemplateIconKey[]) {
|
||||
assert.equal(assetWorkstationAction(asset, [], template(icon)).kind, "none");
|
||||
}
|
||||
assert.equal(assetWorkstationAction(asset, [], template()).kind, "none");
|
||||
assert.equal(assetWorkstationAction(asset, [], undefined).kind, "none");
|
||||
for (const status of ["broken", "maintenance", "retired"] as const) {
|
||||
assert.equal(
|
||||
assetWorkstationAction(
|
||||
{ ...asset, values: { ...asset.values, status } },
|
||||
[],
|
||||
template("laptop")
|
||||
).kind,
|
||||
"none"
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
assetWorkstationAction({ ...asset, assigned: true }, [], template("laptop")).kind,
|
||||
"none"
|
||||
);
|
||||
});
|
||||
|
||||
test("acciones: abrir el puesto asociado aunque el activo sea periférico, no operativo o el puesto esté inactivo", () => {
|
||||
for (const active of [true, false]) {
|
||||
const workstation = station(active);
|
||||
const action = assetWorkstationAction(
|
||||
{ ...asset, values: { ...asset.values, status: "retired" } },
|
||||
[workstation],
|
||||
template("printer")
|
||||
);
|
||||
|
||||
assert.equal(action.kind, "view");
|
||||
if (action.kind === "view") {
|
||||
assert.equal(action.workstations[0]?.id, workstation.id);
|
||||
assert.notEqual(workstation.values.computerAssetId, asset.id);
|
||||
}
|
||||
}
|
||||
const first = station(false);
|
||||
const second = { ...first, id: id(11) };
|
||||
const multiple = assetWorkstationAction(asset, [first, second], undefined);
|
||||
|
||||
assert.equal(multiple.kind, "view");
|
||||
if (multiple.kind === "view") {
|
||||
assert.equal(multiple.workstations.length, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test("acciones: compañía y plantilla correctas; asociaciones solo históricas no bloquean el alta", () => {
|
||||
const foreign = { ...station(), companyId: id(12) };
|
||||
const decommissioned = {
|
||||
...station(false),
|
||||
values: { ...station(false).values, assetIds: [], computerAssetId: null },
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
assetWorkstationAction(asset, [foreign, decommissioned], template("laptop")).kind,
|
||||
"create"
|
||||
);
|
||||
assert.equal(
|
||||
assetWorkstationAction(asset, [], { ...template("laptop"), companyId: id(12) }).kind,
|
||||
"none"
|
||||
);
|
||||
assert.equal(
|
||||
assetWorkstationAction(asset, [], { ...template("laptop"), id: id(12) }).kind,
|
||||
"none"
|
||||
);
|
||||
});
|
||||
|
||||
function WorkstationSeedPreview(props: WorkstationFormProps) {
|
||||
const { values: current, isWizard, canContinue } = useWorkstationFormController(props);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-assets={current.assetIds.join(",")}
|
||||
data-can-continue={canContinue}
|
||||
data-computer={current.computerAssetId}
|
||||
data-name={current.name}
|
||||
data-person={current.personId}
|
||||
data-site={current.siteId}
|
||||
data-wizard={isWizard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
test("alta desde activo es/en: composición y ordenador preseleccionados, sede activa y nombre propuesto; edición y compañía se respetan", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const i18n = createInstance();
|
||||
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
|
||||
});
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Number.POSITIVE_INFINITY, retry: false } },
|
||||
});
|
||||
|
||||
client.setQueryData(inventoryKeys.company(companyId), {
|
||||
assets: [asset],
|
||||
workstations: [],
|
||||
relationships: [],
|
||||
});
|
||||
client.setQueryData(catalogKeys.scope(companyId), { items: [template("laptop")] });
|
||||
|
||||
const render = (props: WorkstationFormProps) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>
|
||||
<WorkstationSeedPreview {...props} />
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
const props: WorkstationFormProps = {
|
||||
companyId,
|
||||
initialAsset: asset,
|
||||
onSaved: () => undefined,
|
||||
lookups: {
|
||||
sites: [
|
||||
{ id: id(13), name: "Oficina", active: true },
|
||||
{ id: id(3), name: "Fábrica", active: true },
|
||||
],
|
||||
people: [],
|
||||
onCreatePerson: () => undefined,
|
||||
onCreateSite: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const personProps: WorkstationFormProps = {
|
||||
...props,
|
||||
initialPersonId: id(14),
|
||||
lookups: { ...props.lookups, people: [{ id: id(14), name: "Ana", active: true }] },
|
||||
};
|
||||
const withPerson = render(personProps);
|
||||
|
||||
assert.ok(withPerson.includes(`data-person="${id(14)}"`));
|
||||
const inactivePerson = render({
|
||||
...personProps,
|
||||
lookups: { ...personProps.lookups, people: [{ id: id(14), name: "Ana", active: false }] },
|
||||
});
|
||||
|
||||
assert.equal(inactivePerson.includes(`data-person="${id(14)}"`), false);
|
||||
const missingPerson = render({ ...personProps, initialPersonId: id(15) });
|
||||
|
||||
assert.equal(missingPerson.includes(`data-person="${id(15)}"`), false);
|
||||
const existingPerson = render({ ...personProps, workstation: station() });
|
||||
|
||||
assert.equal(existingPerson.includes(`data-person="${id(14)}"`), false);
|
||||
const html = render(props);
|
||||
|
||||
assert.ok(html.includes(`data-assets="${asset.id}"`));
|
||||
assert.ok(html.includes(`data-computer="${asset.id}"`));
|
||||
assert.ok(html.includes(`data-site="${asset.values.siteId}"`));
|
||||
assert.ok(
|
||||
html.includes(
|
||||
language === "es"
|
||||
? 'data-name="Puesto PC de Ana en Fábrica"'
|
||||
: 'data-name="Workstation for PC de Ana at Fábrica"'
|
||||
)
|
||||
);
|
||||
assert.ok(html.includes('data-wizard="true"'));
|
||||
assert.ok(html.includes('data-can-continue="true"'));
|
||||
client.setQueryData(inventoryKeys.company(companyId), {
|
||||
assets: [asset],
|
||||
workstations: [station()],
|
||||
relationships: [],
|
||||
});
|
||||
const occupied = render(props);
|
||||
|
||||
assert.ok(occupied.includes('data-can-continue="false"'));
|
||||
client.setQueryData(inventoryKeys.company(companyId), {
|
||||
assets: [asset],
|
||||
workstations: [],
|
||||
relationships: [],
|
||||
});
|
||||
const foreign = render({ ...props, initialAsset: { ...asset, companyId: id(12) } });
|
||||
|
||||
assert.ok(foreign.includes('data-assets=""'));
|
||||
assert.equal(foreign.includes(`data-computer="${asset.id}"`), false);
|
||||
const edited = render({ ...props, workstation: station() });
|
||||
|
||||
assert.ok(edited.includes('data-name="Puesto existente"'));
|
||||
assert.ok(edited.includes(`data-computer="${id(8)}"`));
|
||||
assert.ok(edited.includes('data-wizard="false"'));
|
||||
const inactiveSite = render({
|
||||
...props,
|
||||
lookups: {
|
||||
...props.lookups,
|
||||
sites: [{ id: id(3), name: "Sede archivada", active: false }],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(inactiveSite.includes(`data-site="${id(3)}"`), false);
|
||||
assert.ok(inactiveSite.includes('data-can-continue="false"'));
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("inventario es/en: crear y ver puesto son acciones visibles fuera del menú", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const i18n = createInstance();
|
||||
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
|
||||
});
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Number.POSITIVE_INFINITY, retry: false } },
|
||||
});
|
||||
const labels = language === "es" ? assetsSpanish : assetsEnglish;
|
||||
|
||||
client.setQueryData(catalogKeys.scope(companyId), { items: [template("laptop")] });
|
||||
client.setQueryData(inventoryKeys.company(companyId), {
|
||||
assets: [asset],
|
||||
workstations: [],
|
||||
relationships: [],
|
||||
});
|
||||
|
||||
const renderPage = () =>
|
||||
renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>
|
||||
<AssetInventoryPage
|
||||
companyId={companyId}
|
||||
lookups={{
|
||||
sites: [],
|
||||
people: [],
|
||||
onCreatePerson: () => undefined,
|
||||
onCreateSite: () => undefined,
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
try {
|
||||
const creation = renderPage();
|
||||
const createButton = [...creation.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].find(
|
||||
([button]) => button.includes(labels.assetWorkstation.create)
|
||||
)?.[0];
|
||||
|
||||
assert.ok(createButton);
|
||||
assert.equal(createButton.includes('aria-haspopup="menu"'), false);
|
||||
assert.ok(creation.includes(labels.workstationInAsset));
|
||||
client.setQueryData(inventoryKeys.company(companyId), {
|
||||
assets: [asset],
|
||||
workstations: [station()],
|
||||
relationships: [],
|
||||
});
|
||||
const viewing = renderPage();
|
||||
const viewLink = [...viewing.matchAll(/<a\b[^>]*>[\s\S]*?<\/a>/g)].find(([link]) =>
|
||||
link.includes(station().values.name)
|
||||
)?.[0];
|
||||
|
||||
assert.ok(viewLink);
|
||||
assert.ok(viewLink.includes(`href="/companies/${companyId}/workstations/${station().id}"`));
|
||||
assert.ok(viewLink.includes(station().values.name));
|
||||
assert.equal(viewLink.includes(labels.assetWorkstation.view), false);
|
||||
const headers = [...viewing.matchAll(/<th\b[^>]*>[\s\S]*?<\/th>/g)].map(([header]) => header);
|
||||
|
||||
assert.ok(headers[2]?.includes(labels.template));
|
||||
assert.ok(headers[3]?.includes(labels.status));
|
||||
assert.ok(headers[4]?.includes(labels.site));
|
||||
assert.ok(headers[5]?.includes(labels.workstationInAsset));
|
||||
assert.ok(headers[6]?.includes(labels.registeredOn));
|
||||
assert.ok(headers[7]?.includes(labels.actions));
|
||||
assert.equal(viewing.includes(labels.assetWorkstation.create), false);
|
||||
assert.ok(viewing.includes('aria-haspopup="menu"'));
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -61,6 +61,7 @@ export function useAssetInventoryPageController({
|
||||
});
|
||||
|
||||
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
|
||||
const [workstationAssetId, setWorkstationAssetId] = useState<string | undefined>();
|
||||
const [deleteId, setDeleteId] = useState<string | undefined>();
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => removeAsset(companyId, id),
|
||||
@ -129,6 +130,8 @@ export function useAssetInventoryPageController({
|
||||
setDetailOpen,
|
||||
selected,
|
||||
edited,
|
||||
workstationAsset: assets.find((asset) => asset.id === workstationAssetId),
|
||||
setWorkstationAssetId,
|
||||
viewed,
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import type { CatalogItemDTO, WorkstationDTO } from "../../../../common";
|
||||
import type { AssetDTO } from "../../../../common/inventory";
|
||||
import { canAssignAsset } from "../../../../common/inventory";
|
||||
|
||||
type AssetWorkstationAction =
|
||||
| { kind: "view"; workstations: readonly WorkstationDTO[] }
|
||||
| { kind: "create" }
|
||||
| { kind: "none" };
|
||||
|
||||
/** El acceso rápido usa los tipos visuales del catálogo; no cambia las reglas de composición del dominio. */
|
||||
export function assetWorkstationAction(
|
||||
asset: AssetDTO,
|
||||
workstations: readonly WorkstationDTO[],
|
||||
template: CatalogItemDTO | undefined
|
||||
): AssetWorkstationAction {
|
||||
const related = workstations.filter(
|
||||
(workstation) =>
|
||||
workstation.companyId === asset.companyId && workstation.values.assetIds.includes(asset.id)
|
||||
);
|
||||
|
||||
if (related.length) {
|
||||
return { kind: "view", workstations: related };
|
||||
}
|
||||
if (
|
||||
!canAssignAsset(asset.values.status, asset.assigned) ||
|
||||
template?.id !== asset.values.templateId ||
|
||||
(template.companyId !== null && template.companyId !== asset.companyId) ||
|
||||
template.values.kind !== "template"
|
||||
) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
return template.values.icon === "laptop" ||
|
||||
template.values.icon === "desktop" ||
|
||||
template.values.icon === "server"
|
||||
? { kind: "create" }
|
||||
: { kind: "none" };
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import { BrowserLink } from "@metis/core/client";
|
||||
import { MonitorIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO, WorkstationDTO } from "../../../../common";
|
||||
import type { AssetDTO } from "../../../../common/inventory";
|
||||
import { assetWorkstationAction } from "../helpers/asset-workstation-action";
|
||||
|
||||
export function AssetWorkstationAction({
|
||||
asset,
|
||||
workstations,
|
||||
template,
|
||||
onCreate,
|
||||
}: {
|
||||
asset: AssetDTO;
|
||||
workstations: readonly WorkstationDTO[];
|
||||
template: CatalogItemDTO | undefined;
|
||||
onCreate: (assetId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const action = assetWorkstationAction(asset, workstations, template);
|
||||
|
||||
if (action.kind === "view") {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{action.workstations.map((workstation) => (
|
||||
<BrowserLink
|
||||
className="inline-flex max-w-64 items-center gap-2 text-sm font-medium"
|
||||
href={`/companies/${asset.companyId}/workstations/${workstation.id}`}
|
||||
key={workstation.id}
|
||||
>
|
||||
<MonitorIcon aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span className="min-w-0 break-words">{workstation.values.name}</span>
|
||||
</BrowserLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return action.kind === "create" ? (
|
||||
<Button
|
||||
className="h-auto justify-start px-0 py-1 text-left whitespace-normal"
|
||||
onClick={() => onCreate(asset.id)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
<MonitorIcon aria-hidden="true" />
|
||||
{t("assetWorkstation.create")}
|
||||
</Button>
|
||||
) : null;
|
||||
}
|
||||
@ -1,20 +1,24 @@
|
||||
import { BrowserLink, EntityNotFound } from "@metis/core/client";
|
||||
import { BrowserLink, EntityNotFound, navigateTo } from "@metis/core/client";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { BoxIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CatalogError } from "../../../catalog";
|
||||
import { InventoryFeedback } from "../../../shared";
|
||||
import { WorkstationEditorDialog } from "../../../workstations/shared/ui/workstation-editor-dialog";
|
||||
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
|
||||
import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
|
||||
import { AssetList } from "../../shared/ui/asset-list";
|
||||
import { useAssetInventoryPageController } from "../controllers";
|
||||
import type { AssetInventoryPageProps } from "../types";
|
||||
|
||||
import { AssetWorkstationAction } from "./asset-workstation-action";
|
||||
|
||||
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
|
||||
return id === selectedId && detailOpen
|
||||
? "bg-accent hover:bg-accent [&>[data-pinned=right]]:bg-accent [&>[data-pinned=right]]:group-hover/entity-row:bg-accent [&>[data-pinned=right]]:group-has-aria-expanded/entity-row:bg-accent"
|
||||
@ -33,6 +37,8 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
setSearch,
|
||||
editor,
|
||||
setEditor,
|
||||
workstationAsset,
|
||||
setWorkstationAssetId,
|
||||
deleted,
|
||||
deleteId,
|
||||
setDeleteId,
|
||||
@ -76,11 +82,13 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
|
||||
<BoxIcon aria-hidden="true" />
|
||||
{t("newAsset")}
|
||||
</Button>
|
||||
</div>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<BoxIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("inventory")}
|
||||
/>
|
||||
@ -103,6 +111,21 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
assets={query.data.assets}
|
||||
catalog={catalog.data?.items ?? []}
|
||||
extraColumns={[
|
||||
{
|
||||
id: "workstation",
|
||||
label: t("workstationInAsset"),
|
||||
hideable: false,
|
||||
render: (asset) => (
|
||||
<AssetWorkstationAction
|
||||
asset={asset}
|
||||
onCreate={setWorkstationAssetId}
|
||||
template={catalog.data?.items.find(
|
||||
(item) => item.id === asset.values.templateId
|
||||
)}
|
||||
workstations={query.data?.workstations ?? []}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
@ -117,6 +140,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
>
|
||||
{t("viewAsset")}
|
||||
</EntityAction>
|
||||
|
||||
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
@ -172,12 +196,22 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
lookups={lookups}
|
||||
onClose={() => setEditor(undefined)}
|
||||
onSaved={(id) => {
|
||||
setSelectedId(id);
|
||||
setDetailOpen(true);
|
||||
selectAsset(id);
|
||||
setEditor(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{workstationAsset && (
|
||||
<WorkstationEditorDialog
|
||||
companyId={companyId}
|
||||
initialAsset={workstationAsset}
|
||||
key={workstationAsset.id}
|
||||
lookups={lookups}
|
||||
onClose={() => setWorkstationAssetId(undefined)}
|
||||
onSaved={(id) => navigateTo(`/companies/${companyId}/workstations/${id}`)}
|
||||
workstation={undefined}
|
||||
/>
|
||||
)}
|
||||
{viewed && (
|
||||
<AssetDetailDialog
|
||||
asset={viewed}
|
||||
|
||||
@ -6,6 +6,7 @@ import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { CatalogItemSchema } from "../../../common";
|
||||
import { AssetSchema } from "../../../common/inventory";
|
||||
import { assetsEnglish, assetsSpanish } from "../../shared";
|
||||
import { AssetList } from "../shared/ui/asset-list";
|
||||
@ -103,6 +104,10 @@ test("selector: comparte búsqueda, pestañas por identidad y columnas del inven
|
||||
assert.equal(firstHeader?.includes('data-pinned="right"'), false);
|
||||
assert.equal(firstHeader?.includes("aria-sort"), false);
|
||||
assert.ok(firstCell?.includes('role="checkbox"'));
|
||||
const cells = [...html.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/g)].map((match) => match[1]);
|
||||
|
||||
assert.ok(cells[1]?.includes('data-slot="tooltip-trigger"'));
|
||||
assert.ok(cells[2]?.includes("Equipo 2"));
|
||||
assert.ok(html.includes('aria-checked="true"'));
|
||||
assert.ok(html.includes("Ocupado"));
|
||||
assert.ok(html.includes(language === "es" ? "Buscar" : "Search"));
|
||||
@ -205,3 +210,45 @@ test("picker: búsqueda rápida disponible durante carga y error, sin anunciar u
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("inventario es/en: icono compacto antes del nombre, plantilla solo textual y ordenación por nombre", async () => {
|
||||
const template = CatalogItemSchema.parse({
|
||||
id: id(12),
|
||||
companyId: null,
|
||||
builtin: false,
|
||||
active: true,
|
||||
effectiveActive: true,
|
||||
overridden: false,
|
||||
bindings: [],
|
||||
values: {
|
||||
kind: "template",
|
||||
name: "Portátiles",
|
||||
description: "",
|
||||
icon: "laptop",
|
||||
fields: [{ fieldId: id(5), required: false }],
|
||||
},
|
||||
});
|
||||
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(language, <AssetList {...listProps} catalog={[template]} />);
|
||||
const table = html.match(/<table[\s\S]*?<\/table>/)?.[0];
|
||||
|
||||
assert.ok(table);
|
||||
const headers = [...table.matchAll(/<th\b[^>]*>[\s\S]*?<\/th>/g)].map(([header]) => header);
|
||||
const row = table.match(/<tbody[\s\S]*?<tr\b[^>]*>([\s\S]*?)<\/tr>/)?.[1] ?? "";
|
||||
const cells = [...row.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/g)].map((match) => match[1]);
|
||||
|
||||
assert.ok(headers[0]?.includes(language === "es" ? "Tipo de activo" : "Asset type"));
|
||||
assert.equal(headers[0]?.includes("aria-sort"), false);
|
||||
assert.ok(headers[0]?.includes("min-w-0"));
|
||||
assert.ok(headers[1]?.includes('aria-sort="ascending"'));
|
||||
assert.ok(cells[0]?.includes('aria-label="Portátiles"'));
|
||||
assert.ok(cells[0]?.includes('tabindex="0"'));
|
||||
assert.ok(cells[0]?.includes('data-slot="tooltip-trigger"'));
|
||||
assert.ok(cells[0]?.includes("tabler-icon-device-laptop"));
|
||||
assert.ok(cells[1]?.includes("Equipo 2"));
|
||||
assert.ok(cells[2]?.includes("Portátiles"));
|
||||
assert.equal(cells[2]?.includes("<svg"), false);
|
||||
assert.ok(table.includes("tabler-icon-box"));
|
||||
}
|
||||
});
|
||||
|
||||
@ -506,13 +506,76 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
|
||||
for (const group of ["Ubicación y asignación", "Características", "Observaciones"]) {
|
||||
assert.ok(html.includes(group));
|
||||
}
|
||||
assert.ok(html.includes("<summary"));
|
||||
assert.ok(html.includes('open=""'));
|
||||
assert.ok(html.includes('data-slot="accordion"'));
|
||||
assert.ok(html.includes('data-slot="accordion-trigger"'));
|
||||
assert.ok((html.match(/aria-expanded="true"/g) ?? []).length >= 5);
|
||||
assert.ok(html.includes(`href="/companies/${companyId}/sites"`));
|
||||
assert.ok(html.includes(`href="/companies/${companyId}/catalogs/assets?tab=templates"`));
|
||||
assert.ok(html.includes("ABC123"));
|
||||
assert.ok(html.includes("Fecha de alta"));
|
||||
assert.ok(html.includes("29 feb 2024"));
|
||||
|
||||
const grouped = AssetSchema.parse({
|
||||
...asset,
|
||||
specification: {
|
||||
...asset.specification,
|
||||
sections: [
|
||||
{ id: fieldId, name: "Red", description: "Datos de red" },
|
||||
{ id: personId, name: "Grupo vacío", description: "" },
|
||||
],
|
||||
fields: [
|
||||
...asset.specification.fields.map((field) => ({ ...field, sectionId: fieldId })),
|
||||
{
|
||||
fieldId: siteId,
|
||||
name: "Contador",
|
||||
type: "number",
|
||||
unit: "",
|
||||
choices: [],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
fieldId: personId,
|
||||
name: "Color",
|
||||
type: "boolean",
|
||||
unit: "",
|
||||
choices: [],
|
||||
required: false,
|
||||
sectionId: companyId,
|
||||
},
|
||||
],
|
||||
},
|
||||
values: {
|
||||
...asset.values,
|
||||
characteristics: { ...asset.values.characteristics, [siteId]: 0, [personId]: false },
|
||||
},
|
||||
});
|
||||
|
||||
for (const language of ["es", "en"]) {
|
||||
const labels = language === "es" ? assetsSpanish : assetsEnglish;
|
||||
const groupedHtml = await render(
|
||||
language,
|
||||
<AssetSelectionPanel
|
||||
asset={grouped}
|
||||
canEdit
|
||||
companyId={companyId}
|
||||
lookups={lookups}
|
||||
onClose={() => undefined}
|
||||
onDelete={() => undefined}
|
||||
onEdit={() => undefined}
|
||||
onView={() => undefined}
|
||||
open
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(groupedHtml.includes('data-slot="accordion"'));
|
||||
assert.ok((groupedHtml.match(/aria-expanded="true"/g) ?? []).length >= 6);
|
||||
assert.ok(groupedHtml.includes("Datos de red"));
|
||||
assert.ok(groupedHtml.includes(labels.sections.ungrouped));
|
||||
assert.ok(groupedHtml.includes("ABC123"));
|
||||
assert.ok(groupedHtml.includes(">0</dd>"));
|
||||
assert.ok(groupedHtml.includes(`>${labels.no}</dd>`));
|
||||
assert.equal(groupedHtml.includes("Grupo vacío"), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("texto largo: área editable con ayuda accesible", async () => {
|
||||
|
||||
@ -7,10 +7,11 @@ 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";
|
||||
import { formatAssetRegistrationDate } from "../helpers/format-asset-registration-date";
|
||||
|
||||
import { AssetTemplateIndicator } from "./asset-template-indicator";
|
||||
|
||||
/** Inventario y selectores comparten columnas, búsqueda, pestañas y paginación. */
|
||||
export function AssetList({
|
||||
assets,
|
||||
@ -98,13 +99,31 @@ export function AssetList({
|
||||
<EntityTable
|
||||
columns={[
|
||||
...leadingColumns,
|
||||
{
|
||||
id: "icon",
|
||||
label: t("assetType"),
|
||||
hideable: false,
|
||||
render: (asset) => {
|
||||
const template = catalog.find((item) => item.id === asset.values.templateId);
|
||||
|
||||
return (
|
||||
<AssetTemplateIndicator
|
||||
icon={template?.values.kind === "template" ? template.values.icon : undefined}
|
||||
name={asset.specification.templateName}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (asset) => asset.values.name,
|
||||
hideable: false,
|
||||
render: (asset) => {
|
||||
const summary = assetCharacteristicsSummary(asset, { yes: t("yes"), no: t("no") });
|
||||
const summary = assetCharacteristicsSummary(asset, {
|
||||
yes: t("yes"),
|
||||
no: t("no"),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@ -126,38 +145,39 @@ export function AssetList({
|
||||
id: "template",
|
||||
label: t("template"),
|
||||
value: (asset) => asset.specification.templateName,
|
||||
render: (asset) => {
|
||||
const template = catalog.find((item) => item.id === asset.values.templateId);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<AssetTemplateIcon
|
||||
icon={template?.values.kind === "template" ? template.values.icon : undefined}
|
||||
/>
|
||||
{asset.specification.templateName}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
render: (asset) => (
|
||||
<span className="text-muted-foreground">{asset.specification.templateName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (asset) => t(asset.displayStatus),
|
||||
render: (asset) => (
|
||||
<EntityStatus active={asset.values.status === "operational"}>
|
||||
{t(asset.displayStatus)}
|
||||
</EntityStatus>
|
||||
<span className="whitespace-nowrap">
|
||||
<EntityStatus active={asset.values.status === "operational"}>
|
||||
{t(asset.displayStatus)}
|
||||
</EntityStatus>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ id: "site", label: t("site"), value: siteName, render: siteName },
|
||||
...extraColumns.filter((column) => column.id !== "actions"),
|
||||
{
|
||||
id: "registeredOn",
|
||||
label: t("registeredOn"),
|
||||
value: (asset) => asset.values.registeredOn ?? "",
|
||||
render: (asset) =>
|
||||
formatAssetRegistrationDate(asset.values.registeredOn, i18n.language),
|
||||
render: (asset) => {
|
||||
const date = formatAssetRegistrationDate(asset.values.registeredOn, i18n.language);
|
||||
|
||||
return date ? (
|
||||
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
|
||||
{date}
|
||||
</span>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{ id: "site", label: t("site"), value: siteName, render: siteName },
|
||||
...extraColumns,
|
||||
...extraColumns.filter((column) => column.id === "actions"),
|
||||
]}
|
||||
emptyMessage={t("noAssets")}
|
||||
filterKey={templateId ?? "all"}
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@repo/shadcn-ui/components/tooltip";
|
||||
|
||||
import type { AssetTemplateIconKey } from "../../../../common/asset-template-icons";
|
||||
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
|
||||
|
||||
/** Identifica la plantilla visualmente y revela su nombre con puntero o foco de teclado. */
|
||||
export function AssetTemplateIndicator({
|
||||
icon,
|
||||
name,
|
||||
}: {
|
||||
icon: AssetTemplateIconKey | undefined;
|
||||
name: string;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
aria-label={name}
|
||||
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
render={<span role="img" />}
|
||||
tabIndex={0}
|
||||
>
|
||||
<AssetTemplateIcon className="size-5 shrink-0" icon={icon ?? "box"} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{name}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Building2Icon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -31,10 +32,12 @@ export function BrandCatalogPage() {
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
+ {t("brands.new")}
|
||||
<Building2Icon aria-hidden="true" />
|
||||
{t("brands.new")}
|
||||
</Button>
|
||||
}
|
||||
description={t("brands.introduction")}
|
||||
icon={<Building2Icon />}
|
||||
locale={i18n.language}
|
||||
title={t("brands.title")}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list";
|
||||
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -37,11 +38,13 @@ export function AssetCatalogPage(props: AssetCatalogPageProps) {
|
||||
actions={
|
||||
<>
|
||||
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||
<LayoutTemplateIcon aria-hidden="true" />
|
||||
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
|
||||
icon={<LayoutTemplateIcon />}
|
||||
locale={i18n.language}
|
||||
title={t(scope === "global" ? "title" : "companyTitle")}
|
||||
/>
|
||||
|
||||
@ -9,6 +9,7 @@ export { inventoryApi } from "./shared/api";
|
||||
export { inventoryKeys } from "./shared/queries";
|
||||
export { AssetSupport } from "./support-connections";
|
||||
export { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations";
|
||||
export { WorkstationHistory } from "./workstations/history/ui/workstation-history";
|
||||
export { WorkstationNameEditor } from "./workstations/rename/ui/workstation-name-editor";
|
||||
export { WorkstationActions } from "./workstations/shared/ui/workstation-actions";
|
||||
export { WorkstationLifecycleDialog } from "./workstations/shared/ui/workstation-lifecycle-dialog";
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkstationDTO } from "../../../../common";
|
||||
import type { WorkstationHistoryProps } from "./workstation-history";
|
||||
import { WorkstationHistory } from "./workstation-history";
|
||||
|
||||
export function WorkstationHistoryDialog({
|
||||
workstation,
|
||||
@ -9,14 +10,8 @@ export function WorkstationHistoryDialog({
|
||||
siteName,
|
||||
members,
|
||||
onClose,
|
||||
}: {
|
||||
workstation: WorkstationDTO;
|
||||
personName: (id: string | null) => string;
|
||||
siteName: (id: string | null) => string;
|
||||
members: (ids: readonly string[], empty?: string) => string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
}: WorkstationHistoryProps & { onClose: () => void }) {
|
||||
const { t } = useTranslation("assets");
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
@ -30,28 +25,14 @@ export function WorkstationHistoryDialog({
|
||||
title={t("workstationHistory")}
|
||||
wide
|
||||
>
|
||||
<ol className="space-y-4">
|
||||
{[...workstation.history].reverse().map((revision) => (
|
||||
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
|
||||
<p className="font-medium">
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(revision.recordedAt))}{" "}
|
||||
· {t("recordedBy", { name: revision.actorName })}
|
||||
</p>
|
||||
<p>
|
||||
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
|
||||
</p>
|
||||
<p>
|
||||
{siteName(revision.values.siteId)} · {personName(revision.values.personId)}
|
||||
</p>
|
||||
<p>
|
||||
{t("workstationAssets")}: {members(revision.values.assetIds)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<FormBody>
|
||||
<WorkstationHistory
|
||||
members={members}
|
||||
personName={personName}
|
||||
siteName={siteName}
|
||||
workstation={workstation}
|
||||
/>
|
||||
</FormBody>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
||||
import { TimeLine } from "@repo/rdx-ui/components/time-line";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkstationDTO } from "../../../../common";
|
||||
|
||||
export type WorkstationHistoryProps = {
|
||||
workstation: WorkstationDTO;
|
||||
personName: (id: string | null) => string;
|
||||
siteName: (id: string | null) => string;
|
||||
members: (ids: readonly string[], empty?: string) => string;
|
||||
};
|
||||
|
||||
/** Cada evento presenta su revisión completa, sin sustituir valores antiguos por los actuales. */
|
||||
export function WorkstationHistory({
|
||||
workstation,
|
||||
personName,
|
||||
siteName,
|
||||
members,
|
||||
}: WorkstationHistoryProps) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const dateFormat = new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
return (
|
||||
<TimeLine
|
||||
emptyMessage={t("workstationDetail.noHistory")}
|
||||
items={workstation.history
|
||||
.map((revision, index) => ({
|
||||
id: revision.id,
|
||||
dateTime: revision.recordedAt,
|
||||
dateLabel: dateFormat.format(new Date(revision.recordedAt)),
|
||||
title: t(index === 0 ? "workstationDetail.created" : "workstationDetail.updated"),
|
||||
meta: t("recordedBy", { name: revision.actorName }),
|
||||
content: (
|
||||
<dl className="grid min-w-0 gap-x-5 gap-y-3 text-sm sm:grid-cols-2 [&_dd]:break-words [&_dt]:text-xs [&_dt]:text-muted-foreground">
|
||||
<div>
|
||||
<dt>{t("name")}</dt>
|
||||
<dd>{revision.values.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("status")}</dt>
|
||||
<dd>
|
||||
<EntityStatus active={revision.values.active}>
|
||||
{t(revision.values.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("site")}</dt>
|
||||
<dd>{revision.values.siteId ? siteName(revision.values.siteId) : "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("workstationPerson")}</dt>
|
||||
<dd>{revision.values.personId ? personName(revision.values.personId) : "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("workstationComputer")}</dt>
|
||||
<dd>
|
||||
{revision.values.computerAssetId
|
||||
? members([revision.values.computerAssetId])
|
||||
: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("workstationAssets")}</dt>
|
||||
<dd>{revision.values.assetIds.length ? members(revision.values.assetIds) : "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
),
|
||||
}))
|
||||
.reverse()}
|
||||
label={t("workstationHistory")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -6,10 +6,14 @@ import { getAssetInventory } from "../../../assets/list/api";
|
||||
import { inventoryKeys } from "../../../shared/queries";
|
||||
import type { WorkstationsPageProps } from "../types";
|
||||
|
||||
export function useWorkstationsPageController({ companyId, lookups }: WorkstationsPageProps) {
|
||||
export function useWorkstationsPageController({
|
||||
companyId,
|
||||
lookups,
|
||||
initialCreate = false,
|
||||
}: WorkstationsPageProps) {
|
||||
const { t } = useTranslation("assets");
|
||||
const [search, setSearch] = useState("");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
|
||||
const [view, setView] = useState<string>();
|
||||
const query = useListQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
|
||||
@ -2,5 +2,7 @@ import type { InventoryLookups } from "../../../assets/shared/types";
|
||||
|
||||
export type WorkstationsPageProps = {
|
||||
companyId: string;
|
||||
initialCreate?: boolean | undefined;
|
||||
initialPersonId?: string | undefined;
|
||||
lookups: InventoryLookups;
|
||||
};
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { MonitorIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -18,7 +19,7 @@ import { useWorkstationsPageController } from "../controllers";
|
||||
import type { WorkstationsPageProps } from "../types";
|
||||
|
||||
export function WorkstationsPage(props: WorkstationsPageProps) {
|
||||
const { companyId, lookups } = props;
|
||||
const { companyId, lookups, initialPersonId } = props;
|
||||
const [operation, setOperation] = useState<{ id: string; action: "decommission" | "delete" }>();
|
||||
const {
|
||||
search,
|
||||
@ -45,10 +46,12 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!query.data} onClick={() => setEditor("new")}>
|
||||
<MonitorIcon aria-hidden="true" />
|
||||
{t("newWorkstation")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<MonitorIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("workstations")}
|
||||
/>
|
||||
@ -93,8 +96,17 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
||||
label: t("workstationPerson"),
|
||||
value: (item) =>
|
||||
lookups.people.find((person) => person.id === item.values.personId)?.name,
|
||||
render: (item) =>
|
||||
lookups.people.find((person) => person.id === item.values.personId)?.name,
|
||||
render: (item) => {
|
||||
const person = lookups.people.find(
|
||||
(candidate) => candidate.id === item.values.personId
|
||||
);
|
||||
|
||||
return person ? (
|
||||
<BrowserLink href={`/companies/${companyId}/people/${person.id}`}>
|
||||
{person.name}
|
||||
</BrowserLink>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
@ -110,7 +122,25 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
||||
id: "exclusive",
|
||||
label: t("workstationAssets"),
|
||||
render: (item) =>
|
||||
item.values.assetIds.length ? members(item.values.assetIds) : null,
|
||||
item.values.assetIds.length ? (
|
||||
<ul className="space-y-1">
|
||||
{item.values.assetIds.map((id) => {
|
||||
const asset = query.data?.assets.find((candidate) => candidate.id === id);
|
||||
|
||||
return (
|
||||
<li key={id}>
|
||||
{asset ? (
|
||||
<BrowserLink href={`/companies/${companyId}/assets/${asset.id}`}>
|
||||
{asset.values.name}
|
||||
</BrowserLink>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
id: "count",
|
||||
@ -175,6 +205,7 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
||||
{editor && (
|
||||
<WorkstationEditorDialog
|
||||
companyId={companyId}
|
||||
initialPersonId={initialPersonId}
|
||||
lookups={lookups}
|
||||
onClose={() => setEditor(undefined)}
|
||||
workstation={edited}
|
||||
|
||||
@ -19,6 +19,8 @@ import { suggestedWorkstationName } from "../workstation-name";
|
||||
export function useWorkstationFormController({
|
||||
companyId,
|
||||
workstation,
|
||||
initialAsset,
|
||||
initialPersonId,
|
||||
onSaved,
|
||||
lookups,
|
||||
}: WorkstationFormProps) {
|
||||
@ -28,16 +30,26 @@ export function useWorkstationFormController({
|
||||
const isWizard = !workstation;
|
||||
const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
|
||||
const activeSites = lookups.sites.filter((site) => site.active);
|
||||
const initialSite = activeSites.length === 1 ? activeSites[0] : undefined;
|
||||
const sourceAsset = initialAsset?.companyId === companyId ? initialAsset : undefined;
|
||||
const initialSite =
|
||||
activeSites.find((site) => site.id === sourceAsset?.values.siteId) ??
|
||||
(activeSites.length === 1 ? activeSites[0] : undefined);
|
||||
const initialName = suggestedWorkstationName(
|
||||
sourceAsset?.values.name,
|
||||
initialSite?.name,
|
||||
(computer, site) => t("workstationSuggestedName", { computer, site })
|
||||
);
|
||||
const form = useForm<WorkstationInput>({
|
||||
resolver: zodResolver(WorkstationInputSchema),
|
||||
defaultValues: workstation?.values ?? {
|
||||
name: initialSite ? t("workstationNameBySite", { site: initialSite.name }) : "",
|
||||
name:
|
||||
initialName ?? (initialSite ? t("workstationNameBySite", { site: initialSite.name }) : ""),
|
||||
active: true,
|
||||
siteId: initialSite?.id ?? null,
|
||||
personId: null,
|
||||
assetIds: [],
|
||||
computerAssetId: null,
|
||||
personId:
|
||||
lookups.people.find((person) => person.id === initialPersonId && person.active)?.id ?? null,
|
||||
assetIds: sourceAsset ? [sourceAsset.id] : [],
|
||||
computerAssetId: sourceAsset?.id ?? null,
|
||||
},
|
||||
});
|
||||
const values = form.watch();
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import type { WorkstationDTO } from "../../../../common";
|
||||
import type { AssetDTO, WorkstationDTO } from "../../../../common";
|
||||
import type { InventoryLookups } from "../../../assets/shared/types";
|
||||
|
||||
export type WorkstationFormProps = {
|
||||
companyId: string;
|
||||
lookups: InventoryLookups;
|
||||
workstation?: WorkstationDTO | undefined;
|
||||
initialAsset?: AssetDTO | undefined;
|
||||
initialPersonId?: string | undefined;
|
||||
onSaved: (id: string) => void;
|
||||
};
|
||||
|
||||
@ -8,11 +8,17 @@ import { WorkstationForm } from "./workstation-form";
|
||||
export function WorkstationEditorDialog({
|
||||
workstation,
|
||||
companyId,
|
||||
initialAsset,
|
||||
initialPersonId,
|
||||
lookups,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
workstation: ComponentProps<typeof WorkstationForm>["workstation"];
|
||||
companyId: string;
|
||||
initialAsset?: ComponentProps<typeof WorkstationForm>["initialAsset"];
|
||||
initialPersonId?: string | undefined;
|
||||
onSaved?: (id: string) => void;
|
||||
lookups: ComponentProps<typeof WorkstationForm>["lookups"];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@ -39,8 +45,13 @@ export function WorkstationEditorDialog({
|
||||
>
|
||||
<WorkstationForm
|
||||
companyId={companyId}
|
||||
initialAsset={initialAsset}
|
||||
initialPersonId={initialPersonId}
|
||||
lookups={lookups}
|
||||
onSaved={() => onClose()}
|
||||
onSaved={(id) => {
|
||||
onClose();
|
||||
onSaved?.(id);
|
||||
}}
|
||||
workstation={workstation}
|
||||
/>
|
||||
</FormDialog>
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
{
|
||||
"companies": {
|
||||
"navigationCounts": {
|
||||
"loading": "Loading total…",
|
||||
"error": "The total could not be refreshed."
|
||||
},
|
||||
"navigation": {
|
||||
"invalidRoute": "Invalid address",
|
||||
"notFound": "Page not found",
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
{
|
||||
"companies": {
|
||||
"navigationCounts": {
|
||||
"loading": "Cargando total…",
|
||||
"error": "No se ha podido actualizar el total."
|
||||
},
|
||||
"navigation": {
|
||||
"invalidRoute": "La dirección no es válida",
|
||||
"notFound": "No se ha encontrado esta página",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list";
|
||||
import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -49,12 +50,14 @@ export function AccessWorkspacePage(props: AccessWorkspacePageProps) {
|
||||
{t("helpButton")}
|
||||
</Button>
|
||||
<Button disabled={!data} onClick={() => setEditor("new")}>
|
||||
<KeyRoundIcon aria-hidden="true" />
|
||||
{t(createLabels[kind])}
|
||||
</Button>
|
||||
</div>{" "}
|
||||
</>
|
||||
}
|
||||
description={t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
|
||||
icon={<KeyRoundIcon />}
|
||||
locale={i18n.language}
|
||||
title={t(`kinds.${kind}`)}
|
||||
/>
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -53,10 +54,12 @@ export function ApplicationConfigurationsPage(props: ApplicationConfigurationsPa
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||
<LayoutTemplateIcon aria-hidden="true" />
|
||||
{t("applications.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<LayoutTemplateIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("applications.title")}
|
||||
/>
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { FileKeyIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -53,10 +54,12 @@ export function CertificateConfigurationsPage(props: CertificateConfigurationsPa
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||
<FileKeyIcon aria-hidden="true" />
|
||||
{t("certificates.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<FileKeyIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("certificates.title")}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
import {
|
||||
inventoryApi as assetInventoryApi,
|
||||
inventoryKeys as assetInventoryKeys,
|
||||
} from "@metis/assets/client";
|
||||
import { useListQuery } from "@metis/core/client";
|
||||
import {
|
||||
inventoryApi as resourceInventoryApi,
|
||||
inventoryKeys as resourceInventoryKeys,
|
||||
} from "@metis/resources/client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { profileApi, profileKeys } from "../../../work-profiles/shared/api";
|
||||
import { companyKeys } from "../../shared/queries";
|
||||
import type { CompanyTab } from "../../shared/types";
|
||||
import { getCompany } from "../api";
|
||||
@ -19,5 +29,30 @@ export function useCompanyDetailController({
|
||||
const setTab = onSectionChange ?? setLocalTab;
|
||||
const company = query.data?.company;
|
||||
|
||||
return { query, editOpen, setEditOpen, tab, setTab, company };
|
||||
const inventory = useListQuery({
|
||||
queryKey: assetInventoryKeys.company(id),
|
||||
queryFn: () => assetInventoryApi.get(id),
|
||||
enabled: Boolean(company),
|
||||
});
|
||||
const resources = useListQuery({
|
||||
queryKey: resourceInventoryKeys.company(id),
|
||||
queryFn: () => resourceInventoryApi.get(id),
|
||||
enabled: Boolean(company),
|
||||
});
|
||||
const profiles = useListQuery({
|
||||
queryKey: profileKeys.company(id),
|
||||
queryFn: () => profileApi.get(id),
|
||||
enabled: Boolean(company),
|
||||
});
|
||||
const counts: Partial<Record<CompanyTab, { count: number | undefined; failed: boolean }>> = {
|
||||
people: { count: company?.people.length, failed: false },
|
||||
departments: { count: company?.departments.length, failed: false },
|
||||
sites: { count: company?.sites.length, failed: false },
|
||||
inventory: { count: inventory.data?.assets.length, failed: inventory.isError },
|
||||
workstations: { count: inventory.data?.workstations.length, failed: inventory.isError },
|
||||
resources: { count: resources.data?.resources.length, failed: resources.isError },
|
||||
profiles: { count: profiles.data?.profiles.length, failed: profiles.isError },
|
||||
};
|
||||
|
||||
return { query, editOpen, setEditOpen, tab, setTab, company, counts };
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { BrowserLink } from "@metis/core/client";
|
||||
import { CountBadge } from "@repo/rdx-ui/components/count-badge";
|
||||
import {
|
||||
BoxIcon,
|
||||
BriefcaseIcon,
|
||||
ChevronLeftIcon,
|
||||
DatabaseIcon,
|
||||
FileKeyIcon,
|
||||
FolderIcon,
|
||||
HomeIcon,
|
||||
KeyRoundIcon,
|
||||
LayersIcon,
|
||||
@ -11,7 +14,9 @@ import {
|
||||
MailIcon,
|
||||
MapPinIcon,
|
||||
MonitorIcon,
|
||||
UsersIcon,
|
||||
PersonIcon,
|
||||
PrinterIcon,
|
||||
WorkProfileIcon,
|
||||
} from "@repo/rdx-ui/lib/icons";
|
||||
import { Avatar, AvatarBadge } from "@repo/shadcn-ui/components/avatar";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
@ -34,8 +39,9 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
||||
initialCreate,
|
||||
initialConfiguration,
|
||||
} = props;
|
||||
const { query, editOpen, setEditOpen, tab, setTab, company } = useCompanyDetailController(props);
|
||||
const { t } = useTranslation("companies");
|
||||
const { query, editOpen, setEditOpen, tab, setTab, company, counts } =
|
||||
useCompanyDetailController(props);
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const { t: resourceTranslation } = useTranslation("resources");
|
||||
const { t: assetTranslation } = useTranslation("assets");
|
||||
const { t: accessTranslation } = useTranslation("access");
|
||||
@ -106,25 +112,21 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
||||
{(
|
||||
[
|
||||
["overview", t("companyOverview.navigation"), HomeIcon],
|
||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||
["people", t("people"), PersonIcon],
|
||||
["workstations", assetTranslation("workstations"), MonitorIcon],
|
||||
["profiles", t("profiles.title"), UsersIcon],
|
||||
["profiles", t("profiles.title"), WorkProfileIcon],
|
||||
["inventory", assetTranslation("inventory"), BoxIcon],
|
||||
["resources", resourceTranslation("inventory"), LayersIcon],
|
||||
[
|
||||
"departments",
|
||||
`${t("departments")} (${company.departments.length})`,
|
||||
BriefcaseIcon,
|
||||
],
|
||||
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
|
||||
["departments", t("departments"), BriefcaseIcon],
|
||||
["sites", t("sites"), MapPinIcon],
|
||||
["telework", t("telework.title"), KeyRoundIcon],
|
||||
["remote", t("remote.title"), KeyRoundIcon],
|
||||
["processes", t("processes.title"), LayoutTemplateIcon],
|
||||
["access", accessTranslation("title"), KeyRoundIcon],
|
||||
["folders", t("folders.title"), MailIcon],
|
||||
["certificates", t("certificates.title"), LayersIcon],
|
||||
["applications", t("applications.title"), LayersIcon],
|
||||
["printers", t("printers.title"), LayersIcon],
|
||||
["folders", t("folders.title"), FolderIcon],
|
||||
["certificates", t("certificates.title"), FileKeyIcon],
|
||||
["applications", t("applications.title"), LayoutTemplateIcon],
|
||||
["printers", t("printers.title"), PrinterIcon],
|
||||
["mail", t("mail.title"), MailIcon],
|
||||
["catalogs", t("catalogOverview.companyTitle"), DatabaseIcon],
|
||||
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
||||
@ -135,6 +137,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
||||
return null;
|
||||
}
|
||||
const [section, label, Icon] = entry;
|
||||
const count = counts[section];
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -149,8 +152,8 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
||||
aria-pressed={tab === section}
|
||||
className={
|
||||
embedded
|
||||
? "cursor-pointer text-foreground hover:text-primary h-auto min-h-10 w-full justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] aria-pressed:bg-accent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent aria-pressed:text-accent-foreground shell:text-sm"
|
||||
: "cursor-pointer text-foreground hover:text-primary"
|
||||
? "group/company-section cursor-pointer text-foreground hover:text-primary h-auto min-h-10 w-full justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] aria-pressed:bg-accent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent aria-pressed:text-accent-foreground shell:text-sm"
|
||||
: "group/company-section cursor-pointer text-foreground hover:text-primary"
|
||||
}
|
||||
{...(props.onSectionChange
|
||||
? {
|
||||
@ -162,7 +165,17 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
||||
{label}
|
||||
<span className="min-w-0 flex-1 text-left">{label}</span>
|
||||
{count && (
|
||||
<CountBadge
|
||||
className="ms-auto group-aria-pressed/company-section:bg-primary/10 group-aria-pressed/company-section:text-primary"
|
||||
count={count.count}
|
||||
errorLabel={t("navigationCounts.error")}
|
||||
failed={count.failed}
|
||||
loadingLabel={t("navigationCounts.loading")}
|
||||
locale={i18n.language}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -11,11 +11,13 @@ export function CompanyInventory({
|
||||
workstations = false,
|
||||
initialId,
|
||||
initialCreate,
|
||||
initialPersonId,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
workstations?: boolean;
|
||||
initialId?: string | undefined;
|
||||
initialCreate?: boolean | undefined;
|
||||
initialPersonId?: string | undefined;
|
||||
}) {
|
||||
const [quick, setQuick] = useState<{
|
||||
kind: "site" | "person";
|
||||
@ -40,7 +42,7 @@ export function CompanyInventory({
|
||||
/>
|
||||
) : (
|
||||
<Page
|
||||
{...(workstations ? {} : { initialId, initialCreate })}
|
||||
{...(workstations ? { initialPersonId, initialCreate } : { initialId, initialCreate })}
|
||||
companyId={company.id}
|
||||
lookups={lookups}
|
||||
/>
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
DatabaseIcon,
|
||||
LaptopIcon,
|
||||
MapPinIcon,
|
||||
UsersIcon,
|
||||
PersonIcon,
|
||||
} from "@repo/rdx-ui/lib/icons";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -69,7 +69,7 @@ export function CompanyOverviewStatistics({
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ section: "people", label: "people", value: company.people.length, Icon: UsersIcon },
|
||||
{ section: "people", label: "people", value: company.people.length, Icon: PersonIcon },
|
||||
{
|
||||
section: "departments",
|
||||
label: "departments",
|
||||
|
||||
@ -110,7 +110,15 @@ export function CompanySection({
|
||||
/>
|
||||
);
|
||||
case "workstations":
|
||||
return <CompanyInventory company={company} initialId={initialEntityId} workstations />;
|
||||
return (
|
||||
<CompanyInventory
|
||||
company={company}
|
||||
initialCreate={create}
|
||||
initialId={initialEntityId}
|
||||
initialPersonId={personId}
|
||||
workstations
|
||||
/>
|
||||
);
|
||||
case "inventory":
|
||||
return (
|
||||
<CompanyInventory company={company} initialCreate={create} initialId={initialEntityId} />
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Building2Icon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -52,10 +53,14 @@ export function CompaniesPage(props: CompaniesPageProps) {
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button onClick={() => setCreateOpen(true)}>{t("newCompany")}</Button>{" "}
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Building2Icon aria-hidden="true" />
|
||||
{t("newCompany")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
description={t("help")}
|
||||
icon={<Building2Icon />}
|
||||
locale={i18n.language}
|
||||
title={t("title")}
|
||||
/>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { DatabaseIcon, LaptopIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { DatabaseIcon, LaptopIcon, PersonIcon, WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
|
||||
|
||||
export const KIND_ICONS = {
|
||||
asset: LaptopIcon,
|
||||
resource: DatabaseIcon,
|
||||
person: UsersIcon,
|
||||
profile: UsersIcon,
|
||||
person: PersonIcon,
|
||||
profile: WorkProfileIcon,
|
||||
};
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
FileKeyIcon,
|
||||
TriangleAlertIcon,
|
||||
UserRoundXIcon,
|
||||
UsersIcon,
|
||||
WorkProfileIcon,
|
||||
} from "@repo/rdx-ui/lib/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -16,7 +16,7 @@ import { dashboardApi, dashboardKeys } from "../../shared/api";
|
||||
const ISSUE_ICONS = {
|
||||
certificate: FileKeyIcon,
|
||||
license: FileKeyIcon,
|
||||
profile: UsersIcon,
|
||||
profile: WorkProfileIcon,
|
||||
missing_site: BoxIcon,
|
||||
inactive_person: UserRoundXIcon,
|
||||
};
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { BriefcaseIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -21,7 +22,13 @@ export function DepartmentSection({ company }: { company: CompanyDTO }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<EntityListHeader
|
||||
actions={<Button onClick={() => setEditor("new")}>{t("newDepartment")}</Button>}
|
||||
actions={
|
||||
<Button onClick={() => setEditor("new")}>
|
||||
<BriefcaseIcon aria-hidden="true" />
|
||||
{t("newDepartment")}
|
||||
</Button>
|
||||
}
|
||||
icon={<BriefcaseIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("departments")}
|
||||
/>
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { FolderIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -49,10 +50,12 @@ export function FolderMappingsPage(props: FolderMappingsPageProps) {
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||
<FolderIcon aria-hidden="true" />
|
||||
{t("folders.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<FolderIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("folders.title")}
|
||||
/>
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { MailIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -52,10 +53,12 @@ export function MailConfigurationsPage(props: MailConfigurationsPageProps) {
|
||||
disabled={!(query.data && inventory.data && profiles.data && mailboxes.data)}
|
||||
onClick={() => setEditor("new")}
|
||||
>
|
||||
<MailIcon aria-hidden="true" />
|
||||
{t("mail.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<MailIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("mail.title")}
|
||||
/>
|
||||
|
||||
@ -143,9 +143,11 @@ export function PersonDetails({
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<Button disabled={!person.active} onClick={() => setEditor("new")}>
|
||||
<BriefcaseIcon aria-hidden="true" />
|
||||
{t("addAssignment")}
|
||||
</Button>
|
||||
}
|
||||
icon={<BriefcaseIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("departmentAssignments")}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { BrowserLink, EntityNotFound, useEntityNavigation } from "@metis/core/client";
|
||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||
import {
|
||||
BrowserLink,
|
||||
EntityNotFound,
|
||||
navigateTo,
|
||||
useEntityNavigation,
|
||||
useListQuery,
|
||||
} from "@metis/core/client";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
@ -8,12 +15,14 @@ import {
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { PhoneLink } from "@repo/rdx-ui/components/phone-link";
|
||||
import { PersonIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../../../common";
|
||||
import { personWorkstations } from "../../../workstations/detail/helpers/person-workstations";
|
||||
import { PersonDetails } from "../../detail";
|
||||
import { PersonAvatar, PersonEditorDialog } from "../../shared";
|
||||
|
||||
@ -25,6 +34,7 @@ export function PeopleSection({
|
||||
initialId?: string | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const { t: assetTranslation } = useTranslation("assets");
|
||||
const filterId = useId();
|
||||
const [selection, setSelection] = useState<
|
||||
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
|
||||
@ -36,6 +46,15 @@ export function PeopleSection({
|
||||
const [search, setSearch] = useState("");
|
||||
const [contactsOnly, setContactsOnly] = useState(false);
|
||||
const selected = company.people.find((item) => item.id === (routed ? selectedId : selection?.id));
|
||||
const inventory = useListQuery({
|
||||
queryKey: inventoryKeys.company(company.id),
|
||||
queryFn: () => inventoryApi.get(company.id),
|
||||
enabled: !selected,
|
||||
});
|
||||
|
||||
const responsibleWorkstations = (personId: string) =>
|
||||
personWorkstations(company.id, personId, inventory.data?.workstations ?? [], []).responsible;
|
||||
|
||||
const personEditor = (
|
||||
<PersonEditorDialog
|
||||
companyId={company.id}
|
||||
@ -86,13 +105,37 @@ export function PeopleSection({
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>{" "}
|
||||
<Button onClick={() => setSelection({ mode: "new" })}>
|
||||
<PersonIcon aria-hidden="true" />
|
||||
{t("newPerson")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<PersonIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("people")}
|
||||
/>
|
||||
|
||||
{inventory.isPending && (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{assetTranslation("loading")}
|
||||
</p>
|
||||
)}
|
||||
{inventory.isError && (
|
||||
<div className="space-y-2" role="alert">
|
||||
<p>{t("workstationsLoadError")}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
inventory.refetch().catch(() => undefined);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{assetTranslation("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
@ -125,6 +168,16 @@ export function PeopleSection({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (person) => t(person.active ? "active" : "inactive"),
|
||||
render: (person) => (
|
||||
<EntityStatus active={person.active}>
|
||||
{t(person.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
label: t("phone"),
|
||||
@ -165,21 +218,46 @@ export function PeopleSection({
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (person) => t(person.active ? "active" : "inactive"),
|
||||
render: (person) => (
|
||||
<EntityStatus active={person.active}>
|
||||
{t(person.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
id: "workstations",
|
||||
label: t("responsibleWorkstations"),
|
||||
value: (person) =>
|
||||
responsibleWorkstations(person.id)
|
||||
.map((station) => station.values.name)
|
||||
.join(" · "),
|
||||
render: (person) => {
|
||||
const stations = responsibleWorkstations(person.id);
|
||||
|
||||
return stations.length ? (
|
||||
<ul className="space-y-1">
|
||||
{stations.map((station) => (
|
||||
<li key={station.id}>
|
||||
<BrowserLink href={`/companies/${company.id}/workstations/${station.id}`}>
|
||||
{station.values.name}
|
||||
</BrowserLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (person) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
<EntityAction
|
||||
disabled={!person.active}
|
||||
onClick={() =>
|
||||
navigateTo(
|
||||
`/companies/${company.id}/workstations?personId=${encodeURIComponent(person.id)}`,
|
||||
{ create: true }
|
||||
)
|
||||
}
|
||||
>
|
||||
{assetTranslation("newWorkstation")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
onClick={() => {
|
||||
setSelection({ mode: "detail", id: person.id });
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { PersonIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -24,7 +24,7 @@ export function PersonEditorDialog({
|
||||
size="medium"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<UsersIcon aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
<PersonIcon aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
{t(formProps.person ? "editPerson" : "newPerson")}
|
||||
</span>
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { PrinterIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -50,10 +51,12 @@ export function PrinterConfigurationsPage(props: PrinterConfigurationsPageProps)
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||
<PrinterIcon aria-hidden="true" />
|
||||
{t("printers.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<PrinterIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("printers.title")}
|
||||
/>
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -24,11 +25,13 @@ export function ProcessTemplatesPage() {
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!query.data || query.isError} onClick={() => setEditor("new")}>
|
||||
<LayoutTemplateIcon aria-hidden="true" />
|
||||
{t("processes.newTemplate")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
description={t("processes.globalTemplates")}
|
||||
icon={<LayoutTemplateIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("processes.templates")}
|
||||
/>
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { ChevronLeftIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { ChevronLeftIcon, LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -87,11 +87,13 @@ export function ProcessesPage(props: ProcessesPageProps) {
|
||||
{t("processes.templates")}
|
||||
</Button>
|
||||
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
|
||||
<LayoutTemplateIcon aria-hidden="true" />
|
||||
{t("processes.new")}
|
||||
</Button>
|
||||
</div>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<LayoutTemplateIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("processes.title")}
|
||||
/>
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -46,10 +47,12 @@ export function RemoteConnectionsPage(props: RemoteConnectionsPageProps) {
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||
<KeyRoundIcon aria-hidden="true" />
|
||||
{t("remote.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<KeyRoundIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("remote.title")}
|
||||
/>
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { MapPinIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { useId } from "react";
|
||||
@ -36,7 +37,13 @@ export function SiteSection({ company }: { company: CompanyDTO }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<EntityListHeader
|
||||
actions={<Button onClick={() => setCreateOpen(true)}>{t("newSite")}</Button>}
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<MapPinIcon aria-hidden="true" />
|
||||
{t("newSite")}
|
||||
</Button>
|
||||
}
|
||||
icon={<MapPinIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("sites")}
|
||||
/>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { LayersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { useId, useState } from "react";
|
||||
@ -131,10 +132,12 @@ export function SoftwareInstallationsPanel({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<LayersIcon aria-hidden="true" />
|
||||
{t("software.new")}
|
||||
</Button>
|
||||
}
|
||||
description={t("software.description")}
|
||||
icon={<LayersIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("software.title")}
|
||||
/>
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -48,10 +49,12 @@ export function TeleworkAuthorizationsPage(props: TeleworkAuthorizationsPageProp
|
||||
}
|
||||
onClick={() => setEditor("new")}
|
||||
>
|
||||
<KeyRoundIcon aria-hidden="true" />
|
||||
{t("telework.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<KeyRoundIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("telework.title")}
|
||||
/>
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -69,10 +70,12 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||
<WorkProfileIcon aria-hidden="true" />
|
||||
{t("profiles.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
icon={<WorkProfileIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("profiles.title")}
|
||||
/>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -34,7 +34,7 @@ export function WorkProfileEditorDialog({
|
||||
size={profile ? "large" : "medium"}
|
||||
title={
|
||||
<span className="flex items-center gap-3">
|
||||
<UsersIcon aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||
<WorkProfileIcon aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||
{t(profile ? "profiles.edit" : "profiles.new")}
|
||||
</span>
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
|
||||
import { OptionSelectionField } from "@repo/rdx-ui/components/option-selection-field";
|
||||
import { RequiredIndicator } from "@repo/rdx-ui/components/required-indicator";
|
||||
import { KeyRoundIcon, MonitorIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { KeyRoundIcon, MonitorIcon, PersonIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -26,7 +26,7 @@ export function WorkProfileSelectionField({
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const id = useId();
|
||||
const icons = { workstation: <MonitorIcon />, person: <UsersIcon />, account: <KeyRoundIcon /> };
|
||||
const icons = { workstation: <MonitorIcon />, person: <PersonIcon />, account: <KeyRoundIcon /> };
|
||||
|
||||
return (
|
||||
<fieldset aria-describedby={`${id}-help`} className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
|
||||
@ -2,7 +2,7 @@ import type { AssetDTO } from "@metis/assets/common";
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
|
||||
import { SelectionSummaryCard } from "@repo/rdx-ui/components/selection-summary-card";
|
||||
import { KeyRoundIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { KeyRoundIcon, PersonIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { WorkProfileComputerSummary } from "./work-profile-computer-summary";
|
||||
@ -35,7 +35,7 @@ export function WorkProfileSummary({
|
||||
<li className="min-w-0">
|
||||
<SelectionSummaryCard
|
||||
description={person.description}
|
||||
icon={<UsersIcon />}
|
||||
icon={<PersonIcon />}
|
||||
title={person.label}
|
||||
/>
|
||||
</li>
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { WorkstationHistory } from "@metis/assets/client";
|
||||
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
|
||||
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
|
||||
import { ClockIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -19,7 +22,7 @@ export function WorkstationDetailContent({
|
||||
controller: WorkstationDetailController;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const [tab, setTab] = useState("overview");
|
||||
const [tab, setTab] = useState("information");
|
||||
const [softwareOpened, setSoftwareOpened] = useState(false);
|
||||
const workstation = c.workstation;
|
||||
|
||||
@ -51,25 +54,15 @@ export function WorkstationDetailContent({
|
||||
>
|
||||
<ScrollableTabsList
|
||||
items={[
|
||||
{ value: "overview", label: t("workstationDetail.overview") },
|
||||
{
|
||||
value: "profiles",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{t("workstationDetail.profiles")}
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums">
|
||||
{c.profiles.isSuccess ? c.relatedProfiles.length : "—"}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ value: "information", label: t("workstationDetail.information") },
|
||||
{ value: "software", label: t("workstationDetail.software") },
|
||||
{ value: "history", label: t("history") },
|
||||
]}
|
||||
label={t("workstationDetail.sections")}
|
||||
locale={i18n.language}
|
||||
value={tab}
|
||||
/>
|
||||
<TabsContent className="min-w-0" value="overview">
|
||||
<TabsContent className="min-w-0 space-y-5" keepMounted value="information">
|
||||
<div className="grid items-start gap-5 @min-[1000px]:grid-cols-[minmax(0,1fr)_minmax(260px,0.45fr)]">
|
||||
<WorkstationAssets
|
||||
assets={c.inventory.data?.assets ?? []}
|
||||
@ -86,8 +79,7 @@ export function WorkstationDetailContent({
|
||||
responsibleId={workstation.values.personId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent className="min-w-0" keepMounted value="profiles">
|
||||
|
||||
<WorkstationProfiles
|
||||
canCreate={canCreateProfile}
|
||||
companyId={company.id}
|
||||
@ -110,6 +102,28 @@ export function WorkstationDetailContent({
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent className="min-w-0" value="history">
|
||||
<DetailPanel
|
||||
count={workstation.history.length}
|
||||
description={t("workstationDetail.historyHint")}
|
||||
icon={<ClockIcon />}
|
||||
title={t("workstationHistory")}
|
||||
>
|
||||
<WorkstationHistory
|
||||
members={(ids) =>
|
||||
ids
|
||||
.map(
|
||||
(id) =>
|
||||
c.inventory.data?.assets.find((asset) => asset.id === id)?.values.name ?? "—"
|
||||
)
|
||||
.join(", ") || "—"
|
||||
}
|
||||
personName={(id) => company.people.find((person) => person.id === id)?.name ?? "—"}
|
||||
siteName={(id) => company.sites.find((site) => site.id === id)?.name ?? "—"}
|
||||
workstation={workstation}
|
||||
/>
|
||||
</DetailPanel>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
} from "@metis/assets/client";
|
||||
import { BrowserLink, navigateTo } from "@metis/core/client";
|
||||
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
||||
import { ChevronLeftIcon, MapPinIcon, MonitorIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { ChevronLeftIcon, MapPinIcon, MonitorIcon, PersonIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -90,7 +90,7 @@ export function WorkstationDetailPage({
|
||||
</span>
|
||||
{responsible && (
|
||||
<span className="inline-flex min-w-0 items-center gap-2">
|
||||
<UsersIcon aria-hidden="true" className="size-4 shrink-0" />
|
||||
<PersonIcon aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span className="sr-only">{t("workstationDetail.assignedPerson")}: </span>
|
||||
<BrowserLink href={`${root}/people/${responsible.id}`}>
|
||||
{responsible.name}
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { assetsEnglish, assetsSpanish, catalogKeys, inventoryKeys } from "@metis/assets/client";
|
||||
import {
|
||||
WorkstationHistory,
|
||||
assetsEnglish,
|
||||
assetsSpanish,
|
||||
catalogKeys,
|
||||
inventoryKeys,
|
||||
} from "@metis/assets/client";
|
||||
import type { AssetDTO, WorkstationDTO } from "@metis/assets/common";
|
||||
import { AssetSchema, WorkstationSchema } from "@metis/assets/common";
|
||||
import { inventoryKeys as resourceInventoryKeys } from "@metis/resources/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createInstance } from "i18next";
|
||||
import type { ReactNode } from "react";
|
||||
@ -13,7 +20,10 @@ import { I18nextProvider } from "react-i18next";
|
||||
import type { CompanyDTO, PersonDTO } from "../../../../common";
|
||||
import type { WorkProfileDTO } from "../../../../common/work-profiles";
|
||||
import { WorkProfileSchema } from "../../../../common/work-profiles";
|
||||
import { CompanyDetail } from "../../../companies/detail/ui/company-detail";
|
||||
import { companyKeys } from "../../../companies/shared/queries";
|
||||
import { CurrentWorkstations } from "../../../people/detail/ui/current-workstations";
|
||||
import { PeopleSection } from "../../../people/list/ui/people-section";
|
||||
import { companiesEnglish, companiesSpanish } from "../../../shared";
|
||||
import { profileKeys } from "../../../work-profiles/shared/api";
|
||||
|
||||
@ -62,6 +72,7 @@ async function render(
|
||||
node?: ReactNode;
|
||||
assets?: AssetDTO[];
|
||||
workstation?: WorkstationDTO;
|
||||
workstations?: WorkstationDTO[];
|
||||
people?: PersonDTO[];
|
||||
sites?: CompanyDTO["sites"];
|
||||
profiles?: WorkProfileDTO[];
|
||||
@ -83,10 +94,17 @@ async function render(
|
||||
client.setQueryData(inventoryKeys.company(companyId), {
|
||||
assets: options.assets ?? [],
|
||||
relationships: [],
|
||||
workstations: exists ? [options.workstation ?? workstation] : [],
|
||||
workstations: exists ? (options.workstations ?? [options.workstation ?? workstation]) : [],
|
||||
});
|
||||
client.setQueryData(profileKeys.company(companyId), { profiles: options.profiles ?? [] });
|
||||
client.setQueryData(catalogKeys.scope(companyId), { items: [] });
|
||||
client.setQueryData(companyKeys.detail(companyId), {
|
||||
company: { ...company, people: options.people ?? [], sites: options.sites ?? [] },
|
||||
});
|
||||
client.setQueryData(resourceInventoryKeys.company(companyId), {
|
||||
resources: [],
|
||||
relationships: [],
|
||||
});
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
@ -133,7 +151,18 @@ test("ficha de puesto es/en: cabecera, retorno, bloques vacíos y edición modal
|
||||
assert.ok(html.includes(labels.workstationDetail.people));
|
||||
assert.ok(html.includes(companies.profiles.new));
|
||||
assert.equal(html.includes("<form"), false);
|
||||
assert.ok(html.includes(labels.workstationDetail.overview));
|
||||
assert.ok(html.includes(labels.workstationDetail.information));
|
||||
const tabs = [...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)]
|
||||
.map(([button]) => button)
|
||||
.filter((button) => button.includes('role="tab"'));
|
||||
|
||||
assert.equal(tabs.length, 3);
|
||||
assert.ok(tabs.some((button) => button.includes(labels.history)));
|
||||
assert.equal(
|
||||
tabs.some((button) => button.includes(labels.workstationDetail.profiles)),
|
||||
false
|
||||
);
|
||||
assert.ok(html.includes(labels.workstationDetail.profilesHint));
|
||||
assert.ok(html.includes(labels.workstationDetail.software));
|
||||
assert.ok(html.includes(labels.noWorkstationAssets));
|
||||
assert.ok(html.includes(labels.workstationDetail.profileUnavailable));
|
||||
@ -414,9 +443,184 @@ test("ficha: no permite crear perfiles si el ordenador está retirado o el puest
|
||||
});
|
||||
|
||||
assert.ok(html.includes(assetsSpanish.workstationDetail.profileUnavailable));
|
||||
assert.match(
|
||||
html,
|
||||
/<button(?=[^>]*disabled="")(?=[^>]*type="button")[^>]*>Nuevo perfil<\/button>/
|
||||
);
|
||||
const createButton = [...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].find(([button]) =>
|
||||
button.includes("Nuevo perfil")
|
||||
)?.[0];
|
||||
|
||||
assert.ok(createButton);
|
||||
assert.match(createButton, /\bdisabled=""/);
|
||||
}
|
||||
});
|
||||
|
||||
test("historial del puesto es/en: revisiones de más reciente a más antigua con autor y valores de cada momento", async () => {
|
||||
const initial = { ...workstation.values, name: "Puesto original" };
|
||||
const renamed = {
|
||||
...initial,
|
||||
name: "Puesto renombrado",
|
||||
computerAssetId: companyId,
|
||||
assetIds: [companyId],
|
||||
personId: person.id,
|
||||
siteId: companyId,
|
||||
};
|
||||
const inactive = { ...initial, name: "Puesto desactivado", active: false };
|
||||
const revisions = [
|
||||
{
|
||||
id: "0199b200-0000-7000-8000-000000000071",
|
||||
actorId: companyId,
|
||||
actorName: "David",
|
||||
recordedAt: "2026-10-10T10:00:00.000Z",
|
||||
values: initial,
|
||||
},
|
||||
{
|
||||
id: "0199b200-0000-7000-8000-000000000072",
|
||||
actorId: companyId,
|
||||
actorName: "Ana",
|
||||
recordedAt: "2026-10-11T10:00:00.000Z",
|
||||
values: renamed,
|
||||
},
|
||||
{
|
||||
id: "0199b200-0000-7000-8000-000000000073",
|
||||
actorId: companyId,
|
||||
actorName: "David",
|
||||
recordedAt: "2026-10-12T10:00:00.000Z",
|
||||
values: inactive,
|
||||
},
|
||||
];
|
||||
const station = WorkstationSchema.parse({ ...workstation, values: inactive, history: revisions });
|
||||
|
||||
for (const language of ["es", "en"]) {
|
||||
const labels = language === "es" ? assetsSpanish : assetsEnglish;
|
||||
const html = await render(language, true, {
|
||||
node: (
|
||||
<WorkstationHistory
|
||||
members={() => "PC histórico"}
|
||||
personName={() => "Ana responsable"}
|
||||
siteName={() => "Fábrica"}
|
||||
workstation={station}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
assert.ok(html.includes(`aria-label="${labels.workstationHistory}"`));
|
||||
assert.ok(html.indexOf("Puesto desactivado") < html.indexOf("Puesto renombrado"));
|
||||
assert.ok(html.indexOf("Puesto renombrado") < html.indexOf("Puesto original"));
|
||||
assert.ok(html.includes(labels.workstationDetail.created));
|
||||
assert.ok(html.includes(labels.workstationDetail.updated));
|
||||
assert.ok(html.includes(labels.active));
|
||||
assert.ok(html.includes(labels.inactive));
|
||||
assert.ok(html.includes("Ana responsable"));
|
||||
assert.ok(html.includes("Fábrica"));
|
||||
assert.ok(html.includes("PC histórico"));
|
||||
assert.ok(html.includes('dateTime="2026-10-12T10:00:00.000Z"'));
|
||||
assert.ok(html.includes(labels.recordedBy.replace("{{name}}", "Ana")));
|
||||
assert.equal(station.history[0]?.values.name, "Puesto original");
|
||||
}
|
||||
});
|
||||
|
||||
test("personas es/en: enlaces a todos sus puestos bajo responsabilidad, sin confundirlos con puestos usados", async () => {
|
||||
const anotherPerson = { ...person, id: "0199b200-0000-7000-8000-000000000081", name: "Bea" };
|
||||
const first = {
|
||||
...workstation,
|
||||
values: { ...workstation.values, name: "Puesto principal de Ana", personId: person.id },
|
||||
};
|
||||
const second = {
|
||||
...first,
|
||||
id: "0199b200-0000-7000-8000-000000000082",
|
||||
values: { ...first.values, name: "Puesto de recepción" },
|
||||
};
|
||||
const inactive = {
|
||||
...first,
|
||||
id: "0199b200-0000-7000-8000-000000000083",
|
||||
values: { ...first.values, name: "Puesto archivado", active: false },
|
||||
};
|
||||
const foreign = {
|
||||
...first,
|
||||
id: "0199b200-0000-7000-8000-000000000084",
|
||||
companyId: anotherPerson.id,
|
||||
values: { ...first.values, name: "Puesto de otra compañía" },
|
||||
};
|
||||
const used = {
|
||||
...workstation,
|
||||
id: "0199b200-0000-7000-8000-000000000085",
|
||||
values: {
|
||||
...workstation.values,
|
||||
name: "Puesto solo utilizado",
|
||||
computerAssetId: companyId,
|
||||
assetIds: [companyId],
|
||||
},
|
||||
};
|
||||
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(language, true, {
|
||||
node: <PeopleSection company={{ ...company, people: [person, anotherPerson] }} />,
|
||||
workstations: [first, second, inactive, foreign, used],
|
||||
profiles: [userProfile(86, companyId, person.id, true)],
|
||||
});
|
||||
const labels = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
const rows = [...html.matchAll(/<tr\b[^>]*>[\s\S]*?<\/tr>/g)].map(([row]) => row);
|
||||
const ana = rows.find((row) =>
|
||||
row.includes(`href="/companies/${companyId}/people/${person.id}"`)
|
||||
);
|
||||
const bea = rows.find((row) =>
|
||||
row.includes(`href="/companies/${companyId}/people/${anotherPerson.id}"`)
|
||||
);
|
||||
|
||||
assert.ok(html.includes(labels.responsibleWorkstations));
|
||||
assert.ok(ana);
|
||||
assert.ok(bea);
|
||||
for (const station of [first, second]) {
|
||||
assert.ok(ana.includes(`href="/companies/${companyId}/workstations/${station.id}"`));
|
||||
assert.ok(ana.includes(station.values.name));
|
||||
}
|
||||
assert.equal(bea.includes(`/workstations/${first.id}`), false);
|
||||
assert.equal(bea.includes(`/workstations/${second.id}`), false);
|
||||
assert.ok(bea.includes("—"));
|
||||
for (const station of [inactive, foreign, used]) {
|
||||
assert.equal(html.includes(station.values.name), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("menú de compañía es/en: totales en badges alineados y cero real en secciones vacías", async () => {
|
||||
const first = asset(40, "PC");
|
||||
const second = asset(41, "Monitor");
|
||||
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(language, true, {
|
||||
node: (
|
||||
<CompanyDetail
|
||||
embedded
|
||||
id={companyId}
|
||||
onBack={() => undefined}
|
||||
onSectionChange={() => undefined}
|
||||
section="catalogs"
|
||||
/>
|
||||
),
|
||||
assets: [first, second],
|
||||
workstations: [workstation],
|
||||
profiles: [userProfile(50, first.id, person.id, true)],
|
||||
people: [person],
|
||||
});
|
||||
const nav = html.match(/<nav\b[^>]*>[\s\S]*?<\/nav>/)?.[0];
|
||||
|
||||
assert.ok(nav);
|
||||
const links = [...nav.matchAll(/<a\b[^>]*>[\s\S]*?<\/a>/g)].map(([link]) => link);
|
||||
|
||||
for (const [section, count] of [
|
||||
["assets", 2],
|
||||
["resources", 0],
|
||||
["workstations", 1],
|
||||
["work-profiles", 1],
|
||||
["people", 1],
|
||||
["departments", 0],
|
||||
["sites", 0],
|
||||
] as const) {
|
||||
const link = links.find((item) => item.includes(`href="/companies/${companyId}/${section}"`));
|
||||
|
||||
assert.ok(link, section);
|
||||
assert.ok(link.includes('data-slot="badge"'), section);
|
||||
assert.ok(link.includes(`>${count}</span>`), section);
|
||||
assert.ok(link.includes("ms-auto"), section);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { BrowserLink } from "@metis/core/client";
|
||||
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
|
||||
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { PersonIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { PersonDTO } from "../../../../common";
|
||||
@ -30,7 +30,7 @@ export function WorkstationPeople({
|
||||
<DetailPanel
|
||||
count={loading || failed ? undefined : people.length}
|
||||
description={t("workstationDetail.peopleHint")}
|
||||
icon={<UsersIcon />}
|
||||
icon={<PersonIcon />}
|
||||
title={t("workstationDetail.people")}
|
||||
>
|
||||
{loading && (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { BrowserLink } from "@metis/core/client";
|
||||
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
|
||||
import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list";
|
||||
import { TriangleAlertIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { TriangleAlertIcon, WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -35,12 +35,13 @@ export function WorkstationProfiles({
|
||||
<DetailPanel
|
||||
actions={
|
||||
<Button disabled={!canCreate} onClick={onCreate} type="button">
|
||||
<WorkProfileIcon aria-hidden="true" />
|
||||
{companies("profiles.new")}
|
||||
</Button>
|
||||
}
|
||||
count={loading || failed ? undefined : profiles.length}
|
||||
description={t("workstationDetail.profilesHint")}
|
||||
icon={<UsersIcon />}
|
||||
icon={<WorkProfileIcon />}
|
||||
title={t("workstationDetail.profiles")}
|
||||
>
|
||||
{!canCreate && (
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -23,10 +24,14 @@ export function UserManagement() {
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button onClick={() => setCreateOpen(true)}>{t("createUser")}</Button>{" "}
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<UsersIcon aria-hidden="true" />
|
||||
{t("createUser")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
description={t("usersHelp")}
|
||||
icon={<UsersIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("users")}
|
||||
/>
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
"notes": "Notes",
|
||||
"characteristics": "Characteristics",
|
||||
"chooseValue": "Select a value",
|
||||
"noValue": "No data",
|
||||
"noValue": "—",
|
||||
"history": "History",
|
||||
"recordedBy": "Recorded by {{name}}",
|
||||
"snapshotNotice": "This record retains the template fields captured when the resource was created.",
|
||||
@ -215,4 +215,4 @@
|
||||
"fieldsDescription": "Complete the template details. Fields marked with * are required.",
|
||||
"nameDescription": "Review the suggested name and add notes if you need to provide more context."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,7 @@
|
||||
"notes": "Notas",
|
||||
"characteristics": "Características",
|
||||
"chooseValue": "Selecciona un valor",
|
||||
"noValue": "Sin datos",
|
||||
"noValue": "—",
|
||||
"history": "Historial",
|
||||
"recordedBy": "Registrado por {{name}}",
|
||||
"snapshotNotice": "La ficha conserva los campos de la plantilla utilizados al dar de alta el recurso.",
|
||||
@ -215,4 +215,4 @@
|
||||
"fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
|
||||
"nameDescription": "Revisa el nombre propuesto y añade notas si necesitas aportar más contexto."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list";
|
||||
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -33,11 +34,13 @@ export function ResourceCatalogPage(props: ResourceCatalogPageProps) {
|
||||
actions={
|
||||
<>
|
||||
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||
<LayoutTemplateIcon aria-hidden="true" />
|
||||
{t({ field: "newField", template: "newTemplate" }[tab])}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
|
||||
icon={<LayoutTemplateIcon />}
|
||||
locale={i18n.language}
|
||||
title={t(scope === "global" ? "title" : "companyTitle")}
|
||||
/>
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
|
||||
import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
|
||||
import { LayersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -73,11 +74,13 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
|
||||
<LayersIcon aria-hidden="true" />
|
||||
{t("newResource")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
description={t("resourceScope")}
|
||||
icon={<LayersIcon />}
|
||||
locale={i18n.language}
|
||||
title={t("inventory")}
|
||||
/>
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/selection-components.test.tsx src/entity-list.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/selection-components.test.tsx src/entity-list.test.tsx src/time-line.test.tsx src/count-badge.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.8.0",
|
||||
|
||||
31
packages/rdx-ui/src/components/count-badge.tsx
Normal file
31
packages/rdx-ui/src/components/count-badge.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { Badge } from "@repo/shadcn-ui/components/badge";
|
||||
|
||||
/** Contador compacto; el consumidor aporta el total y los textos de sus estados remotos. */
|
||||
export function CountBadge({
|
||||
count,
|
||||
locale,
|
||||
loadingLabel,
|
||||
errorLabel,
|
||||
failed = false,
|
||||
className,
|
||||
}: {
|
||||
count: number | undefined;
|
||||
locale: string;
|
||||
loadingLabel: string;
|
||||
errorLabel: string;
|
||||
failed?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const status = failed ? errorLabel : count === undefined ? loadingLabel : undefined;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
aria-label={count === undefined ? status : undefined}
|
||||
className={`min-w-6 px-1.5 tabular-nums ${className ?? ""}`}
|
||||
title={status}
|
||||
variant="secondary"
|
||||
>
|
||||
{count === undefined ? "—" : new Intl.NumberFormat(locale).format(count)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,8 @@
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@repo/shadcn-ui/components/accordion";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ChevronRightIcon } from "../lib/icons";
|
||||
@ -5,7 +10,32 @@ import { ChevronRightIcon } from "../lib/icons";
|
||||
import { FormSectionTitle } from "./form-section-title";
|
||||
|
||||
/** Grupo de consulta compartido por fichas y plantillas; abierto inicialmente. */
|
||||
export function DetailSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
export function DetailSection({
|
||||
title,
|
||||
children,
|
||||
accordionValue,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
accordionValue?: string | undefined;
|
||||
}) {
|
||||
const content = (
|
||||
<div className="space-y-3 border-t border-border/60 border-b-0 px-5 pb-2 [&>dl>div:first-child]:pt-2.5 [&>dl>div:last-child]:pb-2.5">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (accordionValue !== undefined) {
|
||||
return (
|
||||
<AccordionItem className="@container" value={accordionValue}>
|
||||
<AccordionTrigger className="items-center py-2 hover:no-underline">
|
||||
<FormSectionTitle as="span">{title}</FormSectionTitle>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-0">{content}</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="@container group/detail-section" open>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 py-2 focus-visible:outline-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden">
|
||||
@ -15,9 +45,7 @@ export function DetailSection({ title, children }: { title: string; children: Re
|
||||
/>
|
||||
<FormSectionTitle as="span">{title}</FormSectionTitle>
|
||||
</summary>
|
||||
<div className="space-y-3 border-t border-border/60 px-5 pb-2 [&>dl>div:first-child]:pt-2.5 [&>dl>div:last-child]:pb-2.5">
|
||||
{children}
|
||||
</div>
|
||||
{content}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,18 +15,30 @@ export { EntityAction, EntityRowActions, EntityStatus } from "./entity-list-cont
|
||||
export function EntityListHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
actions,
|
||||
locale = "es",
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
locale?: string;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-2xl font-semibold">{title}</h2>
|
||||
<h2 className="flex items-center gap-2 text-2xl font-semibold">
|
||||
{icon && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex size-6 shrink-0 items-center justify-center [&>svg]:size-6"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-1 text-base font-medium text-muted-foreground">
|
||||
{description ??
|
||||
`${entityListLabels(locale).description} ${title.toLocaleLowerCase(locale)}.`}
|
||||
|
||||
@ -151,6 +151,12 @@ function entityColumnClass(columnId: string, pinned: boolean, header: boolean) {
|
||||
return `w-px min-w-0 py-3 pr-1 pl-4 text-center whitespace-nowrap [&:has([role=checkbox])]:pr-1 ${labelVisibility}`;
|
||||
}
|
||||
|
||||
if (columnId === "icon") {
|
||||
const labelVisibility = header ? "[&>span]:sr-only bg-background" : "";
|
||||
|
||||
return `w-px min-w-0 py-3 pr-1 pl-3 text-center whitespace-nowrap ${labelVisibility}`;
|
||||
}
|
||||
|
||||
if (pinned) {
|
||||
return header
|
||||
? "sticky right-0 z-20 w-20 min-w-20 bg-card pr-4 text-right"
|
||||
|
||||
@ -19,6 +19,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
|
||||
values = false,
|
||||
hasErrors = false,
|
||||
layout = "fields",
|
||||
sectionPresentation = "details",
|
||||
}: {
|
||||
sections?: readonly FieldSection[] | undefined;
|
||||
items: readonly T[];
|
||||
@ -27,6 +28,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
|
||||
values?: boolean;
|
||||
hasErrors?: boolean;
|
||||
layout?: FieldSectionLayout;
|
||||
sectionPresentation?: "details" | "accordion";
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const styles = fieldSectionStyles(layout, values);
|
||||
@ -85,7 +87,11 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
|
||||
|
||||
if (layout === "rows") {
|
||||
return (
|
||||
<DetailSection key={section.id} title={section.name}>
|
||||
<DetailSection
|
||||
accordionValue={sectionPresentation === "accordion" ? section.id : undefined}
|
||||
key={section.id}
|
||||
title={section.name}
|
||||
>
|
||||
{section.description && (
|
||||
<p className="pt-2 text-sm text-muted-foreground">{section.description}</p>
|
||||
)}
|
||||
@ -112,14 +118,19 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
{ungrouped.length > 0 && (
|
||||
<section className="col-span-full space-y-3">
|
||||
<div className={styles.compact ? "pl-5" : undefined}>
|
||||
<FormSectionTitle as="h4">{ungroupedLabel}</FormSectionTitle>
|
||||
</div>
|
||||
{content(ungrouped)}
|
||||
</section>
|
||||
)}
|
||||
{ungrouped.length > 0 &&
|
||||
(sectionPresentation === "accordion" && layout === "rows" ? (
|
||||
<DetailSection accordionValue="ungrouped" title={ungroupedLabel}>
|
||||
{content(ungrouped)}
|
||||
</DetailSection>
|
||||
) : (
|
||||
<section className="col-span-full space-y-3">
|
||||
<div className={styles.compact ? "pl-5" : undefined}>
|
||||
<FormSectionTitle as="h4">{ungroupedLabel}</FormSectionTitle>
|
||||
</div>
|
||||
{content(ungrouped)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
55
packages/rdx-ui/src/components/time-line.tsx
Normal file
55
packages/rdx-ui/src/components/time-line.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type TimeLineItem = {
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
dateTime: string;
|
||||
dateLabel: string;
|
||||
meta?: ReactNode;
|
||||
content?: ReactNode;
|
||||
};
|
||||
|
||||
/** Secuencia vertical: el consumidor aporta orden, fechas formateadas y contenido de cada evento. */
|
||||
export function TimeLine({
|
||||
items,
|
||||
label,
|
||||
emptyMessage,
|
||||
}: {
|
||||
items: readonly TimeLineItem[];
|
||||
label: string;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ol aria-label={label} className="min-w-0">
|
||||
{items.map((item, index) => (
|
||||
<li className="relative min-w-0 ps-8 pb-6 last:pb-0" key={item.id}>
|
||||
{index < items.length - 1 && (
|
||||
<span aria-hidden="true" className="absolute start-2 top-3 -bottom-3 w-px bg-border" />
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute start-0.5 top-1.5 size-3 rounded-full border-2 border-background bg-primary ring-1 ring-primary/30"
|
||||
/>
|
||||
<article className="min-w-0 space-y-3 rounded-lg border bg-card p-4 text-card-foreground">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<h3 className="min-w-0 break-words text-sm font-semibold">{item.title}</h3>
|
||||
<time className="text-xs text-muted-foreground" dateTime={item.dateTime}>
|
||||
{item.dateLabel}
|
||||
</time>
|
||||
</div>
|
||||
{item.meta && <div className="text-xs text-muted-foreground">{item.meta}</div>}
|
||||
{item.content}
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
50
packages/rdx-ui/src/count-badge.test.tsx
Normal file
50
packages/rdx-ui/src/count-badge.test.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CountBadge } from "./components/count-badge";
|
||||
|
||||
test("contador: conserva cero y formatea el total por idioma", () => {
|
||||
for (const [locale, expected] of [
|
||||
["es", "12.345"],
|
||||
["en", "12,345"],
|
||||
] as const) {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CountBadge count={12345} errorLabel="Failed" loadingLabel="Loading" locale={locale} />
|
||||
);
|
||||
|
||||
assert.ok(markup.includes(expected));
|
||||
assert.equal(markup.includes('title="Loading"'), false);
|
||||
}
|
||||
const empty = renderToStaticMarkup(
|
||||
<CountBadge count={0} errorLabel="Failed" loadingLabel="Loading" locale="es" />
|
||||
);
|
||||
|
||||
assert.ok(empty.includes(">0</span>"));
|
||||
});
|
||||
|
||||
test("contador: carga y error muestran ausencia de total, conservando una cifra anterior si existe", () => {
|
||||
const loading = renderToStaticMarkup(
|
||||
<CountBadge count={undefined} errorLabel="Failed" loadingLabel="Loading total" locale="en" />
|
||||
);
|
||||
const failed = renderToStaticMarkup(
|
||||
<CountBadge
|
||||
count={undefined}
|
||||
errorLabel="Failed"
|
||||
failed
|
||||
loadingLabel="Loading total"
|
||||
locale="en"
|
||||
/>
|
||||
);
|
||||
const stale = renderToStaticMarkup(
|
||||
<CountBadge count={7} errorLabel="Failed" failed loadingLabel="Loading total" locale="en" />
|
||||
);
|
||||
|
||||
assert.ok(loading.includes('aria-label="Loading total"'));
|
||||
assert.ok(loading.includes("—"));
|
||||
assert.ok(failed.includes('aria-label="Failed"'));
|
||||
assert.ok(failed.includes("—"));
|
||||
assert.ok(stale.includes(">7</span>"));
|
||||
assert.ok(stale.includes('title="Failed"'));
|
||||
});
|
||||
@ -25,6 +25,7 @@ export {
|
||||
IconGripVertical as GripVerticalIcon,
|
||||
IconHeadphones as HeadphonesIcon,
|
||||
IconHome as HomeIcon,
|
||||
IconIdBadge2 as WorkProfileIcon,
|
||||
IconInfoCircle as InfoIcon,
|
||||
IconKey as KeyRoundIcon,
|
||||
IconKeyboard as KeyboardIcon,
|
||||
@ -48,6 +49,7 @@ export {
|
||||
IconTool as WrenchIcon,
|
||||
IconTrash as Trash2Icon,
|
||||
IconUserX as UserRoundXIcon,
|
||||
IconUsers as PersonIcon,
|
||||
IconUsers as UsersIcon,
|
||||
IconWifi as WifiIcon,
|
||||
IconX as XIcon,
|
||||
|
||||
51
packages/rdx-ui/src/time-line.test.tsx
Normal file
51
packages/rdx-ui/src/time-line.test.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { TimeLine } from "./components/time-line";
|
||||
|
||||
test("línea temporal: conserva el orden aportado, fechas semánticas, autor y contenido", () => {
|
||||
const items = [
|
||||
{
|
||||
id: "latest",
|
||||
title: "Cambio reciente",
|
||||
dateTime: "2026-10-16T10:00:00.000Z",
|
||||
dateLabel: "16 oct 2026",
|
||||
meta: "Ana",
|
||||
content: <p>Estado actualizado</p>,
|
||||
},
|
||||
{
|
||||
id: "first",
|
||||
title: "Alta original",
|
||||
dateTime: "2026-10-10T10:00:00.000Z",
|
||||
dateLabel: "10 oct 2026",
|
||||
},
|
||||
];
|
||||
const html = renderToStaticMarkup(
|
||||
<TimeLine emptyMessage="Sin eventos" items={items} label="Historial" />
|
||||
);
|
||||
|
||||
assert.ok(html.includes('<ol aria-label="Historial"'));
|
||||
assert.equal((html.match(/<li\b/g) ?? []).length, 2);
|
||||
assert.ok(html.indexOf("Cambio reciente") < html.indexOf("Alta original"));
|
||||
assert.ok(html.includes('dateTime="2026-10-16T10:00:00.000Z"'));
|
||||
assert.ok(html.includes("16 oct 2026"));
|
||||
assert.ok(html.includes("Ana"));
|
||||
assert.ok(html.includes("Estado actualizado"));
|
||||
assert.equal(html.includes("Sin eventos"), false);
|
||||
assert.deepEqual(
|
||||
items.map((item) => item.id),
|
||||
["latest", "first"]
|
||||
);
|
||||
});
|
||||
|
||||
test("línea temporal vacía: muestra el mensaje del consumidor como estado", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TimeLine emptyMessage="No events yet" items={[]} label="History" />
|
||||
);
|
||||
|
||||
assert.ok(html.includes('role="status"'));
|
||||
assert.ok(html.includes("No events yet"));
|
||||
assert.equal(html.includes("<ol"), false);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user