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",
|
"retired": "Retired",
|
||||||
"noAssets": "This company has no assets yet.",
|
"noAssets": "This company has no assets yet.",
|
||||||
"noAssignment": "Unassigned",
|
"noAssignment": "Unassigned",
|
||||||
"noValue": "Not specified",
|
"noValue": "—",
|
||||||
"characteristics": "Characteristics",
|
"characteristics": "Characteristics",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"recordedBy": "Recorded by {{name}}",
|
"recordedBy": "Recorded by {{name}}",
|
||||||
|
|||||||
@ -108,7 +108,7 @@
|
|||||||
"retired": "Retirado",
|
"retired": "Retirado",
|
||||||
"noAssets": "Esta compañía todavía no tiene activos.",
|
"noAssets": "Esta compañía todavía no tiene activos.",
|
||||||
"noAssignment": "Sin asignar",
|
"noAssignment": "Sin asignar",
|
||||||
"noValue": "Sin indicar",
|
"noValue": "—",
|
||||||
"characteristics": "Características",
|
"characteristics": "Características",
|
||||||
"history": "Historial",
|
"history": "Historial",
|
||||||
"recordedBy": "Registrado por {{name}}",
|
"recordedBy": "Registrado por {{name}}",
|
||||||
|
|||||||
@ -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.
|
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
|
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.
|
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
|
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
|
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
|
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
|
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.
|
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.
|
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
|
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.
|
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é
|
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
|
de inventario y la invalidación posterior consulta el estado definitivo. Se mantiene el
|
||||||
tratamiento habitual de CSRF y de errores de inventario.
|
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.
|
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:
|
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.
|
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).
|
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.
|
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;
|
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.
|
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
|
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
|
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.
|
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
|
ordenador principal incluido en su composición y no retirado; el bloqueo explica cómo
|
||||||
completar el puesto.
|
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,
|
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
|
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
|
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
|
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
|
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.
|
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
|
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).
|
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
|
## 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.
|
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.
|
Anterior conserva valores y Siguiente valida los requisitos del paso. Guardar solo aparece en el resumen y no se envía el alta desde pasos intermedios. La edición mantiene un formulario único y permite conservar/completar puestos anteriores o desactivados. Cancelar permanece en el pie del modal y la ayuda se mantiene anclada inmediatamente encima, fuera del scroll de campos. Los ejemplos extensos tienen desplazamiento propio limitado al espacio disponible.
|
||||||
|
|
||||||
Se elimina `sharedAssetIds` de contratos, dominio, reservas y vistas. La migración explícita `removeSharedWorkstationAssets`, ejecutada durante la preparación del esquema, elimina las asociaciones de tipo compartido tanto actuales como históricas y limpia sus referencias técnicas cuando no existe ninguna asociación exclusiva histórica. No convierte activos compartidos en exclusivos, no borra activos ni revisiones y no genera reservas conflictivas. Es transaccional e idempotente. Las composiciones exclusivas e identidades se conservan.
|
Se elimina `sharedAssetIds` de contratos, dominio, reservas y vistas. La migración explícita `removeSharedWorkstationAssets`, ejecutada durante la preparación del esquema, elimina las asociaciones de tipo compartido tanto actuales como históricas y limpia sus referencias técnicas cuando no existe ninguna asociación exclusiva histórica. No convierte activos compartidos en exclusivos, no borra activos ni revisiones y no genera reservas conflictivas. Es transaccional e idempotente. Las composiciones exclusivas e identidades se conservan.
|
||||||
|
|
||||||
|
|
||||||
|
## 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": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
|
|||||||
@ -97,6 +97,12 @@
|
|||||||
"deletingAsset": "Deleting asset…",
|
"deletingAsset": "Deleting asset…",
|
||||||
"deleteAssetError": "The asset could not be deleted. Please try again.",
|
"deleteAssetError": "The asset could not be deleted. Please try again.",
|
||||||
"viewAsset": "Details and history",
|
"viewAsset": "Details and history",
|
||||||
|
"assetWorkstation": {
|
||||||
|
"create": "Create workstation",
|
||||||
|
"view": "View workstation",
|
||||||
|
"viewNamed": "View workstation: {{name}}"
|
||||||
|
},
|
||||||
|
"assetType": "Asset type",
|
||||||
"template": "Template",
|
"template": "Template",
|
||||||
"site": "Site",
|
"site": "Site",
|
||||||
"person": "Assigned person",
|
"person": "Assigned person",
|
||||||
@ -108,7 +114,7 @@
|
|||||||
"retired": "Retired",
|
"retired": "Retired",
|
||||||
"noAssets": "This company has no assets yet.",
|
"noAssets": "This company has no assets yet.",
|
||||||
"noAssignment": "Unassigned",
|
"noAssignment": "Unassigned",
|
||||||
"noValue": "Not specified",
|
"noValue": "—",
|
||||||
"characteristics": "Characteristics",
|
"characteristics": "Characteristics",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"recordedBy": "Recorded by {{name}}",
|
"recordedBy": "Recorded by {{name}}",
|
||||||
@ -386,7 +392,11 @@
|
|||||||
"profilePerson": "Has a profile on the main computer",
|
"profilePerson": "Has a profile on the main computer",
|
||||||
"needsReview": "References awaiting review",
|
"needsReview": "References awaiting review",
|
||||||
"missingAsset": "Asset unavailable.",
|
"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",
|
"software": "Computer software",
|
||||||
"sections": "Workstation sections",
|
"sections": "Workstation sections",
|
||||||
"peripherals": "Other assets and peripherals",
|
"peripherals": "Other assets and peripherals",
|
||||||
|
|||||||
@ -97,6 +97,12 @@
|
|||||||
"deletingAsset": "Eliminando activo…",
|
"deletingAsset": "Eliminando activo…",
|
||||||
"deleteAssetError": "No se ha podido eliminar el activo. Inténtalo de nuevo.",
|
"deleteAssetError": "No se ha podido eliminar el activo. Inténtalo de nuevo.",
|
||||||
"viewAsset": "Ficha e historial",
|
"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",
|
"template": "Plantilla",
|
||||||
"site": "Sede",
|
"site": "Sede",
|
||||||
"person": "Persona asignada",
|
"person": "Persona asignada",
|
||||||
@ -108,7 +114,7 @@
|
|||||||
"retired": "Retirado",
|
"retired": "Retirado",
|
||||||
"noAssets": "Esta compañía todavía no tiene activos.",
|
"noAssets": "Esta compañía todavía no tiene activos.",
|
||||||
"noAssignment": "Sin asignar",
|
"noAssignment": "Sin asignar",
|
||||||
"noValue": "Sin indicar",
|
"noValue": "—",
|
||||||
"characteristics": "Características",
|
"characteristics": "Características",
|
||||||
"history": "Historial",
|
"history": "Historial",
|
||||||
"recordedBy": "Registrado por {{name}}",
|
"recordedBy": "Registrado por {{name}}",
|
||||||
@ -386,7 +392,11 @@
|
|||||||
"profilePerson": "Con perfil en el ordenador principal",
|
"profilePerson": "Con perfil en el ordenador principal",
|
||||||
"needsReview": "Referencias pendientes de revisión",
|
"needsReview": "Referencias pendientes de revisión",
|
||||||
"missingAsset": "Activo no disponible.",
|
"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",
|
"software": "Software del ordenador",
|
||||||
"sections": "Secciones del puesto de trabajo",
|
"sections": "Secciones del puesto de trabajo",
|
||||||
"peripherals": "Otros activos y periféricos",
|
"peripherals": "Otros activos y periféricos",
|
||||||
|
|||||||
@ -29,6 +29,30 @@ export function AssetDetail(props: AssetDetailProps) {
|
|||||||
? t(value ? "yes" : "no")
|
? t(value ? "yes" : "no")
|
||||||
: String(value);
|
: 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 (
|
return (
|
||||||
<div className="@container space-y-5">
|
<div className="@container space-y-5">
|
||||||
{!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>}
|
{!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>}
|
||||||
@ -44,7 +68,10 @@ export function AssetDetail(props: AssetDetailProps) {
|
|||||||
</dl>
|
</dl>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
)}
|
)}
|
||||||
<DetailSection title={t("detailSections.assignment")}>
|
<DetailSection
|
||||||
|
accordionValue={compact ? "assignment" : undefined}
|
||||||
|
title={t("detailSections.assignment")}
|
||||||
|
>
|
||||||
<dl>
|
<dl>
|
||||||
<DetailField label={t("registeredOn")}>
|
<DetailField label={t("registeredOn")}>
|
||||||
{formatAssetRegistrationDate(asset.values.registeredOn, i18n.language) ?? t("noValue")}
|
{formatAssetRegistrationDate(asset.values.registeredOn, i18n.language) ?? t("noValue")}
|
||||||
@ -75,33 +102,23 @@ export function AssetDetail(props: AssetDetailProps) {
|
|||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
{compact && !asset.specification.sections?.length ? (
|
||||||
|
<DetailSection accordionValue="characteristics" title={t("characteristics")}>
|
||||||
|
{characteristics}
|
||||||
|
</DetailSection>
|
||||||
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{!asset.specification.sections?.length && (
|
{!asset.specification.sections?.length && (
|
||||||
<FormSectionTitle>{t("characteristics")}</FormSectionTitle>
|
<FormSectionTitle>{t("characteristics")}</FormSectionTitle>
|
||||||
)}
|
)}
|
||||||
<FieldSections
|
{characteristics}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
sections={asset.specification.sections}
|
|
||||||
ungroupedLabel={t("sections.ungrouped")}
|
|
||||||
values
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{asset.values.notes && (
|
{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>
|
<p className="whitespace-pre-wrap break-words text-sm">{asset.values.notes}</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { DetailSection } from "@repo/rdx-ui/components/detail-section";
|
import { DetailSection } from "@repo/rdx-ui/components/detail-section";
|
||||||
import { SelectionPanel } from "@repo/rdx-ui/components/selection-panel";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -70,9 +71,23 @@ export function AssetSelectionPanel({
|
|||||||
{asset.values.name}
|
{asset.values.name}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
<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}>
|
<AssetDetail asset={asset} compact lookups={lookups}>
|
||||||
<DetailSection title={t("relations")}>
|
<DetailSection accordionValue="relations" title={t("relations")}>
|
||||||
<AssetRelationships
|
<AssetRelationships
|
||||||
assetId={asset.id}
|
assetId={asset.id}
|
||||||
compact
|
compact
|
||||||
@ -80,7 +95,7 @@ export function AssetSelectionPanel({
|
|||||||
sites={lookups.sites}
|
sites={lookups.sites}
|
||||||
/>
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
<DetailSection title={t("support.title")}>
|
<DetailSection accordionValue="support" title={t("support.title")}>
|
||||||
<AssetSupport
|
<AssetSupport
|
||||||
assetId={asset.id}
|
assetId={asset.id}
|
||||||
compact
|
compact
|
||||||
@ -89,6 +104,7 @@ export function AssetSelectionPanel({
|
|||||||
/>
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</AssetDetail>
|
</AssetDetail>
|
||||||
|
</Accordion>
|
||||||
</SelectionPanel>
|
</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 [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
|
||||||
|
const [workstationAssetId, setWorkstationAssetId] = useState<string | undefined>();
|
||||||
const [deleteId, setDeleteId] = useState<string | undefined>();
|
const [deleteId, setDeleteId] = useState<string | undefined>();
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => removeAsset(companyId, id),
|
mutationFn: (id: string) => removeAsset(companyId, id),
|
||||||
@ -129,6 +130,8 @@ export function useAssetInventoryPageController({
|
|||||||
setDetailOpen,
|
setDetailOpen,
|
||||||
selected,
|
selected,
|
||||||
edited,
|
edited,
|
||||||
|
workstationAsset: assets.find((asset) => asset.id === workstationAssetId),
|
||||||
|
setWorkstationAssetId,
|
||||||
viewed,
|
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 {
|
import {
|
||||||
EntityAction,
|
EntityAction,
|
||||||
EntityListHeader,
|
EntityListHeader,
|
||||||
EntityRowActions,
|
EntityRowActions,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { BoxIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CatalogError } from "../../../catalog";
|
import { CatalogError } from "../../../catalog";
|
||||||
import { InventoryFeedback } from "../../../shared";
|
import { InventoryFeedback } from "../../../shared";
|
||||||
|
import { WorkstationEditorDialog } from "../../../workstations/shared/ui/workstation-editor-dialog";
|
||||||
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
|
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
|
||||||
import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
|
import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
|
||||||
import { AssetList } from "../../shared/ui/asset-list";
|
import { AssetList } from "../../shared/ui/asset-list";
|
||||||
import { useAssetInventoryPageController } from "../controllers";
|
import { useAssetInventoryPageController } from "../controllers";
|
||||||
import type { AssetInventoryPageProps } from "../types";
|
import type { AssetInventoryPageProps } from "../types";
|
||||||
|
|
||||||
|
import { AssetWorkstationAction } from "./asset-workstation-action";
|
||||||
|
|
||||||
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
|
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
|
||||||
return id === selectedId && detailOpen
|
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"
|
? "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,
|
setSearch,
|
||||||
editor,
|
editor,
|
||||||
setEditor,
|
setEditor,
|
||||||
|
workstationAsset,
|
||||||
|
setWorkstationAssetId,
|
||||||
deleted,
|
deleted,
|
||||||
deleteId,
|
deleteId,
|
||||||
setDeleteId,
|
setDeleteId,
|
||||||
@ -76,11 +82,13 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
|
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
|
||||||
|
<BoxIcon aria-hidden="true" />
|
||||||
{t("newAsset")}
|
{t("newAsset")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>{" "}
|
</div>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
icon={<BoxIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("inventory")}
|
title={t("inventory")}
|
||||||
/>
|
/>
|
||||||
@ -103,6 +111,21 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
assets={query.data.assets}
|
assets={query.data.assets}
|
||||||
catalog={catalog.data?.items ?? []}
|
catalog={catalog.data?.items ?? []}
|
||||||
extraColumns={[
|
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",
|
id: "actions",
|
||||||
label: t("actions"),
|
label: t("actions"),
|
||||||
@ -117,6 +140,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
>
|
>
|
||||||
{t("viewAsset")}
|
{t("viewAsset")}
|
||||||
</EntityAction>
|
</EntityAction>
|
||||||
|
|
||||||
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
|
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
</EntityAction>
|
</EntityAction>
|
||||||
@ -172,12 +196,22 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
lookups={lookups}
|
lookups={lookups}
|
||||||
onClose={() => setEditor(undefined)}
|
onClose={() => setEditor(undefined)}
|
||||||
onSaved={(id) => {
|
onSaved={(id) => {
|
||||||
setSelectedId(id);
|
selectAsset(id);
|
||||||
setDetailOpen(true);
|
|
||||||
setEditor(undefined);
|
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 && (
|
{viewed && (
|
||||||
<AssetDetailDialog
|
<AssetDetailDialog
|
||||||
asset={viewed}
|
asset={viewed}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { ReactNode } from "react";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
|
||||||
|
import { CatalogItemSchema } from "../../../common";
|
||||||
import { AssetSchema } from "../../../common/inventory";
|
import { AssetSchema } from "../../../common/inventory";
|
||||||
import { assetsEnglish, assetsSpanish } from "../../shared";
|
import { assetsEnglish, assetsSpanish } from "../../shared";
|
||||||
import { AssetList } from "../shared/ui/asset-list";
|
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('data-pinned="right"'), false);
|
||||||
assert.equal(firstHeader?.includes("aria-sort"), false);
|
assert.equal(firstHeader?.includes("aria-sort"), false);
|
||||||
assert.ok(firstCell?.includes('role="checkbox"'));
|
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('aria-checked="true"'));
|
||||||
assert.ok(html.includes("Ocupado"));
|
assert.ok(html.includes("Ocupado"));
|
||||||
assert.ok(html.includes(language === "es" ? "Buscar" : "Search"));
|
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"]) {
|
for (const group of ["Ubicación y asignación", "Características", "Observaciones"]) {
|
||||||
assert.ok(html.includes(group));
|
assert.ok(html.includes(group));
|
||||||
}
|
}
|
||||||
assert.ok(html.includes("<summary"));
|
assert.ok(html.includes('data-slot="accordion"'));
|
||||||
assert.ok(html.includes('open=""'));
|
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}/sites"`));
|
||||||
assert.ok(html.includes(`href="/companies/${companyId}/catalogs/assets?tab=templates"`));
|
assert.ok(html.includes(`href="/companies/${companyId}/catalogs/assets?tab=templates"`));
|
||||||
assert.ok(html.includes("ABC123"));
|
assert.ok(html.includes("ABC123"));
|
||||||
assert.ok(html.includes("Fecha de alta"));
|
assert.ok(html.includes("Fecha de alta"));
|
||||||
assert.ok(html.includes("29 feb 2024"));
|
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 () => {
|
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 { CatalogItemDTO } from "../../../../common";
|
||||||
import type { AssetDTO } from "../../../../common/inventory";
|
import type { AssetDTO } from "../../../../common/inventory";
|
||||||
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
|
|
||||||
import { assetCharacteristicsSummary } from "../helpers/asset-characteristics-summary";
|
import { assetCharacteristicsSummary } from "../helpers/asset-characteristics-summary";
|
||||||
import { formatAssetRegistrationDate } from "../helpers/format-asset-registration-date";
|
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. */
|
/** Inventario y selectores comparten columnas, búsqueda, pestañas y paginación. */
|
||||||
export function AssetList({
|
export function AssetList({
|
||||||
assets,
|
assets,
|
||||||
@ -98,13 +99,31 @@ export function AssetList({
|
|||||||
<EntityTable
|
<EntityTable
|
||||||
columns={[
|
columns={[
|
||||||
...leadingColumns,
|
...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",
|
id: "name",
|
||||||
label: t("name"),
|
label: t("name"),
|
||||||
value: (asset) => asset.values.name,
|
value: (asset) => asset.values.name,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (asset) => {
|
render: (asset) => {
|
||||||
const summary = assetCharacteristicsSummary(asset, { yes: t("yes"), no: t("no") });
|
const summary = assetCharacteristicsSummary(asset, {
|
||||||
|
yes: t("yes"),
|
||||||
|
no: t("no"),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@ -126,38 +145,39 @@ export function AssetList({
|
|||||||
id: "template",
|
id: "template",
|
||||||
label: t("template"),
|
label: t("template"),
|
||||||
value: (asset) => asset.specification.templateName,
|
value: (asset) => asset.specification.templateName,
|
||||||
render: (asset) => {
|
render: (asset) => (
|
||||||
const template = catalog.find((item) => item.id === asset.values.templateId);
|
<span className="text-muted-foreground">{asset.specification.templateName}</span>
|
||||||
|
),
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<AssetTemplateIcon
|
|
||||||
icon={template?.values.kind === "template" ? template.values.icon : undefined}
|
|
||||||
/>
|
|
||||||
{asset.specification.templateName}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "state",
|
id: "state",
|
||||||
label: t("status"),
|
label: t("status"),
|
||||||
value: (asset) => t(asset.displayStatus),
|
value: (asset) => t(asset.displayStatus),
|
||||||
render: (asset) => (
|
render: (asset) => (
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
<EntityStatus active={asset.values.status === "operational"}>
|
<EntityStatus active={asset.values.status === "operational"}>
|
||||||
{t(asset.displayStatus)}
|
{t(asset.displayStatus)}
|
||||||
</EntityStatus>
|
</EntityStatus>
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{ id: "site", label: t("site"), value: siteName, render: siteName },
|
||||||
|
...extraColumns.filter((column) => column.id !== "actions"),
|
||||||
{
|
{
|
||||||
id: "registeredOn",
|
id: "registeredOn",
|
||||||
label: t("registeredOn"),
|
label: t("registeredOn"),
|
||||||
value: (asset) => asset.values.registeredOn ?? "",
|
value: (asset) => asset.values.registeredOn ?? "",
|
||||||
render: (asset) =>
|
render: (asset) => {
|
||||||
formatAssetRegistrationDate(asset.values.registeredOn, i18n.language),
|
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")}
|
emptyMessage={t("noAssets")}
|
||||||
filterKey={templateId ?? "all"}
|
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,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -31,10 +32,12 @@ export function BrandCatalogPage() {
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+ {t("brands.new")}
|
<Building2Icon aria-hidden="true" />
|
||||||
|
{t("brands.new")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
description={t("brands.introduction")}
|
description={t("brands.introduction")}
|
||||||
|
icon={<Building2Icon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("brands.title")}
|
title={t("brands.title")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -37,11 +38,13 @@ export function AssetCatalogPage(props: AssetCatalogPageProps) {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||||
|
<LayoutTemplateIcon aria-hidden="true" />
|
||||||
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
|
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
|
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
|
||||||
|
icon={<LayoutTemplateIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t(scope === "global" ? "title" : "companyTitle")}
|
title={t(scope === "global" ? "title" : "companyTitle")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export { inventoryApi } from "./shared/api";
|
|||||||
export { inventoryKeys } from "./shared/queries";
|
export { inventoryKeys } from "./shared/queries";
|
||||||
export { AssetSupport } from "./support-connections";
|
export { AssetSupport } from "./support-connections";
|
||||||
export { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations";
|
export { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations";
|
||||||
|
export { WorkstationHistory } from "./workstations/history/ui/workstation-history";
|
||||||
export { WorkstationNameEditor } from "./workstations/rename/ui/workstation-name-editor";
|
export { WorkstationNameEditor } from "./workstations/rename/ui/workstation-name-editor";
|
||||||
export { WorkstationActions } from "./workstations/shared/ui/workstation-actions";
|
export { WorkstationActions } from "./workstations/shared/ui/workstation-actions";
|
||||||
export { WorkstationLifecycleDialog } from "./workstations/shared/ui/workstation-lifecycle-dialog";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { WorkstationDTO } from "../../../../common";
|
import type { WorkstationHistoryProps } from "./workstation-history";
|
||||||
|
import { WorkstationHistory } from "./workstation-history";
|
||||||
|
|
||||||
export function WorkstationHistoryDialog({
|
export function WorkstationHistoryDialog({
|
||||||
workstation,
|
workstation,
|
||||||
@ -9,14 +10,8 @@ export function WorkstationHistoryDialog({
|
|||||||
siteName,
|
siteName,
|
||||||
members,
|
members,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: WorkstationHistoryProps & { onClose: () => void }) {
|
||||||
workstation: WorkstationDTO;
|
const { t } = useTranslation("assets");
|
||||||
personName: (id: string | null) => string;
|
|
||||||
siteName: (id: string | null) => string;
|
|
||||||
members: (ids: readonly string[], empty?: string) => string;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { t, i18n } = useTranslation("assets");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
@ -30,28 +25,14 @@ export function WorkstationHistoryDialog({
|
|||||||
title={t("workstationHistory")}
|
title={t("workstationHistory")}
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<ol className="space-y-4">
|
<FormBody>
|
||||||
{[...workstation.history].reverse().map((revision) => (
|
<WorkstationHistory
|
||||||
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
|
members={members}
|
||||||
<p className="font-medium">
|
personName={personName}
|
||||||
{new Intl.DateTimeFormat(i18n.language, {
|
siteName={siteName}
|
||||||
dateStyle: "medium",
|
workstation={workstation}
|
||||||
timeStyle: "short",
|
/>
|
||||||
}).format(new Date(revision.recordedAt))}{" "}
|
</FormBody>
|
||||||
· {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>
|
|
||||||
</FormDialog>
|
</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 { inventoryKeys } from "../../../shared/queries";
|
||||||
import type { WorkstationsPageProps } from "../types";
|
import type { WorkstationsPageProps } from "../types";
|
||||||
|
|
||||||
export function useWorkstationsPageController({ companyId, lookups }: WorkstationsPageProps) {
|
export function useWorkstationsPageController({
|
||||||
|
companyId,
|
||||||
|
lookups,
|
||||||
|
initialCreate = false,
|
||||||
|
}: WorkstationsPageProps) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [editor, setEditor] = useState<string>();
|
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
|
||||||
const [view, setView] = useState<string>();
|
const [view, setView] = useState<string>();
|
||||||
const query = useListQuery({
|
const query = useListQuery({
|
||||||
queryKey: inventoryKeys.company(companyId),
|
queryKey: inventoryKeys.company(companyId),
|
||||||
|
|||||||
@ -2,5 +2,7 @@ import type { InventoryLookups } from "../../../assets/shared/types";
|
|||||||
|
|
||||||
export type WorkstationsPageProps = {
|
export type WorkstationsPageProps = {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
initialCreate?: boolean | undefined;
|
||||||
|
initialPersonId?: string | undefined;
|
||||||
lookups: InventoryLookups;
|
lookups: InventoryLookups;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { MonitorIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -18,7 +19,7 @@ import { useWorkstationsPageController } from "../controllers";
|
|||||||
import type { WorkstationsPageProps } from "../types";
|
import type { WorkstationsPageProps } from "../types";
|
||||||
|
|
||||||
export function WorkstationsPage(props: WorkstationsPageProps) {
|
export function WorkstationsPage(props: WorkstationsPageProps) {
|
||||||
const { companyId, lookups } = props;
|
const { companyId, lookups, initialPersonId } = props;
|
||||||
const [operation, setOperation] = useState<{ id: string; action: "decommission" | "delete" }>();
|
const [operation, setOperation] = useState<{ id: string; action: "decommission" | "delete" }>();
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
@ -45,10 +46,12 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
|||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Button disabled={!query.data} onClick={() => setEditor("new")}>
|
<Button disabled={!query.data} onClick={() => setEditor("new")}>
|
||||||
|
<MonitorIcon aria-hidden="true" />
|
||||||
{t("newWorkstation")}
|
{t("newWorkstation")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
icon={<MonitorIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("workstations")}
|
title={t("workstations")}
|
||||||
/>
|
/>
|
||||||
@ -93,8 +96,17 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
|||||||
label: t("workstationPerson"),
|
label: t("workstationPerson"),
|
||||||
value: (item) =>
|
value: (item) =>
|
||||||
lookups.people.find((person) => person.id === item.values.personId)?.name,
|
lookups.people.find((person) => person.id === item.values.personId)?.name,
|
||||||
render: (item) =>
|
render: (item) => {
|
||||||
lookups.people.find((person) => person.id === item.values.personId)?.name,
|
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",
|
id: "state",
|
||||||
@ -110,7 +122,25 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
|||||||
id: "exclusive",
|
id: "exclusive",
|
||||||
label: t("workstationAssets"),
|
label: t("workstationAssets"),
|
||||||
render: (item) =>
|
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",
|
id: "count",
|
||||||
@ -175,6 +205,7 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
|||||||
{editor && (
|
{editor && (
|
||||||
<WorkstationEditorDialog
|
<WorkstationEditorDialog
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
|
initialPersonId={initialPersonId}
|
||||||
lookups={lookups}
|
lookups={lookups}
|
||||||
onClose={() => setEditor(undefined)}
|
onClose={() => setEditor(undefined)}
|
||||||
workstation={edited}
|
workstation={edited}
|
||||||
|
|||||||
@ -19,6 +19,8 @@ import { suggestedWorkstationName } from "../workstation-name";
|
|||||||
export function useWorkstationFormController({
|
export function useWorkstationFormController({
|
||||||
companyId,
|
companyId,
|
||||||
workstation,
|
workstation,
|
||||||
|
initialAsset,
|
||||||
|
initialPersonId,
|
||||||
onSaved,
|
onSaved,
|
||||||
lookups,
|
lookups,
|
||||||
}: WorkstationFormProps) {
|
}: WorkstationFormProps) {
|
||||||
@ -28,16 +30,26 @@ export function useWorkstationFormController({
|
|||||||
const isWizard = !workstation;
|
const isWizard = !workstation;
|
||||||
const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
|
const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
|
||||||
const activeSites = lookups.sites.filter((site) => site.active);
|
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>({
|
const form = useForm<WorkstationInput>({
|
||||||
resolver: zodResolver(WorkstationInputSchema),
|
resolver: zodResolver(WorkstationInputSchema),
|
||||||
defaultValues: workstation?.values ?? {
|
defaultValues: workstation?.values ?? {
|
||||||
name: initialSite ? t("workstationNameBySite", { site: initialSite.name }) : "",
|
name:
|
||||||
|
initialName ?? (initialSite ? t("workstationNameBySite", { site: initialSite.name }) : ""),
|
||||||
active: true,
|
active: true,
|
||||||
siteId: initialSite?.id ?? null,
|
siteId: initialSite?.id ?? null,
|
||||||
personId: null,
|
personId:
|
||||||
assetIds: [],
|
lookups.people.find((person) => person.id === initialPersonId && person.active)?.id ?? null,
|
||||||
computerAssetId: null,
|
assetIds: sourceAsset ? [sourceAsset.id] : [],
|
||||||
|
computerAssetId: sourceAsset?.id ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const values = form.watch();
|
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";
|
import type { InventoryLookups } from "../../../assets/shared/types";
|
||||||
|
|
||||||
export type WorkstationFormProps = {
|
export type WorkstationFormProps = {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
lookups: InventoryLookups;
|
lookups: InventoryLookups;
|
||||||
workstation?: WorkstationDTO | undefined;
|
workstation?: WorkstationDTO | undefined;
|
||||||
|
initialAsset?: AssetDTO | undefined;
|
||||||
|
initialPersonId?: string | undefined;
|
||||||
onSaved: (id: string) => void;
|
onSaved: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,11 +8,17 @@ import { WorkstationForm } from "./workstation-form";
|
|||||||
export function WorkstationEditorDialog({
|
export function WorkstationEditorDialog({
|
||||||
workstation,
|
workstation,
|
||||||
companyId,
|
companyId,
|
||||||
|
initialAsset,
|
||||||
|
initialPersonId,
|
||||||
lookups,
|
lookups,
|
||||||
onClose,
|
onClose,
|
||||||
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
workstation: ComponentProps<typeof WorkstationForm>["workstation"];
|
workstation: ComponentProps<typeof WorkstationForm>["workstation"];
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
initialAsset?: ComponentProps<typeof WorkstationForm>["initialAsset"];
|
||||||
|
initialPersonId?: string | undefined;
|
||||||
|
onSaved?: (id: string) => void;
|
||||||
lookups: ComponentProps<typeof WorkstationForm>["lookups"];
|
lookups: ComponentProps<typeof WorkstationForm>["lookups"];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@ -39,8 +45,13 @@ export function WorkstationEditorDialog({
|
|||||||
>
|
>
|
||||||
<WorkstationForm
|
<WorkstationForm
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
|
initialAsset={initialAsset}
|
||||||
|
initialPersonId={initialPersonId}
|
||||||
lookups={lookups}
|
lookups={lookups}
|
||||||
onSaved={() => onClose()}
|
onSaved={(id) => {
|
||||||
|
onClose();
|
||||||
|
onSaved?.(id);
|
||||||
|
}}
|
||||||
workstation={workstation}
|
workstation={workstation}
|
||||||
/>
|
/>
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"companies": {
|
"companies": {
|
||||||
|
"navigationCounts": {
|
||||||
|
"loading": "Loading total…",
|
||||||
|
"error": "The total could not be refreshed."
|
||||||
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"invalidRoute": "Invalid address",
|
"invalidRoute": "Invalid address",
|
||||||
"notFound": "Page not found",
|
"notFound": "Page not found",
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"companies": {
|
"companies": {
|
||||||
|
"navigationCounts": {
|
||||||
|
"loading": "Cargando total…",
|
||||||
|
"error": "No se ha podido actualizar el total."
|
||||||
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"invalidRoute": "La dirección no es válida",
|
"invalidRoute": "La dirección no es válida",
|
||||||
"notFound": "No se ha encontrado esta página",
|
"notFound": "No se ha encontrado esta página",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -49,12 +50,14 @@ export function AccessWorkspacePage(props: AccessWorkspacePageProps) {
|
|||||||
{t("helpButton")}
|
{t("helpButton")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!data} onClick={() => setEditor("new")}>
|
<Button disabled={!data} onClick={() => setEditor("new")}>
|
||||||
|
<KeyRoundIcon aria-hidden="true" />
|
||||||
{t(createLabels[kind])}
|
{t(createLabels[kind])}
|
||||||
</Button>
|
</Button>
|
||||||
</div>{" "}
|
</div>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
description={t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
|
description={t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
|
||||||
|
icon={<KeyRoundIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t(`kinds.${kind}`)}
|
title={t(`kinds.${kind}`)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -53,10 +54,12 @@ export function ApplicationConfigurationsPage(props: ApplicationConfigurationsPa
|
|||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||||
|
<LayoutTemplateIcon aria-hidden="true" />
|
||||||
{t("applications.new")}
|
{t("applications.new")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
icon={<LayoutTemplateIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("applications.title")}
|
title={t("applications.title")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { FileKeyIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -53,10 +54,12 @@ export function CertificateConfigurationsPage(props: CertificateConfigurationsPa
|
|||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||||
|
<FileKeyIcon aria-hidden="true" />
|
||||||
{t("certificates.new")}
|
{t("certificates.new")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
icon={<FileKeyIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("certificates.title")}
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { profileApi, profileKeys } from "../../../work-profiles/shared/api";
|
||||||
import { companyKeys } from "../../shared/queries";
|
import { companyKeys } from "../../shared/queries";
|
||||||
import type { CompanyTab } from "../../shared/types";
|
import type { CompanyTab } from "../../shared/types";
|
||||||
import { getCompany } from "../api";
|
import { getCompany } from "../api";
|
||||||
@ -19,5 +29,30 @@ export function useCompanyDetailController({
|
|||||||
const setTab = onSectionChange ?? setLocalTab;
|
const setTab = onSectionChange ?? setLocalTab;
|
||||||
const company = query.data?.company;
|
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 { BrowserLink } from "@metis/core/client";
|
||||||
|
import { CountBadge } from "@repo/rdx-ui/components/count-badge";
|
||||||
import {
|
import {
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
BriefcaseIcon,
|
BriefcaseIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
|
FileKeyIcon,
|
||||||
|
FolderIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
LayersIcon,
|
LayersIcon,
|
||||||
@ -11,7 +14,9 @@ import {
|
|||||||
MailIcon,
|
MailIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
MonitorIcon,
|
MonitorIcon,
|
||||||
UsersIcon,
|
PersonIcon,
|
||||||
|
PrinterIcon,
|
||||||
|
WorkProfileIcon,
|
||||||
} from "@repo/rdx-ui/lib/icons";
|
} from "@repo/rdx-ui/lib/icons";
|
||||||
import { Avatar, AvatarBadge } from "@repo/shadcn-ui/components/avatar";
|
import { Avatar, AvatarBadge } from "@repo/shadcn-ui/components/avatar";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
@ -34,8 +39,9 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
initialCreate,
|
initialCreate,
|
||||||
initialConfiguration,
|
initialConfiguration,
|
||||||
} = props;
|
} = props;
|
||||||
const { query, editOpen, setEditOpen, tab, setTab, company } = useCompanyDetailController(props);
|
const { query, editOpen, setEditOpen, tab, setTab, company, counts } =
|
||||||
const { t } = useTranslation("companies");
|
useCompanyDetailController(props);
|
||||||
|
const { t, i18n } = useTranslation("companies");
|
||||||
const { t: resourceTranslation } = useTranslation("resources");
|
const { t: resourceTranslation } = useTranslation("resources");
|
||||||
const { t: assetTranslation } = useTranslation("assets");
|
const { t: assetTranslation } = useTranslation("assets");
|
||||||
const { t: accessTranslation } = useTranslation("access");
|
const { t: accessTranslation } = useTranslation("access");
|
||||||
@ -106,25 +112,21 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["overview", t("companyOverview.navigation"), HomeIcon],
|
["overview", t("companyOverview.navigation"), HomeIcon],
|
||||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
["people", t("people"), PersonIcon],
|
||||||
["workstations", assetTranslation("workstations"), MonitorIcon],
|
["workstations", assetTranslation("workstations"), MonitorIcon],
|
||||||
["profiles", t("profiles.title"), UsersIcon],
|
["profiles", t("profiles.title"), WorkProfileIcon],
|
||||||
["inventory", assetTranslation("inventory"), BoxIcon],
|
["inventory", assetTranslation("inventory"), BoxIcon],
|
||||||
["resources", resourceTranslation("inventory"), LayersIcon],
|
["resources", resourceTranslation("inventory"), LayersIcon],
|
||||||
[
|
["departments", t("departments"), BriefcaseIcon],
|
||||||
"departments",
|
["sites", t("sites"), MapPinIcon],
|
||||||
`${t("departments")} (${company.departments.length})`,
|
|
||||||
BriefcaseIcon,
|
|
||||||
],
|
|
||||||
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
|
|
||||||
["telework", t("telework.title"), KeyRoundIcon],
|
["telework", t("telework.title"), KeyRoundIcon],
|
||||||
["remote", t("remote.title"), KeyRoundIcon],
|
["remote", t("remote.title"), KeyRoundIcon],
|
||||||
["processes", t("processes.title"), LayoutTemplateIcon],
|
["processes", t("processes.title"), LayoutTemplateIcon],
|
||||||
["access", accessTranslation("title"), KeyRoundIcon],
|
["access", accessTranslation("title"), KeyRoundIcon],
|
||||||
["folders", t("folders.title"), MailIcon],
|
["folders", t("folders.title"), FolderIcon],
|
||||||
["certificates", t("certificates.title"), LayersIcon],
|
["certificates", t("certificates.title"), FileKeyIcon],
|
||||||
["applications", t("applications.title"), LayersIcon],
|
["applications", t("applications.title"), LayoutTemplateIcon],
|
||||||
["printers", t("printers.title"), LayersIcon],
|
["printers", t("printers.title"), PrinterIcon],
|
||||||
["mail", t("mail.title"), MailIcon],
|
["mail", t("mail.title"), MailIcon],
|
||||||
["catalogs", t("catalogOverview.companyTitle"), DatabaseIcon],
|
["catalogs", t("catalogOverview.companyTitle"), DatabaseIcon],
|
||||||
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
||||||
@ -135,6 +137,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const [section, label, Icon] = entry;
|
const [section, label, Icon] = entry;
|
||||||
|
const count = counts[section];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -149,8 +152,8 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
aria-pressed={tab === section}
|
aria-pressed={tab === section}
|
||||||
className={
|
className={
|
||||||
embedded
|
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"
|
? "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"
|
||||||
: "cursor-pointer text-foreground hover:text-primary"
|
: "group/company-section cursor-pointer text-foreground hover:text-primary"
|
||||||
}
|
}
|
||||||
{...(props.onSectionChange
|
{...(props.onSectionChange
|
||||||
? {
|
? {
|
||||||
@ -162,7 +165,17 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,11 +11,13 @@ export function CompanyInventory({
|
|||||||
workstations = false,
|
workstations = false,
|
||||||
initialId,
|
initialId,
|
||||||
initialCreate,
|
initialCreate,
|
||||||
|
initialPersonId,
|
||||||
}: {
|
}: {
|
||||||
company: CompanyDTO;
|
company: CompanyDTO;
|
||||||
workstations?: boolean;
|
workstations?: boolean;
|
||||||
initialId?: string | undefined;
|
initialId?: string | undefined;
|
||||||
initialCreate?: boolean | undefined;
|
initialCreate?: boolean | undefined;
|
||||||
|
initialPersonId?: string | undefined;
|
||||||
}) {
|
}) {
|
||||||
const [quick, setQuick] = useState<{
|
const [quick, setQuick] = useState<{
|
||||||
kind: "site" | "person";
|
kind: "site" | "person";
|
||||||
@ -40,7 +42,7 @@ export function CompanyInventory({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Page
|
<Page
|
||||||
{...(workstations ? {} : { initialId, initialCreate })}
|
{...(workstations ? { initialPersonId, initialCreate } : { initialId, initialCreate })}
|
||||||
companyId={company.id}
|
companyId={company.id}
|
||||||
lookups={lookups}
|
lookups={lookups}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
LaptopIcon,
|
LaptopIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
UsersIcon,
|
PersonIcon,
|
||||||
} from "@repo/rdx-ui/lib/icons";
|
} from "@repo/rdx-ui/lib/icons";
|
||||||
import type { UseQueryResult } from "@tanstack/react-query";
|
import type { UseQueryResult } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
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",
|
section: "departments",
|
||||||
label: "departments",
|
label: "departments",
|
||||||
|
|||||||
@ -110,7 +110,15 @@ export function CompanySection({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "workstations":
|
case "workstations":
|
||||||
return <CompanyInventory company={company} initialId={initialEntityId} workstations />;
|
return (
|
||||||
|
<CompanyInventory
|
||||||
|
company={company}
|
||||||
|
initialCreate={create}
|
||||||
|
initialId={initialEntityId}
|
||||||
|
initialPersonId={personId}
|
||||||
|
workstations
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "inventory":
|
case "inventory":
|
||||||
return (
|
return (
|
||||||
<CompanyInventory company={company} initialCreate={create} initialId={initialEntityId} />
|
<CompanyInventory company={company} initialCreate={create} initialId={initialEntityId} />
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { Building2Icon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -52,10 +53,14 @@ export function CompaniesPage(props: CompaniesPageProps) {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Button onClick={() => setCreateOpen(true)}>{t("newCompany")}</Button>{" "}
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Building2Icon aria-hidden="true" />
|
||||||
|
{t("newCompany")}
|
||||||
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
description={t("help")}
|
description={t("help")}
|
||||||
|
icon={<Building2Icon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("title")}
|
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 = {
|
export const KIND_ICONS = {
|
||||||
asset: LaptopIcon,
|
asset: LaptopIcon,
|
||||||
resource: DatabaseIcon,
|
resource: DatabaseIcon,
|
||||||
person: UsersIcon,
|
person: PersonIcon,
|
||||||
profile: UsersIcon,
|
profile: WorkProfileIcon,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import {
|
|||||||
FileKeyIcon,
|
FileKeyIcon,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
UserRoundXIcon,
|
UserRoundXIcon,
|
||||||
UsersIcon,
|
WorkProfileIcon,
|
||||||
} from "@repo/rdx-ui/lib/icons";
|
} from "@repo/rdx-ui/lib/icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -16,7 +16,7 @@ import { dashboardApi, dashboardKeys } from "../../shared/api";
|
|||||||
const ISSUE_ICONS = {
|
const ISSUE_ICONS = {
|
||||||
certificate: FileKeyIcon,
|
certificate: FileKeyIcon,
|
||||||
license: FileKeyIcon,
|
license: FileKeyIcon,
|
||||||
profile: UsersIcon,
|
profile: WorkProfileIcon,
|
||||||
missing_site: BoxIcon,
|
missing_site: BoxIcon,
|
||||||
inactive_person: UserRoundXIcon,
|
inactive_person: UserRoundXIcon,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { BriefcaseIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -21,7 +22,13 @@ export function DepartmentSection({ company }: { company: CompanyDTO }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<EntityListHeader
|
<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}
|
locale={i18n.language}
|
||||||
title={t("departments")}
|
title={t("departments")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { FolderIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -49,10 +50,12 @@ export function FolderMappingsPage(props: FolderMappingsPageProps) {
|
|||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||||
|
<FolderIcon aria-hidden="true" />
|
||||||
{t("folders.new")}
|
{t("folders.new")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
icon={<FolderIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("folders.title")}
|
title={t("folders.title")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { MailIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -52,10 +53,12 @@ export function MailConfigurationsPage(props: MailConfigurationsPageProps) {
|
|||||||
disabled={!(query.data && inventory.data && profiles.data && mailboxes.data)}
|
disabled={!(query.data && inventory.data && profiles.data && mailboxes.data)}
|
||||||
onClick={() => setEditor("new")}
|
onClick={() => setEditor("new")}
|
||||||
>
|
>
|
||||||
|
<MailIcon aria-hidden="true" />
|
||||||
{t("mail.new")}
|
{t("mail.new")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
icon={<MailIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("mail.title")}
|
title={t("mail.title")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -143,9 +143,11 @@ export function PersonDetails({
|
|||||||
<EntityListHeader
|
<EntityListHeader
|
||||||
actions={
|
actions={
|
||||||
<Button disabled={!person.active} onClick={() => setEditor("new")}>
|
<Button disabled={!person.active} onClick={() => setEditor("new")}>
|
||||||
|
<BriefcaseIcon aria-hidden="true" />
|
||||||
{t("addAssignment")}
|
{t("addAssignment")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
icon={<BriefcaseIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("departmentAssignments")}
|
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 {
|
import {
|
||||||
EntityAction,
|
EntityAction,
|
||||||
EntityListHeader,
|
EntityListHeader,
|
||||||
@ -8,12 +15,14 @@ import {
|
|||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||||
import { PhoneLink } from "@repo/rdx-ui/components/phone-link";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CompanyDTO } from "../../../../common";
|
import type { CompanyDTO } from "../../../../common";
|
||||||
|
import { personWorkstations } from "../../../workstations/detail/helpers/person-workstations";
|
||||||
import { PersonDetails } from "../../detail";
|
import { PersonDetails } from "../../detail";
|
||||||
import { PersonAvatar, PersonEditorDialog } from "../../shared";
|
import { PersonAvatar, PersonEditorDialog } from "../../shared";
|
||||||
|
|
||||||
@ -25,6 +34,7 @@ export function PeopleSection({
|
|||||||
initialId?: string | undefined;
|
initialId?: string | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
|
const { t: assetTranslation } = useTranslation("assets");
|
||||||
const filterId = useId();
|
const filterId = useId();
|
||||||
const [selection, setSelection] = useState<
|
const [selection, setSelection] = useState<
|
||||||
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
|
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
|
||||||
@ -36,6 +46,15 @@ export function PeopleSection({
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [contactsOnly, setContactsOnly] = useState(false);
|
const [contactsOnly, setContactsOnly] = useState(false);
|
||||||
const selected = company.people.find((item) => item.id === (routed ? selectedId : selection?.id));
|
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 = (
|
const personEditor = (
|
||||||
<PersonEditorDialog
|
<PersonEditorDialog
|
||||||
companyId={company.id}
|
companyId={company.id}
|
||||||
@ -86,13 +105,37 @@ export function PeopleSection({
|
|||||||
actions={
|
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}
|
locale={i18n.language}
|
||||||
title={t("people")}
|
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
|
<EntityTable
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
@ -125,6 +168,16 @@ export function PeopleSection({
|
|||||||
</div>
|
</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",
|
id: "phone",
|
||||||
label: t("phone"),
|
label: t("phone"),
|
||||||
@ -165,21 +218,46 @@ export function PeopleSection({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "state",
|
id: "workstations",
|
||||||
label: t("status"),
|
label: t("responsibleWorkstations"),
|
||||||
value: (person) => t(person.active ? "active" : "inactive"),
|
value: (person) =>
|
||||||
render: (person) => (
|
responsibleWorkstations(person.id)
|
||||||
<EntityStatus active={person.active}>
|
.map((station) => station.values.name)
|
||||||
{t(person.active ? "active" : "inactive")}
|
.join(" · "),
|
||||||
</EntityStatus>
|
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",
|
id: "actions",
|
||||||
label: t("actions"),
|
label: t("actions"),
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (person) => (
|
render: (person) => (
|
||||||
<EntityRowActions label={t("actions")}>
|
<EntityRowActions label={t("actions")}>
|
||||||
|
<EntityAction
|
||||||
|
disabled={!person.active}
|
||||||
|
onClick={() =>
|
||||||
|
navigateTo(
|
||||||
|
`/companies/${company.id}/workstations?personId=${encodeURIComponent(person.id)}`,
|
||||||
|
{ create: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{assetTranslation("newWorkstation")}
|
||||||
|
</EntityAction>
|
||||||
<EntityAction
|
<EntityAction
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelection({ mode: "detail", id: person.id });
|
setSelection({ mode: "detail", id: person.id });
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
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 type { ComponentProps } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ export function PersonEditorDialog({
|
|||||||
size="medium"
|
size="medium"
|
||||||
title={
|
title={
|
||||||
<span className="flex items-center gap-2">
|
<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")}
|
{t(formProps.person ? "editPerson" : "newPerson")}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { PrinterIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -50,10 +51,12 @@ export function PrinterConfigurationsPage(props: PrinterConfigurationsPageProps)
|
|||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||||
|
<PrinterIcon aria-hidden="true" />
|
||||||
{t("printers.new")}
|
{t("printers.new")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
icon={<PrinterIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("printers.title")}
|
title={t("printers.title")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -24,11 +25,13 @@ export function ProcessTemplatesPage() {
|
|||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Button disabled={!query.data || query.isError} onClick={() => setEditor("new")}>
|
<Button disabled={!query.data || query.isError} onClick={() => setEditor("new")}>
|
||||||
|
<LayoutTemplateIcon aria-hidden="true" />
|
||||||
{t("processes.newTemplate")}
|
{t("processes.newTemplate")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
description={t("processes.globalTemplates")}
|
description={t("processes.globalTemplates")}
|
||||||
|
icon={<LayoutTemplateIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("processes.templates")}
|
title={t("processes.templates")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} 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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -87,11 +87,13 @@ export function ProcessesPage(props: ProcessesPageProps) {
|
|||||||
{t("processes.templates")}
|
{t("processes.templates")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
|
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
|
||||||
|
<LayoutTemplateIcon aria-hidden="true" />
|
||||||
{t("processes.new")}
|
{t("processes.new")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>{" "}
|
</div>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
icon={<LayoutTemplateIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("processes.title")}
|
title={t("processes.title")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -46,10 +47,12 @@ export function RemoteConnectionsPage(props: RemoteConnectionsPageProps) {
|
|||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||||
|
<KeyRoundIcon aria-hidden="true" />
|
||||||
{t("remote.new")}
|
{t("remote.new")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
icon={<KeyRoundIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("remote.title")}
|
title={t("remote.title")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
@ -36,7 +37,13 @@ export function SiteSection({ company }: { company: CompanyDTO }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<EntityListHeader
|
<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}
|
locale={i18n.language}
|
||||||
title={t("sites")}
|
title={t("sites")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { LayersIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
@ -131,10 +132,12 @@ export function SoftwareInstallationsPanel({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
|
<LayersIcon aria-hidden="true" />
|
||||||
{t("software.new")}
|
{t("software.new")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
description={t("software.description")}
|
description={t("software.description")}
|
||||||
|
icon={<LayersIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("software.title")}
|
title={t("software.title")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -48,10 +49,12 @@ export function TeleworkAuthorizationsPage(props: TeleworkAuthorizationsPageProp
|
|||||||
}
|
}
|
||||||
onClick={() => setEditor("new")}
|
onClick={() => setEditor("new")}
|
||||||
>
|
>
|
||||||
|
<KeyRoundIcon aria-hidden="true" />
|
||||||
{t("telework.new")}
|
{t("telework.new")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
icon={<KeyRoundIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("telework.title")}
|
title={t("telework.title")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -69,10 +70,12 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
|
|||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||||
|
<WorkProfileIcon aria-hidden="true" />
|
||||||
{t("profiles.new")}
|
{t("profiles.new")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
icon={<WorkProfileIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("profiles.title")}
|
title={t("profiles.title")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
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 type { ComponentProps } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ export function WorkProfileEditorDialog({
|
|||||||
size={profile ? "large" : "medium"}
|
size={profile ? "large" : "medium"}
|
||||||
title={
|
title={
|
||||||
<span className="flex items-center gap-3">
|
<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")}
|
{t(profile ? "profiles.edit" : "profiles.new")}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
|
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
|
||||||
import { OptionSelectionField } from "@repo/rdx-ui/components/option-selection-field";
|
import { OptionSelectionField } from "@repo/rdx-ui/components/option-selection-field";
|
||||||
import { RequiredIndicator } from "@repo/rdx-ui/components/required-indicator";
|
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 { useId } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ export function WorkProfileSelectionField({
|
|||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const icons = { workstation: <MonitorIcon />, person: <UsersIcon />, account: <KeyRoundIcon /> };
|
const icons = { workstation: <MonitorIcon />, person: <PersonIcon />, account: <KeyRoundIcon /> };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset aria-describedby={`${id}-help`} className="flex min-w-0 flex-1 flex-col gap-3">
|
<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 { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||||
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
|
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
|
||||||
import { SelectionSummaryCard } from "@repo/rdx-ui/components/selection-summary-card";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { WorkProfileComputerSummary } from "./work-profile-computer-summary";
|
import { WorkProfileComputerSummary } from "./work-profile-computer-summary";
|
||||||
@ -35,7 +35,7 @@ export function WorkProfileSummary({
|
|||||||
<li className="min-w-0">
|
<li className="min-w-0">
|
||||||
<SelectionSummaryCard
|
<SelectionSummaryCard
|
||||||
description={person.description}
|
description={person.description}
|
||||||
icon={<UsersIcon />}
|
icon={<PersonIcon />}
|
||||||
title={person.label}
|
title={person.label}
|
||||||
/>
|
/>
|
||||||
</li>
|
</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 { 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 { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -19,7 +22,7 @@ export function WorkstationDetailContent({
|
|||||||
controller: WorkstationDetailController;
|
controller: WorkstationDetailController;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation("assets");
|
const { t, i18n } = useTranslation("assets");
|
||||||
const [tab, setTab] = useState("overview");
|
const [tab, setTab] = useState("information");
|
||||||
const [softwareOpened, setSoftwareOpened] = useState(false);
|
const [softwareOpened, setSoftwareOpened] = useState(false);
|
||||||
const workstation = c.workstation;
|
const workstation = c.workstation;
|
||||||
|
|
||||||
@ -51,25 +54,15 @@ export function WorkstationDetailContent({
|
|||||||
>
|
>
|
||||||
<ScrollableTabsList
|
<ScrollableTabsList
|
||||||
items={[
|
items={[
|
||||||
{ value: "overview", label: t("workstationDetail.overview") },
|
{ value: "information", label: t("workstationDetail.information") },
|
||||||
{
|
|
||||||
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: "software", label: t("workstationDetail.software") },
|
{ value: "software", label: t("workstationDetail.software") },
|
||||||
|
{ value: "history", label: t("history") },
|
||||||
]}
|
]}
|
||||||
label={t("workstationDetail.sections")}
|
label={t("workstationDetail.sections")}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
value={tab}
|
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)]">
|
<div className="grid items-start gap-5 @min-[1000px]:grid-cols-[minmax(0,1fr)_minmax(260px,0.45fr)]">
|
||||||
<WorkstationAssets
|
<WorkstationAssets
|
||||||
assets={c.inventory.data?.assets ?? []}
|
assets={c.inventory.data?.assets ?? []}
|
||||||
@ -86,8 +79,7 @@ export function WorkstationDetailContent({
|
|||||||
responsibleId={workstation.values.personId}
|
responsibleId={workstation.values.personId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
|
||||||
<TabsContent className="min-w-0" keepMounted value="profiles">
|
|
||||||
<WorkstationProfiles
|
<WorkstationProfiles
|
||||||
canCreate={canCreateProfile}
|
canCreate={canCreateProfile}
|
||||||
companyId={company.id}
|
companyId={company.id}
|
||||||
@ -110,6 +102,28 @@ export function WorkstationDetailContent({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
} from "@metis/assets/client";
|
} from "@metis/assets/client";
|
||||||
import { BrowserLink, navigateTo } from "@metis/core/client";
|
import { BrowserLink, navigateTo } from "@metis/core/client";
|
||||||
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -90,7 +90,7 @@ export function WorkstationDetailPage({
|
|||||||
</span>
|
</span>
|
||||||
{responsible && (
|
{responsible && (
|
||||||
<span className="inline-flex min-w-0 items-center gap-2">
|
<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>
|
<span className="sr-only">{t("workstationDetail.assignedPerson")}: </span>
|
||||||
<BrowserLink href={`${root}/people/${responsible.id}`}>
|
<BrowserLink href={`${root}/people/${responsible.id}`}>
|
||||||
{responsible.name}
|
{responsible.name}
|
||||||
|
|||||||
@ -1,9 +1,16 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
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 type { AssetDTO, WorkstationDTO } from "@metis/assets/common";
|
||||||
import { AssetSchema, WorkstationSchema } 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createInstance } from "i18next";
|
import { createInstance } from "i18next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@ -13,7 +20,10 @@ import { I18nextProvider } from "react-i18next";
|
|||||||
import type { CompanyDTO, PersonDTO } from "../../../../common";
|
import type { CompanyDTO, PersonDTO } from "../../../../common";
|
||||||
import type { WorkProfileDTO } from "../../../../common/work-profiles";
|
import type { WorkProfileDTO } from "../../../../common/work-profiles";
|
||||||
import { WorkProfileSchema } 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 { CurrentWorkstations } from "../../../people/detail/ui/current-workstations";
|
||||||
|
import { PeopleSection } from "../../../people/list/ui/people-section";
|
||||||
import { companiesEnglish, companiesSpanish } from "../../../shared";
|
import { companiesEnglish, companiesSpanish } from "../../../shared";
|
||||||
import { profileKeys } from "../../../work-profiles/shared/api";
|
import { profileKeys } from "../../../work-profiles/shared/api";
|
||||||
|
|
||||||
@ -62,6 +72,7 @@ async function render(
|
|||||||
node?: ReactNode;
|
node?: ReactNode;
|
||||||
assets?: AssetDTO[];
|
assets?: AssetDTO[];
|
||||||
workstation?: WorkstationDTO;
|
workstation?: WorkstationDTO;
|
||||||
|
workstations?: WorkstationDTO[];
|
||||||
people?: PersonDTO[];
|
people?: PersonDTO[];
|
||||||
sites?: CompanyDTO["sites"];
|
sites?: CompanyDTO["sites"];
|
||||||
profiles?: WorkProfileDTO[];
|
profiles?: WorkProfileDTO[];
|
||||||
@ -83,10 +94,17 @@ async function render(
|
|||||||
client.setQueryData(inventoryKeys.company(companyId), {
|
client.setQueryData(inventoryKeys.company(companyId), {
|
||||||
assets: options.assets ?? [],
|
assets: options.assets ?? [],
|
||||||
relationships: [],
|
relationships: [],
|
||||||
workstations: exists ? [options.workstation ?? workstation] : [],
|
workstations: exists ? (options.workstations ?? [options.workstation ?? workstation]) : [],
|
||||||
});
|
});
|
||||||
client.setQueryData(profileKeys.company(companyId), { profiles: options.profiles ?? [] });
|
client.setQueryData(profileKeys.company(companyId), { profiles: options.profiles ?? [] });
|
||||||
client.setQueryData(catalogKeys.scope(companyId), { items: [] });
|
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 {
|
try {
|
||||||
return renderToStaticMarkup(
|
return renderToStaticMarkup(
|
||||||
<I18nextProvider i18n={i18n}>
|
<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(labels.workstationDetail.people));
|
||||||
assert.ok(html.includes(companies.profiles.new));
|
assert.ok(html.includes(companies.profiles.new));
|
||||||
assert.equal(html.includes("<form"), false);
|
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.workstationDetail.software));
|
||||||
assert.ok(html.includes(labels.noWorkstationAssets));
|
assert.ok(html.includes(labels.noWorkstationAssets));
|
||||||
assert.ok(html.includes(labels.workstationDetail.profileUnavailable));
|
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.ok(html.includes(assetsSpanish.workstationDetail.profileUnavailable));
|
||||||
assert.match(
|
const createButton = [...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].find(([button]) =>
|
||||||
html,
|
button.includes("Nuevo perfil")
|
||||||
/<button(?=[^>]*disabled="")(?=[^>]*type="button")[^>]*>Nuevo perfil<\/button>/
|
)?.[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 { BrowserLink } from "@metis/core/client";
|
||||||
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { PersonDTO } from "../../../../common";
|
import type { PersonDTO } from "../../../../common";
|
||||||
@ -30,7 +30,7 @@ export function WorkstationPeople({
|
|||||||
<DetailPanel
|
<DetailPanel
|
||||||
count={loading || failed ? undefined : people.length}
|
count={loading || failed ? undefined : people.length}
|
||||||
description={t("workstationDetail.peopleHint")}
|
description={t("workstationDetail.peopleHint")}
|
||||||
icon={<UsersIcon />}
|
icon={<PersonIcon />}
|
||||||
title={t("workstationDetail.people")}
|
title={t("workstationDetail.people")}
|
||||||
>
|
>
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { BrowserLink } from "@metis/core/client";
|
import { BrowserLink } from "@metis/core/client";
|
||||||
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
|
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
|
||||||
import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -35,12 +35,13 @@ export function WorkstationProfiles({
|
|||||||
<DetailPanel
|
<DetailPanel
|
||||||
actions={
|
actions={
|
||||||
<Button disabled={!canCreate} onClick={onCreate} type="button">
|
<Button disabled={!canCreate} onClick={onCreate} type="button">
|
||||||
|
<WorkProfileIcon aria-hidden="true" />
|
||||||
{companies("profiles.new")}
|
{companies("profiles.new")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
count={loading || failed ? undefined : profiles.length}
|
count={loading || failed ? undefined : profiles.length}
|
||||||
description={t("workstationDetail.profilesHint")}
|
description={t("workstationDetail.profilesHint")}
|
||||||
icon={<UsersIcon />}
|
icon={<WorkProfileIcon />}
|
||||||
title={t("workstationDetail.profiles")}
|
title={t("workstationDetail.profiles")}
|
||||||
>
|
>
|
||||||
{!canCreate && (
|
{!canCreate && (
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
EntityStatus,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
|
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -23,10 +24,14 @@ export function UserManagement() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Button onClick={() => setCreateOpen(true)}>{t("createUser")}</Button>{" "}
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<UsersIcon aria-hidden="true" />
|
||||||
|
{t("createUser")}
|
||||||
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
description={t("usersHelp")}
|
description={t("usersHelp")}
|
||||||
|
icon={<UsersIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("users")}
|
title={t("users")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"characteristics": "Characteristics",
|
"characteristics": "Characteristics",
|
||||||
"chooseValue": "Select a value",
|
"chooseValue": "Select a value",
|
||||||
"noValue": "No data",
|
"noValue": "—",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"recordedBy": "Recorded by {{name}}",
|
"recordedBy": "Recorded by {{name}}",
|
||||||
"snapshotNotice": "This record retains the template fields captured when the resource was created.",
|
"snapshotNotice": "This record retains the template fields captured when the resource was created.",
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
"notes": "Notas",
|
"notes": "Notas",
|
||||||
"characteristics": "Características",
|
"characteristics": "Características",
|
||||||
"chooseValue": "Selecciona un valor",
|
"chooseValue": "Selecciona un valor",
|
||||||
"noValue": "Sin datos",
|
"noValue": "—",
|
||||||
"history": "Historial",
|
"history": "Historial",
|
||||||
"recordedBy": "Registrado por {{name}}",
|
"recordedBy": "Registrado por {{name}}",
|
||||||
"snapshotNotice": "La ficha conserva los campos de la plantilla utilizados al dar de alta el recurso.",
|
"snapshotNotice": "La ficha conserva los campos de la plantilla utilizados al dar de alta el recurso.",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -33,11 +34,13 @@ export function ResourceCatalogPage(props: ResourceCatalogPageProps) {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||||
|
<LayoutTemplateIcon aria-hidden="true" />
|
||||||
{t({ field: "newField", template: "newTemplate" }[tab])}
|
{t({ field: "newField", template: "newTemplate" }[tab])}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
|
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
|
||||||
|
icon={<LayoutTemplateIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t(scope === "global" ? "title" : "companyTitle")}
|
title={t(scope === "global" ? "title" : "companyTitle")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
|
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
|
||||||
import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
|
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -73,11 +74,13 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
|
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
|
||||||
|
<LayersIcon aria-hidden="true" />
|
||||||
{t("newResource")}
|
{t("newResource")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
description={t("resourceScope")}
|
description={t("resourceScope")}
|
||||||
|
icon={<LayersIcon />}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
title={t("inventory")}
|
title={t("inventory")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.8.0",
|
"@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 type { ReactNode } from "react";
|
||||||
|
|
||||||
import { ChevronRightIcon } from "../lib/icons";
|
import { ChevronRightIcon } from "../lib/icons";
|
||||||
@ -5,7 +10,32 @@ import { ChevronRightIcon } from "../lib/icons";
|
|||||||
import { FormSectionTitle } from "./form-section-title";
|
import { FormSectionTitle } from "./form-section-title";
|
||||||
|
|
||||||
/** Grupo de consulta compartido por fichas y plantillas; abierto inicialmente. */
|
/** 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 (
|
return (
|
||||||
<details className="@container group/detail-section" open>
|
<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">
|
<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>
|
<FormSectionTitle as="span">{title}</FormSectionTitle>
|
||||||
</summary>
|
</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">
|
{content}
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,18 +15,30 @@ export { EntityAction, EntityRowActions, EntityStatus } from "./entity-list-cont
|
|||||||
export function EntityListHeader({
|
export function EntityListHeader({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
icon,
|
||||||
actions,
|
actions,
|
||||||
locale = "es",
|
locale = "es",
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<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">
|
<p className="mt-1 text-base font-medium text-muted-foreground">
|
||||||
{description ??
|
{description ??
|
||||||
`${entityListLabels(locale).description} ${title.toLocaleLowerCase(locale)}.`}
|
`${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}`;
|
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) {
|
if (pinned) {
|
||||||
return header
|
return header
|
||||||
? "sticky right-0 z-20 w-20 min-w-20 bg-card pr-4 text-right"
|
? "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,
|
values = false,
|
||||||
hasErrors = false,
|
hasErrors = false,
|
||||||
layout = "fields",
|
layout = "fields",
|
||||||
|
sectionPresentation = "details",
|
||||||
}: {
|
}: {
|
||||||
sections?: readonly FieldSection[] | undefined;
|
sections?: readonly FieldSection[] | undefined;
|
||||||
items: readonly T[];
|
items: readonly T[];
|
||||||
@ -27,6 +28,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
|
|||||||
values?: boolean;
|
values?: boolean;
|
||||||
hasErrors?: boolean;
|
hasErrors?: boolean;
|
||||||
layout?: FieldSectionLayout;
|
layout?: FieldSectionLayout;
|
||||||
|
sectionPresentation?: "details" | "accordion";
|
||||||
}) {
|
}) {
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
const styles = fieldSectionStyles(layout, values);
|
const styles = fieldSectionStyles(layout, values);
|
||||||
@ -85,7 +87,11 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
|
|||||||
|
|
||||||
if (layout === "rows") {
|
if (layout === "rows") {
|
||||||
return (
|
return (
|
||||||
<DetailSection key={section.id} title={section.name}>
|
<DetailSection
|
||||||
|
accordionValue={sectionPresentation === "accordion" ? section.id : undefined}
|
||||||
|
key={section.id}
|
||||||
|
title={section.name}
|
||||||
|
>
|
||||||
{section.description && (
|
{section.description && (
|
||||||
<p className="pt-2 text-sm text-muted-foreground">{section.description}</p>
|
<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>
|
</details>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{ungrouped.length > 0 && (
|
{ungrouped.length > 0 &&
|
||||||
|
(sectionPresentation === "accordion" && layout === "rows" ? (
|
||||||
|
<DetailSection accordionValue="ungrouped" title={ungroupedLabel}>
|
||||||
|
{content(ungrouped)}
|
||||||
|
</DetailSection>
|
||||||
|
) : (
|
||||||
<section className="col-span-full space-y-3">
|
<section className="col-span-full space-y-3">
|
||||||
<div className={styles.compact ? "pl-5" : undefined}>
|
<div className={styles.compact ? "pl-5" : undefined}>
|
||||||
<FormSectionTitle as="h4">{ungroupedLabel}</FormSectionTitle>
|
<FormSectionTitle as="h4">{ungroupedLabel}</FormSectionTitle>
|
||||||
</div>
|
</div>
|
||||||
{content(ungrouped)}
|
{content(ungrouped)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
))}
|
||||||
</div>
|
</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,
|
IconGripVertical as GripVerticalIcon,
|
||||||
IconHeadphones as HeadphonesIcon,
|
IconHeadphones as HeadphonesIcon,
|
||||||
IconHome as HomeIcon,
|
IconHome as HomeIcon,
|
||||||
|
IconIdBadge2 as WorkProfileIcon,
|
||||||
IconInfoCircle as InfoIcon,
|
IconInfoCircle as InfoIcon,
|
||||||
IconKey as KeyRoundIcon,
|
IconKey as KeyRoundIcon,
|
||||||
IconKeyboard as KeyboardIcon,
|
IconKeyboard as KeyboardIcon,
|
||||||
@ -48,6 +49,7 @@ export {
|
|||||||
IconTool as WrenchIcon,
|
IconTool as WrenchIcon,
|
||||||
IconTrash as Trash2Icon,
|
IconTrash as Trash2Icon,
|
||||||
IconUserX as UserRoundXIcon,
|
IconUserX as UserRoundXIcon,
|
||||||
|
IconUsers as PersonIcon,
|
||||||
IconUsers as UsersIcon,
|
IconUsers as UsersIcon,
|
||||||
IconWifi as WifiIcon,
|
IconWifi as WifiIcon,
|
||||||
IconX as XIcon,
|
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