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:
David Arranz 2026-10-16 20:09:02 +02:00
parent df44fdceb4
commit 68980d9131
80 changed files with 1970 additions and 231 deletions

View File

@ -108,7 +108,7 @@
"retired": "Retired",
"noAssets": "This company has no assets yet.",
"noAssignment": "Unassigned",
"noValue": "Not specified",
"noValue": "—",
"characteristics": "Characteristics",
"history": "History",
"recordedBy": "Recorded by {{name}}",
@ -413,4 +413,4 @@
"clear": "Clear selection",
"required": "Required"
}
}
}

View File

@ -108,7 +108,7 @@
"retired": "Retirado",
"noAssets": "Esta compañía todavía no tiene activos.",
"noAssignment": "Sin asignar",
"noValue": "Sin indicar",
"noValue": "—",
"characteristics": "Características",
"history": "Historial",
"recordedBy": "Registrado por {{name}}",
@ -413,4 +413,4 @@
"clear": "Quitar selección",
"required": "Obligatorio"
}
}
}

View File

@ -827,7 +827,7 @@ si el consumidor todavía no conoce el resultado. El contenido responde al ancho
propio contenedor. No incorpora scroll, acciones ni estado de apertura por su cuenta.
La ficha del puesto reutiliza el panel en activos, personas y perfiles. Se organiza en
Resumen, Perfiles y Software mediante `Tabs` y `ScrollableTabsList`. El resumen destaca
Información, Software e Historial mediante `Tabs` y `ScrollableTabsList`. Información destaca
el ordenador principal y presenta el resto como tarjetas de periféricos sin duplicarlo.
Reutiliza `AssetSummaryCard`, que acepta contenido opcional para `name` y permite enlazar
el nombre desde el consumidor sin depender de una librería de rutas. Las tarjetas
@ -837,7 +837,7 @@ accesible desde el nombre. Las listas usan `ul`/`li` y una o dos columnas según
El panel lateral presenta las personas con avatar y su relación; el responsable aparece
primero y cada persona se muestra una sola vez. Los perfiles pertenecen exclusivamente
al ordenador principal, incluidos los archivados que ya se mostraban en la ficha.
Su pestaña reutiliza `EntityTable` con búsqueda por nombre o persona, ordenación,
Su panel dentro de Información reutiliza `EntityTable` con búsqueda por nombre o persona, ordenación,
paginación, estados y avisos de revisión; omite repetir el mismo dispositivo por fila.
La tabla conserva su estado al cambiar a otra pestaña. El software se monta al abrir
su pestaña y permanece montado después, conservando sus filtros al cambiar de sección.
@ -872,3 +872,141 @@ vigentes y el nuevo nombre. No añade endpoints ni cambia las reglas del agregad
historial. Si el nombre ya coincide, evita una escritura. La respuesta actualiza la caché
de inventario y la invalidación posterior consulta el estado definitivo. Se mantiene el
tratamiento habitual de CSRF y de errores de inventario.
## Iconos de Personas y Perfiles de usuario
`PersonIcon`, del catálogo `@repo/rdx-ui/lib/icons`, utiliza `IconUsers` de Tabler,
la silueta de dos personas que antes identificaba los perfiles de usuario. Se reutiliza
en navegación, listados y altas de personas, indicadores, actividad, selección y resumen
de personas y responsables de puestos.
`WorkProfileIcon` utiliza `IconIdBadge2`, una credencial con una silueta individual,
para representar Perfiles de usuario en navegación, listados, alta/edición, paneles del
puesto y actividad. Los controles que seleccionan una persona dentro del formulario
de perfil conservan `PersonIcon`, porque representan la relación con esa entidad.
`UsersIcon` mantiene sus consumidores de usuarios de la aplicación. Los estados
específicos, como persona inactiva, conservan su variante visual. Los iconos decorativos
no sustituyen el nombre accesible.
## Iconos en las cabeceras de listados
`EntityListHeader` admite `icon` como contenido React opcional, independiente de
entidades o consultas. Lo sitúa junto al título `h2` en un área de 24 px, sin encogerse
y oculta para lectores de pantalla. La descripción sigue debajo y las acciones a la
derecha se adaptan al ancho disponible.
Los listados de activos, recursos, compañías, personas, puestos, perfiles, usuarios,
sedes, departamentos, marcas, catálogos, accesos, procesos y configuraciones presentan
el mismo icono de entidad en el título y en su botón de alta, manteniendo textos,
permisos, estados deshabilitados y callbacks. El consumidor aporta ambos iconos desde
el catálogo compartido; no se infieren a partir de las traducciones ni se añaden a
acciones secundarias como ayuda o reintento. Véase el [patrón de listados](patron-de-listados-de-entidades.md).
## Línea temporal vertical
`TimeLine`, en `@repo/rdx-ui/components/time-line`, presenta una lista ordenada accesible
con línea vertical, hitos y tarjetas adaptables. Recibe `label`, `emptyMessage` e `items`;
cada evento aporta `id`, `title`, `dateTime`, `dateLabel`, `meta` y `content` opcionales.
El consumidor decide el orden, traduce textos y formatea las fechas. `time` conserva
la fecha ISO y las líneas e hitos son decorativos. El componente no conoce entidades,
DTO, endpoints ni consultas. Un array vacío muestra el mensaje de estado aportado.
`WorkstationHistory` compone la línea temporal con revisiones del puesto en orden
inverso al registro y presenta el alta y las actualizaciones con autor y valores de
cada revisión. Se comparte entre la pestaña Historial de la ficha y el diálogo del
listado. Información reúne activos, personas y el panel de perfiles; mantiene montada
su tabla para conservar filtros y paginación al cambiar de pestaña.
## Tipo de activo en la primera columna
`AssetList` presenta una columna `icon` compacta, no ocultable ni ordenable, antes de
Nombre. `AssetTemplateIndicator` utiliza el icono actual de la plantilla y un tooltip
de shadcn/ui con su nombre registrado; el foco de teclado también permite consultarlo.
Si el catálogo o el icono no están disponibles, usa la caja como alternativa y conserva
el nombre de la instantánea. La columna Plantilla mantiene únicamente su texto.
`EntityTable` reserva el identificador `icon` para una columna visual compacta con
cabecera solo accesible. En selectores, `selection` sigue siendo la primera columna:
selección, icono, nombre y demás datos. La nueva columna no altera la ordenación
inicial por nombre, la búsqueda, la selección de filas ni las acciones ancladas.
## Acciones de puesto desde un activo
La columna Puesto del inventario compone `AssetWorkstationAction`, fuera del menú de
fila: muestra el nombre de cada puesto asociado como enlace a su ficha o inicia el
alta del puesto con un ordenador disponible
(portátil, sobremesa o servidor). `WorkstationEditorDialog` y `WorkstationForm` admiten
`initialAsset` para inicializar un alta y `onSaved` conserva la navegación al resultado.
La edición mantiene los valores del puesto y tiene prioridad sobre esta inicialización.
El formulario conserva sus cuatro pasos, validación, altura estable y ayuda anclada;
no se crea otro flujo de persistencia ni un formulario incrustado en el listado.
## Secciones desplegables en la ficha lateral del activo
La ficha lateral utiliza `Accordion` de shadcn/ui (Base UI) con `multiple` y
`keepMounted`: se pueden abrir varias secciones a la vez y plegarlas conserva el
contenido y los estados internos de relaciones y asistencia. Todas las secciones
existentes empiezan abiertas. El acordeón se reinicia por UUID al seleccionar otro
activo, sin cambiar el desplazamiento ni las acciones del panel.
`DetailSection.accordionValue` integra cada grupo en el acordeón padre con el mismo
título común y campos de consulta; sin esa propiedad mantiene su presentación actual
con `details`. `FieldSections` con `layout="rows"` y
`sectionPresentation="accordion"` aplica este criterio a los grupos históricos de
la plantilla y al grupo sin sección, sin duplicar campos ni mostrar grupos vacíos.
Las especificaciones antiguas sin grupos ofrecen un único desplegable Características.
Ubicación/asignación, observaciones, relaciones y asistencia también pertenecen al
mismo acordeón. El detalle completo conserva su presentación y su historial.
## Distribución de columnas del inventario
En el inventario, tras el icono y Nombre, las columnas se ordenan como Plantilla,
Estado, Sede, Puesto de trabajo y Fecha de alta; Acciones permanece al final y anclada.
Plantilla y fecha usan texto secundario; estado y fecha evitan saltos internos, mientras
los nombres de puesto pueden partirse para ajustarse al ancho. Las fechas ausentes
siguen mostrando `—`. El enlace del puesto presenta su nombre real y «Crear puesto de
trabajo» permanece visible cuando corresponde.
Al guardar un activo desde el inventario, la selección del resultado reutiliza
`selectAsset`, igual que el clic de fila, conservando los parámetros `template` y `q`
de la URL. El cierre del editor no restablece la pestaña Todo ni elimina la búsqueda.
El alta de puesto desde Personas reutiliza el flujo de `CompanyInventory`, incluidas
las altas rápidas relacionadas. `initialPersonId` recorre la página y el diálogo hasta
los valores iniciales del formulario; solo preselecciona una persona activa de sus
lookups. Se aplica como valor inicial editable, sin efectos de sincronización que
puedan sobrescribir cambios manuales y sin modificar un puesto en edición.
La tabla de Personas añade «Puestos bajo su responsabilidad» con `ul`/`li` y enlaces
`BrowserLink` a las fichas de los puestos activos. Reutiliza `personWorkstations` y la
consulta compartida de inventario (`useListQuery`, clave por compañía), sin peticiones
por fila ni consultas de perfiles. La columna permite ordenar por los nombres de los
puestos, conserva `—` cuando no hay relación y muestra carga/error de inventario con
reintento junto a la tabla, sin ocultar los datos de las personas.
## Contadores en la navegación de compañía
`CountBadge`, de `@repo/rdx-ui/components/count-badge`, reutiliza `Badge` de shadcn/ui
con ancho mínimo, cifra tabular y formato numérico según `locale`. Recibe `count`,
`loadingLabel`, `errorLabel`, `failed` y `className`. Conserva cero; un total desconocido
muestra `—`, con etiqueta accesible de carga o error. Si falla una actualización y hay
un total anterior, lo conserva con el aviso en el título.
La navegación de compañía utiliza el contador para Personas, Departamentos, Sedes,
Activos, Recursos, Puestos de trabajo y Perfiles de usuario. Las cifras se alinean a
la derecha y la entrada activa mantiene los colores semánticos. Cuenta todos los
registros que presenta cada listado, incluidos los inactivos, sin aplicar sus filtros.
Los inventarios y perfiles usan `useListQuery` con las claves existentes por compañía:
comparten caché e invalidaciones de altas, cambios y bajas, sin nuevas rutas HTTP ni
estado remoto duplicado. Antes de cargar la compañía, esas consultas están deshabilitadas.

View File

