Refactor user management components and improve workspace page structure

- Removed UserManagementDialog and updated UserManagement export in user list.
- Adjusted CustomWorkspace to directly render UserManagement based on user permissions.
- Enhanced IdentityWorkspace layout for better accessibility and visual appeal.
- Introduced new AssetPanelSummary and WorkstationDetailPage components for asset management.
- Implemented useListQuery for consistent data fetching across catalog and resources pages.
- Added PersonAvatarGroup for displaying user avatars in a group format.
- Created workstation detail controller and related tests for improved workstation management.
- Added MetisLogo component for branding consistency.
This commit is contained in:
David Arranz 2026-10-11 13:49:28 +02:00
parent dfb0a699eb
commit e061d60eb7
98 changed files with 1564 additions and 363 deletions

View File

@ -1,3 +1,3 @@
{
"editor.fontFamily": "'C64 Keyboard Regular', 'Fira Code Retina', 'Fira Code'"
"editor.fontFamily": "'Fira Code Retina', 'Fira Code'"
}

View File

@ -1,2 +1,3 @@
- En los campos de recursos, añade texto de ayuda a cada campo que no lo tenga ya.
- Quiero buscar un sistema de nombrado para puestos de trabajo. Cuando se da de alta un puesto de trabajo Quiero que el campo "nombre" se rellene dinámicamente cuando el usuario ha elegido la sede y la persona del puesto. Por ejemplo, "Puesto de Fulanito en Uecko Fábrica". El usuario tiene la opción de borrar eso y poner lo que quiera antes de guardar los cambios.
- En el listado de puestos de trabajo de una compañía, cuando se hace click en nombre de un puesto, nos iríamos a una pantalla que hay que crear que es la ficha completa de ese puesto de trabajo. En esa ficha aparecerían de forma resumida la información sobre el activo, los perfiles de usuario que tiene, personas relacionadas, etc. Visualmente esquemático y bonito.
- Analiza la posibildad de que los listados se autorefresquen para deterctar cambios hechos por otros usuarios, sabiendo que utilizamos @tanstack/react-query

View File

