Compare commits

..

No commits in common. "e061d60eb7a20f8aeb574779e4210972b231daf9" and "45b559ab929880584b4e6ff98ffef11d0e2d5cac" have entirely different histories.

111 changed files with 368 additions and 1972 deletions

View File

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

View File

@ -7,7 +7,6 @@ Aplicación interna para soporte técnico a pequeñas empresas.
- [Organización del código](docs/arquitectura/capas-del-servidor.md)
- [Organización web y guía para nuevas entidades y operaciones](docs/estructura-web-por-entidades-y-operaciones.md)
- [Usuarios y acceso](docs/usuarios-y-acceso.md)
- [Diseño de auditoría centralizada](docs/auditoria-centralizada.md)
- [Compañías, contactos y sedes](docs/companias-contactos-y-sedes.md)
- [Personas y departamentos](docs/personas-y-departamentos.md)
- [Cuentas, grupos y recursos](docs/cuentas-grupos-y-recursos.md)

View File

@ -1,3 +1,2 @@
- 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
- 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.

View File

@ -87,15 +87,6 @@ export async function verifyInventory(
assert.equal(created.history[0]?.id.charAt(14), "7");
assert.ok(created.history[0]?.actorName);
assert.ok(created.history[0]?.actorId);
const disposable = await save(path, { ...input, name: "MON-ELIMINAR" });
const deletion = await request(`${path}/${disposable.id}/delete`, {});
assert.equal(deletion.status, 200, await deletion.clone().text());
assert.equal(AssetResponseSchema.parse(await deletion.json()).asset.id, disposable.id);
assert.equal(
(await read()).assets.some((asset) => asset.id === disposable.id),
false
);
assert.equal((await read(`/assets/${otherId}`)).assets.length, 0);
await error(`/assets/${otherId}/${created.id}/update`, input, "ASSET_NOT_FOUND", 404);
await error(path, { ...input, siteId: foreignSite.id }, "SITE_NOT_FOUND", 404);

View File

@ -201,24 +201,6 @@ 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,11 +2,10 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { catalogKeys, inventoryKeys } from "@metis/assets/client";
import { CompaniesWorkspace, companyKeys } from "@metis/companies/client";
import { 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";
@ -17,7 +16,6 @@ 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";
@ -122,7 +120,8 @@ 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.equal(markup.includes('role="combobox"'), false);
assert.match(markup, /role="combobox"/);
assert.ok(markup.includes(language === "es" ? "Español" : "English"));
assert.ok(markup.includes(pending));
assert.ok(markup.includes('role="status"'));
}
@ -142,15 +141,6 @@ 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);
@ -349,70 +339,3 @@ 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,12 +16,16 @@ 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, administrationContent) => (
renderWorkspace={(snapshot, accountNavigation) => (
<CompaniesWorkspace
accountNavigation={accountNavigation}
administrationContent={administrationContent}
canManageUsers={snapshot.user?.isInitialAdmin ?? false}
languageSwitcher={<LanguageSwitcher />}
userName={snapshot.user?.name ?? ""}
/>

View File

@ -24,7 +24,6 @@ export const workspaceCompany: CompanyDTO = {
companyId: "0199b200-0000-7000-8000-000000000001",
name: "Sede central",
address: null,
phone: null,
},
],
departments: [],
@ -76,6 +75,7 @@ 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,7 +2,6 @@ name: metis-desk
services:
database:
image: mariadb:11.8
restart: unless-stopped
ports:
- "127.0.0.1:3307:3306"
environment:

View File

@ -1,179 +0,0 @@
# Diseño de auditoría centralizada
Estado: diseño acordado para futura implementación; no describe una funcionalidad ya disponible.
Fecha: 2026-10-10.
## Objetivo y decisiones acordadas
Permitir conocer quién hizo qué, cuándo, en qué compañía y qué cambios se produjeron como consecuencia. La auditoría se consulta globalmente o dentro de una compañía y complementa los historiales propios de las entidades.
Regla obligatoria: ninguna operación que modifique datos mediante el backend puede confirmarse sin registrar su auditoría. Afecta a todas las entidades actuales y futuras, sus relaciones, catálogos, configuraciones y operaciones de mantenimiento integradas.
Las modificaciones ejecutadas directamente en MariaDB quedan fuera del alcance, por decisión del usuario. No se implementarán triggers ni un sistema de auditoría de SQL directo. Los scripts que deban quedar registrados utilizarán el mecanismo común del backend.
Persistencia normalizada: tablas y columnas tradicionales, sin JSON ni documentos serializados para valores o diferencias. JSON puede utilizarse como transporte HTTP.
## Situación de partida
Existen historiales de distintas entidades con revisiones, autor y fecha. El servidor registra solicitudes HTTP con identificador de petición y resultado técnico. No existe un registro central que agrupe cambios entre módulos ni asegure cobertura uniforme de eliminaciones y consecuencias.
Los historiales existentes se conservan: aportan la evolución propia de cada entidad. La auditoría central explica operaciones transversales, incluidas entidades eliminadas. No sustituye los registros técnicos del servidor ni convierte la aplicación en un sistema de event sourcing.
## Operaciones, eventos y causalidad
Una operación representa una solicitud del usuario o una ejecución autónoma. Contiene uno o varios eventos que representan efectos reales sobre entidades.
Cada evento puede señalar otro evento como causa. Todos los efectos de una misma operación comparten identificador de operación; el identificador HTTP sirve para diagnóstico, pero no sustituye esa identidad de negocio.
Ejemplo ilustrativo, sujeto a las reglas reales de retirada:
| Evento | Naturaleza | Causa |
| --- | --- | --- |
| Retirar el perfil de Ana | Directa | Solicitud de David |
| Retirar su configuración de correo | Derivada | Retirada del perfil |
| Retirar su configuración de impresora | Derivada | Retirada del perfil |
David permanece como iniciador. El backend puede ejecutar los efectos derivados, pero no se pierde la atribución al usuario. Una tarea autónoma tiene origen sistema y carece de iniciador humano; no se inventa un usuario para ella.
Solo se registran efectos ocurridos. Retirar una configuración no equivale a eliminar el recurso referenciado. No se generan eventos ficticios por compañías cuando cambia la disponibilidad de un catálogo global sin modificar registros particulares.
Las operaciones masivas agrupan sus eventos. Si permiten resultados parciales, cada unidad atómica conserva su resultado y la operación refleja el resultado parcial. Las consecuencias diferidas conservan la causa y la operación iniciadora; cada ejecución tiene su propio resultado y transacción. El diseño inicial no exige introducir tareas asíncronas.
## Cobertura obligatoria
- Altas, modificaciones, eliminaciones, activaciones, desactivaciones y cambios de estado.
- Asignaciones, desasignaciones, relaciones y modificaciones de sus atributos.
- Compañías, personas, sedes, departamentos, puestos de trabajo y perfiles de usuario.
- Activos y recursos; campos, agrupaciones, plantillas, marcas y catálogos globales o particulares.
- Cuentas, grupos, accesos, configuraciones de correo, aplicaciones, carpetas, impresoras, certificados y demás entidades existentes.
- Tickets, intervenciones, plantillas de intervención, pasos y procesos de personas.
- Sustitución y eliminación de fotos y logotipos, registrando el hecho y la referencia segura, no el contenido binario.
- Gestión de usuarios y seguridad: cambios de contraseña, autenticación y resultados relevantes de acceso, sin almacenar credenciales.
- Importaciones, operaciones masivas, tareas automáticas y scripts de mantenimiento integrados.
Esta enumeración no es una lista cerrada de entidades. Toda entidad nueva que permita escrituras debe incorporar auditoría antes de darse por terminada.
Las lecturas, navegación y peticiones de consulta ordinarias no generan eventos de negocio. Las exportaciones o descargas sensibles se registran expresamente cuando se incorporen. Los intentos rechazados o fallidos generan un resultado seguro, sin aparentar que se produjeron cambios.
Guardar sin diferencias puede registrar la operación como sin cambios, pero no crea eventos ficticios de modificación ni nuevas revisiones. Las repeticiones idempotentes no duplican efectos: se distingue el intento del cambio efectivo y se conserva la referencia a la operación original cuando corresponda.
## Mecanismo común en el backend
La implementación debe ofrecer un contrato sencillo, compartido e independiente de entidades concretas:
1. Abrir una operación con contexto validado: iniciador, origen, petición y ámbito.
2. Ejecutar las escrituras mediante una unidad de trabajo que exige auditoría.
3. Capturar altas, diferencias semánticas y eliminaciones en el punto común de persistencia.
4. Enriquecer los eventos con acciones de negocio y causas desde Application.
5. Confirmar conjuntamente las escrituras y la auditoría.
El autor procede de la sesión del servidor, nunca de un identificador enviado libremente por el navegador. El contexto se transmite mediante dependencias y contratos explícitos, conforme a la composición de módulos. No se introduce un Service Locator ni se obliga a propagar datos de auditoría por componentes React.
Los repositorios no pueden ofrecer un camino ordinario de escritura que eluda este mecanismo. La creación, modificación, eliminación y escritura de tablas de relación deben quedar cubiertas, incluidas operaciones en bloque. La captura se realiza sobre valores realmente persistidos, después de aplicar las reglas de negocio; las diferencias se comparan semánticamente.
Cada entidad declara sus campos auditables y sensibles. Los campos nuevos se auditan por defecto salvo exclusión explícita y justificada por sensibilidad o carácter técnico. Un alta o eliminación conserva los valores relevantes para entender el registro, incluso cuando deja de existir su historial. Los cambios de relaciones conservan identificadores y nombres seguros de los extremos.
Los hooks de Sequelize pueden servir como apoyo técnico, pero no son la única garantía: operaciones masivas, cascadas y SQL interno pueden eludir hooks individuales. Los efectos en cascada deben identificarse antes de borrar y registrarse en la misma transacción. El middleware HTTP aporta contexto y resultado, pero no sustituye eventos de negocio.
La infraestructura de persistencia implementa el contrato con Sequelize y MariaDB. Los módulos funcionales no acceden a modelos ORM de auditoría. La composición del servidor conecta los consumidores; la ubicación final del módulo se decide al implementar conforme a los consumidores reales, sin crear un framework genérico anticipado.
## Atomicidad y errores
Los cambios de negocio y sus eventos se guardan en la misma transacción MariaDB. Si falla la escritura de auditoría, se revierte la modificación completa. No se envía una respuesta de éxito ni se invalida caché como si hubiese cambiado el registro.
Los intentos fallidos se registran en una transacción independiente después del rollback. Se conserva un código de resultado seguro, nunca SQL, stack ni secretos. No se intenta guardar este resultado dentro de una transacción ya revertida.
Si también falla el registro del intento, el servidor emite un error técnico seguro con identificador de petición. No puede prometerse persistencia en MariaDB mientras la propia base de datos no está disponible; la garantía estricta es que no se confirma una escritura de negocio sin evidencia persistida.
Las acciones externas o de sistema de ficheros no comparten la transacción MariaDB. Las fotos y logotipos necesitan preparación, limpieza o compensación conforme al almacenamiento existente: la auditoría distingue el cambio confirmado de su preparación o de un fallo. No se afirmará una atomicidad inexistente entre servicios.
## Modelo relacional propuesto
Todas las filas con identidad de negocio utilizan UUID v7. Fechas almacenadas en UTC.
| Tabla | Contenido |
| --- | --- |
| `audit_operations` | Identidad, fechas, usuario iniciador y nombre conservado, origen, acción solicitada, identificador de petición y resultado. |
| `audit_events` | Operación, evento causante opcional, fecha, compañía opcional, entidad afectada, nombre conservado, acción y naturaleza directa o derivada. |
| `audit_field_changes` | Evento, clave y etiqueta de campo conservadas, tipo de dato y valores anteriores/posteriores en columnas tipadas. |
Los identificadores y nombres de usuario, compañía y entidad deben conservarse aunque desaparezca el registro original. No deben existir borrados en cascada desde esas entidades hacia la auditoría. Las referencias vivas que se necesiten para navegación serán opcionales y estarán separadas de la identidad histórica conservada.
Una entidad afectada se identifica mediante código de tipo e identificador; no se simula una FK polimórfica a distintas tablas. La eliminación del evento causante no se permite. Se valida que causa y evento pertenezcan a la misma operación y que no haya ciclos.
Los valores anteriores y posteriores tienen discriminador de tipo y columnas para texto, número, booleano, fecha o referencia. Debe distinguirse ausente, nulo y cadena vacía. Solo se utiliza la columna correspondiente al tipo, con restricciones de coherencia. Las colecciones se representan mediante filas ordenadas de cambio de miembros, sin concatenaciones ambiguas ni serialización JSON. Los campos de catálogo conservan su identificador y etiqueta aunque se renombren después.
Índices iniciales: compañía + fecha + identidad; iniciador + fecha + identidad; operación; tipo e identificador de entidad + fecha. La paginación tiene orden estable por fecha e identidad y se ejecuta en servidor.
Los códigos de acción son estables e independientes del idioma. Los textos de presentación se traducen en la interfaz; los nombres y etiquetas históricos se conservan como datos. Un código desconocido debe tener representación legible sin romper la consulta.
## Ámbitos, privacidad e integridad
- Compañía concreta: únicamente sus eventos autorizados.
- Vista global: compañías permitidas y eventos realmente globales.
- Operación con varias compañías: la vista particular no revela eventos o valores de otras compañías.
- Cambio global: se registra una vez; solo produce eventos locales si hay modificaciones locales reales.
- El acceso a una entidad mediante un enlace vuelve a comprobar autorización y pertenencia.
Nunca almacenar contraseñas, hashes de contraseña, tokens, cookies de sesión, CSRF, claves privadas ni secretos de licencias o credenciales. Para un secreto se registra que se estableció, sustituyó o retiró, sin copiar su contenido. Los datos personales se limitan a lo necesario para explicar el cambio. No se guarda el cuerpo HTTP completo.
La auditoría es de solo lectura para usuarios de la aplicación: no hay endpoints de edición o eliminación. La credencial de escritura tendrá los privilegios mínimos compatibles con el despliegue. Esto no convierte el registro en inalterable frente a un administrador de MariaDB; esa protección adicional no forma parte del alcance.
Aplicar las autorizaciones existentes. La futura ampliación de roles no condiciona la primera implementación; no se crea ahora un sistema paralelo de permisos.
## Interfaz de consulta
Añadir Auditoría al ámbito global y al menú de compañía. Reutilizar el [patrón de listados](patron-de-listados-de-entidades.md) y los componentes comunes.
Columnas: fecha/hora, usuario, compañía solo en vista global, acción, entidad, resultado y número de consecuencias. Filtros: fechas, usuario, compañía, tipo de entidad, acción, resultado y origen. Búsqueda por referencias y nombres conservados; limpiar filtros y configuración de columnas según el patrón común.
La vista principal agrupa por operación para no presentar cada consecuencia como una acción independiente del usuario. El detalle muestra los eventos ordenados, su causa y diferencias Antes → Después. Permite consultar también eventos por entidad.
Enlaces destacados, subrayado al pasar el cursor y puntero de enlace, respetando la excepción de los menús de navegación. Entidades eliminadas se muestran como referencias históricas sin enlaces rotos. Fechas en la zona del usuario, estados accesibles y textos en español e inglés. Mantener estados de carga, vacío y error.
Los historiales actuales siguen disponibles. La actividad reciente del dashboard podrá consumir esta fuente posteriormente, sin duplicar eventos. No requiere rediseñar el dashboard para implementar la auditoría.
## Implantación y evolución
1. Inventariar todas las rutas de escritura, repositorios, transacciones, cascadas y scripts existentes.
2. Crear esquema, contratos y unidad de trabajo auditada; probar atomicidad y causalidad.
3. Adaptar todos los caminos actuales de escritura, incluyendo eliminaciones y operaciones masivas.
4. Incorporar consulta global y por compañía, detalle y filtros.
5. Añadir la regla de cobertura a la documentación de desarrollo y a las pruebas de integración para futuras entidades.
La auditoría central se inicia en una fecha conocida. No se inventan autores, causas ni eventos anteriores. Los historiales previos se conservan como tales; cualquier importación histórica futura identifica expresamente su procedencia y limitaciones.
Sin borrado automático inicial. La política de conservación y archivo se decidirá antes de incorporar purgas. No se mezclan esas purgas con la eliminación ordinaria de entidades.
No se considerará completada la implantación mientras quede una escritura del backend sin cobertura. Durante la entrega incremental se documentarán las operaciones cubiertas y pendientes, sin anunciar una garantía total prematuramente.
## Criterios de aceptación y pruebas
- Toda alta, modificación, eliminación o cambio de relación confirmado genera sus eventos y valores relevantes.
- Ninguna entidad actual o futura con escritura puede darse por terminada sin cobertura de auditoría.
- Una operación registra todos sus efectos reales, con iniciador y causalidad correctos.
- Una modificación concurrente registra el valor anterior realmente sustituido, no un valor leído fuera de la transacción.
- Un fallo de auditoría revierte el cambio de negocio; un fallo de negocio no deja eventos de éxito.
- Repeticiones idempotentes no duplican cambios; guardar sin diferencias no inventa modificaciones.
- Eliminaciones y cascadas conservan evidencia aunque desaparezcan entidades e historiales.
- Cambios de catálogos globales y locales respetan su ámbito.
- Las consultas particulares y operaciones con varias compañías no filtran datos ajenos.
- Credenciales y secretos no aparecen en tablas, respuestas, exportaciones ni logs.
- Tareas autónomas, mantenimiento e importaciones se distinguen de acciones iniciadas por usuarios.
- La paginación, filtros, navegación y detalle funcionan con estados de carga, vacío y error.
Verificar con pruebas de integración MariaDB sobre los caminos reales de persistencia. Mantener un inventario de cobertura revisable y exigir estas pruebas al incorporar entidades. No basta con probar el servicio de auditoría de forma aislada ni con confiar en una convención documental.
## Referencias y documentación relacionada
Referencias funcionales consultadas el 2026-10-10; no implican reproducir su arquitectura interna:
- [GLPI: historial de cambios y acciones automáticas](https://help.glpi-project.org/documentation/modules/tabs/historical).
- [Snipe-IT: informe de actividad con filtros por entidad y acción](https://snipe-it.readme.io/reference/reportsactivity).
- [Freshservice: auditoría global y por espacio](https://support.freshservice.com/support/solutions/articles/232444-audit-log).
- [Freshservice: actividad de tickets y automatizaciones](https://support.freshservice.com/support/solutions/articles/155562-viewing-ticket-activity-history).
- [Composición de módulos](arquitectura/composicion-de-modulos.md).
- [Capas del servidor](arquitectura/capas-del-servidor.md).
- [Modelo DDD e identificadores](arquitectura/modelo-ddd-e-identificadores.md).
- [Normalización de la persistencia](normalizacion-de-la-persistencia.md).
- [Pruebas del proyecto](arquitectura/pruebas.md).

View File

@ -280,14 +280,6 @@ 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.
@ -374,17 +366,3 @@ 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,7 +33,6 @@ 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.
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.
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.
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,9 +45,3 @@ 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,20 +32,6 @@ 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 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.
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.
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.
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.
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.
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,9 +35,3 @@ 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,8 +10,6 @@ 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.
@ -37,7 +35,3 @@ 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

@ -21,10 +21,6 @@ export type InventoryContext = Readonly<{
}>;
export interface IAssetRepository {
remove(
company: UniqueID,
id: UniqueID
): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>;
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
saveWorkstation(
companyId: UniqueID,

View File

@ -22,17 +22,6 @@ export class AssetUseCases {
return this.repository.get(company.data);
}
remove(companyId: string, id: string) {
const company = UniqueID.create(companyId);
const identity = UniqueID.create(id);
if (!(company.isSuccess && identity.isSuccess)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
}
return this.repository.remove(company.data, identity.data);
}
save(companyId: string, id: string | undefined, input: AssetInput, actorId: string) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);

View File

@ -7,7 +7,6 @@ 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>,
@ -23,17 +22,8 @@ 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 &&
computer.isSuccess
)
) {
if (!(parsed.success && name.isSuccess && site.isSuccess && person.isSuccess)) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
const ids: UniqueID[] = [];
@ -56,13 +46,8 @@ 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,
@ -75,9 +60,6 @@ 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,
@ -92,7 +74,6 @@ 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,7 +1,6 @@
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,31 +117,3 @@ 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

@ -55,32 +55,6 @@ export function registerAssetRoutes(
})
.catch(next);
});
router.post("/:companyId/:id/delete", (request, response, next) => {
const params = AssetParamsSchema.safeParse(request.params);
if (
!(
params.success &&
z.strictObject({}).safeParse(request.query).success &&
z.strictObject({}).safeParse(request.body).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.companyId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ asset: buildAssetResponse(result.data.asset, result.data.actorNames) });
})
.catch(next);
});
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
router.post(path, (request, response, next) => {
const params = (

View File

@ -12,7 +12,6 @@ import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mappe
import { readBindings, readDefinitions } from "./asset-catalog-relational";
import { readInventory, writeInventory } from "./asset-inventory-relational";
import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper";
import { sqlIdentifier } from "./asset-relational-sql";
import { RelationshipModel } from "./asset-relationship.model";
import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper";
import { loadWorkstations } from "./load-workstations";
@ -187,78 +186,6 @@ export class SequelizeAssetRepository implements IAssetRepository {
}
}
private async hasExternalReferences(assetId: string, transaction: Transaction) {
const references = await this.database.query<{ tableName: string; columnName: string }>(
"SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME='assets' AND REFERENCED_COLUMN_NAME='id'",
{ type: QueryTypes.SELECT, transaction }
);
const ownedTables = new Set([
"asset_characteristics",
"asset_revision_characteristics",
"asset_revisions",
"asset_specification_field_choices",
"asset_specification_fields",
"asset_specification_sections",
]);
for (const reference of references) {
if (ownedTables.has(reference.tableName)) {
continue;
}
const rows = await this.database.query<{ value: number }>(
`SELECT 1 AS value FROM ${sqlIdentifier(reference.tableName)} WHERE ${sqlIdentifier(reference.columnName)}=:assetId LIMIT 1`,
{ replacements: { assetId }, type: QueryTypes.SELECT, transaction }
);
if (rows.length) {
return true;
}
}
return false;
}
private async removeInventory(assetId: string, companyId: string, transaction: Transaction) {
for (const table of [
"asset_revision_characteristics",
"asset_characteristics",
"asset_specification_field_choices",
"asset_specification_fields",
"asset_specification_sections",
"asset_revisions",
]) {
await this.database.query(`DELETE FROM ${sqlIdentifier(table)} WHERE assetId=:assetId`, {
replacements: { assetId },
transaction,
});
}
await this.database.query("DELETE FROM assets WHERE companyId=:companyId AND id=:assetId", {
replacements: { assetId, companyId },
transaction,
});
}
remove(companyId: UniqueID, id: UniqueID) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return Result.fail(loaded.error);
}
const asset = loaded.data.assets.find((item) => item.id.equals(id));
if (!asset) {
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
}
if (await this.hasExternalReferences(id.toPrimitive(), transaction)) {
return Result.fail(new AssetCatalogError("ASSET_IN_USE"));
}
await this.removeInventory(id.toPrimitive(), companyId.toPrimitive(), transaction);
return Result.ok({ asset, actorNames: loaded.data.actorNames });
});
}
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>> {
return this.database.transaction(async (transaction) => {
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {

View File

@ -52,7 +52,6 @@ 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,
@ -159,12 +158,7 @@ async function writeSnapshot(
await writeAssetRow(
database,
definition.root,
{
...row,
name: values.name,
active: values.active,
computerAssetId: values.computerAssetId ?? null,
},
{ ...row, name: values.name, active: values.active },
transaction
);
await writeMembers(

View File

@ -33,7 +33,6 @@ 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 },
};
@ -43,7 +42,6 @@ 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 },
@ -65,13 +63,6 @@ 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

@ -90,10 +90,6 @@
"relatedNotice": "This item is saved separately and remains available if you later cancel the main form.",
"newAsset": "New asset",
"editAsset": "Edit asset",
"deleteAsset": "Delete asset",
"deleteAssetDescription": "The asset “{{name}}” and its history will be deleted. It cannot be deleted while it is linked to other records.",
"deletingAsset": "Deleting asset…",
"deleteAssetError": "The asset could not be deleted. Please try again.",
"viewAsset": "Details and history",
"template": "Template",
"site": "Site",
@ -127,7 +123,6 @@
"INVALID_MEMBERS": "Select distinct valid assets.",
"WORKSTATION_NOT_FOUND": "Workstation not found in this company.",
"ASSET_IN_WORKSTATION": "An asset belongs to another active workstation. Remove it there before assigning it.",
"ASSET_IN_USE": "The asset cannot be deleted because it is linked to other records.",
"INVALID_REQUEST": "Check the form data.",
"INVALID_CHARACTERISTICS": "A characteristic does not match its field or type.",
"REQUIRED_CHARACTERISTICS": "Complete the template's required characteristics.",
@ -148,7 +143,7 @@
"sharedWorkstationAssets": "Shared resources",
"noSharedWorkstationAssets": "No shared resources.",
"editWorkstation": "Edit workstation",
"workstationPerson": "Workstation custodian",
"workstationPerson": "Workstation assignee",
"workstationSuggestedName": "{{person}}'s workstation at {{site}}",
"workstationHistory": "Workstation history",
"workstationAssets": "Workstation assets",
@ -160,7 +155,7 @@
"workstationCount": "{{count}} assets",
"workstationCount_one": "{{count}} asset",
"workstationCount_other": "{{count}} assets",
"workstationDescription": "A workstation groups a computer and its peripherals. Its optional custodian is independent of the people with profiles on it.",
"workstationDescription": "A workstation groups assets a person uses regularly. Person and site are optional and it can be created without assets.",
"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,24 +337,5 @@
"box": "Other asset"
}
},
"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."
"multiline": "Long text"
}

