diff --git a/docs/architecture/auditoria-sistemas-operativos-activos-y-puestos.md b/docs/architecture/auditoria-sistemas-operativos-activos-y-puestos.md new file mode 100644 index 00000000..21abac1d --- /dev/null +++ b/docs/architecture/auditoria-sistemas-operativos-activos-y-puestos.md @@ -0,0 +1,333 @@ +# Auditoría arquitectónica — Sistemas operativos de activos y puestos de trabajo + +Fecha: 25 de octubre de 2026. Estado: investigación y recomendación preliminar; **no es una decisión aprobada ni una implementación**. + +## 1. Resumen ejecutivo + +Metis Desk ya diferencia dos conceptos: el SO de fábrica se documenta mediante características textuales de cada activo; el SO operativo se registra como un recurso de uso `os` relacionado con el activo mediante el agregado `SoftwareInstallation`. Este último tiene UUID, estado, versión textual, licencia opcional e historial con actor y fecha. El puesto no posee ese dato: su ficha consulta las instalaciones de su ordenador principal. No hace falta crear una segunda entidad de instalación ni trasladar el SO al puesto [E01–E10, E17–E20]. + +La implementación puede mantener FreeDOS como información de compra y Windows 11 Pro como instalación, registrar SO distintos sobre un mismo activo y conservar sus revisiones. Sin embargo, no representa explícitamente «SO desconocido / ausencia confirmada / instalado», procedencia del dato, fecha efectiva de instalación, última verificación, arquitectura o instancia de arranque. Las reinstalaciones del mismo producto requieren cerrar la instalación anterior y crear otra, o quedan indistinguibles si se repiten exactamente los valores. La licencia es una referencia documental, sin cómputo de derechos o consumo [E04–E11]. + +El catálogo global implementado admite **aplicaciones**, no SO globales. Una plantilla global `template:os` no convierte sus recursos en productos globales: los recursos SO son particulares de compañía. Los flags de asociación global deben distinguirse de las capacidades del código y de su activación efectiva [E12–E16]. + +Recomendación preliminar: **alternativa D acotada, apoyada en B ya implementada**. Conservar origen en Assets, producto en Resources, instalación en Companies y consulta derivada en el puesto; decidir qué carencias justifican extensiones antes de diseñarlas. No se justifica hoy un catálogo paralelo, una relación SO–puesto ni un framework de plugins. Confianza alta en este diagnóstico de código, media en el alcance futuro y sin verificación del esquema/datos de una instalación desplegada. + +## 2. Alcance y metodología + +Se inspeccionaron dominio, Application, composición, rutas, schemas, persistencia, migraciones, consumidores React, pruebas y documentación normativa de Assets, Resources y Companies; también la composición del servidor y las decisiones R2.0–R2.6/D08.2 aplicables. Búsquedas con `rg` incluyeron SO, sistemas operativos, Windows, Linux, macOS, FreeDOS, firmware, Icecat, instalaciones y atributos dinámicos. Una ausencia significa «no localizado en el código activo examinado», no una afirmación sobre datos particulares existentes o sistemas externos. + +Base: HEAD `0ceeb9658cc85c913a05bebf96a189424a754cea`, con cambios locales previos. Las evidencias describen **el árbol de trabajo inspeccionado**, no solo HEAD. En particular, el hook compartido de software y el mapa visual del puesto estaban modificados o sin seguimiento al comenzar. No se atribuyen a esta auditoría ni se presume que estén desplegados. + +Se comprobaron WSL/bash, directorio, Git, Node `v24.20.0` y PNPM `11.25.0`, concordantes con `package.json`. Se consultó `rdx-solid` y sus referencias de arquitectura y decisiones: responsabilidades según consumidores existentes, comparación explícita y separación de propuesta/evidencia. Se leyeron DESIGN y la matriz de skills frontend como contexto de presentación; no se realizó diseño ni auditoría UX y no se aplicaron criterios de skills visuales. No faltó ninguna skill necesaria para este alcance. + +**Grados de evidencia:** verificado estáticamente = contrato y camino presentes en código; probado en esta auditoría = pruebas indicadas en §16; inferencia = combinación posible de contratos; propuesto = no implementado. Las matrices funcionales son evaluación arquitectónica, no un recorrido E2E de cada caso. + +No se accedió a `.env`, secretos, datos reales ni bases existentes. No se arrancaron servicios, ejecutaron migraciones, prepararon fixtures SQL ni realizaron escrituras funcionales. `archive/` y vendor permanecieron intactos. No se ejecutó Playwright: se describe presentación por lectura de componentes y pruebas de render, sin afirmar funcionamiento visual, teclado, foco o responsive en navegador. + +## 3. Estado actual verificado e inventario + +### 3.1. Activos, tipos y datos de origen + +`AssetCatalog` contiene definiciones de campos, plantillas y relaciones. La tipología se expresa por `templateId` y especificación capturada en cada `Asset`; no hay una unión cerrada de clases físicas ni una entidad `Computer` separada. Las plantillas de serie incluyen portátil, PC de sobremesa, monitor, impresora, router, firewall, switch, punto de acceso, NAS, servidor, smartphone y SAI. Las propias pueden ampliarlas [E01, E02]. + +Portátil y sobremesa tienen `field:factory_os`; servidor contiene **tanto** `field:factory_preloaded_os` **como** `field:factory_os`, con etiquetas próximas. Esto es una duplicidad semántica real de las definiciones de serie, aunque no demuestra que haya valores contradictorios en una BD. No hay plantillas de serie específicas de VM, mini-PC o estación de trabajo de altas prestaciones; pueden documentarse con plantillas propias, sin semántica especializada verificada. NAS, móviles y dispositivos de infraestructura también pueden ejecutar SO/firmware: la política de instalación no restringe por plantilla de activo, sino por pertenencia y condición no retirado [E01, E06]. + +Marca tiene referencia opcional `brandId`; modelo, serie, MPN/código de producto, procesador, memoria, almacenamiento, compra, proveedor y garantía se resuelven con características de plantilla. No se localizó un catálogo de productos físicos o SKU que determine automáticamente la configuración de cada unidad. La plantilla define campos, **no el SO de todos los equipos de un modelo**. Las descripciones de seed son ejemplos/ayuda, no un inventario de modelos ni valores precargados obligatorios [E01–E03]. + +El formulario de activo y su controlador gestionan características por UUID de campo; las fichas e historial muestran valores y especificación. `AssetUseCases`, `mapAssetInput` y `SequelizeAssetRepository` coordinan validación, cambios e historial. Endpoints: `GET/POST /api/assets/:companyId`, `POST /api/assets/:companyId/:id/update` y `/delete`; catálogo en `/api/asset-catalog/:scope` y puestos bajo `/api/assets/:companyId/workstations…` [E21, E27]. + +### 3.2. Registro de los elementos relacionados con SO + +| Elemento real | Persistencia y naturaleza | Propietario / cardinalidad | API y frontend | Validación / historial | +| --- | --- | --- | --- | --- | +| `field:factory_os` | Definición en `asset_definitions`; valor de unidad en `asset_characteristics.textValue`, identificado por `assetId,fieldId` | Assets / un valor por campo y activo; no relación a software | API de activos; `AssetFields`, `AssetCharacteristicControl`, `AssetDetailContent`, `AssetHistory` | Campo textual opcional de seed; validación por especificación; revisiones del activo [E01–E03, E21] | +| `field:factory_preloaded_os` | Mismo mecanismo; UUID de campo diferente | Assets / otro valor independiente en servidor | Mismos consumidores | No hay regla de igualdad con `factory_os`; historial genérico [E01–E03] | +| Recurso de plantilla `template:os` o `usage: os` | `resources`, `resource_characteristics`, definiciones/especificación relacionales | Resources / producto particular; un recurso puede ser referenciado por N instalaciones | API de recursos y candidatos/resolución; editor de recursos y alta rápida de SO | UUID, pertenencia, actividad y clasificación por plantilla; `ResourceRevision` [E12–E15, E28] | +| `SoftwareInstallation` | `software_installations`: `id,companyId,assetId,resourceId,licenseResourceId,version,state,notes,currentKey` | Companies / N instalaciones por activo y por recurso; pareja activo/recurso fija; máximo una abierta por pareja | `GET/POST /api/software-installations/:companyId`, `POST /:companyId/:id/update`, `/delete`; `SoftwareInstallationsPanel`, formulario e historial | Zod estricto, políticas de referencias/disponibilidad, transición, UUID, transacción y clave única [E04–E10] | +| `SoftwareInstallationRevision` | `software_installation_revisions`: `id,configurationId,position,actorId,recordedAt` más snapshot de campos de instalación | Companies / 1:N desde instalación | Se devuelve dentro de `history`, sin endpoint de revisión independiente; `SoftwareHistoryDialog` | UUID propio; orden único; mapper valida cronología, destino y última revisión [E07–E10] | +| `licenseResourceId` | UUID nullable con FK a `resources`, en raíz y revisiones | Instalación / 0..1 licencia actual; una licencia puede ser referenciada por N instalaciones | Selector y enlaces de instalación/historial | Uso `license`, misma compañía, activa/no caducada en nuevas dependencias; no calcula derechos [E06–E08, E13] | +| `computerAssetId` / `assetIds` | `workstations`, `workstation_assets`, revisiones y miembros históricos | Assets / 0..1 principal, hasta 200 miembros; máximo un puesto activo por activo | API de puestos e inventario; ficha del puesto | Principal debe ser miembro, pertenencia y exclusividad; historial de composición [E17, E18] | +| `ApplicationConfiguration` | `application_configurations` y revisiones; `profileId,resourceId,delivery,version,state,notes` | Companies / aplicación por perfil, no instalación del activo | API de configuraciones y bloque de aplicaciones del perfil | `delivery: installed/web`; recurso `application`, no `os`; historial y destino fijo [E19] | + +Las tablas/columnas anteriores son las declaradas por las migraciones y writers inspeccionados. Su existencia efectiva en una BD desplegada no se comprobó. Los atributos viajan como objetos en HTTP; en el esquema relacional actual se guardan en columnas tipadas, no en JSON [E02, E03, E07, E28]. + +## 4. Mapa de entidades y relaciones — modelo verificado + +```mermaid +erDiagram + COMPANY ||--o{ ASSET : posee + ASSET_TEMPLATE ||--o{ ASSET : especifica + ASSET ||--o{ ASSET_CHARACTERISTIC : contiene + ASSET ||--|{ ASSET_REVISION : registra + COMPANY ||--o{ WORKSTATION : organiza + WORKSTATION ||--o{ WORKSTATION_MEMBER : agrupa + ASSET ||--o{ WORKSTATION_MEMBER : referencia + ASSET o|--o{ WORKSTATION : principal + ASSET ||--o{ SOFTWARE_INSTALLATION : destino + RESOURCE ||--o{ SOFTWARE_INSTALLATION : producto + RESOURCE o|--o{ SOFTWARE_INSTALLATION : licencia_opcional + SOFTWARE_INSTALLATION ||--|{ SOFTWARE_INSTALLATION_REVISION : registra + PERSON ||--o{ WORK_PROFILE : utiliza + ASSET ||--o{ WORK_PROFILE : aloja + WORK_PROFILE ||--o{ APPLICATION_CONFIGURATION : configura + RESOURCE ||--o{ APPLICATION_CONFIGURATION : aplicacion +``` + +`RESOURCE` tiene papeles diferentes de producto y licencia; el diagrama no permite intercambiarlos libremente. Omite especificaciones/revisiones secundarias para legibilidad. Un SO particular pertenece a una compañía; una aplicación global tiene `scope: global, companyId: null`, mientras sus instalaciones/configuraciones conservan compañía concreta. No hay FK desde instalación a puesto ni a perfil. La exclusividad de miembros activos, el tipo de recurso y el ámbito no se deducen solo de las líneas del diagrama: se aplican por políticas y restricciones [E06–E08, E12–E19]. + +## 5. Flujo actual de los datos + +1. **Origen:** catálogo de activos → plantilla/especificación → formulario de unidad → características y revisión de `Asset`. `factory_os` no se sincroniza con instalaciones. +2. **Producto:** catálogo de definiciones de Resources → recurso SO particular con nombre/proveedor/edición/descripción. No existe entidad de versión de software: la versión operativa va en la instalación. +3. **Instalación:** formulario → `softwareApi` con contrato de recursos `2` → ruta autenticada y schemas estrictos → `SoftwareInstallationUseCases.save` → mapper/policies y agregado → repositorio transaccional. El actor procede de la sesión. +4. **Persistencia:** sesión pública del resolver Resources ligada a la transacción → coordinación de catálogo/compañía → inventarios públicos de Assets y Resources → validación por propósito/disponibilidad → raíz y revisiones en la misma transacción. `currentKey` único protege la pareja abierta; una actualización semánticamente igual no añade revisión. +5. **Consulta:** `GET` devuelve todas las instalaciones de compañía y su historial; la ficha filtra por `assetId === computerAssetId`. Los nombres/tipos se obtienen del inventario local o resolver de referencias; el producto global no exige cargar un ResourceDTO particular. La UI invalida las query keys afectadas tras guardar [E04–E10, E14–E16, E20, E22]. + +Autenticación y pertenencia operativa son garantías diferentes. El espacio de soporte usa sesiones válidas y contexto de compañía compartido; no hay que presentar el rechazo de un recurso de B dentro de A como una ACL operador–compañía. Las FKs simples de instalación protegen existencia, no por sí solas la igualdad de compañía ni elegibilidad; estas se comprueban en aplicación/lectores transaccionales. Origen/CSRF están en el middleware común de escritura y sus negativos figuran en integración; no se ejecutaron aquí [E08, E10, E14, E23]. + +## 6. SO de fábrica frente a SO instalado + +### 6.1. Información original + +1. **¿Campo original?** Sí: `factory_os` y el segundo campo de servidor `factory_preloaded_os`. +2. **¿Dónde?** En características de cada unidad, según plantilla. No en una entidad producto físico ni en `SoftwareInstallation`. +3. **¿Procedencia fabricante/manual?** No hay procedencia por característica, evidencia de fabricante ni estado de confirmación. Actor/fecha de revisión acreditan registro en Metis, no quién certificó la especificación. +4. **¿Sustitución?** Guardar una instalación nueva no modifica características del activo. El operador debe cerrar/actualizar instalaciones según corresponda. +5. **¿Preservación?** Las revisiones conservan valores/especificación. El campo original sigue siendo editable: no es un registro inmutable de recepción. Su independencia de la instalación evita sobrescritura automática, no errores manuales. +6. **¿Sin SO de fábrica?** Puede escribirse «Sin SO precargado», que ya aparece como ejemplo de servidor. Eso es texto, sin estado normalizado de ausencia. Campo omitido y ausencia confirmada no se distinguen estructuralmente [E01–E03, E21]. + +No se localizaron integración Icecat, importador funcional de activos o sincronización automática de especificaciones comerciales en el área inspeccionada. No se atribuye a una plantilla la configuración de todas las variantes Lenovo ThinkPad T14. Modelo/MPN y SO de la unidad deben contrastarse individualmente. + +### 6.2. Configuración operativa + +| Dato solicitado | Capacidad verificada | Límite | +| --- | --- | --- | +| Producto SO | `resourceId` a recurso `os` | Particular; no SO global elegible | +| Edición | Característica `software_edition` del recurso SO | Texto de producto, sin entidad de edición ni snapshot de su revisión en instalación | +| Versión/build | `version`, nullable, trim y máximo 100 caracteres | Un único texto: no distingue versión/build ni valida semántica del fabricante | +| Arquitectura | Sin campo específico de instalación | Nombre/notas no equivalen a arquitectura estructurada | +| Fecha de instalación | Sin fecha efectiva | `recordedAt` fecha el registro/revisión, no demuestra cuándo se instaló | +| Estado | `pending/configured/removal_pending/removed/never_configured` | Ciclo documental; no estado global de conocimiento de SO del equipo | +| Origen / última verificación | Sin campos específicos | Actor y `needsReview` no sustituyen procedencia ni inspección técnica | +| SO anterior | Revisiones o instalaciones cerradas | Cambiar de producto requiere cerrar una raíz y crear otra; no vínculo de sucesión | +| Reinstalación | Nuevo registro tras cerrar pareja | No operación ni evento específico de reinstalación | +| Varios SO | Varias parejas activo/recurso abiertas | No varias instancias del mismo recurso; sin partición, disco, arranque preferente o virtualización | + +`configured` documenta una configuración declarada; no acredita descubrimiento automático. `pending` no debe reinterpretarse como «desconocido». `never_configured` cierra una intención concreta, no confirma que el equipo entero carezca de SO. Cero instalaciones tampoco permite inferir ausencia: puede faltar inventariado [E04–E09]. + +## 7. Software y licencias + +Resources representa productos mediante recursos UUID y plantillas. El SO dispone de nombre de recurso y campos de proveedor, edición y descripción. Las aplicaciones de serie tienen proveedor, URL y descripción. No se encontraron entidades normalizadas de familia, fabricante de software, edición o versión; números y ediciones también pueden formar parte del nombre. La identidad UUID no demuestra que dos productos descritos de forma parecida sean equivalentes [E12–E15]. + +El catálogo de aplicaciones globales comparte producto entre compañías, conservando instalaciones/configuraciones particulares. El SO y la licencia globales quedan excluidos por elegibilidad real de `template:application`. R2.5 ya incorpora aplicaciones globales a perfiles: las limitaciones de perfil descritas como futuras en R2.4 son históricas y no el estado vigente. Los gates de catálogo, candidatos, software y perfil son independientes; no se leyeron variables locales ni se dedujo su activación [E12–E16, E19, E24]. + +La licencia es otro Resource particular, con producto licenciado, edición/plan, proveedor, modalidad, métrica, cantidad, contrato, caducidad, renovación y autorrenovación. Las opciones de serie de modalidad son Suscripción/Perpetua/OEM/Otra; **volumen no tiene opción propia**, aunque puede documentarse como Otra/notas o mediante configuración del catálogo. La métrica admite usuario/dispositivo/servidor/concurrente/corporativa/otro. Esto es documentación contractual; no un motor de cumplimiento [E13]. + +Una instalación admite una licencia actual nullable y revisiones con la licencia anterior. Se valida tipo, pertenencia, actividad y vigencia inclusiva UTC al crear/cambiar dependencias. Se permiten notas, desvinculación y retirada cuando referencias se vuelven no disponibles. `needsReview` indica falta de disponibilidad o retirada pendiente; no un inventario verificado. No hay reserva/consumo de plazas, validación producto–licencia, asignación efectiva por usuario/cuenta ni prueba de cobertura OEM. La misma licencia puede ser referenciada varias veces sin comprobar capacidad [E06–E08]. + +El historial guarda UUID del producto/licencia y versión/estado/notas de instalación; no enlaza una revisión inmutable del producto o del contrato. Cambiar el nombre/edición del recurso puede modificar cómo se interpreta una revisión de instalación al resolver su nombre actual. `ResourceRevision` aporta historia separada, sin reconstrucción temporal automática verificada. El borrado de instalación elimina también sus revisiones; no es auditoría inmutable ni archivo de desinstalación [E08, E09, E22]. + +## 8. Puestos y perfiles + +**Sí, Metis puede mostrar el SO del principal sin duplicarlo en el puesto; ya utiliza ese recorrido.** Assets es dueño de `Workstation`, su composición y `computerAssetId`; Companies es dueño de `WorkProfile`, instalaciones y configuraciones. La relación principal exige pertenencia al conjunto, pero el mapper de puesto no verifica una categoría cerrada de «ordenador»: es una designación funcional del miembro [E17, E18]. + +El puesto puede agrupar varios activos; solo tiene un principal. Los demás se presentan como equipamiento, sin un rol estructurado de «ordenador adicional». Además, la guía de puestos establece que dos ordenadores se organizan en dos puestos: aunque el contrato genérico de miembros no impida elegir otro equipo informático como miembro, eso no acredita soporte funcional de varios ordenadores en un puesto. El software mostrado y el mapa local del puesto filtran exclusivamente por principal. El modelo de instalaciones acepta SO de los otros activos por API, pero la ficha no agrega sus SO. Tampoco existe un puesto propietario de una configuración SO independiente [E06, E20]. + +Los perfiles pertenecen a persona/dispositivo y pueden tener cuenta opcional. Alta/reactivación requieren dispositivo no retirado que sea principal de un puesto activo. La pareja persona/dispositivo es fija. Sustituir principal no mueve perfiles o instalaciones; el anterior conserva registros, y el puesto consulta los del nuevo. Desvincular un activo tampoco borra su software. La baja de puesto mediante `decommission` vacía responsable, principal y miembros actuales y conserva revisión previa: no confundirlo con un cambio de flag que conserva composición [E17–E20]. + +Las `ApplicationConfiguration` de perfil distinguen uso web/instalado y su ciclo. No son un catálogo de aplicaciones autorizadas, una política de compatibilidad de SO ni prueba de instalación en todo el dispositivo. No se generan a partir de `SoftwareInstallation` ni a la inversa. Puede haber dos registros sobre el mismo producto con ámbitos diferentes; no hay reconciliación automática, aunque eso no constituye por sí mismo una duplicidad de dominio [E19]. + +Dependencias para mostrar SO: inventario del activo, principal válido, consulta de instalaciones de compañía, catálogo/recursos para nombres y clasificación, y resolución pública para aplicaciones globales. Un fallo o referencia sin resolver no autoriza inferir «sin SO». La lectura actual carga instalaciones e historial de toda la compañía y filtra en cliente; el coste puede crecer con el volumen. No se midió rendimiento [E08, E20, E22]. + +## 9. Matriz de escenarios funcionales + +«SOPORTADO» significa que el núcleo del caso dispone de contrato explícito; no certifica E2E ni todas las extensiones deseables. «PARCIALMENTE SOPORTADO» distingue un registro útil de la falta de semántica específica. No se asigna NO VERIFICADO a capacidades inspeccionadas solo por no haberlas probado en navegador; las limitaciones de ejecución aplican a toda la matriz. + +Convenciones: **O** = características de origen; **I** = instalación/revisiones; **Q** = GET de instalaciones de compañía y filtro por activo; **V** = panel de software del principal; **H** = historial de instalación, borrable con su raíz. Las evidencias remiten al índice de §15. + +| ID y escenario | Clasificación | Almacenamiento / consulta / visualización | Historial, integridad y límites | Evidencia | +| --- | --- | --- | --- | --- | +| SO-01 FreeDOS → Windows 11 Pro | SOPORTADO | O=FreeDOS; Resource SO=Windows 11 Pro; I=`configured`; Q/V | H de Windows y revisiones de O independientes; no hay obligación de sustituir O ni relación de equivalencia | E01–E10, E20 | +| SO-02 Windows 11 Pro original conservado | SOPORTADO | O=Windows; I debe registrarse explícitamente con Resource Windows; Q/V | No deduce instalación desde fábrica; FKs/policies e H. Coincidencia de textos no vincula ambos conceptos | E01–E10 | +| SO-03 Sin SO de fábrica → Ubuntu | PARCIALMENTE SOPORTADO | O admite texto de ausencia; Ubuntu en I; Q/V | Instalación e H explícitos; ausencia original solo textual, sin estado normalizado | E01, E04–E09 | +| SO-04 Windows 10 Pro → Windows 11 Pro | PARCIALMENTE SOPORTADO | Si mismo producto: cambio de `version`; si productos distintos: cerrar I de 10 y crear I de 11; Q/V y cerradas | H conserva cambios, pero no hay upgrade/sucesión ni atomicidad entre dos comandos; no cierra otro SO automáticamente | E04–E10, E22 | +| SO-05 Reinstalación limpia Windows 11 Pro | PARCIALMENTE SOPORTADO | Cerrar configurado→retirada pendiente→retirado, crear nueva I de la misma pareja; Q y cerradas | Nueva UUID e H; sin operación específica, fecha efectiva o enlace de reinstalación. Repetir valores es no-op | E04, E05, E08, E09 | +| SO-06 Arranque dual Windows/Linux | PARCIALMENTE SOPORTADO | Dos recursos SO y dos I en el activo; Q/V admite varias filas | La cardinalidad lo permite; no registra particiones/arranque ni sincroniza estados. Dos instancias del mismo Resource quedan bloqueadas | E04–E09, E22 | +| SO-07 Servidor físico Windows Server | PARCIALMENTE SOPORTADO | Plantilla servidor; Resource SO e I válidos por API; Q | H/FKs genéricos; no exige puesto. Sin panel específico de software en ficha de activo localizado; V solo si es principal | E01, E06, E08, E20, E21 | +| SO-08 Hipervisor y varias VM | PARCIALMENTE SOPORTADO | Hipervisor como software; VM como activos de plantilla propia; relación genérica `hosted`; Q por activo | Referencias e historial genéricos; no modelo de hipervisor/guest, topología validada ni vista de SO de todos los guests | E01, E06, E25 | +| SO-09 VM con SO propio | PARCIALMENTE SOPORTADO | Activo propio y Resource/I por API; Q; V si se designa principal | Sin plantilla VM de serie o identidad del inventario externo; no se impide por tipo de activo | E01, E06–E09, E17 | +| SO-10 Cambio de ordenador principal | SOPORTADO | Cambia `computerAssetId`; Q/V pasan al nuevo; I del anterior no se reasigna | Revisiones del puesto conservan composición; destinos de I/perfil fijos; no transfiere software, cuentas o licencias | E05, E17–E20 | +| SO-11 Activo deja de estar en un puesto | SOPORTADO | Se quita de miembros y se ajusta principal si corresponde; I sigue en activo; Q | Historia del puesto y H independientes; V deja de mostrarlo. Principal no puede quedar fuera de miembros | E06, E17, E18, E20 | +| SO-12 SO desconocido | NO SOPORTADO | Cero I o campo omitido no tienen significado inequívoco; V vacío | No estado explícito de conocimiento; no permite distinguir ausencia confirmada | E01, E04, E20 | +| SO-13 Equipo entregado sin SO | PARCIALMENTE SOPORTADO | O permite anotarlo; no crear Resource ficticio «sin SO»; V sin instalaciones | No estructura que confirme ausencia actual ni quién/cuándo la verificó | E01, E04–E09 | +| SO-14 Cambio de edición/versión | PARCIALMENTE SOPORTADO | Versión en I; edición en Resource; otra edición/producto puede exigir nueva pareja; Q/V | Versión conserva H. Editar edición del producto afecta consumidores; no evento específico ni snapshot de edición por I | E04, E05, E09, E13, E22 | +| SO-15 OEM, volumen o suscripción | PARCIALMENTE SOPORTADO | Resource licencia + `licenseResourceId`; selector/enlace e H | OEM/suscripción tipadas; volumen sin opción propia. Sin derechos, cobertura/capacidad o asociación contractual comprobada | E06–E09, E13, E22 | +| SO-16 Instalado aún no inventariado/verificado | NO SOPORTADO | Sin I, Metis no conoce la instalación; `pending` no significa no verificado | No descubrimiento ni estado de observación. Puede registrarse declarado como configured, sin acreditar verificación | E04, E06, E08 | + +En SO-08/09 el soporte parcial procede de contratos genéricos: no se ejecutó un flujo de virtualización. En SO-06 se verificó la regla de unicidad por pareja, no un caso E2E dual boot. Las relaciones `hosted` no prueban virtualización ni evitan por sí mismas configuraciones incoherentes de hipervisores. + +## 10. Deficiencias y riesgos + +| Prioridad | Hallazgo | Consecuencia / condición | +| --- | --- | --- | +| Alta | Ausencia de conocimiento explícito a nivel activo | Un vacío puede interpretarse erróneamente como «sin SO»; distinguir carga/error/vacío de estado técnico | +| Alta | Ciclo de configuración usado como evidencia operativa | `configured`, fecha de revisión o `needsReview` no prueban instalación ni inspección | +| Alta | Dos campos de fábrica en servidor | Posibles valores divergentes; inspeccionar datos y significado antes de unificar, sin migración automática | +| Media | Reinstalación/upgrade sin identidad de sucesión ni fecha efectiva | No reconstruye episodios automáticamente; dos comandos no son una operación atómica | +| Media | Máximo una pareja abierta activo/recurso | Impide inventariar dos instancias del mismo producto sobre el mismo activo sin duplicar recursos | +| Media | Historial borrable y nombres/ediciones resueltos actuales | No es trazabilidad forense ni snapshot completo de producto/licencia | +| Media | SO global excluido | Mismo producto SO puede repetirse entre compañías; no reutilizar global applications con una clasificación falsa | +| Media | Licencia documental | No acredita cumplimiento; una FK válida no garantiza compatibilidad ni disponibilidad contractual | +| Media | Software visible solo para el principal | Servidores sin puesto y equipos adicionales carecen de acceso equivalente localizado en ficha de activo | +| Media | Consulta por compañía con historial completo | Coste creciente posible; estudiar paginación/filtro servidor con evidencia, sin afirmación de lentitud | +| Baja | No hay catálogo de SKU ni procedencia por campo | Especificación comercial y unidad pueden confundirse si se automatiza en el futuro | + +Conflictos documentales: `docs/software-del-ordenador.md` describe correctamente la separación, instalaciones, licencias y dual boot documental. Su expresión «plantilla global» se refiere a la definición, no a un SO Resource global. R2.4 relata restricciones de perfiles que R2.5 ya amplió. `docs/puestos-de-trabajo.md` coincide con la baja mediante `decommission`, que vacía la composición; en cambio, la frase de `docs/software-del-ordenador.md` sobre perfiles que consultan la misma instalación no significa una relación instalación–perfil. La guía de puestos prescribe dos puestos para dos ordenadores, mientras que el contrato genérico de miembros no restringe su categoría: no se debe elevar esa permisividad a soporte funcional de múltiples ordenadores. Las afirmaciones históricas de pruebas/build/errores en R2.4 no son resultados de esta auditoría. D08.2 propone adopción/sucesión con nuevo terminal, pero declara que no está ratificada: **`superseded` no existe en el ciclo actual** [E04, E17, E24, E26]. + +## 11. Comparación de alternativas + +A = SO actual directamente en activo; B = producto Resource + instalación en activo; C = SO propiedad del puesto; D = origen e instalación separados y puesto derivado, con requisitos de perfil opcionales. + +Escala cualitativa: **favorable**, **condicionada**, **desfavorable**. Las valoraciones son inferencias arquitectónicas basadas en el inventario anterior, no métricas medidas. Se compara A como solución directa, B reutilizando la existente y D como evolución acotada; enriquecer A hasta tener instalaciones terminaría aproximándola a B. + +| Criterio | A — Atributo | B — Instalación | C — Puesto | D — Híbrido acotado | +| --- | --- | --- | --- | --- | +| 1. Coherencia de dominio | Condicionada: simple para un SO declarado | Favorable: configuración pertenece al activo | Desfavorable: depende de una asignación mutable | Favorable: distingue hechos de origen, operación y vista | +| 2. Normalización | Desfavorable si texto; condicionada con FK | Favorable: producto UUID e instalación separados | Condicionada en tablas; duplica hechos del equipo | Favorable si evita copiar el SO a puesto/perfil | +| 3. Catálogo existente | Condicionada: habría que relacionarlo | Favorable: ya consume Resource os | Condicionada: otro consumidor para el mismo dato | Favorable: conserva Resources como producto | +| 4. Modularidad/extensiones | Condicionada: nueva dependencia de Assets | Favorable: puertos existentes | Condicionada: Assets consultaría producto para escritura | Favorable con composición actual, sin plugins nuevos | +| 5. Servidores y VM | Favorable por activo, sin semántica VM | Favorable por API; presentación pendiente | Desfavorable para equipos sin puesto | Favorable por activo; VM especializada solo si se requiere | +| 6. Varios SO | Desfavorable en atributo escalar | Condicionada: distintos productos sí, misma pareja no | Desfavorable: ambiguo con varios ordenadores | Condicionada a reglas de instancia, no resolverlas con arrays de texto | +| 7. Historial/auditoría | Condicionada: revisiones de activo, sin episodios | Condicionada: revisiones existentes, borrado permitido | Condicionada: historia del puesto confunde equipo y software | Favorable en separación; retención/episodios aún pendientes | +| 8. Licencias | Desfavorable: exige relación paralela | Condicionada: vínculo actual, sin derechos | Desfavorable: vínculo cambia con equipo | Condicionada: reutiliza vínculo sin prometer compliance | +| 9. Inventario automático | Condicionada: falta procedencia/verificación | Condicionada: buen destino, faltan identidad externa y reconciliación | Desfavorable: dispositivos sin puesto quedan fuera | Favorable como dirección; necesita contrato de observación aprobado | +| 10. Frontend | Condicionada: editor fácil, duplica bloque actual | Favorable: panel/editor/historial reutilizables | Condicionada: formulario adicional y riesgo al reemplazar | Favorable: extender vistas por activo, conservar puesto derivado | +| 11. Backend/API | Desfavorable: segunda fuente respecto a I | Favorable: agregado/servicios existentes | Desfavorable: nuevas escrituras y sincronización | Condicionada: evolución pequeña si se aprueban datos faltantes | +| 12. Migración | Desfavorable: decidir cómo reducir N I a un valor | Favorable: sin migración para el núcleo actual | Desfavorable: activos sin puesto y múltiples asignaciones | Condicionada: sin mover I; origen duplicado y estados requieren revisión | +| 13. Duplicidad/divergencia | Desfavorable si conserva I además del atributo | Favorable: una fuente operativa; vigilar productos repetidos | Desfavorable: sustituciones y reasignaciones | Favorable: fuentes distintas por significado explícito | +| 14. Mantenimiento | Condicionada: baja solo si se elimina funcionalidad existente | Favorable: coste incremental menor | Desfavorable: sincronización permanente | Condicionada: favorable si se limita a necesidades aprobadas | + +Fundamento: A/C empeoran la unicidad del hecho operativo y pierden capacidades de I [E04–E10]. B encaja con módulos y licencias existentes [E06–E16]. D refleja gran parte de lo ya implementado y conserva sus límites reales [E01, E17–E20]. No hay justificación actual para modelar requisitos de SO del perfil: sería una capacidad nueva, distinta de `ApplicationConfiguration`. + +## 12. Impacto en arquitectura modular y extensibilidad + +**Propiedad que conviene conservar:** Assets posee unidad física/lógica, características de origen y composición del puesto; Resources posee producto SO/aplicación y recurso licencia; Companies posee la instalación y su ciclo. Que el agregado esté en Companies no convierte al puesto en dueño del SO. Moverlo de módulo no es requisito de esta auditoría. + +El servidor ya compone servicios públicos: `assets.services.inventoryForTransaction`, `resources.services.inventoryForTransaction` y `resources.services.applicationReferences`; Companies los recibe por inyección explícita. El repositorio de instalaciones accede a sus tablas y consulta activos/recursos mediante esas superficies. La recomendación no autoriza leer tablas ajenas desde nuevos servicios ni importar repositorios/ORM de otros módulos [E08, E14, E16]. + +Para mostrar software en una ficha de activo, reutilizar el consumidor web/panel existente o publicar una lectura de instalaciones con consumidor real; el servidor puede componer la proyección. Evitar que Assets importe internals de Companies y que Companies importe internals de Assets. Una lectura filtrada por activo sería una evolución contractual a evaluar por volumen, no un endpoint que ya exista. Tipos comunes/DTO Zod y servicios públicos deben exponer solo datos necesarios. + +No hay un framework dinámico de plugins implementado que haya que extender. La normativa exige composition roots y prohíbe copiar el registro dinámico del ERP. Existe una infraestructura explícita de **lectura de relaciones tipadas** con proveedores reales de otras relaciones; no sustituye comandos/agregados ni registra automáticamente SoftwareInstallation. No se necesita un proveedor SO nuevo para resolver este caso. Valorar su reutilización solo si aparece un consumidor transversal concreto [E16, E25]. + +Para inventario automático harían falta decisiones sobre identificación de unidad/instancia, origen, fecha de observación, confianza, ausencia confirmada, concurrencia, idempotencia y conflicto manual/agente. No convertir una observación ausente o un escaneo fallido en retirada. Esos contratos pertenecen a los propietarios y deben entrar por servicios públicos, no por DML externo o escritura de características con nombres supuestos. + +Habilitar SO global exigiría ampliar elegibilidad, proyecciones, seguridad, administración, selectores, gates y pruebas en Resources y consumidores. La infraestructura global existente es reutilizable, pero el permiso actual `template:application` no lo autoriza. Tampoco debe reciclarse D08 como migración de SO: su adopción de aplicaciones está pendiente y no ratificada [E12–E16, E26]. + +## 13. Recomendación preliminar y condiciones — modelo propuesto + +Preferencia: **D acotada sobre B**, manteniendo los agregados existentes. No hay que «introducir instalaciones» porque ya existen. Primero fijar semántica de ausencia, verificación y reinstalación; solo después valorar contratos y columnas relacionales adicionales. + +```mermaid +flowchart LR + Origin["Assets: origen de la unidad\ncaracterísticas de fábrica existentes"] --> Asset["Assets: activo UUID"] + Product["Resources: producto SO\nrecurso os existente"] --> Install["Companies: SoftwareInstallation\nexistente, extensión por decidir"] + License["Resources: licencia particular\nvínculo documental existente"] -.-> Install + Asset --> Install + Knowledge["Propuesto: conocimiento del SO\ndesconocido / ausencia confirmada / presente\npropietario y reglas por decidir"] -.-> Asset + Observation["Propuesto si se aprueba:\nprocedencia y verificación"] -.-> Install + Station["Assets: puesto\nprincipal y miembros existentes"] --> View["Vista derivada por activo\nprincipal existente; otros equipos propuestos"] + Install --> View + Requirement["Opcional, sin requisito confirmado:\nSO requerido por perfil"] -.-> View +``` + +**Modelo propuesto:** cajas marcadas como propuestas y flechas discontinuas no afirman APIs/tablas instaladas; incluso la extensión de conocimiento necesita decidir su propietario. Las cajas existentes documentan reutilización. El requisito de perfil se muestra como opción diferida, sin añadir entidad ni inferir compatibilidad desde aplicaciones configuradas. + +Condiciones para mantener esta recomendación: + +- Acordar una definición operativa de «instalado» y de «verificado»; no reinterpretar estados actuales silenciosamente. +- Resolver si basta una instalación por producto o se necesita identidad de instancia, disco/VM/arranque. +- Mantener información original y operativa independientes. Revisar los dos campos de fábrica de servidor con datos de prueba antes de cualquier consolidación. +- Preservar UUID/revisiones existentes, valores desconocidos y NULL de licencias. No inventar instalaciones desde características de fábrica o configuraciones de perfil. +- No introducir procedencia, compatibilidad, rights management o integración externa sin consumidores/requisitos aprobados. Si no se necesitan, conservar el núcleo existente. + +Confianza **alta** en reutilizar B y mantener el puesto derivado; **media** en el diseño de futuras extensiones; **no verificada** en datos desplegados, rendimiento o UX real de esta revisión. Una decisión posterior debe convertir las condiciones en alcance y criterios de aceptación. + +## 14. Decisiones pendientes + +| Decisión | Pregunta que deben resolver responsables funcionales/arquitectónicos | +| --- | --- | +| Alcance de SO | ¿Incluye hipervisores, firmware, NAS, móviles y SO de contenedores, o solo entornos arrancables de equipos/VM? | +| Origen | ¿Se necesita configuración comercial del SKU, configuración de recepción de unidad o ambas? ¿Puede corregirse el dato original? | +| Campo duplicado | ¿Son equivalentes `factory_os` y `factory_preloaded_os` en servidores? ¿Qué política aplicar si divergen? | +| Conocimiento | ¿Qué acredita ausencia confirmada? ¿Quién la declara y caduca? ¿Puede coexistir con instalaciones pendientes? | +| Episodios | ¿Reinstalación necesita nueva identidad y fecha efectiva? ¿Cómo se distingue corrección documental, upgrade y cambio físico? | +| Instancias | ¿Dos Windows del mismo producto en discos diferentes son dos instalaciones? ¿Debe existir principal/preferida? | +| Verificación | ¿Origen manual/agente, fecha observada y última verificación son obligatorios? ¿Cómo resolver conflictos y escaneos incompletos? | +| Producto global | ¿Se aprueba ampliar catálogo a SO? ¿Qué identifica familia, edición y producto sin deducirlo por nombre? | +| Licencias | ¿Basta la referencia actual o hacen falta asignaciones, capacidad, volumen y cobertura? ¿Qué reglas OEM/transferencia se necesitan? | +| Historial | ¿Se permite borrar altas erróneas con sus revisiones? ¿Se requiere fijar revisión de producto/licencia o reconstrucción temporal? | +| Presentación | ¿Dónde se gestiona software de servidor/VM sin puesto y equipos adicionales? ¿Qué dato debe resumirse en cada ficha? | +| Perfiles | ¿Hay un requisito real de SO permitido/requerido? ¿Quién lo define y cómo se evalúa frente a múltiples equipos? | +| Seguridad/operación | ¿Se mantiene espacio de soporte compartido? ¿Qué capacidades tendría un agente de inventario y bajo qué compañía/contexto? | +| Migración y aceptación | ¿Qué datos heredados se revisarán manualmente y qué pruebas aisladas, de concurrencia, volumen y UI exigirán la aprobación? | + +## 15. Índice de evidencias de código y documentación + +Los enlaces usan líneas del árbol inspeccionado; pueden desplazarse con los cambios locales pendientes. Una FK/migración citada es evidencia del diseño ejecutable, no de su ejecución en una BD. Las pruebas de integración se consultaron como contratos, sin ejecutarlas en esta auditoría. + +| ID | Evidencia / interpretación | Rutas, símbolos y líneas | +| --- | --- | --- | +| E01 | Campos y plantillas de serie de activos; fábrica duplicada en servidor | [asset-catalog-seed.ts:52](../../modules/assets/src/api/domain/asset-catalog-seed.ts#L52); [asset-catalog-seed.ts:122](../../modules/assets/src/api/domain/asset-catalog-seed.ts#L122); [asset-catalog-seed.ts:267](../../modules/assets/src/api/domain/asset-catalog-seed.ts#L267) | +| E02 | Contrato, tipos y validación de características de activo | [asset-values.ts:19](../../modules/assets/src/api/domain/asset-values.ts#L19); [asset-values.vo.ts:33](../../modules/assets/src/api/domain/value-objects/asset-values.vo.ts#L33); [asset-definition.ts:23](../../modules/assets/src/api/domain/asset-definition.ts#L23) | +| E03 | Persistencia relacional e historial del activo | [asset-relational-schema.ts:100](../../modules/assets/src/api/infrastructure/persistence/asset-relational-schema.ts#L100); [asset.model.ts:15](../../modules/assets/src/api/infrastructure/persistence/asset.model.ts#L15); [asset-revision-specifications.ts:39](../../modules/assets/src/api/infrastructure/persistence/asset-revision-specifications.ts#L39); [asset.aggregate.ts:100](../../modules/assets/src/api/domain/asset.aggregate.ts#L100) | +| E04 | Campos de instalación y máquina de estados | [software-installation-values.ts:5](../../modules/companies/src/api/domain/software-installation-values.ts#L5); [software-installation-values.ts:22](../../modules/companies/src/api/domain/software-installation-values.ts#L22) | +| E05 | Identidad, igualdad, cierre y destino inmutable | [software-installation.aggregate.ts:24](../../modules/companies/src/api/domain/software-installation.aggregate.ts#L24); [software-installation.aggregate.ts:64](../../modules/companies/src/api/domain/software-installation.aggregate.ts#L64); [software-installation-values.vo.ts:52](../../modules/companies/src/api/domain/value-objects/software-installation-values.vo.ts#L52) | +| E06 | Ámbito, tipo, disponibilidad, licencia y duplicados abiertos | [software-installation-policy.ts:6](../../modules/companies/src/api/application/software-installation-policy.ts#L6); [software-installation-input.mapper.ts:16](../../modules/companies/src/api/application/software-installation-input.mapper.ts#L16); [resource-inventory.service.ts:7](../../modules/resources/src/api/application/resource-inventory.service.ts#L7) | +| E07 | Migración de instalaciones, snapshots, FKs e índices | [software-installation-migration.ts:6](../../modules/companies/src/api/infrastructure/persistence/software-installation-migration.ts#L6); [software-installation-snapshot.ts:9](../../modules/companies/src/api/infrastructure/persistence/software-installation-snapshot.ts#L9) | +| E08 | Lectura por compañía, transacción, locks, guardado y borrado de historial | [sequelize-software-installation.repository.ts:32](../../modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts#L32); [sequelize-software-installation.repository.ts:102](../../modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts#L102); [sequelize-software-installation.repository.ts:220](../../modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts#L220); [software-installation-response.builder.ts:7](../../modules/companies/src/api/application/software-installation-response.builder.ts#L7) | +| E09 | Mapper y clave de pareja abierta | [software-installation-persistence.mapper.ts:43](../../modules/companies/src/api/infrastructure/persistence/software-installation-persistence.mapper.ts#L43); [software-installation-persistence.mapper.ts:125](../../modules/companies/src/api/infrastructure/persistence/software-installation-persistence.mapper.ts#L125) | +| E10 | Contratos HTTP, actor de sesión y casos de uso | [software-installations.ts:19](../../modules/companies/src/common/software-installations.ts#L19); [software-installation.routes.ts:20](../../modules/companies/src/api/infrastructure/http/software-installation.routes.ts#L20); [software-installation.use-cases.ts:38](../../modules/companies/src/api/application/software-installation.use-cases.ts#L38) | +| E11 | Tests de instalaciones e integración consultada | [software-installation.test.ts:53](../../modules/companies/src/api/domain/software-installation.test.ts#L53); [software-installations.integration.ts:17](../../apps/server/src/software-installations.integration.ts#L17); [global-software.integration.test.ts:33](../../apps/server/src/global-software.integration.test.ts#L33) | +| E12 | Elegibilidad global: solo aplicación stock | [global-application-template-policy.ts:2](../../modules/resources/src/api/application/global-application-template-policy.ts#L2); [resource-reference-metadata.ts:104](../../modules/resources/src/api/infrastructure/persistence/resource-reference-metadata.ts#L104) | +| E13 | Producto SO, edición, atributos de licencia y uso explícito | [resource-catalog-seed.ts:96](../../modules/resources/src/api/domain/resource-catalog-seed.ts#L96); [resource-catalog-seed.ts:149](../../modules/resources/src/api/domain/resource-catalog-seed.ts#L149); [resource-definition.ts:40](../../modules/resources/src/api/domain/resource-definition.ts#L40) | +| E14 | Resolver público transaccional, propósito y lectura de UUID | [resource-reference-service.ts:32](../../modules/resources/src/api/application/resource-reference-service.ts#L32); [resource-reference-reader.ts:47](../../modules/resources/src/api/infrastructure/persistence/resource-reference-reader.ts#L47); [configuration-resource-references.ts:6](../../modules/companies/src/api/infrastructure/persistence/configuration-resource-references.ts#L6) | +| E15 | Resource y propietario global/particular; definiciones no son productos | [resource.aggregate.ts:63](../../modules/resources/src/api/domain/resource.aggregate.ts#L63); [resource-scope.ts:5](../../modules/resources/src/api/domain/resource-scope.ts#L5); [resource-values.ts:40](../../modules/resources/src/api/domain/resource-values.ts#L40) | +| E16 | Composición e inyección; gates predeterminados cerrados para asociación | [identity-runtime.ts:30](../../apps/server/src/infrastructure/http/identity-runtime.ts#L30); [resource-configuration-dependencies.ts:13](../../modules/companies/src/api/infrastructure/persistence/resource-configuration-dependencies.ts#L13); [module.ts:149](../../modules/assets/src/api/module.ts#L149); [resource-global-gates.ts:14](../../modules/resources/src/api/application/resource-global-gates.ts#L14); [composicion-de-modulos.md:1](../../docs/arquitectura/composicion-de-modulos.md#L1) | +| E17 | Workstation, principal, igualdad e historial; baja vacía composición | [workstation-values.ts:3](../../modules/assets/src/api/domain/workstation-values.ts#L3); [workstation-values.vo.ts:49](../../modules/assets/src/api/domain/value-objects/workstation-values.vo.ts#L49); [workstation.aggregate.ts:56](../../modules/assets/src/api/domain/workstation.aggregate.ts#L56) | +| E18 | Pertenencia y exclusividad de activos del puesto | [workstation-input.mapper.ts:52](../../modules/assets/src/api/application/workstation-input.mapper.ts#L52); [workstation.model.ts:18](../../modules/assets/src/api/infrastructure/persistence/workstation.model.ts#L18); [workstation.test.ts:90](../../modules/assets/src/api/domain/workstation.test.ts#L90) | +| E19 | Perfil frente a instalación, delivery y aplicaciones globales R2.5 | [work-profile-values.ts:3](../../modules/companies/src/api/domain/work-profile-values.ts#L3); [work-profile-input.mapper.ts:16](../../modules/companies/src/api/application/work-profile-input.mapper.ts#L16); [application-configuration-values.ts:5](../../modules/companies/src/api/domain/application-configuration-values.ts#L5); [application-configuration-policy.ts:6](../../modules/companies/src/api/application/application-configuration-policy.ts#L6); [sequelize-application-configuration.repository.ts:83](../../modules/companies/src/api/infrastructure/persistence/sequelize-application-configuration.repository.ts#L83) | +| E20 | Consulta y representación del software por principal; cambios locales previos | [use-workstation-detail-controller.ts:41](../../modules/companies/src/web/workstations/detail/controllers/use-workstation-detail-controller.ts#L41); [use-workstation-software.ts:9](../../modules/companies/src/web/workstations/detail/controllers/use-workstation-software.ts#L9); [workstation-software.tsx:22](../../modules/companies/src/web/workstations/detail/ui/workstation-software.tsx#L22); [workplace-map-model.ts:30](../../modules/companies/src/web/workstations/detail/helpers/workplace-map-model.ts#L30) | +| E21 | Edición, consulta e historial de activo; sin panel propio de instalaciones localizado | [asset.use-cases.ts:12](../../modules/assets/src/api/application/asset.use-cases.ts#L12); [asset-fields.tsx:14](../../modules/assets/src/web/assets/shared/ui/asset-fields.tsx#L14); [asset-detail-content.tsx:16](../../modules/assets/src/web/assets/detail/ui/asset-detail-content.tsx#L16); [asset-history.tsx:21](../../modules/assets/src/web/assets/detail/ui/asset-history.tsx#L21) | +| E22 | Panel filtrado, formulario, licencia, historial y API del navegador | [software-installations-panel.tsx:130](../../modules/companies/src/web/software-installations/list/ui/software-installations-panel.tsx#L130); [software-installation-form.tsx:20](../../modules/companies/src/web/software-installations/shared/ui/software-installation-form.tsx#L20); [software-history-dialog.tsx:33](../../modules/companies/src/web/software-installations/history/ui/software-history-dialog.tsx#L33); [software-api.ts:18](../../modules/companies/src/web/software-installations/shared/api/software-api.ts#L18) | +| E23 | Seguridad de sesión y alcance compartido; integración no ejecutada | [require-authenticated.ts:6](../../modules/identity/src/api/infrastructure/http/require-authenticated.ts#L6); [identity.routes.ts:102](../../modules/identity/src/api/infrastructure/http/identity.routes.ts#L102); [03-contrato-seguridad.md:7](../../docs/architecture/resources-global-scope/03-contrato-seguridad.md#L7); [global-software.integration.test.ts:232](../../apps/server/src/global-software.integration.test.ts#L232) | +| E24 | Documentación funcional y evolución R2.4–R2.6 | [software-del-ordenador.md:1](../../docs/software-del-ordenador.md#L1); [puestos-de-trabajo.md:43](../../docs/puestos-de-trabajo.md#L43); [15-r2-4-software-global.md:40](../../docs/architecture/resources-global-scope/15-r2-4-software-global.md#L40); [16-r2-5-configuraciones-globales.md:1](../../docs/architecture/resources-global-scope/16-r2-5-configuraciones-globales.md#L1); [17-r2-6-coexistencia-duplicados.md:46](../../docs/architecture/resources-global-scope/17-r2-6-coexistencia-duplicados.md#L46) | +| E25 | Relaciones genéricas y contratos de extensión de lectura, sin plugins dinámicos | [asset-catalog-seed.ts:359](../../modules/assets/src/api/domain/asset-catalog-seed.ts#L359); [asset-relationship.aggregate.ts:23](../../modules/assets/src/api/domain/asset-relationship.aggregate.ts#L23); [contratos-de-relaciones-tipadas.md:1](../../docs/arquitectura/contratos-de-relaciones-tipadas.md#L1); [capas-del-servidor.md:29](../../docs/arquitectura/capas-del-servidor.md#L29) | +| E26 | Adopción/sucesión todavía propuesta; no trasladar al ciclo instalado | [19-d08-2-resolucion-ratificacion.md:3](../../docs/architecture/resources-global-scope/19-d08-2-resolucion-ratificacion.md#L3); [19-d08-2-resolucion-ratificacion.md:44](../../docs/architecture/resources-global-scope/19-d08-2-resolucion-ratificacion.md#L44) | +| E27 | Rutas públicas de activos, catálogo, puestos y recursos | [asset.routes.ts:21](../../modules/assets/src/api/infrastructure/http/asset.routes.ts#L21); [asset-catalog.routes.ts:141](../../modules/assets/src/api/infrastructure/http/asset-catalog.routes.ts#L141); [workstation.routes.ts:16](../../modules/assets/src/api/infrastructure/http/workstation.routes.ts#L16); [resource-application.routes.ts:102](../../modules/resources/src/api/infrastructure/http/resource-application.routes.ts#L102); [resource.routes.ts:54](../../modules/resources/src/api/infrastructure/http/resource.routes.ts#L54) | +| E28 | Persistencia relacional de productos/atributos y revisiones de Resources | [resource.model.ts:19](../../modules/resources/src/api/infrastructure/persistence/resource.model.ts#L19); [resource-relational-schema.ts:107](../../modules/resources/src/api/infrastructure/persistence/resource-relational-schema.ts#L107); [resource-inventory-relational.ts:198](../../modules/resources/src/api/infrastructure/persistence/resource-inventory-relational.ts#L198); [resource.aggregate.ts:120](../../modules/resources/src/api/domain/resource.aggregate.ts#L120) | + +## 16. Validaciones y límites de entrega + +Se ejecutó una selección sin acceso a BD de siete archivos de pruebas: dominio de instalaciones, perfiles, configuraciones de aplicación, catálogo de recursos, activos y puestos; más render de software es/en. El runner `node --import tsx --test …` informó **7 archivos correctos**. Se ejecutó además la importación explícita de esos siete archivos con `node --import tsx -e` con los imports siguientes: **38 pruebas, 38 correctas, cero fallos/canceladas/omitidas**. Así se evita confundir la agregación por fichero con el número de casos. + +Comando reproducible de los casos individuales (sin conexión a BD): + +```bash +node --import tsx -e ' +await import("./modules/companies/src/api/domain/software-installation.test.ts"); +await import("./modules/companies/src/api/domain/work-profile.test.ts"); +await import("./modules/companies/src/api/domain/application-configuration.test.ts"); +await import("./modules/resources/src/api/domain/resource-catalog.test.ts"); +await import("./modules/companies/src/web/software-installations/shared/software.test.tsx"); +await import("./modules/assets/src/api/domain/workstation.test.ts"); +await import("./modules/assets/src/api/domain/asset.test.ts"); +' +``` + +Cubren contratos de referencia y tipo, destino fijo, versiones, revisiones, transiciones, licencia/vigencia/desvinculación, idempotencia, mapper de persistencia sin BD, perfiles, composición de puestos, características opcionales y render/localización. No equivalen a 16 escenarios E2E ni a ensayos con MariaDB. Se consultaron además `software-installations.integration.ts` y `global-software.integration.test.ts` para concurrencia, CSRF, aislamiento contextual, gates y migración; **no se ejecutaron**. + +No se ejecutaron `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm check` ni `pnpm build` globales: el cambio propio es exclusivamente este documento y no se requiere compilar código de producción para entregarlo. No se certifica el estado global del árbol previo ni se reproducen como actuales los fallos de informes anteriores. Se revisaron referencias locales y se comprobó whitespace con `git diff --no-index --check /dev/null `: sin diagnósticos de whitespace (el código de salida 1 corresponde a que el archivo nuevo difiere de `/dev/null`). Se contrastaron huellas de 55 rutas modificadas/no seguidas previas: 52 no variaron y se observaron variaciones concurrentes en `workplace-software-card.tsx`, `workstation-detail.test.tsx` y `scripts/workplace-visual-map.test.py`. Esta tarea no escribió esos archivos. La única ruta nueva respecto de esa instantánea es este informe; las pruebas ejecutadas no certifican los cambios concurrentes posteriores. + +Volumen realmente utilizado: fixtures pequeños en memoria definidos por las pruebas, sin carga de compañías/activos/personas/recursos en BD. El escenario aislado de 15 compañías y al menos 50 personas, activos y recursos de una principal queda pendiente para una futura validación funcional/visual; no se declara ejecutado aquí. Tampoco se midieron rendimiento, accesibilidad o navegación real. + +Entrega autorizada: un informe Markdown. **No se implementó ninguna recomendación, modelo, endpoint, migración o cambio de componente.** diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 3ce18527..935e6ec0 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -1527,3 +1527,75 @@ genérico descrito arriba. La cabecera común mantiene identidad y acciones de e Información permanece montada para conservar las configuraciones abiertas; el acceso «Ver historial completo» activa Historial y sitúa el foco en su pestaña. La composición se detalla en [la implementación del dashboard](workplace-dashboard-implementacion.md#pestañas-de-la-ficha-del-puesto). + +### Identidad y contexto del responsable del puesto + +`WorkstationResponsibleContext` encapsula la identidad del responsable en la cabecera: +Avatar y nombre forman un único botón que abre el popup, con preferencia de apertura +por encima del activador. El popup presenta foto o iniciales ampliadas, nombre, email, +teléfono y asignaciones organizativas vigentes con departamentos y cargos. Reutiliza +`PersonAvatar`, `DetailField` y `PhoneLink`; el email permite escribir mediante `mailto:` +y los datos ausentes muestran «—». Mantiene dentro el enlace para abrir la ficha. Recibe `company` y `responsible` +(`PersonDTO | undefined`); si no existe responsable, muestra el estado traducido sin +avatar, enlace ni botón de contexto. La página solo proporciona los datos. El popover +conserva su gestión de foco, errores y reintento sobre la consulta de compañía existente. + +Validación del popup ampliado: prueba MCP +`WorkstationDashboardTests.test_25_responsible_person_popup` en 1440×900, 390×844 +y 320×640, con apertura desde avatar y nombre, teclado, Escape, retorno de foco, +contacto, departamento, límites horizontales y navegación a la ficha. Se revisaron +las capturas de esos tres tamaños. Datos exclusivamente desechables: 15 compañías, +59 personas, 135 activos y 73 recursos en la compañía principal, además de perfiles; +las personas de la fixture se prepararon con email y teléfono sintéticos. Se verificó +el fallback de iniciales; no se probó una foto subida en esta ejecución. La suite +TypeScript de detalle pasó sus 30 pruebas, junto con tipos y estilo de los ficheros +modificados. Skills de composición `shadcn`, `vercel-composition-patterns` y +`rdx-solid`: primitivas existentes, un activador semántico y reutilización de avatar +y contacto, sin dependencias nuevas para este componente. + +### Mapa visual del puesto + +La ficha de puesto compone `WorkplaceVisualMapCard`, nodos de equipo/software y una leyenda externa con las lecturas existentes. React Flow y dagre calculan la vista de consulta; por debajo de 640 px útiles se usan las mismas tarjetas en un recorrido lineal legible. El contenido de los nodos no hace fetch. Las colecciones existentes mantienen la gestión y el acceso al conjunto completo. Personas y perfiles reutiliza `CompactList` y una fila visual con avatar, cuenta, estado y configuraciones. Contratos, límites, validación y evolución: [mapa visual del puesto](mapa-visual-del-puesto.md). + +### Uso del contexto de persona en cabeceras + +`WorkstationResponsibleContext` se comparte desde +`people/shared/ui/workstation-responsible-context.tsx`, conservando su nombre. +Lo utilizan las cabeceras del puesto, de la ficha de persona, del entorno del perfil +y del detalle de proceso. No se aplica al usuario de sesión, que es una cuenta +interna distinta de las personas de la compañía, ni a filas, formularios e históricos. + +El componente recibe la compañía y la persona, verifica su pertenencia y comparte +foto, contacto, asignaciones y navegación a la ficha. Los textos generales viven en +`companies.personContext`; `description` y `emptyLabel` conservan la semántica específica +del responsable del puesto. `presentation="heading"` mantiene la jerarquía de la ficha +de persona; `children` permite componer su badge de estado en el avatar. Las demás +cabeceras usan la presentación compacta. El cálculo de asignaciones se comparte en +`people/shared/helpers/person-organization.ts`, con sus pruebas junto a él. El popup +observa la consulta de compañía existente: abrirlo no realiza otra carga ni crea caché +paralela. La página de proceso proporciona `CompanyDTO` en lugar de una etiqueta suelta. + +Validación de la adopción en cabeceras: 50 pruebas dirigidas correctas de puestos, +entornos, procesos y asignaciones, incluyendo la pertenencia de la persona a su +compañía. Tipos de `@metis/companies`, Biome/ESLint de los archivos afectados y build +global correctos. Se ejecutaron las cinco comprobaciones globales: typecheck falla en +`packages/shadcn-ui/src/components/calendar.tsx` por la opcionalidad exacta de `locale`; +lint/check conservan diagnósticos fuera de este cambio; la suite de Companies termina +con 258/260 pruebas correctas y fallan las expectativas de «Personas (1)» y del texto +«Esta persona no tiene acceso a Metis Desk», en `companies-page.test.tsx`. + +Playwright MCP: `WorkstationDashboardTests.test_26_shared_person_headers` verifica las +cuatro cabeceras a 1440×900, 390×844 y 320×640, apertura con Enter, contenido del popup, +retorno del foco con Escape, ausencia de botones/enlaces anidados y navegación a la +ficha. Se revisaron las capturas de los doce escenarios; la ficha de persona reserva +ancho para la identidad y desplaza las acciones a otra fila cuando falta espacio. +El entorno desechable contiene 15 compañías y, en la principal, 59 personas, 135 activos, +73 recursos y 64 perfiles; se amplió hasta 50 puestos, respetando la disponibilidad de +las personas. Una comprobación adicional confirma los doce escenarios con ese volumen. +No se utilizaron cuentas internas como personas de compañía ni se modificó vendor. +Skills consultadas: `vercel-composition-patterns` y `rdx-solid`, aplicadas a ubicación +compartida, composición del badge y contratos con semántica de cada pantalla. + +#### Refinamiento del mapa — Iteración 2 + +El mapa diferencia dimensiones de principal, secundarios y software. `WorkplaceSoftwareHeadingNode` rotula el grupo sin representar un recurso ni recibir foco; `WorkplaceMapCoverage` indica elementos fuera del preview solo con una fuente conocida y conduce a las colecciones existentes. La leyenda se limita a Principal, Asociado al puesto y Software del principal. La altura se deriva de los bounds de dagre; un refetch sin cambio de geometría conserva el canvas. Contratos y evidencia comparativa: [refinamiento visual del mapa](mapa-visual-del-puesto.md#refinamiento-visual--iteración-2). diff --git a/docs/mapa-visual-del-puesto.md b/docs/mapa-visual-del-puesto.md new file mode 100644 index 00000000..c1c66895 --- /dev/null +++ b/docs/mapa-visual-del-puesto.md @@ -0,0 +1,239 @@ +# Mapa visual del puesto + +Implementación del 25 de octubre de 2026 sobre la ficha existente de puesto de trabajo. + +## Composición y decisiones + +La cabecera funcional, edición, historial y gestión mantienen sus contratos. La parte superior contiene `WorkplaceVisualMapCard` y, cuando el contenedor dispone de 1040 px, una columna de 280 px con resumen, pendientes y soporte. En contenedores menores, esos paneles pasan debajo. Se conservan Inter, tokens, iconos semánticos, badges y `DetailPanel`; no hay cambios en vendor. El contexto remoto se despliega con `Collapsible` para mantener compacto el panel de soporte, conservando los enlaces y resúmenes existentes. + +El equipo principal se valida con la composición del puesto y la pertenencia de compañía. El mapa distingue sus activos asociados y su software documental. Las líneas de equipamiento **no afirman conexión física**: el modelo actual documenta pertenencia al puesto, no cableado. Software conserva `configured`, `pending`, `removal_pending` y revisión; no convierte todos los registros en «Configurado». Se excluyen retirados y nunca configurados, como en la gestión previa. Estado técnico y asignación son independientes. + +El canvas muestra hasta dos activos asociados y tres aplicaciones para mantener texto legible. Un aviso informa de las cantidades cuando hay más. «Ver todo el equipamiento» abre el componente existente con su preview ampliable; el panel de software conserva versiones, licencias, revisión, colección ampliable y diálogo de gestión. No se pierde acceso a elementos fuera del mapa. + +La leyenda se sitúa fuera del canvas para conservar legibilidad y flujo de lectura. El resumen mantiene cero como valor real y muestra `—` cuando la fuente está pendiente o fallida. Las personas y perfiles utilizan `CompactList`/`CompactListItem`, avatares, estado, cuenta y resumen de configuraciones; conservan archivo, expansión contextual, foco y gestión. + +## Componentes y responsabilidades + +Todos los archivos específicos viven en `modules/companies/src/web/workstations/detail/`: + +| Unidad | Responsabilidad | +| --- | --- | +| `ui/workplace-visual-map-card.tsx` | Card, acciones, estados parciales, preview y selección de presentación según capacidad | +| `controllers/use-workplace-map-model.ts` | Adaptación reactiva y resolución de nombres/enlaces con las lecturas existentes | +| `helpers/workplace-map-model.ts` | Modelo de presentación, aislamiento de compañía y selección del principal/software | +| `helpers/workplace-map-layout.ts` | Cálculo dagre independiente del fetch y de los componentes | +| `ui/workplace-map-canvas.tsx` | Canvas de React Flow cargado mediante `React.lazy` | +| `ui/workplace-equipment-node.tsx`, `ui/workplace-software-node.tsx` | Custom nodes con handles de solo lectura | +| `ui/workplace-relationship-edge.tsx` | Custom edge bezier, sin flechas, con patrón distinto por familia | +| `ui/workplace-equipment-card.tsx`, `ui/workplace-software-card.tsx` | Contenido común de canvas y presentación compacta; enlaces nativos que cubren la tarjeta | +| `ui/workplace-compact-map.tsx` | Presentación lineal cuando el ancho útil es inferior a 640 px | +| `ui/workplace-map-legend.tsx`, `ui/workplace-summary.tsx` | Leyenda y contadores | +| `controllers/use-workstation-software.ts` | Una lectura compartida entre mapa y gestión; claves e invalidación existentes | +| `ui/workstation-profile-summary.tsx` | Fila visual del perfil y acción independiente de configuraciones | + +No se crean APIs, schemas, migraciones, persistencia, providers propios ni cambios de autorización. Los nodos reciben datos de presentación y enlaces; no realizan fetch. Las traducciones nuevas están en es/en del namespace `assets`. + +## React Flow y layout + +Dependencias directas de `@metis/companies`: `@xyflow/react` 12.12.0 y `@dagrejs/dagre` 3.1.2, fijadas en manifest y lockfile. React Flow y dagre quedan en el chunk diferido del canvas; los tipos compartidos no cargan la biblioteca en runtime. + +Dos grupos dagre comparten el principal: LR para el equipamiento lateral y TB para el software inferior. Cada nodo recibe un objeto propio de dimensiones, porque dagre lo modifica al calcular posiciones. La franja de software queda debajo de toda la extensión vertical del equipamiento. Una prueba comprueba coordenadas finitas y ausencia de solapamientos. + +La vista inicial usa `fitView`. El ancho real se mide con un único `ResizeObserver`, con desconexión al desmontar; los cambios de ancho reajustan el canvas. Están desactivados arrastre, selección, conexiones, reconexión, borrado, pan y zoom gestual. El scroll de página sigue funcionando. Los enlaces nativos tienen foco visible y son alcanzables por teclado; los wrappers y edges de React Flow no añaden pasos de tabulación. Se conserva la atribución de React Flow. + +En espacios estrechos se muestran las mismas tarjetas a tamaño de lectura, ordenadas como principal, equipamiento y software. No se reduce un canvas hasta hacer ilegibles sus textos. Este cambio responde al ancho útil, incluyendo navegaciones y paneles, no solo al viewport. + +Referencias consultadas: [custom nodes](https://reactflow.dev/learn/customization/custom-nodes), [handles](https://reactflow.dev/learn/customization/handles), [layout](https://reactflow.dev/learn/layouting/layouting) y [contrato de ReactFlow](https://reactflow.dev/api-reference/react-flow). + +## Estados y accesibilidad + +El esqueleto inicial de la ficha continúa vigente. Cada consulta conserva carga, refresco, datos retenidos, error y reintento mediante los componentes existentes. El fallo de software o referencias no retira el equipamiento. Un error de configuración mantiene disponible el mapa y los demás paneles. Se distinguen ausencia confirmada de software, ausencia de activos asociados, principal ausente, referencia no resuelta y principal fuera de composición. + +Los badges contienen texto, los iconos decorativos se ocultan de tecnologías asistivas y las relaciones no dependen exclusivamente del color. Los enlaces cubren cada nodo y mantienen nombres completos accesibles aunque el título visual se limite a dos líneas. El listado de perfiles conserva `aria-busy`, estados y controles de expansión. La gestión utiliza los diálogos existentes, con sus restricciones y retorno de foco. + +## Validación y evidencia + +El recorrido reproducible está en `scripts/workplace-visual-map.test.py`; utiliza el cliente MCP y el servidor Docker fijado en `.codex/config.toml`, mediante el procedimiento de `docs/playwright-mcp.md`. Requiere mantener `visual-audit.py` abierto y ejecutar antes `visual-audit-volume.py` y `workstation-dashboard-fixtures.py` con su sesión privada. No imprime ni versiona cookies, claves ni `storageState`. + +```bash +METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \ + python3 scripts/workplace-visual-map.test.py +``` + +Datos reales de la ejecución sintética: 15 compañías, 59 personas, 135 activos y 73 recursos en la principal después de preparar las fixtures de puestos; escenario denso con 80 miembros y 60 perfiles. Toda persistencia pertenece al contenedor desechable `metis_desk_test`. + +Los resultados técnicos y de navegador figuran en el apartado de resultados de esta entrega. + +## Límites y evolución + +- Vista de consulta: no hay edición libre, reordenación, relaciones físicas ni guardado de coordenadas. +- No se incluyen marcas/logos de aplicaciones porque los contratos existentes no ofrecen un icono validado. Se usa el icono semántico de software. +- La primera iteración limita nodos visibles y conserva el resto en las colecciones de gestión. No se implementan búsqueda, filtros ni virtualización del canvas. +- El layout está aislado para sustituir su cálculo por ELK si aparecen grafos más complejos; no se añade elkjs ni una interfaz anticipada de motores. +- No se certifica WCAG ni compatibilidad con lectores de pantalla concretos. Los auditores especializados `ux-*-audit` no están instalados; no se han instalado herramientas adicionales. +- Las suites visuales históricas de versiones anteriores contienen expectativas de seis paneles/tablas. El nuevo recorrido comprueba la composición del mapa; no se han reescrito auditorías históricas ajenas a esta tarea. + +Skills consultadas: `frontend-design` para jerarquía y aproximación al mockup dentro de los tokens; `shadcn` para composición y APIs Base UI; `ux-dashboard` para resumen y prioridad; `ux-tables` para el listado compacto; `vercel-composition-patterns` para separar nodos, presentación y acciones; `vercel-react-best-practices` para datos derivados y carga diferida; `rdx-solid` para responsabilidades y pruebas observables. Se inspeccionaron `.agents/skills/`, `skills-lock.json`, DESIGN y la matriz del proyecto. No se aplicaron paletas ni dependencias alternativas propuestas genéricamente por skills. + +## Resultados de esta entrega + +| Comprobación ejecutada | Resultado | +| --- | --- | +| `pnpm --filter @metis/companies typecheck` | Correcto | +| `pnpm --filter @metis/web typecheck` | Correcto | +| Biome `check` sobre detalle de puestos, traducciones es/en de assets y manifest de companies | Correcto | +| ESLint sobre `modules/companies/src/web/workstations/detail` | Correcto | +| Suite dirigida de ficha (`node --import tsx` con tsconfig raíz) | 32 pruebas correctas en el estado final compartido, incluidas las incorporaciones concurrentes de contexto de persona | +| Suite dirigida de helpers y ficha con `node --test` | Correcta; se conservan las comprobaciones de relaciones, composición y expansión | +| `pnpm build` | Correcto para servidor y web; conserva el aviso de tamaño del chunk principal. El canvas se emite por separado, aproximadamente 226 kB / 74 kB gzip | +| Recorrido MCP completo del mapa | Cuatro pruebas correctas: seis tamaños, 80 activos, navegación/gestión y vacíos/errores parciales | +| `git diff --check` y sintaxis Python del nuevo recorrido | Correctos | +| `pnpm typecheck` | Falla fuera del cambio: `packages/shadcn-ui/src/components/calendar.tsx:165`, incompatibilidad de `locale` con `exactOptionalPropertyTypes`. No se modifica vendor | +| `pnpm lint` y `pnpm check` | Fallan en archivos ajenos al mapa: reglas de bloques en backend de personas/procesos/migraciones y formato previo, entre otros. Las comprobaciones dirigidas pasan | +| `pnpm test` | Falla en `modules/resources/src/api/application/resource-scope.test.ts`, ajeno a esta implementación | +| `pnpm --filter @metis/companies test` | La ficha y sus helpers pasan; permanecen dos aserciones de `companies-page.test.tsx` sobre textos de personas, fuera del cambio | + +Las capturas e informes están excluidos de Git en `.artifacts/playwright/authenticated-ysqdk5ab/workstation-dashboard-wd043/visual-map/`: `map-1600.png`, `map-1440.png`, `map-1280.png`, `map-1024.png`, `map-768.png`, `map-390.png`, errores parciales y `measurements.json`. Se abrieron e inspeccionaron las seis vistas y las imágenes de error. El recorrido mide ausencia de overflow global y solapamientos, ancho legible de nodos, número de edges, foco, navegación y retorno desde diálogos. La prueba densa despliega los 80 miembros completos fuera del canvas. + +Se verifica el enlace al principal mediante teclado y el retorno de foco desde equipamiento; la expansión del perfil, gestión de software e historial se comprueban con interacciones reales. Los escenarios de error simulan respuestas HTTP únicamente en el navegador, sin alterar datos. La validación no cubre contraste automatizado, lectores de pantalla, zoom de texto, modo oscuro ni carga masiva de software en navegador; las unidades cubren los tres estados de software y el layout con tres aplicaciones y dos elementos asociados. Estos límites no equivalen a una certificación de accesibilidad. + +Se conservaron cambios previos y concurrentes en cabecera, contexto de personas y otros módulos; no forman parte de la implementación del mapa ni se revierten para validar esta tarea. Las comprobaciones globales reflejan ese workspace compartido. + +Tras ajustar el breakpoint de la columna lateral a 1040 px útiles, se repitieron los recorridos de seis tamaños y navegación: dos pruebas correctas. Se verificaron Tab desde «Gestionar equipamiento» al principal, outline visible, Enter para navegar y Space para abrir/cerrar el contexto remoto. Se inspeccionó también `profiles-1440.png`. + +## Refinamiento visual — Iteración 2 + +25 de octubre de 2026. Refinamiento de presentación sobre la implementación anterior; se conserva el informe histórico precedente. + +### Estado anterior y referencia + +Se inspeccionaron los componentes reales, DESIGN, el dashboard, el patrón de listados compactos y las capturas de la primera entrega. Se generaron otras seis capturas **antes de cambiar la composición**, en una nueva instalación desechable. El principal y el equipamiento compartían 260 × 166 px, el canvas reservaba 430 px con independencia del contenido y la leyenda repetía estados ya presentes en los nodos. El software no tenía un rótulo propio dentro del grafo. + +La dirección visual procede del mockup aportado en la conversación y de la especificación de esta iteración: portátil protagonista, equipamiento secundario, software inferior y relaciones discretas. No se afirma una reproducción exacta de píxeles ni se incorporan fotografías o logos por coincidencia de nombre. + +### Cambios de presentación + +| Aspecto | Implementación de la iteración 2 | +| --- | --- | +| Principal | 264 × 192 px en el layout, centrado, icono semántico de 48 px, nombre semibold, tipo junto a «Principal» y estados agrupados abajo. Borde/fondo `secondary` y padding de 16 px | +| Secundarios | 212 × 120 px, icono de 24 px, identidad horizontal y estados agrupados; menor superficie y peso tipográfico que el principal | +| Software | 204 × 132 px, icono de 24 px, nombre de dos líneas y estado de instalación real. Revisión compacta, con explicación completa en el árbol accesible y tooltip | +| Títulos largos | Los enlaces nativos conservan el nombre completo; `Tooltip` de shadcn permite leerlo con foco y puntero sin añadir otro tab stop | +| Grupo de software | `WorkplaceSoftwareHeadingNode` muestra «Software del ordenador principal». No tiene Resource, handles, enlace, selección, arrastre ni foco | +| Aristas | `WorkplaceRelationshipEdge`: bezier de 1,5 px, asociación continua con opacidad 0,65 y software discontinuo con 0,55. Sin flechas ni animación. Handles transparentes, conservando sus dimensiones | +| Leyenda | Tres símbolos y textos: círculo lleno «Principal», círculo vacío «Asociado al puesto» y trazo discontinuo «Software del principal». Se retiran los badges de estado de la leyenda | +| Cobertura | `WorkplaceMapCoverage` separa «Mostrando 3 de 12 activos» y «Mostrando 3 de 8 instalaciones de software», solo cuando hay elementos fuera del mapa y la fuente es conocida | +| Acciones | Equipamiento conserva su ampliación existente. «Ver todo el software» lleva al panel existente y enfoca su botón «Gestionar software»; no crea otra pantalla ni otra implementación de gestión | + +Las dimensiones de layout reservan espacio para dos líneas de identidad y los estados. Las tarjetas mantienen altura mínima y las pruebas de navegador verifican que su contenido no desborde; no se añade `overflow: hidden` para ocultar errores geométricos. Los detalles completos permanecen en las colecciones existentes. + +### Dagre, geometría y estabilidad + +Se conservan dos cálculos dagre. El primero distribuye equipamiento con separación horizontal de 36 px y vertical de 20 px. Con dos asociados y al menos 820 px útiles de canvas, los reparte a izquierda y derecha; con menos capacidad, los apila a la izquierda para preservar la lectura. El segundo distribuye software horizontalmente con 16 px entre nodos. La franja inferior se coloca bajo el límite inferior de **todo** el equipamiento, con espacio para el rótulo y las curvas. + +Las coordenadas del rótulo y el tamaño del canvas se derivan de las dimensiones y bounds de esos resultados. No se recolocan nodos con CSS después de dagre. `WorkplaceLayoutNode` expresa dimensiones resueltas; no se debilitan los tipos de React Flow ni se añaden assertions o supresiones. + +El canvas calcula su altura con los bounds y el ancho disponible, con 32 px de margen total; un principal sin periféricos/software ocupa aproximadamente 224 px de canvas. Con el preview completo la altura sigue acotada por los límites existentes. `fitView` tiene menos padding y zoom mínimo de 0,86. No se amplían nodos por encima de su tamaño de lectura. + +La identidad del canvas ya no depende del objeto de datos ni se desmonta desde la card con cada render. React Flow reajusta la vista cuando cambian las dimensiones, posiciones, conjunto de IDs o ancho disponible. Un refetch con el mismo conjunto conserva el canvas y la cámara. + +### Cobertura y estados parciales + +`workplaceMapPreview` centraliza la presentación del límite que ya existía: un principal válido, hasta dos asociados y hasta tres instalaciones. `WorkplaceCompactMap` recibe ese mismo preview, evitando límites duplicados entre las dos presentaciones. No se modifica `workplaceMapModel`, la resolución de referencias ni ninguna regla de selección. + +El total de equipamiento incluye el principal cuando forma parte de la composición. El de software corresponde a las instalaciones admitidas por el contrato previo, no al catálogo de productos ni a aplicaciones activas en general. No se muestran «3 de 3» ni cantidades de una fuente fallida. En un error con datos retenidos se conservan los nodos, se explica el error con el componente de consultas existente y se retira el total definitivo de esa fuente. + +Se mantienen carga inicial, carga parcial, ausencia de software, ausencia de periféricos, principal ausente/inconsistente y errores recuperables. No se añaden nodos ficticios para rellenar vacíos ni se convierte «Pendiente» o «Retirada pendiente» en «Configurado». + +### Responsive y accesibilidad + +Se mantienen el umbral de 640 px útiles para la alternativa lineal y la estructura global del dashboard. En móvil aparecen principal, asociados y software, con las mismas tarjetas, estados, enlaces y cobertura. El nuevo rótulo no aparece en el orden de tabulación; las aristas tampoco reciben foco. + +Se conservan controles de gestión fuera de los wrappers editables de React Flow, foco visible y devolución de foco desde los diálogos existentes. El mapa sigue sin arrastre, conexiones manuales, reconexión, borrado, selección múltiple, pan ni zoom gestual. No persiste coordenadas. + +Los símbolos de la leyenda y los patrones de arista distinguen conceptos sin depender solo del color. Se usan tokens reales, Inter local y componentes instalados. Los tooltips respetan reducción de movimiento; no se añade animación a las aristas ni a la cámara. + +### Archivos y alcance + +Se ajustan las tarjetas/nodos, canvas, leyenda, compact map y `workplace-map-layout.ts`; se incorporan `workplace-map-preview.ts`, `WorkplaceSoftwareHeadingNode`, `WorkplaceMapCoverage` y pruebas geométricas específicas. La composición exterior solo aporta una referencia al panel de software para el acceso desde la cobertura, conservando sus columnas y componentes. + +No se modifican APIs, DTO, MariaDB, Sequelize, permisos, compañías, estados, historial, configuraciones de perfiles, operaciones de equipamiento/software, sidebar ni cabecera. No se instalan dependencias o skills. El manifest de Companies únicamente registra la prueba nueva en su suite, además de los cambios previos de la primera iteración. + +Skills utilizadas: `frontend-design` para protagonismo y proporción; `ux-dashboard` para jerarquía y reducción de redundancia; `shadcn` para badges, tooltip y acciones con las APIs Base UI reales; `vercel-composition-patterns` para separar cobertura, preview y nodos; `vercel-react-best-practices` para estabilidad del grafo y datos derivados; `ux-tables` y Compact List para cobertura y salida al conjunto completo; `ui-ux-pro-max` para foco, nombres largos, movimiento reducido y ausencia de overflow; `rdx-solid` para contratos y pruebas geométricas. No se instala un auditor WCAG dedicado ni se sustituye DESIGN por recomendaciones genéricas. + +### Pruebas y resultados de la iteración 2 + +| Comprobación ejecutada | Resultado real | +| --- | --- | +| `pnpm --filter @metis/companies typecheck` | Correcto | +| `pnpm --filter @metis/web typecheck` | Correcto | +| `workplace-map-layout.test.ts`, con Node/tsx | 22 pruebas correctas: tamaños, bounds, coordenadas, no solapamiento, rótulo, software inferior, capacidad, determinismo, límites y cobertura parcial | +| `workstation-detail.test.tsx`, con Node/tsx | 34 pruebas correctas es/en. Se amplía cobertura/leyenda y se añade regresión de traducción de revisión sin referencia navegable | +| Suite dirigida con `node --test` | Seis ficheros correctos: mapa, ficha, dashboard, equipamiento, relaciones y expansión de perfiles | +| Biome `check` del detalle, traducciones es/en de assets y manifest de Companies | Correcto, sin supresiones ni advertencias del cambio | +| ESLint dirigido al detalle de puestos | Correcto | +| `pnpm build` | Correcto para servidor y web. Se mantiene el aviso general de chunk principal grande; el canvas diferido ocupa unos 228 kB / 74 kB gzip | +| Recorrido MCP completo | Siete recorridos correctos: seis anchos, denso, navegación/gestión, errores/vacíos, resize en la misma página, cobertura/estados/refetch y contraste/movimiento reducido | +| Verificación final de revisión | Tras corregir una clave de traducción, dos recorridos adicionales correctos para software/cobertura/refetch y contraste/movimiento reducido, con capturas renovadas | +| `git diff --check` y parseo AST del script Python | Correctos | +| Checks globales | Ejecutados; continúan fallando fuera del alcance, como se detalla a continuación | + +`pnpm typecheck` vuelve a fallar en `packages/shadcn-ui/src/components/calendar.tsx:165`, por la opcionalidad de `locale`. `pnpm lint`/`pnpm check` señalan archivos ajenos al mapa, entre ellos código previo de fotos de personas, migraciones y ejemplos de skills. `pnpm test` se detiene en `modules/resources/src/api/application/resource-scope.test.ts`. La suite completa de Companies pasa el mapa/ficha y sigue fallando en `companies-page.test.tsx`, fuera del cambio. No se modifica vendor ni esos módulos para ocultar los fallos. + +Las mediciones incluyen: + +- Principal sin asociados ni software: **224 px** de canvas y un nodo, frente a los 430 px anteriores. +- Denso: tres nodos visibles de **80 activos**; la acción existente despliega los 80. Hay 60 perfiles sintéticos en ese escenario, 50 activos y 10 archivados. +- Estados de instalación: se conservan las etiquetas canónicas **Configurado**, **Pendiente de configurar** y **Pendiente de retirar**, más **Requiere revisión** cuando corresponde. No se sustituyen por el estado del producto. +- Nombres largos: tooltip abierto mediante Tab/Shift+Tab, nombre completo accesible y tarjetas de software sin overflow en ambos ejes. +- Refetch: **mismo elemento DOM de canvas y misma transformación de cámara**, con cinco nodos de datos retenidos. Al fallar software, esos nodos permanecen y desaparece el total definitivo. +- Contraste de 18 textos del mapa/leyenda en modo claro: mínimo medido **4,83:1**. La medición detectó inicialmente 4,39:1 en el subtipo del principal; se corrigió con `text-secondary`, sin crear otro token ni modificar el tema. +- Teclado: Tab al principal, outline visible, Enter para navegar, Tab/Shift+Tab para nombres largos, Space para contexto remoto y Escape para cerrar software. También se comprueban apertura/cancelación de gestión, expansión de perfiles y retorno de foco. Rótulo y aristas sin tab stop. +- Reducción de movimiento activada y comprobada en navegador; sin añadir animación al grafo. + +### Evidencia comparativa e instalación utilizada + +La auditoría usa el servidor Playwright MCP fijado en el proyecto y el procedimiento de `docs/playwright-mcp.md`, con usuario ordinario, API real e instalación aislada exclusivamente en `metis_desk_test`. Volumen final de ambas preparaciones: 15 compañías y, en la principal, 59 personas, 135 activos y 73 recursos. El escenario de ocho instalaciones, nombres largos y estados variados se simula en la **frontera HTTP del navegador** utilizando recursos sintéticos existentes; no se insertan esas instalaciones ni se modifican datos habituales. Los errores también se simulan en esa frontera. + +Comandos reproducibles, con el preparador y las fixtures documentados abiertos: + +```bash +METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \ +METIS_MAP_CAPTURE_PHASE=before \ + python3 scripts/workplace-visual-map.test.py WorkplaceVisualMapTests.test_map + +METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \ +METIS_MAP_CAPTURE_PHASE=after \ + python3 scripts/workplace-visual-map.test.py +``` + +Capturas normales del mismo conjunto de datos, abiertas e inspeccionadas antes y después: + +| Ancho | Antes | Después | +| --- | --- | --- | +| 1600 px | [Antes](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/before/map-1600.png) | [Después](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/after/map-1600.png) | +| 1440 px | [Antes](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/before/map-1440.png) | [Después](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/after/map-1440.png) | +| 1280 px | [Antes](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/before/map-1280.png) | [Después](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/after/map-1280.png) | +| 1024 px | [Antes](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/before/map-1024.png) | [Después](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/after/map-1024.png) | +| 768 px | [Antes](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/before/map-768.png) | [Después](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/after/map-768.png) | +| 390 px | [Antes](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/before/map-390.png) | [Después](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/after/map-390.png) | + +Evidencia adicional inspeccionada: + +- [Principal sin periféricos ni software](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/after/principal-only.png). +- [Sin software](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/after/without-software.png). +- [Sin principal](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/after/without-principal.png). +- [Denso y cobertura de equipamiento](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/after/dense.png). +- [Error parcial de software](../.artifacts/playwright/authenticated-9iin9foh/workstation-dashboard-wd043/visual-map-iteration-2/after/software-error.png). +- [Cobertura, estados y revisión traducida](../.artifacts/playwright/authenticated-rcm2kou7/workstation-dashboard-wd043/visual-map-iteration-2/after/software-coverage-states.png). +- [Datos retenidos sin total definitivo](../.artifacts/playwright/authenticated-rcm2kou7/workstation-dashboard-wd043/visual-map-iteration-2/after/software-stale-no-total.png). + +Los dos últimos casos se repitieron en una segunda instalación desechable tras detectar y corregir la etiqueta de revisión sin traducir durante la inspección visual. Cada directorio conserva su propio `measurements.json`; no se mezclan sesiones ni se editan imágenes para aparentar un resultado. Ambos preparadores se cerraron y sus sesiones privadas se retiraron, conservando las imágenes excluidas de Git. + +### Diferencias respecto al mockup, límites y mejoras futuras + +La primera banda prioriza la misma jerarquía del mockup, pero mantiene los tokens navy/secondary del sistema, iconos semánticos y badges reales. No utiliza fotos de portátiles ni logos de software inventados. Los asociados representan pertenencia al puesto, no cableado; el software refleja documentación, no ejecución en tiempo real. No se añaden menús decorativos sin una acción existente. + +La leyenda se sitúa debajo para no competir con el nodo principal ni reducir el ancho útil. El reparto bilateral depende de capacidad; las ventanas intermedias conservan una columna de asociados a la izquierda. Se mantienen los límites 2/3 y el resto se consulta en sus colecciones. + +Quedan fuera de la validación lectores de pantalla concretos, ampliación exclusiva de texto al 200 %, alto contraste del sistema y modo oscuro. La medición de textos en claro y las pruebas de teclado no equivalen a certificar WCAG. Una mejora futura puede medir alturas a partir del contenido ampliado y ampliar esa matriz de accesibilidad; esta entrega no añade un motor de layout, virtualización, persistencia o edición de diagramas. diff --git a/docs/workplace-dashboard-implementacion.md b/docs/workplace-dashboard-implementacion.md index 0007e7c7..f3ccce60 100644 --- a/docs/workplace-dashboard-implementacion.md +++ b/docs/workplace-dashboard-implementacion.md @@ -162,7 +162,7 @@ Los siguientes archivos contienen la implementación, sus pruebas o su documenta - `modules/companies/src/web/workstations/detail/ui/workstation-profile-configurations.tsx` (componente histórico sustituido en WD04.2 por [workstation-profile-configuration-details.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-profile-configuration-details.tsx)) - [modules/companies/src/web/workstations/detail/ui/workstation-profiles.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-profiles.tsx) - [modules/companies/src/web/workstations/detail/ui/workstation-query-feedback.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-query-feedback.tsx) -- `modules/companies/src/web/workstations/detail/ui/workstation-responsible.tsx` (componente histórico retirado en WD04.3; su información contextual pasa a [workstation-responsible-context.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-responsible-context.tsx)) +- `modules/companies/src/web/workstations/detail/ui/workstation-responsible.tsx` (componente histórico retirado en WD04.3; su información contextual pasa a [workstation-responsible-context.tsx](../modules/companies/src/web/people/shared/ui/workstation-responsible-context.tsx)) - [modules/companies/src/web/workstations/detail/ui/workstation-software-dialog.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-software-dialog.tsx) - [modules/companies/src/web/workstations/detail/ui/workstation-software.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-software.tsx) - [modules/companies/src/web/workstations/detail/ui/workstation-support.tsx](../modules/companies/src/web/workstations/detail/ui/workstation-support.tsx) @@ -841,7 +841,7 @@ ficha. Sede procede de siteId del puesto; no se obtiene del activo ni de la pers La persona responsable sigue separada de los perfiles y del contexto remoto de Soporte. No se modifican DTOs, relaciones, asignaciones, validaciones de dominio ni persistencia. -`workstationOrganizationalAssignments` deriva las relaciones desde CompanyDTO: +`personOrganizationalAssignments` (compartido en `people/shared/helpers/person-organization.ts`) deriva las relaciones desde CompanyDTO: misma compañía/persona y periodo semiabierto `startDate <= hoy < endDate`, o sin fecha de fin. Conserva el criterio de fecha UTC del resolver anterior. No filtra relaciones vigentes arbitrariamente por el estado actual de departamento/persona: una relación diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index b9ec23bb..e570fd28 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -717,6 +717,23 @@ "stale": "The information could not be updated. Showing the last available data." } }, + "workplaceMap": { + "title": "Workplace visual map", + "description": "View the equipment, peripherals and software associated with this workplace.", + "manage": "Manage equipment", + "legend": "Legend", + "summary": "Workplace summary", + "noSoftware": "No software is documented for the primary computer.", + "noPeripherals": "No other assets are associated with this workplace.", + "mobileHint": "Equipment associated with the workplace and software on the primary computer.", + "allEquipment": "View all equipment", + "hideEquipment": "Hide equipment", + "associatedLegend": "Associated with workplace", + "softwareLegend": "Primary computer software", + "equipmentCoverage": "Showing {{shown}} of {{total}} assets", + "softwareCoverage": "Showing {{shown}} of {{total}} software installations", + "allSoftware": "View all software" + }, "workstationDashboard": { "breadcrumb": "Workstation navigation", "companies": "Companies", diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index 78ed9cd0..d4f98928 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -717,6 +717,23 @@ "stale": "No se pudo actualizar la información. Se muestran los últimos datos disponibles." } }, + "workplaceMap": { + "title": "Mapa visual del puesto", + "description": "Vista de los equipos, periféricos y software asociados a este puesto de trabajo.", + "manage": "Gestionar equipamiento", + "legend": "Leyenda", + "summary": "Resumen del puesto", + "noSoftware": "No hay software documentado para el ordenador principal.", + "noPeripherals": "No hay otros activos asociados a este puesto.", + "mobileHint": "Elementos asociados al puesto y software del ordenador principal.", + "allEquipment": "Ver todo el equipamiento", + "hideEquipment": "Ocultar equipamiento", + "associatedLegend": "Asociado al puesto", + "softwareLegend": "Software del principal", + "equipmentCoverage": "Mostrando {{shown}} de {{total}} activos", + "softwareCoverage": "Mostrando {{shown}} de {{total}} instalaciones de software", + "allSoftware": "Ver todo el software" + }, "workstationDashboard": { "breadcrumb": "Navegación del puesto", "companies": "Compañías", diff --git a/modules/companies/package.json b/modules/companies/package.json index fa77c5b0..3b8ade9d 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -10,18 +10,20 @@ }, "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/web/people/list/people-search.test.tsx src/web/companies/list/company-filters.test.tsx src/web/shared/helpers/company-list-query-state.test.ts 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/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/infrastructure/shared/helpers/printer-usage.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/helpers/workstation-dashboard.test.ts src/web/workstations/detail/helpers/workstation-equipment.test.ts src/web/workstations/detail/helpers/workstation-profile-expansion.test.ts src/web/workstations/detail/helpers/workstation-organization.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 src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx src/api/application/directory-context-plan.test.ts src/web/access/list/helpers/access-environment-filter.test.ts src/api/domain/folder-mapping-account.test.ts src/api/application/local-identity-plan.test.ts src/web/access/list/helpers/access-resource-filter.test.ts src/web/folder-mappings/shared/helpers/folder-account-options.test.ts src/web/local-identity-environments/shared/local-identities-ui.test.tsx" + "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/web/people/list/people-search.test.tsx src/web/companies/list/company-filters.test.tsx src/web/shared/helpers/company-list-query-state.test.ts 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/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/infrastructure/shared/helpers/printer-usage.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/helpers/workplace-map-layout.test.ts src/web/workstations/detail/helpers/workstation-dashboard.test.ts src/web/workstations/detail/helpers/workstation-equipment.test.ts src/web/workstations/detail/helpers/workstation-profile-expansion.test.ts src/web/people/shared/helpers/person-organization.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 src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx src/api/application/directory-context-plan.test.ts src/web/access/list/helpers/access-environment-filter.test.ts src/api/domain/folder-mapping-account.test.ts src/api/application/local-identity-plan.test.ts src/web/access/list/helpers/access-resource-filter.test.ts src/web/folder-mappings/shared/helpers/folder-account-options.test.ts src/web/local-identity-environments/shared/local-identities-ui.test.tsx" }, "dependencies": { - "@metis/core": "workspace:*", + "@dagrejs/dagre": "3.1.2", "@hookform/resolvers": "5.2.2", "@metis/assets": "workspace:*", + "@metis/core": "workspace:*", "@metis/resources": "workspace:*", "@repo/rdx-ddd": "workspace:*", "@repo/rdx-ui": "workspace:*", "@repo/rdx-utils": "workspace:*", "@repo/shadcn-ui": "workspace:*", "@tanstack/react-table": "8.21.3", + "@xyflow/react": "12.12.0", "react-hook-form": "7.72.1", "sequelize": "6.37.8", "sharp": "0.35.5", diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index bc94ddc9..2a295c22 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -1,5 +1,17 @@ { "companies": { + "personContext": { + "open": "View organizational context for {{name}}", + "refreshing": "Refreshing…", + "stale": "The organizational context could not be updated. Previous data is shown.", + "currentAssignments_one": "{{count}} current assignment", + "currentAssignments_other": "{{count}} current assignments", + "primaryAssignment": "Primary", + "department": "Department", + "position": "Position", + "openPerson": "Open person details", + "description": "Person information" + }, "responsiveNavigation": { "hideSections": "Hide sections", "pinSections": "Pin sections", diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index 876f47be..070539a8 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -1,5 +1,17 @@ { "companies": { + "personContext": { + "open": "Consultar contexto organizativo de {{name}}", + "refreshing": "Actualizando…", + "stale": "No se ha podido actualizar el contexto organizativo. Se muestran los datos anteriores.", + "currentAssignments_one": "{{count}} asignación vigente", + "currentAssignments_other": "{{count}} asignaciones vigentes", + "primaryAssignment": "Principal", + "department": "Departamento", + "position": "Cargo", + "openPerson": "Abrir ficha de persona", + "description": "Información de la persona" + }, "responsiveNavigation": { "hideSections": "Ocultar secciones", "pinSections": "Fijar secciones", diff --git a/modules/companies/src/web/people/shared/api/index.ts b/modules/companies/src/web/people/shared/api/index.ts index 1faca664..14ea10d4 100644 --- a/modules/companies/src/web/people/shared/api/index.ts +++ b/modules/companies/src/web/people/shared/api/index.ts @@ -1 +1 @@ -export { deletePerson, savePerson, savePersonPhoto, removePersonPhoto } from "./save-person.api"; +export { deletePerson, removePersonPhoto, savePerson, savePersonPhoto } from "./save-person.api"; diff --git a/modules/companies/src/web/workstations/detail/helpers/workstation-organization.test.ts b/modules/companies/src/web/people/shared/helpers/person-organization.test.ts similarity index 69% rename from modules/companies/src/web/workstations/detail/helpers/workstation-organization.test.ts rename to modules/companies/src/web/people/shared/helpers/person-organization.test.ts index e13bb5db..64b747ee 100644 --- a/modules/companies/src/web/workstations/detail/helpers/workstation-organization.test.ts +++ b/modules/companies/src/web/people/shared/helpers/person-organization.test.ts @@ -6,7 +6,7 @@ import { z } from "zod/v4"; import { CompanySchema } from "../../../../common/dto/shared/company.dto"; -import { workstationOrganizationalAssignments } from "./workstation-organization"; +import { personOrganizationalAssignments } from "./person-organization"; const fixture: unknown = JSON.parse( await readFile( @@ -26,7 +26,7 @@ const personId = assignment.personId; const today = "2026-10-22"; test("organización: resuelve departamento y cargo vigentes del responsable", () => { - const rows = workstationOrganizationalAssignments(company, personId, today); + const rows = personOrganizationalAssignments(company, personId, today); assert.equal(rows.length, 1); assert.equal(rows[0]?.department?.name, "Administración y gestión documental"); @@ -34,9 +34,9 @@ test("organización: resuelve departamento y cargo vigentes del responsable", () }); test("organización: sin responsable o asignación no infiere datos desde perfiles ni cargo genérico de persona", () => { - assert.deepEqual(workstationOrganizationalAssignments(company, null, today), []); + assert.deepEqual(personOrganizationalAssignments(company, null, today), []); assert.deepEqual( - workstationOrganizationalAssignments({ ...company, assignments: [] }, personId, today), + personOrganizationalAssignments({ ...company, assignments: [] }, personId, today), [] ); }); @@ -47,9 +47,9 @@ test("organización: periodos semiabiertos, históricos y futuros", () => { assignments: [{ ...assignment, startDate: "2026-10-22", endDate: "2026-10-23" }], }; - assert.equal(workstationOrganizationalAssignments(source, personId, "2026-10-21").length, 0); - assert.equal(workstationOrganizationalAssignments(source, personId, "2026-10-22").length, 1); - assert.equal(workstationOrganizationalAssignments(source, personId, "2026-10-23").length, 0); + assert.equal(personOrganizationalAssignments(source, personId, "2026-10-21").length, 0); + assert.equal(personOrganizationalAssignments(source, personId, "2026-10-22").length, 1); + assert.equal(personOrganizationalAssignments(source, personId, "2026-10-23").length, 0); }); test("organización: varias vigentes conservan todas, principal primero y orden estable sin mutar", () => { @@ -60,14 +60,14 @@ test("organización: varias vigentes conservan todas, principal primero y orden position: "Otra función", }; const source = { ...company, assignments: [secondary, assignment] }; - const rows = workstationOrganizationalAssignments(source, personId, today); + const rows = personOrganizationalAssignments(source, personId, today); assert.equal(rows.length, 2); assert.equal(rows[0]?.isPrimary, true); assert.equal(rows[1]?.position, "Otra función"); assert.equal(source.assignments[0], secondary); assert.equal( - workstationOrganizationalAssignments( + personOrganizationalAssignments( { ...source, assignments: source.assignments.map((item) => ({ ...item, isPrimary: false })) }, personId, today @@ -80,7 +80,7 @@ test("organización: dos principales no ocultan información seleccionando una a const other = { ...assignment, id: "019a0a00-0000-7000-8000-000000000997" }; assert.equal( - workstationOrganizationalAssignments( + personOrganizationalAssignments( { ...company, assignments: [assignment, other] }, personId, today @@ -93,7 +93,7 @@ test("organización: aislamiento de compañía y persona, y referencias incomple const foreign = "019a0a00-0000-7000-8000-000000000999"; assert.deepEqual( - workstationOrganizationalAssignments( + personOrganizationalAssignments( { ...company, assignments: [{ ...assignment, companyId: foreign }] }, personId, today @@ -101,22 +101,22 @@ test("organización: aislamiento de compañía y persona, y referencias incomple [] ); assert.deepEqual( - workstationOrganizationalAssignments({ ...company, id: foreign }, personId, today), + personOrganizationalAssignments({ ...company, id: foreign }, personId, today), [] ); - assert.deepEqual(workstationOrganizationalAssignments(company, foreign, today), []); + assert.deepEqual(personOrganizationalAssignments(company, foreign, today), []); assert.equal( - workstationOrganizationalAssignments({ ...company, departments: [] }, personId, today)[0] + personOrganizationalAssignments({ ...company, departments: [] }, personId, today)[0] ?.department, undefined ); }); test("organización: cambios y refetch se derivan de la fuente actual", () => { - const before = workstationOrganizationalAssignments(company, personId, today); + const before = personOrganizationalAssignments(company, personId, today); assert.equal( - workstationOrganizationalAssignments( + personOrganizationalAssignments( { ...company, assignments: [{ ...assignment, position: "Cargo actualizado" }] }, personId, today @@ -124,5 +124,5 @@ test("organización: cambios y refetch se derivan de la fuente actual", () => { "Cargo actualizado" ); assert.equal(before[0]?.position, "Responsable de documentación"); - assert.deepEqual(workstationOrganizationalAssignments(company, null, today), []); + assert.deepEqual(personOrganizationalAssignments(company, null, today), []); }); diff --git a/modules/companies/src/web/workstations/detail/helpers/workstation-organization.ts b/modules/companies/src/web/people/shared/helpers/person-organization.ts similarity index 86% rename from modules/companies/src/web/workstations/detail/helpers/workstation-organization.ts rename to modules/companies/src/web/people/shared/helpers/person-organization.ts index 6d1f01d5..77ac00ec 100644 --- a/modules/companies/src/web/workstations/detail/helpers/workstation-organization.ts +++ b/modules/companies/src/web/people/shared/helpers/person-organization.ts @@ -1,7 +1,7 @@ import type { CompanyDTO } from "../../../../common"; -/** Presenta las asignaciones vigentes del responsable sin inferir una única relación. */ -export function workstationOrganizationalAssignments( +/** Presenta las asignaciones vigentes de una persona sin inferir una única relación. */ +export function personOrganizationalAssignments( company: CompanyDTO, personId: string | null, today: string diff --git a/modules/companies/src/web/people/shared/index.ts b/modules/companies/src/web/people/shared/index.ts index 739b52c5..059faaf4 100644 --- a/modules/companies/src/web/people/shared/index.ts +++ b/modules/companies/src/web/people/shared/index.ts @@ -1 +1,7 @@ -export { PersonEditorDialog, PersonForm, PersonAvatar, PersonAvatarGroup } from "./ui"; +export { + PersonAvatar, + PersonAvatarGroup, + PersonEditorDialog, + PersonForm, + WorkstationResponsibleContext, +} from "./ui"; diff --git a/modules/companies/src/web/people/shared/ui/index.ts b/modules/companies/src/web/people/shared/ui/index.ts index 85cb2e39..842f3ca1 100644 --- a/modules/companies/src/web/people/shared/ui/index.ts +++ b/modules/companies/src/web/people/shared/ui/index.ts @@ -2,3 +2,4 @@ export { PersonAvatar } from "./person-avatar"; export { PersonAvatarGroup } from "./person-avatar-group"; export { PersonEditorDialog } from "./person-editor-dialog"; export { PersonForm } from "./person-form"; +export { WorkstationResponsibleContext } from "./workstation-responsible-context"; diff --git a/modules/companies/src/web/people/shared/ui/workstation-responsible-context.tsx b/modules/companies/src/web/people/shared/ui/workstation-responsible-context.tsx new file mode 100644 index 00000000..b5b91a1a --- /dev/null +++ b/modules/companies/src/web/people/shared/ui/workstation-responsible-context.tsx @@ -0,0 +1,219 @@ +import { BrowserLink } from "@metis/core/client"; +import { DetailField } from "@repo/rdx-ui/components/detail-field"; +import { PhoneLink } from "@repo/rdx-ui/components/phone-link"; +import { Badge } from "@repo/shadcn-ui/components/badge"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverTitle, + PopoverTrigger, +} from "@repo/shadcn-ui/components/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components/tooltip"; +import { useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import type { CompanyDTO, PersonDTO } from "../../../../common"; +import { getCompany } from "../../../companies/detail/api"; +import { companyKeys } from "../../../companies/shared/queries"; +import { personOrganizationalAssignments } from "../helpers/person-organization"; + +import { PersonAvatar } from "./person-avatar"; + +export function WorkstationResponsibleContext({ + company, + responsible, + description, + emptyLabel = "—", + presentation = "compact", + children, +}: { + company: CompanyDTO; + responsible: PersonDTO | undefined; + description?: string; + emptyLabel?: string; + presentation?: "compact" | "heading"; + children?: ReactNode; +}) { + const { t } = useTranslation("companies"); + const personDescription = description ?? t("personContext.description"); + const titleRef = useRef(null); + const retryRef = useRef(null); + const recoveryFocus = useRef(false); + // Observa la fuente ya cargada por CompanyDetail; abrir el contexto no inicia otra petición. + const query = useQuery({ + queryKey: companyKeys.detail(company.id), + queryFn: () => getCompany(company.id), + enabled: false, + }); + + // El foco se devuelve después del commit que retira el botón de error. + useEffect(() => { + if (recoveryFocus.current && !query.isFetching) { + recoveryFocus.current = false; + (query.isError ? retryRef.current : titleRef.current)?.focus(); + } + }, [query.isError, query.isFetching]); + + if (!responsible || responsible.companyId !== company.id) { + return ( + + {personDescription}: + {emptyLabel} + + ); + } + + const label = t("personContext.open", { name: responsible.name }); + const assignments = personOrganizationalAssignments( + company, + responsible.id, + new Date().toISOString().slice(0, 10) + ); + const fields = assignments.length + ? assignments + : [{ id: "empty", isPrimary: false, department: undefined, position: null }]; + + return ( + + {personDescription}: + + + + } + /> + } + > + + + {responsible.name} + + + {label} + + +
+ +
+ + {responsible.name} + + {personDescription} +
+
+
+ + {responsible.email ? ( + + {responsible.email} + + ) : ( + "—" + )} + + + {responsible.phone ? : "—"} + +
+ {query.isFetching && ( +

+ {t("personContext.refreshing")} +

+ )} + {query.isError && ( +
+

{t("personContext.stale")}

+ +
+ )} + {assignments.length > 1 && ( +

+ {t("personContext.currentAssignments", { count: assignments.length })} +

+ )} +
+ {fields.map((assignment) => ( +
+ {assignment.isPrimary && ( + {t("personContext.primaryAssignment")} + )} +
+ + {assignment.department ? ( + + {assignment.department.name} + + ) : ( + "—" + )} + + + {assignment.position ?? "—"} + +
+
+ ))} +
+ + {t("personContext.openPerson")} + +
+
+
+ ); +} diff --git a/modules/companies/src/web/processes/detail/types/process-detail-props.ts b/modules/companies/src/web/processes/detail/types/process-detail-props.ts index d0df9c1a..d0bb4c3f 100644 --- a/modules/companies/src/web/processes/detail/types/process-detail-props.ts +++ b/modules/companies/src/web/processes/detail/types/process-detail-props.ts @@ -1,8 +1,9 @@ +import type { CompanyDTO } from "../../../../common"; import type { PersonProcessDTO } from "../../../../common/processes"; export type ProcessDetailProps = { process: PersonProcessDTO; - person: string; + company: CompanyDTO; onDelete?: (id: string) => void; onDeleted?: () => void; }; diff --git a/modules/companies/src/web/processes/detail/ui/process-detail.tsx b/modules/companies/src/web/processes/detail/ui/process-detail.tsx index edc3e249..7de89b5e 100644 --- a/modules/companies/src/web/processes/detail/ui/process-detail.tsx +++ b/modules/companies/src/web/processes/detail/ui/process-detail.tsx @@ -3,6 +3,7 @@ import { Card, CardContent } from "@repo/shadcn-ui/components/card"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { WorkstationResponsibleContext } from "../../../people/shared"; import { ProcessFeedback } from "../../shared"; import { processReferenceTitle, processTaskTitle } from "../../shared/helpers"; import { ProcessEditDialog } from "../../update"; @@ -10,7 +11,10 @@ import { useProcessDetailController } from "../controllers"; import type { ProcessDetailProps } from "../types"; export function ProcessDetail(props: ProcessDetailProps) { - const { process, person, onDelete, onDeleted } = props; + const { process, company, onDelete, onDeleted } = props; + const person = company.people.find( + (item) => item.id === process.values.personId && item.companyId === company.id + ); const { edit, setEdit, refresh, open, resolved } = useProcessDetailController(props); const { t, i18n } = useTranslation("companies"); const [deleting, setDeleting] = useState(false); @@ -20,9 +24,9 @@ export function ProcessDetail(props: ProcessDetailProps) {

{process.values.name}

+

- {person} · {t(`processes.${process.values.kind}`)} ·{" "} - {t(`processes.${process.values.state}`)} + {t(`processes.${process.values.kind}`)} · {t(`processes.${process.values.state}`)}

{t("processes.progress", { resolved, total: process.values.tasks.length })} diff --git a/modules/companies/src/web/processes/list/ui/processes-page.tsx b/modules/companies/src/web/processes/list/ui/processes-page.tsx index 5a1d4093..225d4e25 100644 --- a/modules/companies/src/web/processes/list/ui/processes-page.tsx +++ b/modules/companies/src/web/processes/list/ui/processes-page.tsx @@ -74,9 +74,9 @@ export function ProcessesPage(props: ProcessesPageProps) { )} {current && !query.isError ? ( remove.mutate(id)} onDeleted={() => setSelected(undefined)} - person={person(current.values.personId)} process={current} /> ) : ( diff --git a/modules/companies/src/web/processes/shared/processes.test.tsx b/modules/companies/src/web/processes/shared/processes.test.tsx index 8c45c2f1..4813e48f 100644 --- a/modules/companies/src/web/processes/shared/processes.test.tsx +++ b/modules/companies/src/web/processes/shared/processes.test.tsx @@ -21,7 +21,18 @@ const company = { id: "0199b200-0000-7000-8000-000000000001", name: "Empresa", active: true, - people: [], + people: [ + { + id: "0199b200-0000-7000-8000-000000000001", + companyId: "0199b200-0000-7000-8000-000000000001", + name: "Ana", + active: true, + isContact: false, + position: null, + email: null, + phone: null, + }, + ], contacts: [], sites: [], departments: [], @@ -201,8 +212,13 @@ test("seguimiento: origen personal, revisión actual y edición sin afirmar reti for (const language of ["es", "en"]) { const text = language === "es" ? companiesSpanish : companiesEnglish; - const html = await render(language, ); + const html = await render(language, ); + const header = html.slice(html.indexOf("")); + + assert.ok(header.includes('aria-haspopup="dialog"')); + assert.ok(header.includes('data-slot="avatar"')); + assert.ok(header.includes(text.personContext.open.replace("{{name}}", "Ana"))); assert.ok(html.includes(text.remote.personal_mobile)); assert.ok(html.includes(text.processes.stillRecorded)); assert.ok( @@ -220,7 +236,7 @@ test("seguimiento: origen personal, revisión actual y edición sin afirmar reti const closed = await render( language, ); diff --git a/modules/companies/src/web/work-environments/detail/ui/environment-content.tsx b/modules/companies/src/web/work-environments/detail/ui/environment-content.tsx index 5245a6a6..2079decd 100644 --- a/modules/companies/src/web/work-environments/detail/ui/environment-content.tsx +++ b/modules/companies/src/web/work-environments/detail/ui/environment-content.tsx @@ -13,6 +13,7 @@ import type { CompanyDTO, WorkProfileDTO } from "../../../../common"; import type { RemoteConnectionDTO } from "../../../../common/remote-connections"; import type { TeleworkAuthorizationDTO } from "../../../../common/telework-authorizations"; import { accessLabel } from "../../../access/shared/helpers"; +import { WorkstationResponsibleContext } from "../../../people/shared"; import { RemoteSummary } from "../../../remote-connections"; import { TeleworkAuthorizationSummary } from "../../../telework-authorizations"; import type { EnvironmentSources, ProfileConfigurationKind } from "../../shared/helpers"; @@ -86,8 +87,9 @@ export function EnvironmentContent({

- {t("environment.title")} · {person?.name ?? profile.values.name} + {t("environment.title")} · {profile.values.name}

+ {person && }

{profile.values.name} · {model.host?.values.name ?? profile.values.assetId}

diff --git a/modules/companies/src/web/work-environments/shared/work-environment.test.tsx b/modules/companies/src/web/work-environments/shared/work-environment.test.tsx index 5d5af15e..c9f27bee 100644 --- a/modules/companies/src/web/work-environments/shared/work-environment.test.tsx +++ b/modules/companies/src/web/work-environments/shared/work-environment.test.tsx @@ -41,7 +41,18 @@ const company = { id: id(1), name: "Empresa test", active: true, - people: [], + people: [ + { + id: id(4), + companyId: id(1), + name: "Ana", + active: true, + isContact: false, + position: null, + email: null, + phone: null, + }, + ], contacts: [], sites: [], departments: [], @@ -422,6 +433,12 @@ test("entorno: ficha es/en con estados y acciones, sin formularios; muestra fals const text = language === "es" ? companiesSpanish : companiesEnglish; assert.ok(html.includes(text.environment.title)); + const header = html.slice(html.indexOf("")); + + assert.ok(header.includes('aria-haspopup="dialog"')); + assert.ok(header.includes('data-slot="avatar"')); + assert.ok(header.includes(text.personContext.open.replace("{{name}}", "Ana"))); + assert.equal(header.includes("Responsable del puesto"), false); assert.ok(html.includes(text.environment.onlyAttention)); assert.ok(html.includes(text.environment.samePerson)); const headings = [ diff --git a/modules/companies/src/web/workstations/detail/controllers/use-workplace-map-model.ts b/modules/companies/src/web/workstations/detail/controllers/use-workplace-map-model.ts new file mode 100644 index 00000000..e68ae1b6 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/controllers/use-workplace-map-model.ts @@ -0,0 +1,44 @@ +import { useMemo } from "react"; + +import { workplaceMapModel } from "../helpers/workplace-map-model"; + +import type { WorkstationDetailController } from "./use-workstation-detail-controller"; + +/** Mantiene estable el grafo ante cambios ajenos al mapa y resuelve enlaces fuera de los nodos. */ +export function useWorkplaceMapModel(companyId: string, c: WorkstationDetailController) { + const { workstation } = c; + const inventory = c.inventory.data; + const catalog = c.catalog.data; + const installations = c.software.query.data; + const references = c.software.references.data; + const resources = c.configurationQueries.resources.data; + + return useMemo( + () => + workstation + ? workplaceMapModel({ + companyId, + workstation, + assets: inventory?.assets ?? [], + catalog: catalog?.items ?? [], + installations: installations?.configurations ?? [], + softwareReference: (id) => { + const global = references?.find((item) => item.id === id && item.scope === "global"); + const local = resources?.resources.find( + (item) => item.id === id && item.companyId === companyId + ); + + return { + name: global?.name ?? local?.values.name ?? "—", + href: global + ? `/catalogs/global-applications/${id}` + : local + ? `/companies/${companyId}/resources/${id}` + : undefined, + }; + }, + }) + : undefined, + [companyId, workstation, inventory, catalog, installations, references, resources] + ); +} diff --git a/modules/companies/src/web/workstations/detail/controllers/use-workstation-detail-controller.ts b/modules/companies/src/web/workstations/detail/controllers/use-workstation-detail-controller.ts index 40f7181c..a8fff7fd 100644 --- a/modules/companies/src/web/workstations/detail/controllers/use-workstation-detail-controller.ts +++ b/modules/companies/src/web/workstations/detail/controllers/use-workstation-detail-controller.ts @@ -9,6 +9,7 @@ import { workstationDashboard, workstationPendingCount } from "../helpers/workst import { useWorkstationConfigurationQueries } from "./use-workstation-configuration-queries"; import { useWorkstationProfileExpansion } from "./use-workstation-profile-expansion"; +import { useWorkstationSoftware } from "./use-workstation-software"; export function useWorkstationDetailController(company: CompanyDTO, workstationId: string) { const [creatingProfile, setCreatingProfile] = useState(false); @@ -37,6 +38,7 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI enabled: Boolean(computer), }); const configurationQueries = useWorkstationConfigurationQueries(company.id, Boolean(computer)); + const software = useWorkstationSoftware(company.id, computer?.id, configurationQueries.resources); const [activeTab, setActiveTab] = useState<"information" | "history">("information"); const dashboard = workstation ? workstationDashboard( @@ -77,6 +79,7 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI return { dashboard, + software, catalog, configurationQueries, sources, diff --git a/modules/companies/src/web/workstations/detail/controllers/use-workstation-software.ts b/modules/companies/src/web/workstations/detail/controllers/use-workstation-software.ts new file mode 100644 index 00000000..b7b5267a --- /dev/null +++ b/modules/companies/src/web/workstations/detail/controllers/use-workstation-software.ts @@ -0,0 +1,45 @@ +import { useListQuery } from "@metis/core/client"; +import { useSoftwareReferences } from "@metis/resources/client"; + +import { softwareApi, softwareKeys } from "../../../software-installations/shared/api/software-api"; + +import type { useWorkstationConfigurationQueries } from "./use-workstation-configuration-queries"; + +/** Una lectura compartida por el mapa y la gestión; conserva referencias globales y estados parciales. */ +export function useWorkstationSoftware( + companyId: string, + computerId: string | undefined, + resources: ReturnType["resources"] +) { + const query = useListQuery({ + queryKey: softwareKeys.company(companyId), + queryFn: () => softwareApi.get(companyId), + enabled: Boolean(computerId), + }); + const items = + query.data?.configurations.filter( + (item) => + item.companyId === companyId && + item.values.assetId === computerId && + !["removed", "never_configured"].includes(item.values.state) + ) ?? []; + const references = useSoftwareReferences( + companyId, + resources.data + ? items + .filter( + (item) => + !resources.data.resources.some((resource) => resource.id === item.values.resourceId) + ) + .map((item) => item.values.resourceId) + : [] + ); + + const globalReference = (id: string) => + references.references.find((item) => item.id === id && item.scope === "global"); + + const resource = (id: string) => + resources.data?.resources.find((item) => item.id === id && item.companyId === companyId); + + return { query, items, references, globalReference, resource }; +} diff --git a/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.test.ts b/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.test.ts new file mode 100644 index 00000000..ef09d752 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.test.ts @@ -0,0 +1,190 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { workplaceMapLayout } from "./workplace-map-layout"; +import type { WorkplaceMapNode, workplaceMapModel } from "./workplace-map-model"; +import { workplaceMapPreview } from "./workplace-map-preview"; + +function mapFixture( + peripherals: number, + software: number, + withPrincipal = true +): ReturnType { + const equipment: WorkplaceMapNode[] = Array.from( + { length: peripherals + Number(withPrincipal) }, + (_, index) => ({ + id: `asset:${index}`, + type: "equipment", + position: { x: 0, y: 0 }, + data: { + name: `Equipo ${index} — documentación y atención técnica`, + href: `/assets/${index}`, + subtitle: "Portátil", + icon: "laptop", + principal: withPrincipal && index === 0, + status: "operational", + assigned: true, + }, + }) + ); + const principal = equipment.find((node) => node.type === "equipment" && node.data.principal); + const installations: WorkplaceMapNode[] = withPrincipal + ? Array.from({ length: software }, (_, index) => ({ + id: `software:${index}`, + type: "software", + position: { x: 0, y: 0 }, + data: { + name: `Software ${index}`, + href: `/resources/${index}`, + state: index % 2 ? "pending" : "configured", + needsReview: index % 3 === 0, + }, + })) + : []; + + return { + equipment, + software: installations, + principal, + principalState: withPrincipal ? "ready" : "missing", + }; +} + +function assertNoOverlap(nodes: ReturnType["nodes"]) { + for (const [index, first] of nodes.entries()) { + for (const second of nodes.slice(index + 1)) { + assert.ok( + first.position.x + first.width <= second.position.x || + second.position.x + second.width <= first.position.x || + first.position.y + first.height <= second.position.y || + second.position.y + second.height <= first.position.y, + `${first.id} y ${second.id} no se solapan` + ); + } + } +} + +for (const width of [640, 748, 900, 1200]) { + for (const scenario of [ + { peripherals: 0, software: 0 }, + { peripherals: 0, software: 3 }, + { peripherals: 1, software: 0 }, + { peripherals: 1, software: 2 }, + { peripherals: 2, software: 3 }, + ]) { + test(`layout ${width}px: ${scenario.peripherals} asociados y ${scenario.software} instalaciones`, () => { + const model = mapFixture(scenario.peripherals, scenario.software); + const preview = workplaceMapPreview(model, { equipmentKnown: true, softwareKnown: true }); + const layout = workplaceMapLayout(preview.nodes, width); + const principal = layout.nodes.find((node) => node.id === "asset:0"); + + assert.ok(principal); + assert.deepEqual(workplaceMapLayout(preview.nodes, width), layout); + assert.ok(layout.canvasHeight > 0 && layout.canvasHeight <= 500); + assertNoOverlap(layout.nodes); + for (const node of layout.nodes) { + assert.ok(node.width > 0 && node.height > 0); + assert.ok(Number.isFinite(node.position.x) && Number.isFinite(node.position.y)); + assert.ok(node.position.x >= 0 && node.position.y >= 0); + assert.ok(node.position.x + node.width <= layout.bounds.width); + assert.ok(node.position.y + node.height <= layout.bounds.height); + if (node.type === "equipment" && !node.data.principal) { + assert.ok(principal.width > node.width && principal.height > node.height); + } + } + const heading = layout.nodes.find((node) => node.type === "softwareHeading"); + + assert.equal(Boolean(heading), Boolean(scenario.software)); + if (heading) { + assert.equal(heading.focusable, false); + assert.ok(heading.position.y > principal.position.y + principal.height); + assert.ok( + layout.nodes + .filter((node) => node.type === "software") + .every((node) => node.position.y > heading.position.y + heading.height) + ); + } + assert.equal(layout.edges.length, scenario.peripherals + scenario.software); + assert.ok( + layout.edges.every((edge) => edge.source === principal.id && edge.type === "relationship") + ); + assert.ok(model.equipment.every((node) => node.position.x === 0 && node.position.y === 0)); + }); + } +} + +test("layout: dos asociados se reparten por capacidad; sin principal no se fabrican conexiones", () => { + const model = mapFixture(2, 3); + const nodes = workplaceMapPreview(model, { equipmentKnown: true, softwareKnown: true }).nodes; + const wide = workplaceMapLayout(nodes, 900); + const narrow = workplaceMapLayout(nodes, 640); + + assert.deepEqual( + wide.edges.filter((edge) => edge.data?.kind === "equipment").map((edge) => edge.sourceHandle), + ["equipment-left", "equipment-right"] + ); + assert.ok( + narrow.edges + .filter((edge) => edge.data?.kind === "equipment") + .every((edge) => edge.sourceHandle === "equipment-left") + ); + const missing = workplaceMapLayout(mapFixture(2, 0, false).equipment); + + assert.equal(missing.edges.length, 0); + assert.equal( + missing.nodes.some((node) => node.type === "softwareHeading"), + false + ); + const empty = workplaceMapLayout([]); + const single = workplaceMapLayout(mapFixture(0, 0).equipment); + + assert.deepEqual(empty.nodes, []); + assert.deepEqual(empty.edges, []); + assert.ok(single.canvasHeight < wide.canvasHeight); +}); + +test("preview: conserva límites y publica solo cobertura real de fuentes conocidas", () => { + for (const [peripherals, software] of [ + [0, 0], + [2, 3], + [11, 8], + ]) { + const model = mapFixture(peripherals ?? 0, software ?? 0); + const preview = workplaceMapPreview(model, { equipmentKnown: true, softwareKnown: true }); + + assert.equal( + preview.nodes.filter((node) => node.type === "equipment").length, + 1 + Math.min(2, peripherals ?? 0) + ); + assert.equal( + preview.nodes.filter((node) => node.type === "software").length, + Math.min(3, software ?? 0) + ); + assert.equal( + preview.nodes.filter((node) => node.type === "equipment" && node.data.principal).length, + 1 + ); + if ((peripherals ?? 0) > 2) { + assert.deepEqual(preview.equipmentCoverage, { shown: 3, total: 12 }); + assert.deepEqual(preview.softwareCoverage, { shown: 3, total: 8 }); + } else { + assert.equal(preview.equipmentCoverage, undefined); + assert.equal(preview.softwareCoverage, undefined); + } + const partial = workplaceMapPreview(model, { equipmentKnown: false, softwareKnown: false }); + + assert.deepEqual(partial.nodes, preview.nodes); + assert.equal(partial.equipmentCoverage, undefined); + assert.equal(partial.softwareCoverage, undefined); + } + const missing = workplaceMapPreview(mapFixture(12, 0, false), { + equipmentKnown: true, + softwareKnown: true, + }); + + assert.deepEqual(missing.equipmentCoverage, { shown: 2, total: 12 }); + assert.deepEqual( + workplaceMapPreview(undefined, { equipmentKnown: false, softwareKnown: false }).nodes, + [] + ); +}); diff --git a/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.ts b/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.ts new file mode 100644 index 00000000..df38d676 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/helpers/workplace-map-layout.ts @@ -0,0 +1,159 @@ +import dagre from "@dagrejs/dagre"; +import type { Edge, Node } from "@xyflow/react"; +import { Position } from "@xyflow/react"; + +import type { WorkplaceMapNode } from "./workplace-map-model"; + +export const principalSize = { width: 264, height: 192 }; + +export const equipmentSize = { width: 212, height: 120 }; + +export const softwareSize = { width: 204, height: 132 }; + +const softwareHeadingSize = { width: 280, height: 28 }; + +type WorkplaceLayoutNode = (WorkplaceMapNode | Node, "softwareHeading">) & { + width: number; + height: number; +}; + +function nodeSize(node: WorkplaceMapNode) { + return node.type === "equipment" + ? node.data.principal + ? principalSize + : equipmentSize + : softwareSize; +} + +function equipmentLayout( + equipment: WorkplaceMapNode[], + principal: WorkplaceMapNode | undefined, + availableWidth: number +) { + const peripherals = equipment.filter((node) => node.id !== principal?.id); + const lateral = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + + lateral.setGraph({ rankdir: "LR", nodesep: 20, ranksep: 36 }); + for (const node of equipment) { + lateral.setNode(node.id, { ...nodeSize(node) }); + } + if (principal) { + for (const [index, node] of peripherals.entries()) { + if (availableWidth >= 820 && index % 2 === 1) { + lateral.setEdge(principal.id, node.id); + } else { + lateral.setEdge(node.id, principal.id); + } + } + } + dagre.layout(lateral); + + return lateral; +} + +/** Dagre distribuye equipamiento y software; el rótulo y la cámara se derivan de sus bounds. */ +export function workplaceMapLayout(nodes: WorkplaceMapNode[], availableWidth = 900) { + const principal = nodes.find((node) => node.type === "equipment" && node.data.principal); + const equipment = nodes.filter((node) => node.type === "equipment"); + const software = nodes.filter((node) => node.type === "software"); + const lateral = equipmentLayout(equipment, principal, availableWidth); + const lower = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + + lower.setGraph({ rankdir: "TB", nodesep: 16, ranksep: 68 }); + if (principal) { + lower.setNode(principal.id, { ...principalSize }); + } + for (const node of software) { + lower.setNode(node.id, { ...softwareSize }); + if (principal) { + lower.setEdge(principal.id, node.id); + } + } + dagre.layout(lower); + const anchor = principal ? lateral.node(principal.id) : undefined; + const softwareAnchor = principal ? lower.node(principal.id) : undefined; + const equipmentBottom = Math.max( + 0, + ...equipment.map((node) => lateral.node(node.id).y + nodeSize(node).height / 2) + ); + const softwareTop = equipmentBottom + 20 + softwareHeadingSize.height + 20; + const lowerTop = Math.min( + ...software.map((node) => lower.node(node.id).y - softwareSize.height / 2) + ); + const positioned: WorkplaceLayoutNode[] = nodes.map((node) => { + const size = nodeSize(node); + const equipmentNode = node.type === "equipment"; + const position = equipmentNode ? lateral.node(node.id) : lower.node(node.id); + const x = + position.x + + (!equipmentNode && anchor && softwareAnchor ? anchor.x - softwareAnchor.x : 0) - + size.width / 2; + const y = equipmentNode + ? position.y - size.height / 2 + : position.y - size.height / 2 - lowerTop + softwareTop; + + return { + ...node, + ...size, + position: { x, y }, + targetPosition: + equipmentNode && anchor && position.x > anchor.x + ? Position.Left + : equipmentNode + ? Position.Right + : Position.Top, + }; + }); + + if (anchor && software.length > 0) { + positioned.push({ + id: "presentation:software-heading", + type: "softwareHeading", + data: {}, + position: { x: anchor.x - softwareHeadingSize.width / 2, y: equipmentBottom + 20 }, + ...softwareHeadingSize, + focusable: false, + selectable: false, + draggable: false, + ariaRole: "presentation", + }); + } + const left = Math.min(0, ...positioned.map((node) => node.position.x)); + const top = Math.min(0, ...positioned.map((node) => node.position.y)); + const normalized = positioned.map((node) => ({ + ...node, + position: { x: node.position.x - left, y: node.position.y - top }, + })); + const bounds = { + x: 0, + y: 0, + width: Math.max(0, ...normalized.map((node) => node.position.x + node.width)), + height: Math.max(0, ...normalized.map((node) => node.position.y + node.height)), + }; + const edges: Edge[] = principal + ? nodes + .filter((node) => node.id !== principal.id) + .map((node) => ({ + id: `${principal.id}:${node.id}`, + source: principal.id, + target: node.id, + sourceHandle: + node.type === "software" + ? "software" + : lateral.node(node.id).x > lateral.node(principal.id).x + ? "equipment-right" + : "equipment-left", + type: "relationship", + data: { kind: node.type }, + selectable: false, + })) + : []; + const scale = bounds.width ? Math.min(1, (availableWidth - 32) / bounds.width) : 1; + + return { + nodes: normalized, + edges, + bounds, + canvasHeight: nodes.length ? Math.ceil(bounds.height * scale) + 32 : 0, + }; +} diff --git a/modules/companies/src/web/workstations/detail/helpers/workplace-map-model.ts b/modules/companies/src/web/workstations/detail/helpers/workplace-map-model.ts new file mode 100644 index 00000000..17b00941 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/helpers/workplace-map-model.ts @@ -0,0 +1,86 @@ +import type { AssetDTO, CatalogItemDTO, WorkstationDTO } from "@metis/assets/common"; +import type { Node } from "@xyflow/react"; + +import type { SoftwareInstallationDTO } from "../../../../common/software-installations"; + +import { workstationEquipment } from "./workstation-equipment"; + +export type WorkplaceEquipmentData = { + name: string; + href: string | undefined; + subtitle: string; + icon: Extract["icon"]; + principal: boolean; + status: AssetDTO["values"]["status"] | undefined; + assigned: boolean; +}; + +export type WorkplaceSoftwareData = { + name: string; + href: string | undefined; + state: SoftwareInstallationDTO["values"]["state"]; + needsReview: boolean; +}; + +export type WorkplaceMapNode = + | Node + | Node; + +/** Solo representa miembros del puesto y software de su principal validado; no infiere conexiones físicas. */ +export function workplaceMapModel({ + companyId, + workstation, + assets, + catalog, + installations, + softwareReference, +}: { + companyId: string; + workstation: WorkstationDTO; + assets: readonly AssetDTO[]; + catalog: readonly CatalogItemDTO[]; + installations: readonly SoftwareInstallationDTO[]; + softwareReference: (id: string) => { name: string; href: string | undefined }; +}) { + const equipment = workstationEquipment(companyId, workstation, assets); + const nodes: WorkplaceMapNode[] = equipment.members.map(({ id, asset, principal }) => { + const template = catalog.find((item) => item.id === asset?.values.templateId); + + return { + id: `asset:${id}`, + type: "equipment", + position: { x: 0, y: 0 }, + data: { + name: asset?.values.name ?? "—", + subtitle: asset?.specification.templateName ?? "—", + href: asset ? `/companies/${companyId}/assets/${id}` : undefined, + icon: template?.values.kind === "template" ? template.values.icon : "box", + principal, + status: asset?.values.status, + assigned: asset?.assigned ?? false, + }, + }; + }); + const principal = nodes.find((node) => node.type === "equipment" && node.data.principal); + const software: WorkplaceMapNode[] = principal + ? installations + .filter( + (item) => + item.companyId === companyId && + `asset:${item.values.assetId}` === principal.id && + !["removed", "never_configured"].includes(item.values.state) + ) + .map((item) => ({ + id: `software:${item.id}`, + type: "software", + position: { x: 0, y: 0 }, + data: { + ...softwareReference(item.values.resourceId), + state: item.values.state, + needsReview: item.needsReview, + }, + })) + : []; + + return { equipment: nodes, software, principal, principalState: equipment.principalState }; +} diff --git a/modules/companies/src/web/workstations/detail/helpers/workplace-map-preview.ts b/modules/companies/src/web/workstations/detail/helpers/workplace-map-preview.ts new file mode 100644 index 00000000..d77d8205 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/helpers/workplace-map-preview.ts @@ -0,0 +1,26 @@ +import type { workplaceMapModel } from "./workplace-map-model"; + +/** Conserva los límites de consulta y solo publica cobertura con una fuente conocida. */ +export function workplaceMapPreview( + model: ReturnType | undefined, + availability: { equipmentKnown: boolean; softwareKnown: boolean } +) { + const peripherals = model?.equipment.filter((node) => node.id !== model.principal?.id) ?? []; + const equipment = [...(model?.principal ? [model.principal] : []), ...peripherals.slice(0, 2)]; + const software = model?.software.slice(0, 3) ?? []; + const equipmentTotal = model?.equipment.length ?? 0; + const softwareTotal = model?.software.length ?? 0; + + return { + peripherals, + nodes: [...equipment, ...software], + equipmentCoverage: + availability.equipmentKnown && equipmentTotal > equipment.length + ? { shown: equipment.length, total: equipmentTotal } + : undefined, + softwareCoverage: + availability.softwareKnown && softwareTotal > software.length + ? { shown: software.length, total: softwareTotal } + : undefined, + }; +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-compact-map.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-compact-map.tsx new file mode 100644 index 00000000..dbf1a848 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-compact-map.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from "react-i18next"; + +import type { WorkplaceMapNode } from "../helpers/workplace-map-model"; + +import { WorkplaceEquipmentCard } from "./workplace-equipment-card"; +import { WorkplaceSoftwareCard } from "./workplace-software-card"; + +/** Alternativa de lectura sin escalado: conserva las mismas identidades y enlaces del canvas. */ +export function WorkplaceCompactMap({ nodes }: { nodes: readonly WorkplaceMapNode[] }) { + const { t } = useTranslation("assets"); + const principal = nodes.find((node) => node.type === "equipment" && node.data.principal); + const peripherals = nodes.filter( + (node) => node.type === "equipment" && node.id !== principal?.id + ); + const software = nodes.filter((node) => node.type === "software"); + + return ( +
+ {principal?.type === "equipment" && } + {peripherals.length > 0 && ( + <> +

{t("workplaceMap.mobileHint")}

+
+ {peripherals.map( + (node) => + node.type === "equipment" && ( + + ) + )} +
+ + )} + {software.length > 0 && ( + <> +

{t("workstationDashboard.software")}

+
+ {software.map( + (node) => + node.type === "software" && + )} +
+ + )} +
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-equipment-card.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-equipment-card.tsx new file mode 100644 index 00000000..9e259ce3 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-equipment-card.tsx @@ -0,0 +1,94 @@ +import { AssetTemplateIcon } from "@metis/assets/client"; +import { BrowserLink } from "@metis/core/client"; +import { Badge } from "@repo/shadcn-ui/components/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components/tooltip"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { useTranslation } from "react-i18next"; + +import type { WorkplaceEquipmentData } from "../helpers/workplace-map-model"; + +export function WorkplaceEquipmentCard({ data }: { data: WorkplaceEquipmentData }) { + const { t } = useTranslation("assets"); + + return ( +
+
+ +
+ + {data.href ? data.name : t("workstationDetail.missingAsset")} + +
+ + {data.subtitle} + + {data.principal && ( + {t("workstationDashboard.principal")} + )} +
+
+
+
+ {data.status && ( + <> + + {t(data.status)} + + + {t(data.assigned ? "in_use" : "workstationDashboard.unassigned")} + + + )} +
+ {data.href && ( + + } + /> + +

+ {data.name} +
+ {data.subtitle} +

+
+
+ )} +
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-equipment-node.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-equipment-node.tsx new file mode 100644 index 00000000..2e105d6a --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-equipment-node.tsx @@ -0,0 +1,50 @@ +import type { Node, NodeProps } from "@xyflow/react"; +import { Handle, Position } from "@xyflow/react"; + +import type { WorkplaceEquipmentData } from "../helpers/workplace-map-model"; + +import { WorkplaceEquipmentCard } from "./workplace-equipment-card"; + +export function WorkplaceEquipmentNode({ + data, + targetPosition, +}: NodeProps>) { + return ( +
+ {!data.principal && ( + + )} + {data.principal && ( + <> + + + + + )} + +
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-map-canvas.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-map-canvas.tsx new file mode 100644 index 00000000..dfb1c84c --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-map-canvas.tsx @@ -0,0 +1,69 @@ +import "@xyflow/react/dist/style.css"; + +import { ReactFlow } from "@xyflow/react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { workplaceMapLayout } from "../helpers/workplace-map-layout"; +import type { WorkplaceMapNode } from "../helpers/workplace-map-model"; + +import { WorkplaceEquipmentNode } from "./workplace-equipment-node"; +import { WorkplaceRelationshipEdge } from "./workplace-relationship-edge"; +import { WorkplaceSoftwareHeadingNode } from "./workplace-software-heading-node"; +import { WorkplaceSoftwareNode } from "./workplace-software-node"; + +const nodeTypes = { + equipment: WorkplaceEquipmentNode, + software: WorkplaceSoftwareNode, + softwareHeading: WorkplaceSoftwareHeadingNode, +}; +const edgeTypes = { relationship: WorkplaceRelationshipEdge }; + +export default function WorkplaceMapCanvas({ + nodes, + availableWidth, +}: { + nodes: WorkplaceMapNode[]; + availableWidth: number; +}) { + const { t } = useTranslation("assets"); + const layout = useMemo(() => workplaceMapLayout(nodes, availableWidth), [nodes, availableWidth]); + + const geometry = layout.nodes + .map((node) => `${node.id}:${node.position.x}:${node.position.y}:${node.width}:${node.height}`) + .join("|"); + + return ( +
+ +
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-map-coverage.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-map-coverage.tsx new file mode 100644 index 00000000..d7c3516c --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-map-coverage.tsx @@ -0,0 +1,51 @@ +import { Button } from "@repo/shadcn-ui/components/button"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import type { workplaceMapPreview } from "../helpers/workplace-map-preview"; + +/** Cobertura del resumen y salida a las colecciones existentes; los errores no publican totales. */ +export function WorkplaceMapCoverage({ + equipment, + software, + equipmentAction, + onViewSoftware, +}: { + equipment: ReturnType["equipmentCoverage"]; + software: ReturnType["softwareCoverage"]; + equipmentAction: ReactNode; + onViewSoftware: () => void; +}) { + const { t } = useTranslation("assets"); + + return ( +
+
+ {equipment && ( +

+ {t("workplaceMap.equipmentCoverage", equipment)} +

+ )} + {equipmentAction} +
+ {software && ( +
+

+ {t("workplaceMap.softwareCoverage", software)} +

+ +
+ )} +
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-map-legend.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-map-legend.tsx new file mode 100644 index 00000000..40a2b973 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-map-legend.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from "react-i18next"; + +export function WorkplaceMapLegend() { + const { t } = useTranslation("assets"); + + return ( + + ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-map-styles.d.ts b/modules/companies/src/web/workstations/detail/ui/workplace-map-styles.d.ts new file mode 100644 index 00000000..24731166 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-map-styles.d.ts @@ -0,0 +1 @@ +declare module "@xyflow/react/dist/style.css"; diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-relationship-edge.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-relationship-edge.tsx new file mode 100644 index 00000000..0eee72a2 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-relationship-edge.tsx @@ -0,0 +1,20 @@ +import type { EdgeProps } from "@xyflow/react"; +import { BaseEdge, getBezierPath } from "@xyflow/react"; + +export function WorkplaceRelationshipEdge(props: EdgeProps) { + const [path] = getBezierPath(props); + + return ( + + ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-software-card.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-software-card.tsx new file mode 100644 index 00000000..cd3638da --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-software-card.tsx @@ -0,0 +1,52 @@ +import { BrowserLink } from "@metis/core/client"; +import { LayersIcon } from "@repo/rdx-ui/lib/icons"; +import { Badge } from "@repo/shadcn-ui/components/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components/tooltip"; +import { useTranslation } from "react-i18next"; + +import type { WorkplaceSoftwareData } from "../helpers/workplace-map-model"; + +export function WorkplaceSoftwareCard({ data }: { data: WorkplaceSoftwareData }) { + const { t } = useTranslation("companies"); + + return ( +
+
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-software-heading-node.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-software-heading-node.tsx new file mode 100644 index 00000000..dd9ef77b --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-software-heading-node.tsx @@ -0,0 +1,14 @@ +import { useTranslation } from "react-i18next"; + +/** Rótulo del grupo, sin identidad de recurso, handles ni interacción. */ +export function WorkplaceSoftwareHeadingNode() { + const { t } = useTranslation("assets"); + + return ( +
+ + {t("workstationDashboard.software")} + +
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-software-node.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-software-node.tsx new file mode 100644 index 00000000..32d34581 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-software-node.tsx @@ -0,0 +1,22 @@ +import type { Node, NodeProps } from "@xyflow/react"; +import { Handle, Position } from "@xyflow/react"; + +import type { WorkplaceSoftwareData } from "../helpers/workplace-map-model"; + +import { WorkplaceSoftwareCard } from "./workplace-software-card"; + +export function WorkplaceSoftwareNode({ + data, +}: NodeProps>) { + return ( +
+ + +
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-summary.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-summary.tsx new file mode 100644 index 00000000..d8aa367f --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-summary.tsx @@ -0,0 +1,44 @@ +import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; +import { MonitorIcon } from "@repo/rdx-ui/lib/icons"; +import { useTranslation } from "react-i18next"; + +import type { WorkstationDetailController } from "../controllers/use-workstation-detail-controller"; + +export function WorkplaceSummary({ controller: c }: { controller: WorkstationDetailController }) { + const { t } = useTranslation("assets"); + const rows = [ + { + label: t("workstationDashboard.equipment"), + value: c.inventory.isError + ? undefined + : c.workstation + ? new Set(c.workstation.values.assetIds).size + : undefined, + }, + { + label: t("workstationDashboard.activeProfiles"), + value: + c.profiles.data && !c.profiles.isError ? c.dashboard?.activeProfiles.length : undefined, + }, + { + label: t("workstationDashboard.softwareTitle"), + value: + c.dashboard?.computer && c.software.query.data && !c.software.query.isError + ? c.software.items.length + : undefined, + }, + ]; + + return ( + } title={t("workplaceMap.summary")}> +
+ {rows.map((row) => ( +
+
{row.label}
+
{row.value ?? "—"}
+
+ ))} +
+
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workplace-visual-map-card.tsx b/modules/companies/src/web/workstations/detail/ui/workplace-visual-map-card.tsx new file mode 100644 index 00000000..f24e1f26 --- /dev/null +++ b/modules/companies/src/web/workstations/detail/ui/workplace-visual-map-card.tsx @@ -0,0 +1,151 @@ +import { SoftwareReferenceFeedback } from "@metis/resources/client"; +import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; +import { MonitorIcon } from "@repo/rdx-ui/lib/icons"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Skeleton } from "@repo/shadcn-ui/components/skeleton"; +import type { MouseEventHandler } from "react"; +import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useWorkplaceMapModel } from "../controllers/use-workplace-map-model"; +import type { WorkstationDetailController } from "../controllers/use-workstation-detail-controller"; +import { workplaceMapPreview } from "../helpers/workplace-map-preview"; + +import { WorkplaceCompactMap } from "./workplace-compact-map"; +import { WorkplaceMapCoverage } from "./workplace-map-coverage"; +import { WorkplaceMapLegend } from "./workplace-map-legend"; +import { WorkstationAssets } from "./workstation-assets"; +import { WorkstationEquipmentGuidance } from "./workstation-equipment-guidance"; +import { WorkstationQueryFeedback } from "./workstation-query-feedback"; + +const WorkplaceMapCanvas = lazy(() => import("./workplace-map-canvas")); + +export function WorkplaceVisualMapCard({ + companyId, + controller: c, + onEdit, + onViewSoftware, +}: { + companyId: string; + controller: WorkstationDetailController; + onEdit: MouseEventHandler; + onViewSoftware: () => void; +}) { + const { t } = useTranslation("assets"); + const container = useRef(null); + const [width, setWidth] = useState(0); + const [expanded, setExpanded] = useState(false); + const { query, references } = c.software; + const model = useWorkplaceMapModel(companyId, c); + const equipmentKnown = c.inventory.data !== undefined && !c.inventory.isError; + const softwareKnown = query.data !== undefined && !query.isError; + const preview = useMemo( + () => workplaceMapPreview(model, { equipmentKnown, softwareKnown }), + [model, equipmentKnown, softwareKnown] + ); + + useEffect(() => { + if (!container.current) { + return; + } + const observer = new ResizeObserver(([entry]) => { + if (entry) { + setWidth(entry.contentRect.width); + } + }); + + observer.observe(container.current); + + return () => observer.disconnect(); + }, []); + + if (!model) { + return null; + } + const { peripherals, nodes: visible } = preview; + const compact = width < 640; + + return ( + + {t("workplaceMap.manage")} + + } + density="compact" + description={t("workplaceMap.description")} + icon={} + title={t("workplaceMap.title")} + > + +
+ {model.principalState === "outside-composition" && ( +

{t("workstationDashboard.principalOutsideComposition")}

+ )} + {model.principalState === "unresolved" && ( +

{t("workstationDashboard.principalUnavailable")}

+ )} + + {visible.length > 0 && + (compact ? ( + + ) : ( + }> + + + ))} +
+ {model.principal && ( + <> + + + + {query.data && !query.isError && !model.software.length && ( +

{t("workplaceMap.noSoftware")}

+ )} + {!peripherals.length && ( +

{t("workplaceMap.noPeripherals")}

+ )} + + )} + + setExpanded(!expanded)} + size="sm" + variant="ghost" + > + {t(expanded ? "workplaceMap.hideEquipment" : "workplaceMap.allEquipment")} + + } + onViewSoftware={onViewSoftware} + software={preview.softwareCoverage} + /> + {expanded && ( +
+ +
+ )} +
+ ); +} diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-detail-content.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-detail-content.tsx index d7360866..190f32b5 100644 --- a/modules/companies/src/web/workstations/detail/ui/workstation-detail-content.tsx +++ b/modules/companies/src/web/workstations/detail/ui/workstation-detail-content.tsx @@ -1,5 +1,6 @@ import { entityListState } from "@repo/rdx-ui/lib/entity-list-state"; import type { MouseEventHandler } from "react"; +import { useRef } from "react"; import { useTranslation } from "react-i18next"; import type { CompanyDTO } from "../../../../common"; @@ -8,7 +9,8 @@ import type { WorkstationDetailController } from "../controllers/use-workstation import { workstationProfileAvailability } from "../helpers/workstation-profile-availability"; import { firstPendingWorkstationProfile } from "../helpers/workstation-profile-expansion"; -import { WorkstationAssets } from "./workstation-assets"; +import { WorkplaceSummary } from "./workplace-summary"; +import { WorkplaceVisualMapCard } from "./workplace-visual-map-card"; import { WorkstationLatestChange } from "./workstation-latest-change"; import { WorkstationPendingConfigurations } from "./workstation-pending-configurations"; import { WorkstationProfileConfigurationDetails } from "./workstation-profile-configuration-details"; @@ -29,6 +31,7 @@ export function WorkstationDetailContent({ onEdit: MouseEventHandler; }) { const { t, i18n } = useTranslation("assets"); + const softwarePanel = useRef(null); const workstation = c.workstation; const model = c.dashboard; @@ -58,16 +61,21 @@ export function WorkstationDetailContent({ return (
-
- + { + softwarePanel.current?.scrollIntoView({ block: "center" }); + softwarePanel.current + ?.querySelector("button") + ?.focus({ preventScroll: true }); + }} /> -
+
+ { @@ -82,6 +90,7 @@ export function WorkstationDetailContent({ } }} /> +
@@ -132,12 +141,13 @@ export function WorkstationDetailContent({
- - +
+ +
- - {t("workstationDetail.assignedPerson")}: - {responsible ? ( - <> - - - - {responsible.name} - - - - - ) : ( - t("workstationDashboard.noResponsible") - )} - +
Promise.reject(new Error("Fallo sintético")), + retry: false, + }) + ); + } client.setQueryData(profileKeys.company(companyId), { profiles: options.profiles ?? [] }); client.setQueryData(catalogKeys.scope(companyId), { items: [] }); client.setQueryData(companyKeys.detail(companyId), { @@ -381,7 +401,7 @@ test("ficha: ordenador destacado y periféricos sin repetir, perfiles solo del o assert.ok(html.includes('aria-expanded="false"')); const header = html.slice(html.indexOf("")); - assert.ok(header.includes(`href="/companies/${companyId}/people/${person.id}"`)); + assert.equal(header.includes(`href="/companies/${companyId}/people/${person.id}"`), false); assert.ok(header.includes(person.name)); assert.ok( header.includes( @@ -400,7 +420,7 @@ test("ficha: ordenador destacado y periféricos sin repetir, perfiles solo del o assert.ok(html.indexOf(computer.values.name) < html.indexOf(peripheral.values.name)); assert.ok(html.includes(labels.workstationDashboard.equipment)); assert.equal(html.includes(`${labels.workstationComputer}`), false); - assert.equal(html.split(computer.values.name).length - 1, 1); + assert.equal(html.split(`href="/companies/${companyId}/assets/${computer.id}"`).length - 1, 1); assert.ok( html.indexOf(labels.workstationDashboard.equipment) < html.indexOf(labels.workstationDashboard.peopleProfiles) @@ -462,7 +482,7 @@ test("personas y perfiles: carga y error no se presentan como listados vacíos", assert.ok(profiles.includes(labels.retry)); assert.equal(profiles.includes(labels.workstationDetail.noProfiles), false); assert.equal(profiles.includes('data-quick-search=""'), false); - assert.ok(profiles.includes('data-slot="compact-table"')); + assert.ok(profiles.includes('data-slot="compact-list"')); } }); @@ -834,7 +854,7 @@ test("personas y perfiles es/en: expansión contextual, enlaces independientes y } }); -test("cabecera es/en: responsable largo o ausente, contexto independiente y seis tarjetas", async () => { +test("cabecera es/en: responsable largo o ausente, contexto independiente y mapa con resumen", async () => { const computer = asset(40, "PC-PRINCIPAL"); const longPerson = { ...person, @@ -858,12 +878,21 @@ test("cabecera es/en: responsable largo o ausente, contexto independiente y seis assert.equal(header.includes(`href="/companies/${companyId}"`), false); assert.equal(header.includes(company.name), false); assert.equal(html.includes(`${labels.workstationDashboard.responsibleContext}`), false); - assert.equal([...html.matchAll(/]*aria-haspopup="dialog"[^>]*>[\s\S]*?data-slot="avatar"[\s\S]*?<\/button>/ + ); + const trigger = [...header.matchAll(/]*>(.*?)<\/button>/gs)].find(([button]) => + button.includes('aria-haspopup="dialog"') + ); + + assert.ok(trigger?.[1]?.includes(longPerson.name)); } else { assert.ok(header.includes(labels.workstationDashboard.noResponsible)); assert.equal(header.includes('data-slot="avatar"'), false); @@ -1043,13 +1072,13 @@ test("perfiles: una actualización en curso conserva el total conocido y anuncia assert.ok(html.includes('aria-busy="true"')); }); -test("v2.2: dependencia sin principal conserva seis paneles y una explicación principal sin tabla vacía", async () => { +test("v2.2: dependencia sin principal conserva mapa y resumen y una explicación principal sin tabla vacía", async () => { for (const language of ["es", "en"]) { const html = await render(language, true); const labels = language === "es" ? assetsSpanish : assetsEnglish; assert.ok(html.includes(labels.noWorkstationAssets)); - assert.equal((html.match(/