@ -201,6 +201,24 @@ export async function verifyWorkstations(
await error({ ...sharedTwo.values, active: true }, "ASSET_IN_WORKSTATION", sharedTwo.id);
await save({ ...exclusive.values, active: false }, exclusive.id);
const designated = await save(
{ ...exclusive.values, active: false, computerAssetId: first.id },
exclusive.id
);
const reread = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
).workstations.find((item) => item.id === exclusive.id);
assert.equal(reread?.values.computerAssetId, first.id);
assert.equal(reread?.history.at(-1)?.values.computerAssetId, first.id);
const repeated = await save(designated.values, designated.id);
assert.equal(repeated.history.length, designated.history.length);
await error(
{ ...designated.values, computerAssetId: second.id },
"INVALID_MEMBERS",
designated.id
);
const race = await Promise.all([
request(path, {
values: {

View File

@ -2,10 +2,11 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { catalogKeys, inventoryKeys } from "@metis/assets/client";
import { companyKeys } from "@metis/companies/client";
import { CompaniesWorkspace, companyKeys } from "@metis/companies/client";
import { identityKeys } from "@metis/identity/client";
import type { SessionResponseDTO, UserDTO } from "@metis/identity/common";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
@ -16,6 +17,7 @@ import {
dashboardKeys,
entryDestination,
} from "../../../modules/companies/src/web/dashboard";
import { CustomWorkspace } from "../../../modules/identity/src/web/workspace/view/ui/custom-workspace";
import { App } from "./app";
import { createAppI18n } from "./i18n";
@ -120,8 +122,7 @@ test("el acceso protege el contenido y traduce la carga de sesión", async () =>
assert.equal(markup.includes("Tu espacio de soporte"), false);
assert.equal(markup.includes("Your support workspace"), false);
assert.match(markup, /role="combobox"/);
assert.ok(markup.includes(language === "es" ? "Español" : "English"));
assert.equal(markup.includes('role="combobox"'), false);
assert.ok(markup.includes(pending));
assert.ok(markup.includes('role="status"'));
}
@ -141,6 +142,15 @@ test("la configuración única y el login presentan sus formularios traducidos s
const login = await renderPage(language, { ...snapshot, setupRequired: false });
assert.ok(login.includes(loginTitle));
for (const markup of [setup, login]) {
assert.equal(markup.includes('role="combobox"'), false);
assert.match(markup, /aria-labelledby="access-title"/);
assert.match(markup, /<svg[^>]*viewBox="0 0 36 30"/);
assert.ok(markup.includes("Metis Desk"));
assert.match(markup, /type="email"/);
assert.match(markup, /type="password"/);
assert.match(markup, /type="submit"/);
}
assert.match(login, /autoComplete="current-password"/);
assert.match(login, /minLength="1"/);
assert.equal(login.includes('autoComplete="name"'), false);
@ -339,3 +349,70 @@ test("los bloques del dashboard presentan datos, avisos y actividad reales en am
assert.equal(html.includes("<form"), false);
}
});
test("usuarios: pantalla integrada es/en, solo para el administrador, con altas en modal", async () => {
for (const language of ["es", "en"]) {
const { instance, initialization } = createAppI18n(language);
await initialization;
for (const admin of [true, false]) {
const snapshot = {
user: { ...user, isInitialAdmin: admin },
csrfToken: "a".repeat(64),
setupRequired: false,
};
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
});
client.setQueryData(identityKeys.users, { users: [snapshot.user] });
client.setQueryData(companyKeys.list, { companies: [] });
const renderWorkspace = (
session: SessionResponseDTO,
accountNavigation: ReactNode,
administrationContent?: ReactNode
) => (
<CompaniesWorkspace
accountNavigation={accountNavigation}
administrationContent={administrationContent}
canManageUsers={session.user?.isInitialAdmin ?? false}
languageSwitcher={null}
userName={session.user?.name ?? ""}
/>
);
try {
const html = renderToStaticMarkup(
<I18nextProvider i18n={instance}>
<QueryClientProvider client={client}>
<CustomWorkspace
logoutError={null}
logoutPending={false}
onLogout={() => undefined}
page="users"
renderWorkspace={renderWorkspace}
setPage={() => undefined}
snapshot={snapshot}
/>
</QueryClientProvider>
</I18nextProvider>
);
if (admin) {
assert.ok(html.includes(language === "es" ? "Nuevo usuario" : "New user"));
assert.ok(html.includes('href="/settings/users"'));
assert.ok(html.includes(user.email));
assert.equal(html.includes('role="dialog"'), false);
assert.equal(html.includes("<form"), false);
} else {
assert.ok(html.includes('role="alert"'));
assert.equal(html.includes(user.email), false);
assert.equal(html.includes('href="/settings/users"'), false);
}
} finally {
client.clear();
}
}
}
});

View File

@ -16,16 +16,12 @@ export function App() {
return (
<div className="min-h-dvh bg-shell-content">
{!session.data?.user && (
<header className="flex items-center justify-between px-[30px] py-5">
<strong>Metis Desk</strong>
<LanguageSwitcher className="mt-0" />
</header>
)}
<IdentityWorkspace
renderWorkspace={(snapshot, accountNavigation) => (
renderWorkspace={(snapshot, accountNavigation, administrationContent) => (
<CompaniesWorkspace
accountNavigation={accountNavigation}
administrationContent={administrationContent}
canManageUsers={snapshot.user?.isInitialAdmin ?? false}
languageSwitcher={<LanguageSwitcher />}
userName={snapshot.user?.name ?? ""}
/>

View File

@ -24,6 +24,7 @@ export const workspaceCompany: CompanyDTO = {
companyId: "0199b200-0000-7000-8000-000000000001",
name: "Sede central",
address: null,
phone: null,
},
],
departments: [],
@ -75,7 +76,6 @@ export const workspaceAssets: AssetDTO[] = ["PORT-ANA-01", "PORT-RESERVA"].map((
name,
templateId,
siteId: workspaceCompany.sites[0]?.id ?? null,
personId: index === 0 ? (workspaceCompany.people[0]?.id ?? null) : null,
status: index === 0 ? "in_use" : "available",
notes: "",
characteristics: { [fieldId]: "Lenovo" },

View File

@ -2,6 +2,7 @@ name: metis-desk
services:
database:
image: mariadb:11.8
restart: unless-stopped
ports:
- "127.0.0.1:3307:3306"
environment:

View File

@ -280,6 +280,14 @@ contrae el menú y conservan la selección y las ayudas del botón de navegació
`CompanyAvatar`, `CompanyIdentity` y `CompanyLogoField` reúnen la presentación y selección del logotipo. Su contrato, límites y persistencia se documentan en [Logotipos de compañías](logotipos-de-companias.md). Se usan tamaños de 24–28 px en navegación/listas y 40–56 px en detalle/formulario.
## Avatar de persona
`PersonAvatar` reutiliza `InitialsAvatar` con los mismos colores y ajuste de texto que
`CompanyAvatar`. Muestra la foto versionada con carga diferida y recurre a las iniciales
si falta la foto o falla su carga. Conserva el nombre accesible, admite atributos de
`Avatar` y permite indicadores de estado como `children`. `PersonAvatarGroup` compone
los avatares del grupo y vive en un fichero independiente.
## FieldSections y TemplateSectionsEditor
Componentes de @repo/rdx-ui independientes de los DTO, consultas y endpoints de Metis Desk. FieldSections recibe secciones, elementos con sectionId opcional y renderItem; presenta una cuadrícula plana cuando no hay secciones, o bloques desplegables accesibles con descripción y «Sin sección». Admite presentación de valores como listas de descripción y apertura ante errores externos.
@ -366,3 +374,17 @@ La edición conserva UUID, clave de serie y las identidades de asociaciones que
El atributo `description` de las definiciones de campo se presenta como «Texto de ayuda (opcional)», con una explicación de dónde aparecerá. En los formularios de alta y edición se muestra bajo el control, con tipografía pequeña, color `muted-foreground` y saltos de línea conservados. Se reutiliza `FieldDescription` de shadcn/ui y se vincula mediante `aria-describedby`, incluidos desplegables y el selector de marca.
La ayuda se consulta en el catálogo de cada ámbito por el identificador del campo, manteniendo separados activos y recursos. En edición se utiliza la ayuda vigente del catálogo sin modificar los tipos, etiquetas ni valores de la especificación capturada. Actualizar un texto de ayuda no requiere alterar datos ni historial de los activos o recursos existentes. Si el texto está vacío o falta la definición, no se muestra un bloque vacío.
Las fichas de activos usan también `FieldSections layout="rows" values`. Sus grupos y los de las plantillas se componen con `DetailSection`: título plegable en mayúsculas, pequeño y `muted-foreground`, indicador y contenido con sangría simétrica. `DetailField` destaca el valor y atenúa la etiqueta; `TemplateFieldSummary` destaca el nombre del campo y atenúa los metadatos. La ficha de valores mantiene una columna de grupos, adaptándose al ancho del panel lateral.
El panel lateral de activos muestra plantilla, estado y puestos activos inmediatamente después del título y antes de las acciones, mediante `AssetPanelSummary`. Los mismos datos no se repiten en los grupos inferiores; la ficha completa conserva sus secciones. Los enlaces mantienen su estilo común y los iconos decorativos tienen etiquetas textuales accesibles.
## Ficha de puesto de trabajo
La ruta `/companies/:companyId/workstations/:id` muestra una ficha completa al pulsar el nombre en el listado. La cabecera concentra nombre, estado, sede, persona asignada y edición modal. El contenido adapta sus dos columnas a una en pantallas pequeñas: activos exclusivos/compartidos, perfiles de usuario y personas relacionadas con avatar. Los enlaces abren fichas reales de activos, personas y perfiles. El historial sigue disponible desde las acciones del listado.
Los perfiles se deducen por los activos del puesto, incluidos los compartidos; no se introduce una relación de propiedad entre perfil y puesto. Los inactivos se identifican explícitamente. Las personas se reúnen sin duplicados a partir de la asignación al puesto y sus perfiles. Los fallos parciales al cargar perfiles no impiden consultar los activos; se ofrece reintento. No se confunde un error o carga pendiente con ausencia de perfiles.
## Gestión de usuarios
`/settings/users` muestra el listado de usuarios como pantalla dentro del espacio global, con acceso directo desde la navegación para el administrador inicial. Identity aporta el contenido administrativo al shell mediante composición; Companies no importa el módulo Identity. Se reutiliza `UserManagement`, su API y los formularios modales existentes para crear, editar y restablecer contraseñas. La activación/desactivación se mantiene en la edición; las personas de las compañías son independientes de estas cuentas. El administrador inicial sigue protegido y la autorización del backend permanece obligatoria.

View File

@ -33,6 +33,7 @@ El pie opcional está fuera de la tabla y solo aparece cuando existe contenido c
- Acciones de fila siempre al final, con cabecera y celdas alineadas a la derecha y columna anclada al borde derecho durante el desplazamiento horizontal. La columna actions no se oculta ni se ordena. El disparador muestra únicamente el icono de tres puntos; conserva etiqueta accesible y título, sin repetir «Acciones» en las filas. El menú contextual mantiene las restricciones existentes. No convertir acciones de negocio en acciones masivas.
- Selección múltiple solo cuando una operación real la necesita. No añadir casillas sin consumidor. Mantener separada la selección del activo para su panel lateral.
- Cancelar un modal conserva búsqueda, filtros, página y selección de la lista. Las actualizaciones continúan invalidando las consultas actuales.
- Las consultas que alimentan un listado usan `useListQuery`: se vuelven a consultar cada 60 segundos mientras la pestaña está visible y también al recuperar foco o conexión. No se consulta en segundo plano ni se aplica este intervalo a formularios, sesión u otras consultas que no presenten un listado.
## Común y específico

View File

@ -4,7 +4,7 @@
La sección Perfiles de usuario de cada compañía registra el entorno de una persona en un dispositivo concreto. Nuevo perfil aparece a la derecha de la cabecera y abre un modal. Editar y Ficha e historial son acciones explícitas. Los perfiles archivados quedan ocultos inicialmente y pueden incluirse mediante una casilla.
Nombre, persona y dispositivo son obligatorios. Cuenta de inicio de sesión y notas son opcionales. La persona no recibe acceso a Metis Desk. El dispositivo es un activo de la compañía; puede alojar perfiles de varias personas y una persona puede tener perfiles en varios dispositivos. No se deduce el tipo de dispositivo del nombre de la plantilla: las plantillas son configurables. La ayuda pide seleccionar un equipo que aloje la configuración.
El alta pide nombre, puesto y persona. El ordenador se obtiene del puesto y no se solicita como campo editable. Cuenta de inicio de sesión y notas son opcionales. La persona no recibe acceso a Metis Desk. El dispositivo es un activo de la compañía; puede alojar perfiles de varias personas y una persona puede tener perfiles en varios dispositivos. No se deduce el tipo de dispositivo del nombre de la plantilla: las plantillas son configurables. La ayuda pide seleccionar un equipo que aloje la configuración.
La cuenta informática describe el inicio de sesión, no un buzón de correo ni una licencia. Seleccionarla no configura el equipo. Si se indica, debe ser una cuenta personal o compartida, activa y no caducada, de esta compañía, cuyo sistema y entorno estén activos. Debe existir una autorización vigente de la persona en estado pendiente o configurado. Las cuentas técnicas no representan su identidad de inicio de sesión. La caducidad incluye el día indicado; el final de una autorización es exclusivo.
@ -45,3 +45,9 @@ La base del perfil incorpora ahora [buzones y configuraciones de correo](correos
El perfil incorpora también carpetas compartidas e [impresoras por perfil](impresoras-del-perfil.md), con acciones para abrir cada consulta filtrada. Las [aplicaciones por perfil](aplicaciones-del-perfil.md) están implementadas sobre recursos. Los [certificados por perfil](certificados-del-perfil.md) también están implementados. La ficha [Entorno actual](entorno-actual.md) reúne estas configuraciones. El mapa visual queda aplazado.
La comprobación en navegador verifica la nueva sección, los desplegables de persona y dispositivo, la apertura de las tres altas rápidas y la conservación del formulario al cancelarlas. En puestos se comprueba la carga de una composición anterior, el selector compartido, su incorporación visual y la cancelación sin persistir cambios.
## Alta desde el puesto
El formulario de alta pide puesto y persona. La persona no se toma automáticamente del responsable del puesto; puede ser cualquier persona activa de la compañía. El ordenador se obtiene de `computerAssetId` del puesto activo y no se pide como campo editable. Desde la ficha del puesto, «Nuevo perfil» abre el mismo modal con ese puesto fijado. Si no tiene un ordenador principal disponible, se indica que hay que completar el puesto primero.
Se conserva la identidad persona/dispositivo y sus validaciones, permisos e historial. La relación con el puesto sigue consultándose por su composición; no se duplica una asignación independiente en el perfil. Cambiar responsable no modifica perfiles.

View File

@ -32,6 +32,20 @@ En este equipo se utiliza http://192.168.0.2:5173. Vite usa server.host: true y
Ejecutar activar.ps1 desde PowerShell como administrador y mantener pnpm dev en WSL. Los scripts activar.ps1 y desactivar.ps1 están en la carpeta metis-desk-lan del espacio de trabajo de Windows. Para acceder y guardar datos desde la dirección de red, establecer APP_ORIGIN=http://192.168.0.2:5173 en .env y reiniciar pnpm dev. Solo se permite un origen: con esa configuración, las operaciones desde localhost serán rechazadas. Si cambia la dirección de este ordenador, retirar el reenvío anterior, actualizar los scripts y APP_ORIGIN y volver a activarlo. La activación requiere privilegios de Windows. El reenvío y la regla privada están activados y se ha comprobado la respuesta HTTP y la sesión por la dirección LAN. Si WSL cambia de dirección después de reiniciar, volver a ejecutar activar.ps1 para actualizar el destino. La comprobación desde un segundo equipo depende de disponer de ese equipo en la red.
## Diagnóstico de conexión con MariaDB
Docker debe estar iniciado y accesible desde WSL. Comprobar el servicio sin mostrar las credenciales:
```bash
docker compose ps -a
docker compose logs --tail=50 database
pnpm db:up
```
`pnpm db:up` espera a que MariaDB esté saludable antes de terminar y conserva el volumen existente. El contenedor utiliza `restart: unless-stopped`: vuelve a arrancar tras reiniciar Docker o ante una salida inesperada, salvo que se haya detenido explícitamente. Si se usa `docker compose stop` o `down`, es necesario ejecutar de nuevo `pnpm db:up`. Nunca usar `down -v` para resolver una conexión: elimina los datos.
El backend ejecutado en WSL conecta a `127.0.0.1:3307`; el puerto interno del contenedor es `3306`. Después de recuperar la base de datos, reiniciar el backend si había terminado con un error de arranque: el modo watch espera cambios en archivos y no se reactiva por arrancar Docker.
## Verificaciones
```bash

View File

@ -4,7 +4,7 @@
La sección Puestos de trabajo de cada compañía agrupa los activos utilizados habitualmente como un conjunto. Un puesto tiene UUID v7, nombre, estado activo/inactivo, sede opcional, persona opcional y hasta 200 activos exclusivos y 200 recursos compartidos. No se admite duplicar un activo ni incluirlo en ambas listas. Se puede crear vacío y completar posteriormente. Una persona puede tener varios puestos. Los puestos no contienen otros puestos.
Nuevo puesto abre un modal desde la cabecera derecha; Editar abre otro modal con los datos existentes. Al crear, elegir persona y sede propone el nombre «Puesto de {persona} en {sede}»; el técnico puede modificarlo o borrarlo antes de guardar y esa edición manual no se sobrescribe. El formulario ofrece selectores con «+» para dar de alta sedes, personas y activos sin perder sus datos. Guardar un elemento relacionado lo selecciona o incorpora al puesto; cancelarlo mantiene el formulario principal. Los elementos guardados sobreviven si posteriormente se cancela el puesto. El alta de activo desde un puesto usa plantillas y sedes existentes para evitar un tercer modal.
Nuevo puesto abre un modal desde la cabecera derecha; Editar abre otro modal con los datos existentes. Al crear, elegir responsable y sede propone el nombre «Puesto de {persona} en {sede}»; el técnico puede modificarlo o borrarlo antes de guardar y esa edición manual no se sobrescribe. El formulario ofrece selectores con «+» para dar de alta sedes, personas y activos sin perder sus datos. Guardar un elemento relacionado lo selecciona o incorpora al puesto; cancelarlo mantiene el formulario principal. Los elementos guardados sobreviven si posteriormente se cancela el puesto. El alta de activo desde un puesto usa plantillas y sedes existentes para evitar un tercer modal.
La ayuda aparece inmediatamente antes de Guardar y Cancelar. El listado permite buscar por nombre, persona o sede. Historial del puesto muestra las versiones completas, con estado, asignación y composición, fecha y responsable. La ficha de cada activo muestra los puestos vigentes en los que se utiliza.
@ -16,7 +16,7 @@ Desactivar un puesto conserva su composición y libera sus reservas. Un activo p
Persona, sede y activos deben pertenecer a la misma compañía. Una persona debe estar activa para una nueva asignación. Si se desactiva después, se conserva la asignación anterior para revisión y puede retirarse. La persona no recibe acceso a Metis Desk.
La asignación del puesto no cambia automáticamente sede ni estado de cada activo; la persona se determina por el propio puesto. Tampoco se crean conexiones entre equipos automáticamente: se registran mediante relaciones entre activos. Los activos retirados pueden permanecer en una composición para revisión.
El responsable del puesto no cambia automáticamente la sede ni el estado de cada activo, ni identifica a todos sus usuarios. Tampoco se crean conexiones entre equipos automáticamente: se registran mediante relaciones entre activos. Los activos retirados pueden permanecer en una composición para revisión.
No se eliminan puestos ni revisiones. Guardar valores normalizados equivalentes no añade una revisión; cambiar solo el orden de selección de activos tampoco. Cambiar nombre, estado, sede, persona o composición registra una instantánea nueva con UUID propio y conserva el UUID del puesto.
@ -35,3 +35,9 @@ Las pruebas verifican identidad, composición opcional, límites, igualdad, hist
## Perfiles de usuario
Un puesto agrupa equipos y recursos; el [perfil de usuario](perfiles-de-trabajo.md) registra la persona en un dispositivo concreto. La composición no crea conexiones físicas, permisos ni perfiles automáticamente. La asignación de un activo no representa propiedad personal: todos los activos del inventario pertenecen a su compañía.
## Responsable y ordenador principal
La relación opcional `personId` identifica al responsable del puesto, no a todos sus usuarios. No se propaga a los perfiles. Estos identifican a cada persona que utiliza el ordenador, de modo que un puesto compartido puede tener varios perfiles.
`computerAssetId` identifica el único ordenador principal entre los activos exclusivos del puesto. No se deduce por nombre de plantilla ni por orden de selección. El resto de activos se registra como periféricos. Dos ordenadores se organizan en dos puestos. El ordenador principal se persiste como columna con clave foránea en el puesto y sus revisiones; un cambio conserva historial. Los puestos existentes mantienen su composición y necesitan designar el ordenador antes de crear nuevos perfiles. Retirarlo de la composición limpia la selección del ordenador principal.

View File

@ -10,6 +10,8 @@ Por ahora no existe un catálogo de roles. La cuenta inicial tiene una condició
## Gestión
La pantalla «Usuarios» está disponible en `/settings/users`, desde el menú global y el menú de cuenta del administrador. El listado se muestra dentro del espacio de Metis Desk; las altas, ediciones y restablecimientos de contraseña se abren en formularios modales.
Desde «Usuarios» el administrador puede crear cuentas, editar nombre y correo, activar/desactivar usuarios y restablecer contraseñas. No se borran cuentas: la desactivación conserva su identidad para las futuras relaciones con tickets e intervenciones.
El administrador cambia su propia contraseña en «Mi contraseña», proporcionando la contraseña actual. El restablecimiento de otro usuario invalida sus sesiones. Su nueva contraseña debe comunicarse por un canal privado; no hay envío automático de correos.
@ -35,3 +37,7 @@ APP_ORIGIN debe coincidir exactamente con el origen usado en el navegador; el va
pnpm db:up prepara el entorno. pnpm test ejecuta pruebas de dominio, hashes, interfaz y HTTP con MariaDB. La prueba de integración solo puede limpiar metis_desk_test y comprueba ese nombre antes de hacerlo. Nunca limpia metis_desk.
Cubre configuración concurrente, cierre del bootstrap, protección de administrador en HTTP y SQL, contratos estrictos, correos duplicados, login, autorización, CSRF, rotación de sesión, cambio y restablecimiento de contraseña, desactivación/reactivación, cierre de sesión y límite de intentos.
## Pantalla de acceso
El acceso reúne la marca Metis Desk y el formulario en una tarjeta centrada. En escritorio se muestran en dos columnas; en pantallas estrechas la marca queda encima del formulario. El logotipo se comparte con la navegación global. No se muestra un selector de idioma antes de iniciar sesión; se conserva en el espacio autenticado. La configuración inicial utiliza la misma composición y mantiene sus campos y validaciones específicos.

View File

@ -7,6 +7,7 @@ import { WorkstationInputSchema } from "../workstation-values";
export class WorkstationValues {
private constructor(
public readonly computerAssetId: Maybe<UniqueID>,
public readonly name: Name,
public readonly active: boolean,
public readonly siteId: Maybe<UniqueID>,
@ -22,8 +23,17 @@ export class WorkstationValues {
const name = Name.create(input.name, 100);
const site = optionalIdentity(input.siteId);
const person = optionalIdentity(input.personId);
const computer = optionalIdentity(input.computerAssetId ?? null);
if (!(parsed.success && name.isSuccess && site.isSuccess && person.isSuccess)) {
if (
!(
parsed.success &&
name.isSuccess &&
site.isSuccess &&
person.isSuccess &&
computer.isSuccess
)
) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
const ids: UniqueID[] = [];
@ -46,8 +56,13 @@ export class WorkstationValues {
shared.push(id.data);
}
if (input.computerAssetId && !input.assetIds.includes(input.computerAssetId)) {
return Result.fail(new AssetCatalogError("INVALID_MEMBERS"));
}
return Result.ok(
new WorkstationValues(
computer.data,
name.data,
input.active,
site.data,
@ -60,6 +75,9 @@ export class WorkstationValues {
toPrimitive(): WorkstationInput {
return {
...(this.computerAssetId.getOrUndefined()
? { computerAssetId: this.computerAssetId.getOrUndefined()?.toPrimitive() }
: {}),
name: this.name.toPrimitive(),
active: this.active,
siteId: this.siteId.getOrUndefined()?.toPrimitive() ?? null,
@ -74,6 +92,7 @@ export class WorkstationValues {
const b = other.toPrimitive();
return (
a.computerAssetId === b.computerAssetId &&
a.name === b.name &&
a.active === b.active &&
a.siteId === b.siteId &&

View File

@ -1,6 +1,7 @@
import { z } from "zod/v4";
export const WorkstationInputSchema = z.strictObject({
computerAssetId: z.uuid().nullable().optional(),
name: z.string().min(1).max(100),
active: z.boolean(),
siteId: z.uuid().nullable(),

View File

@ -117,3 +117,31 @@ test("Puestos: recursos compartidos separados, sin duplicados y cambios con hist
assert.deepEqual(changed.data.history[0]?.values.toPrimitive().sharedAssetIds, [sharedId]);
assert.equal(changed.data.history.length, 2);
});
test("Puestos: ordenador principal exclusivo, único e independiente del responsable", () => {
const computerId = assetIds[0] ?? "";
const first = values({ ...input(), computerAssetId: computerId });
assert.equal(first.toPrimitive().computerAssetId, computerId);
assert.equal(first.toPrimitive().personId, null);
assert.ok(
!WorkstationValues.create({
...input(),
computerAssetId: UniqueID.generateNewID().toPrimitive(),
}).isSuccess
);
assert.ok(
!WorkstationValues.create({
...input(),
computerAssetId: computerId,
assetIds: [],
sharedAssetIds: [computerId],
}).isSuccess
);
assert.ok(!first.equals(values({ ...input(), computerAssetId: assetIds[1] })));
assert.ok(
first.equals(
values({ ...input(), computerAssetId: computerId, assetIds: [...assetIds].reverse() })
)
);
});

View File

@ -52,6 +52,7 @@ function reconstruct(row: StoredRow, members: readonly Member[]): Values {
}
return WorkstationInputSchema.parse({
...(row.computerAssetId ? { computerAssetId: row.computerAssetId } : {}),
name: row.name,
active: Boolean(row.active),
siteId: row.siteId,
@ -158,7 +159,12 @@ async function writeSnapshot(
await writeAssetRow(
database,
definition.root,
{ ...row, name: values.name, active: values.active },
{
...row,
name: values.name,
active: values.active,
computerAssetId: values.computerAssetId ?? null,
},
transaction
);
await writeMembers(

View File

@ -33,6 +33,7 @@ export async function migrateWorkstations(database: Sequelize) {
companyId: fk("companies"),
siteId: fk("company_sites", true),
personId: fk("company_contacts", true),
computerAssetId: fk("assets", true),
name: { type: DataTypes.STRING(100), allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
};
@ -42,6 +43,7 @@ export async function migrateWorkstations(database: Sequelize) {
actorId: fk("users"),
siteId: fk("company_sites", true),
personId: fk("company_contacts", true),
computerAssetId: fk("assets", true),
name: { type: DataTypes.STRING(100), allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
@ -63,6 +65,13 @@ export async function migrateWorkstations(database: Sequelize) {
await query.createTable(table, columns);
}
}
for (const table of ["workstations", "workstation_revisions"]) {
const columns = await query.describeTable(table);
if (!columns.computerAssetId) {
await query.addColumn(table, "computerAssetId", fk("assets", true));
}
}
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='workstations'",
{ type: QueryTypes.SELECT }

View File

@ -148,7 +148,7 @@
"sharedWorkstationAssets": "Shared resources",
"noSharedWorkstationAssets": "No shared resources.",
"editWorkstation": "Edit workstation",
"workstationPerson": "Workstation assignee",
"workstationPerson": "Workstation custodian",
"workstationSuggestedName": "{{person}}'s workstation at {{site}}",
"workstationHistory": "Workstation history",
"workstationAssets": "Workstation assets",
@ -160,7 +160,7 @@
"workstationCount": "{{count}} assets",
"workstationCount_one": "{{count}} asset",
"workstationCount_other": "{{count}} assets",
"workstationDescription": "A workstation groups assets a person uses regularly. Person and site are optional and it can be created without assets.",
"workstationDescription": "A workstation groups a computer and its peripherals. Its optional custodian is independent of the people with profiles on it.",
"workstationExamples": "Ana's workstation: laptop, two monitors and a printer. One person can have several workstations.",
"workstationGuidance": "Exclusive assets can belong to one active workstation. Shared resources can be used in several workstations unless they are reserved as exclusive. Deactivation preserves composition and releases its assets; reactivation checks availability. Workstation assignment does not change individual asset assignments.",
"workstationScope": "All elements must belong to this company. Person, site and composition changes retain history.",
@ -342,5 +342,24 @@
"box": "Other asset"
}
},
"multiline": "Long text"
"multiline": "Long text",
"workstationDetail": {
"back": "Back to workstations",
"subtitle": "Assets, user profiles and people connected to this workstation.",
"loadError": "Unable to load the workstation.",
"notFound": "This workstation does not exist in this company.",
"assets": "Workstation assets",
"profiles": "User profiles",
"profilesHint": "Profiles associated with the exclusive and shared assets of this workstation.",
"profilesError": "Unable to load user profiles.",
"noProfiles": "No profiles are linked to this workstation’s assets.",
"people": "Related people",
"noPeople": "No people are related to this workstation.",
"assignedPerson": "Workstation custodian",
"profilePerson": "Has a profile on a workstation asset",
"needsReview": "References awaiting review",
"missingAsset": "Asset unavailable."
},
"workstationComputer": "Main computer",
"workstationComputerHelp": "Select the computer among this workstation’s exclusive assets. Profiles will reside on it; the remaining assets are peripherals. Use two workstations for two computers."
}

View File

@ -148,7 +148,7 @@
"sharedWorkstationAssets": "Recursos compartidos",
"noSharedWorkstationAssets": "Sin recursos compartidos.",
"editWorkstation": "Editar puesto",
"workstationPerson": "Persona del puesto",
"workstationPerson": "Responsable del puesto",
"workstationSuggestedName": "Puesto de {{person}} en {{site}}",
"workstationHistory": "Historial del puesto",
"workstationAssets": "Activos del puesto",
@ -160,7 +160,7 @@
"workstationCount": "{{count}} activos",
"workstationCount_one": "{{count}} activo",
"workstationCount_other": "{{count}} activos",
"workstationDescription": "Un puesto reúne los activos que una persona utiliza habitualmente. Puede quedar sin persona o sede y puede crearse sin activos.",
"workstationDescription": "Un puesto reúne un ordenador y sus periféricos. Su responsable es opcional e independiente de las personas que tienen perfiles en él.",
"workstationExamples": "Puesto de Ana: portátil, dos monitores y una impresora. Una persona puede tener varios puestos.",
"workstationGuidance": "Los activos exclusivos solo pueden estar en un puesto activo. Los recursos compartidos pueden utilizarse en varios puestos, siempre que no estén reservados como exclusivos. Desactivar conserva la composición y libera sus activos; reactivar comprueba su disponibilidad. El puesto no cambia las asignaciones individuales de los activos.",
"workstationScope": "Todos los elementos deben pertenecer a esta compañía. Los cambios de persona, sede y composición conservan historial.",
@ -342,5 +342,24 @@
"box": "Otro activo"
}
},
"multiline": "Texto largo"
"multiline": "Texto largo",
"workstationDetail": {
"back": "Volver a puestos de trabajo",
"subtitle": "Activos, perfiles de usuario y personas vinculadas a este puesto.",
"loadError": "No se ha podido cargar el puesto de trabajo.",
"notFound": "Este puesto de trabajo no existe en esta compañía.",
"assets": "Activos del puesto",
"profiles": "Perfiles de usuario",
"profilesHint": "Perfiles asociados a los activos exclusivos y compartidos de este puesto.",
"profilesError": "No se han podido cargar los perfiles de usuario.",
"noProfiles": "No hay perfiles vinculados a los activos de este puesto.",
"people": "Personas relacionadas",
"noPeople": "No hay personas relacionadas con este puesto.",
"assignedPerson": "Responsable del puesto",
"profilePerson": "Con perfil en un activo del puesto",
"needsReview": "Referencias pendientes de revisión",
"missingAsset": "Activo no disponible."
},
"workstationComputer": "Ordenador principal",
"workstationComputerHelp": "Selecciona el ordenador entre los activos exclusivos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos. Si hay dos ordenadores, crea dos puestos."
}

View File

@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -8,7 +8,7 @@ import type { AssetRelationshipsProps } from "../types";
export function useAssetRelationshipsController({ companyId, assetId }: AssetRelationshipsProps) {
const { i18n } = useTranslation("assets");
const query = useQuery({
const query = useListQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => getAssetInventory(companyId),
});

View File

@ -30,16 +30,18 @@ export function AssetDetail(props: AssetDetailProps) {
return (
<div className="space-y-2">
{!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>}
<DetailSection title={t("detailSections.identification")}>
<dl>
<DetailField label={t("template")}>
<BrowserLink href={`/companies/${asset.companyId}/catalogs/assets?tab=templates`}>
{asset.specification.templateName}
</BrowserLink>
</DetailField>
<DetailField label={t("status")}>{t(asset.values.status)}</DetailField>
</dl>
</DetailSection>
{!compact && (
<DetailSection title={t("detailSections.identification")}>
<dl>
<DetailField label={t("template")}>
<BrowserLink href={`/companies/${asset.companyId}/catalogs/assets?tab=templates`}>
{asset.specification.templateName}
</BrowserLink>
</DetailField>
<DetailField label={t("status")}>{t(asset.values.status)}</DetailField>
</dl>
</DetailSection>
)}
<DetailSection title={t("detailSections.assignment")}>
<dl>
<DetailField label={t("site")}>
@ -51,24 +53,32 @@ export function AssetDetail(props: AssetDetailProps) {
t("noAssignment")
)}
</DetailField>
<DetailField label={t("workstationInAsset")}>
{workstations?.length
? workstations.map((item) => (
<BrowserLink
className="block"
href={`/companies/${asset.companyId}/workstations`}
key={item.id}
>
{item.values.name}
</BrowserLink>
))
: t("noWorkstation")}
</DetailField>
{!compact && (
<DetailField label={t("workstationInAsset")}>
{workstations?.length
? workstations.map((item) => (
<BrowserLink
className="block"
href={`/companies/${asset.companyId}/workstations/${item.id}`}
key={item.id}
>
{item.values.name}
</BrowserLink>
))
: t("noWorkstation")}
</DetailField>
)}
</dl>
</DetailSection>
<DetailSection title={t("characteristics")}>
<div>
{!asset.specification.sections?.length && (
<h3 className="py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("characteristics")}
</h3>
)}
<FieldSections
items={asset.specification.fields}
layout="rows"
renderItem={(field) => (
<DetailField
key={field.fieldId}
@ -86,7 +96,7 @@ export function AssetDetail(props: AssetDetailProps) {
ungroupedLabel={t("sections.ungrouped")}
values
/>
</DetailSection>
</div>
{asset.values.notes && (
<DetailSection title={t("detailSections.notes")}>
<p className="whitespace-pre-wrap break-words text-sm">{asset.values.notes}</p>
@ -118,6 +128,7 @@ export function AssetDetail(props: AssetDetailProps) {
<summary className="cursor-pointer">{t("characteristics")}</summary>
<FieldSections
items={asset.specification.fields}
layout="rows"
renderItem={(field) => (
<div key={field.fieldId}>
<dt className="text-muted-foreground">{field.name}</dt>

View File

@ -0,0 +1,63 @@
import { BrowserLink } from "@metis/core/client";
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { BriefcaseIcon, LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
import { useTranslation } from "react-i18next";
import { useAssetDetailController } from "../controllers";
import type { AssetDetailProps } from "../types";
/** Contexto inmediato del activo, antes de las acciones del panel. */
export function AssetPanelSummary(props: AssetDetailProps) {
const { asset } = props;
const { workstations } = useAssetDetailController(props);
const { t } = useTranslation("assets");
return (
<dl className="space-y-2 text-sm">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex min-w-0 items-center gap-2">
<dt className="shrink-0 text-muted-foreground">
<LayoutTemplateIcon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("template")}</span>
</dt>
<dd className="min-w-0">
<BrowserLink href={`/companies/${asset.companyId}/catalogs/assets?tab=templates`}>
{asset.specification.templateName}
</BrowserLink>
</dd>
</div>
<div>
<dt className="sr-only">{t("status")}</dt>
<dd>
<EntityStatus active={asset.values.status !== "retired"}>
{t(asset.values.status)}
</EntityStatus>
</dd>
</div>
</div>
<div className="flex items-start gap-2">
<dt className="shrink-0 pt-0.5 text-muted-foreground">
<BriefcaseIcon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("workstationInAsset")}</span>
</dt>
<dd className="min-w-0">
{workstations?.length ? (
workstations.map((item) => (
<BrowserLink
className="block"
href={`/companies/${asset.companyId}/workstations/${item.id}`}
key={item.id}
>
{item.values.name}
</BrowserLink>
))
) : (
<span className="text-muted-foreground">
{workstations ? t("noWorkstation") : t("noValue")}
</span>
)}
</dd>
</div>
</dl>
);
}

View File

@ -11,6 +11,7 @@ import { AssetSupport } from "../../../support-connections";
import type { InventoryLookups } from "../../shared/types";
import { AssetDetail } from "./asset-detail";
import { AssetPanelSummary } from "./asset-panel-summary";
export function AssetSelectionPanel({
companyId,
@ -46,17 +47,25 @@ export function AssetSelectionPanel({
aria-label={t("assetPanel")}
className="@container hidden min-w-0 grid-cols-[minmax(0,1fr)] gap-4 rounded-panel border border-border bg-card p-4 [overflow-wrap:anywhere] inventory:grid"
>
<header className="flex items-start justify-between gap-3">
<h2 className="flex min-w-0 items-center gap-2 break-words text-lg font-semibold">
<AssetTemplateIcon
className="size-5 shrink-0 text-muted-foreground"
icon={template?.values.kind === "template" ? template.values.icon : undefined}
/>
{asset.values.name}
</h2>
<Button aria-label={t("closeAssetPanel")} onClick={onClose} size="icon-sm" variant="ghost">
<XIcon aria-hidden="true" className="size-4" />
</Button>
<header className="space-y-2">
<div className="flex items-start justify-between gap-3">
<h2 className="flex min-w-0 items-center gap-2 break-words text-lg font-semibold">
<AssetTemplateIcon
className="size-5 shrink-0 text-muted-foreground"
icon={template?.values.kind === "template" ? template.values.icon : undefined}
/>
{asset.values.name}
</h2>
<Button
aria-label={t("closeAssetPanel")}
onClick={onClose}
size="icon-sm"
variant="ghost"
>
<XIcon aria-hidden="true" className="size-4" />
</Button>
</div>
<AssetPanelSummary asset={asset} lookups={lookups} />
</header>
<div className="flex flex-wrap gap-2">
<Button onClick={() => onView(asset.id)} size="sm" variant="outline">

View File

@ -1,5 +1,5 @@
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -19,11 +19,11 @@ export function useAssetInventoryPageController({
}: AssetInventoryPageProps) {
const { t } = useTranslation("assets");
const client = useQueryClient();
const query = useQuery({
const query = useListQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => getAssetInventory(companyId),
});
const catalog = useQuery({
const catalog = useListQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});

View File

@ -279,14 +279,15 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
/>
);
assert.ok(html.indexOf("Ficha e historial") < html.indexOf("Identificación"));
assert.ok(html.indexOf(">Editar<") < html.indexOf("Identificación"));
for (const group of [
"Identificación",
"Ubicación y asignación",
"Características",
"Observaciones",
]) {
assert.ok(
html.indexOf(`href="/companies/${companyId}/catalogs/assets?tab=templates"`) <
html.indexOf("Ficha e historial")
);
assert.ok(html.indexOf("En uso") < html.indexOf("Ficha e historial"));
assert.ok(html.indexOf("Sin puesto activo") < html.indexOf("Ficha e historial"));
assert.ok(html.indexOf("Ficha e historial") < html.indexOf("Ubicación y asignación"));
assert.ok(html.indexOf(">Editar<") < html.indexOf("Ubicación y asignación"));
for (const group of ["Ubicación y asignación", "Características", "Observaciones"]) {
assert.ok(html.includes(group));
}
assert.ok(html.includes("<summary"));

View File

@ -1,11 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import type { BrandDTO } from "../../../../common/brands";
import { brandApi, brandKeys } from "../../shared/api";
export function useBrandCatalogPageController() {
const query = useQuery({ queryKey: brandKeys.all, queryFn: brandApi.list });
const query = useListQuery({ queryKey: brandKeys.all, queryFn: brandApi.list });
const [edit, setEdit] = useState<BrandDTO | undefined>();
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState<BrandDTO>();

View File

@ -1,5 +1,5 @@
import { useBrowserSearchParams } from "@metis/core/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useBrowserSearchParams, useListQuery } from "@metis/core/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import type { CatalogItemDTO } from "../../../../common";
@ -37,7 +37,7 @@ export function useAssetCatalogPageController({ scope }: AssetCatalogPageProps)
const [viewId, setViewId] = useState<string>();
const [editor, setEditor] = useState<CatalogEditor>();
const [deletingId, setDeletingId] = useState<string>();
const query = useQuery({
const query = useListQuery({
queryKey: catalogKeys.scope(scope),
queryFn: () => catalogApi.get(scope),
});

View File

@ -1,17 +1,9 @@
export { AssetForm, AssetInventoryPage } from "./assets";
export { BrandCatalogPage } from "./brands";
export { AssetCatalogPage } from "./catalog";
export { catalogApi, catalogKeys } from "./catalog/shared/api";
export { assetsEnglish, assetsSpanish } from "./shared";
export { inventoryApi } from "./shared/api";
export { inventoryKeys } from "./shared/queries";
export { AssetSupport } from "./support-connections";
export { WorkstationForm, WorkstationsPage } from "./workstations";
export { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations";

View File

@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import type { SupportConnectionDTO } from "../../../../common/support";
@ -7,7 +7,7 @@ import { getSupportConnections } from "../api";
import type { AssetSupportProps } from "../types";
export function useAssetSupportController({ companyId, assetId }: AssetSupportProps) {
const query = useQuery({
const query = useListQuery({
queryKey: supportKeys.asset(companyId, assetId),
queryFn: () => getSupportConnections(companyId, assetId),
});

View File

@ -1,3 +1,2 @@
export { WorkstationsPage } from "./list";
export { WorkstationForm } from "./shared";
export { WorkstationEditorDialog, WorkstationForm } from "./shared";

View File

@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -11,7 +11,7 @@ export function useWorkstationsPageController({ companyId, lookups }: Workstatio
const [search, setSearch] = useState("");
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const query = useQuery({
const query = useListQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => getAssetInventory(companyId),
});

View File

@ -1,3 +1,4 @@
import { BrowserLink } from "@metis/core/client";
import {
EntityAction,
EntityListHeader,
@ -68,13 +69,12 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
value: (item) => item.values.name,
hideable: false,
render: (item) => (
<button
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)}
type="button"
<BrowserLink
className="font-medium"
href={`/companies/${companyId}/workstations/${item.id}`}
>
{item.values.name}
</button>
</BrowserLink>
),
},
{
@ -85,7 +85,7 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
},
{
id: "person",
label: t("person"),
label: t("workstationPerson"),
value: (item) => personName(item.values.personId),
render: (item) => <>{personName(item.values.personId)}</>,
},

View File

@ -86,6 +86,21 @@ export function WorkstationForm(props: WorkstationFormProps) {
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-computer`}>{t("workstationComputer")}</Label>
<SelectWithAction
id={`${prefix}-computer`}
label={t("workstationComputer")}
onValueChange={(id) => form.setValue("computerAssetId", id || null)}
options={assets
.filter((asset) => values.assetIds.includes(asset.id))
.map((asset) => ({ value: asset.id, label: asset.values.name }))}
placeholder={t("noAssignment")}
value={values.computerAssetId ?? ""}
/>
<p className="text-xs text-muted-foreground">{t("workstationComputerHelp")}</p>
</div>
{(["assetIds", "sharedAssetIds"] as const).map((field) => (
<div className="space-y-2" key={field}>
<Label htmlFor={`${prefix}-${field}`}>
@ -139,12 +154,15 @@ export function WorkstationForm(props: WorkstationFormProps) {
aria-label={t("removeWorkstationAsset", {
name: assets.find((item) => item.id === id)?.values.name ?? id,
})}
onClick={() =>
onClick={() => {
form.setValue(
field,
values[field].filter((item) => item !== id)
)
}
);
if (values.computerAssetId === id) {
form.setValue("computerAssetId", null);
}
}}
size="sm"
type="button"
variant="outline"

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/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts"
},
"dependencies": {
"@metis/core": "workspace:*",

View File

@ -721,7 +721,7 @@
"fixed": "Person and device identify this profile. Create another profile for a different person or device.",
"help": "What is a user profile for?",
"description": "Identifies a person's environment on a specific device. A person can have several profiles and a device can host profiles for several people.",
"guidance": "Choose a computer or another device hosting the person's configuration. The sign-in account is optional and must be authorized for this person when supplied. A current profile does not mean all its configurations are ready.",
"guidance": "Select the workstation and the person using this profile. The computer comes from the workstation; its custodian does not automatically become a user. The optional sign-in account must be authorized for that person.",
"examplesLabel": "Examples and guidance",
"examples": "Ana on PORT-ANA-01; Ana on a second computer; Luis on a shared computer. Each profile can have several mailboxes. Applications and resources will be added later.",
"scope": "All elements belong to this company. Archiving preserves data and history; it does not revoke access or remove device configurations.",
@ -738,7 +738,10 @@
"PROFILE_NOT_FOUND": "This profile is unavailable.",
"COMPANY_NOT_FOUND": "This company is unavailable.",
"INTERNAL_ERROR": "The operation could not be completed. Please try again."
}
},
"workstation": "Workstation",
"chooseWorkstation": "Select a workstation",
"workstationNeedsComputer": "Edit this workstation and identify an available main computer before creating the profile."
},
"companySpaces": "Company spaces",
"globalCatalogs": "Global catalogs",

View File

@ -721,7 +721,7 @@
"fixed": "La persona y el dispositivo identifican este perfil. Para otro dispositivo o persona, crea un perfil nuevo.",
"help": "¿Para qué sirve un perfil de usuario?",
"description": "Identifica el entorno de una persona en un dispositivo concreto. Una persona puede tener varios perfiles y un equipo puede tener perfiles de varias personas.",
"guidance": "Elige un ordenador u otro dispositivo que aloje la configuración de la persona. La cuenta de inicio de sesión es opcional y, si se indica, debe estar autorizada para ella. Un perfil vigente no significa que todas sus configuraciones estén listas.",
"guidance": "Selecciona el puesto y la persona que utilizará este perfil. El ordenador se obtiene del puesto, y su responsable no se convierte automáticamente en usuario. La cuenta de inicio de sesión es opcional y debe estar autorizada para esa persona.",
"examplesLabel": "Ejemplos y recomendaciones",
"examples": "Ana en PORT-ANA-01; Ana en su segundo ordenador; Luis en un ordenador compartido. Cada perfil puede tener varios buzones de correo. Programas y recursos se incorporarán después.",
"scope": "Todos los elementos pertenecen a esta compañía. Archivar conserva los datos y el historial; no retira accesos ni borra configuraciones en el equipo.",
@ -738,7 +738,10 @@
"PROFILE_NOT_FOUND": "El perfil ya no está disponible.",
"COMPANY_NOT_FOUND": "La compañía ya no está disponible.",
"INTERNAL_ERROR": "No se pudo completar la operación. Inténtalo de nuevo."
}
},
"workstation": "Puesto de trabajo",
"chooseWorkstation": "Selecciona un puesto de trabajo",
"workstationNeedsComputer": "Edita el puesto e identifica un ordenador principal disponible antes de crear el perfil."
},
"companySpaces": "Espacios de compañías",
"globalCatalogs": "Catálogos globales",

View File

@ -1,5 +1,4 @@
import { useBrowserSearchParams } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useBrowserSearchParams, useListQuery } from "@metis/core/client";
import { useState } from "react";
import type { AccessKind } from "../../../../common";
@ -10,7 +9,7 @@ import { getAccess } from "../api";
import type { AccessWorkspacePageProps } from "../types";
export function useAccessWorkspacePageController({ scope, people = [] }: AccessWorkspacePageProps) {
const query = useQuery({
const query = useListQuery({
queryKey: accessKeys.scope(scope),
queryFn: () => getAccess(scope),
});

View File

@ -1,9 +1,9 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { useListQuery } from "@metis/core/client";
import {
inventoryApi as resourceApi,
inventoryKeys as resourceKeys,
} from "@metis/resources/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { profileApi, profileKeys } from "../../../work-profiles/shared/api";
@ -17,19 +17,19 @@ export function useApplicationConfigurationsPageController({
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const query = useQuery({
const query = useListQuery({
queryKey: applicationKeys.company(company.id),
queryFn: () => applicationApi.get(company.id),
});
const profiles = useQuery({
const profiles = useListQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const inventory = useQuery({
const inventory = useListQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const resources = useQuery({
const resources = useListQuery({
queryKey: resourceKeys.company(company.id),
queryFn: () => resourceApi.get(company.id),
});

View File

@ -1,9 +1,9 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { useListQuery } from "@metis/core/client";
import {
inventoryApi as resourceApi,
inventoryKeys as resourceKeys,
} from "@metis/resources/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { profileApi, profileKeys } from "../../../work-profiles/shared/api";
@ -17,19 +17,19 @@ export function useCertificateConfigurationsPageController({
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const query = useQuery({
const query = useListQuery({
queryKey: certificateKeys.company(company.id),
queryFn: () => certificateApi.get(company.id),
});
const profiles = useQuery({
const profiles = useListQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const inventory = useQuery({
const inventory = useListQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const resources = useQuery({
const resources = useListQuery({
queryKey: resourceKeys.company(company.id),
queryFn: () => resourceApi.get(company.id),
});

View File

@ -2,6 +2,7 @@ import { AssetInventoryPage, WorkstationsPage } from "@metis/assets/client";
import { useState } from "react";
import type { CompanyDTO } from "../../../../common";
import { WorkstationDetailPage } from "../../../workstations/detail/ui/workstation-detail-page";
import { InventoryQuickCreateDialog } from "./inventory-quick-create-dialog";
@ -20,20 +21,30 @@ export function CompanyInventory({
kind: "site" | "person";
onCreated: (id: string) => void;
}>();
const lookups = {
sites: company.sites,
people: company.people,
onCreateSite: (onCreated: (id: string) => void) => setQuick({ kind: "site", onCreated }),
onCreatePerson: (onCreated: (id: string) => void) => setQuick({ kind: "person", onCreated }),
};
const Page = workstations ? WorkstationsPage : AssetInventoryPage;
return (
<>
<Page
{...(workstations ? {} : { initialId, initialCreate })}
companyId={company.id}
lookups={{
sites: company.sites,
people: company.people,
onCreateSite: (onCreated) => setQuick({ kind: "site", onCreated }),
onCreatePerson: (onCreated) => setQuick({ kind: "person", onCreated }),
}}
/>
{workstations && initialId ? (
<WorkstationDetailPage
company={company}
key={initialId}
lookups={lookups}
workstationId={initialId}
/>
) : (
<Page
{...(workstations ? {} : { initialId, initialCreate })}
companyId={company.id}
lookups={lookups}
/>
)}
{quick && (
<InventoryQuickCreateDialog
companyId={company.id}

View File

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

View File

@ -1,5 +1,4 @@
import { useBrowserSearchParams } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useBrowserSearchParams, useListQuery } from "@metis/core/client";
import type { ColumnDef } from "@tanstack/react-table";
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
import { useState } from "react";
@ -21,7 +20,7 @@ export function useCompaniesPageController({
initialCompanyId,
initialCreate = false,
}: CompaniesPageProps) {
const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
const query = useListQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
const [selectedId, setSelectedId] = useState<string | undefined>();
const { searchParams, setSearchParams } = useBrowserSearchParams();
const companyId = searchParams.get("companyId") ?? initialCompanyId;

View File

@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import { getAccess } from "../../../access/list/api";
@ -15,15 +15,15 @@ export function useFolderMappingsPageController({
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const query = useQuery({
const query = useListQuery({
queryKey: folderKeys.company(company.id),
queryFn: () => folderApi.get(company.id),
});
const access = useQuery({
const access = useListQuery({
queryKey: accessKeys.scope(company.id),
queryFn: () => getAccess(company.id),
});
const profiles = useQuery({
const profiles = useListQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});

View File

@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import { interventionTemplateApi, interventionTemplateKeys } from "../../shared/api";
@ -7,7 +7,7 @@ import type { InterventionTemplatesPageProps } from "../types";
export function useInterventionTemplatesPageController({
scope = "global",
}: InterventionTemplatesPageProps) {
const query = useQuery({
const query = useListQuery({
queryKey: interventionTemplateKeys.list(scope),
queryFn: () => interventionTemplateApi.list(scope),
});

View File

@ -1,5 +1,9 @@
import { navigateTo, useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import {
navigateTo,
useBrowserSearchParams,
useEntityNavigation,
useListQuery,
} from "@metis/core/client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -23,7 +27,7 @@ export function useInterventionsPageController({
initialPendingOnly = false,
}: InterventionsPageProps) {
const { i18n } = useTranslation("companies");
const query = useQuery({
const query = useListQuery({
queryKey: interventionKeys.list(companyId),
queryFn: () => interventionApi.list(companyId),
});

View File

@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useTranslation } from "react-i18next";
import { InterventionFeedback } from "../../shared";
@ -12,7 +12,7 @@ export function TicketInterventions({
ticketId: string;
}) {
const { t } = useTranslation("companies");
const query = useQuery({
const query = useListQuery({
queryKey: interventionKeys.list(companyId),
queryFn: () => interventionApi.list(companyId),
});

View File

@ -1,5 +1,5 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import type { MailConfigurationDTO } from "../../../../common/mail-configurations";
@ -16,19 +16,19 @@ export function useMailConfigurationsPageController({
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const query = useQuery({
const query = useListQuery({
queryKey: mailKeys.company(company.id),
queryFn: () => mailApi.get(company.id),
});
const inventory = useQuery({
const inventory = useListQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const profiles = useQuery({
const profiles = useListQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const mailboxes = useQuery({
const mailboxes = useListQuery({
queryKey: mailboxKeys.company(company.id),
queryFn: () => getMailboxes(company.id),
});

View File

@ -301,7 +301,7 @@ function CurrentWorkstations({ company, personId }: { company: CompanyDTO; perso
<div className="py-4 first:pt-0 last:pb-0" key={workstation.id}>
<BrowserLink
className="app-link font-medium"
href={`/companies/${company.id}/workstations`}
href={`/companies/${company.id}/workstations/${workstation.id}`}
>
{workstation.values.name}
</BrowserLink>

View File

@ -1,5 +1,4 @@
export { PersonAvatar } from "./person-avatar";
export { PersonAvatarGroup } from "./person-avatar-group";
export { PersonEditorDialog } from "./person-editor-dialog";
export { PersonForm } from "./person-form";
export { PersonAvatar, PersonAvatarGroup } from "./person-avatar";

View File

@ -0,0 +1,42 @@
import { PersonAvatar } from "./person-avatar";
export function PersonAvatarGroup({
companyId,
people,
max = 3,
peopleLabel = "Personas",
}: {
companyId: string;
people: readonly { id: string; name: string; photoVersion?: string | null | undefined }[];
max?: number;
peopleLabel?: string;
}) {
const visible = people.slice(0, max);
const remaining = people.length - visible.length;
if (!people.length) {
return <span className="text-muted-foreground">—</span>;
}
return (
<div aria-label={`${people.length} ${peopleLabel}`} className="flex items-center" role="group">
<div className="flex -space-x-2">
{visible.map((person) => (
<PersonAvatar
className="size-8 border-2 border-background"
companyId={companyId}
key={person.id}
name={person.name}
personId={person.id}
photoVersion={person.photoVersion}
/>
))}
</div>
{remaining > 0 && (
<span className="ml-2 inline-flex size-8 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
+{remaining}
</span>
)}
</div>
);
}

View File

@ -1,5 +1,15 @@
import { Avatar, AvatarFallback, AvatarImage } from "@repo/shadcn-ui/components/avatar";
import type { ReactNode } from "react";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentProps } from "react";
import { InitialsAvatar } from "../../../shared/ui/initials-avatar";
export type PersonAvatarProps = ComponentProps<typeof Avatar> & {
companyId: string;
personId: string;
photoVersion?: string | null | undefined;
name: string;
};
export function PersonAvatar({
companyId,
@ -8,70 +18,31 @@ export function PersonAvatar({
name,
className,
children,
}: {
companyId: string;
personId: string;
photoVersion?: string | null | undefined;
name: string;
className?: string;
children?: ReactNode;
}) {
const initials = name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase();
...props
}: PersonAvatarProps) {
const src = photoVersion
? `/api/companies/${companyId}/people/${personId}/photo?version=${photoVersion}`
: undefined;
return (
<Avatar className={className}>
<AvatarImage alt="" src={src} />
<AvatarFallback>{initials}</AvatarFallback>
<Avatar
{...props}
aria-label={name}
className={cn(
"inline-flex size-7 items-center justify-center [container-type:inline-size]",
className
)}
role="img"
>
<AvatarImage alt="" decoding="async" loading="lazy" src={src} />
<AvatarFallback>
<InitialsAvatar
aria-hidden="true"
className="size-full text-[clamp(0.75rem,45cqi,2rem)]"
name={name}
/>
</AvatarFallback>
{children}
</Avatar>
);
}
export function PersonAvatarGroup({
companyId,
people,
max = 3,
peopleLabel = "Personas",
}: {
companyId: string;
people: readonly { id: string; name: string; photoVersion?: string | null | undefined }[];
max?: number;
peopleLabel?: string;
}) {
const visible = people.slice(0, max);
const remaining = people.length - visible.length;
if (!people.length) {
return <span className="text-muted-foreground">—</span>;
}
return (
<div aria-label={`${people.length} ${peopleLabel}`} className="flex items-center" role="group">
<div className="flex -space-x-2">
{visible.map((person) => (
<PersonAvatar
className="size-8 border-2 border-background"
companyId={companyId}
key={person.id}
name={person.name}
personId={person.id}
photoVersion={person.photoVersion}
/>
))}
</div>
{remaining > 0 && (
<span className="ml-2 inline-flex size-8 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
+{remaining}
</span>
)}
</div>
);
}

View File

@ -1,5 +1,5 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import { profileApi, profileKeys } from "../../../work-profiles/shared/api";
@ -13,15 +13,15 @@ export function usePrinterConfigurationsPageController({
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const query = useQuery({
const query = useListQuery({
queryKey: printerKeys.company(company.id),
queryFn: () => printerApi.get(company.id),
});
const profiles = useQuery({
const profiles = useListQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const inventory = useQuery({
const inventory = useListQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});

View File

@ -1,11 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import { processKeys } from "../../../processes/shared/queries";
import { listProcessTemplates } from "../api";
export function useProcessTemplatesPageController() {
const query = useQuery({ queryKey: processKeys.catalog(), queryFn: listProcessTemplates });
const query = useListQuery({ queryKey: processKeys.catalog(), queryFn: listProcessTemplates });
const [editor, setEditor] = useState<string>();
const [copy, setCopy] = useState<string>();
const templates = query.data?.templates ?? [];

View File

@ -1,5 +1,5 @@
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -44,7 +44,7 @@ export function useProcessesPageController({ company }: ProcessesPageProps) {
setSelected(undefined);
},
});
const query = useQuery({
const query = useListQuery({
queryKey: processKeys.company(company.id),
queryFn: () => getProcesses(company.id),
});

View File

@ -1,9 +1,9 @@
import { inventoryApi as assetApi, inventoryKeys as assetKeys } from "@metis/assets/client";
import { useListQuery } from "@metis/core/client";
import {
inventoryApi as resourceApi,
inventoryKeys as resourceKeys,
} from "@metis/resources/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -20,15 +20,15 @@ export function useRemoteConnectionsPageController({
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const [closed, setClosed] = useState(false);
const query = useQuery({
const query = useListQuery({
queryKey: remoteKeys.company(company.id),
queryFn: () => remoteApi.get(company.id),
});
const assets = useQuery({
const assets = useListQuery({
queryKey: assetKeys.company(company.id),
queryFn: () => assetApi.get(company.id),
});
const resources = useQuery({
const resources = useListQuery({
queryKey: resourceKeys.company(company.id),
queryFn: () => resourceApi.get(company.id),
});

View File

@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import { teleworkApi, teleworkKeys } from "../../shared/api";
@ -11,7 +11,7 @@ export function useTeleworkAuthorizationsPageController({
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const query = useQuery({
const query = useListQuery({
queryKey: teleworkKeys.company(company.id),
queryFn: () => teleworkApi.get(company.id),
});

View File

@ -1,5 +1,9 @@
import { navigateTo, useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import {
navigateTo,
useBrowserSearchParams,
useEntityNavigation,
useListQuery,
} from "@metis/core/client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -23,7 +27,7 @@ export function useTicketsPageController({
initialPendingOnly = false,
}: TicketsPageProps) {
const { i18n } = useTranslation("companies");
const query = useQuery({
const query = useListQuery({
queryKey: ticketKeys.list(companyId),
queryFn: () => ticketApi.list(companyId),
});

View File

@ -4,8 +4,8 @@ import {
navigateTo,
useBrowserSearchParams,
useEntityNavigation,
useListQuery,
} from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -116,15 +116,15 @@ export function useWorkProfilesPageController({
}
});
const query = useQuery({
const query = useListQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const inventory = useQuery({
const inventory = useListQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const access = useQuery({
const access = useListQuery({
queryKey: accessKeys.scope(company.id),
queryFn: () => getAccess(company.id),
});

View File

@ -13,7 +13,12 @@ import type { ConfigurationQuickKind } from "../../../shared/types";
import { profileApi, profileKeys } from "../api";
import type { WorkProfileFormProps } from "../types";
export function useWorkProfileFormController({ company, profile, onSaved }: WorkProfileFormProps) {
export function useWorkProfileFormController({
company,
profile,
workstationId: initialWorkstationId,
onSaved,
}: WorkProfileFormProps) {
const client = useQueryClient();
const mounted = useRef(true);
@ -24,6 +29,7 @@ export function useWorkProfileFormController({ company, profile, onSaved }: Work
mounted.current = false;
};
}, []);
const [selectedWorkstationId, setSelectedWorkstationId] = useState(initialWorkstationId ?? "");
const [quick, setQuick] = useState<ConfigurationQuickKind>();
const access = useQuery({
queryKey: accessKeys.scope(company.id),
@ -33,8 +39,29 @@ export function useWorkProfileFormController({ company, profile, onSaved }: Work
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const workstations = (inventory.data?.workstations ?? []).filter(
(station) => station.companyId === company.id && station.values.active
);
const workstation = workstations.find((station) => station.id === selectedWorkstationId);
const computer = (inventory.data?.assets ?? []).find(
(asset) =>
asset.id === workstation?.values.computerAssetId &&
workstation.values.assetIds.includes(asset.id) &&
asset.values.status !== "retired"
);
const targetReady = Boolean(profile || computer);
const chooseWorkstation = (id: string) => {
setSelectedWorkstationId(id);
};
const form = useForm<WorkProfileInput>({
resolver: zodResolver(WorkProfileInputSchema),
resolver: (input, context, options) =>
zodResolver(WorkProfileInputSchema)(
{ ...input, assetId: profile?.values.assetId ?? computer?.id ?? "" },
context,
options
),
defaultValues: profile?.values ?? {
name: "",
personId: "",
@ -81,15 +108,6 @@ export function useWorkProfileFormController({ company, profile, onSaved }: Work
.filter((item) => item.active || item.id === values.personId)
.map((item) => ({ value: item.id, label: item.name })),
},
{
field: "assetId",
label: "asset",
create: "newAsset",
kind: "asset",
options: (inventory.data?.assets ?? [])
.filter((item) => item.values.status !== "retired" || item.id === values.assetId)
.map((item) => ({ value: item.id, label: item.values.name })),
},
{
field: "accountId",
label: "account",
@ -105,7 +123,11 @@ export function useWorkProfileFormController({ company, profile, onSaved }: Work
.map((item) => ({ value: item.id, label: accessLabel(item, items, company.people) })),
},
] as const;
const submit = form.handleSubmit((input) => mutation.mutate(input));
const submit = form.handleSubmit((input) => {
if (targetReady) {
mutation.mutate(input);
}
});
const quickCreated = (field: "personId" | "accountId" | "assetId", id: string | undefined) => {
if (mounted.current && id) {
@ -115,6 +137,11 @@ export function useWorkProfileFormController({ company, profile, onSaved }: Work
};
return {
workstations,
selectedWorkstationId,
chooseWorkstation,
computer,
targetReady,
quick,
setQuick,
access,

View File

@ -1,5 +1,3 @@
export { profileApi, profileKeys } from "./api";
export type { ProfileSelection } from "./types";
export { ProfileFeedback, WorkProfileEditorDialog, WorkProfileForm } from "./ui";

View File

@ -26,7 +26,7 @@ const company = {
assignments: [],
};
async function render(language: string, node: ReactNode) {
async function render(language: string, node: ReactNode, seed?: (client: QueryClient) => void) {
const i18n = createInstance();
await i18n.init({
@ -44,6 +44,7 @@ async function render(language: string, node: ReactNode) {
relationships: [],
workstations: [],
});
seed?.(client);
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
@ -67,11 +68,70 @@ test("perfiles: listado es/en, alta en cabecera y ausencia de formulario junto a
test("perfiles: cuenta opcional, altas rápidas y ayuda anterior al pie", async () => {
const html = await render("es", <WorkProfileForm company={company} onSaved={() => undefined} />);
for (const label of ["Nueva persona", "Nueva cuenta", "Nuevo dispositivo"]) {
for (const label of ["Nueva persona", "Nueva cuenta"]) {
assert.ok(html.includes(`aria-label="${label}"`));
}
assert.equal(html.includes('aria-label="Nuevo dispositivo"'), false);
assert.ok(html.includes("Puesto de trabajo"));
assert.ok(html.includes("Cuenta de inicio de sesión (opcional)"));
assert.ok(html.includes("Un perfil vigente no significa"));
assert.ok(html.includes("su responsable no se convierte automáticamente en usuario"));
assert.ok(html.indexOf("¿Para qué sirve un perfil de usuario?") < html.indexOf("Guardar"));
assert.equal(html.includes('type="password"'), false);
});
test("perfiles: el puesto fijado aporta su ordenador y no selecciona al responsable como usuario", async () => {
const workstationId = "0199b200-0000-7000-8000-000000000010";
const computerId = "0199b200-0000-7000-8000-000000000011";
const personId = "0199b200-0000-7000-8000-000000000012";
const station = {
id: workstationId,
companyId: company.id,
values: {
name: "Administración",
active: true,
personId,
siteId: null,
computerAssetId: computerId,
assetIds: [computerId],
sharedAssetIds: [],
},
history: [],
};
const assignedCompany = {
...company,
people: [
{
id: personId,
name: "Responsable test",
companyId: company.id,
isContact: false,
active: true,
position: null,
email: null,
phone: null,
},
],
};
const html = await render(
"es",
<WorkProfileForm
company={assignedCompany}
onSaved={() => undefined}
workstationId={workstationId}
/>,
(client) => {
client.setQueryData(inventoryKeys.company(company.id), {
assets: [
{ id: computerId, companyId: company.id, values: { name: "PC-ADMIN", status: "in_use" } },
],
relationships: [],
workstations: [station],
});
}
);
assert.ok(html.includes("Administración"));
assert.ok(html.includes("Dispositivo: PC-ADMIN"));
assert.equal(html.includes("Responsable test"), false);
assert.equal(html.includes('aria-label="Nuevo dispositivo"'), false);
});

View File

@ -1,3 +1,2 @@
export type { ProfileSelection } from "./profile-selection";
export type { WorkProfileFormProps } from "./work-profile-form-props";

View File

@ -3,6 +3,7 @@ import type { WorkProfileDTO } from "../../../../common/work-profiles";
export type WorkProfileFormProps = {
company: CompanyDTO;
workstationId?: string | undefined;
profile?: WorkProfileDTO | undefined;
onSaved: (id: string) => void;
allowQuickCreate?: boolean;

View File

@ -1,5 +1,3 @@
export { ProfileFeedback } from "./profile-feedback";
export { WorkProfileEditorDialog } from "./work-profile-editor-dialog";
export { WorkProfileForm } from "./work-profile-form";

View File

@ -9,10 +9,12 @@ import { WorkProfileForm } from "./work-profile-form";
export function WorkProfileEditorDialog({
profile,
company,
workstationId,
onClose,
}: {
profile: ComponentProps<typeof WorkProfileForm>["profile"];
company: CompanyDTO;
workstationId?: string | undefined;
onClose: () => void;
}) {
const { t } = useTranslation("companies");
@ -30,7 +32,12 @@ export function WorkProfileEditorDialog({
title={t(profile ? "profiles.edit" : "profiles.new")}
wide
>
<WorkProfileForm company={company} onSaved={() => onClose()} profile={profile} />
<WorkProfileForm
company={company}
onSaved={() => onClose()}
profile={profile}
workstationId={workstationId}
/>
</FormDialog>
);
}

View File

@ -18,6 +18,11 @@ import { ProfileFeedback } from "./profile-feedback";
export function WorkProfileForm(props: WorkProfileFormProps) {
const { company, profile, allowQuickCreate = true } = props;
const {
workstations,
selectedWorkstationId,
chooseWorkstation,
computer,
targetReady,
quick,
setQuick,
access,
@ -43,6 +48,31 @@ export function WorkProfileForm(props: WorkProfileFormProps) {
<Label htmlFor={prefix}>{t("profiles.name")}</Label>
<Input id={prefix} maxLength={100} required {...form.register("name")} />
</div>
{!profile && (
<div className="space-y-2">
<Label htmlFor={`${prefix}-workstation`}>{t("profiles.workstation")}</Label>
<SelectWithAction
disabled={disabled || !inventory.data || Boolean(props.workstationId)}
id={`${prefix}-workstation`}
label={t("profiles.workstation")}
onValueChange={chooseWorkstation}
options={workstations.map((station) => ({
value: station.id,
label: station.values.name,
}))}
placeholder={t("profiles.chooseWorkstation")}
required
value={selectedWorkstationId}
/>
{selectedWorkstationId && (
<p className="text-sm text-muted-foreground" role="status">
{computer
? `${t("profiles.asset")}: ${computer.values.name}`
: t("profiles.workstationNeedsComputer")}
</p>
)}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
{selectors.map((selector) => {
const fixed = !!profile && selector.field !== "accountId";
@ -107,7 +137,16 @@ export function WorkProfileForm(props: WorkProfileFormProps) {
/>
</div>
</fieldset>
{profile && <p className="text-sm text-muted-foreground">{t("profiles.fixed")}</p>}
{profile && (
<div className="space-y-1 text-sm text-muted-foreground">
<p>
{t("profiles.asset")}:{" "}
{inventory.data?.assets.find((asset) => asset.id === profile.values.assetId)?.values
.name ?? profile.values.assetId}
</p>
<p>{t("profiles.fixed")}</p>
</div>
)}
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-destructive" role="alert">
{t("profiles.errors.INVALID_REQUEST")}
@ -124,7 +163,10 @@ export function WorkProfileForm(props: WorkProfileFormProps) {
/>
</FormBody>
<FormActions>
<Button disabled={disabled || !inventory.data || !access.data} type="submit">
<Button
disabled={disabled || !inventory.data || !access.data || !targetReady}
type="submit"
>
{t(disabled ? "saving" : "save")}
</Button>
</FormActions>

View File

@ -23,7 +23,15 @@ export function WorkspaceSettingsDialog({
open={open}
title={coreTranslation("shell.settings")}
>
{accountNavigation}
<div
onClickCapture={(event) => {
if (event.target instanceof Element && event.target.closest("a[href]")) {
onOpenChange(false);
}
}}
>
{accountNavigation}
</div>
{languageSwitcher}
</FormDialog>
);

View File

@ -42,6 +42,7 @@ const GLOBAL_CATALOGS: readonly GlobalCatalog[] = [
"process-templates",
];
const DETAIL_SECTIONS: readonly CompanyTab[] = [
"workstations",
"inventory",
"resources",
"people",

View File

@ -121,3 +121,25 @@ test("las rutas desconocidas y los parámetros inválidos no abren una sección
assert.equal(parseWorkspaceUrl(href).area, "invalid-route");
}
});
test("la ficha de puesto tiene ruta propia, conserva el UUID y rechaza identificadores inválidos", () => {
const href = `/companies/${COMPANY_ID}/workstations/${ENTITY_ID}`;
assert.deepEqual(parseWorkspaceUrl(href), {
area: "company",
id: COMPANY_ID,
section: "workstations",
entityId: ENTITY_ID,
});
assert.equal(
workspaceHref({
area: "company",
id: COMPANY_ID,
section: "workstations",
entityId: ENTITY_ID,
}),
href
);
assert.deepEqual(parseWorkspaceUrl(`/companies/${COMPANY_ID}/workstations/no-valido`), {
area: "invalid-route",
});
});

View File

@ -2,6 +2,8 @@ import type { ReactNode } from "react";
export type CompaniesWorkspaceProps = {
accountNavigation: ReactNode;
administrationContent?: ReactNode;
canManageUsers?: boolean;
userName: string;
languageSwitcher: ReactNode;
};

View File

@ -11,7 +11,13 @@ import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceSidebar } from "./workspace-sidebar";
export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
const { accountNavigation, userName, languageSwitcher } = props;
const {
accountNavigation,
userName,
languageSwitcher,
administrationContent,
canManageUsers = false,
} = props;
const {
query,
setLocation,
@ -32,10 +38,12 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
data-slot="workspace"
>
<WorkspaceSidebar
canManageUsers={canManageUsers}
catalog={catalog}
companies={companies}
current={current}
onSettings={() => setSettingsOpen(true)}
usersActive={Boolean(administrationContent)}
/>
<div className="min-w-0 flex-1 overflow-y-auto bg-shell-content">
<WorkspaceHeader
@ -51,12 +59,12 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
open={settingsOpen}
/>
<span className="sr-only">{coreTranslation("workspace")}</span>
{query.isPending && (
{!administrationContent && query.isPending && (
<p className="p-6" role="status">
{t("loading")}
</p>
)}
{query.isError && (
{!administrationContent && query.isError && (
<div className="space-y-4 p-6">
<CompanyFeedback error={query.error} />
<Button
@ -69,7 +77,9 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
</Button>
</div>
)}
{current.area === "company" ? (
{administrationContent ? (
<main className="min-w-0 p-4 shell:p-6">{administrationContent}</main>
) : current.area === "company" ? (
<CompanyDetail
embedded
id={current.id}

View File

@ -1,3 +1,4 @@
import { MetisLogo } from "@metis/core/client";
import {
BoxIcon,
Building2Icon,
@ -9,6 +10,7 @@ import {
LayoutTemplateIcon,
SettingsIcon,
TicketIcon,
UsersIcon,
WrenchIcon,
} from "@repo/rdx-ui/lib/icons";
import { useState } from "react";
@ -25,13 +27,18 @@ export function WorkspaceSidebar({
current,
catalog,
onSettings,
canManageUsers = false,
usersActive = false,
}: {
companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[];
current: WorkspaceLocation;
catalog: GlobalCatalog | undefined;
onSettings: () => void;
canManageUsers?: boolean;
usersActive?: boolean;
}) {
const { t } = useTranslation("companies");
const { t: identityTranslation } = useTranslation("identity");
const { t: coreTranslation } = useTranslation("core");
const { t: resourceTranslation } = useTranslation("resources");
const { t: assetTranslation } = useTranslation("assets");
@ -55,20 +62,7 @@ export function WorkspaceSidebar({
className="flex shrink-0 items-center gap-[13px] whitespace-nowrap p-0.5 text-[21px] font-[650] leading-[30px] tracking-[-0.5px] shell:p-0 shell:pl-[30px] shell:pt-[31px] shell:group-data-[collapsed=true]/sidebar:justify-center shell:group-data-[collapsed=true]/sidebar:pl-0"
role="img"
>
<svg
aria-hidden="true"
className="h-[30px] w-8 shrink-0 text-shell-accent"
viewBox="0 0 36 30"
>
<path
d="M5 24 13 7l9 17 9-18"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="7"
/>
</svg>
<MetisLogo className="h-[30px] w-8 shrink-0 text-shell-accent" />
<span className="shell:group-data-[collapsed=true]/sidebar:hidden">Metis Desk</span>
</div>
<nav
@ -86,7 +80,7 @@ export function WorkspaceSidebar({
).map(([area, label, Icon]) => (
<WorkspaceNavigationButton
aria-label={t(label)}
aria-pressed={current.area === area}
aria-pressed={!usersActive && current.area === area}
href={workspaceHref({ area })}
key={area}
title={t(label)}
@ -101,6 +95,17 @@ export function WorkspaceSidebar({
)}
</WorkspaceNavigationButton>
))}
{canManageUsers && (
<WorkspaceNavigationButton
aria-label={identityTranslation("users")}
aria-pressed={usersActive}
href="/settings/users"
title={identityTranslation("users")}
>
<UsersIcon aria-hidden="true" className="size-5 shell:size-6" />
<span>{identityTranslation("users")}</span>
</WorkspaceNavigationButton>
)}
{current.area === "catalogs" && (
<nav
aria-label={t("globalCatalogs")}

View File

@ -0,0 +1,39 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { CompanyDTO } from "../../../../common";
import { profileApi, profileKeys } from "../../../work-profiles/shared/api";
import { workstationRelations } from "../helpers/workstation-relations";
export function useWorkstationDetailController(company: CompanyDTO, workstationId: string) {
const [creatingProfile, setCreatingProfile] = useState(false);
const [editing, setEditing] = useState(false);
const inventory = useQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const profiles = useQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const workstation = inventory.data?.workstations.find((item) => item.id === workstationId);
const { relatedProfiles, personIds } = workstationRelations(
workstation,
profiles.data?.profiles ?? []
);
const people = company.people.filter((person) => personIds.has(person.id));
return {
creatingProfile,
setCreatingProfile,
inventory,
profiles,
workstation,
editing,
setEditing,
relatedProfiles,
people,
};
}

View File

@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { WorkstationSchema } from "@metis/assets/common";
import { WorkProfileSchema } from "../../../../common/work-profiles";
import { workstationRelations } from "./workstation-relations";
const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`;
const values = {
name: "Puesto",
active: true,
siteId: null,
personId: id(3),
assetIds: [id(4)],
sharedAssetIds: [id(5)],
};
const history = {
id: id(9),
actorId: id(10),
actorName: "David",
recordedAt: "2026-10-10T10:00:00.000Z",
};
const workstation = WorkstationSchema.parse({
id: id(1),
companyId: id(2),
values,
history: [{ ...history, values }],
});
const profile = (
n: number,
assetId: string,
personId: string,
active = true,
companyId = id(2)
) => {
const profileValues = {
name: `Perfil ${n}`,
assetId,
personId,
active,
accountId: null,
notes: null,
};
return WorkProfileSchema.parse({
id: id(n),
companyId,
values: profileValues,
needsReview: false,
history: [{ ...history, values: profileValues }],
});
};
test("incluye perfiles de activos exclusivos y compartidos, distingue inactivos y deduplica personas", () => {
const profiles = [
profile(11, id(4), id(3)),
profile(12, id(5), id(6), false),
profile(13, id(7), id(8)),
profile(14, id(4), id(3)),
profile(15, id(4), id(8), true, id(20)),
];
const result = workstationRelations(workstation, profiles);
assert.deepEqual(
result.relatedProfiles.map((item) => item.id),
[id(11), id(12), id(14)]
);
assert.equal(result.relatedProfiles[1]?.values.active, false);
assert.deepEqual([...result.personIds], [id(3), id(6)]);
});
test("puesto inexistente o sin composición no hereda perfiles de otros activos", () => {
assert.equal(
workstationRelations(undefined, [profile(11, id(4), id(3))]).relatedProfiles.length,
0
);
const empty = WorkstationSchema.parse({
...workstation,
values: { ...values, assetIds: [], sharedAssetIds: [] },
});
const result = workstationRelations(empty, [profile(11, id(4), id(6))]);
assert.equal(result.relatedProfiles.length, 0);
assert.deepEqual([...result.personIds], [id(3)]);
});

View File

@ -0,0 +1,25 @@
import type { WorkstationDTO } from "@metis/assets/common";
import type { WorkProfileDTO } from "../../../../common/work-profiles";
/** Un perfil pertenece a un activo; los compartidos pueden aparecer en varios puestos. */
export function workstationRelations(
workstation: WorkstationDTO | undefined,
profiles: readonly WorkProfileDTO[]
) {
const assetIds = new Set([
...(workstation?.values.assetIds ?? []),
...(workstation?.values.sharedAssetIds ?? []),
]);
const relatedProfiles = profiles.filter(
(profile) =>
profile.companyId === workstation?.companyId && assetIds.has(profile.values.assetId)
);
const personIds = new Set(relatedProfiles.map((profile) => profile.values.personId));
if (workstation?.values.personId) {
personIds.add(workstation.values.personId);
}
return { relatedProfiles, personIds };
}

View File

@ -0,0 +1,86 @@
import type { AssetDTO, WorkstationDTO } from "@metis/assets/common";
import { BrowserLink } from "@metis/core/client";
import { DetailField } from "@repo/rdx-ui/components/detail-field";
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { LaptopIcon } from "@repo/rdx-ui/lib/icons";
import { useTranslation } from "react-i18next";
export function WorkstationAssets({
companyId,
workstation,
assets,
}: {
companyId: string;
workstation: WorkstationDTO;
assets: readonly AssetDTO[];
}) {
const { t } = useTranslation("assets");
const root = `/companies/${companyId}`;
const assetIds = new Set([...workstation.values.assetIds, ...workstation.values.sharedAssetIds]);
const assetName = (id: string) =>
assets.find((asset) => asset.id === id)?.values.name ?? t("noValue");
return (
<section aria-labelledby="workstation-assets" className="space-y-3">
<h2 className="flex items-center gap-2 text-lg font-semibold" id="workstation-assets">
<LaptopIcon aria-hidden="true" className="size-5 text-muted-foreground" />
{t("workstationDetail.assets")}{" "}
<span className="text-sm font-normal text-muted-foreground">{assetIds.size}</span>
</h2>
{!assetIds.size && (
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
)}
<div className="grid gap-3 md:grid-cols-2">
{[...assetIds].map((id) => {
const asset = assets.find((item) => item.id === id);
return (
<article className="min-w-0 space-y-3 rounded-xl border bg-card p-4" key={id}>
<div className="space-y-1">
<BrowserLink className="font-medium" href={`${root}/assets/${id}`}>
{assetName(id)}
</BrowserLink>
<p className="text-xs text-muted-foreground">
{id === workstation.values.computerAssetId
? t("workstationComputer")
: t(
workstation.values.sharedAssetIds.includes(id)
? "sharedWorkstationAssets"
: "exclusiveWorkstationAssets"
)}
</p>
</div>
{asset ? (
<>
<p className="text-sm text-muted-foreground">
{asset.specification.templateName}
</p>
<EntityStatus active={asset.values.status !== "retired"}>
{t(asset.values.status)}
</EntityStatus>
<dl>
{asset.specification.fields
.filter((field) => asset.values.characteristics[field.fieldId] !== undefined)
.slice(0, 3)
.map((field) => (
<DetailField key={field.fieldId} label={field.name}>
{typeof asset.values.characteristics[field.fieldId] === "boolean"
? t(asset.values.characteristics[field.fieldId] ? "yes" : "no")
: String(asset.values.characteristics[field.fieldId])}
</DetailField>
))}
</dl>
</>
) : (
<p className="text-sm text-muted-foreground">
{t("workstationDetail.missingAsset")}
</p>
)}
</article>
);
})}
</div>
</section>
);
}

View File

@ -0,0 +1,243 @@
import { WorkstationEditorDialog } from "@metis/assets/client";
import { BrowserLink } from "@metis/core/client";
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { BriefcaseIcon, ChevronLeftIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import { PersonAvatar } from "../../../people/shared";
import { WorkProfileEditorDialog } from "../../../work-profiles/shared";
import { useWorkstationDetailController } from "../controllers/use-workstation-detail-controller";
import { WorkstationAssets } from "./workstation-assets";
export function WorkstationDetailPage({
company,
workstationId,
lookups,
}: {
company: CompanyDTO;
workstationId: string;
lookups: ComponentProps<typeof WorkstationEditorDialog>["lookups"];
}) {
const { t } = useTranslation("assets");
const { t: profileTranslation } = useTranslation("companies");
const {
creatingProfile,
setCreatingProfile,
inventory,
profiles,
workstation,
editing,
setEditing,
relatedProfiles,
people,
} = useWorkstationDetailController(company, workstationId);
const root = `/companies/${company.id}`;
const personName = (id: string) =>
company.people.find((person) => person.id === id)?.name ?? t("noValue");
const assetName = (id: string) =>
inventory.data?.assets.find((asset) => asset.id === id)?.values.name ?? t("noValue");
return (
<div className="space-y-6">
<BrowserLink className="inline-flex items-center gap-1 text-sm" href={`${root}/workstations`}>
<ChevronLeftIcon aria-hidden="true" className="size-4" />
{t("workstationDetail.back")}
</BrowserLink>
{inventory.isPending && <p role="status">{t("loading")}</p>}
{inventory.isError && (
<div className="space-y-2" role="alert">
<p>{t("workstationDetail.loadError")}</p>
<Button
onClick={() => {
inventory.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
</div>
)}
{inventory.isSuccess && !workstation && (
<p role="status">{t("workstationDetail.notFound")}</p>
)}
{workstation && (
<>
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<h1 className="flex items-center gap-3 text-2xl font-semibold">
<BriefcaseIcon aria-hidden="true" className="size-6 text-muted-foreground" />
{workstation.values.name}
</h1>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
<EntityStatus active={workstation.values.active}>
{t(workstation.values.active ? "active" : "inactive")}
</EntityStatus>
{workstation.values.siteId && (
<BrowserLink href={`${root}/sites`}>
{company.sites.find((site) => site.id === workstation.values.siteId)?.name ??
t("noValue")}
</BrowserLink>
)}
{workstation.values.personId && (
<span className="inline-flex flex-wrap items-center gap-1">
<span className="text-muted-foreground">{t("workstationPerson")}:</span>
<BrowserLink href={`${root}/people/${workstation.values.personId}`}>
{personName(workstation.values.personId)}
</BrowserLink>
</span>
)}
</div>
<p className="text-sm text-muted-foreground">{t("workstationDetail.subtitle")}</p>
</div>
<Button onClick={() => setEditing(true)} variant="outline">
{t("editWorkstation")}
</Button>
</header>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)]">
<div className="space-y-6">
<WorkstationAssets
assets={inventory.data?.assets ?? []}
companyId={company.id}
workstation={workstation}
/>
<section aria-labelledby="workstation-profiles" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold" id="workstation-profiles">
{t("workstationDetail.profiles")}
</h2>
<Button
disabled={!workstation.values.active}
onClick={() => setCreatingProfile(true)}
size="sm"
variant="outline"
>
{profileTranslation("profiles.new")}
</Button>
</div>
<p className="text-sm text-muted-foreground">
{t("workstationDetail.profilesHint")}
</p>
{profiles.isPending && <p role="status">{t("loading")}</p>}
{profiles.isError && (
<div role="alert">
<p className="text-sm text-muted-foreground">
{t("workstationDetail.profilesError")}
</p>
<Button
onClick={() => {
profiles.refetch().catch(() => undefined);
}}
size="sm"
variant="outline"
>
{t("retry")}
</Button>
</div>
)}
{profiles.isSuccess && !relatedProfiles.length && (
<p className="text-sm text-muted-foreground">
{t("workstationDetail.noProfiles")}
</p>
)}
<div className="divide-y">
{relatedProfiles.map((profile) => (
<article
className="flex flex-wrap items-start justify-between gap-3 py-3"
key={profile.id}
>
<div className="space-y-1">
<BrowserLink
className="font-medium"
href={`${root}/work-profiles/${profile.id}`}
>
{profile.values.name}
</BrowserLink>
<p className="text-sm">
<BrowserLink href={`${root}/people/${profile.values.personId}`}>
{personName(profile.values.personId)}
</BrowserLink>
<span className="mx-2 text-muted-foreground">·</span>
<BrowserLink href={`${root}/assets/${profile.values.assetId}`}>
{assetName(profile.values.assetId)}
</BrowserLink>
</p>
{profile.needsReview && (
<p className="text-xs text-muted-foreground">
{t("workstationDetail.needsReview")}
</p>
)}
</div>
<EntityStatus active={profile.values.active}>
{t(profile.values.active ? "active" : "inactive")}
</EntityStatus>
</article>
))}
</div>
</section>
</div>
<div className="space-y-6">
<section aria-labelledby="workstation-people" className="space-y-3">
<h2
className="flex items-center gap-2 text-lg font-semibold"
id="workstation-people"
>
<UsersIcon aria-hidden="true" className="size-5 text-muted-foreground" />
{t("workstationDetail.people")}
</h2>
{!people.length && profiles.isSuccess && (
<p className="text-sm text-muted-foreground">{t("workstationDetail.noPeople")}</p>
)}
<div className="divide-y">
{people.map((person) => (
<div className="flex items-center gap-3 py-3" key={person.id}>
<PersonAvatar
companyId={company.id}
name={person.name}
personId={person.id}
photoVersion={person.photoVersion}
/>
<div>
<BrowserLink href={`${root}/people/${person.id}`}>
{person.name}
</BrowserLink>
<p className="text-xs text-muted-foreground">
{t(
person.id === workstation.values.personId
? "workstationDetail.assignedPerson"
: "workstationDetail.profilePerson"
)}
</p>
</div>
</div>
))}
</div>
</section>
</div>
</div>
{creatingProfile && (
<WorkProfileEditorDialog
company={company}
onClose={() => setCreatingProfile(false)}
profile={undefined}
workstationId={workstation.id}
/>
)}
{editing && (
<WorkstationEditorDialog
companyId={company.id}
lookups={lookups}
onClose={() => setEditing(false)}
workstation={workstation}
/>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { assetsEnglish, assetsSpanish, inventoryKeys } from "@metis/assets/client";
import { WorkstationSchema } from "@metis/assets/common";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { companiesEnglish, companiesSpanish } from "../../../shared";
import { profileKeys } from "../../../work-profiles/shared/api";
import { WorkstationDetailPage } from "./workstation-detail-page";
const companyId = "0199b200-0000-7000-8000-000000000001";
const workstationId = "0199b200-0000-7000-8000-000000000002";
const company = {
id: companyId,
name: "Empresa",
active: true,
people: [],
contacts: [],
sites: [],
departments: [],
assignments: [],
};
const values = {
name: "Administración",
active: true,
siteId: null,
personId: null,
assetIds: [],
sharedAssetIds: [],
};
const workstation = WorkstationSchema.parse({
id: workstationId,
companyId,
values,
history: [
{
id: "0199b200-0000-7000-8000-000000000003",
actorId: companyId,
actorName: "David",
recordedAt: "2026-10-10T10:00:00.000Z",
values,
},
],
});
async function render(language: string, exists: boolean) {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: {
es: { assets: assetsSpanish, companies: companiesSpanish },
en: { assets: assetsEnglish, companies: companiesEnglish },
},
});
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
});
client.setQueryData(inventoryKeys.company(companyId), {
assets: [],
relationships: [],
workstations: exists ? [workstation] : [],
});
client.setQueryData(profileKeys.company(companyId), { profiles: [] });
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>
<WorkstationDetailPage
company={company}
lookups={{
people: [],
sites: [],
onCreatePerson: () => undefined,
onCreateSite: () => undefined,
}}
workstationId={workstationId}
/>
</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("ficha de puesto es/en: cabecera, retorno, bloques vacíos y edición modal sin formulario incrustado", async () => {
for (const language of ["es", "en"]) {
const html = await render(language, true);
assert.ok(html.includes("Administración"));
assert.ok(html.includes(`href="/companies/${companyId}/workstations"`));
assert.ok(html.includes(language === "es" ? "Editar puesto" : "Edit workstation"));
assert.ok(html.includes(language === "es" ? "Perfiles de usuario" : "User profiles"));
assert.ok(html.includes(language === "es" ? "Personas relacionadas" : "Related people"));
assert.ok(html.includes(language === "es" ? "Nuevo perfil" : "New profile"));
assert.equal(html.includes("<form"), false);
}
});
test("un UUID de puesto ausente muestra no encontrado sin cabecera ni edición de otro puesto", async () => {
const html = await render("es", false);
assert.ok(html.includes("Este puesto de trabajo no existe en esta compañía."));
assert.equal(html.includes("Administración"), false);
assert.equal(html.includes("Editar puesto"), false);
});

View File

@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function MetisLogo(props: ComponentProps<"svg">) {
return (
<svg aria-hidden="true" viewBox="0 0 36 30" {...props}>
<path
d="M5 24 13 7l9 17 9-18"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="7"
/>
</svg>
);
}

View File

@ -1,7 +1,9 @@
export { default as coreEnglish } from "../common/locales/en.json";
export { default as coreSpanish } from "../common/locales/es.json";
export { MetisLogo } from "./branding/shared/ui/metis-logo";
export { configureCsrfTokenReader, csrfFetch } from "./http/shared/api/csrf-fetch";
export { LIST_REFRESH_INTERVAL, useListQuery } from "./list";
export {
BrowserLink,
EntityNotFound,

View File

@ -0,0 +1 @@
export { LIST_REFRESH_INTERVAL, useListQuery } from "./shared/hooks/use-list-query";

View File

@ -0,0 +1,29 @@
import type {
DefaultError,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
/** Intervalo de actualización de los listados mientras la pestaña permanece visible. */
export const LIST_REFRESH_INTERVAL = 60_000;
/**
* Mantiene actualizados los datos remotos que presenta un listado sin consultar
* mientras la aplicación no está en primer plano.
*/
export function useListQuery<
TQueryFnData,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
return useQuery({
...options,
refetchInterval: LIST_REFRESH_INTERVAL,
refetchIntervalInBackground: false,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
});
}

View File

@ -15,7 +15,7 @@
"saving": "Saving…",
"saved": "Changes saved.",
"users": "Users",
"usersHelp": "Create accounts, update their details and control access. There are no configurable roles.",
"usersHelp": "Manage accounts that can access Metis Desk. Company people are managed separately.",
"createUser": "New user",
"editUser": "Edit user",
"save": "Save",
@ -48,5 +48,6 @@
"ADMIN_PROTECTED": "The initial administrator account is protected.",
"TOO_MANY_REQUESTS": "Too many attempts. Try again in 15 minutes.",
"INTERNAL_ERROR": "The operation could not be completed. Please try again."
}
},
"accessBrandHelp": "Your space to organize support, assets and people."
}

View File

@ -15,7 +15,7 @@
"saving": "Guardando…",
"saved": "Cambios guardados.",
"users": "Usuarios",
"usersHelp": "Crea cuentas, actualiza sus datos y controla el acceso. No hay roles configurables.",
"usersHelp": "Gestiona las cuentas de acceso a Metis Desk. Las personas de las compañías se administran por separado.",
"createUser": "Nuevo usuario",
"editUser": "Editar usuario",
"save": "Guardar",
@ -48,5 +48,6 @@
"ADMIN_PROTECTED": "La cuenta de administrador inicial está protegida.",
"TOO_MANY_REQUESTS": "Demasiados intentos. Vuelve a probar dentro de 15 minutos.",
"INTERNAL_ERROR": "No se ha podido completar la operación. Inténtalo de nuevo."
}
},
"accessBrandHelp": "Tu espacio para organizar el soporte, los activos y las personas."
}

View File

@ -58,7 +58,7 @@ export function CredentialForm(props: CredentialFormProps) {
</p>
)}
<FormFeedback error={mutation.error} />
<Button className="w-full" disabled={mutation.isPending} type="submit">
<Button className="mt-2 h-11 w-full" disabled={mutation.isPending} type="submit">
{mutation.isPending ? t("saving") : t(setup ? "createAdmin" : "login")}
</Button>
</form>

View File

@ -1 +1 @@
export { UserManagement, UserManagementDialog } from "./list";
export { UserManagement } from "./list";

View File

@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import type { UserDTO } from "../../../../common";
@ -6,7 +6,7 @@ import { identityKeys } from "../../../shared/queries";
import { listUsers } from "../api";
export function useUserManagementController() {
const users = useQuery({ queryKey: identityKeys.users, queryFn: listUsers });
const users = useListQuery({ queryKey: identityKeys.users, queryFn: listUsers });
const [createOpen, setCreateOpen] = useState(false);
const [selection, setSelection] = useState<
| {

View File

@ -1 +1 @@
export { UserManagement, UserManagementDialog } from "./ui";
export { UserManagement } from "./ui";

View File

@ -1,3 +1 @@
export { UserManagement } from "./user-management";
export { UserManagementDialog } from "./user-management-dialog";

View File

@ -1,26 +0,0 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
import { UserManagement } from "./user-management";
export function UserManagementDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation("identity");
return (
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={onOpenChange}
open={open}
title={t("users")}
wide
>
<UserManagement />
</FormDialog>
);
}

View File

@ -10,5 +10,6 @@ export type WorkspacePage = Readonly<{
export type WorkspaceRenderer = (
session: SessionResponseDTO,
accountNavigation: ReactNode
accountNavigation: ReactNode,
administrationContent?: ReactNode
) => ReactNode;

View File

@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import type { SessionResponseDTO } from "../../../../common";
import { AccountPasswordDialog } from "../../../passwords";
import { FormFeedback } from "../../../shared";
import { UserManagementDialog } from "../../../users";
import { UserManagement } from "../../../users";
import type { WorkspaceRenderer } from "../../shared/types";
export function CustomWorkspace({
@ -69,16 +69,9 @@ export function CustomWorkspace({
</Button>
</div>
<FormFeedback error={logoutError} />
</div>
</div>,
page === "users" && snapshot.user.isInitialAdmin ? <UserManagement /> : undefined
)}
<UserManagementDialog
onOpenChange={(open) => {
if (!open) {
setPage("home");
}
}}
open={page === "users" && snapshot.user.isInitialAdmin}
/>
<AccountPasswordDialog onClose={() => setPage("home")} open={page === "password"} />
</>
);

View File

@ -1,11 +1,5 @@
import { MetisLogo } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components/card";
import { useTranslation } from "react-i18next";
import { CredentialForm } from "../../../sessions";
@ -48,23 +42,35 @@ export function IdentityWorkspace(props: IdentityWorkspaceProps) {
if (!snapshot.user) {
return (
<main className="mx-auto max-w-lg px-6 py-12">
<Card>
<CardHeader>
<CardTitle className="text-2xl">
{t(snapshot.setupRequired ? "setupTitle" : "loginTitle")}
</CardTitle>
<CardDescription>
{t(snapshot.setupRequired ? "setupHelp" : "loginHelp")}
</CardDescription>
</CardHeader>
<CardContent>
<main className="flex min-h-dvh items-center justify-center bg-muted/40 px-4 py-8 sm:px-8">
<section
aria-labelledby="access-title"
className="grid w-full max-w-4xl overflow-hidden rounded-2xl border bg-card shadow-xl md:grid-cols-[0.9fr_1.1fr]"
>
<div className="flex flex-col justify-center gap-5 bg-shell-sidebar px-8 py-8 text-shell-sidebar-foreground sm:px-10 md:py-14">
<div className="flex items-center gap-4 md:flex-col md:items-start md:gap-6">
<MetisLogo className="h-12 w-14 shrink-0 text-shell-accent md:h-16 md:w-20" />
<p className="text-3xl font-semibold tracking-tight md:text-4xl">Metis Desk</p>
</div>
<p className="max-w-xs text-base leading-relaxed text-shell-sidebar-muted">
{t("accessBrandHelp")}
</p>
</div>
<div className="px-6 py-8 sm:px-10 md:py-14">
<div className="mb-8 space-y-3">
<h1 className="text-2xl font-semibold tracking-tight" id="access-title">
{t(snapshot.setupRequired ? "setupTitle" : "loginTitle")}
</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
{t(snapshot.setupRequired ? "setupHelp" : "loginHelp")}
</p>
</div>
<CredentialForm
key={snapshot.setupRequired ? "setup" : "login"}
setup={snapshot.setupRequired}
/>
</CardContent>
</Card>
</div>
</section>
</main>
);
}

View File

@ -1,5 +1,5 @@
import { useBrowserSearchParams } from "@metis/core/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useBrowserSearchParams, useListQuery } from "@metis/core/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import type { CatalogItemDTO } from "../../../../common";
@ -35,7 +35,7 @@ export function useResourceCatalogPageController({ scope }: ResourceCatalogPageP
const [viewId, setViewId] = useState<string>();
const [editor, setEditor] = useState<CatalogEditor>();
const query = useQuery({
const query = useListQuery({
queryKey: catalogKeys.scope(scope),
queryFn: () => catalogApi.get(scope),
});

View File

@ -1,5 +1,4 @@
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client";
import { useState } from "react";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
@ -33,11 +32,11 @@ export function useResourcesPageController({
selectEntity: setViewId,
entityHref,
} = useEntityNavigation(`/companies/${companyId}/resources`, initialId);
const query = useQuery({
const query = useListQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const catalog = useQuery({
const catalog = useListQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});

View File

@ -1,13 +1,21 @@
import type { ReactNode } from "react";
/** Agrupación plegable accesible para fichas; comienza abierta sin ocultar información. */
import { ChevronRightIcon } from "../lib/icons";
/** Grupo de consulta compartido por fichas y plantillas; abierto inicialmente. */
export function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<details className="@container border-t border-border/60 first:border-t-0" open>
<summary className="cursor-pointer py-2 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-ring">
<details className="@container group/detail-section" open>
<summary className="flex cursor-pointer list-none items-center gap-2 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden">
<ChevronRightIcon
aria-hidden="true"
className="size-3 shrink-0 transition-transform group-open/detail-section:rotate-90 motion-reduce:transition-none"
/>
{title}
</summary>
<div className="space-y-3 pb-2 pt-1">{children}</div>
<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>
</details>
);
}

View File

@ -3,6 +3,8 @@ import { useEffect, useRef } from "react";
import { ChevronRightIcon } from "../lib/icons";
import { DetailSection } from "./detail-section";
export type FieldSection = { id: string; name: string; description: string };
type FieldSectionLayout = "fields" | "rows" | "form";
@ -27,8 +29,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
}) {
const container = useRef<HTMLDivElement>(null);
const styles = fieldSectionStyles(layout, values);
const contentClass =
layout === "rows" && sections.length > 0 ? `${styles.content} px-5` : styles.content;
const contentClass = styles.content;
useEffect(() => {
if (hasErrors) {
@ -40,7 +41,15 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
const content = (fields: readonly T[]) =>
values ? (
<dl className="@container">{fields.map(renderItem)}</dl>
<dl
className={
layout === "rows"
? "@container [&>div:first-child]:pt-2.5 [&>div:last-child]:pb-2.5"
: "@container"
}
>
{fields.map(renderItem)}
</dl>
) : (
<div className={contentClass}>{fields.map(renderItem)}</div>
);
@ -53,7 +62,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
return (
<div
className={styles.wrapper}
className={values && layout === "rows" ? "space-y-3" : styles.wrapper}
onInvalidCapture={(event) => {
// Abrir antes de que el navegador intente enfocar un control inválido oculto.
if (event.target instanceof HTMLElement) {
@ -73,6 +82,17 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
return null;
}
if (layout === "rows") {
return (
<DetailSection key={section.id} title={section.name}>
{section.description && (
<p className="pt-2 text-sm text-muted-foreground">{section.description}</p>
)}
{content(fields)}
</DetailSection>
);
}
return (
<details className={styles.section} key={section.id} open>
<summary className={styles.summary}>