From e061d60eb7a20f8aeb574779e4210972b231daf9 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 11 Oct 2026 13:49:28 +0200 Subject: [PATCH] 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. --- .vscode/settings.json | 2 +- TODO.md | 5 +- apps/server/src/workstations.integration.ts | 18 ++ apps/web/src/app.test.tsx | 83 +++++- apps/web/src/app.tsx | 10 +- apps/web/src/test-fixtures/workspace.ts | 2 +- compose.yaml | 1 + docs/componentes-de-interfaz.md | 22 ++ docs/patron-de-listados-de-entidades.md | 1 + docs/perfiles-de-trabajo.md | 8 +- docs/puesta-en-marcha.md | 14 + docs/puestos-de-trabajo.md | 10 +- docs/usuarios-y-acceso.md | 6 + .../value-objects/workstation-values.vo.ts | 21 +- .../src/api/domain/workstation-values.ts | 1 + .../assets/src/api/domain/workstation.test.ts | 28 ++ .../persistence/workstation-relational.ts | 8 +- .../persistence/workstation.model.ts | 9 + modules/assets/src/common/locales/en.json | 25 +- modules/assets/src/common/locales/es.json | 25 +- .../use-asset-relationships-controller.ts | 4 +- .../src/web/assets/detail/ui/asset-detail.tsx | 61 +++-- .../assets/detail/ui/asset-panel-summary.tsx | 63 +++++ .../detail/ui/asset-selection-panel.tsx | 31 ++- .../use-inventory-page-controller.ts | 8 +- .../src/web/assets/shared/inventory.test.tsx | 17 +- .../controllers/use-brands-page-controller.ts | 4 +- .../use-catalog-page-controller.ts | 6 +- modules/assets/src/web/index.ts | 10 +- .../use-asset-support-controller.ts | 4 +- modules/assets/src/web/workstations/index.ts | 3 +- .../use-workstations-page-controller.ts | 4 +- .../list/ui/workstations-page.tsx | 12 +- .../shared/ui/workstation-form.tsx | 24 +- modules/companies/package.json | 2 +- modules/companies/src/common/locales/en.json | 7 +- modules/companies/src/common/locales/es.json | 7 +- .../use-access-workspace-controller.ts | 5 +- ...lication-configurations-page-controller.ts | 10 +- ...tificate-configurations-page-controller.ts | 10 +- .../companies/detail/ui/company-inventory.tsx | 31 ++- .../companies/detail/ui/company-section.tsx | 2 +- .../use-companies-page-controller.ts | 5 +- .../use-folder-mappings-page-controller.ts | 8 +- .../use-templates-page-controller.ts | 4 +- .../use-interventions-page-controller.ts | 10 +- .../list/ui/ticket-interventions.tsx | 4 +- ...use-mail-configurations-page-controller.ts | 10 +- .../web/people/detail/ui/person-details.tsx | 2 +- .../src/web/people/shared/ui/index.ts | 5 +- .../people/shared/ui/person-avatar-group.tsx | 42 +++ .../web/people/shared/ui/person-avatar.tsx | 89 +++---- ...-printer-configurations-page-controller.ts | 8 +- .../use-process-templates-page-controller.ts | 4 +- .../use-processes-page-controller.ts | 6 +- .../use-remote-connections-page-controller.ts | 8 +- ...telework-authorizations-page-controller.ts | 4 +- .../use-tickets-page-controller.ts | 10 +- .../use-work-profiles-page-controller.ts | 8 +- .../use-work-profile-form-controller.ts | 51 +++- .../src/web/work-profiles/shared/index.ts | 2 - .../web/work-profiles/shared/profile.test.tsx | 66 ++++- .../web/work-profiles/shared/types/index.ts | 1 - .../shared/types/work-profile-form-props.ts | 1 + .../src/web/work-profiles/shared/ui/index.ts | 2 - .../shared/ui/work-profile-editor-dialog.tsx | 9 +- .../shared/ui/work-profile-form.tsx | 46 +++- .../settings/ui/workspace-settings-dialog.tsx | 10 +- .../shared/helpers/workspace-routes.ts | 1 + .../workspace/shared/workspace-routes.test.ts | 22 ++ .../view/types/companies-workspace-props.ts | 2 + .../workspace/view/ui/companies-workspace.tsx | 18 +- .../workspace/view/ui/workspace-sidebar.tsx | 35 +-- .../use-workstation-detail-controller.ts | 39 +++ .../helpers/workstation-relations.test.ts | 89 +++++++ .../detail/helpers/workstation-relations.ts | 25 ++ .../detail/ui/workstation-assets.tsx | 86 +++++++ .../detail/ui/workstation-detail-page.tsx | 243 ++++++++++++++++++ .../detail/ui/workstation-detail.test.tsx | 113 ++++++++ .../src/web/branding/shared/ui/metis-logo.tsx | 16 ++ modules/core/src/web/index.ts | 2 + modules/core/src/web/list/index.ts | 1 + .../web/list/shared/hooks/use-list-query.ts | 29 +++ modules/identity/src/common/locales/en.json | 5 +- modules/identity/src/common/locales/es.json | 5 +- .../sessions/shared/ui/credential-form.tsx | 2 +- modules/identity/src/web/users/index.ts | 2 +- .../use-user-management-controller.ts | 4 +- modules/identity/src/web/users/list/index.ts | 2 +- .../identity/src/web/users/list/ui/index.ts | 2 - .../users/list/ui/user-management-dialog.tsx | 26 -- .../workspace/shared/types/workspace-page.ts | 3 +- .../workspace/view/ui/custom-workspace.tsx | 13 +- .../workspace/view/ui/identity-workspace.tsx | 46 ++-- .../use-catalog-page-controller.ts | 6 +- .../use-resources-page-controller.ts | 7 +- .../rdx-ui/src/components/detail-section.tsx | 16 +- .../rdx-ui/src/components/field-sections.tsx | 28 +- 98 files changed, 1564 insertions(+), 363 deletions(-) create mode 100644 modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx create mode 100644 modules/companies/src/web/people/shared/ui/person-avatar-group.tsx create mode 100644 modules/companies/src/web/workstations/detail/controllers/use-workstation-detail-controller.ts create mode 100644 modules/companies/src/web/workstations/detail/helpers/workstation-relations.test.ts create mode 100644 modules/companies/src/web/workstations/detail/helpers/workstation-relations.ts create mode 100644 modules/companies/src/web/workstations/detail/ui/workstation-assets.tsx create mode 100644 modules/companies/src/web/workstations/detail/ui/workstation-detail-page.tsx create mode 100644 modules/companies/src/web/workstations/detail/ui/workstation-detail.test.tsx create mode 100644 modules/core/src/web/branding/shared/ui/metis-logo.tsx create mode 100644 modules/core/src/web/list/index.ts create mode 100644 modules/core/src/web/list/shared/hooks/use-list-query.ts delete mode 100644 modules/identity/src/web/users/list/ui/user-management-dialog.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 341df9a..cf024cd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "editor.fontFamily": "'C64 Keyboard Regular', 'Fira Code Retina', 'Fira Code'" + "editor.fontFamily": "'Fira Code Retina', 'Fira Code'" } diff --git a/TODO.md b/TODO.md index 461ca85..530b5ea 100644 --- a/TODO.md +++ b/TODO.md @@ -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. \ No newline at end of file +- 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 \ No newline at end of file diff --git a/apps/server/src/workstations.integration.ts b/apps/server/src/workstations.integration.ts index 73261f3..890c99e 100644 --- a/apps/server/src/workstations.integration.ts +++ b/apps/server/src/workstations.integration.ts @@ -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: { diff --git a/apps/web/src/app.test.tsx b/apps/web/src/app.test.tsx index 2ebc332..7ed9e6e 100644 --- a/apps/web/src/app.test.tsx +++ b/apps/web/src/app.test.tsx @@ -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, /]*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(" { + 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 + ) => ( + + ); + + try { + const html = renderToStaticMarkup( + + + undefined} + page="users" + renderWorkspace={renderWorkspace} + setPage={() => undefined} + snapshot={snapshot} + /> + + + ); + + 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(" - {!session.data?.user && ( -
- Metis Desk - -
- )} ( + renderWorkspace={(snapshot, accountNavigation, administrationContent) => ( } userName={snapshot.user?.name ?? ""} /> diff --git a/apps/web/src/test-fixtures/workspace.ts b/apps/web/src/test-fixtures/workspace.ts index bd3e57d..9e8ddf3 100644 --- a/apps/web/src/test-fixtures/workspace.ts +++ b/apps/web/src/test-fixtures/workspace.ts @@ -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" }, diff --git a/compose.yaml b/compose.yaml index c520da4..33c72e6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,6 +2,7 @@ name: metis-desk services: database: image: mariadb:11.8 + restart: unless-stopped ports: - "127.0.0.1:3307:3306" environment: diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index c348d76..17f7b67 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -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. diff --git a/docs/patron-de-listados-de-entidades.md b/docs/patron-de-listados-de-entidades.md index 56666d3..88ea9b4 100644 --- a/docs/patron-de-listados-de-entidades.md +++ b/docs/patron-de-listados-de-entidades.md @@ -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 diff --git a/docs/perfiles-de-trabajo.md b/docs/perfiles-de-trabajo.md index b31fd53..5e3cbc8 100644 --- a/docs/perfiles-de-trabajo.md +++ b/docs/perfiles-de-trabajo.md @@ -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. diff --git a/docs/puesta-en-marcha.md b/docs/puesta-en-marcha.md index 0ddb423..402fc78 100644 --- a/docs/puesta-en-marcha.md +++ b/docs/puesta-en-marcha.md @@ -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 diff --git a/docs/puestos-de-trabajo.md b/docs/puestos-de-trabajo.md index 6bdd2ee..fe0947d 100644 --- a/docs/puestos-de-trabajo.md +++ b/docs/puestos-de-trabajo.md @@ -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. diff --git a/docs/usuarios-y-acceso.md b/docs/usuarios-y-acceso.md index 8521343..79a45db 100644 --- a/docs/usuarios-y-acceso.md +++ b/docs/usuarios-y-acceso.md @@ -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. diff --git a/modules/assets/src/api/domain/value-objects/workstation-values.vo.ts b/modules/assets/src/api/domain/value-objects/workstation-values.vo.ts index 64293fb..0699c6c 100644 --- a/modules/assets/src/api/domain/value-objects/workstation-values.vo.ts +++ b/modules/assets/src/api/domain/value-objects/workstation-values.vo.ts @@ -7,6 +7,7 @@ import { WorkstationInputSchema } from "../workstation-values"; export class WorkstationValues { private constructor( + public readonly computerAssetId: Maybe, public readonly name: Name, public readonly active: boolean, public readonly siteId: Maybe, @@ -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 && diff --git a/modules/assets/src/api/domain/workstation-values.ts b/modules/assets/src/api/domain/workstation-values.ts index 491ef97..95caa8e 100644 --- a/modules/assets/src/api/domain/workstation-values.ts +++ b/modules/assets/src/api/domain/workstation-values.ts @@ -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(), diff --git a/modules/assets/src/api/domain/workstation.test.ts b/modules/assets/src/api/domain/workstation.test.ts index 369dbb6..60007bb 100644 --- a/modules/assets/src/api/domain/workstation.test.ts +++ b/modules/assets/src/api/domain/workstation.test.ts @@ -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() }) + ) + ); +}); diff --git a/modules/assets/src/api/infrastructure/persistence/workstation-relational.ts b/modules/assets/src/api/infrastructure/persistence/workstation-relational.ts index 7f69eb9..582eb98 100644 --- a/modules/assets/src/api/infrastructure/persistence/workstation-relational.ts +++ b/modules/assets/src/api/infrastructure/persistence/workstation-relational.ts @@ -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( diff --git a/modules/assets/src/api/infrastructure/persistence/workstation.model.ts b/modules/assets/src/api/infrastructure/persistence/workstation.model.ts index b2a86ab..1e1162a 100644 --- a/modules/assets/src/api/infrastructure/persistence/workstation.model.ts +++ b/modules/assets/src/api/infrastructure/persistence/workstation.model.ts @@ -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 } diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index 393bcf8..a35e6ad 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -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." } diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index 783b275..87ff9a7 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -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." } diff --git a/modules/assets/src/web/asset-relationships/list/controllers/use-asset-relationships-controller.ts b/modules/assets/src/web/asset-relationships/list/controllers/use-asset-relationships-controller.ts index e45d605..89f3e88 100644 --- a/modules/assets/src/web/asset-relationships/list/controllers/use-asset-relationships-controller.ts +++ b/modules/assets/src/web/asset-relationships/list/controllers/use-asset-relationships-controller.ts @@ -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), }); diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx index 055bbc6..316a8f2 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx @@ -30,16 +30,18 @@ export function AssetDetail(props: AssetDetailProps) { return (
{!compact &&

{t("snapshotNotice")}

} - -
- - - {asset.specification.templateName} - - - {t(asset.values.status)} -
-
+ {!compact && ( + +
+ + + {asset.specification.templateName} + + + {t(asset.values.status)} +
+
+ )}
@@ -51,24 +53,32 @@ export function AssetDetail(props: AssetDetailProps) { t("noAssignment") )} - - {workstations?.length - ? workstations.map((item) => ( - - {item.values.name} - - )) - : t("noWorkstation")} - + {!compact && ( + + {workstations?.length + ? workstations.map((item) => ( + + {item.values.name} + + )) + : t("noWorkstation")} + + )}
- +
+ {!asset.specification.sections?.length && ( +

+ {t("characteristics")} +

+ )} ( - +
{asset.values.notes && (

{asset.values.notes}

@@ -118,6 +128,7 @@ export function AssetDetail(props: AssetDetailProps) { {t("characteristics")} (
{field.name}
diff --git a/modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx b/modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx new file mode 100644 index 0000000..e5484ff --- /dev/null +++ b/modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx @@ -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 ( +
+
+
+
+
+
+ + {asset.specification.templateName} + +
+
+
+
{t("status")}
+
+ + {t(asset.values.status)} + +
+
+
+
+
+
+
+ {workstations?.length ? ( + workstations.map((item) => ( + + {item.values.name} + + )) + ) : ( + + {workstations ? t("noWorkstation") : t("noValue")} + + )} +
+
+
+ ); +} diff --git a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx index 0a4986f..9f83ce9 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx @@ -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" > -
-

- - {asset.values.name} -

- +
+
+

+ + {asset.values.name} +

+ +
+
+ ), }, { @@ -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)}, }, diff --git a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx index 75025a2..c4d732a 100644 --- a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx +++ b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx @@ -86,6 +86,21 @@ export function WorkstationForm(props: WorkstationFormProps) {
+
+ + 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 ?? ""} + /> +

{t("workstationComputerHelp")}

+
+ {(["assetIds", "sharedAssetIds"] as const).map((field) => (
-
+ , + page === "users" && snapshot.user.isInitialAdmin ? : undefined )} - { - if (!open) { - setPage("home"); - } - }} - open={page === "users" && snapshot.user.isInitialAdmin} - /> setPage("home")} open={page === "password"} /> ); diff --git a/modules/identity/src/web/workspace/view/ui/identity-workspace.tsx b/modules/identity/src/web/workspace/view/ui/identity-workspace.tsx index ad95c19..d24a5cb 100644 --- a/modules/identity/src/web/workspace/view/ui/identity-workspace.tsx +++ b/modules/identity/src/web/workspace/view/ui/identity-workspace.tsx @@ -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 ( -
- - - - {t(snapshot.setupRequired ? "setupTitle" : "loginTitle")} - - - {t(snapshot.setupRequired ? "setupHelp" : "loginHelp")} - - - +
+
+
+
+ +

Metis Desk

+
+

+ {t("accessBrandHelp")} +

+
+
+
+

+ {t(snapshot.setupRequired ? "setupTitle" : "loginTitle")} +

+

+ {t(snapshot.setupRequired ? "setupHelp" : "loginHelp")} +

+
- - +
+
); } diff --git a/modules/resources/src/web/catalog/list/controllers/use-catalog-page-controller.ts b/modules/resources/src/web/catalog/list/controllers/use-catalog-page-controller.ts index 66ab5c9..0a31ef0 100644 --- a/modules/resources/src/web/catalog/list/controllers/use-catalog-page-controller.ts +++ b/modules/resources/src/web/catalog/list/controllers/use-catalog-page-controller.ts @@ -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(); const [editor, setEditor] = useState(); - const query = useQuery({ + const query = useListQuery({ queryKey: catalogKeys.scope(scope), queryFn: () => catalogApi.get(scope), }); diff --git a/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts b/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts index 7d9ecd1..8bc596c 100644 --- a/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts +++ b/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts @@ -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), }); diff --git a/packages/rdx-ui/src/components/detail-section.tsx b/packages/rdx-ui/src/components/detail-section.tsx index 3639b1a..a27a017 100644 --- a/packages/rdx-ui/src/components/detail-section.tsx +++ b/packages/rdx-ui/src/components/detail-section.tsx @@ -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 ( -
- +
+ + -
{children}
+
+ {children} +
); } diff --git a/packages/rdx-ui/src/components/field-sections.tsx b/packages/rdx-ui/src/components/field-sections.tsx index 63ae4b0..c593894 100644 --- a/packages/rdx-ui/src/components/field-sections.tsx +++ b/packages/rdx-ui/src/components/field-sections.tsx @@ -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({ }) { const container = useRef(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({ const content = (fields: readonly T[]) => values ? ( -
{fields.map(renderItem)}
+
div:first-child]:pt-2.5 [&>div:last-child]:pb-2.5" + : "@container" + } + > + {fields.map(renderItem)} +
) : (
{fields.map(renderItem)}
); @@ -53,7 +62,7 @@ export function FieldSections({ return (
{ // 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({ return null; } + if (layout === "rows") { + return ( + + {section.description && ( +

{section.description}

+ )} + {content(fields)} +
+ ); + } + return (