View File

@ -90,10 +90,6 @@
"relatedNotice": "El elemento se guarda por separado y se conserva aunque después canceles el formulario principal.",
"newAsset": "Nuevo activo",
"editAsset": "Editar activo",
"deleteAsset": "Eliminar activo",
"deleteAssetDescription": "Se eliminarán el activo «{{name}}» y su historial. No se puede eliminar si está relacionado con otros registros.",
"deletingAsset": "Eliminando activo…",
"deleteAssetError": "No se ha podido eliminar el activo. Inténtalo de nuevo.",
"viewAsset": "Ficha e historial",
"template": "Plantilla",
"site": "Sede",
@ -127,7 +123,6 @@
"INVALID_MEMBERS": "Selecciona activos distintos y válidos.",
"WORKSTATION_NOT_FOUND": "El puesto no existe en esta compañía.",
"ASSET_IN_WORKSTATION": "Un activo ya pertenece a otro puesto activo. Retíralo de allí antes de asignarlo.",
"ASSET_IN_USE": "No se puede eliminar el activo porque está relacionado con otros registros.",
"INVALID_REQUEST": "Revisa los datos del formulario.",
"INVALID_CHARACTERISTICS": "Alguna característica no corresponde a su campo o tipo.",
"REQUIRED_CHARACTERISTICS": "Completa las características obligatorias de la plantilla.",
@ -148,7 +143,7 @@
"sharedWorkstationAssets": "Recursos compartidos",
"noSharedWorkstationAssets": "Sin recursos compartidos.",
"editWorkstation": "Editar puesto",
"workstationPerson": "Responsable del puesto",
"workstationPerson": "Persona del puesto",
"workstationSuggestedName": "Puesto de {{person}} en {{site}}",
"workstationHistory": "Historial del puesto",
"workstationAssets": "Activos del puesto",
@ -160,7 +155,7 @@
"workstationCount": "{{count}} activos",
"workstationCount_one": "{{count}} activo",
"workstationCount_other": "{{count}} 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.",
"workstationDescription": "Un puesto reúne los activos que una persona utiliza habitualmente. Puede quedar sin persona o sede y puede crearse sin activos.",
"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,24 +337,5 @@
"box": "Otro activo"
}
},
"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."
"multiline": "Texto largo"
}