@ -12,6 +12,16 @@ respectivas. Los formularios y diálogos comunes de alta y edición pertenecen a
La pantalla se divide en cabecera, contenido y pie opcional. La cabecera muestra título y subtítulo breve a la izquierda y acciones globales a la derecha. El alta es la acción primaria, normalmente «+ Añadir [entidad]», y abre un modal; se conservan las etiquetas actuales cuando ya describen una operación concreta. Nunca mostrar el formulario junto al listado.
El título y el botón de alta muestran el mismo icono de entidad antes del texto, tomado
del catálogo `@repo/rdx-ui/lib/icons` y coherente con la navegación. Activos utiliza
`BoxIcon`, Recursos `LayersIcon`, Personas `PersonIcon`, Perfiles de usuario
`WorkProfileIcon` y Puestos `MonitorIcon`.
`EntityListHeader.icon` presenta el icono del título a 24 px, sin encogerse; el botón
reutiliza el tamaño de icono de `Button`. Ambos son decorativos (`aria-hidden`),
conservando el texto y el nombre accesible. No sustituir el icono de entidad por un
signo «+» ni añadir ambos. Esta pauta se aplica también a catálogos, configuraciones
y sublistados con alta propia; las demás acciones conservan su representación.
El contenido contiene una tabla y cuatro bandas, en este orden:
1. Búsqueda rápida a la izquierda y filtros propios de la entidad a la derecha. El texto indica los campos buscables. Cuando hay criterios aplicados, Limpiar filtros restaura los valores iniciales de la pantalla; no cambia el ámbito de compañía ni el perfil desde el que se ha navegado.
@ -140,3 +150,12 @@ Verificación: 204 pruebas correctas, tipos y compilación correctos; los cinco
Los formularios que seleccionan activos usan un diálogo con el mismo `AssetList` del inventario. Reutiliza `EntityTable`, búsqueda, pestañas por plantilla, columnas y paginación. La selección provisional no cambia el formulario hasta confirmar, ni altera la URL o filtros del listado padre. Cada consumidor restringe los candidatos y declara selección única o múltiple con límite. Véase [componentes de interfaz](componentes-de-interfaz.md#selección-de-activos-en-diálogo).
Tickets, intervenciones y sus plantillas se conservan en [el archivo de funcionalidades](funcionalidades-archivadas.md), fuera del inventario activo.
### Columna visual del tipo de activo
En `AssetList`, el icono de plantilla ocupa una columna `icon` compacta antes de Nombre,
con cabecera accesible y tooltip con el nombre de plantilla, disponible con puntero y
teclado. La columna Plantilla conserva solo el nombre. La columna visual no se ordena
ni se oculta y no sustituye la ordenación inicial por Nombre. En `PickerList`, la
selección permanece siempre por delante del icono.

View File

@ -64,7 +64,7 @@ vigentes para no enviar sede, responsable o composición capturados al abrir el
La respuesta confirmada actualiza la caché de inventario y después invalida esa consulta;
no se invalidan perfiles ni software porque sus referencias no cambian.
La ficha separa Resumen, Perfiles y Software con pestañas accesibles y desplazables en
La ficha separa Información, Software e Historial con pestañas accesibles y desplazables en
espacios estrechos. Bajo el nombre, la cabecera muestra estado, sede y responsable si
existe, con enlaces a sede y persona. A la derecha aparece Editar puesto y un menú de
acciones con Desactivar y Eliminar definitivamente, manteniendo sus confirmaciones.
@ -72,11 +72,12 @@ Nuevo perfil se sitúa en la cabecera del panel Perfiles y se habilita únicamen
ordenador principal incluido en su composición y no retirado; el bloqueo explica cómo
completar el puesto.
El resumen destaca el ordenador principal y muestra los demás activos como periféricos
Información reúne activos, personas y perfiles. Destaca el ordenador principal y muestra
los demás activos como periféricos
mediante `AssetSummaryCard`, sin duplicados. Sus nombres enlazan las fichas de activos,
que conservan el detalle técnico completo. Las personas se presentan con avatar, con el
responsable primero y una única aparición por persona. La carga o el error de perfiles
no se confunde con ausencia de personas. La pestaña Perfiles reúne los del ordenador
no se confunde con ausencia de personas. El panel Perfiles en Información reúne los del ordenador
principal, incluidos los archivados, mediante la tabla común con búsqueda, ordenación
y paginación. Conserva los enlaces y avisos de revisión, sin repetir el mismo ordenador
en cada fila. El software se carga al abrir su pestaña y mantiene la gestión existente.
@ -85,6 +86,17 @@ Los paneles de activos, personas y perfiles reutilizan `DetailPanel` de `@repo/r
El diseño responde al ancho disponible y mantiene accesibles contenidos y acciones en
móvil y escritorio. Véase [Componentes de interfaz](componentes-de-interfaz.md).
La pestaña Historial muestra las revisiones registradas del puesto, de más reciente a
más antigua: fecha y hora, autor, nombre, estado, sede, responsable, ordenador principal
y activos de cada revisión. Los nombres de las referencias se resuelven con los datos
disponibles; las referencias ausentes muestran `—`. Las instantáneas mantienen sus
identificadores y valores originales; no se reconstruye un historial de cambios de
nombre de activos o sedes que el contrato no registra. `WorkstationHistory` comparte
la presentación entre la ficha y el diálogo del listado mediante `TimeLine` de
`@repo/rdx-ui/components/time-line`. No se añaden consultas ni nuevas escrituras.
## Desactivación y eliminación
El listado y la ficha completa ofrecen **Desactivar** y **Eliminar definitivamente**, mediante un mismo diálogo reutilizable con confirmación y resultado de servidor.
@ -123,3 +135,46 @@ el diálogo grande con altura estable y los mismos grupos, conforme al [patrón
Anterior conserva valores y Siguiente valida los requisitos del paso. Guardar solo aparece en el resumen y no se envía el alta desde pasos intermedios. La edición mantiene un formulario único y permite conservar/completar puestos anteriores o desactivados. Cancelar permanece en el pie del modal y la ayuda se mantiene anclada inmediatamente encima, fuera del scroll de campos. Los ejemplos extensos tienen desplazamiento propio limitado al espacio disponible.
Se elimina `sharedAssetIds` de contratos, dominio, reservas y vistas. La migración explícita `removeSharedWorkstationAssets`, ejecutada durante la preparación del esquema, elimina las asociaciones de tipo compartido tanto actuales como históricas y limpia sus referencias técnicas cuando no existe ninguna asociación exclusiva histórica. No convierte activos compartidos en exclusivos, no borra activos ni revisiones y no genera reservas conflictivas. Es transaccional e idempotente. Las composiciones exclusivas e identidades se conservan.
## Crear y consultar puestos desde el inventario
La columna Puesto del inventario muestra un botón visible «Crear puesto de trabajo» para portátiles, PC de
sobremesa y servidores operativos y sin asignación. Este acceso rápido identifica
estos tipos mediante el icono configurado en la plantilla (`laptop`, `desktop`,
`server`); no utiliza nombres traducidos ni cambia las restricciones del dominio.
Si falta el catálogo o no se reconoce el tipo, no ofrece crear.
La acción abre el mismo formulario de cuatro pasos, con el activo incorporado como
ordenador principal y su sede preseleccionada si está activa. Conserva la propuesta
de nombre por sede y ordenador; permite revisar sede, responsable y composición antes
de guardar. Cancelar no crea puestos. Guardar utiliza el caso de uso existente,
invalida el inventario y abre la ficha del nuevo puesto; no transforma ni duplica el activo.
Si el activo está en la composición vigente de un puesto, la columna muestra su nombre
como enlace a la ficha independientemente de su papel como ordenador principal o periférico y
de su estado técnico. Incluye puestos inactivos que todavía conservan composición;
si hay varios, cada uno conserva su propio enlace. La acción de crear sigue visible
fuera del menú de edición y eliminación. Las asociaciones que solo aparecen en
revisiones históricas no se consideran vigentes. Se preservan las comprobaciones de
compañía, reservas y estado operativo del formulario y del servidor ante concurrencia.
## Alta desde una persona
El menú de cada persona activa ofrece «Nuevo puesto de trabajo». La acción navega al
listado de puestos de la compañía y abre el mismo formulario de alta mediante la
navegación de creación existente, aportando `personId` como contexto. El diálogo y
el formulario reciben `initialPersonId`: el responsable comienza con esa persona si
está activa y aparece en los datos de la compañía. El usuario puede cambiarlo o
retirarlo. Un identificador desconocido o de una persona inactiva no se preselecciona.
La edición conserva siempre el responsable guardado del puesto. Se mantienen las
altas relacionadas de sede y persona, los cuatro pasos y las validaciones de servidor.
El listado de Personas muestra la columna «Puestos bajo su responsabilidad», con un
enlace por puesto activo cuyo responsable actual sea esa persona. Reutiliza el mismo
criterio que la ficha de persona: no incluye puestos usados solo mediante un perfil,
responsabilidades históricas, puestos desactivados ni registros de otra compañía.
La ausencia de puestos muestra `—`; el inventario se consulta una vez por compañía
mediante la caché compartida, con estado de carga y recuperación explícita de errores.

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
},
"dependencies": {
"@hookform/resolvers": "5.2.2",

View File

@ -97,6 +97,12 @@
"deletingAsset": "Deleting asset…",
"deleteAssetError": "The asset could not be deleted. Please try again.",
"viewAsset": "Details and history",
"assetWorkstation": {
"create": "Create workstation",
"view": "View workstation",
"viewNamed": "View workstation: {{name}}"
},
"assetType": "Asset type",
"template": "Template",
"site": "Site",
"person": "Assigned person",
@ -108,7 +114,7 @@
"retired": "Retired",
"noAssets": "This company has no assets yet.",
"noAssignment": "Unassigned",
"noValue": "Not specified",
"noValue": "—",
"characteristics": "Characteristics",
"history": "History",
"recordedBy": "Recorded by {{name}}",
@ -386,7 +392,11 @@
"profilePerson": "Has a profile on the main computer",
"needsReview": "References awaiting review",
"missingAsset": "Asset unavailable.",
"overview": "Overview",
"information": "Information",
"created": "Workstation created",
"updated": "Workstation updated",
"noHistory": "No workstation revisions.",
"historyHint": "Revisions from newest to oldest, with the data recorded at each change.",
"software": "Computer software",
"sections": "Workstation sections",
"peripherals": "Other assets and peripherals",
@ -466,4 +476,4 @@
"fieldsDescription": "Complete the template details. Fields marked with * are required.",
"nameDescription": "Review the suggested name, technical status, site and registration date. Add notes if you need to provide more context."
}
}
}

View File

@ -97,6 +97,12 @@
"deletingAsset": "Eliminando activo…",
"deleteAssetError": "No se ha podido eliminar el activo. Inténtalo de nuevo.",
"viewAsset": "Ficha e historial",
"assetWorkstation": {
"create": "Crear puesto de trabajo",
"view": "Ver puesto de trabajo",
"viewNamed": "Ver puesto: {{name}}"
},
"assetType": "Tipo de activo",
"template": "Plantilla",
"site": "Sede",
"person": "Persona asignada",
@ -108,7 +114,7 @@
"retired": "Retirado",
"noAssets": "Esta compañía todavía no tiene activos.",
"noAssignment": "Sin asignar",
"noValue": "Sin indicar",
"noValue": "—",
"characteristics": "Características",
"history": "Historial",
"recordedBy": "Registrado por {{name}}",
@ -386,7 +392,11 @@
"profilePerson": "Con perfil en el ordenador principal",
"needsReview": "Referencias pendientes de revisión",
"missingAsset": "Activo no disponible.",
"overview": "Resumen",
"information": "Información",
"created": "Alta del puesto",
"updated": "Actualización del puesto",
"noHistory": "No hay revisiones del puesto.",
"historyHint": "Revisiones de más reciente a más antigua, con los datos registrados en cada cambio.",
"software": "Software del ordenador",
"sections": "Secciones del puesto de trabajo",
"peripherals": "Otros activos y periféricos",
@ -466,4 +476,4 @@
"fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
"nameDescription": "Revisa el nombre propuesto, el estado técnico, la sede y la fecha de alta. Añade notas si necesitas aportar más contexto."
}
}
}

View File

@ -29,6 +29,30 @@ export function AssetDetail(props: AssetDetailProps) {
? t(value ? "yes" : "no")
: String(value);
const characteristics = (
<FieldSections
items={asset.specification.fields}
layout="rows"
renderItem={(field) => (
<DetailField
key={field.fieldId}
label={
<>
{field.name}
{field.unit ? ` (${field.unit})` : ""}
</>
}
>
{display(asset.values.characteristics[field.fieldId])}
</DetailField>
)}
sectionPresentation={compact ? "accordion" : "details"}
sections={asset.specification.sections}
ungroupedLabel={t("sections.ungrouped")}
values
/>
);
return (
<div className="@container space-y-5">
{!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>}
@ -44,7 +68,10 @@ export function AssetDetail(props: AssetDetailProps) {
</dl>
</DetailSection>
)}
<DetailSection title={t("detailSections.assignment")}>
<DetailSection
accordionValue={compact ? "assignment" : undefined}
title={t("detailSections.assignment")}
>
<dl>
<DetailField label={t("registeredOn")}>
{formatAssetRegistrationDate(asset.values.registeredOn, i18n.language) ?? t("noValue")}
@ -75,33 +102,23 @@ export function AssetDetail(props: AssetDetailProps) {
)}
</dl>
</DetailSection>
<div>
{!asset.specification.sections?.length && (
<FormSectionTitle>{t("characteristics")}</FormSectionTitle>
)}
<FieldSections
items={asset.specification.fields}
layout="rows"
renderItem={(field) => (
<DetailField
key={field.fieldId}
label={
<>
{field.name}
{field.unit ? ` (${field.unit})` : ""}
</>
}
>
{display(asset.values.characteristics[field.fieldId])}
</DetailField>
{compact && !asset.specification.sections?.length ? (
<DetailSection accordionValue="characteristics" title={t("characteristics")}>
{characteristics}
</DetailSection>
) : (
<div>
{!asset.specification.sections?.length && (
<FormSectionTitle>{t("characteristics")}</FormSectionTitle>
)}
sections={asset.specification.sections}
ungroupedLabel={t("sections.ungrouped")}
values
/>
</div>
{characteristics}
</div>
)}
{asset.values.notes && (
<DetailSection title={t("detailSections.notes")}>
<DetailSection
accordionValue={compact ? "notes" : undefined}
title={t("detailSections.notes")}
>
<p className="whitespace-pre-wrap break-words text-sm">{asset.values.notes}</p>
</DetailSection>
)}

View File

@ -1,5 +1,6 @@
import { DetailSection } from "@repo/rdx-ui/components/detail-section";
import { SelectionPanel } from "@repo/rdx-ui/components/selection-panel";
import { Accordion } from "@repo/shadcn-ui/components/accordion";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -71,24 +72,39 @@ export function AssetSelectionPanel({
</>
}
>
<AssetDetail asset={asset} compact lookups={lookups}>
<DetailSection title={t("relations")}>
<AssetRelationships
assetId={asset.id}
compact
companyId={companyId}
sites={lookups.sites}
/>
</DetailSection>
<DetailSection title={t("support.title")}>
<AssetSupport
assetId={asset.id}
compact
companyId={companyId}
retired={asset.values.status === "retired"}
/>
</DetailSection>
</AssetDetail>
<Accordion
defaultValue={[
"assignment",
"characteristics",
"ungrouped",
"notes",
"relations",
"support",
...(asset.specification.sections?.map((section) => section.id) ?? []),
]}
keepMounted
key={asset.id}
multiple
>
<AssetDetail asset={asset} compact lookups={lookups}>
<DetailSection accordionValue="relations" title={t("relations")}>
<AssetRelationships
assetId={asset.id}
compact
companyId={companyId}
sites={lookups.sites}
/>
</DetailSection>
<DetailSection accordionValue="support" title={t("support.title")}>
<AssetSupport
assetId={asset.id}
compact
companyId={companyId}
retired={asset.values.status === "retired"}
/>
</DetailSection>
</AssetDetail>
</Accordion>
</SelectionPanel>
);
}

View 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();
}
}
});

View File

@ -61,6 +61,7 @@ export function useAssetInventoryPageController({
});
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
const [workstationAssetId, setWorkstationAssetId] = useState<string | undefined>();
const [deleteId, setDeleteId] = useState<string | undefined>();
const remove = useMutation({
mutationFn: (id: string) => removeAsset(companyId, id),
@ -129,6 +130,8 @@ export function useAssetInventoryPageController({
setDetailOpen,
selected,
edited,
workstationAsset: assets.find((asset) => asset.id === workstationAssetId),
setWorkstationAssetId,
viewed,
};
}

View File

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

View File

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

View File

@ -1,20 +1,24 @@
import { BrowserLink, EntityNotFound } from "@metis/core/client";
import { BrowserLink, EntityNotFound, navigateTo } from "@metis/core/client";
import {
EntityAction,
EntityListHeader,
EntityRowActions,
} from "@repo/rdx-ui/components/entity-list";
import { BoxIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
import { CatalogError } from "../../../catalog";
import { InventoryFeedback } from "../../../shared";
import { WorkstationEditorDialog } from "../../../workstations/shared/ui/workstation-editor-dialog";
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
import { AssetList } from "../../shared/ui/asset-list";
import { useAssetInventoryPageController } from "../controllers";
import type { AssetInventoryPageProps } from "../types";
import { AssetWorkstationAction } from "./asset-workstation-action";
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
return id === selectedId && detailOpen
? "bg-accent hover:bg-accent [&>[data-pinned=right]]:bg-accent [&>[data-pinned=right]]:group-hover/entity-row:bg-accent [&>[data-pinned=right]]:group-has-aria-expanded/entity-row:bg-accent"
@ -33,6 +37,8 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
setSearch,
editor,
setEditor,
workstationAsset,
setWorkstationAssetId,
deleted,
deleteId,
setDeleteId,
@ -76,11 +82,13 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
</Button>
)}
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
<BoxIcon aria-hidden="true" />
{t("newAsset")}
</Button>
</div>{" "}
</>
}
icon={<BoxIcon />}
locale={i18n.language}
title={t("inventory")}
/>
@ -103,6 +111,21 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
assets={query.data.assets}
catalog={catalog.data?.items ?? []}
extraColumns={[
{
id: "workstation",
label: t("workstationInAsset"),
hideable: false,
render: (asset) => (
<AssetWorkstationAction
asset={asset}
onCreate={setWorkstationAssetId}
template={catalog.data?.items.find(
(item) => item.id === asset.values.templateId
)}
workstations={query.data?.workstations ?? []}
/>
),
},
{
id: "actions",
label: t("actions"),
@ -117,6 +140,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
>
{t("viewAsset")}
</EntityAction>
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
{t("edit")}
</EntityAction>
@ -172,12 +196,22 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
lookups={lookups}
onClose={() => setEditor(undefined)}
onSaved={(id) => {
setSelectedId(id);
setDetailOpen(true);
selectAsset(id);
setEditor(undefined);
}}
/>
)}
{workstationAsset && (
<WorkstationEditorDialog
companyId={companyId}
initialAsset={workstationAsset}
key={workstationAsset.id}
lookups={lookups}
onClose={() => setWorkstationAssetId(undefined)}
onSaved={(id) => navigateTo(`/companies/${companyId}/workstations/${id}`)}
workstation={undefined}
/>
)}
{viewed && (
<AssetDetailDialog
asset={viewed}

View File

@ -6,6 +6,7 @@ import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { CatalogItemSchema } from "../../../common";
import { AssetSchema } from "../../../common/inventory";
import { assetsEnglish, assetsSpanish } from "../../shared";
import { AssetList } from "../shared/ui/asset-list";
@ -103,6 +104,10 @@ test("selector: comparte búsqueda, pestañas por identidad y columnas del inven
assert.equal(firstHeader?.includes('data-pinned="right"'), false);
assert.equal(firstHeader?.includes("aria-sort"), false);
assert.ok(firstCell?.includes('role="checkbox"'));
const cells = [...html.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/g)].map((match) => match[1]);
assert.ok(cells[1]?.includes('data-slot="tooltip-trigger"'));
assert.ok(cells[2]?.includes("Equipo 2"));
assert.ok(html.includes('aria-checked="true"'));
assert.ok(html.includes("Ocupado"));
assert.ok(html.includes(language === "es" ? "Buscar" : "Search"));
@ -205,3 +210,45 @@ test("picker: búsqueda rápida disponible durante carga y error, sin anunciar u
}
}
});
test("inventario es/en: icono compacto antes del nombre, plantilla solo textual y ordenación por nombre", async () => {
const template = CatalogItemSchema.parse({
id: id(12),
companyId: null,
builtin: false,
active: true,
effectiveActive: true,
overridden: false,
bindings: [],
values: {
kind: "template",
name: "Portátiles",
description: "",
icon: "laptop",
fields: [{ fieldId: id(5), required: false }],
},
});
for (const language of ["es", "en"]) {
const html = await render(language, <AssetList {...listProps} catalog={[template]} />);
const table = html.match(/<table[\s\S]*?<\/table>/)?.[0];
assert.ok(table);
const headers = [...table.matchAll(/<th\b[^>]*>[\s\S]*?<\/th>/g)].map(([header]) => header);
const row = table.match(/<tbody[\s\S]*?<tr\b[^>]*>([\s\S]*?)<\/tr>/)?.[1] ?? "";
const cells = [...row.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/g)].map((match) => match[1]);
assert.ok(headers[0]?.includes(language === "es" ? "Tipo de activo" : "Asset type"));
assert.equal(headers[0]?.includes("aria-sort"), false);
assert.ok(headers[0]?.includes("min-w-0"));
assert.ok(headers[1]?.includes('aria-sort="ascending"'));
assert.ok(cells[0]?.includes('aria-label="Portátiles"'));
assert.ok(cells[0]?.includes('tabindex="0"'));
assert.ok(cells[0]?.includes('data-slot="tooltip-trigger"'));
assert.ok(cells[0]?.includes("tabler-icon-device-laptop"));
assert.ok(cells[1]?.includes("Equipo 2"));
assert.ok(cells[2]?.includes("Portátiles"));
assert.equal(cells[2]?.includes("<svg"), false);
assert.ok(table.includes("tabler-icon-box"));
}
});

View File

@ -506,13 +506,76 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
for (const group of ["Ubicación y asignación", "Características", "Observaciones"]) {
assert.ok(html.includes(group));
}
assert.ok(html.includes("<summary"));
assert.ok(html.includes('open=""'));
assert.ok(html.includes('data-slot="accordion"'));
assert.ok(html.includes('data-slot="accordion-trigger"'));
assert.ok((html.match(/aria-expanded="true"/g) ?? []).length >= 5);
assert.ok(html.includes(`href="/companies/${companyId}/sites"`));
assert.ok(html.includes(`href="/companies/${companyId}/catalogs/assets?tab=templates"`));
assert.ok(html.includes("ABC123"));
assert.ok(html.includes("Fecha de alta"));
assert.ok(html.includes("29 feb 2024"));
const grouped = AssetSchema.parse({
...asset,
specification: {
...asset.specification,
sections: [
{ id: fieldId, name: "Red", description: "Datos de red" },
{ id: personId, name: "Grupo vacío", description: "" },
],
fields: [
...asset.specification.fields.map((field) => ({ ...field, sectionId: fieldId })),
{
fieldId: siteId,
name: "Contador",
type: "number",
unit: "",
choices: [],
required: false,
},
{
fieldId: personId,
name: "Color",
type: "boolean",
unit: "",
choices: [],
required: false,
sectionId: companyId,
},
],
},
values: {
...asset.values,
characteristics: { ...asset.values.characteristics, [siteId]: 0, [personId]: false },
},
});
for (const language of ["es", "en"]) {
const labels = language === "es" ? assetsSpanish : assetsEnglish;
const groupedHtml = await render(
language,
<AssetSelectionPanel
asset={grouped}
canEdit
companyId={companyId}
lookups={lookups}
onClose={() => undefined}
onDelete={() => undefined}
onEdit={() => undefined}
onView={() => undefined}
open
/>
);
assert.ok(groupedHtml.includes('data-slot="accordion"'));
assert.ok((groupedHtml.match(/aria-expanded="true"/g) ?? []).length >= 6);
assert.ok(groupedHtml.includes("Datos de red"));
assert.ok(groupedHtml.includes(labels.sections.ungrouped));
assert.ok(groupedHtml.includes("ABC123"));
assert.ok(groupedHtml.includes(">0</dd>"));
assert.ok(groupedHtml.includes(`>${labels.no}</dd>`));
assert.equal(groupedHtml.includes("Grupo vacío"), false);
}
});
test("texto largo: área editable con ayuda accesible", async () => {

View File

@ -7,10 +7,11 @@ import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory";
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
import { assetCharacteristicsSummary } from "../helpers/asset-characteristics-summary";
import { formatAssetRegistrationDate } from "../helpers/format-asset-registration-date";
import { AssetTemplateIndicator } from "./asset-template-indicator";
/** Inventario y selectores comparten columnas, búsqueda, pestañas y paginación. */
export function AssetList({
assets,
@ -98,13 +99,31 @@ export function AssetList({
<EntityTable
columns={[
...leadingColumns,
{
id: "icon",
label: t("assetType"),
hideable: false,
render: (asset) => {
const template = catalog.find((item) => item.id === asset.values.templateId);
return (
<AssetTemplateIndicator
icon={template?.values.kind === "template" ? template.values.icon : undefined}
name={asset.specification.templateName}
/>
);
},
},
{
id: "name",
label: t("name"),
value: (asset) => asset.values.name,
hideable: false,
render: (asset) => {
const summary = assetCharacteristicsSummary(asset, { yes: t("yes"), no: t("no") });
const summary = assetCharacteristicsSummary(asset, {
yes: t("yes"),
no: t("no"),
});
return (
<div className="space-y-1">
@ -126,38 +145,39 @@ export function AssetList({
id: "template",
label: t("template"),
value: (asset) => asset.specification.templateName,
render: (asset) => {
const template = catalog.find((item) => item.id === asset.values.templateId);
return (
<span className="inline-flex items-center gap-2">
<AssetTemplateIcon
icon={template?.values.kind === "template" ? template.values.icon : undefined}
/>
{asset.specification.templateName}
</span>
);
},
render: (asset) => (
<span className="text-muted-foreground">{asset.specification.templateName}</span>
),
},
{
id: "state",
label: t("status"),
value: (asset) => t(asset.displayStatus),
render: (asset) => (
<EntityStatus active={asset.values.status === "operational"}>
{t(asset.displayStatus)}
</EntityStatus>
<span className="whitespace-nowrap">
<EntityStatus active={asset.values.status === "operational"}>
{t(asset.displayStatus)}
</EntityStatus>
</span>
),
},
{ id: "site", label: t("site"), value: siteName, render: siteName },
...extraColumns.filter((column) => column.id !== "actions"),
{
id: "registeredOn",
label: t("registeredOn"),
value: (asset) => asset.values.registeredOn ?? "",
render: (asset) =>
formatAssetRegistrationDate(asset.values.registeredOn, i18n.language),
render: (asset) => {
const date = formatAssetRegistrationDate(asset.values.registeredOn, i18n.language);
return date ? (
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{date}
</span>
) : null;
},
},
{ id: "site", label: t("site"), value: siteName, render: siteName },
...extraColumns,
...extraColumns.filter((column) => column.id === "actions"),
]}
emptyMessage={t("noAssets")}
filterKey={templateId ?? "all"}

View File

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

View File

@ -6,6 +6,7 @@ import {
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { Building2Icon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useId } from "react";
import { useTranslation } from "react-i18next";
@ -31,10 +32,12 @@ export function BrandCatalogPage() {
setOpen(true);
}}
>
+ {t("brands.new")}
<Building2Icon aria-hidden="true" />
{t("brands.new")}
</Button>
}
description={t("brands.introduction")}
icon={<Building2Icon />}
locale={i18n.language}
title={t("brands.title")}
/>

View File

@ -1,4 +1,5 @@
import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list";
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -37,11 +38,13 @@ export function AssetCatalogPage(props: AssetCatalogPageProps) {
actions={
<>
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
<LayoutTemplateIcon aria-hidden="true" />
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
</Button>
</>
}
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
icon={<LayoutTemplateIcon />}
locale={i18n.language}
title={t(scope === "global" ? "title" : "companyTitle")}
/>

View File

@ -9,6 +9,7 @@ export { inventoryApi } from "./shared/api";
export { inventoryKeys } from "./shared/queries";
export { AssetSupport } from "./support-connections";
export { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations";
export { WorkstationHistory } from "./workstations/history/ui/workstation-history";
export { WorkstationNameEditor } from "./workstations/rename/ui/workstation-name-editor";
export { WorkstationActions } from "./workstations/shared/ui/workstation-actions";
export { WorkstationLifecycleDialog } from "./workstations/shared/ui/workstation-lifecycle-dialog";

View File

@ -1,7 +1,8 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
import type { WorkstationDTO } from "../../../../common";
import type { WorkstationHistoryProps } from "./workstation-history";
import { WorkstationHistory } from "./workstation-history";
export function WorkstationHistoryDialog({
workstation,
@ -9,14 +10,8 @@ export function WorkstationHistoryDialog({
siteName,
members,
onClose,
}: {
workstation: WorkstationDTO;
personName: (id: string | null) => string;
siteName: (id: string | null) => string;
members: (ids: readonly string[], empty?: string) => string;
onClose: () => void;
}) {
const { t, i18n } = useTranslation("assets");
}: WorkstationHistoryProps & { onClose: () => void }) {
const { t } = useTranslation("assets");
return (
<FormDialog
@ -30,28 +25,14 @@ export function WorkstationHistoryDialog({
title={t("workstationHistory")}
wide
>
<ol className="space-y-4">
{[...workstation.history].reverse().map((revision) => (
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {t("recordedBy", { name: revision.actorName })}
</p>
<p>
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
</p>
<p>
{siteName(revision.values.siteId)} · {personName(revision.values.personId)}
</p>
<p>
{t("workstationAssets")}: {members(revision.values.assetIds)}
</p>
</li>
))}
</ol>
<FormBody>
<WorkstationHistory
members={members}
personName={personName}
siteName={siteName}
workstation={workstation}
/>
</FormBody>
</FormDialog>
);
}

View File

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

View File

@ -6,10 +6,14 @@ import { getAssetInventory } from "../../../assets/list/api";
import { inventoryKeys } from "../../../shared/queries";
import type { WorkstationsPageProps } from "../types";
export function useWorkstationsPageController({ companyId, lookups }: WorkstationsPageProps) {
export function useWorkstationsPageController({
companyId,
lookups,
initialCreate = false,
}: WorkstationsPageProps) {
const { t } = useTranslation("assets");
const [search, setSearch] = useState("");
const [editor, setEditor] = useState<string>();
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
const [view, setView] = useState<string>();
const query = useListQuery({
queryKey: inventoryKeys.company(companyId),

View File

@ -2,5 +2,7 @@ import type { InventoryLookups } from "../../../assets/shared/types";
export type WorkstationsPageProps = {
companyId: string;
initialCreate?: boolean | undefined;
initialPersonId?: string | undefined;
lookups: InventoryLookups;
};

View File

@ -6,6 +6,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { MonitorIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -18,7 +19,7 @@ import { useWorkstationsPageController } from "../controllers";
import type { WorkstationsPageProps } from "../types";
export function WorkstationsPage(props: WorkstationsPageProps) {
const { companyId, lookups } = props;
const { companyId, lookups, initialPersonId } = props;
const [operation, setOperation] = useState<{ id: string; action: "decommission" | "delete" }>();
const {
search,
@ -45,10 +46,12 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
<>
{" "}
<Button disabled={!query.data} onClick={() => setEditor("new")}>
<MonitorIcon aria-hidden="true" />
{t("newWorkstation")}
</Button>{" "}
</>
}
icon={<MonitorIcon />}
locale={i18n.language}
title={t("workstations")}
/>
@ -93,8 +96,17 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
label: t("workstationPerson"),
value: (item) =>
lookups.people.find((person) => person.id === item.values.personId)?.name,
render: (item) =>
lookups.people.find((person) => person.id === item.values.personId)?.name,
render: (item) => {
const person = lookups.people.find(
(candidate) => candidate.id === item.values.personId
);
return person ? (
<BrowserLink href={`/companies/${companyId}/people/${person.id}`}>
{person.name}
</BrowserLink>
) : null;
},
},
{
id: "state",
@ -110,7 +122,25 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
id: "exclusive",
label: t("workstationAssets"),
render: (item) =>
item.values.assetIds.length ? members(item.values.assetIds) : null,
item.values.assetIds.length ? (
<ul className="space-y-1">
{item.values.assetIds.map((id) => {
const asset = query.data?.assets.find((candidate) => candidate.id === id);
return (
<li key={id}>
{asset ? (
<BrowserLink href={`/companies/${companyId}/assets/${asset.id}`}>
{asset.values.name}
</BrowserLink>
) : (
"—"
)}
</li>
);
})}
</ul>
) : null,
},
{
id: "count",
@ -175,6 +205,7 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
{editor && (
<WorkstationEditorDialog
companyId={companyId}
initialPersonId={initialPersonId}
lookups={lookups}
onClose={() => setEditor(undefined)}
workstation={edited}

View File

@ -19,6 +19,8 @@ import { suggestedWorkstationName } from "../workstation-name";
export function useWorkstationFormController({
companyId,
workstation,
initialAsset,
initialPersonId,
onSaved,
lookups,
}: WorkstationFormProps) {
@ -28,16 +30,26 @@ export function useWorkstationFormController({
const isWizard = !workstation;
const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
const activeSites = lookups.sites.filter((site) => site.active);
const initialSite = activeSites.length === 1 ? activeSites[0] : undefined;
const sourceAsset = initialAsset?.companyId === companyId ? initialAsset : undefined;
const initialSite =
activeSites.find((site) => site.id === sourceAsset?.values.siteId) ??
(activeSites.length === 1 ? activeSites[0] : undefined);
const initialName = suggestedWorkstationName(
sourceAsset?.values.name,
initialSite?.name,
(computer, site) => t("workstationSuggestedName", { computer, site })
);
const form = useForm<WorkstationInput>({
resolver: zodResolver(WorkstationInputSchema),
defaultValues: workstation?.values ?? {
name: initialSite ? t("workstationNameBySite", { site: initialSite.name }) : "",
name:
initialName ?? (initialSite ? t("workstationNameBySite", { site: initialSite.name }) : ""),
active: true,
siteId: initialSite?.id ?? null,
personId: null,
assetIds: [],
computerAssetId: null,
personId:
lookups.people.find((person) => person.id === initialPersonId && person.active)?.id ?? null,
assetIds: sourceAsset ? [sourceAsset.id] : [],
computerAssetId: sourceAsset?.id ?? null,
},
});
const values = form.watch();

View File

@ -1,9 +1,11 @@
import type { WorkstationDTO } from "../../../../common";
import type { AssetDTO, WorkstationDTO } from "../../../../common";
import type { InventoryLookups } from "../../../assets/shared/types";
export type WorkstationFormProps = {
companyId: string;
lookups: InventoryLookups;
workstation?: WorkstationDTO | undefined;
initialAsset?: AssetDTO | undefined;
initialPersonId?: string | undefined;
onSaved: (id: string) => void;
};

View File

@ -8,11 +8,17 @@ import { WorkstationForm } from "./workstation-form";
export function WorkstationEditorDialog({
workstation,
companyId,
initialAsset,
initialPersonId,
lookups,
onClose,
onSaved,
}: {
workstation: ComponentProps<typeof WorkstationForm>["workstation"];
companyId: string;
initialAsset?: ComponentProps<typeof WorkstationForm>["initialAsset"];
initialPersonId?: string | undefined;
onSaved?: (id: string) => void;
lookups: ComponentProps<typeof WorkstationForm>["lookups"];
onClose: () => void;
}) {
@ -39,8 +45,13 @@ export function WorkstationEditorDialog({
>
<WorkstationForm
companyId={companyId}
initialAsset={initialAsset}
initialPersonId={initialPersonId}
lookups={lookups}
onSaved={() => onClose()}
onSaved={(id) => {
onClose();
onSaved?.(id);
}}
workstation={workstation}
/>
</FormDialog>

View File

@ -1,5 +1,9 @@
{
"companies": {
"navigationCounts": {
"loading": "Loading total…",
"error": "The total could not be refreshed."
},
"navigation": {
"invalidRoute": "Invalid address",
"notFound": "Page not found",

View File

@ -1,5 +1,9 @@
{
"companies": {
"navigationCounts": {
"loading": "Cargando total…",
"error": "No se ha podido actualizar el total."
},
"navigation": {
"invalidRoute": "La dirección no es válida",
"notFound": "No se ha encontrado esta página",

View File

@ -1,4 +1,5 @@
import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list";
import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -49,12 +50,14 @@ export function AccessWorkspacePage(props: AccessWorkspacePageProps) {
{t("helpButton")}
</Button>
<Button disabled={!data} onClick={() => setEditor("new")}>
<KeyRoundIcon aria-hidden="true" />
{t(createLabels[kind])}
</Button>
</div>{" "}
</>
}
description={t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
icon={<KeyRoundIcon />}
locale={i18n.language}
title={t(`kinds.${kind}`)}
/>

View File

@ -5,6 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -53,10 +54,12 @@ export function ApplicationConfigurationsPage(props: ApplicationConfigurationsPa
<>
{" "}
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
<LayoutTemplateIcon aria-hidden="true" />
{t("applications.new")}
</Button>{" "}
</>
}
icon={<LayoutTemplateIcon />}
locale={i18n.language}
title={t("applications.title")}
/>

View File

@ -5,6 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { FileKeyIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -53,10 +54,12 @@ export function CertificateConfigurationsPage(props: CertificateConfigurationsPa
<>
{" "}
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
<FileKeyIcon aria-hidden="true" />
{t("certificates.new")}
</Button>{" "}
</>
}
icon={<FileKeyIcon />}
locale={i18n.language}
title={t("certificates.title")}
/>

View File

@ -1,6 +1,16 @@
import {
inventoryApi as assetInventoryApi,
inventoryKeys as assetInventoryKeys,
} from "@metis/assets/client";
import { useListQuery } from "@metis/core/client";
import {
inventoryApi as resourceInventoryApi,
inventoryKeys as resourceInventoryKeys,
} from "@metis/resources/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { profileApi, profileKeys } from "../../../work-profiles/shared/api";
import { companyKeys } from "../../shared/queries";
import type { CompanyTab } from "../../shared/types";
import { getCompany } from "../api";
@ -19,5 +29,30 @@ export function useCompanyDetailController({
const setTab = onSectionChange ?? setLocalTab;
const company = query.data?.company;
return { query, editOpen, setEditOpen, tab, setTab, company };
const inventory = useListQuery({
queryKey: assetInventoryKeys.company(id),
queryFn: () => assetInventoryApi.get(id),
enabled: Boolean(company),
});
const resources = useListQuery({
queryKey: resourceInventoryKeys.company(id),
queryFn: () => resourceInventoryApi.get(id),
enabled: Boolean(company),
});
const profiles = useListQuery({
queryKey: profileKeys.company(id),
queryFn: () => profileApi.get(id),
enabled: Boolean(company),
});
const counts: Partial<Record<CompanyTab, { count: number | undefined; failed: boolean }>> = {
people: { count: company?.people.length, failed: false },
departments: { count: company?.departments.length, failed: false },
sites: { count: company?.sites.length, failed: false },
inventory: { count: inventory.data?.assets.length, failed: inventory.isError },
workstations: { count: inventory.data?.workstations.length, failed: inventory.isError },
resources: { count: resources.data?.resources.length, failed: resources.isError },
profiles: { count: profiles.data?.profiles.length, failed: profiles.isError },
};
return { query, editOpen, setEditOpen, tab, setTab, company, counts };
}

View File

@ -1,9 +1,12 @@
import { BrowserLink } from "@metis/core/client";
import { CountBadge } from "@repo/rdx-ui/components/count-badge";
import {
BoxIcon,
BriefcaseIcon,
ChevronLeftIcon,
DatabaseIcon,
FileKeyIcon,
FolderIcon,
HomeIcon,
KeyRoundIcon,
LayersIcon,
@ -11,7 +14,9 @@ import {
MailIcon,
MapPinIcon,
MonitorIcon,
UsersIcon,
PersonIcon,
PrinterIcon,
WorkProfileIcon,
} from "@repo/rdx-ui/lib/icons";
import { Avatar, AvatarBadge } from "@repo/shadcn-ui/components/avatar";
import { Button } from "@repo/shadcn-ui/components/button";
@ -34,8 +39,9 @@ export function CompanyDetail(props: CompanyDetailProps) {
initialCreate,
initialConfiguration,
} = props;
const { query, editOpen, setEditOpen, tab, setTab, company } = useCompanyDetailController(props);
const { t } = useTranslation("companies");
const { query, editOpen, setEditOpen, tab, setTab, company, counts } =
useCompanyDetailController(props);
const { t, i18n } = useTranslation("companies");
const { t: resourceTranslation } = useTranslation("resources");
const { t: assetTranslation } = useTranslation("assets");
const { t: accessTranslation } = useTranslation("access");
@ -106,25 +112,21 @@ export function CompanyDetail(props: CompanyDetailProps) {
{(
[
["overview", t("companyOverview.navigation"), HomeIcon],
["people", `${t("people")} (${company.people.length})`, UsersIcon],
["people", t("people"), PersonIcon],
["workstations", assetTranslation("workstations"), MonitorIcon],
["profiles", t("profiles.title"), UsersIcon],
["profiles", t("profiles.title"), WorkProfileIcon],
["inventory", assetTranslation("inventory"), BoxIcon],
["resources", resourceTranslation("inventory"), LayersIcon],
[
"departments",
`${t("departments")} (${company.departments.length})`,
BriefcaseIcon,
],
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
["departments", t("departments"), BriefcaseIcon],
["sites", t("sites"), MapPinIcon],
["telework", t("telework.title"), KeyRoundIcon],
["remote", t("remote.title"), KeyRoundIcon],
["processes", t("processes.title"), LayoutTemplateIcon],
["access", accessTranslation("title"), KeyRoundIcon],
["folders", t("folders.title"), MailIcon],
["certificates", t("certificates.title"), LayersIcon],
["applications", t("applications.title"), LayersIcon],
["printers", t("printers.title"), LayersIcon],
["folders", t("folders.title"), FolderIcon],
["certificates", t("certificates.title"), FileKeyIcon],
["applications", t("applications.title"), LayoutTemplateIcon],
["printers", t("printers.title"), PrinterIcon],
["mail", t("mail.title"), MailIcon],
["catalogs", t("catalogOverview.companyTitle"), DatabaseIcon],
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
@ -135,6 +137,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
return null;
}
const [section, label, Icon] = entry;
const count = counts[section];
return (
<div
@ -149,8 +152,8 @@ export function CompanyDetail(props: CompanyDetailProps) {
aria-pressed={tab === section}
className={
embedded
? "cursor-pointer text-foreground hover:text-primary h-auto min-h-10 w-full justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] aria-pressed:bg-accent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent aria-pressed:text-accent-foreground shell:text-sm"
: "cursor-pointer text-foreground hover:text-primary"
? "group/company-section cursor-pointer text-foreground hover:text-primary h-auto min-h-10 w-full justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] aria-pressed:bg-accent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent aria-pressed:text-accent-foreground shell:text-sm"
: "group/company-section cursor-pointer text-foreground hover:text-primary"
}
{...(props.onSectionChange
? {
@ -162,7 +165,17 @@ export function CompanyDetail(props: CompanyDetailProps) {
variant="ghost"
>
<Icon aria-hidden="true" className="size-4 shrink-0" />
{label}
<span className="min-w-0 flex-1 text-left">{label}</span>
{count && (
<CountBadge
className="ms-auto group-aria-pressed/company-section:bg-primary/10 group-aria-pressed/company-section:text-primary"
count={count.count}
errorLabel={t("navigationCounts.error")}
failed={count.failed}
loadingLabel={t("navigationCounts.loading")}
locale={i18n.language}
/>
)}
</Button>
</div>
);

View File

@ -11,11 +11,13 @@ export function CompanyInventory({
workstations = false,
initialId,
initialCreate,
initialPersonId,
}: {
company: CompanyDTO;
workstations?: boolean;
initialId?: string | undefined;
initialCreate?: boolean | undefined;
initialPersonId?: string | undefined;
}) {
const [quick, setQuick] = useState<{
kind: "site" | "person";
@ -40,7 +42,7 @@ export function CompanyInventory({
/>
) : (
<Page
{...(workstations ? {} : { initialId, initialCreate })}
{...(workstations ? { initialPersonId, initialCreate } : { initialId, initialCreate })}
companyId={company.id}
lookups={lookups}
/>

View File

@ -6,7 +6,7 @@ import {
DatabaseIcon,
LaptopIcon,
MapPinIcon,
UsersIcon,
PersonIcon,
} from "@repo/rdx-ui/lib/icons";
import type { UseQueryResult } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
@ -69,7 +69,7 @@ export function CompanyOverviewStatistics({
>
{(
[
{ section: "people", label: "people", value: company.people.length, Icon: UsersIcon },
{ section: "people", label: "people", value: company.people.length, Icon: PersonIcon },
{
section: "departments",
label: "departments",

View File

@ -110,7 +110,15 @@ export function CompanySection({
/>
);
case "workstations":
return <CompanyInventory company={company} initialId={initialEntityId} workstations />;
return (
<CompanyInventory
company={company}
initialCreate={create}
initialId={initialEntityId}
initialPersonId={personId}
workstations
/>
);
case "inventory":
return (
<CompanyInventory company={company} initialCreate={create} initialId={initialEntityId} />

View File

@ -6,6 +6,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { Building2Icon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -52,10 +53,14 @@ export function CompaniesPage(props: CompaniesPageProps) {
actions={
<>
{" "}
<Button onClick={() => setCreateOpen(true)}>{t("newCompany")}</Button>{" "}
<Button onClick={() => setCreateOpen(true)}>
<Building2Icon aria-hidden="true" />
{t("newCompany")}
</Button>{" "}
</>
}
description={t("help")}
icon={<Building2Icon />}
locale={i18n.language}
title={t("title")}
/>

View File

@ -1,8 +1,8 @@
import { DatabaseIcon, LaptopIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { DatabaseIcon, LaptopIcon, PersonIcon, WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
export const KIND_ICONS = {
asset: LaptopIcon,
resource: DatabaseIcon,
person: UsersIcon,
profile: UsersIcon,
person: PersonIcon,
profile: WorkProfileIcon,
};

View File

@ -4,7 +4,7 @@ import {
FileKeyIcon,
TriangleAlertIcon,
UserRoundXIcon,
UsersIcon,
WorkProfileIcon,
} from "@repo/rdx-ui/lib/icons";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
@ -16,7 +16,7 @@ import { dashboardApi, dashboardKeys } from "../../shared/api";
const ISSUE_ICONS = {
certificate: FileKeyIcon,
license: FileKeyIcon,
profile: UsersIcon,
profile: WorkProfileIcon,
missing_site: BoxIcon,
inactive_person: UserRoundXIcon,
};

View File

@ -5,6 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { BriefcaseIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -21,7 +22,13 @@ export function DepartmentSection({ company }: { company: CompanyDTO }) {
return (
<div className="space-y-5">
<EntityListHeader
actions={<Button onClick={() => setEditor("new")}>{t("newDepartment")}</Button>}
actions={
<Button onClick={() => setEditor("new")}>
<BriefcaseIcon aria-hidden="true" />
{t("newDepartment")}
</Button>
}
icon={<BriefcaseIcon />}
locale={i18n.language}
title={t("departments")}
/>

View File

@ -5,6 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { FolderIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -49,10 +50,12 @@ export function FolderMappingsPage(props: FolderMappingsPageProps) {
<>
{" "}
<Button disabled={!ready} onClick={() => setEditor("new")}>
<FolderIcon aria-hidden="true" />
{t("folders.new")}
</Button>{" "}
</>
}
icon={<FolderIcon />}
locale={i18n.language}
title={t("folders.title")}
/>

View File

@ -5,6 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { MailIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -52,10 +53,12 @@ export function MailConfigurationsPage(props: MailConfigurationsPageProps) {
disabled={!(query.data && inventory.data && profiles.data && mailboxes.data)}
onClick={() => setEditor("new")}
>
<MailIcon aria-hidden="true" />
{t("mail.new")}
</Button>{" "}
</>
}
icon={<MailIcon />}
locale={i18n.language}
title={t("mail.title")}
/>

View File

@ -143,9 +143,11 @@ export function PersonDetails({
<EntityListHeader
actions={
<Button disabled={!person.active} onClick={() => setEditor("new")}>
<BriefcaseIcon aria-hidden="true" />
{t("addAssignment")}
</Button>
}
icon={<BriefcaseIcon />}
locale={i18n.language}
title={t("departmentAssignments")}
/>

View File

@ -1,4 +1,11 @@
import { BrowserLink, EntityNotFound, useEntityNavigation } from "@metis/core/client";
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import {
BrowserLink,
EntityNotFound,
navigateTo,
useEntityNavigation,
useListQuery,
} from "@metis/core/client";
import {
EntityAction,
EntityListHeader,
@ -8,12 +15,14 @@ import {
} from "@repo/rdx-ui/components/entity-list";
import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { PhoneLink } from "@repo/rdx-ui/components/phone-link";
import { PersonIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import { personWorkstations } from "../../../workstations/detail/helpers/person-workstations";
import { PersonDetails } from "../../detail";
import { PersonAvatar, PersonEditorDialog } from "../../shared";
@ -25,6 +34,7 @@ export function PeopleSection({
initialId?: string | undefined;
}) {
const { t, i18n } = useTranslation("companies");
const { t: assetTranslation } = useTranslation("assets");
const filterId = useId();
const [selection, setSelection] = useState<
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
@ -36,6 +46,15 @@ export function PeopleSection({
const [search, setSearch] = useState("");
const [contactsOnly, setContactsOnly] = useState(false);
const selected = company.people.find((item) => item.id === (routed ? selectedId : selection?.id));
const inventory = useListQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
enabled: !selected,
});
const responsibleWorkstations = (personId: string) =>
personWorkstations(company.id, personId, inventory.data?.workstations ?? [], []).responsible;
const personEditor = (
<PersonEditorDialog
companyId={company.id}
@ -86,13 +105,37 @@ export function PeopleSection({
actions={
<>
{" "}
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>{" "}
<Button onClick={() => setSelection({ mode: "new" })}>
<PersonIcon aria-hidden="true" />
{t("newPerson")}
</Button>{" "}
</>
}
icon={<PersonIcon />}
locale={i18n.language}
title={t("people")}
/>
{inventory.isPending && (
<p className="text-sm text-muted-foreground" role="status">
{assetTranslation("loading")}
</p>
)}
{inventory.isError && (
<div className="space-y-2" role="alert">
<p>{t("workstationsLoadError")}</p>
<Button
onClick={() => {
inventory.refetch().catch(() => undefined);
}}
size="sm"
type="button"
variant="outline"
>
{assetTranslation("retry")}
</Button>
</div>
)}
<EntityTable
columns={[
{
@ -125,6 +168,16 @@ export function PeopleSection({
</div>
),
},
{
id: "state",
label: t("status"),
value: (person) => t(person.active ? "active" : "inactive"),
render: (person) => (
<EntityStatus active={person.active}>
{t(person.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
id: "phone",
label: t("phone"),
@ -165,21 +218,46 @@ export function PeopleSection({
},
},
{
id: "state",
label: t("status"),
value: (person) => t(person.active ? "active" : "inactive"),
render: (person) => (
<EntityStatus active={person.active}>
{t(person.active ? "active" : "inactive")}
</EntityStatus>
),
id: "workstations",
label: t("responsibleWorkstations"),
value: (person) =>
responsibleWorkstations(person.id)
.map((station) => station.values.name)
.join(" · "),
render: (person) => {
const stations = responsibleWorkstations(person.id);
return stations.length ? (
<ul className="space-y-1">
{stations.map((station) => (
<li key={station.id}>
<BrowserLink href={`/companies/${company.id}/workstations/${station.id}`}>
{station.values.name}
</BrowserLink>
</li>
))}
</ul>
) : null;
},
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (person) => (
<EntityRowActions label={t("actions")}>
<EntityAction
disabled={!person.active}
onClick={() =>
navigateTo(
`/companies/${company.id}/workstations?personId=${encodeURIComponent(person.id)}`,
{ create: true }
)
}
>
{assetTranslation("newWorkstation")}
</EntityAction>
<EntityAction
onClick={() => {
setSelection({ mode: "detail", id: person.id });

View File

@ -1,5 +1,5 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
import { PersonIcon } from "@repo/rdx-ui/lib/icons";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@ -24,7 +24,7 @@ export function PersonEditorDialog({
size="medium"
title={
<span className="flex items-center gap-2">
<UsersIcon aria-hidden="true" className="size-5 text-muted-foreground" />
<PersonIcon aria-hidden="true" className="size-5 text-muted-foreground" />
{t(formProps.person ? "editPerson" : "newPerson")}
</span>
}

View File

@ -5,6 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { PrinterIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -50,10 +51,12 @@ export function PrinterConfigurationsPage(props: PrinterConfigurationsPageProps)
<>
{" "}
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
<PrinterIcon aria-hidden="true" />
{t("printers.new")}
</Button>{" "}
</>
}
icon={<PrinterIcon />}
locale={i18n.language}
title={t("printers.title")}
/>

View File

@ -5,6 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -24,11 +25,13 @@ export function ProcessTemplatesPage() {
<>
{" "}
<Button disabled={!query.data || query.isError} onClick={() => setEditor("new")}>
<LayoutTemplateIcon aria-hidden="true" />
{t("processes.newTemplate")}
</Button>{" "}
</>
}
description={t("processes.globalTemplates")}
icon={<LayoutTemplateIcon />}
locale={i18n.language}
title={t("processes.templates")}
/>

View File

@ -6,7 +6,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { ChevronLeftIcon } from "@repo/rdx-ui/lib/icons";
import { ChevronLeftIcon, LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -87,11 +87,13 @@ export function ProcessesPage(props: ProcessesPageProps) {
{t("processes.templates")}
</Button>
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
<LayoutTemplateIcon aria-hidden="true" />
{t("processes.new")}
</Button>
</div>{" "}
</>
}
icon={<LayoutTemplateIcon />}
locale={i18n.language}
title={t("processes.title")}
/>

View File

@ -5,6 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -46,10 +47,12 @@ export function RemoteConnectionsPage(props: RemoteConnectionsPageProps) {
<>
{" "}
<Button disabled={!ready} onClick={() => setEditor("new")}>
<KeyRoundIcon aria-hidden="true" />
{t("remote.new")}
</Button>{" "}
</>
}
icon={<KeyRoundIcon />}
locale={i18n.language}
title={t("remote.title")}
/>

View File

@ -6,6 +6,7 @@ import {
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { MapPinIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { useId } from "react";
@ -36,7 +37,13 @@ export function SiteSection({ company }: { company: CompanyDTO }) {
return (
<div className="space-y-6">
<EntityListHeader
actions={<Button onClick={() => setCreateOpen(true)}>{t("newSite")}</Button>}
actions={
<Button onClick={() => setCreateOpen(true)}>
<MapPinIcon aria-hidden="true" />
{t("newSite")}
</Button>
}
icon={<MapPinIcon />}
locale={i18n.language}
title={t("sites")}
/>

View File

@ -12,6 +12,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { LayersIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { useId, useState } from "react";
@ -131,10 +132,12 @@ export function SoftwareInstallationsPanel({
size="sm"
variant="outline"
>
<LayersIcon aria-hidden="true" />
{t("software.new")}
</Button>
}
description={t("software.description")}
icon={<LayersIcon />}
locale={i18n.language}
title={t("software.title")}
/>

View File

@ -5,6 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -48,10 +49,12 @@ export function TeleworkAuthorizationsPage(props: TeleworkAuthorizationsPageProp
}
onClick={() => setEditor("new")}
>
<KeyRoundIcon aria-hidden="true" />
{t("telework.new")}
</Button>{" "}
</>
}
icon={<KeyRoundIcon />}
locale={i18n.language}
title={t("telework.title")}
/>

View File

@ -6,6 +6,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -69,10 +70,12 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
<>
{" "}
<Button disabled={!ready} onClick={() => setEditor("new")}>
<WorkProfileIcon aria-hidden="true" />
{t("profiles.new")}
</Button>{" "}
</>
}
icon={<WorkProfileIcon />}
locale={i18n.language}
title={t("profiles.title")}
/>

View File

@ -1,5 +1,5 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
import { WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@ -34,7 +34,7 @@ export function WorkProfileEditorDialog({
size={profile ? "large" : "medium"}
title={
<span className="flex items-center gap-3">
<UsersIcon aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
<WorkProfileIcon aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
{t(profile ? "profiles.edit" : "profiles.new")}
</span>
}

View File

@ -1,7 +1,7 @@
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
import { OptionSelectionField } from "@repo/rdx-ui/components/option-selection-field";
import { RequiredIndicator } from "@repo/rdx-ui/components/required-indicator";
import { KeyRoundIcon, MonitorIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { KeyRoundIcon, MonitorIcon, PersonIcon } from "@repo/rdx-ui/lib/icons";
import { useId } from "react";
import { useTranslation } from "react-i18next";
@ -26,7 +26,7 @@ export function WorkProfileSelectionField({
}) {
const { t, i18n } = useTranslation("companies");
const id = useId();
const icons = { workstation: <MonitorIcon />, person: <UsersIcon />, account: <KeyRoundIcon /> };
const icons = { workstation: <MonitorIcon />, person: <PersonIcon />, account: <KeyRoundIcon /> };
return (
<fieldset aria-describedby={`${id}-help`} className="flex min-w-0 flex-1 flex-col gap-3">

View File

@ -2,7 +2,7 @@ import type { AssetDTO } from "@metis/assets/common";
import { FormSection } from "@repo/rdx-ui/components/form-section";
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
import { SelectionSummaryCard } from "@repo/rdx-ui/components/selection-summary-card";
import { KeyRoundIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { KeyRoundIcon, PersonIcon } from "@repo/rdx-ui/lib/icons";
import { useTranslation } from "react-i18next";
import { WorkProfileComputerSummary } from "./work-profile-computer-summary";
@ -35,7 +35,7 @@ export function WorkProfileSummary({
<li className="min-w-0">
<SelectionSummaryCard
description={person.description}
icon={<UsersIcon />}
icon={<PersonIcon />}
title={person.label}
/>
</li>

View File

@ -1,4 +1,7 @@
import { WorkstationHistory } from "@metis/assets/client";
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
import { ClockIcon } from "@repo/rdx-ui/lib/icons";
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -19,7 +22,7 @@ export function WorkstationDetailContent({
controller: WorkstationDetailController;
}) {
const { t, i18n } = useTranslation("assets");
const [tab, setTab] = useState("overview");
const [tab, setTab] = useState("information");
const [softwareOpened, setSoftwareOpened] = useState(false);
const workstation = c.workstation;
@ -51,25 +54,15 @@ export function WorkstationDetailContent({
>
<ScrollableTabsList
items={[
{ value: "overview", label: t("workstationDetail.overview") },
{
value: "profiles",
label: (
<span className="inline-flex items-center gap-2">
{t("workstationDetail.profiles")}
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums">
{c.profiles.isSuccess ? c.relatedProfiles.length : "—"}
</span>
</span>
),
},
{ value: "information", label: t("workstationDetail.information") },
{ value: "software", label: t("workstationDetail.software") },
{ value: "history", label: t("history") },
]}
label={t("workstationDetail.sections")}
locale={i18n.language}
value={tab}
/>
<TabsContent className="min-w-0" value="overview">
<TabsContent className="min-w-0 space-y-5" keepMounted value="information">
<div className="grid items-start gap-5 @min-[1000px]:grid-cols-[minmax(0,1fr)_minmax(260px,0.45fr)]">
<WorkstationAssets
assets={c.inventory.data?.assets ?? []}
@ -86,8 +79,7 @@ export function WorkstationDetailContent({
responsibleId={workstation.values.personId}
/>
</div>
</TabsContent>
<TabsContent className="min-w-0" keepMounted value="profiles">
<WorkstationProfiles
canCreate={canCreateProfile}
companyId={company.id}
@ -110,6 +102,28 @@ export function WorkstationDetailContent({
/>
)}
</TabsContent>
<TabsContent className="min-w-0" value="history">
<DetailPanel
count={workstation.history.length}
description={t("workstationDetail.historyHint")}
icon={<ClockIcon />}
title={t("workstationHistory")}
>
<WorkstationHistory
members={(ids) =>
ids
.map(
(id) =>
c.inventory.data?.assets.find((asset) => asset.id === id)?.values.name ?? "—"
)
.join(", ") || "—"
}
personName={(id) => company.people.find((person) => person.id === id)?.name ?? "—"}
siteName={(id) => company.sites.find((site) => site.id === id)?.name ?? "—"}
workstation={workstation}
/>
</DetailPanel>
</TabsContent>
</Tabs>
);
}

View File

@ -5,7 +5,7 @@ import {
} from "@metis/assets/client";
import { BrowserLink, navigateTo } from "@metis/core/client";
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { ChevronLeftIcon, MapPinIcon, MonitorIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { ChevronLeftIcon, MapPinIcon, MonitorIcon, PersonIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@ -90,7 +90,7 @@ export function WorkstationDetailPage({
</span>
{responsible && (
<span className="inline-flex min-w-0 items-center gap-2">
<UsersIcon aria-hidden="true" className="size-4 shrink-0" />
<PersonIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="sr-only">{t("workstationDetail.assignedPerson")}: </span>
<BrowserLink href={`${root}/people/${responsible.id}`}>
{responsible.name}

View File

@ -1,9 +1,16 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { assetsEnglish, assetsSpanish, catalogKeys, inventoryKeys } from "@metis/assets/client";
import {
WorkstationHistory,
assetsEnglish,
assetsSpanish,
catalogKeys,
inventoryKeys,
} from "@metis/assets/client";
import type { AssetDTO, WorkstationDTO } from "@metis/assets/common";
import { AssetSchema, WorkstationSchema } from "@metis/assets/common";
import { inventoryKeys as resourceInventoryKeys } from "@metis/resources/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import type { ReactNode } from "react";
@ -13,7 +20,10 @@ import { I18nextProvider } from "react-i18next";
import type { CompanyDTO, PersonDTO } from "../../../../common";
import type { WorkProfileDTO } from "../../../../common/work-profiles";
import { WorkProfileSchema } from "../../../../common/work-profiles";
import { CompanyDetail } from "../../../companies/detail/ui/company-detail";
import { companyKeys } from "../../../companies/shared/queries";
import { CurrentWorkstations } from "../../../people/detail/ui/current-workstations";
import { PeopleSection } from "../../../people/list/ui/people-section";
import { companiesEnglish, companiesSpanish } from "../../../shared";
import { profileKeys } from "../../../work-profiles/shared/api";
@ -62,6 +72,7 @@ async function render(
node?: ReactNode;
assets?: AssetDTO[];
workstation?: WorkstationDTO;
workstations?: WorkstationDTO[];
people?: PersonDTO[];
sites?: CompanyDTO["sites"];
profiles?: WorkProfileDTO[];
@ -83,10 +94,17 @@ async function render(
client.setQueryData(inventoryKeys.company(companyId), {
assets: options.assets ?? [],
relationships: [],
workstations: exists ? [options.workstation ?? workstation] : [],
workstations: exists ? (options.workstations ?? [options.workstation ?? workstation]) : [],
});
client.setQueryData(profileKeys.company(companyId), { profiles: options.profiles ?? [] });
client.setQueryData(catalogKeys.scope(companyId), { items: [] });
client.setQueryData(companyKeys.detail(companyId), {
company: { ...company, people: options.people ?? [], sites: options.sites ?? [] },
});
client.setQueryData(resourceInventoryKeys.company(companyId), {
resources: [],
relationships: [],
});
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
@ -133,7 +151,18 @@ test("ficha de puesto es/en: cabecera, retorno, bloques vacíos y edición modal
assert.ok(html.includes(labels.workstationDetail.people));
assert.ok(html.includes(companies.profiles.new));
assert.equal(html.includes("<form"), false);
assert.ok(html.includes(labels.workstationDetail.overview));
assert.ok(html.includes(labels.workstationDetail.information));
const tabs = [...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)]
.map(([button]) => button)
.filter((button) => button.includes('role="tab"'));
assert.equal(tabs.length, 3);
assert.ok(tabs.some((button) => button.includes(labels.history)));
assert.equal(
tabs.some((button) => button.includes(labels.workstationDetail.profiles)),
false
);
assert.ok(html.includes(labels.workstationDetail.profilesHint));
assert.ok(html.includes(labels.workstationDetail.software));
assert.ok(html.includes(labels.noWorkstationAssets));
assert.ok(html.includes(labels.workstationDetail.profileUnavailable));
@ -414,9 +443,184 @@ test("ficha: no permite crear perfiles si el ordenador está retirado o el puest
});
assert.ok(html.includes(assetsSpanish.workstationDetail.profileUnavailable));
assert.match(
html,
/<button(?=[^>]*disabled="")(?=[^>]*type="button")[^>]*>Nuevo perfil<\/button>/
);
const createButton = [...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].find(([button]) =>
button.includes("Nuevo perfil")
)?.[0];
assert.ok(createButton);
assert.match(createButton, /\bdisabled=""/);
}
});
test("historial del puesto es/en: revisiones de más reciente a más antigua con autor y valores de cada momento", async () => {
const initial = { ...workstation.values, name: "Puesto original" };
const renamed = {
...initial,
name: "Puesto renombrado",
computerAssetId: companyId,
assetIds: [companyId],
personId: person.id,
siteId: companyId,
};
const inactive = { ...initial, name: "Puesto desactivado", active: false };
const revisions = [
{
id: "0199b200-0000-7000-8000-000000000071",
actorId: companyId,
actorName: "David",
recordedAt: "2026-10-10T10:00:00.000Z",
values: initial,
},
{
id: "0199b200-0000-7000-8000-000000000072",
actorId: companyId,
actorName: "Ana",
recordedAt: "2026-10-11T10:00:00.000Z",
values: renamed,
},
{
id: "0199b200-0000-7000-8000-000000000073",
actorId: companyId,
actorName: "David",
recordedAt: "2026-10-12T10:00:00.000Z",
values: inactive,
},
];
const station = WorkstationSchema.parse({ ...workstation, values: inactive, history: revisions });
for (const language of ["es", "en"]) {
const labels = language === "es" ? assetsSpanish : assetsEnglish;
const html = await render(language, true, {
node: (
<WorkstationHistory
members={() => "PC histórico"}
personName={() => "Ana responsable"}
siteName={() => "Fábrica"}
workstation={station}
/>
),
});
assert.ok(html.includes(`aria-label="${labels.workstationHistory}"`));
assert.ok(html.indexOf("Puesto desactivado") < html.indexOf("Puesto renombrado"));
assert.ok(html.indexOf("Puesto renombrado") < html.indexOf("Puesto original"));
assert.ok(html.includes(labels.workstationDetail.created));
assert.ok(html.includes(labels.workstationDetail.updated));
assert.ok(html.includes(labels.active));
assert.ok(html.includes(labels.inactive));
assert.ok(html.includes("Ana responsable"));
assert.ok(html.includes("Fábrica"));
assert.ok(html.includes("PC histórico"));
assert.ok(html.includes('dateTime="2026-10-12T10:00:00.000Z"'));
assert.ok(html.includes(labels.recordedBy.replace("{{name}}", "Ana")));
assert.equal(station.history[0]?.values.name, "Puesto original");
}
});
test("personas es/en: enlaces a todos sus puestos bajo responsabilidad, sin confundirlos con puestos usados", async () => {
const anotherPerson = { ...person, id: "0199b200-0000-7000-8000-000000000081", name: "Bea" };
const first = {
...workstation,
values: { ...workstation.values, name: "Puesto principal de Ana", personId: person.id },
};
const second = {
...first,
id: "0199b200-0000-7000-8000-000000000082",
values: { ...first.values, name: "Puesto de recepción" },
};
const inactive = {
...first,
id: "0199b200-0000-7000-8000-000000000083",
values: { ...first.values, name: "Puesto archivado", active: false },
};
const foreign = {
...first,
id: "0199b200-0000-7000-8000-000000000084",
companyId: anotherPerson.id,
values: { ...first.values, name: "Puesto de otra compañía" },
};
const used = {
...workstation,
id: "0199b200-0000-7000-8000-000000000085",
values: {
...workstation.values,
name: "Puesto solo utilizado",
computerAssetId: companyId,
assetIds: [companyId],
},
};
for (const language of ["es", "en"]) {
const html = await render(language, true, {
node: <PeopleSection company={{ ...company, people: [person, anotherPerson] }} />,
workstations: [first, second, inactive, foreign, used],
profiles: [userProfile(86, companyId, person.id, true)],
});
const labels = language === "es" ? companiesSpanish : companiesEnglish;
const rows = [...html.matchAll(/<tr\b[^>]*>[\s\S]*?<\/tr>/g)].map(([row]) => row);
const ana = rows.find((row) =>
row.includes(`href="/companies/${companyId}/people/${person.id}"`)
);
const bea = rows.find((row) =>
row.includes(`href="/companies/${companyId}/people/${anotherPerson.id}"`)
);
assert.ok(html.includes(labels.responsibleWorkstations));
assert.ok(ana);
assert.ok(bea);
for (const station of [first, second]) {
assert.ok(ana.includes(`href="/companies/${companyId}/workstations/${station.id}"`));
assert.ok(ana.includes(station.values.name));
}
assert.equal(bea.includes(`/workstations/${first.id}`), false);
assert.equal(bea.includes(`/workstations/${second.id}`), false);
assert.ok(bea.includes("—"));
for (const station of [inactive, foreign, used]) {
assert.equal(html.includes(station.values.name), false);
}
}
});
test("menú de compañía es/en: totales en badges alineados y cero real en secciones vacías", async () => {
const first = asset(40, "PC");
const second = asset(41, "Monitor");
for (const language of ["es", "en"]) {
const html = await render(language, true, {
node: (
<CompanyDetail
embedded
id={companyId}
onBack={() => undefined}
onSectionChange={() => undefined}
section="catalogs"
/>
),
assets: [first, second],
workstations: [workstation],
profiles: [userProfile(50, first.id, person.id, true)],
people: [person],
});
const nav = html.match(/<nav\b[^>]*>[\s\S]*?<\/nav>/)?.[0];
assert.ok(nav);
const links = [...nav.matchAll(/<a\b[^>]*>[\s\S]*?<\/a>/g)].map(([link]) => link);
for (const [section, count] of [
["assets", 2],
["resources", 0],
["workstations", 1],
["work-profiles", 1],
["people", 1],
["departments", 0],
["sites", 0],
] as const) {
const link = links.find((item) => item.includes(`href="/companies/${companyId}/${section}"`));
assert.ok(link, section);
assert.ok(link.includes('data-slot="badge"'), section);
assert.ok(link.includes(`>${count}</span>`), section);
assert.ok(link.includes("ms-auto"), section);
}
}
});

View File

@ -1,6 +1,6 @@
import { BrowserLink } from "@metis/core/client";
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
import { PersonIcon } from "@repo/rdx-ui/lib/icons";
import { useTranslation } from "react-i18next";
import type { PersonDTO } from "../../../../common";
@ -30,7 +30,7 @@ export function WorkstationPeople({
<DetailPanel
count={loading || failed ? undefined : people.length}
description={t("workstationDetail.peopleHint")}
icon={<UsersIcon />}
icon={<PersonIcon />}
title={t("workstationDetail.people")}
>
{loading && (

View File

@ -1,7 +1,7 @@
import { BrowserLink } from "@metis/core/client";
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list";
import { TriangleAlertIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { TriangleAlertIcon, WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -35,12 +35,13 @@ export function WorkstationProfiles({
<DetailPanel
actions={
<Button disabled={!canCreate} onClick={onCreate} type="button">
<WorkProfileIcon aria-hidden="true" />
{companies("profiles.new")}
</Button>
}
count={loading || failed ? undefined : profiles.length}
description={t("workstationDetail.profilesHint")}
icon={<UsersIcon />}
icon={<WorkProfileIcon />}
title={t("workstationDetail.profiles")}
>
{!canCreate && (

View File

@ -5,6 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -23,10 +24,14 @@ export function UserManagement() {
actions={
<>
{" "}
<Button onClick={() => setCreateOpen(true)}>{t("createUser")}</Button>{" "}
<Button onClick={() => setCreateOpen(true)}>
<UsersIcon aria-hidden="true" />
{t("createUser")}
</Button>{" "}
</>
}
description={t("usersHelp")}
icon={<UsersIcon />}
locale={i18n.language}
title={t("users")}
/>

View File

@ -38,7 +38,7 @@
"notes": "Notes",
"characteristics": "Characteristics",
"chooseValue": "Select a value",
"noValue": "No data",
"noValue": "—",
"history": "History",
"recordedBy": "Recorded by {{name}}",
"snapshotNotice": "This record retains the template fields captured when the resource was created.",
@ -215,4 +215,4 @@
"fieldsDescription": "Complete the template details. Fields marked with * are required.",
"nameDescription": "Review the suggested name and add notes if you need to provide more context."
}
}
}

View File

@ -38,7 +38,7 @@
"notes": "Notas",
"characteristics": "Características",
"chooseValue": "Selecciona un valor",
"noValue": "Sin datos",
"noValue": "—",
"history": "Historial",
"recordedBy": "Registrado por {{name}}",
"snapshotNotice": "La ficha conserva los campos de la plantilla utilizados al dar de alta el recurso.",
@ -215,4 +215,4 @@
"fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
"nameDescription": "Revisa el nombre propuesto y añade notas si necesitas aportar más contexto."
}
}
}

View File

@ -1,4 +1,5 @@
import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list";
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -33,11 +34,13 @@ export function ResourceCatalogPage(props: ResourceCatalogPageProps) {
actions={
<>
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
<LayoutTemplateIcon aria-hidden="true" />
{t({ field: "newField", template: "newTemplate" }[tab])}
</Button>
</>
}
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
icon={<LayoutTemplateIcon />}
locale={i18n.language}
title={t(scope === "global" ? "title" : "companyTitle")}
/>

View File

@ -8,6 +8,7 @@ import {
} from "@repo/rdx-ui/components/entity-list";
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
import { LayersIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
import { useTranslation } from "react-i18next";
@ -73,11 +74,13 @@ export function ResourcesPage(props: ResourcesPageProps) {
</Button>
)}
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
<LayersIcon aria-hidden="true" />
{t("newResource")}
</Button>{" "}
</>
}
description={t("resourceScope")}
icon={<LayersIcon />}
locale={i18n.language}
title={t("inventory")}
/>

View File

@ -11,7 +11,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/selection-components.test.tsx src/entity-list.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/selection-components.test.tsx src/entity-list.test.tsx src/time-line.test.tsx src/count-badge.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts"
},
"dependencies": {
"@base-ui/react": "1.8.0",

View 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>
);
}

View File

@ -1,3 +1,8 @@
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@repo/shadcn-ui/components/accordion";
import type { ReactNode } from "react";
import { ChevronRightIcon } from "../lib/icons";
@ -5,7 +10,32 @@ import { ChevronRightIcon } from "../lib/icons";
import { FormSectionTitle } from "./form-section-title";
/** Grupo de consulta compartido por fichas y plantillas; abierto inicialmente. */
export function DetailSection({ title, children }: { title: string; children: ReactNode }) {
export function DetailSection({
title,
children,
accordionValue,
}: {
title: string;
children: ReactNode;
accordionValue?: string | undefined;
}) {
const content = (
<div className="space-y-3 border-t border-border/60 border-b-0 px-5 pb-2 [&>dl>div:first-child]:pt-2.5 [&>dl>div:last-child]:pb-2.5">
{children}
</div>
);
if (accordionValue !== undefined) {
return (
<AccordionItem className="@container" value={accordionValue}>
<AccordionTrigger className="items-center py-2 hover:no-underline">
<FormSectionTitle as="span">{title}</FormSectionTitle>
</AccordionTrigger>
<AccordionContent className="pb-0">{content}</AccordionContent>
</AccordionItem>
);
}
return (
<details className="@container group/detail-section" open>
<summary className="flex cursor-pointer list-none items-center gap-2 py-2 focus-visible:outline-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden">
@ -15,9 +45,7 @@ export function DetailSection({ title, children }: { title: string; children: Re
/>
<FormSectionTitle as="span">{title}</FormSectionTitle>
</summary>
<div className="space-y-3 border-t border-border/60 px-5 pb-2 [&>dl>div:first-child]:pt-2.5 [&>dl>div:last-child]:pb-2.5">
{children}
</div>
{content}
</details>
);
}

View File

@ -15,18 +15,30 @@ export { EntityAction, EntityRowActions, EntityStatus } from "./entity-list-cont
export function EntityListHeader({
title,
description,
icon,
actions,
locale = "es",
}: {
title: string;
description?: string;
icon?: ReactNode;
actions?: ReactNode;
locale?: string;
}) {
return (
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-2xl font-semibold">{title}</h2>
<h2 className="flex items-center gap-2 text-2xl font-semibold">
{icon && (
<span
aria-hidden="true"
className="inline-flex size-6 shrink-0 items-center justify-center [&>svg]:size-6"
>
{icon}
</span>
)}
{title}
</h2>
<p className="mt-1 text-base font-medium text-muted-foreground">
{description ??
`${entityListLabels(locale).description} ${title.toLocaleLowerCase(locale)}.`}

View File

@ -151,6 +151,12 @@ function entityColumnClass(columnId: string, pinned: boolean, header: boolean) {
return `w-px min-w-0 py-3 pr-1 pl-4 text-center whitespace-nowrap [&:has([role=checkbox])]:pr-1 ${labelVisibility}`;
}
if (columnId === "icon") {
const labelVisibility = header ? "[&>span]:sr-only bg-background" : "";
return `w-px min-w-0 py-3 pr-1 pl-3 text-center whitespace-nowrap ${labelVisibility}`;
}
if (pinned) {
return header
? "sticky right-0 z-20 w-20 min-w-20 bg-card pr-4 text-right"

View File

@ -19,6 +19,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
values = false,
hasErrors = false,
layout = "fields",
sectionPresentation = "details",
}: {
sections?: readonly FieldSection[] | undefined;
items: readonly T[];
@ -27,6 +28,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
values?: boolean;
hasErrors?: boolean;
layout?: FieldSectionLayout;
sectionPresentation?: "details" | "accordion";
}) {
const container = useRef<HTMLDivElement>(null);
const styles = fieldSectionStyles(layout, values);
@ -85,7 +87,11 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
if (layout === "rows") {
return (
<DetailSection key={section.id} title={section.name}>
<DetailSection
accordionValue={sectionPresentation === "accordion" ? section.id : undefined}
key={section.id}
title={section.name}
>
{section.description && (
<p className="pt-2 text-sm text-muted-foreground">{section.description}</p>
)}
@ -112,14 +118,19 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
</details>
);
})}
{ungrouped.length > 0 && (
<section className="col-span-full space-y-3">
<div className={styles.compact ? "pl-5" : undefined}>
<FormSectionTitle as="h4">{ungroupedLabel}</FormSectionTitle>
</div>
{content(ungrouped)}
</section>
)}
{ungrouped.length > 0 &&
(sectionPresentation === "accordion" && layout === "rows" ? (
<DetailSection accordionValue="ungrouped" title={ungroupedLabel}>
{content(ungrouped)}
</DetailSection>
) : (
<section className="col-span-full space-y-3">
<div className={styles.compact ? "pl-5" : undefined}>
<FormSectionTitle as="h4">{ungroupedLabel}</FormSectionTitle>
</div>
{content(ungrouped)}
</section>
))}
</div>
);
}

View 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>
);
}

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

View File

@ -25,6 +25,7 @@ export {
IconGripVertical as GripVerticalIcon,
IconHeadphones as HeadphonesIcon,
IconHome as HomeIcon,
IconIdBadge2 as WorkProfileIcon,
IconInfoCircle as InfoIcon,
IconKey as KeyRoundIcon,
IconKeyboard as KeyboardIcon,
@ -48,6 +49,7 @@ export {
IconTool as WrenchIcon,
IconTrash as Trash2Icon,
IconUserX as UserRoundXIcon,
IconUsers as PersonIcon,
IconUsers as UsersIcon,
IconWifi as WifiIcon,
IconX as XIcon,

View 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);
});