diff --git a/apps/server/src/infrastructure/http/directory-relationship.routes.ts b/apps/server/src/infrastructure/http/directory-relationship.routes.ts index ef903f6..558190a 100644 --- a/apps/server/src/infrastructure/http/directory-relationship.routes.ts +++ b/apps/server/src/infrastructure/http/directory-relationship.routes.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { DirectoryRelationshipHttpQuerySchema, NETWORK_SHARE_PUBLICATION_TYPE_ID, + PublicationOptionQuerySchema, } from "@metis/assets/common"; import type { RelationshipContext } from "@metis/relationship-contracts"; import { DirectoryDomainOptionsQuerySchema } from "@metis/resources/common"; @@ -85,7 +86,21 @@ async function readPage( : runtime.assetSummaries; const resolved = await resolver.resolveMany(references, context); - return resolved.isSuccess ? Result.ok({ page: page.data, summaries: resolved.data }) : resolved; + if (!resolved.isSuccess) { + return resolved; + } + if (publication) { + const periods = await runtime.publications.periods( + page.data.items.flatMap((item) => (item.origin.kind === "record" ? [item.origin.id] : [])), + context + ); + + return periods.isSuccess + ? Result.ok({ page: page.data, summaries: resolved.data, publications: periods.data }) + : periods; + } + + return Result.ok({ page: page.data, summaries: resolved.data }); } /** Solo lectura: contexto creado desde sesión y proyecciones de los propietarios existentes. */ @@ -129,6 +144,41 @@ export function registerDirectoryRelationshipRoutes( } } ); + for (const [path, service] of [ + ["asset-options", runtime.publicationAssets], + ["share-options", runtime.publicationShares], + ] as const) { + app.get( + `/api/asset-network-share-publications/:companyId/${path}`, + authenticate, + async (request, response, next) => { + try { + const query = PublicationOptionQuerySchema.safeParse(request.query); + + if (!query.success) { + fail(response, "INVALID_QUERY"); + + return; + } + const context = await contextFor(request, response, runtime); + + if (!context) { + return; + } + const result = await service.list(query.data, context); + + if (!result.isSuccess) { + fail(response, result.error.code); + + return; + } + response.json(result.data); + } catch (error) { + next(error); + } + } + ); + } router.get("/:companyId/domains", authenticate, async (request, response, next) => { try { const query = DirectoryDomainOptionsQuerySchema.safeParse(request.query); diff --git a/apps/server/src/infrastructure/http/relationship-runtime.ts b/apps/server/src/infrastructure/http/relationship-runtime.ts index e1a82e6..924a1f9 100644 --- a/apps/server/src/infrastructure/http/relationship-runtime.ts +++ b/apps/server/src/infrastructure/http/relationship-runtime.ts @@ -36,6 +36,8 @@ export function createRelationshipRuntime( return Object.freeze({ registry: registered.data, + publicationAssets: assets.services.publicationOptions(access), + publicationShares: resources.services.networkShareOptions(access), contexts: access, memberships: assetReads.memberships, publications: assets.services.publicationReads(registered.data, access, shareSummaries), diff --git a/apps/server/src/local-identities-folder-mapping.integration.test.ts b/apps/server/src/local-identities-folder-mapping.integration.test.ts index 58ee08b..efbd100 100644 --- a/apps/server/src/local-identities-folder-mapping.integration.test.ts +++ b/apps/server/src/local-identities-folder-mapping.integration.test.ts @@ -9,6 +9,7 @@ import { QueryTypes } from "sequelize"; import { WorkstationUseCases } from "../../../modules/assets/src/api/application/workstation.use-cases"; import { SequelizeAssetRepository } from "../../../modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository"; import { AccessUseCases } from "../../../modules/companies/src/api/application/access.use-cases"; +import { buildAccessResponse } from "../../../modules/companies/src/api/application/access-response.builder"; import { FolderMappingUseCases } from "../../../modules/companies/src/api/application/folder-mapping.use-cases"; import { buildFolderResponse } from "../../../modules/companies/src/api/application/folder-mapping-response.builder"; import { WorkProfileUseCases } from "../../../modules/companies/src/api/application/work-profile.use-cases"; @@ -564,6 +565,48 @@ test("MariaDB: identidades locales y cuenta documental de FolderMapping", async assert.ok(original.isSuccess); const mappingId = original.data.mapping.id.toPrimitive(); + await t.test( + "UI 3.5: propietario local por lote y revisión obsoleta no sobrescribe un mapeo", + async () => { + const snapshot = await access.get(company); + + assert.ok(snapshot.isSuccess); + const response = buildAccessResponse(snapshot.data); + + assert.equal( + response.localEnvironments?.find((item) => item.environmentId === environmentId)?.assetId, + nas.id.toPrimitive() + ); + const rejected = await folders.save( + company, + mappingId, + { ...folderInput, notes: "No guardar" }, + actor, + UniqueID.generateNewID().toPrimitive() + ); + + assert.ok(!rejected.isSuccess); + assert.equal(rejected.error.code, "FOLDER_MAPPING_CONFLICT"); + const unchanged = await folders.get(company); + + assert.ok(unchanged.isSuccess); + assert.equal( + unchanged.data.mappings.find((item) => item.id.toPrimitive() === mappingId)?.history + .length, + 1 + ); + const accepted = await folders.save( + company, + mappingId, + folderInput, + actor, + original.data.mapping.history.at(-1)?.id.toPrimitive() + ); + + assert.ok(accepted.isSuccess); + } + ); + const read = async () => { const snapshot = await folders.get(company); diff --git a/apps/server/src/network-share-publications.integration.test.ts b/apps/server/src/network-share-publications.integration.test.ts index 67a3886..e0a4694 100644 --- a/apps/server/src/network-share-publications.integration.test.ts +++ b/apps/server/src/network-share-publications.integration.test.ts @@ -13,6 +13,8 @@ import { NETWORK_SHARE_PUBLICATION_TYPE_ID, NetworkSharePublicationPageSchema, NetworkSharePublicationSchema, + NetworkShareRelationshipResponseSchema, + PublicationAssetOptionsSchema, } from "../../../modules/assets/src/common/network-share-publications"; import { AccessUseCases } from "../../../modules/companies/src/api/application/access.use-cases"; import { buildAccessResponse } from "../../../modules/companies/src/api/application/access-response.builder"; @@ -22,6 +24,7 @@ import { } from "../../../modules/companies/src/api/infrastructure/persistence/access-persistence"; import { CompanyPersistenceMapper } from "../../../modules/companies/src/api/infrastructure/persistence/company-persistence.mapper"; import { SequelizeCompanyRepository } from "../../../modules/companies/src/api/infrastructure/persistence/sequelize-company.repository"; +import { NetworkShareOptionsSchema } from "../../../modules/resources/src/common/network-shares"; import { UniqueID } from "../../../packages/rdx-ddd/src/index"; import { @@ -736,6 +739,44 @@ test("MariaDB: publicaciones SMB, historial, proveedor y HTTP autenticado", asyn } ); + await t.test( + "UI 3.5: selector remoto filtra en SQL y pagina sin hidratar el inventario", + async () => { + const expected: string[] = []; + + for (let index = 0; index < 21; index++) { + expected.push((await share(`UiPage${index}`)).id.toPrimitive()); + } + const before = sql.length; + const firstPage = await f.relationships.publicationShares.list( + { search: "UiPage" }, + context + ); + + assert.ok(firstPage.isSuccess && firstPage.data.nextCursor); + assert.equal(firstPage.data.items.length, 20); + const next = await f.relationships.publicationShares.list( + { search: "UiPage", cursor: firstPage.data.nextCursor }, + context + ); + + assert.ok(next.isSuccess); + assert.equal(next.data.items.length, 1); + assert.equal(next.data.nextCursor, null); + assert.deepEqual( + [...firstPage.data.items, ...next.data.items].map((item) => item.id).sort(), + expected.sort() + ); + assert.ok( + !sql + .slice(before) + .some((statement) => + /resource_revisions|resource_revision_characteristics/.test(statement) + ) + ); + } + ); + await t.test("HTTP: sesión, CSRF, validación, idempotencia y consultas paginadas", async () => { const http = f.app.listen(0, "127.0.0.1"); @@ -847,6 +888,53 @@ test("MariaDB: publicaciones SMB, historial, proveedor y HTTP autenticado", asyn }); assert.equal(relations.status, 200); + const metadata = NetworkShareRelationshipResponseSchema.parse( + await relations.clone().json() + ); + + assert.equal(metadata.publications[0]?.id, firstResponse.id); + assert.equal(metadata.publications[0]?.startedBy, actor); + for (const kind of ["asset", "share"]) { + const path = `${endpoint}/${kind}-options`; + + assert.equal((await fetch(path)).status, 401); + assert.equal( + (await fetch(`${path}?operatorId=${actor}`, { headers: { cookie } })).status, + 400 + ); + const direct = + kind === "asset" + ? await f.relationships.publicationAssets.list({ search: "" }, context) + : await f.relationships.publicationShares.list({ search: "" }, context); + + assert.ok(direct.isSuccess, kind); + const response = await fetch(path, { headers: { cookie } }); + + assert.equal(response.status, 200); + const raw: unknown = await response.json(); + const ids = + kind === "asset" + ? PublicationAssetOptionsSchema.parse(raw).items.map((item) => item.id) + : NetworkShareOptionsSchema.parse(raw).items.map((item) => item.id); + + assert.ok( + !ids.includes( + kind === "asset" ? foreign.id.toPrimitive() : foreignShare.id.toPrimitive() + ) + ); + } + const searchResult = await fetch( + `${endpoint}/share-options?search=${encodeURIComponent("Documentos")}`, + { headers: { cookie } } + ); + const searched = NetworkShareOptionsSchema.parse(await searchResult.json()); + + assert.deepEqual( + searched.items.map((item) => item.id), + [second.id.toPrimitive()] + ); + assert.equal(searched.items[0]?.address, String.raw`\\alias\Documentos`); + const payload = z .object({ page: RelationshipPageSchema, diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 8d11c53..9171c34 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -1138,3 +1138,9 @@ no aplica paginación local a una página ya paginada. altura estable, como la finalización de pertenencias a dominio. Conserva los textos, la confirmación, el estado pendiente y el manejo de errores del patrón existente. Véase [integración visual de dominios](integracion-visual-de-dominios-windows.md). + +## Selección con páginas remotas + +`RemoteOptionPicker` y `RemoteOptionList`, de `@repo/rdx-ui`, presentan una página aportada por el consumidor: búsqueda controlada, selección provisional, radios accesibles, columna de selección compacta y composición de la paginación. No aplican búsqueda, orden ni paginación locales. El diálogo mantiene altura estable y foco inicial en la búsqueda. Sus contratos no dependen de entidades, DTOs ni consultas de Metis. + +El consumidor gestiona carga/error, consulta remota, cursores opacos y textos traducidos. Assets los utiliza para seleccionar publicadores y carpetas SMB; Companies reutiliza el selector de activos para propietarios de entornos locales. Véase [integración visual de recursos compartidos SMB](integracion-visual-de-recursos-compartidos-smb.md). diff --git a/docs/integracion-visual-de-recursos-compartidos-smb.md b/docs/integracion-visual-de-recursos-compartidos-smb.md new file mode 100644 index 0000000..208eaed --- /dev/null +++ b/docs/integracion-visual-de-recursos-compartidos-smb.md @@ -0,0 +1,131 @@ +# Integración visual de recursos compartidos SMB — Paso 3.5 + +## Alcance y composición + +La integración utiliza las fichas actuales de Resources y Assets, AccessWorkspace y las configuraciones de WorkProfile. La especialización se reconoce por `networkShare.capability === "network:smb-share"`; ni el nombre de la plantilla, ni el hostname de la UNC, ni un campo documental sustituyen una publicación. + +- `CompanyResourceRelatedContent` compone `NetworkSharePublications` con ancla Resource y la navegación contextual a accesos. Los recursos ordinarios, buzones y dominios mantienen su composición anterior. +- `AssetDetail` incorpora el mismo bloque con ancla Asset, tanto en ficha como en panel lateral. La sección de pertenencia a dominios sigue siendo independiente. +- `PublicationList`, `PublicationRow` y `PublicationPeriod` separan consulta paginada, presentación y datos auditados. El historial se carga cuando se despliega y conserva las publicaciones finalizadas. +- `PublicationEditor` representa operaciones explícitas: publicar, trasladar o finalizar. Captura el UUID del periodo al abrirse. +- `AssetRelatedInformation`, propiedad de Companies, incorpora `AssetLocalIdentities`. Assets no importa Companies. `LocalIdentityEditor` sirve para crear, vincular y retirar mediante el propietario de los comandos. +- `FolderAccessAccountField` amplía el formulario oficial del mapeo. El listado, la línea temporal de revisiones y el entorno de trabajo presentan la cuenta utilizada. + +Se reutilizan `DetailPanel`, `DetailSection`, `EmptyState`, `EntityTable`, `EntityStatus`, `CursorPagination`, `FormDialog`, `FormBody`, `FormHelp`, `FormActions`, `FormLabel`, `SelectWithAction`, `TimeLine` y `BrowserLink`. Las primitivas nuevas se componen con las APIs instaladas de Button, Input, Field, Table y RadioGroup de shadcn/Base UI. Los iconos proceden del catálogo Tabler del proyecto. + +## Selectores reutilizables + +`@repo/rdx-ui/components/remote-option-picker` encapsula selección provisional, confirmación, búsqueda controlada, altura estable y foco inicial en búsqueda. `remote-option-list` representa una página con selección compacta al principio, radios accesibles y selección al pulsar una zona no interactiva de la fila. + +Sus props son datos presentacionales, textos y callbacks. No conocen compañías, DTOs de negocio, endpoints ni TanStack Query. El consumidor aporta la paginación como composición. No filtran, ordenan ni vuelven a paginar la página recibida. El patrón local de `OptionPickerList` utiliza EntityTable con búsqueda y paginación locales; por eso no se utiliza para fingir un inventario remoto completo. + +`PublicationPicker` aporta consulta y cursores para elegir activos y carpetas. El selector de activos también tiene un consumidor real en los diálogos de entornos locales. Se admite sobrescribir el título para no llamar «publicador» al propietario de un entorno local. + +## Contratos HTTP + +Se conservan los comandos propietarios: + +- `POST /api/asset-network-share-publications/:companyId`: `publish`, `transfer`, `end` del paso 3.3. El traslado se ejecuta con una sola petición atómica. +- `GET /api/asset-network-share-publications/:companyId`: lectura especializada de los periodos, disponible para otros consumidores. +- `GET/POST /api/local-identity-environments/:companyId`: lectura paginada y comandos `create`, `associate`, `retire` del paso 3.4. +- Lectura y edición oficiales de AccessWorkspace, Resources y FolderMapping. + +Ampliaciones de lectura necesarias para la UI: + +| Endpoint o respuesta | Finalidad | +| --- | --- | +| `GET /api/asset-network-share-publications/:companyId/relationships` | Typed Relationships con `page`, `summaries` y `publications`: periodos de esa página resueltos en una única consulta adicional por lote. | +| `GET /api/asset-network-share-publications/:companyId/asset-options?search=&cursor=` | Activos operativos de cualquier plantilla, máximo 20 por página. | +| `GET /api/asset-network-share-publications/:companyId/share-options?search=&cursor=` | Recursos SMB activos, máximo 20 por página, con UNC original. | +| Respuesta de AccessWorkspace: `localEnvironments` | Proyección por compañía de entorno, UUID/nombre del activo propietario y retirada. Una consulta por lote, sin peticiones por fila. | +| Respuesta de AccessWorkspace: `directoryEnvironmentIds` | Clasificación estructurada para distinguir dominios de entornos locales al ofrecer asociaciones. | + +Las lecturas de opciones tienen búsqueda SQL y keyset por UUID. Los cursores se conservan como valores opacos en el cliente. Las lecturas de relaciones mantienen los cursores firmados del proveedor y sus filtros originales. No se añade metadato libre a `RelationshipView`, ni se descargan todos los recursos o sus revisiones para seleccionar una carpeta. Los resúmenes SMB presentan la UNC original mediante una proyección por lote. + +### Precondición de revisión de FolderMapping + +Se detectó una incompatibilidad con el requisito de concurrencia: el contrato anterior de guardado no permitía indicar qué revisión había leído el operador. Se añade `expectedRevisionId` opcional fuera de `values` a `SaveFolderMappingRequestSchema`. + +El formulario de edición siempre envía la revisión capturada al abrirse. Bajo el bloqueo transaccional existente de la compañía, el caso de uso comprueba la última revisión antes de actualizar. Si difiere, devuelve `FOLDER_MAPPING_CONFLICT` (HTTP 409) sin escritura. La identidad y los campos del formulario abierto no se sustituyen por una actualización de caché. La UI ofrece refrescar el listado y explica que hay que cerrar y volver a abrir para revisar los cambios. Los consumidores internos anteriores que no envían la precondición conservan su contrato; no cambia la semántica de cuenta omitida, UUID o `null` del paso 3.4. + +## Publicaciones, historial y navegación + +El recurso muestra su publicador vigente, plantilla, estado y enlace a Asset. Asset muestra sus carpetas, UNC, estados y enlaces a Resource. Los estados vacíos distinguen la ausencia de relación de un error de lectura. + +Las acciones trasladar y finalizar conservan el UUID iniciado. Un conflicto muestra el error funcional y permite actualizar sin transformar silenciosamente el comando en otro sobre el nuevo periodo. No se presenta una escritura como confirmada antes de recibir respuesta. + +El historial diferencia vigente/finalizada y presenta fechas y UUID de actores proporcionados por Assets. No inventa nombres de actores. La UNC es seleccionable y copiable en su característica habitual; no se duplica en otro bloque. Los fallos de Clipboard muestran una alternativa manual. + +## AccessWorkspace contextual e identidades locales + +Las rutas de carpeta incluyen `resourceId` UUID. El parser solo lo admite en el espacio de accesos de una compañía; rechaza valores inválidos, parámetros repetidos y otros destinos. El filtro se mantiene al buscar y cambiar de sección, muestra el recurso y permite retirarlo. + +`accessResourceIds` recorre referencias de necesidades, concesiones, permisos, coberturas, grupos, pertenencias, asociaciones persona/cuenta y sus sistemas/entornos. No compara nombres o UNC ni impone coincidencia entre el sistema de la identidad y el publicador. Necesidades y concesiones de otro recurso quedan fuera del resultado. El alta contextual de una necesidad o concesión utiliza el editor existente y fija el UUID del recurso. + +La columna de activo propietario aparece en cuentas, grupos y entornos. Utiliza la asociación estructurada, permite navegar al activo y distingue propietarios de entornos retirados. Crear/vincular utiliza el diálogo local oficial de esta integración. Un entorno ya propietario no ofrece reasignación; dominios y entornos globales no se ofrecen como entornos locales asociables. El backend conserva la validación definitiva. + +El bloque del activo consulta entornos locales por páginas de 20, incluidos retirados, con enlaces al filtro de cuentas del entorno. No sustituye al catálogo de cuentas ni a la relación de dominio. + +## Cuenta y cobertura de FolderMapping + +La selección permite no indicar cuenta, elegir explícitamente la cuenta del perfil, seleccionar otra cuenta válida de la persona y retirar mediante `null`. Una edición que no toca la cuenta conserva el valor anterior; los campos omitidos siguen omitiéndose. Un cambio explícito de persona/perfil limpia la selección incompatible. Una cuenta histórica no disponible no se reemplaza silenciosamente. + +Las opciones respetan asociación persona/cuenta vigente, estado de cuenta, caducidad y catálogos activos; el backend vuelve a validar al guardar. La cuenta del perfil y la utilizada por el mapeo pueden ser distintas. No se solicitan credenciales. + +`accessAccountCoverage` se muestra con tres etiquetas: cuenta no indicada, cobertura documentada y sin cobertura documentada. La explicación visible aclara su carácter documental. `permissionsCovered` continúa describiendo la cobertura general de la necesidad, por separado. No se atribuye la cobertura de otra cuenta de la persona a la cuenta elegida. + +Se conservan ruta registrada, letra, estados, discrepancias de UNC y adopción explícita de la ruta actual. Las revisiones muestran la cuenta de cada revisión, sin recalcular una cobertura histórica que el contrato no proporciona. + +## Caché y seguridad + +TanStack Query centraliza claves por compañía, ancla, filtros y cursor. Las consultas independientes del activo y del espacio de accesos se inician en paralelo y comparten caché. No se utiliza un efecto para derivar la selección, la cobertura o los resultados filtrados. + +Publicar, finalizar y trasladar invalidan los extremos afectados, incluidos activo anterior y nuevo. Los cambios de Access invalidan los mapeos cuya cobertura puede haber cambiado; un cambio de catálogo global invalida los espacios dependientes. Los comandos de entornos locales invalidan su lectura, Access y los mapeos de la compañía. + +La sesión aporta el operador. Las escrituras usan CSRF. Los endpoints nuevos reutilizan el contexto de lectura autenticado de Typed Relationships y validan compañía y entrada. Las proyecciones SQL limitan por compañía, usan parámetros y no cambian datos. No se añade ACL universal, credenciales, nuevas entidades, migraciones ni verificación técnica de acceso. + +## Skills UI/UX aplicadas + +| Skill y referencias locales consultadas | Aplicación y adaptación | +| --- | --- | +| `shadcn/SKILL.md`; `rules/forms.md`, `composition.md`, `styling.md`, `icons.md`, `base-vs-radix.md`, `customization.md`, `design-system.md` y secciones pertinentes de `design-system-page.md` | FormDialog y FormHelp conservan el patrón normativo local. Nuevos campos con Field/FormLabel, descripciones asociadas, botones deshabilitados en pendiente, radios etiquetados, EmptyState y estados textuales. Tokens semánticos y composición Base UI/Vega; se mantiene el catálogo Tabler y no se modifica vendor. | +| `vercel-composition-patterns/SKILL.md`; reglas de evitar booleanos, variantes explícitas, composición por children y separación del estado | Operaciones discriminadas de publicación, listas/filas/periodos separados, composición mediante relatedContent, selectores genéricos independientes de consultas y diálogos en sus propios ficheros. No se crean providers globales. | +| `vercel-react-best-practices/SKILL.md`; `async-parallel.md`, `rerender-derived-state-no-effect.md`, `rerender-move-effect-to-event.md`, `bundle-barrel-imports.md` y criterios de memoización consultados | Consultas independientes en paralelo, caché e invalidaciones de TanStack Query, paginación SQL y lotes; estado derivado durante render, acciones en handlers, sin memoización preventiva. Se mantienen las fronteras públicas `./client` del monorepo y las importaciones por componente de RDX; no se introduce configuración Next.js, RSC o SWR. | + +Las convenciones de Metis prevalecen sobre ejemplos genéricos. Se reutiliza FormLabel en los formularios existentes y no se rediseñan sus grupos ajenos a esta entrega. La invocación de documentación de shadcn por CLI falló por resolución de red (`EAI_AGAIN`); las integraciones se contrastaron con las APIs locales instaladas y las referencias de la skill. + +## Verificación y revisión manual + +Las pruebas específicas cubren enlaces por UUID, traducciones es/en, UNC Unicode, estados vacío/error, periodos auditados, transferencia con una petición y CSRF, cursores opacos, filtro estructurado, cuentas elegibles y semántica omitido/null. Los tests de RDX comprueban radios etiquetados, orden de la página, selección compacta y separación de error/carga/vacío mediante renderizado estático. + +Las pruebas MariaDB/HTTP usan exclusivamente `metis_desk_test`, fixtures temporales y limpieza. Cubren aislamiento de compañía, búsqueda y paginación SQL, metadatos de periodos, propietarios locales por lote, rechazo de revisiones obsoletas y regresiones de los pasos 3.1–3.4 y dominios Windows. + +No se dispone de navegador ni herramienta de automatización de navegador en esta sesión. No se afirma validación interactiva. Pendiente comprobar manualmente en claro/oscuro, ficha y panel lateral, móvil y escritorio: + +1. Alta SMB, asignación, consulta desde Asset, traslado y finalización. +2. Historial y navegación a necesidades/concesiones, búsqueda y retirada del filtro. +3. Crear/vincular/retirar entorno local, navegar al propietario y a sus cuentas. +4. Elegir cuenta del perfil/alternativa, retirar, guardar otros campos y revisar coberturas. +5. Concurrencia con dos operadores y conservación del formulario abierto. +6. Foco inicial en búsqueda, radios por teclado, cancelación y restitución del foco, ayuda junto al pie, scroll y UNC extensas. + +Incidencias previas indicadas por el encargo: dos expectativas de texto de Companies y TS2375 del calendario vendor. No se modifican esas pruebas ni vendor para ocultarlas. Los resultados concretos de esta ejecución se recogen en la entrega; una incidencia previa que no se reproduzca no se presenta como fallo actual. + +### Resultados de la ejecución (21 de octubre de 2026) + +| Comprobación ejecutada | Resultado | +| --- | --- | +| `pnpm --filter … typecheck`: Assets, Resources, Companies, RDX UI, servidor y web | Correcto. TS2375 del calendario vendor no se reproduce en estas comprobaciones. | +| `pnpm exec biome check` sobre los TS/TSX/JSON modificados y nuevos; `pnpm exec eslint` sobre TS/TSX afectados | Correcto, sin supresiones. | +| Scripts de test de Assets y Resources, con `--test-isolation=none` | 136/136 y 80/80. | +| Script de test de Companies, con `--test-isolation=none` | 187/189. Persisten únicamente las dos expectativas de texto previas de `companies-page.test.tsx` (ficha de compañía y formulario de persona). | +| Script de test de RDX UI, con `--test-isolation=none` | 54/54. | +| `pnpm test` de Typed Relationships Contracts | 13/13. | +| Tests HTTP sin persistencia: app, infraestructura, diagnóstico, entorno y contexto de lectura | 8/8. Fue necesario permitir el puerto local temporal en el entorno restringido. | +| MariaDB: network-shares, smb-access, network-share-publications, local-identities-folder-mapping, typed-relationships y windows-domains-ui | 56/56, exclusivamente `metis_desk_test`. | +| Repetición dirigida tras la última mejora de presentación de UNC y cuenta | Publicaciones MariaDB 11/11; entorno de trabajo e identidades locales UI 10/10; accesos y filtro de recurso 8/8. | +| Builds de servidor y web | Correctos. Vite mantiene un aviso de tamaño de chunk superior a 500 kB; no se modifica la estrategia global de bundles en esta entrega. | +| `pnpm exec turbo run build --dry=json` y comprobación de sus dependencias | Grafo sin ciclos, 13 tareas. | +| `git diff --check` | Correcto. | + +Los comandos PNPM se ejecutaron con `--config.verify-deps-before-run=false` para utilizar las dependencias instaladas. No se instalaron librerías ni se cambiaron lockfiles. La última adaptación de los editores de Access fija su altura y coloca `EntityHelp` mediante `FormHelp` junto al pie; conserva los mismos textos y la variante original de ayuda para los demás consumidores. diff --git a/modules/assets/package.json b/modules/assets/package.json index aa062e2..e1f134c 100644 --- a/modules/assets/package.json +++ b/modules/assets/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/asset-template-propagation.test.ts src/api/domain/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/assets/shared/helpers/asset-columns.test.tsx src/web/assets/detail/ui/asset-detail.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-membership.test.ts src/api/infrastructure/persistence/directory-membership-provider.test.ts src/web/directory-memberships/shared/directory-memberships.test.tsx src/api/domain/network-share-publication.test.ts" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/asset-template-propagation.test.ts src/api/domain/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/assets/shared/helpers/asset-columns.test.tsx src/web/assets/detail/ui/asset-detail.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-membership.test.ts src/api/infrastructure/persistence/directory-membership-provider.test.ts src/web/directory-memberships/shared/directory-memberships.test.tsx src/api/domain/network-share-publication.test.ts src/web/network-share-publications/shared/network-share-ui.test.tsx" }, "dependencies": { "@hookform/resolvers": "5.2.2", diff --git a/modules/assets/src/api/infrastructure/persistence/network-share-publication-query.ts b/modules/assets/src/api/infrastructure/persistence/network-share-publication-query.ts index 328a189..66c2044 100644 --- a/modules/assets/src/api/infrastructure/persistence/network-share-publication-query.ts +++ b/modules/assets/src/api/infrastructure/persistence/network-share-publication-query.ts @@ -16,8 +16,12 @@ import { import { Result } from "@repo/rdx-utils"; import type { Sequelize } from "sequelize"; import { QueryTypes } from "sequelize"; +import { z } from "zod/v4"; -import { NETWORK_SHARE_PUBLICATION_TYPE_ID } from "../../../common/network-share-publications"; +import { + NETWORK_SHARE_PUBLICATION_TYPE_ID, + NetworkSharePublicationSchema, +} from "../../../common/network-share-publications"; import { readPublicationPage } from "./network-share-publication-read"; import { SignedQueryCursor } from "./signed-query-cursor"; @@ -109,6 +113,28 @@ export class NetworkSharePublicationQuery implements RelationshipQueryService { ); } + async periods(ids: readonly string[], context: RelationshipContext) { + const allowed = await this.access.authorize(context); + + if (!allowed.isSuccess) { + return allowed; + } + const parsed = z.array(z.uuid()).max(100).safeParse(ids); + + if (!parsed.success) { + return Result.fail(new RelationshipReadError("INVALID_QUERY")); + } + if (!parsed.data.length) { + return Result.ok([]); + } + const rows = await this.database.query( + "SELECT id,companyId,assetId,resourceId,predecessorId,startedAt,startedBy,endedAt,endedBy FROM asset_network_share_publications WHERE companyId=:companyId AND id IN (:ids)", + { replacements: { companyId: context.companyId, ids: parsed.data }, type: QueryTypes.SELECT } + ); + + return Result.ok(z.array(NetworkSharePublicationSchema).parse(rows)); + } + private async anchor( query: RelationshipQuery, context: RelationshipContext diff --git a/modules/assets/src/api/infrastructure/persistence/publication-asset-options.ts b/modules/assets/src/api/infrastructure/persistence/publication-asset-options.ts new file mode 100644 index 0000000..f97080d --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/publication-asset-options.ts @@ -0,0 +1,48 @@ +import type { RelationshipContext, RelationshipReadAccess } from "@metis/relationship-contracts"; +import { Result } from "@repo/rdx-utils"; +import type { Sequelize } from "sequelize"; +import { QueryTypes } from "sequelize"; +import type { z } from "zod/v4"; + +import type { PublicationOptionQuerySchema } from "../../../common/network-share-publications"; +import { PublicationAssetOptionsSchema } from "../../../common/network-share-publications"; + +/** Opciones operativas por compañía; no hidrata inventarios ni impone roles o dominio. */ +export class PublicationAssetOptions { + constructor( + private readonly database: Sequelize, + private readonly access: RelationshipReadAccess + ) {} + + async list(query: z.infer, context: RelationshipContext) { + const allowed = await this.access.authorize(context); + + if (!allowed.isSuccess) { + return allowed; + } + const rows = await this.database.query<{ + id: string; + name: string; + templateName: string; + status: string; + }>( + "SELECT id,name,templateName,status FROM assets WHERE companyId=:companyId AND status IN ('operational','available','in_use') AND (:search = '' OR LOCATE(:search,name)>0 OR LOCATE(:search,templateName)>0) AND (:cursor IS NULL OR id>:cursor) ORDER BY id LIMIT 21", + { + replacements: { + companyId: context.companyId, + search: query.search, + cursor: query.cursor ?? null, + }, + type: QueryTypes.SELECT, + } + ); + const items = rows.slice(0, 20); + + return Result.ok( + PublicationAssetOptionsSchema.parse({ + items, + nextCursor: rows.length > 20 ? items.at(-1)?.id : null, + }) + ); + } +} diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts index fe2aa45..7fabdd9 100644 --- a/modules/assets/src/api/module.ts +++ b/modules/assets/src/api/module.ts @@ -53,6 +53,7 @@ import { migrateInfrastructure } from "./infrastructure/persistence/infrastructu import { LocalIdentityAssetReader } from "./infrastructure/persistence/local-identity-asset-reader"; import { migrateNetworkSharePublications } from "./infrastructure/persistence/network-share-publication-migration"; import { NetworkSharePublicationQuery } from "./infrastructure/persistence/network-share-publication-query"; +import { PublicationAssetOptions } from "./infrastructure/persistence/publication-asset-options"; import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository"; import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository"; import { SequelizeBrandRepository } from "./infrastructure/persistence/sequelize-brand.repository"; @@ -126,6 +127,8 @@ export async function createAssetModule( return { services: { + publicationOptions: (access: RelationshipReadAccess) => + new PublicationAssetOptions(database, access), localIdentities: (transaction: Transaction) => new LocalIdentityAssetReader(database, transaction), publicationReads: ( diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index 5e9d11a..99562d2 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -607,5 +607,51 @@ "networkSharePublication": { "resources": "Published SMB resources", "publisher": "Publishing asset" + }, + "shareUi": { + "published": "Published network shares", + "publisher": "Published by", + "publish": "Assign publication", + "transfer": "Change publisher", + "end": "End publication", + "resource": "Network share", + "address": "UNC address", + "publication": "Publication", + "period": "Validity period", + "actor": "Actors (UUID)", + "current": "Current", + "readError": "Publications could not be loaded. Refresh the data.", + "writeError": "The publication could not be saved. Refresh the data and review the operation.", + "emptyAsset": "This asset does not publish network shares.", + "emptyPublisher": "No publishing asset has been specified for this resource.", + "history": "Publisher history", + "selection": "Select", + "select": "Select", + "confirm": "Confirm", + "noOptions": "No items are available for this search.", + "helpTitle": "SMB share publication", + "help": "Document which asset publishes a network share.", + "transferHelp": "Transfer ends the previous publication and creates the new one atomically. If it has changed, review the conflict before starting another operation.", + "endHelp": "Confirm ending this publication. The resource, asset and history are preserved.", + "publishHelp": "Select an existing item. This dialog does not create resources or assets.", + "documentary": "This relationship is documentation. It does not check connectivity or effective SMB permissions.", + "examples": "Example", + "example": "NAS-01 publishes the Administration folder.", + "states": { + "active": "Current", + "ended": "Ended" + }, + "choose": { + "asset": "Select publishing asset", + "share": "Select network share" + }, + "errors": { + "PUBLICATION_CONFLICT": "The publication has changed. Refresh the data and review the history before starting another operation.", + "PUBLICATION_NOT_FOUND": "This publication is no longer available.", + "ASSET_INACTIVE": "The asset is not operational. Select another asset.", + "ASSET_NOT_FOUND": "This asset is no longer available.", + "SMB_RESOURCE_INACTIVE": "The share is inactive.", + "SMB_RESOURCE_NOT_FOUND": "This share is no longer available." + } } } diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index d9b372f..0906bfd 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -607,5 +607,51 @@ "networkSharePublication": { "resources": "Recursos SMB publicados", "publisher": "Activo publicador" + }, + "shareUi": { + "published": "Recursos compartidos publicados", + "publisher": "Publicado por", + "publish": "Asignar publicación", + "transfer": "Cambiar publicador", + "end": "Finalizar publicación", + "resource": "Carpeta compartida", + "address": "Dirección UNC", + "publication": "Publicación", + "period": "Periodo de vigencia", + "actor": "Actores (UUID)", + "current": "Vigente", + "readError": "No se han podido cargar las publicaciones. Actualiza los datos.", + "writeError": "No se ha podido guardar la publicación. Actualiza los datos y revisa la operación.", + "emptyAsset": "Este activo no publica carpetas compartidas.", + "emptyPublisher": "No se ha indicado qué activo publica este recurso.", + "history": "Historial de publicadores", + "selection": "Seleccionar", + "select": "Seleccionar", + "confirm": "Confirmar", + "noOptions": "No hay elementos disponibles para esta búsqueda.", + "helpTitle": "Publicación de carpetas SMB", + "help": "Documenta qué activo publica una carpeta compartida.", + "transferHelp": "El traslado finaliza la publicación anterior y crea la nueva en una única operación. Si ha cambiado, revisa el conflicto antes de iniciar otra operación.", + "endHelp": "Confirma la finalización de esta publicación. El recurso, el activo y su historial se conservarán.", + "publishHelp": "Selecciona el elemento existente. No se crean recursos ni activos desde este diálogo.", + "documentary": "Esta relación es documental. No comprueba conectividad ni permisos SMB efectivos.", + "examples": "Ejemplo", + "example": "NAS-01 publica la carpeta de Administración.", + "states": { + "active": "Vigente", + "ended": "Finalizada" + }, + "choose": { + "asset": "Seleccionar activo publicador", + "share": "Seleccionar carpeta compartida" + }, + "errors": { + "PUBLICATION_CONFLICT": "La publicación ha cambiado. Actualiza los datos y revisa el historial antes de iniciar otra operación.", + "PUBLICATION_NOT_FOUND": "La publicación ya no está disponible.", + "ASSET_INACTIVE": "El activo no está operativo. Selecciona otro activo.", + "ASSET_NOT_FOUND": "El activo ya no está disponible.", + "SMB_RESOURCE_INACTIVE": "La carpeta está inactiva.", + "SMB_RESOURCE_NOT_FOUND": "La carpeta ya no está disponible." + } } } diff --git a/modules/assets/src/common/network-share-publications.ts b/modules/assets/src/common/network-share-publications.ts index 1ba11ba..9393bab 100644 --- a/modules/assets/src/common/network-share-publications.ts +++ b/modules/assets/src/common/network-share-publications.ts @@ -1,4 +1,8 @@ -import { RelationshipDefinitionSchema } from "@metis/relationship-contracts"; +import { + RelationshipDefinitionSchema, + RelationshipPageSchema, + SummaryResolutionSchema, +} from "@metis/relationship-contracts"; import { z } from "zod/v4"; export const NETWORK_SHARE_PUBLICATION_TYPE_ID = "assets:network-share-publication"; @@ -55,3 +59,32 @@ export const NetworkSharePublicationPageSchema = z.strictObject({ publications: z.array(NetworkSharePublicationSchema).max(100), nextCursor: z.string().nullable(), }); + +export const NetworkShareRelationshipResponseSchema = z.strictObject({ + page: RelationshipPageSchema, + summaries: z.array(SummaryResolutionSchema).max(100), + publications: z.array(NetworkSharePublicationSchema).max(100), +}); + +export type NetworkShareRelationshipResponse = z.infer< + typeof NetworkShareRelationshipResponseSchema +>; + +export const PublicationOptionQuerySchema = z.strictObject({ + search: z.string().trim().max(100).default(""), + cursor: z.uuid().optional(), +}); + +export const PublicationAssetOptionsSchema = z.strictObject({ + items: z + .array( + z.strictObject({ + id: z.uuid(), + name: z.string(), + templateName: z.string(), + status: z.string(), + }) + ) + .max(20), + nextCursor: z.string().nullable(), +}); diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx index f8c6903..d4a6cdf 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { AssetDirectoryMembership } from "../../../directory-memberships/detail/ui/asset-directory-membership"; import { AssetInfrastructureSections } from "../../../infrastructure/shared/ui/asset-infrastructure-sections"; +import { NetworkSharePublications } from "../../../network-share-publications/detail/ui/network-share-publications"; import { formatAssetRegistrationDate } from "../../shared/helpers/format-asset-registration-date"; import { useAssetDetailController } from "../controllers"; import type { AssetDetailProps } from "../types"; @@ -145,6 +146,12 @@ export function AssetDetail(props: AssetDetailProps) { ) : ( directoryMembership )} + {children} {!compact &&

ID: {asset.id}

} diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts index f17828d..b417c64 100644 --- a/modules/assets/src/web/index.ts +++ b/modules/assets/src/web/index.ts @@ -11,6 +11,8 @@ export { infrastructureApi, infrastructureKeys, } from "./infrastructure/shared/api/infrastructure-api"; +export { NetworkSharePublications } from "./network-share-publications/detail/ui/network-share-publications"; +export { PublicationPicker } from "./network-share-publications/select/ui/publication-picker"; export { assetsEnglish, assetsSpanish } from "./shared"; export { inventoryApi } from "./shared/api"; export { inventoryKeys } from "./shared/queries"; diff --git a/modules/assets/src/web/network-share-publications/detail/ui/network-share-publications.tsx b/modules/assets/src/web/network-share-publications/detail/ui/network-share-publications.tsx new file mode 100644 index 0000000..fd82344 --- /dev/null +++ b/modules/assets/src/web/network-share-publications/detail/ui/network-share-publications.tsx @@ -0,0 +1,79 @@ +import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; +import { FolderIcon } from "@repo/rdx-ui/lib/icons"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { PublicationOperation } from "../../manage/ui/publication-editor"; +import { PublicationEditor } from "../../manage/ui/publication-editor"; +import type { PublicationAnchor } from "../../shared/api/network-share-publication-api"; +import { usePublicationPage } from "../../shared/controllers/use-publication-page"; +import { PublicationList } from "../../shared/ui/publication-list"; + +/** Misma relación, dos anclas explícitas; Companies compone este bloque en las fichas de Resource. */ +export function NetworkSharePublications({ + companyId, + anchor, + canPublish = true, +}: { + companyId: string; + anchor: PublicationAnchor; + canPublish?: boolean; +}) { + const { t } = useTranslation("assets"); + const [operation, setOperation] = useState(); + const [history, setHistory] = useState(false); + const current = usePublicationPage(companyId, anchor, false); + const hasPublisher = + anchor.type === "resources:resource" && !!current.query.data?.page.items.length; + + return ( + setOperation({ action: "publish", anchor })} + > + {t("shareUi.publish")} + + } + icon={} + title={t(anchor.type === "assets:asset" ? "shareUi.published" : "shareUi.publisher")} + > +
+ +

{t("shareUi.documentary")}

+ + {history && ( + + )} +
+ {operation && ( + setOperation(undefined)} + operation={operation} + /> + )} +
+ ); +} diff --git a/modules/assets/src/web/network-share-publications/manage/ui/publication-editor.tsx b/modules/assets/src/web/network-share-publications/manage/ui/publication-editor.tsx new file mode 100644 index 0000000..339a27a --- /dev/null +++ b/modules/assets/src/web/network-share-publications/manage/ui/publication-editor.tsx @@ -0,0 +1,178 @@ +import { FormActions, FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog"; +import { FormHelp } from "@repo/rdx-ui/components/form-help"; +import { FormLabel } from "@repo/rdx-ui/components/form-label"; +import type { RemotePickerOption } from "@repo/rdx-ui/components/remote-option-picker"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { NetworkSharePublicationCommand } from "../../../../common/network-share-publications"; +import { PublicationPicker } from "../../select/ui/publication-picker"; +import type { PublicationAnchor } from "../../shared/api/network-share-publication-api"; +import { publicationApi, publicationKeys } from "../../shared/api/network-share-publication-api"; + +export type PublicationOperation = + | { action: "publish"; anchor: PublicationAnchor } + | { action: "transfer"; publicationId: string; assetId: string; resourceId: string } + | { action: "end"; publicationId: string; assetId: string; resourceId: string }; + +/** La operación conserva el UUID iniciado, incluso si la consulta de fondo recibe otra publicación. */ +export function PublicationEditor({ + companyId, + operation, + onClose, +}: { + companyId: string; + operation: PublicationOperation; + onClose: () => void; +}) { + const { t } = useTranslation("assets"); + const id = useId(); + const client = useQueryClient(); + const [selected, setSelected] = useState(); + const [picker, setPicker] = useState(false); + const kind = + operation.action === "publish" && operation.anchor.type === "assets:asset" ? "share" : "asset"; + const mutation = useMutation({ + mutationFn: (command: NetworkSharePublicationCommand) => + publicationApi.execute(companyId, command), + onSuccess: async (result) => { + const ids = new Set([ + result.assetId, + operation.action === "publish" ? "" : operation.assetId, + ]); + + await Promise.all([ + client.invalidateQueries({ + queryKey: publicationKeys.anchor(companyId, { + type: "resources:resource", + id: result.resourceId, + }), + }), + ...Array.from(ids) + .filter(Boolean) + .map((assetId) => + client.invalidateQueries({ + queryKey: publicationKeys.anchor(companyId, { type: "assets:asset", id: assetId }), + }) + ), + ]); + onClose(); + }, + }); + + function save() { + if (mutation.isPending) { + return; + } + if (operation.action === "end") { + mutation.mutate({ action: "end", publicationId: operation.publicationId }); + + return; + } + if (!selected) { + return; + } + if (operation.action === "transfer") { + mutation.mutate({ + action: "transfer", + publicationId: operation.publicationId, + assetId: selected.id, + }); + + return; + } + mutation.mutate({ + action: "publish", + assetId: operation.anchor.type === "assets:asset" ? operation.anchor.id : selected.id, + resourceId: + operation.anchor.type === "resources:resource" ? operation.anchor.id : selected.id, + }); + } + + return ( + <> + { + if (!(open || mutation.isPending)) { + onClose(); + } + }} + open + title={t(`shareUi.${operation.action}`)} + > + + {operation.action !== "end" && ( +
+ + {t(`shareUi.choose.${kind}`)} + + +
+ )} + {mutation.isError && ( +
+

+ {t(`shareUi.errors.${mutation.error.message}`, { + defaultValue: t("shareUi.writeError"), + })} +

+ +
+ )} +
+ + + + +
+ {picker && ( + setPicker(false)} + onSelect={setSelected} + /> + )} + + ); +} diff --git a/modules/assets/src/web/network-share-publications/select/ui/publication-picker.tsx b/modules/assets/src/web/network-share-publications/select/ui/publication-picker.tsx new file mode 100644 index 0000000..7f55b5c --- /dev/null +++ b/modules/assets/src/web/network-share-publications/select/ui/publication-picker.tsx @@ -0,0 +1,85 @@ +import { CursorPagination } from "@repo/rdx-ui/components/cursor-pagination"; +import type { RemotePickerOption } from "@repo/rdx-ui/components/remote-option-picker"; +import { RemoteOptionPicker } from "@repo/rdx-ui/components/remote-option-picker"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { publicationApi, publicationKeys } from "../../shared/api/network-share-publication-api"; + +export function PublicationPicker({ + title, + companyId, + kind, + excludedId, + onSelect, + onClose, +}: { + title?: string; + companyId: string; + kind: "asset" | "share"; + excludedId?: string | undefined; + onSelect: (option: RemotePickerOption) => void; + onClose: () => void; +}) { + const { t } = useTranslation("assets"); + const [search, setSearch] = useState(""); + const [cursors, setCursors] = useState([null]); + const cursor = cursors.at(-1) ?? null; + const query = useQuery({ + queryKey: publicationKeys.options(companyId, kind, search, cursor), + queryFn: ({ signal }) => publicationApi.options(companyId, kind, search, cursor, signal), + }); + + return ( + ({ + ...item, + disabled: item.id === excludedId, + }))} + labels={{ + search: t("search"), + selection: t("shareUi.selection"), + name: t("name"), + description: t(kind === "asset" ? "template" : "shareUi.address"), + empty: t("shareUi.noOptions"), + loading: t("loading"), + confirm: t("shareUi.select"), + cancel: t("cancel"), + retry: t("retry"), + }} + onClose={onClose} + onRetry={query.refetch} + onSearch={(value) => { + setSearch(value); + setCursors([null]); + }} + onSelect={onSelect} + pagination={ + 1} + nextLabel={t("directoryUi.next")} + onNext={() => { + const next = query.data?.nextCursor; + + if (next) { + setCursors((values) => [...values, next]); + } + }} + onPrevious={() => + setCursors((values) => (values.length > 1 ? values.slice(0, -1) : values)) + } + onRestart={() => setCursors([null])} + pending={query.isFetching} + previousLabel={t("directoryUi.previous")} + restartLabel={t("directoryUi.restart")} + /> + } + pending={query.isFetching} + search={search} + title={title ?? t(`shareUi.choose.${kind}`)} + /> + ); +} diff --git a/modules/assets/src/web/network-share-publications/shared/api/network-share-publication-api.ts b/modules/assets/src/web/network-share-publications/shared/api/network-share-publication-api.ts new file mode 100644 index 0000000..fd14657 --- /dev/null +++ b/modules/assets/src/web/network-share-publications/shared/api/network-share-publication-api.ts @@ -0,0 +1,121 @@ +import { csrfFetch } from "@metis/core/client"; +import { NetworkShareOptionsSchema } from "@metis/resources/common"; +import { z } from "zod/v4"; + +import type { NetworkSharePublicationCommand } from "../../../../common/network-share-publications"; +import { + NETWORK_SHARE_PUBLICATION_TYPE_ID, + NetworkSharePublicationCommandSchema, + NetworkSharePublicationSchema, + NetworkShareRelationshipResponseSchema, + PublicationAssetOptionsSchema, +} from "../../../../common/network-share-publications"; + +export type PublicationAnchor = { type: "assets:asset" | "resources:resource"; id: string }; + +export const publicationKeys = { + company: (companyId: string) => ["assets", "network-share-publications", companyId] as const, + anchor: (companyId: string, anchor: PublicationAnchor) => + [...publicationKeys.company(companyId), anchor.type, anchor.id] as const, + page: (companyId: string, anchor: PublicationAnchor, ended: boolean, cursor: string | null) => + [...publicationKeys.anchor(companyId, anchor), ended, cursor] as const, + options: (companyId: string, kind: "asset" | "share", search: string, cursor: string | null) => + [...publicationKeys.company(companyId), "options", kind, search, cursor] as const, +}; + +async function request( + companyId: string, + suffix: string, + signal?: AbortSignal, + command?: NetworkSharePublicationCommand +): Promise { + const response = await csrfFetch(`/api/asset-network-share-publications/${companyId}${suffix}`, { + credentials: "same-origin", + ...(signal ? { signal } : {}), + ...(command + ? { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(NetworkSharePublicationCommandSchema.parse(command)), + } + : {}), + }); + const data: unknown = await response.json(); + + if (!response.ok) { + const error = z.object({ code: z.string() }).safeParse(data); + + throw new Error(error.success ? error.data.code : "INTERNAL_ERROR"); + } + + return data; +} + +export const publicationApi = { + async read( + companyId: string, + anchor: PublicationAnchor, + ended: boolean, + cursor: string | null, + signal?: AbortSignal + ) { + const params = new URLSearchParams({ + typeId: NETWORK_SHARE_PUBLICATION_TYPE_ID, + anchorType: anchor.type, + anchorId: anchor.id, + direction: anchor.type === "assets:asset" ? "outgoing" : "incoming", + includeEnded: String(ended), + pageSize: "20", + }); + + if (cursor) { + params.set("cursor", cursor); + } + + return NetworkShareRelationshipResponseSchema.parse( + await request(companyId, `/relationships?${params}`, signal) + ); + }, + async options( + companyId: string, + kind: "asset" | "share", + search: string, + cursor: string | null, + signal?: AbortSignal + ) { + const params = new URLSearchParams({ search }); + + if (cursor) { + params.set("cursor", cursor); + } + const data = await request(companyId, `/${kind}-options?${params}`, signal); + + if (kind === "asset") { + const page = PublicationAssetOptionsSchema.parse(data); + + return { + items: page.items.map((item) => ({ + id: item.id, + label: item.name, + description: item.templateName, + })), + nextCursor: page.nextCursor, + }; + } + const page = NetworkShareOptionsSchema.parse(data); + + return { + items: page.items.map((item) => ({ + id: item.id, + label: item.name, + description: item.address, + })), + nextCursor: page.nextCursor, + }; + }, + async execute(companyId: string, command: NetworkSharePublicationCommand) { + return z + .object({ publication: NetworkSharePublicationSchema }) + .parse(await request(companyId, "", undefined, command)).publication; + }, +}; diff --git a/modules/assets/src/web/network-share-publications/shared/controllers/use-publication-page.ts b/modules/assets/src/web/network-share-publications/shared/controllers/use-publication-page.ts new file mode 100644 index 0000000..03b3a36 --- /dev/null +++ b/modules/assets/src/web/network-share-publications/shared/controllers/use-publication-page.ts @@ -0,0 +1,34 @@ +import { useListQuery } from "@metis/core/client"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import type { PublicationAnchor } from "../api/network-share-publication-api"; +import { publicationApi, publicationKeys } from "../api/network-share-publication-api"; + +export function usePublicationPage(companyId: string, anchor: PublicationAnchor, history: boolean) { + const client = useQueryClient(); + const [cursors, setCursors] = useState([null]); + const cursor = cursors.at(-1) ?? null; + const query = useListQuery({ + queryKey: publicationKeys.page(companyId, anchor, history, cursor), + queryFn: ({ signal }) => publicationApi.read(companyId, anchor, history, cursor, signal), + }); + + return { + query, + previous: cursors.length > 1, + next: () => { + const next = query.data?.page.nextCursor; + + if (next) { + setCursors((values) => [...values, next]); + } + }, + back: () => setCursors((values) => (values.length > 1 ? values.slice(0, -1) : values)), + restart: () => { + setCursors([null]); + + return client.resetQueries({ queryKey: publicationKeys.anchor(companyId, anchor) }); + }, + }; +} diff --git a/modules/assets/src/web/network-share-publications/shared/network-share-ui.test.tsx b/modules/assets/src/web/network-share-publications/shared/network-share-ui.test.tsx new file mode 100644 index 0000000..09a7a00 --- /dev/null +++ b/modules/assets/src/web/network-share-publications/shared/network-share-ui.test.tsx @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { configureCsrfTokenReader } from "@metis/core/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createInstance } from "i18next"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nextProvider } from "react-i18next"; + +import { NetworkShareRelationshipResponseSchema } from "../../../common/network-share-publications"; +import { assetsEnglish, assetsSpanish } from "../../shared"; +import { NetworkSharePublications } from "../detail/ui/network-share-publications"; + +import { publicationApi, publicationKeys } from "./api/network-share-publication-api"; +import { PublicationRow } from "./ui/publication-row"; + +const company = "019a0000-0000-7000-8000-000000000001"; +const asset = "019a0000-0000-7000-8000-000000000002"; +const resource = "019a0000-0000-7000-8000-000000000003"; +const publication = "019a0000-0000-7000-8000-000000000004"; +const actor = "019a0000-0000-7000-8000-000000000005"; +const anchor = { type: "assets:asset", id: asset } as const; + +function fixture(ended = false) { + const source = { + providerId: "assets", + entityType: "assets:asset", + companyId: company, + id: asset, + }; + const target = { + providerId: "resources", + entityType: "resources:resource", + companyId: company, + id: resource, + }; + + return NetworkShareRelationshipResponseSchema.parse({ + page: { + items: [ + { + typeId: "assets:network-share-publication", + origin: { kind: "record", id: publication }, + source, + target, + stateCode: ended ? "ended" : "active", + }, + ], + nextCursor: "opaque-not-a-uuid", + }, + summaries: [ + { + status: "resolved", + summary: { + reference: target, + name: "Documentación", + subtitle: String.raw`\\NAS\Documentación\Áreas`, + state: { code: "active", labelKey: "resources:active" }, + }, + }, + ], + publications: [ + { + id: publication, + companyId: company, + assetId: asset, + resourceId: resource, + predecessorId: null, + startedAt: "2026-10-20T10:00:00.000Z", + startedBy: actor, + endedAt: ended ? "2026-10-21T10:00:00.000Z" : null, + endedBy: ended ? actor : null, + }, + ], + }); +} + +async function render(language: string, node: ReactNode, client = new QueryClient()) { + const i18n = createInstance(); + + await i18n.init({ + lng: language, + resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } }, + }); + try { + return renderToStaticMarkup( + + {node} + + ); + } finally { + client.clear(); + } +} + +test("SMB: relaciones paginadas, UNC Unicode y enlaces bidireccionales es/en", async () => { + for (const language of ["es", "en"]) { + const client = new QueryClient(); + + client.setQueryData(publicationKeys.page(company, anchor, false, null), fixture()); + const html = await render( + language, + , + client + ); + + assert.ok(html.includes(`/companies/${company}/resources/${resource}`)); + assert.ok(html.includes("Documentación")); + assert.ok(html.includes("Áreas")); + assert.ok(html.includes(language === "es" ? "Cambiar publicador" : "Change publisher")); + assert.equal(html.includes("Mostrando 1-"), false); + } +}); +test("SMB: historial conserva periodos y actores y no ofrece modificar periodos finalizados", async () => { + const data = fixture(true); + const item = data.page.items[0]; + + assert.ok(item); + const html = await render( + "es", + + + undefined} + period={data.publications[0]} + resolved={data.summaries[0]} + /> + +
+ ); + + assert.ok(html.includes(actor)); + assert.ok(html.includes('dateTime="2026-10-20T10:00:00.000Z"')); + assert.ok(html.includes("Finalizada")); + assert.equal(html.includes("Cambiar publicador"), false); +}); +test("SMB: vacío real y error de lectura son distintos", async () => { + const empty = new QueryClient(); + + empty.setQueryData(publicationKeys.page(company, anchor, false, null), { + ...fixture(), + page: { items: [], nextCursor: null }, + summaries: [], + publications: [], + }); + const html = await render( + "es", + , + empty + ); + + assert.ok(html.includes(assetsSpanish.shareUi.emptyAsset)); + assert.equal(html.includes(assetsSpanish.shareUi.readError), false); + const failed = new QueryClient(); + + failed.setQueryData(publicationKeys.page(company, anchor, false, null), fixture()); + failed + .getQueryCache() + .find({ queryKey: publicationKeys.page(company, anchor, false, null) }) + ?.setState({ status: "error", error: new Error("failure") }); + const error = await render( + "en", + , + failed + ); + + assert.ok(error.includes(assetsEnglish.shareUi.readError)); + assert.equal(error.includes("Documentación"), false); +}); +test("SMB: transferencia conserva UUID y envía un único comando propietario con CSRF", async () => { + const previous = globalThis.fetch; + const calls: { url: string; init: RequestInit | undefined }[] = []; + + configureCsrfTokenReader(() => "a".repeat(64)); + globalThis.fetch = async (input, init) => { + calls.push({ url: String(input), init }); + + return Response.json({ publication: fixture().publications[0] }); + }; + try { + await publicationApi.execute(company, { + action: "transfer", + publicationId: publication, + assetId: asset, + }); + assert.equal(calls.length, 1); + const call = calls[0]; + + assert.ok(call); + assert.deepEqual(JSON.parse(String(call.init?.body)), { + action: "transfer", + publicationId: publication, + assetId: asset, + }); + assert.equal(call.init?.method, "POST"); + assert.equal(new Headers(call.init?.headers).get("x-csrf-token"), "a".repeat(64)); + assert.equal(call.url.includes("operator"), false); + } finally { + globalThis.fetch = previous; + configureCsrfTokenReader(() => undefined); + } +}); +test("SMB: búsqueda remota y cursor opaco viajan al servidor", async () => { + const previous = globalThis.fetch; + const urls: string[] = []; + + globalThis.fetch = async (input) => { + urls.push(String(input)); + + return Response.json(fixture()); + }; + try { + await publicationApi.read(company, anchor, true, "signed-opaque-cursor"); + const url = new URL(urls[0] ?? "", "http://localhost"); + + assert.equal(url.searchParams.get("cursor"), "signed-opaque-cursor"); + assert.equal(url.searchParams.get("anchorId"), asset); + assert.equal(url.searchParams.get("includeEnded"), "true"); + assert.equal(url.searchParams.get("pageSize"), "20"); + } finally { + globalThis.fetch = previous; + } +}); diff --git a/modules/assets/src/web/network-share-publications/shared/ui/publication-list.tsx b/modules/assets/src/web/network-share-publications/shared/ui/publication-list.tsx new file mode 100644 index 0000000..4e448ee --- /dev/null +++ b/modules/assets/src/web/network-share-publications/shared/ui/publication-list.tsx @@ -0,0 +1,117 @@ +import { CursorPagination } from "@repo/rdx-ui/components/cursor-pagination"; +import { EmptyState } from "@repo/rdx-ui/components/empty-state"; +import { FolderIcon } from "@repo/rdx-ui/lib/icons"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@repo/shadcn-ui/components/table"; +import { useTranslation } from "react-i18next"; + +import type { PublicationOperation } from "../../manage/ui/publication-editor"; +import type { PublicationAnchor } from "../api/network-share-publication-api"; +import { usePublicationPage } from "../controllers/use-publication-page"; + +import { PublicationRow } from "./publication-row"; + +export function PublicationList({ + companyId, + anchor, + mode, + onManage, +}: { + companyId: string; + anchor: PublicationAnchor; + mode: "active" | "history"; + onManage: (operation: PublicationOperation) => void; +}) { + const { t } = useTranslation("assets"); + const c = usePublicationPage(companyId, anchor, mode === "history"); + const data = c.query.data; + const fromAsset = anchor.type === "assets:asset"; + + return ( +
+ {c.query.isPending &&

{t("loading")}

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

{t("shareUi.readError")}

+ +
+ )} + {data && !c.query.isError && ( + <> + {data.page.items.length ? ( +
+ + + + + {t(fromAsset ? "shareUi.resource" : "shareUi.publisher")} + + + {t(fromAsset ? "shareUi.address" : "template")} + + {t("status")} + {t("shareUi.publication")} + {mode === "history" && ( + <> + {t("shareUi.period")} + {t("shareUi.actor")} + + )} + {t("actions")} + + + + {data.page.items.map((item, index) => ( + item.origin.kind === "record" && period.id === item.origin.id + )} + resolved={data.summaries[index]} + /> + ))} + +
+
+ ) : ( + } + title={t(fromAsset ? "shareUi.emptyAsset" : "shareUi.emptyPublisher")} + /> + )} + + )} + +
+ ); +} diff --git a/modules/assets/src/web/network-share-publications/shared/ui/publication-period.tsx b/modules/assets/src/web/network-share-publications/shared/ui/publication-period.tsx new file mode 100644 index 0000000..fc21f23 --- /dev/null +++ b/modules/assets/src/web/network-share-publications/shared/ui/publication-period.tsx @@ -0,0 +1,40 @@ +import { TableCell } from "@repo/shadcn-ui/components/table"; +import { useTranslation } from "react-i18next"; + +import type { NetworkSharePublicationDTO } from "../../../../common/network-share-publications"; + +/** Solo presenta datos auditados por el propietario de la publicación. */ +export function PublicationPeriod({ period }: { period: NetworkSharePublicationDTO | undefined }) { + const { t, i18n } = useTranslation("assets"); + + const date = (value: string) => + new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format( + new Date(value) + ); + + if (!period) { + return ( + <> + — + — + + ); + } + + return ( + <> + + ·{" "} + {period.endedAt ? ( + + ) : ( + t("shareUi.current") + )} + + + {period.startedBy} +

{period.endedBy ?? "—"}

+
+ + ); +} diff --git a/modules/assets/src/web/network-share-publications/shared/ui/publication-row.tsx b/modules/assets/src/web/network-share-publications/shared/ui/publication-row.tsx new file mode 100644 index 0000000..1bde0d7 --- /dev/null +++ b/modules/assets/src/web/network-share-publications/shared/ui/publication-row.tsx @@ -0,0 +1,87 @@ +import { BrowserLink } from "@metis/core/client"; +import { EntityStatus } from "@repo/rdx-ui/components/entity-list"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { TableCell, TableRow } from "@repo/shadcn-ui/components/table"; +import { useTranslation } from "react-i18next"; + +import type { NetworkShareRelationshipResponse } from "../../../../common/network-share-publications"; +import type { PublicationOperation } from "../../manage/ui/publication-editor"; + +import { PublicationPeriod } from "./publication-period"; + +export function PublicationRow({ + companyId, + item, + resolved, + period, + fromAsset, + mode, + onManage, +}: { + companyId: string; + item: NetworkShareRelationshipResponse["page"]["items"][number]; + resolved: NetworkShareRelationshipResponse["summaries"][number] | undefined; + period: NetworkShareRelationshipResponse["publications"][number] | undefined; + fromAsset: boolean; + mode: "active" | "history"; + onManage: (operation: PublicationOperation) => void; +}) { + const { t } = useTranslation("assets"); + + if (item.origin.kind !== "record") { + return null; + } + const publicationId = item.origin.id; + const summary = resolved?.status === "resolved" ? resolved.summary : undefined; + const target = fromAsset ? item.target : item.source; + + const current = item.stateCode === "active"; + const canManage = mode === "active" && current; + const captured = { publicationId, assetId: item.source.id, resourceId: item.target.id }; + + return ( + + + {summary ? ( + + {summary.name} + + ) : ( + "—" + )} + + + {summary?.subtitle ?? "—"} + + {summary?.state ? t(summary.state.labelKey) : "—"} + + {t(`shareUi.states.${item.stateCode}`)} + + {mode === "history" && } + + {canManage ? ( +
+ + +
+ ) : ( + "—" + )} +
+
+ ); +} diff --git a/modules/companies/package.json b/modules/companies/package.json index 930ea1d..5aab6a4 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/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/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" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/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/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:*", diff --git a/modules/companies/src/api/application/access-response.builder.ts b/modules/companies/src/api/application/access-response.builder.ts index 45819b1..84070de 100644 --- a/modules/companies/src/api/application/access-response.builder.ts +++ b/modules/companies/src/api/application/access-response.builder.ts @@ -7,6 +7,8 @@ export function buildAccessResponse(snapshot: AccessSnapshot): AccessResponseDTO const items = [...snapshot.context.catalog, ...snapshot.workspace.items]; return { + localEnvironments: snapshot.localEnvironments ?? [], + directoryEnvironmentIds: [...(snapshot.directoryEnvironmentIds ?? [])], items: items.map((item) => ({ id: item.id.toPrimitive(), companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null, diff --git a/modules/companies/src/api/application/access.repository.ts b/modules/companies/src/api/application/access.repository.ts index 74b8c46..8a03dd3 100644 --- a/modules/companies/src/api/application/access.repository.ts +++ b/modules/companies/src/api/application/access.repository.ts @@ -1,10 +1,13 @@ import type { UniqueID } from "@repo/rdx-ddd"; import type { Maybe, Result } from "@repo/rdx-utils"; +import type { AccessResponseDTO } from "../../common/dto/shared/access.dto"; import type { AccessError } from "../domain/access/access-error"; import type { AccessContext, AccessWorkspace } from "../domain/access/access-workspace.aggregate"; export type AccessSnapshot = { + localEnvironments?: AccessResponseDTO["localEnvironments"]; + directoryEnvironmentIds?: readonly string[]; workspace: AccessWorkspace; actors: ReadonlyMap; context: AccessContext; diff --git a/modules/companies/src/api/application/folder-mapping.use-cases.ts b/modules/companies/src/api/application/folder-mapping.use-cases.ts index d3a23b4..69a8fa3 100644 --- a/modules/companies/src/api/application/folder-mapping.use-cases.ts +++ b/modules/companies/src/api/application/folder-mapping.use-cases.ts @@ -30,7 +30,13 @@ export class FolderMappingUseCases { : Promise.resolve(Result.fail(new FolderMappingError("INVALID_REQUEST"))); } - save(companyId: string, id: string | undefined, input: FolderMappingInput, actorId: string) { + save( + companyId: string, + id: string | undefined, + input: FolderMappingInput, + actorId: string, + expectedRevisionId?: string + ) { const company = UniqueID.create(companyId); const actor = UniqueID.create(actorId); const identity = id ? UniqueID.create(id) : undefined; @@ -47,6 +53,12 @@ export class FolderMappingUseCases { if (id && !previous) { return Result.fail(new FolderMappingError("FOLDER_MAPPING_NOT_FOUND")); } + if ( + expectedRevisionId !== undefined && + previous?.history.at(-1)?.id.toPrimitive() !== expectedRevisionId + ) { + return Result.fail(new FolderMappingError("FOLDER_MAPPING_CONFLICT")); + } const values = mapFolderInput(input, context, previous); if (!values.isSuccess) { diff --git a/modules/companies/src/api/infrastructure/http/folder-mapping.routes.ts b/modules/companies/src/api/infrastructure/http/folder-mapping.routes.ts index 77b589c..8d2344f 100644 --- a/modules/companies/src/api/infrastructure/http/folder-mapping.routes.ts +++ b/modules/companies/src/api/infrastructure/http/folder-mapping.routes.ts @@ -68,7 +68,13 @@ export function registerFolderMappingRoutes( "id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined; useCases - .save(params.data.companyId, id, body.data.values, actor.data.userId) + .save( + params.data.companyId, + id, + body.data.values, + actor.data.userId, + body.data.expectedRevisionId + ) .then((result) => { if (!result.isSuccess) { fail(response, result.error.code); diff --git a/modules/companies/src/api/infrastructure/persistence/access-persistence.ts b/modules/companies/src/api/infrastructure/persistence/access-persistence.ts index ccfd37a..f73f5fd 100644 --- a/modules/companies/src/api/infrastructure/persistence/access-persistence.ts +++ b/modules/companies/src/api/infrastructure/persistence/access-persistence.ts @@ -8,7 +8,9 @@ import type { Transaction, } from "sequelize"; import { DataTypes, Model, QueryTypes } from "sequelize"; +import { z } from "zod/v4"; +import { LocalIdentityOwnerSchema } from "../../../common/dto/shared/access.dto"; import type { AccessSnapshot, IAccessRepository } from "../../application/access.repository"; import type { ICompanyRepository } from "../../application/company.repository"; import type { DirectoryContextSnapshot } from "../../application/directory-context-plan"; @@ -350,7 +352,28 @@ export class SequelizeAccessRepository implements IAccessRepository { ); const actors = new Map(actorRows.map((row) => [row.id, row.name])); + const localEnvironments = company + ? z.array(LocalIdentityOwnerSchema).parse( + await this.database.query( + "SELECT l.environmentId,l.assetId,a.name AS assetName,l.endedAt FROM access_local_identity_environments l JOIN assets a ON a.id=l.assetId AND a.companyId=l.companyId WHERE l.companyId=:companyId", + { + replacements: { companyId: company.toPrimitive() }, + transaction, + type: QueryTypes.SELECT, + } + ) + ) + : []; + const directories = company + ? await this.database.query<{ environmentId: string }>( + "SELECT environmentId FROM access_directory_environments WHERE workspaceId=:workspaceId", + { replacements: { workspaceId: root.id }, transaction, type: QueryTypes.SELECT } + ) + : []; + return Result.ok({ + localEnvironments, + directoryEnvironmentIds: directories.map((row) => row.environmentId), actors, workspace, context: { catalog, people, today: new Date().toISOString().slice(0, 10) }, @@ -414,6 +437,8 @@ export class SequelizeAccessRepository implements IAccessRepository { } return Result.ok({ + localEnvironments: loaded.data.localEnvironments, + directoryEnvironmentIds: loaded.data.directoryEnvironmentIds ?? [], workspace: changed.data, context: loaded.data.context, actors: loaded.data.actors, diff --git a/modules/companies/src/common/dto/shared/access.dto.ts b/modules/companies/src/common/dto/shared/access.dto.ts index 0b3d144..d3fb1dc 100644 --- a/modules/companies/src/common/dto/shared/access.dto.ts +++ b/modules/companies/src/common/dto/shared/access.dto.ts @@ -3,7 +3,6 @@ import { z } from "zod/v4"; import { AccessDefinitionSchema } from "../../../api/domain/access/access-definition"; export type { AccessKind } from "../../../api/domain/access/access-definition"; - export { AccessKindSchema } from "../../../api/domain/access/access-definition"; export const AccessFieldsSchema = AccessDefinitionSchema; @@ -51,10 +50,19 @@ export const AccessCoverageSchema = z.strictObject({ missingPermissionIds: z.array(id), }); +export const LocalIdentityOwnerSchema = z.strictObject({ + environmentId: id, + assetId: id, + assetName: z.string().min(1), + endedAt: z.iso.datetime().nullable(), +}); + export const AccessResponseSchema = z.strictObject({ items: z.array(AccessItemSchema), reviews: z.array(AccessReviewSchema), coverage: z.array(AccessCoverageSchema), + localEnvironments: z.array(LocalIdentityOwnerSchema).optional(), + directoryEnvironmentIds: z.array(id).optional(), }); export type AccessResponseDTO = z.infer; diff --git a/modules/companies/src/common/folder-mappings.ts b/modules/companies/src/common/folder-mappings.ts index c8a87dc..73d17c4 100644 --- a/modules/companies/src/common/folder-mappings.ts +++ b/modules/companies/src/common/folder-mappings.ts @@ -8,7 +8,10 @@ export { FolderMappingInputSchema, } from "../api/domain/folder-mapping-values"; -export const SaveFolderMappingRequestSchema = z.strictObject({ values: FolderMappingInputSchema }); +export const SaveFolderMappingRequestSchema = z.strictObject({ + values: FolderMappingInputSchema, + expectedRevisionId: z.uuid().optional(), +}); export const FolderMappingSchema = z.strictObject({ id: z.uuid(), diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index 59cc11e..99f1a68 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -495,8 +495,22 @@ "COMPANY_NOT_FOUND": "The company is unavailable.", "INTERNAL_ERROR": "Unable to complete the operation. Try again.", "UNAUTHENTICATED": "Your session expired.", - "FORBIDDEN": "Reload the page to authorize the operation." - } + "FORBIDDEN": "Reload the page to authorize the operation.", + "FOLDER_MAPPING_CONFLICT": "Another operator changed this mapping. Your changes have not been saved. Refresh the list, close and reopen the form to review them." + }, + "accessAccount": "Account used for access", + "accountUnavailable": "Historical account unavailable", + "useProfileAccount": "Use profile account", + "clearAccount": "Remove account", + "documentaryCoverage": "Account coverage", + "accountCoverage": { + "unknown": "Account not specified", + "covered": "Documented coverage", + "not-covered": "No documented coverage" + }, + "accountHelp": "Explicitly select an account belonging to this person. It may differ from the profile account; no credentials are requested.", + "coverageHelp": "Documentary coverage of this account based on recorded needs, grants and memberships. It does not verify effective permissions.", + "refreshConflict": "Refresh list (close and reopen to review the changes)" }, "profiles": { "title": "User profiles", @@ -838,6 +852,45 @@ "assetInformation": { "profilesError": "The profiles for this asset could not be loaded.", "noProfiles": "This asset has no user profiles yet." + }, + "localIdentity": { + "title": "Local identities", + "asset": "Owning asset", + "chooseAsset": "Select asset", + "name": "Environment name", + "system": "System", + "environment": "Existing environment", + "create": "Create local environment", + "associate": "Link local environment", + "retire": "Retire local environment", + "retireHelp": "The local environment will be retired. Its owner and history are preserved. Review associated accounts and groups first.", + "description": "Local account environments linked to this asset, independent of its domain membership.", + "immutable": "The owning asset cannot change. Create a different environment for another asset.", + "scope": "No passwords are stored and no actual access is verified.", + "help": "Asset local identities", + "examples": "NAS-01 → local NAS users.", + "current": "Current", + "ended": "Retired", + "empty": "This asset has no documented local environments.", + "error": "The operation could not be completed. Refresh the data and review the associations.", + "previous": "Previous", + "next": "Next", + "errors": { + "IMMUTABLE_FIELDS": "This environment already has an immutable owner.", + "ACCESS_CONFLICT": "The environment has changed. Refresh the data and review the association.", + "ACCESS_NOT_FOUND": "This item is no longer available.", + "INACTIVE_TARGET": "The asset is not operational.", + "SCOPE_MISMATCH": "This environment or system is incompatible with a local identity in this company." + } + }, + "smb": { + "access": "Documented access", + "need": "Needs", + "grant": "Grants", + "permission": "Documented permissions", + "account": "Related accounts", + "group": "Related groups", + "documentary": "Coverage is derived from recorded needs, grants and memberships; it does not verify effective SMB permissions." } }, "access": { @@ -1071,7 +1124,10 @@ "INTERNAL_ERROR": "The action could not be completed." }, "directoryEnvironment": "Domain environment: {{name}}", - "clearEnvironment": "Clear environment filter" + "clearEnvironment": "Clear environment filter", + "resourceFilter": "Filtered resource: {{name}}", + "clearResource": "Clear resource filter", + "environmentFilter": "Environment: {{name}}" }, "windowsDomain": { "accountsAndGroups": "Active Directory accounts and groups", diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index 41f3ff3..f2e01b2 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -495,8 +495,22 @@ "COMPANY_NOT_FOUND": "La compañía no está disponible.", "INTERNAL_ERROR": "No se ha podido completar la operación. Inténtalo de nuevo.", "UNAUTHENTICATED": "La sesión ha caducado.", - "FORBIDDEN": "Recarga la página para autorizar la operación." - } + "FORBIDDEN": "Recarga la página para autorizar la operación.", + "FOLDER_MAPPING_CONFLICT": "Otro operador ha modificado este mapeo. Tus cambios no se han guardado. Actualiza el listado, cierra y vuelve a abrir el formulario para revisarlos." + }, + "accessAccount": "Cuenta utilizada para acceder", + "accountUnavailable": "Cuenta histórica no disponible", + "useProfileAccount": "Usar cuenta del perfil", + "clearAccount": "Retirar cuenta", + "documentaryCoverage": "Cobertura de la cuenta", + "accountCoverage": { + "unknown": "Cuenta no indicada", + "covered": "Cobertura documentada", + "not-covered": "Sin cobertura documentada" + }, + "accountHelp": "Selecciona explícitamente una cuenta de esta persona. Puede ser diferente de la cuenta del perfil; no se solicitan credenciales.", + "coverageHelp": "Cobertura documental de esta cuenta según necesidades, concesiones y pertenencias registradas. No verifica permisos efectivos.", + "refreshConflict": "Actualizar listado (cierra y vuelve a abrir para revisar los cambios)" }, "profiles": { "title": "Perfiles de usuario", @@ -838,6 +852,45 @@ "assetInformation": { "profilesError": "No se pudieron consultar los perfiles de este activo.", "noProfiles": "Este activo todavía no tiene perfiles de usuario." + }, + "localIdentity": { + "title": "Identidades locales", + "asset": "Activo propietario", + "chooseAsset": "Seleccionar activo", + "name": "Nombre del entorno", + "system": "Sistema", + "environment": "Entorno existente", + "create": "Crear entorno local", + "associate": "Vincular entorno local", + "retire": "Retirar entorno local", + "retireHelp": "Se retirará el entorno local. Su propietario y su historial se conservarán. Revisa primero las cuentas y grupos asociados.", + "description": "Entornos de cuentas locales vinculados a este activo, independientes de su pertenencia a un dominio.", + "immutable": "El activo propietario no puede cambiarse. Para otro activo, crea un entorno diferente.", + "scope": "No almacena contraseñas ni verifica accesos reales.", + "help": "Identidades locales del activo", + "examples": "NAS-01 → usuarios locales del NAS.", + "current": "Vigente", + "ended": "Retirado", + "empty": "Este activo no tiene entornos locales documentados.", + "error": "No se ha podido completar la operación. Actualiza los datos y revisa las asociaciones.", + "previous": "Anterior", + "next": "Siguiente", + "errors": { + "IMMUTABLE_FIELDS": "Este entorno ya tiene un propietario inmutable.", + "ACCESS_CONFLICT": "El entorno ha cambiado. Actualiza los datos y revisa la asociación.", + "ACCESS_NOT_FOUND": "El elemento ya no está disponible.", + "INACTIVE_TARGET": "El activo no está operativo.", + "SCOPE_MISMATCH": "El entorno o sistema no es compatible con una identidad local de esta compañía." + } + }, + "smb": { + "access": "Accesos documentados", + "need": "Necesidades", + "grant": "Concesiones", + "permission": "Permisos documentados", + "account": "Cuentas relacionadas", + "group": "Grupos relacionados", + "documentary": "La cobertura se deriva de necesidades, concesiones y pertenencias registradas; no verifica permisos SMB efectivos." } }, "access": { @@ -1071,7 +1124,10 @@ "INTERNAL_ERROR": "No se ha podido completar la operación." }, "directoryEnvironment": "Entorno del dominio: {{name}}", - "clearEnvironment": "Quitar filtro de entorno" + "clearEnvironment": "Quitar filtro de entorno", + "resourceFilter": "Recurso filtrado: {{name}}", + "clearResource": "Quitar filtro de recurso", + "environmentFilter": "Entorno: {{name}}" }, "windowsDomain": { "accountsAndGroups": "Cuentas y grupos de Active Directory", diff --git a/modules/companies/src/web/access/create/controllers/use-quick-create-dialog-controller.ts b/modules/companies/src/web/access/create/controllers/use-quick-create-dialog-controller.ts index 273b1a2..fc0422a 100644 --- a/modules/companies/src/web/access/create/controllers/use-quick-create-dialog-controller.ts +++ b/modules/companies/src/web/access/create/controllers/use-quick-create-dialog-controller.ts @@ -54,7 +54,14 @@ export function useQuickCreateDialogController({ return { result, id: created.id }; }, onSuccess: async ({ result, id }) => { - await client.invalidateQueries({ queryKey: ["access"] }); + await Promise.all([ + client.invalidateQueries({ + queryKey: scope === "global" ? ["access"] : accessKeys.scope(scope), + }), + client.invalidateQueries({ + queryKey: scope === "global" ? ["folder-mappings"] : ["folder-mappings", scope], + }), + ]); client.setQueryData(accessKeys.scope(scope), result); if (!dismissed.current) { onCreated(id); diff --git a/modules/companies/src/web/access/help/ui/entity-help.tsx b/modules/companies/src/web/access/help/ui/entity-help.tsx index 0ed1cff..f69e5db 100644 --- a/modules/companies/src/web/access/help/ui/entity-help.tsx +++ b/modules/companies/src/web/access/help/ui/entity-help.tsx @@ -1,14 +1,25 @@ import { ContextHelp } from "@repo/rdx-ui/components/context-help"; +import { FormHelp } from "@repo/rdx-ui/components/form-help"; import { useTranslation } from "react-i18next"; import type { HelpKind } from "../../shared/i18n"; -export function EntityHelp({ kind, scope }: { kind: HelpKind; scope?: string }) { +export function EntityHelp({ + kind, + scope, + placement = "body", +}: { + kind: HelpKind; + scope?: string; + placement?: "body" | "footer"; +}) { const { t } = useTranslation("access"); const catalogue = ["system", "resource_type", "permission", "template"].includes(kind); + const Help = placement === "footer" ? FormHelp : ContextHelp; + return ( - value === searchParams.get("kind")) ?? (scope === "global" ? "system" : "account"); const environmentId = searchParams.get("environmentId")?.toLowerCase() ?? null; + const resourceId = searchParams.get("resourceId")?.toLowerCase() ?? null; + const resource = data?.items.find( + (item) => item.id === resourceId && item.values.kind === "resource" + ); + const related = resourceId ? accessResourceIds(data?.items ?? [], resourceId) : null; + + const clearResource = () => + setSearchParams((params) => { + params.delete("resourceId"); + }); + const visible = data?.items.filter( (item) => item.values.kind === kind && + (related === null || related.has(item.id)) && matchesAccessEnvironment(item.values, environmentId) && accessLabel(item, data.items, people) .toLocaleLowerCase() @@ -67,6 +80,9 @@ export function useAccessWorkspacePageController({ scope, people = [] }: AccessW return { query, + resourceId, + resource, + clearResource, environmentId, environment, clearEnvironment, diff --git a/modules/companies/src/web/access/list/helpers/access-resource-filter.test.ts b/modules/companies/src/web/access/list/helpers/access-resource-filter.test.ts new file mode 100644 index 0000000..3621e46 --- /dev/null +++ b/modules/companies/src/web/access/list/helpers/access-resource-filter.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { AccessFieldsDTO } from "../../../../common"; +import { AccessItemSchema } from "../../../../common/dto/shared/access.dto"; +import { parseWorkspaceUrl } from "../../../workspace/shared/helpers/workspace-routes"; + +import { accessResourceIds } from "./access-resource-filter"; + +const company = "019a0000-0000-7000-8000-000000000001"; + +function item(id: string, values: AccessFieldsDTO) { + return AccessItemSchema.parse({ + id, + companyId: company, + values, + history: [ + { + id: "019a0000-0000-7000-8000-000000000002", + actorId: "019a0000-0000-7000-8000-000000000003", + actorName: "Operator", + recordedAt: "2026-10-20T10:00:00.000Z", + values, + }, + ], + }); +} + +const id = (n: number) => `019a0000-0000-7000-8000-${String(n).padStart(12, "0")}`; + +const period = { startDate: "2020-01-01", endDate: null }; + +test("recurso contextual: incluye identidades de sistemas distintos sin mezclar necesidades y concesiones ajenas", () => { + const resource = id(10); + const other = id(11); + const need = id(12); + const grant = id(13); + const group = id(14); + const account = id(15); + const permission = id(16); + const membership = id(17); + const relation = id(18); + const coverage = id(19); + const otherNeed = id(20); + const otherGrant = id(21); + const items = [ + item(need, { + kind: "need", + resourceId: resource, + personId: id(30), + permissionIds: [permission], + notes: null, + ...period, + }), + item(grant, { + kind: "grant", + resourceId: resource, + permissionId: permission, + accountId: null, + groupId: group, + configuration: "configured", + ...period, + }), + item(coverage, { + kind: "coverage", + needId: need, + grantId: grant, + accountPersonId: relation, + membershipId: membership, + ...period, + }), + item(membership, { + kind: "membership", + groupId: group, + accountId: account, + configuration: "configured", + ...period, + }), + item(relation, { + kind: "account_person", + personId: id(30), + accountId: account, + configuration: "configured", + ...period, + }), + item(account, { + kind: "account", + systemId: id(40), + environmentId: id(41), + login: "local", + usage: "personal", + active: true, + expiresOn: null, + notes: null, + }), + item(group, { + kind: "group", + systemId: id(42), + environmentId: id(43), + name: "AD", + identifier: "ad", + active: true, + description: null, + templateId: null, + }), + item(otherNeed, { + kind: "need", + resourceId: other, + personId: id(30), + permissionIds: [permission], + notes: null, + ...period, + }), + item(otherGrant, { + kind: "grant", + resourceId: other, + permissionId: permission, + accountId: account, + groupId: null, + configuration: "configured", + ...period, + }), + ]; + const selected = accessResourceIds(items, resource); + + for (const key of [ + resource, + need, + grant, + group, + account, + permission, + membership, + relation, + coverage, + id(40), + id(41), + id(42), + id(43), + ]) { + assert.ok(selected.has(key), key); + } + for (const key of [other, otherNeed, otherGrant]) { + assert.equal(selected.has(key), false); + } +}); +test("filtro vacío no incluye elementos ajenos y las rutas validan UUID y contexto de compañía", () => { + assert.deepEqual([...accessResourceIds([], id(10))], [id(10)]); + assert.equal( + parseWorkspaceUrl(`/companies/${company}/access?kind=need&resourceId=${id(10)}`).area, + "company" + ); + for (const url of [ + `/companies/${company}/access?resourceId=\\\\nas\\share`, + `/catalogs/access?resourceId=${id(10)}`, + `/companies/${company}/assets?resourceId=${id(10)}`, + `/companies/${company}/access?resourceId=${id(10)}&resourceId=${id(11)}`, + ]) { + assert.equal(parseWorkspaceUrl(url).area, "invalid-route"); + } +}); diff --git a/modules/companies/src/web/access/list/helpers/access-resource-filter.ts b/modules/companies/src/web/access/list/helpers/access-resource-filter.ts new file mode 100644 index 0000000..e6e1c0a --- /dev/null +++ b/modules/companies/src/web/access/list/helpers/access-resource-filter.ts @@ -0,0 +1,71 @@ +import type { AccessItemDTO } from "../../../../common"; + +function resourceReferences(item: AccessItemDTO, resourceId: string): readonly string[] { + const v = item.values; + + if ((v.kind !== "need" && v.kind !== "grant") || v.resourceId !== resourceId) { + return []; + } + + return v.kind === "need" + ? [item.id, ...v.permissionIds] + : [ + item.id, + v.permissionId, + ...[v.accountId, v.groupId].filter((id): id is string => id !== null), + ]; +} + +function identityReferences(item: AccessItemDTO, ids: ReadonlySet): readonly string[] { + const v = item.values; + + if (v.kind === "coverage" && ids.has(v.needId) && ids.has(v.grantId)) { + return [item.id, v.accountPersonId, ...(v.membershipId ? [v.membershipId] : [])]; + } + if (v.kind === "membership" && ids.has(v.groupId)) { + return [item.id, v.accountId]; + } + + return []; +} + +function accountReferences(item: AccessItemDTO, ids: ReadonlySet): readonly string[] { + const v = item.values; + + return v.kind === "account_person" && (ids.has(item.id) || ids.has(v.accountId)) + ? [item.id, v.accountId] + : []; +} + +function catalogReferences(item: AccessItemDTO, ids: ReadonlySet): readonly string[] { + if (!ids.has(item.id)) { + return []; + } + const v = item.values; + + return [ + ...("systemId" in v && v.systemId ? [v.systemId] : []), + ...("environmentId" in v && v.environmentId ? [v.environmentId] : []), + ]; +} + +/** Recorre referencias estructuradas sin comparar nombres ni direcciones UNC. */ +export function accessResourceIds( + items: readonly AccessItemDTO[], + resourceId: string +): ReadonlySet { + const ids = new Set([ + resourceId, + ...items.flatMap((item) => resourceReferences(item, resourceId)), + ]); + + for (const references of [identityReferences, accountReferences, catalogReferences]) { + for (const item of items) { + for (const id of references(item, ids)) { + ids.add(id); + } + } + } + + return ids; +} diff --git a/modules/companies/src/web/access/list/ui/access-workspace.tsx b/modules/companies/src/web/access/list/ui/access-workspace.tsx index 744fdfd..f471d44 100644 --- a/modules/companies/src/web/access/list/ui/access-workspace.tsx +++ b/modules/companies/src/web/access/list/ui/access-workspace.tsx @@ -1,8 +1,13 @@ import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list"; import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons"; import { Button } from "@repo/shadcn-ui/components/button"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import type { AccessItemDTO } from "../../../../common"; +import { LocalIdentityOwner } from "../../../local-identity-environments/detail/ui/local-identity-owner"; +import type { LocalIdentityOperation } from "../../../local-identity-environments/manage/ui/local-identity-editor"; +import { LocalIdentityEditor } from "../../../local-identity-environments/manage/ui/local-identity-editor"; import { AccessHelpDialog } from "../../help"; import { AccessHistory } from "../../history"; import { AccessEditorDialog } from "../../shared"; @@ -19,8 +24,12 @@ function errorCode(error: Error) { export function AccessWorkspacePage(props: AccessWorkspacePageProps) { const { scope, people = [] } = props; + const [localOperation, setLocalOperation] = useState(); const { query, + resourceId, + resource, + clearResource, environmentId, environment, clearEnvironment, @@ -48,7 +57,12 @@ export function AccessWorkspacePage(props: AccessWorkspacePageProps) { actions={ <> {" "} -
+
+ {scope !== "global" && kind === "environment" && ( + + )} @@ -64,13 +78,28 @@ export function AccessWorkspacePage(props: AccessWorkspacePageProps) { locale={i18n.language} title={t(`kinds.${kind}`)} /> + {resourceId && ( +
+

+ {t("resourceFilter", { + name: resource && "name" in resource.values ? resource.values.name : "—", + })} +

+ +
+ )} {environmentId && (

- {t("directoryEnvironment", { + {t("environmentFilter", { name: environment?.values.kind === "environment" ? environment.values.name : "—", })}

@@ -79,6 +108,13 @@ export function AccessWorkspacePage(props: AccessWorkspacePageProps) {
)} + {localOperation && ( + setLocalOperation(undefined)} + operation={localOperation} + /> + )}
); } diff --git a/modules/companies/src/web/folder-mappings/shared/api/folder-api.ts b/modules/companies/src/web/folder-mappings/shared/api/folder-api.ts index 253222c..da39105 100644 --- a/modules/companies/src/web/folder-mappings/shared/api/folder-api.ts +++ b/modules/companies/src/web/folder-mappings/shared/api/folder-api.ts @@ -35,14 +35,19 @@ export const folderApi = { ) ); }, - async save(companyId: string, values: FolderMappingInput, id?: string) { + async save( + companyId: string, + values: FolderMappingInput, + id?: string, + expectedRevisionId?: string + ) { return FolderMappingResponseSchema.parse( await responseData( await csrfFetch(`/api/folder-mappings/${companyId}${id ? `/${id}/update` : ""}`, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, - body: JSON.stringify({ values }), + body: JSON.stringify({ values, expectedRevisionId }), }) ) ); diff --git a/modules/companies/src/web/folder-mappings/shared/controllers/use-folder-mapping-form-controller.ts b/modules/companies/src/web/folder-mappings/shared/controllers/use-folder-mapping-form-controller.ts index bc1c457..c2bf7db 100644 --- a/modules/companies/src/web/folder-mappings/shared/controllers/use-folder-mapping-form-controller.ts +++ b/modules/companies/src/web/folder-mappings/shared/controllers/use-folder-mapping-form-controller.ts @@ -20,6 +20,7 @@ export function useFolderMappingFormController({ company, mapping, onSaved, + onPendingChange, initialProfileId = "", }: FolderMappingFormProps) { const client = useQueryClient(); @@ -63,7 +64,10 @@ export function useFolderMappingFormController({ }; const mutation = useMutation({ - mutationFn: (input: FolderMappingInput) => folderApi.save(company.id, input, mapping?.id), + onMutate: () => onPendingChange?.(true), + onSettled: () => onPendingChange?.(false), + mutationFn: (input: FolderMappingInput) => + folderApi.save(company.id, input, mapping?.id, mapping?.history.at(-1)?.id), onSuccess: async () => { await client.invalidateQueries({ queryKey: folderKeys.company(company.id) }); if (mounted.current) { @@ -94,6 +98,7 @@ export function useFolderMappingFormController({ return; } + form.setValue("accessAccountId", null); changeNeed(""); }; diff --git a/modules/companies/src/web/folder-mappings/shared/helpers/folder-account-options.test.ts b/modules/companies/src/web/folder-mappings/shared/helpers/folder-account-options.test.ts new file mode 100644 index 0000000..a79231a --- /dev/null +++ b/modules/companies/src/web/folder-mappings/shared/helpers/folder-account-options.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { AccessFieldsDTO } from "../../../../common"; +import { AccessItemSchema } from "../../../../common/dto/shared/access.dto"; +import { SaveFolderMappingRequestSchema } from "../../../../common/folder-mappings"; + +import { folderAccountOptions } from "./folder-account-options"; + +const id = (n: number) => `019a0000-0000-7000-8000-${String(n).padStart(12, "0")}`; + +function item(n: number, values: AccessFieldsDTO) { + return AccessItemSchema.parse({ + id: id(n), + companyId: id(1), + values, + history: [ + { + id: id(2), + actorId: id(3), + actorName: "Operator", + recordedAt: "2026-10-20T10:00:00.000Z", + values, + }, + ], + }); +} + +test("cuentas del mapeo: exige asociación vigente y catálogos activos; no atribuye por login o por perfil", () => { + const system = item(10, { kind: "system", name: "Local", active: true }); + const environment = item(11, { + kind: "environment", + name: "NAS", + systemId: system.id, + active: true, + }); + + const account = (n: number) => + item(n, { + kind: "account", + systemId: system.id, + environmentId: environment.id, + login: "duplicado", + usage: "shared", + active: true, + expiresOn: null, + notes: null, + }); + + const first = account(20); + const alternative = account(21); + const foreign = account(22); + + const relation = (n: number, accountId: string, personId: string) => + item(n, { + kind: "account_person", + accountId, + personId, + startDate: "2020-01-01", + endDate: null, + configuration: "configured", + }); + + const items = [ + system, + environment, + first, + alternative, + foreign, + relation(30, first.id, id(40)), + relation(31, alternative.id, id(40)), + relation(32, foreign.id, id(41)), + ]; + + assert.deepEqual( + folderAccountOptions(items, id(40), "2026-10-21").map((item) => item.id), + [first.id, alternative.id] + ); + assert.equal(folderAccountOptions(items, undefined, "2026-10-21").length, 0); + assert.equal( + folderAccountOptions( + items.map((item) => + item.id === system.id ? { ...item, values: { ...system.values, active: false } } : item + ), + id(40), + "2026-10-21" + ).length, + 0 + ); +}); +test("actualización del mapeo: omitir cuenta conserva semántica distinta de retirarla explícitamente; admite revisión capturada", () => { + const values = { + profileId: id(50), + needId: id(51), + path: String.raw`\\NAS\Docs`, + driveLetter: "F", + state: "configured", + notes: null, + }; + const omitted = SaveFolderMappingRequestSchema.parse({ values, expectedRevisionId: id(52) }); + + assert.equal(Object.hasOwn(omitted.values, "accessAccountId"), false); + const cleared = SaveFolderMappingRequestSchema.parse({ + values: { ...values, accessAccountId: null }, + }); + + assert.equal(cleared.values.accessAccountId, null); + assert.equal( + SaveFolderMappingRequestSchema.safeParse({ values, expectedRevisionId: "invalid" }).success, + false + ); +}); diff --git a/modules/companies/src/web/folder-mappings/shared/helpers/folder-account-options.ts b/modules/companies/src/web/folder-mappings/shared/helpers/folder-account-options.ts new file mode 100644 index 0000000..8dd6746 --- /dev/null +++ b/modules/companies/src/web/folder-mappings/shared/helpers/folder-account-options.ts @@ -0,0 +1,44 @@ +import type { AccessItemDTO } from "../../../../common"; + +/** Es una ayuda de selección; la validación definitiva sigue perteneciendo a Companies. */ +export function folderAccountOptions( + items: readonly AccessItemDTO[], + personId: string | undefined, + today: string +) { + const linked = new Set( + items + .filter((item) => { + const v = item.values; + + return ( + v.kind === "account_person" && + v.personId === personId && + v.startDate <= today && + v.endDate === null && + (v.configuration === null || + v.configuration === "pending" || + v.configuration === "configured") + ); + }) + .flatMap((item) => (item.values.kind === "account_person" ? [item.values.accountId] : [])) + ); + + return items.filter((item) => { + const v = item.values; + + return ( + v.kind === "account" && + v.active && + linked.has(item.id) && + (v.expiresOn === null || v.expiresOn >= today) && + [v.systemId, v.environmentId].every( + (id) => + id === null || + items.some( + (target) => target.id === id && "active" in target.values && target.values.active + ) + ) + ); + }); +} diff --git a/modules/companies/src/web/folder-mappings/shared/types/folder-mapping-form-props.ts b/modules/companies/src/web/folder-mappings/shared/types/folder-mapping-form-props.ts index 78d2db3..e200452 100644 --- a/modules/companies/src/web/folder-mappings/shared/types/folder-mapping-form-props.ts +++ b/modules/companies/src/web/folder-mappings/shared/types/folder-mapping-form-props.ts @@ -5,5 +5,6 @@ export type FolderMappingFormProps = { company: CompanyDTO; mapping?: FolderMappingDTO | undefined; onSaved: () => void; + onPendingChange?: (pending: boolean) => void; initialProfileId?: string; }; diff --git a/modules/companies/src/web/folder-mappings/shared/ui/folder-access-account-field.tsx b/modules/companies/src/web/folder-mappings/shared/ui/folder-access-account-field.tsx new file mode 100644 index 0000000..8e80f22 --- /dev/null +++ b/modules/companies/src/web/folder-mappings/shared/ui/folder-access-account-field.tsx @@ -0,0 +1,86 @@ +import { FormLabel } from "@repo/rdx-ui/components/form-label"; +import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Field, FieldDescription } from "@repo/shadcn-ui/components/field"; +import { useId } from "react"; +import { useTranslation } from "react-i18next"; + +import type { AccessItemDTO } from "../../../../common"; +import type { WorkProfileDTO } from "../../../../common/work-profiles"; +import { accessLabel } from "../../../access/shared/helpers"; +import { folderAccountOptions } from "../helpers/folder-account-options"; + +export function FolderAccessAccountField({ + items, + profile, + value, + onChange, + disabled, +}: { + items: readonly AccessItemDTO[]; + profile: WorkProfileDTO | undefined; + value: string | null | undefined; + onChange: (id: string | null) => void; + disabled: boolean; +}) { + const { t } = useTranslation("companies"); + const id = useId(); + const eligible = folderAccountOptions( + items, + profile?.values.personId, + new Date().toISOString().slice(0, 10) + ); + const options = eligible.map((item) => ({ value: item.id, label: accessLabel(item, items, []) })); + const previous = items.find((item) => item.id === value); + + if (value && !options.some((option) => option.value === value)) { + options.push({ + value, + label: previous ? accessLabel(previous, items, []) : t("folders.accountUnavailable"), + }); + } + const profileAccount = profile?.values.accountId; + + return ( + + {t("folders.accessAccount")} + + onChange(selected === "none" || selected === "" ? null : selected) + } + options={[{ value: "none", label: t("folders.accountCoverage.unknown") }, ...options]} + placeholder={t("mail.choose")} + value={value ?? "none"} + /> +
+ + {value && ( + + )} +
+ {t("folders.accountHelp")} +
+ ); +} diff --git a/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-editor-dialog.tsx b/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-editor-dialog.tsx index 18f1a00..eb0b46b 100644 --- a/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-editor-dialog.tsx +++ b/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-editor-dialog.tsx @@ -1,5 +1,6 @@ import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; import type { ComponentProps } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { CompanyDTO } from "../../../../common"; @@ -17,14 +18,17 @@ export function FolderMappingEditorDialog({ initialProfileId: ComponentProps["initialProfileId"]; onClose: () => void; }) { + const [snapshot] = useState(mapping); + const [pending, setPending] = useState(false); const { t } = useTranslation("companies"); return ( { - if (!open) { + if (!(open || pending)) { onClose(); } }} @@ -35,7 +39,8 @@ export function FolderMappingEditorDialog({ onClose()} /> diff --git a/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-form.tsx b/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-form.tsx index 347a2ec..f5fb095 100644 --- a/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-form.tsx +++ b/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-form.tsx @@ -1,22 +1,26 @@ -import { ContextHelp } from "@repo/rdx-ui/components/context-help"; import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog"; +import { FormHelp } from "@repo/rdx-ui/components/form-help"; import { FormLabel } from "@repo/rdx-ui/components/form-label"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; import { Button } from "@repo/shadcn-ui/components/button"; import { Input } from "@repo/shadcn-ui/components/input"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import { useQueryClient } from "@tanstack/react-query"; import { useId } from "react"; import { useTranslation } from "react-i18next"; import { FolderMappingInputSchema } from "../../../../common/folder-mappings"; import { FolderQuickEditor } from "../../create"; +import { folderKeys } from "../api"; import { useFolderMappingFormController } from "../controllers"; import type { FolderMappingFormProps } from "../types"; +import { FolderAccessAccountField } from "./folder-access-account-field"; import { FolderFeedback } from "./folder-feedback"; import { FolderMappingTargets } from "./folder-mapping-targets"; export function FolderMappingForm(props: FolderMappingFormProps) { + const client = useQueryClient(); const { company, mapping, initialProfileId = "" } = props; const { quick, @@ -58,6 +62,13 @@ export function FolderMappingForm(props: FolderMappingFormProps) { profiles={profiles.data?.profiles ?? []} ready={ready} /> + form.setValue("accessAccountId", id, { shouldDirty: true })} + profile={profile} + value={values.accessAccountId} + />
{t("folders.path")} @@ -124,16 +135,27 @@ export function FolderMappingForm(props: FolderMappingFormProps) {

{t("folders.errors.INVALID_REQUEST")}

)} - + {mutation.error?.message === "FOLDER_MAPPING_CONFLICT" && ( + + )} - + + diff --git a/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-targets.tsx b/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-targets.tsx index cc84971..543601a 100644 --- a/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-targets.tsx +++ b/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-targets.tsx @@ -55,6 +55,7 @@ export function FolderMappingTargets({ label={t("mail.profile")} onValueChange={(id) => { form.setValue("profileId", id); + form.setValue("accessAccountId", null); onNeedChange(""); }} options={profiles diff --git a/modules/companies/src/web/local-identity-environments/detail/ui/asset-local-identities.tsx b/modules/companies/src/web/local-identity-environments/detail/ui/asset-local-identities.tsx new file mode 100644 index 0000000..9fe4100 --- /dev/null +++ b/modules/companies/src/web/local-identity-environments/detail/ui/asset-local-identities.tsx @@ -0,0 +1,146 @@ +import { BrowserLink } from "@metis/core/client"; +import { CursorPagination } from "@repo/rdx-ui/components/cursor-pagination"; +import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; +import { EntityStatus } from "@repo/rdx-ui/components/entity-list"; +import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { getAccess } from "../../../access/list/api"; +import { accessKeys } from "../../../access/shared/queries"; +import type { LocalIdentityOperation } from "../../manage/ui/local-identity-editor"; +import { LocalIdentityEditor } from "../../manage/ui/local-identity-editor"; +import { localIdentityApi, localIdentityKeys } from "../../shared/api/local-identity-api"; + +export function AssetLocalIdentities({ + companyId, + asset, +}: { + companyId: string; + asset: { id: string; name: string }; +}) { + const { t } = useTranslation("companies"); + const [cursors, setCursors] = useState([]); + const cursor = cursors.at(-1); + const [operation, setOperation] = useState(); + const query = useQuery({ + queryKey: localIdentityKeys.page(companyId, asset.id, cursor), + queryFn: () => localIdentityApi.get(companyId, asset.id, cursor), + }); + const access = useQuery({ + queryKey: accessKeys.scope(companyId), + queryFn: () => getAccess(companyId), + }); + + const label = (id: string) => { + const value = access.data?.items.find((item) => item.id === id)?.values; + + return value && "name" in value ? value.name : "—"; + }; + + return ( + + + +
+ } + icon={} + title={t("localIdentity.title")} + > +

{t("localIdentity.description")}

+ {(query.isPending || access.isPending) &&

{t("loading")}

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

{t("localIdentity.error")}

+ +
+ )} + {!(query.isError || access.isError) && query.data && ( + <> + {query.data.environments.length ? ( +
    + {query.data.environments.map((item) => ( +
  • +
    + + {label(item.environmentId)} + +

    {label(item.systemId)}

    + + {t(item.endedAt ? "localIdentity.ended" : "localIdentity.current")} + +

    + ·{" "} + {item.endedAt ?? "—"} +

    +
    + {!item.endedAt && ( + + )} +
  • + ))} +
+ ) : ( +

+ {t("localIdentity.empty")} +

+ )} + + )} + 0} + nextLabel={t("localIdentity.next")} + onNext={() => { + const next = query.data?.nextAfterId; + + if (next) { + setCursors((values) => [...values, next]); + } + }} + onPrevious={() => setCursors((values) => values.slice(0, -1))} + onRestart={() => { + setCursors([]); + query.refetch().catch(() => undefined); + }} + pending={query.isFetching} + previousLabel={t("localIdentity.previous")} + restartLabel={t("retry")} + /> + {operation && ( + setOperation(undefined)} + operation={operation} + /> + )} + + ); +} diff --git a/modules/companies/src/web/local-identity-environments/detail/ui/local-identity-owner.tsx b/modules/companies/src/web/local-identity-environments/detail/ui/local-identity-owner.tsx new file mode 100644 index 0000000..4282b95 --- /dev/null +++ b/modules/companies/src/web/local-identity-environments/detail/ui/local-identity-owner.tsx @@ -0,0 +1,36 @@ +import { BrowserLink } from "@metis/core/client"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { useTranslation } from "react-i18next"; + +export function LocalIdentityOwner({ + companyId, + owner, + onAssociate, +}: { + companyId: string; + owner: { assetId: string; assetName: string; endedAt: string | null } | undefined; + onAssociate: (() => void) | undefined; +}) { + const { t } = useTranslation("companies"); + + if (owner) { + return ( +
+ + {owner.assetName} + + {owner.endedAt && ( +

{t("localIdentity.ended")}

+ )} +
+ ); + } + + return onAssociate ? ( + + ) : ( + "—" + ); +} diff --git a/modules/companies/src/web/local-identity-environments/manage/ui/local-identity-editor.tsx b/modules/companies/src/web/local-identity-environments/manage/ui/local-identity-editor.tsx new file mode 100644 index 0000000..de41450 --- /dev/null +++ b/modules/companies/src/web/local-identity-environments/manage/ui/local-identity-editor.tsx @@ -0,0 +1,254 @@ +import { PublicationPicker } from "@metis/assets/client"; +import { FormActions, FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog"; +import { FormHelp } from "@repo/rdx-ui/components/form-help"; +import { FormLabel } from "@repo/rdx-ui/components/form-label"; +import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Field } from "@repo/shadcn-ui/components/field"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { LocalIdentityCommand } from "../../../../common/local-identity-environments"; +import { getAccess } from "../../../access/list/api"; +import { accessKeys } from "../../../access/shared/queries"; +import { localIdentityApi, localIdentityKeys } from "../../shared/api/local-identity-api"; + +export type LocalIdentityOperation = + | { action: "create" | "associate"; asset?: { id: string; name: string }; environmentId?: string } + | { action: "retire"; environmentId: string }; + +function validOperation( + operation: LocalIdentityOperation, + asset: { id: string; name: string } | undefined, + systemId: string, + name: string, + environmentId: string +) { + if (operation.action === "retire") { + return true; + } + if (!asset) { + return false; + } + + return operation.action === "create" ? Boolean(systemId && name.trim()) : Boolean(environmentId); +} + +/** Las operaciones de asociación son explícitas y nunca reasignan un propietario existente. */ +export function LocalIdentityEditor({ + companyId, + operation, + onClose, +}: { + companyId: string; + operation: LocalIdentityOperation; + onClose: () => void; +}) { + const { t } = useTranslation("companies"); + const prefix = useId(); + const client = useQueryClient(); + const data = useQuery({ + queryKey: accessKeys.scope(companyId), + queryFn: () => getAccess(companyId), + }); + const [asset, setAsset] = useState(operation.action === "retire" ? undefined : operation.asset); + const [picker, setPicker] = useState(false); + const [name, setName] = useState(""); + const [systemId, setSystemId] = useState(""); + const [environmentId, setEnvironmentId] = useState(operation.environmentId ?? ""); + const items = data.data?.items ?? []; + const owners = data.data?.localEnvironments ?? []; + const environments = items.filter( + (item) => + item.companyId === companyId && + item.values.kind === "environment" && + item.values.active && + !owners.some((owner) => owner.environmentId === item.id) && + !data.data?.directoryEnvironmentIds?.includes(item.id) + ); + const directorySystems = new Set( + items.flatMap((item) => + item.values.kind === "environment" && data.data?.directoryEnvironmentIds?.includes(item.id) + ? [item.values.systemId] + : [] + ) + ); + const systems = items.filter( + (item) => item.values.kind === "system" && item.values.active && !directorySystems.has(item.id) + ); + const mutation = useMutation({ + mutationFn: (command: LocalIdentityCommand) => localIdentityApi.execute(companyId, command), + onSuccess: async () => { + await Promise.all([ + client.invalidateQueries({ queryKey: localIdentityKeys.company(companyId) }), + client.invalidateQueries({ queryKey: accessKeys.scope(companyId) }), + client.invalidateQueries({ queryKey: ["folder-mappings", companyId] }), + ]); + onClose(); + }, + }); + + const valid = validOperation(operation, asset, systemId, name, environmentId); + + function save() { + if (mutation.isPending || !valid) { + return; + } + if (operation.action === "retire") { + mutation.mutate({ action: "retire", environmentId: operation.environmentId }); + } else if (asset && operation.action === "create") { + mutation.mutate({ action: "create", assetId: asset.id, systemId, name: name.trim() }); + } else if (asset && environmentId) { + mutation.mutate({ action: "associate", assetId: asset.id, environmentId }); + } + } + + return ( + <> + { + if (!(open || mutation.isPending)) { + onClose(); + } + }} + open + title={t(`localIdentity.${operation.action}`)} + > +
{ + event.preventDefault(); + save(); + }} + > + +
+ {operation.action !== "retire" && ( + <> + + + {t("localIdentity.asset")} + + + + {operation.action === "create" ? ( + <> + + + {t("localIdentity.name")} + + setName(event.target.value)} + required + value={name} + /> + + + + {t("localIdentity.system")} + + ({ + value: item.id, + label: "name" in item.values ? item.values.name : "—", + }))} + placeholder={t("mail.choose")} + required + value={systemId} + /> + + + ) : ( + + + {t("localIdentity.environment")} + + ({ + value: item.id, + label: "name" in item.values ? item.values.name : "—", + }))} + placeholder={t("mail.choose")} + required + value={environmentId} + /> + + )} + + )} + {operation.action === "retire" &&

{t("localIdentity.retireHelp")}

} +
+ {data.isPending &&

{t("loading")}

} + {(data.isError || mutation.isError) && ( +
+

+ {t(`localIdentity.errors.${mutation.error?.message}`, { + defaultValue: t("localIdentity.error"), + })} +

+ +
+ )} +
+ + + + + +
+ {picker && ( + setPicker(false)} + onSelect={(option) => setAsset({ id: option.id, name: option.label })} + title={t("localIdentity.chooseAsset")} + /> + )} + + ); +} diff --git a/modules/companies/src/web/local-identity-environments/shared/api/local-identity-api.ts b/modules/companies/src/web/local-identity-environments/shared/api/local-identity-api.ts new file mode 100644 index 0000000..903b0e7 --- /dev/null +++ b/modules/companies/src/web/local-identity-environments/shared/api/local-identity-api.ts @@ -0,0 +1,57 @@ +import { csrfFetch } from "@metis/core/client"; +import { z } from "zod/v4"; + +import type { LocalIdentityCommand } from "../../../../common/local-identity-environments"; +import { + LocalIdentityCommandSchema, + LocalIdentityEnvironmentSchema, + LocalIdentityPageSchema, +} from "../../../../common/local-identity-environments"; + +export const localIdentityKeys = { + company: (id: string) => ["local-identities", id] as const, + page: (id: string, assetId: string, cursor: string | undefined) => + ["local-identities", id, assetId, cursor ?? ""] as const, +}; + +async function body(response: Response) { + const value: unknown = await response.json(); + + if (!response.ok) { + const error = z.object({ code: z.string() }).safeParse(value); + + throw new Error(error.success ? error.data.code : "INTERNAL_ERROR"); + } + + return value; +} + +export const localIdentityApi = { + async get(companyId: string, assetId: string, cursor?: string) { + const params = new URLSearchParams({ assetId, includeEnded: "true", pageSize: "20" }); + + if (cursor) { + params.set("afterId", cursor); + } + + return LocalIdentityPageSchema.parse( + await body( + await csrfFetch(`/api/local-identity-environments/${companyId}?${params}`, { + credentials: "same-origin", + }) + ) + ); + }, + async execute(companyId: string, command: LocalIdentityCommand) { + return z.strictObject({ environment: LocalIdentityEnvironmentSchema }).parse( + await body( + await csrfFetch(`/api/local-identity-environments/${companyId}`, { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify(LocalIdentityCommandSchema.parse(command)), + }) + ) + ); + }, +}; diff --git a/modules/companies/src/web/local-identity-environments/shared/local-identities-ui.test.tsx b/modules/companies/src/web/local-identity-environments/shared/local-identities-ui.test.tsx new file mode 100644 index 0000000..855643a --- /dev/null +++ b/modules/companies/src/web/local-identity-environments/shared/local-identities-ui.test.tsx @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createInstance } from "i18next"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nextProvider } from "react-i18next"; + +import { AccessItemSchema, AccessResponseSchema } from "../../../common/dto/shared/access.dto"; +import { WorkProfileSchema } from "../../../common/work-profiles"; +import { accessKeys } from "../../access/shared/queries"; +import { FolderAccessAccountField } from "../../folder-mappings/shared/ui/folder-access-account-field"; +import { companiesEnglish, companiesSpanish } from "../../shared"; +import { AssetLocalIdentities } from "../detail/ui/asset-local-identities"; +import { LocalIdentityOwner } from "../detail/ui/local-identity-owner"; + +import { localIdentityKeys } from "./api/local-identity-api"; + +const id = (n: number) => `019a0000-0000-7000-8000-${String(n).padStart(12, "0")}`; + +const company = id(1); +const asset = id(2); +const environment = id(3); +const system = id(4); +const account = id(5); +const person = id(6); +const recordedAt = "2026-10-20T10:00:00.000Z"; + +async function render(language: string, node: ReactNode, client = new QueryClient()) { + const i18n = createInstance(); + + await i18n.init({ + lng: language, + resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } }, + }); + try { + return renderToStaticMarkup( + + {node} + + ); + } finally { + client.clear(); + } +} + +const access = AccessResponseSchema.parse({ + items: [ + { + id: environment, + companyId: company, + values: { kind: "environment", name: "NAS local", active: true, systemId: system }, + history: [ + { + id: id(10), + actorId: id(11), + actorName: "Operator", + recordedAt, + values: { kind: "environment", name: "NAS local", active: true, systemId: system }, + }, + ], + }, + ], + reviews: [], + coverage: [], + localEnvironments: [ + { environmentId: environment, assetId: asset, assetName: "NAS-Áreas", endedAt: null }, + ], +}); + +test("identidades locales: entorno del activo, navegación por UUID y estado vigente en es/en", async () => { + for (const language of ["es", "en"]) { + const client = new QueryClient(); + + client.setQueryData(accessKeys.scope(company), access); + client.setQueryData(localIdentityKeys.page(company, asset, undefined), { + environments: [ + { + id: id(12), + companyId: company, + environmentId: environment, + assetId: asset, + systemId: system, + identityScope: "local-device", + startedAt: recordedAt, + startedBy: id(11), + endedAt: null, + endedBy: null, + }, + ], + nextAfterId: null, + }); + const html = await render( + language, + , + client + ); + + assert.ok(html.includes("NAS local")); + assert.ok( + html.includes(`/companies/${company}/access?kind=account&environmentId=${environment}`) + ); + assert.ok(html.includes(language === "es" ? "Vigente" : "Current")); + } +}); +test("propietario local: vínculo retirado conserva navegación y no ofrece reasignación", async () => { + const html = await render( + "es", + undefined} + owner={{ assetId: asset, assetName: "NAS-Áreas", endedAt: recordedAt }} + /> + ); + + assert.ok(html.includes(`/companies/${company}/assets/${asset}`)); + assert.ok(html.includes("Retirado")); + assert.equal(html.includes("Vincular entorno local"), false); +}); +test("cuenta del mapeo: seleccionar perfil es una acción explícita; el render no infiere ni cambia cuenta", async () => { + const values = { + name: "Perfil", + personId: person, + assetId: asset, + accountId: account, + active: true, + notes: null, + }; + const profile = WorkProfileSchema.parse({ + id: id(20), + companyId: company, + values, + needsReview: false, + history: [{ id: id(21), values, actorId: id(11), actorName: "Operator", recordedAt }], + }); + const accountValues = { + kind: "account", + systemId: system, + environmentId: null, + login: "Cuenta perfil", + usage: "personal", + active: true, + expiresOn: null, + notes: null, + }; + const accountItem = AccessItemSchema.parse({ + id: account, + companyId: company, + values: accountValues, + history: [ + { id: id(22), values: accountValues, actorId: id(11), actorName: "Operator", recordedAt }, + ], + }); + let changed = false; + + for (const language of ["es", "en"]) { + const html = await render( + language, + { + changed = true; + }} + profile={profile} + value={null} + /> + ); + + assert.ok(html.includes(language === "es" ? "Usar cuenta del perfil" : "Use profile account")); + assert.ok(html.includes(language === "es" ? "Cuenta no indicada" : "Account not specified")); + assert.equal(changed, false); + } +}); diff --git a/modules/companies/src/web/resources/detail/ui/company-resource-related-content.tsx b/modules/companies/src/web/resources/detail/ui/company-resource-related-content.tsx index 1c931d0..9c391d4 100644 --- a/modules/companies/src/web/resources/detail/ui/company-resource-related-content.tsx +++ b/modules/companies/src/web/resources/detail/ui/company-resource-related-content.tsx @@ -1,4 +1,4 @@ -import { DomainDirectoryMemberships } from "@metis/assets/client"; +import { DomainDirectoryMemberships, NetworkSharePublications } from "@metis/assets/client"; import { BrowserLink } from "@metis/core/client"; import type { ResourceDTO } from "@metis/resources/common"; import { DetailPanel } from "@repo/rdx-ui/components/detail-panel"; @@ -18,6 +18,32 @@ export function CompanyResourceRelatedContent({ const { t } = useTranslation("companies"); const directory = resource.directory; + if (resource.networkShare?.capability === "network:smb-share") { + return ( +
+ + } title={t("smb.access")}> +
+ {(["need", "grant", "permission", "account", "group"] as const).map((kind) => ( + + {t(`smb.${kind}`)} + + ))} +
+

{t("smb.documentary")}

+
+
+ ); + } + if (directory?.capability === "directory:domain") { return (
diff --git a/modules/companies/src/web/work-environments/shared/helpers/work-environment.model.ts b/modules/companies/src/web/work-environments/shared/helpers/work-environment.model.ts index a02aaf6..00f6eb3 100644 --- a/modules/companies/src/web/work-environments/shared/helpers/work-environment.model.ts +++ b/modules/companies/src/web/work-environments/shared/helpers/work-environment.model.ts @@ -10,6 +10,7 @@ import type { PrinterConfigurationDTO, WorkProfileDTO, } from "../../../../common"; +import { accessLabel } from "../../../access/shared/helpers"; export type ProfileConfigurationKind = | "mail" @@ -184,6 +185,23 @@ function appendFolderRows( value: item.values.driveLetter ? `${item.values.driveLetter.replace(/:$/, "")}:` : "—", }, { label: "folders.permissions", value: permissions || "—" }, + { + label: "folders.accessAccount", + value: (() => { + const account = sources.access.find( + (entry) => entry.id === item.values.accessAccountId + ); + + return account?.values.kind === "account" + ? accessLabel(account, sources.access, []) + : "—"; + })(), + }, + { + label: "folders.documentaryCoverage", + value: `folders.accountCoverage.${item.accessAccountCoverage}`, + translated: true, + }, { label: "environment.authorization", value: item.permissionsCovered ? "folders.covered" : "folders.uncovered", diff --git a/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts b/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts index 8ff9ba0..232e1ff 100644 --- a/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts +++ b/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts @@ -65,6 +65,7 @@ const RouteQuerySchema = z.strictObject({ tab: z.enum(["templates", "fields", "relations"]).optional(), kind: AccessKindSchema.optional(), environmentId: z.uuid().optional(), + resourceId: z.uuid().optional(), }); export function companySectionHref(companyId: string, section: CompanyTab): string { @@ -168,6 +169,10 @@ function queryMatchesLocation( return false; } + if (query.resourceId && !(location.area === "company" && location.section === "access")) { + return false; + } + return kindMatchesLocation(location, query.kind) && tabMatchesLocation(location, query.tab); } diff --git a/modules/resources/src/api/infrastructure/persistence/network-share-options.ts b/modules/resources/src/api/infrastructure/persistence/network-share-options.ts new file mode 100644 index 0000000..a4dcf64 --- /dev/null +++ b/modules/resources/src/api/infrastructure/persistence/network-share-options.ts @@ -0,0 +1,43 @@ +import type { RelationshipContext, RelationshipReadAccess } from "@metis/relationship-contracts"; +import { Result } from "@repo/rdx-utils"; +import type { Sequelize } from "sequelize"; +import { QueryTypes } from "sequelize"; +import type { z } from "zod/v4"; + +import type { NetworkShareOptionsQuerySchema } from "../../../common/network-shares"; +import { NetworkShareOptionsSchema } from "../../../common/network-shares"; + +/** Selección SMB estructurada y paginada, sin descargar recursos o revisiones de la compañía. */ +export class NetworkShareOptions { + constructor( + private readonly database: Sequelize, + private readonly access: RelationshipReadAccess + ) {} + + async list(query: z.infer, context: RelationshipContext) { + const allowed = await this.access.authorize(context); + + if (!allowed.isSuccess) { + return allowed; + } + const rows = await this.database.query<{ id: string; name: string; address: string }>( + `SELECT r.id,r.name,c.textValue AS address FROM resources r JOIN resource_network_shares s ON s.resourceId=r.id AND s.companyId=r.companyId JOIN resource_characteristics c ON c.resourceId=r.id JOIN resource_definitions f ON f.id=c.fieldId AND f.stockKey='field:unc' WHERE r.companyId=:companyId AND r.active=1 AND (:search = '' OR LOCATE(:search,r.name)>0 OR LOCATE(:search,c.textValue)>0) AND (:cursor IS NULL OR r.id>:cursor) ORDER BY r.id LIMIT 21`, + { + replacements: { + companyId: context.companyId, + search: query.search, + cursor: query.cursor ?? null, + }, + type: QueryTypes.SELECT, + } + ); + const items = rows.slice(0, 20); + + return Result.ok( + NetworkShareOptionsSchema.parse({ + items, + nextCursor: rows.length > 20 ? items.at(-1)?.id : null, + }) + ); + } +} diff --git a/modules/resources/src/api/infrastructure/persistence/network-share-summary-resolver.ts b/modules/resources/src/api/infrastructure/persistence/network-share-summary-resolver.ts index 18716bc..693ad7b 100644 --- a/modules/resources/src/api/infrastructure/persistence/network-share-summary-resolver.ts +++ b/modules/resources/src/api/infrastructure/persistence/network-share-summary-resolver.ts @@ -20,7 +20,7 @@ const RowSchema = z.object({ id: z.uuid(), name: z.string().min(1), active: z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean), - normalizedAddress: z.string().min(1), + address: z.string().min(1), }); /** Una proyección por lote, sin hidratar inventarios ni cargar sus historiales. */ @@ -64,7 +64,7 @@ export class NetworkShareSummaryResolver implements EntitySummaryResolver { .array(RowSchema) .parse( await this.database.query( - "SELECT r.id,r.name,r.active,d.normalizedAddress FROM resources r JOIN resource_network_shares d ON d.resourceId=r.id AND d.companyId=r.companyId WHERE r.companyId=:companyId AND r.id IN (:ids)", + "SELECT r.id,r.name,r.active,c.textValue AS address FROM resources r JOIN resource_network_shares d ON d.resourceId=r.id AND d.companyId=r.companyId JOIN resource_characteristics c ON c.resourceId=r.id JOIN resource_definitions f ON f.id=c.fieldId AND f.stockKey='field:unc' WHERE r.companyId=:companyId AND r.id IN (:ids)", { type: QueryTypes.SELECT, replacements: { companyId: context.companyId, ids } } ) ); @@ -86,7 +86,7 @@ export class NetworkShareSummaryResolver implements EntitySummaryResolver { summary: EntitySummarySchema.parse({ reference, name: row.name, - subtitle: row.normalizedAddress, + subtitle: row.address, state: { code: row.active ? "active" : "inactive", labelKey: row.active ? "resources:active" : "resources:inactive", diff --git a/modules/resources/src/api/module.ts b/modules/resources/src/api/module.ts index aacb91e..39fe7cb 100644 --- a/modules/resources/src/api/module.ts +++ b/modules/resources/src/api/module.ts @@ -21,6 +21,7 @@ import { DirectoryDomainSummaryResolver } from "./infrastructure/persistence/dir import { migrateLegacyResources } from "./infrastructure/persistence/migrate-legacy-resources"; import { migrateSupportResources } from "./infrastructure/persistence/migrate-support-resources"; import { migrateNetworkShares } from "./infrastructure/persistence/network-share-migration"; +import { NetworkShareOptions } from "./infrastructure/persistence/network-share-options"; import { NetworkShareReader } from "./infrastructure/persistence/network-share-reader"; import { NetworkShareSummaryResolver } from "./infrastructure/persistence/network-share-summary-resolver"; import { migrateInventory } from "./infrastructure/persistence/resource.model"; @@ -73,6 +74,8 @@ export async function createResourceModule( return { services: { + networkShareOptions: (access: RelationshipReadAccess) => + new NetworkShareOptions(database, access), networkShareSummaries: (access: RelationshipReadAccess) => new NetworkShareSummaryResolver(database, access), networkShares: (transaction: Transaction): INetworkShareReader => diff --git a/modules/resources/src/common/locales/en.json b/modules/resources/src/common/locales/en.json index 77a9e63..ff46fc2 100644 --- a/modules/resources/src/common/locales/en.json +++ b/modules/resources/src/common/locales/en.json @@ -243,5 +243,10 @@ "directoryScope": "This record documents the domain; it does not change Active Directory or create accounts, groups or servers.", "smbAccessAutomatic": "Saving makes this folder available in Accounts and access to document needs and grants. No domain or publisher is required.", "smbAccessConfigured": "Manage access in Accounts and access. Edit the address and status in this resource.", - "smbAccessHistorical": "This existing resource retains its access configuration. Saving does not convert it or add an integration." + "smbAccessHistorical": "This existing resource retains its access configuration. Saving does not convert it or add an integration.", + "smb": { + "copy": "Copy UNC address", + "copied": "Address copied", + "error": "Copy failed. Select the address and copy it manually." + } } diff --git a/modules/resources/src/common/locales/es.json b/modules/resources/src/common/locales/es.json index 2fd79ba..a51e782 100644 --- a/modules/resources/src/common/locales/es.json +++ b/modules/resources/src/common/locales/es.json @@ -243,5 +243,10 @@ "directoryScope": "Esta ficha documenta el dominio; no modifica Active Directory ni crea cuentas, grupos o servidores.", "smbAccessAutomatic": "Al guardar, la carpeta quedará disponible en Cuentas y accesos para documentar necesidades y concesiones. No requiere dominio ni publicador.", "smbAccessConfigured": "Los accesos se gestionan en Cuentas y accesos. La dirección y el estado se editan en esta ficha.", - "smbAccessHistorical": "Este recurso anterior conserva su configuración de accesos. Guardarlo no lo convierte ni añade una integración." + "smbAccessHistorical": "Este recurso anterior conserva su configuración de accesos. Guardarlo no lo convierte ni añade una integración.", + "smb": { + "copy": "Copiar dirección UNC", + "copied": "Dirección copiada", + "error": "No se ha podido copiar. Selecciona la dirección y cópiala manualmente." + } } diff --git a/modules/resources/src/common/network-shares.ts b/modules/resources/src/common/network-shares.ts index 97b57fe..986b5c3 100644 --- a/modules/resources/src/common/network-shares.ts +++ b/modules/resources/src/common/network-shares.ts @@ -27,3 +27,13 @@ export const NetworkShareDiagnosticsSchema = z.strictObject({ }); export type NetworkShareDiagnostic = z.infer; + +export const NetworkShareOptionsQuerySchema = z.strictObject({ + search: z.string().trim().max(100).default(""), + cursor: z.uuid().optional(), +}); + +export const NetworkShareOptionsSchema = z.strictObject({ + items: z.array(z.strictObject({ id: z.uuid(), name: z.string(), address: z.string() })).max(20), + nextCursor: z.string().nullable(), +}); diff --git a/modules/resources/src/web/resources/detail/ui/network-share-address.tsx b/modules/resources/src/web/resources/detail/ui/network-share-address.tsx new file mode 100644 index 0000000..7e290bf --- /dev/null +++ b/modules/resources/src/web/resources/detail/ui/network-share-address.tsx @@ -0,0 +1,31 @@ +import { Button } from "@repo/shadcn-ui/components/button"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +export function NetworkShareAddress({ address }: { address: string }) { + const { t } = useTranslation("resources"); + const [state, setState] = useState<"idle" | "copied" | "error">("idle"); + + return ( +
+
+ {address} + +
+ {state !== "idle" &&

{t(`smb.${state}`)}

} +
+ ); +} diff --git a/modules/resources/src/web/resources/detail/ui/resource-detail-dialog.tsx b/modules/resources/src/web/resources/detail/ui/resource-detail-dialog.tsx index 4dc7491..9fa8d09 100644 --- a/modules/resources/src/web/resources/detail/ui/resource-detail-dialog.tsx +++ b/modules/resources/src/web/resources/detail/ui/resource-detail-dialog.tsx @@ -1,4 +1,4 @@ -import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; +import { FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog"; import type { ComponentProps, ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -32,8 +32,10 @@ export function ResourceDetailDialog({ title={resource.values.name} wide > - - {relatedContent?.(resource)} + + + {relatedContent?.(resource)} + ); } diff --git a/modules/resources/src/web/resources/detail/ui/resource-detail.tsx b/modules/resources/src/web/resources/detail/ui/resource-detail.tsx index 39b3fa7..9c43f58 100644 --- a/modules/resources/src/web/resources/detail/ui/resource-detail.tsx +++ b/modules/resources/src/web/resources/detail/ui/resource-detail.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import type { ResourceDTO, ResourceReferenceDTO } from "../../../../common/inventory"; import { IntegrationSummary } from "./integration-summary"; +import { NetworkShareAddress } from "./network-share-address"; export function ResourceDetail({ resource, @@ -53,7 +54,12 @@ export function ResourceDetail({ } > - {display(resource.values.characteristics[field.fieldId])} + {resource.networkShare?.capability === "network:smb-share" && + resource.values.characteristics[field.fieldId] === resource.networkShare.address ? ( + + ) : ( + display(resource.values.characteristics[field.fieldId]) + )} )} sections={resource.specification.sections} diff --git a/packages/rdx-ui/package.json b/packages/rdx-ui/package.json index 01ca07b..e560aa0 100644 --- a/packages/rdx-ui/package.json +++ b/packages/rdx-ui/package.json @@ -11,7 +11,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/selection-components.test.tsx src/entity-list.test.tsx src/entity-column-visibility.test.tsx src/time-line.test.tsx src/count-badge.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/selection-components.test.tsx src/entity-list.test.tsx src/entity-column-visibility.test.tsx src/time-line.test.tsx src/count-badge.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts src/remote-option-list.test.tsx" }, "dependencies": { "@base-ui/react": "1.8.0", diff --git a/packages/rdx-ui/src/components/remote-option-list.tsx b/packages/rdx-ui/src/components/remote-option-list.tsx new file mode 100644 index 0000000..a2897b1 --- /dev/null +++ b/packages/rdx-ui/src/components/remote-option-list.tsx @@ -0,0 +1,105 @@ +import { RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/components/radio-group"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@repo/shadcn-ui/components/table"; + +export type RemotePickerOption = { + id: string; + label: string; + description: string; + disabled?: boolean; +}; + +/** Selección de una página remota sin ordenar, filtrar ni paginar de nuevo sus filas. */ +export function RemoteOptionList({ + title, + items, + pending, + error, + value, + onSelect, + labels, +}: { + title: string; + items: readonly RemotePickerOption[]; + pending: boolean; + error: string | undefined; + value: string; + onSelect: (option: RemotePickerOption) => void; + labels: { selection: string; name: string; description: string; empty: string }; +}) { + function choose(id: string) { + const option = items.find((item) => item.id === id); + + if (option && !option.disabled && !pending && !error) { + onSelect(option); + } + } + + return ( +
+ choose(value)} + value={value ?? ""} + > + + + + + {labels.selection} + + {labels.name} + {labels.description} + + + + {!(pending || error || items.length) && ( + + {labels.empty} + + )} + {!error && + items.map((item) => ( + { + if ( + !( + pending || + item.disabled || + (event.target instanceof Element && + event.target.closest("button,input,a,[role=radio]")) + ) + ) { + onSelect(item); + } + }} + > + + + + + {item.label} + + + {item.description || "—"} + + + ))} + +
+
+
+ ); +} diff --git a/packages/rdx-ui/src/components/remote-option-picker.tsx b/packages/rdx-ui/src/components/remote-option-picker.tsx new file mode 100644 index 0000000..e37a478 --- /dev/null +++ b/packages/rdx-ui/src/components/remote-option-picker.tsx @@ -0,0 +1,117 @@ +import { Button } from "@repo/shadcn-ui/components/button"; +import { Input } from "@repo/shadcn-ui/components/input"; +import type { ReactNode } from "react"; +import { useId, useState } from "react"; + +import { FormActions, FormBody, FormDialog } from "./form-dialog"; +import type { RemotePickerOption } from "./remote-option-list"; +import { RemoteOptionList } from "./remote-option-list"; + +export type { RemotePickerOption } from "./remote-option-list"; + +/** Presenta exclusivamente la página recibida; el consumidor controla consulta, filtros y paginación remota. */ +export function RemoteOptionPicker({ + title, + items, + pending, + error, + search, + onSearch, + onRetry, + onSelect, + onClose, + pagination, + labels, +}: { + title: string; + items: readonly RemotePickerOption[]; + pending: boolean; + error: string | undefined; + search: string; + onSearch: (search: string) => void; + onRetry: () => Promise; + onSelect: (option: RemotePickerOption) => void; + onClose: () => void; + pagination: ReactNode; + labels: { + search: string; + selection: string; + name: string; + description: string; + empty: string; + loading: string; + confirm: string; + cancel: string; + retry: string; + }; +}) { + const id = useId(); + const [selected, setSelected] = useState(); + + return ( + { + if (!open) { + onClose(); + } + }} + open + size="medium" + title={title} + > + + + onSearch(event.target.value)} + placeholder={labels.search} + value={search} + /> + {pending &&

{labels.loading}

} + {error && ( +
+

{error}

+ +
+ )} + + {pagination} +
+ + + +
+ ); +} diff --git a/packages/rdx-ui/src/remote-option-list.test.tsx b/packages/rdx-ui/src/remote-option-list.test.tsx new file mode 100644 index 0000000..c6452bf --- /dev/null +++ b/packages/rdx-ui/src/remote-option-list.test.tsx @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import { RemoteOptionList } from "./components/remote-option-list"; + +const labels = { + selection: "Elegir", + name: "Nombre", + description: "Dirección", + empty: "Sin resultados", +}; +const items = [ + { id: "1", label: "Zeta", description: String.raw`\\NAS\Documentación` }, + { id: "2", label: "Álvaro", description: "", disabled: true }, +]; + +test("selector remoto: selección compacta al principio, radios etiquetados y orden recibido sin paginación local", () => { + const html = renderToStaticMarkup( + undefined} + pending={false} + title="Activos" + value="1" + /> + ); + + assert.ok(html.includes('role="radiogroup"')); + assert.ok(html.includes('aria-label="Elegir: Zeta"')); + assert.ok(html.includes('role="radio"')); + assert.ok(html.includes("Documentación")); + assert.ok(html.indexOf("Zeta") < html.indexOf("Álvaro")); + assert.equal(html.includes("placeholder="), false); + assert.equal(html.includes("Mostrando"), false); + assert.ok(html.includes("—")); + assert.ok(html.includes("data-disabled")); +}); +test("selector remoto: vacío, carga y error no se confunden y errores ocultan filas obsoletas", () => { + const props = { title: "Activos", items: [], labels, value: "", onSelect: () => undefined }; + const empty = renderToStaticMarkup( + + ); + + assert.ok(empty.includes(labels.empty)); + for (const status of [ + { pending: true, error: undefined }, + { pending: false, error: "No disponible" }, + ]) { + const html = renderToStaticMarkup(); + + assert.equal(html.includes(labels.empty), false); + } + const stale = renderToStaticMarkup( + + ); + + assert.equal(stale.includes("Zeta"), false); +});