View File

@ -1,4 +1,4 @@
import { useListQuery } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
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 = useListQuery({
const query = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => getAssetInventory(companyId),
});

View File

@ -30,18 +30,16 @@ export function AssetDetail(props: AssetDetailProps) {
return (
<div className="space-y-2">
{!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>}
{!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.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")}>
@ -53,32 +51,24 @@ export function AssetDetail(props: AssetDetailProps) {
t("noAssignment")
)}
</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>
)}
<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>
</dl>
</DetailSection>
<div>
{!asset.specification.sections?.length && (
<h3 className="py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("characteristics")}
</h3>
)}
<DetailSection title={t("characteristics")}>
<FieldSections
items={asset.specification.fields}
layout="rows"
renderItem={(field) => (
<DetailField
key={field.fieldId}
@ -96,7 +86,7 @@ export function AssetDetail(props: AssetDetailProps) {
ungroupedLabel={t("sections.ungrouped")}
values
/>
</div>
</DetailSection>
{asset.values.notes && (
<DetailSection title={t("detailSections.notes")}>
<p className="whitespace-pre-wrap break-words text-sm">{asset.values.notes}</p>
@ -128,7 +118,6 @@ 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

@ -1,63 +0,0 @@
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,7 +11,6 @@ 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,
@ -23,7 +22,6 @@ export function AssetSelectionPanel({
onClose,
onView,
onEdit,
onDelete,
}: {
companyId: string;
asset: AssetDTO | undefined;
@ -34,7 +32,6 @@ export function AssetSelectionPanel({
onClose: () => void;
onView: (id: string) => void;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}) {
const { t } = useTranslation("assets");
@ -47,25 +44,17 @@ 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="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 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>
<div className="flex flex-wrap gap-2">
<Button onClick={() => onView(asset.id)} size="sm" variant="outline">
@ -74,9 +63,6 @@ export function AssetSelectionPanel({
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} size="sm" variant="outline">
{t("edit")}
</Button>
<Button onClick={() => onDelete(asset.id)} size="sm" variant="destructive">
{t("deleteAsset")}
</Button>
</div>
<AssetDetail asset={asset} compact lookups={lookups}>
<DetailSection title={t("relations")}>

View File

@ -1,11 +1,10 @@
import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
import { inventoryKeys } from "../../../shared/queries";
import { removeAsset } from "../../shared/api";
import { getAssetInventory } from "../api";
import type { AssetInventoryPageProps } from "../types";
@ -18,12 +17,11 @@ export function useAssetInventoryPageController({
initialCreate = false,
}: AssetInventoryPageProps) {
const { t } = useTranslation("assets");
const client = useQueryClient();
const query = useListQuery({
const query = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => getAssetInventory(companyId),
});
const catalog = useListQuery({
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
@ -40,14 +38,6 @@ export function useAssetInventoryPageController({
});
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
const [deleteId, setDeleteId] = useState<string | undefined>();
const remove = useMutation({
mutationFn: (id: string) => removeAsset(companyId, id),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
setDeleteId(undefined);
},
});
const compact = useCompactInventory();
const [localViewId, setLocalViewId] = useState<string | undefined>();
const {
@ -63,7 +53,6 @@ export function useAssetInventoryPageController({
const selected = selectedId ? assets.find((asset) => asset.id === selectedId) : assets[0];
const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId);
const deleted = assets.find((asset) => asset.id === deleteId);
const filtered = assets.filter((asset) => {
const site = lookups.sites.find((site) => site.id === asset.values.siteId);
@ -84,10 +73,6 @@ export function useAssetInventoryPageController({
setSearch,
editor,
setEditor,
deleted,
deleteId,
setDeleteId,
remove,
setViewId,
setSelectedId,
detailOpen,

View File

@ -13,7 +13,7 @@ import { CatalogError } from "../../../catalog";
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
import { InventoryFeedback } from "../../../shared";
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
import { AssetEditorDialog } from "../../shared";
import { useAssetInventoryPageController } from "../controllers";
import type { AssetInventoryPageProps } from "../types";
@ -32,10 +32,6 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
setSearch,
editor,
setEditor,
deleted,
deleteId,
setDeleteId,
remove,
setViewId,
setSelectedId,
detailOpen,
@ -177,12 +173,6 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
{t("edit")}
</EntityAction>
<EntityAction
className="text-destructive data-highlighted:text-destructive"
onClick={() => setDeleteId(asset.id)}
>
{t("deleteAsset")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
@ -205,7 +195,6 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
companyId={companyId}
lookups={lookups}
onClose={() => setDetailOpen(false)}
onDelete={setDeleteId}
onEdit={setEditor}
onView={setViewId}
open={detailOpen}
@ -233,21 +222,6 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
onClose={() => setViewId(undefined)}
/>
)}
<DeleteAssetDialog
asset={deleted}
errorCode={remove.error instanceof Error ? remove.error.message : undefined}
onClose={() => setDeleteId(undefined)}
onConfirm={async () => {
if (!deleteId) {
return;
}
await remove.mutateAsync(deleteId);
setDetailOpen(false);
setSelectedId(undefined);
setViewId(undefined);
}}
open={Boolean(deleted)}
/>
</div>
);
}

View File

@ -1 +1 @@
export { removeAsset, saveAsset } from "./save-asset.api";
export { saveAsset } from "./save-asset.api";

View File

@ -7,7 +7,3 @@ export async function saveAsset(companyId: string, values: AssetInput, id?: stri
await inventoryRequest(`${companyId}${id ? `/${id}/update` : ""}`, { values })
);
}
export async function removeAsset(companyId: string, id: string) {
return AssetResponseSchema.parse(await inventoryRequest(`${companyId}/${id}/delete`, {}));
}

View File

@ -1,2 +1,3 @@
export type { InventoryLookups } from "./types";
export { AssetEditorDialog, AssetForm, DeleteAssetDialog } from "./ui";
export { AssetEditorDialog, AssetForm } from "./ui";

View File

@ -272,22 +272,20 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
sites: [{ id: siteId, name: "Fábrica" }],
}}
onClose={() => undefined}
onDelete={() => undefined}
onEdit={() => undefined}
onView={() => undefined}
open
/>
);
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.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.includes(group));
}
assert.ok(html.includes("<summary"));

View File

@ -1,40 +0,0 @@
import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog";
import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../../../../common/inventory";
export function DeleteAssetDialog({
asset,
open,
errorCode,
onClose,
onConfirm,
}: {
asset: AssetDTO | undefined;
open: boolean;
errorCode: string | undefined;
onClose: () => void;
onConfirm: () => Promise<void>;
}) {
const { t } = useTranslation("assets");
return (
<ConfirmDeleteDialog
cancelLabel={t("cancel")}
confirmLabel={t("deleteAsset")}
description={t("deleteAssetDescription", { name: asset?.values.name ?? "" })}
errorLabel={
errorCode === "ASSET_IN_USE" ? t("inventoryErrors.ASSET_IN_USE") : t("deleteAssetError")
}
onConfirm={onConfirm}
onOpenChange={(value) => {
if (!value) {
onClose();
}
}}
open={open}
pendingLabel={t("deletingAsset")}
title={t("deleteAsset")}
/>
);
}

View File

@ -1,3 +1,3 @@
export { AssetEditorDialog } from "./asset-editor-dialog";
export { AssetForm } from "./asset-form";
export { DeleteAssetDialog } from "./delete-asset-dialog";

View File

@ -1,11 +1,11 @@
import { useListQuery } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { BrandDTO } from "../../../../common/brands";
import { brandApi, brandKeys } from "../../shared/api";
export function useBrandCatalogPageController() {
const query = useListQuery({ queryKey: brandKeys.all, queryFn: brandApi.list });
const query = useQuery({ 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, useListQuery } from "@metis/core/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useBrowserSearchParams } from "@metis/core/client";
import { useMutation, useQuery, 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 = useListQuery({
const query = useQuery({
queryKey: catalogKeys.scope(scope),
queryFn: () => catalogApi.get(scope),
});

View File

@ -1,9 +1,17 @@
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 { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations";
export { WorkstationForm, WorkstationsPage } from "./workstations";

View File

@ -1,4 +1,4 @@
import { useListQuery } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
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 = useListQuery({
const query = useQuery({
queryKey: supportKeys.asset(companyId, assetId),
queryFn: () => getSupportConnections(companyId, assetId),
});

View File

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

View File

@ -1,4 +1,4 @@
import { useListQuery } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
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 = useListQuery({
const query = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => getAssetInventory(companyId),
});

View File

@ -1,4 +1,3 @@
import { BrowserLink } from "@metis/core/client";
import {
EntityAction,
EntityListHeader,
@ -69,12 +68,13 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
value: (item) => item.values.name,
hideable: false,
render: (item) => (
<BrowserLink
className="font-medium"
href={`/companies/${companyId}/workstations/${item.id}`}
<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"
>
{item.values.name}
</BrowserLink>
</button>
),
},
{
@ -85,7 +85,7 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
},
{
id: "person",
label: t("workstationPerson"),
label: t("person"),
value: (item) => personName(item.values.personId),
render: (item) => <>{personName(item.values.personId)}</>,
},

View File

@ -86,21 +86,6 @@ 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}`}>
@ -154,15 +139,12 @@ 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/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"
"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"
},
"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": "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.",
"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.",
"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,10 +738,7 @@
"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": "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.",
"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.",
"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,10 +738,7 @@
"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,4 +1,5 @@
import { useBrowserSearchParams, useListQuery } from "@metis/core/client";
import { useBrowserSearchParams } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { AccessKind } from "../../../../common";
@ -9,7 +10,7 @@ import { getAccess } from "../api";
import type { AccessWorkspacePageProps } from "../types";
export function useAccessWorkspacePageController({ scope, people = [] }: AccessWorkspacePageProps) {
const query = useListQuery({
const query = useQuery({
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 = useListQuery({
const query = useQuery({
queryKey: applicationKeys.company(company.id),
queryFn: () => applicationApi.get(company.id),
});
const profiles = useListQuery({
const profiles = useQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const inventory = useListQuery({
const inventory = useQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const resources = useListQuery({
const resources = useQuery({
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 = useListQuery({
const query = useQuery({
queryKey: certificateKeys.company(company.id),
queryFn: () => certificateApi.get(company.id),
});
const profiles = useListQuery({
const profiles = useQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const inventory = useListQuery({
const inventory = useQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const resources = useListQuery({
const resources = useQuery({
queryKey: resourceKeys.company(company.id),
queryFn: () => resourceApi.get(company.id),
});

View File

@ -2,7 +2,6 @@ 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";
@ -21,30 +20,20 @@ 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 (
<>
{workstations && initialId ? (
<WorkstationDetailPage
company={company}
key={initialId}
lookups={lookups}
workstationId={initialId}
/>
) : (
<Page
{...(workstations ? {} : { initialId, initialCreate })}
companyId={company.id}
lookups={lookups}
/>
)}
<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 }),
}}
/>
{quick && (
<InventoryQuickCreateDialog
companyId={company.id}

View File

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

View File

@ -1,4 +1,5 @@
import { useBrowserSearchParams, useListQuery } from "@metis/core/client";
import { useBrowserSearchParams } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
import { useState } from "react";
@ -20,7 +21,7 @@ export function useCompaniesPageController({
initialCompanyId,
initialCreate = false,
}: CompaniesPageProps) {
const query = useListQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
const query = useQuery({ 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 { useListQuery } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
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 = useListQuery({
const query = useQuery({
queryKey: folderKeys.company(company.id),
queryFn: () => folderApi.get(company.id),
});
const access = useListQuery({
const access = useQuery({
queryKey: accessKeys.scope(company.id),
queryFn: () => getAccess(company.id),
});
const profiles = useListQuery({
const profiles = useQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});

View File

@ -1,4 +1,4 @@
import { useListQuery } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
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 = useListQuery({
const query = useQuery({
queryKey: interventionTemplateKeys.list(scope),
queryFn: () => interventionTemplateApi.list(scope),
});

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { useListQuery } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
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 = useListQuery({
const query = useQuery({
queryKey: mailKeys.company(company.id),
queryFn: () => mailApi.get(company.id),
});
const inventory = useListQuery({
const inventory = useQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const profiles = useListQuery({
const profiles = useQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const mailboxes = useListQuery({
const mailboxes = useQuery({
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/${workstation.id}`}
href={`/companies/${company.id}/workstations`}
>
{workstation.values.name}
</BrowserLink>

View File

@ -1,4 +1,5 @@
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

@ -1,42 +0,0 @@
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,15 +1,5 @@
import { Avatar, AvatarFallback, AvatarImage } from "@repo/shadcn-ui/components/avatar";
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;
};
import type { ReactNode } from "react";
export function PersonAvatar({
companyId,
@ -18,31 +8,70 @@ export function PersonAvatar({
name,
className,
children,
...props
}: PersonAvatarProps) {
}: {
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();
const src = photoVersion
? `/api/companies/${companyId}/people/${personId}/photo?version=${photoVersion}`
: undefined;
return (
<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>
<Avatar className={className}>
<AvatarImage alt="" src={src} />
<AvatarFallback>{initials}</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 { useListQuery } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
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 = useListQuery({
const query = useQuery({
queryKey: printerKeys.company(company.id),
queryFn: () => printerApi.get(company.id),
});
const profiles = useListQuery({
const profiles = useQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const inventory = useListQuery({
const inventory = useQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});

View File

@ -1,11 +1,11 @@
import { useListQuery } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { processKeys } from "../../../processes/shared/queries";
import { listProcessTemplates } from "../api";
export function useProcessTemplatesPageController() {
const query = useListQuery({ queryKey: processKeys.catalog(), queryFn: listProcessTemplates });
const query = useQuery({ 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, useListQuery } from "@metis/core/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useMutation, useQuery, 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 = useListQuery({
const query = useQuery({
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 = useListQuery({
const query = useQuery({
queryKey: remoteKeys.company(company.id),
queryFn: () => remoteApi.get(company.id),
});
const assets = useListQuery({
const assets = useQuery({
queryKey: assetKeys.company(company.id),
queryFn: () => assetApi.get(company.id),
});
const resources = useListQuery({
const resources = useQuery({
queryKey: resourceKeys.company(company.id),
queryFn: () => resourceApi.get(company.id),
});

View File

@ -1,4 +1,4 @@
import { useListQuery } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
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 = useListQuery({
const query = useQuery({
queryKey: teleworkKeys.company(company.id),
queryFn: () => teleworkApi.get(company.id),
});

View File

@ -1,9 +1,5 @@
import {
navigateTo,
useBrowserSearchParams,
useEntityNavigation,
useListQuery,
} from "@metis/core/client";
import { navigateTo, useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -27,7 +23,7 @@ export function useTicketsPageController({
initialPendingOnly = false,
}: TicketsPageProps) {
const { i18n } = useTranslation("companies");
const query = useListQuery({
const query = useQuery({
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 = useListQuery({
const query = useQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const inventory = useListQuery({
const inventory = useQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const access = useListQuery({
const access = useQuery({
queryKey: accessKeys.scope(company.id),
queryFn: () => getAccess(company.id),
});

View File

@ -13,12 +13,7 @@ import type { ConfigurationQuickKind } from "../../../shared/types";
import { profileApi, profileKeys } from "../api";
import type { WorkProfileFormProps } from "../types";
export function useWorkProfileFormController({
company,
profile,
workstationId: initialWorkstationId,
onSaved,
}: WorkProfileFormProps) {
export function useWorkProfileFormController({ company, profile, onSaved }: WorkProfileFormProps) {
const client = useQueryClient();
const mounted = useRef(true);
@ -29,7 +24,6 @@ export function useWorkProfileFormController({
mounted.current = false;
};
}, []);
const [selectedWorkstationId, setSelectedWorkstationId] = useState(initialWorkstationId ?? "");
const [quick, setQuick] = useState<ConfigurationQuickKind>();
const access = useQuery({
queryKey: accessKeys.scope(company.id),
@ -39,29 +33,8 @@ export function useWorkProfileFormController({
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: (input, context, options) =>
zodResolver(WorkProfileInputSchema)(
{ ...input, assetId: profile?.values.assetId ?? computer?.id ?? "" },
context,
options
),
resolver: zodResolver(WorkProfileInputSchema),
defaultValues: profile?.values ?? {
name: "",
personId: "",
@ -108,6 +81,15 @@ export function useWorkProfileFormController({
.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",
@ -123,11 +105,7 @@ export function useWorkProfileFormController({
.map((item) => ({ value: item.id, label: accessLabel(item, items, company.people) })),
},
] as const;
const submit = form.handleSubmit((input) => {
if (targetReady) {
mutation.mutate(input);
}
});
const submit = form.handleSubmit((input) => mutation.mutate(input));
const quickCreated = (field: "personId" | "accountId" | "assetId", id: string | undefined) => {
if (mounted.current && id) {
@ -137,11 +115,6 @@ export function useWorkProfileFormController({
};
return {
workstations,
selectedWorkstationId,
chooseWorkstation,
computer,
targetReady,
quick,
setQuick,
access,

View File

@ -1,3 +1,5 @@
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, seed?: (client: QueryClient) => void) {
async function render(language: string, node: ReactNode) {
const i18n = createInstance();
await i18n.init({
@ -44,7 +44,6 @@ async function render(language: string, node: ReactNode, seed?: (client: QueryCl
relationships: [],
workstations: [],
});
seed?.(client);
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
@ -68,70 +67,11 @@ 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"]) {
for (const label of ["Nueva persona", "Nueva cuenta", "Nuevo dispositivo"]) {
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("su responsable no se convierte automáticamente en usuario"));
assert.ok(html.includes("Un perfil vigente no significa"));
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,2 +1,3 @@
export type { ProfileSelection } from "./profile-selection";
export type { WorkProfileFormProps } from "./work-profile-form-props";

View File

@ -3,7 +3,6 @@ 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,3 +1,5 @@
export { ProfileFeedback } from "./profile-feedback";
export { WorkProfileEditorDialog } from "./work-profile-editor-dialog";
export { WorkProfileForm } from "./work-profile-form";

View File

@ -9,12 +9,10 @@ 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");
@ -32,12 +30,7 @@ export function WorkProfileEditorDialog({
title={t(profile ? "profiles.edit" : "profiles.new")}
wide
>
<WorkProfileForm
company={company}
onSaved={() => onClose()}
profile={profile}
workstationId={workstationId}
/>
<WorkProfileForm company={company} onSaved={() => onClose()} profile={profile} />
</FormDialog>
);
}

View File

@ -18,11 +18,6 @@ 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,
@ -48,31 +43,6 @@ 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";
@ -137,16 +107,7 @@ export function WorkProfileForm(props: WorkProfileFormProps) {
/>
</div>
</fieldset>
{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>
)}
{profile && <p className="text-sm text-muted-foreground">{t("profiles.fixed")}</p>}
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-destructive" role="alert">
{t("profiles.errors.INVALID_REQUEST")}
@ -163,10 +124,7 @@ export function WorkProfileForm(props: WorkProfileFormProps) {
/>
</FormBody>
<FormActions>
<Button
disabled={disabled || !inventory.data || !access.data || !targetReady}
type="submit"
>
<Button disabled={disabled || !inventory.data || !access.data} type="submit">
{t(disabled ? "saving" : "save")}
</Button>
</FormActions>

View File

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

View File

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

View File

@ -121,25 +121,3 @@ 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,8 +2,6 @@ import type { ReactNode } from "react";
export type CompaniesWorkspaceProps = {
accountNavigation: ReactNode;
administrationContent?: ReactNode;
canManageUsers?: boolean;
userName: string;
languageSwitcher: ReactNode;
};

View File

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

View File

@ -1,4 +1,3 @@
import { MetisLogo } from "@metis/core/client";
import {
BoxIcon,
Building2Icon,
@ -10,7 +9,6 @@ import {
LayoutTemplateIcon,
SettingsIcon,
TicketIcon,
UsersIcon,
WrenchIcon,
} from "@repo/rdx-ui/lib/icons";
import { useState } from "react";
@ -27,18 +25,13 @@ 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");
@ -62,7 +55,20 @@ 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"
>
<MetisLogo className="h-[30px] w-8 shrink-0 text-shell-accent" />
<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>
<span className="shell:group-data-[collapsed=true]/sidebar:hidden">Metis Desk</span>
</div>
<nav
@ -80,7 +86,7 @@ export function WorkspaceSidebar({
).map(([area, label, Icon]) => (
<WorkspaceNavigationButton
aria-label={t(label)}
aria-pressed={!usersActive && current.area === area}
aria-pressed={current.area === area}
href={workspaceHref({ area })}
key={area}
title={t(label)}
@ -95,17 +101,6 @@ 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

@ -1,39 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1,25 +0,0 @@
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

@ -1,86 +0,0 @@
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

@ -1,243 +0,0 @@
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

@ -1,113 +0,0 @@
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

@ -1,16 +0,0 @@
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,9 +1,7 @@
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

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

View File

@ -1,29 +0,0 @@
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": "Manage accounts that can access Metis Desk. Company people are managed separately.",
"usersHelp": "Create accounts, update their details and control access. There are no configurable roles.",
"createUser": "New user",
"editUser": "Edit user",
"save": "Save",
@ -48,6 +48,5 @@
"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": "Gestiona las cuentas de acceso a Metis Desk. Las personas de las compañías se administran por separado.",
"usersHelp": "Crea cuentas, actualiza sus datos y controla el acceso. No hay roles configurables.",
"createUser": "Nuevo usuario",
"editUser": "Editar usuario",
"save": "Guardar",
@ -48,6 +48,5 @@
"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="mt-2 h-11 w-full" disabled={mutation.isPending} type="submit">
<Button className="w-full" disabled={mutation.isPending} type="submit">
{mutation.isPending ? t("saving") : t(setup ? "createAdmin" : "login")}
</Button>
</form>

View File

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

Some files were not shown because too many files have changed in this diff Show More