feat: add technical logs and implement infrastructure item retrieval

- Created node-check.log to capture Node.js syntax errors.
- Added proxy.log for TLS termination diagnostics.
- Introduced rdx-ui-types.log and resources-types.log for TypeScript compilation logs.
- Implemented resources.log to log test results for various resource-related tests.
- Added table.log to log column restoration and preference isolation tests.
- Created types.log to log type checking results across multiple packages.
- Added web-types.log for TypeScript compilation logs specific to web components.
- Introduced traceability.json to track changes in recorded sources and their states.
- Implemented infrastructure-items.ts to retrieve infrastructure items with roles and interfaces.
- Added company-people-navigation.tsx for navigation in company detail views.
This commit is contained in:
David Arranz 2026-10-26 12:20:08 +01:00
parent 6992a4ac74
commit 0a5fb28263
34 changed files with 2926 additions and 102 deletions

View File

@ -0,0 +1,14 @@
{
"apps/server/dist/index.js": "391a1d3f4ae0d5f4231ac487931a93c81b0cc0eb221f33f04ce785983a925507",
"apps/server/dist/index.js.map": "bddd81d81d134df7ef35ab4da1dca82d61f0a3bbd59bbc5751a08527affcf315",
"apps/web/dist/assets/geist-mono-latin-ext-variable-Bwz-egvJ.woff2": "1a189eb997c3e2ece68373e387afaec9e8617424186c4b1ab3cff7c54ba6223b",
"apps/web/dist/assets/geist-mono-latin-variable-XN7g48iV.woff2": "684ad5b531f81d43c1e8c7038262d5db7cdc1f68006e04d6c7769efa8d33c8cc",
"apps/web/dist/assets/index-DsAXQkUa.js": "9e9d770b82597b1a7f8c2aa812d9874aa3e84f8a35ddba9400a941b6e1a73f5b",
"apps/web/dist/assets/index-ZFzBcHiV.css": "c8a8acc5a0ef0f1891efb7fcab2ca6fa100458ad27df938dd374aee081e7200b",
"apps/web/dist/assets/inter-variable-DiVDrmQJ.woff2": "693b77d4f32ee9b8bfc995589b5fad5e99adf2832738661f5402f9978429a8e3",
"apps/web/dist/assets/roboto-serif-latin-ext-variable-B9BEn9Tg.woff2": "90626de112742b75e72b27865650de397eccf6845b22b577b0283b9cbfda83fc",
"apps/web/dist/assets/roboto-serif-latin-variable-CusprKnR.woff2": "a32deb82e55d4bcb083e09dbb4da3198011ce0e9f919877179e8c2bca23a9042",
"apps/web/dist/assets/workplace-map-canvas-D7df2W27.js": "e74b72073a0149f70e3700809214b929bd88841577b63266199849b160ee9859",
"apps/web/dist/assets/workplace-map-canvas-DLioOiRN.css": "b0cb7bd6fec5951c9c8f5808821438206604b1629a912c96009ac427f38e7d69",
"apps/web/dist/index.html": "a34046a30a628b0f6a7d9d44d6951885ed130daced14c2de7dd683dffee3daf0"
}

View File

@ -0,0 +1,115 @@
{
"recordedAtUtc": "2026-10-26T11:01:50.867076+00:00",
"candidateCommit": "6992a4ac745acf6b474a0f22d1a859b861119452",
"candidateBranch": "main",
"initialWorkingTree": "clean",
"verdict": "NO PREPARADO",
"blockers": [
"B-01: compiled server duplicate createRequire",
"B-02: no Secure cookie in tested TLS-to-HTTP scenario"
],
"newFrontendVisualValidation": "pending",
"newResourcesTests": {
"pass": 124,
"fail": 0
},
"newTableTests": {
"pass": 9,
"fail": 0
},
"goSourcesIdentical": false,
"candidateChangesComparedUsingGitObjects": true,
"concurrentChangesNotCertified": true,
"productionConnected": false,
"databaseConnected": false,
"migrationsExecuted": false,
"productionActivated": false,
"commands": [
{
"command": "pnpm exec turbo run build --force",
"exitCode": 0,
"log": "technical/build.log"
},
{
"command": "node --check apps/server/dist/index.js",
"exitCode": 1,
"log": "technical/node-check.log"
},
{
"command": "Diagnóstico proxy-session-diagnostic.mjs en loopback, sin BD",
"exitCode": 0,
"log": "technical/proxy.log"
},
{
"command": "pnpm --filter @metis/resources test",
"exitCode": 0,
"log": "technical/resources.log"
},
{
"command": "Pruebas entity-column-visibility.test.tsx con aislamiento none",
"exitCode": 0,
"log": "technical/table.log"
},
{
"command": "pnpm --filter @metis/resources typecheck",
"exitCode": 0,
"log": "technical/resources-types.log"
},
{
"command": "pnpm --filter @metis/web typecheck",
"exitCode": 0,
"log": "technical/web-types.log"
},
{
"command": "pnpm --filter @repo/rdx-ui typecheck",
"exitCode": 0,
"log": "technical/rdx-ui-types.log"
},
{
"command": "Biome dirigido, 15 fuentes",
"exitCode": 0,
"log": "technical/biome.log"
},
{
"command": "ESLint dirigido, 15 fuentes",
"exitCode": 0,
"log": "technical/eslint.log"
},
{
"command": "pnpm typecheck",
"exitCode": 2,
"log": "technical/types.log"
},
{
"command": "pnpm lint",
"exitCode": 1,
"log": "technical/lint.log"
},
{
"command": "pnpm check",
"exitCode": 1,
"log": "technical/check.log"
}
],
"files": {
"builds.json": "f3c2db9da0f6d88c312c9111d37f8380b66ae19eb5fdd92e18f851e9f32aad66",
"technical/biome.log": "39f4d8fe1255abee2e51cadc52a3613b308d03c4e646cd271c7ed6df407fbab6",
"technical/build.log": "3ccaef5a89dc97a321913bb345a2c70b2e8ac36a679e9bff08c50caa952adfce",
"technical/check.log": "15ad425f82d431d4a39aa0adf303c90a45a3f983b4c723eb10d26952cc5217d3",
"technical/eslint.log": "2bd73ff0eb7555f4a8aac3ac9f64061e3b9f5bc50c0560cf8afd987ecf0fd00b",
"technical/lint.log": "90b5583555e49b2cd54f6b284dd47df4086dcb0e85d50a688b65c7a0afb5c4b2",
"technical/node-check.log": "8257d56c85b4657dfaaedc60781e41646a526c3bfc5b7e9ecc351f321f6eae8e",
"technical/proxy.log": "b70d5360741b61f26b7f9a1366481b22acb55704f8a0d567b438686b614ccc69",
"technical/rdx-ui-types.log": "29c5ee0f6b0b20dbb259b99d01276198c3d28190090706394608d4deda6d2c0b",
"technical/resources-types.log": "29c5ee0f6b0b20dbb259b99d01276198c3d28190090706394608d4deda6d2c0b",
"technical/resources.log": "1aa100050d30b4a19d43044fb5bf0b2b4e14fec26914cdd824bdd58e10bd663f",
"technical/table.log": "25cd0182b29d78b97e8f35842e9a6ea1724f70bd2da65d058719f291b5d097ca",
"technical/types.log": "6d534229b49c3f6329c466d76fd6cbce8bbf3afe5f22d35a67656ba3648d4fba",
"technical/web-types.log": "29c5ee0f6b0b20dbb259b99d01276198c3d28190090706394608d4deda6d2c0b",
"traceability.json": "4a006707c2705419377a2ea59f71a7c31e27a478b97785fc31793df56e28ab20"
},
"report": {
"path": "docs/architecture/preflight-activacion-catalogo-global-sistemas-operativos.md",
"sha256": "a9318a2168479b94c7f33f56c84543eb0baa7539b2d44d2eaea4a55f96c59cf6"
}
}

View File

@ -0,0 +1 @@
Checked 15 files in 15ms. No fixes applied.

View File

@ -0,0 +1,47 @@
• turbo 2.9.14
• Packages in scope: @metis/assets, @metis/companies, @metis/core, @metis/identity, @metis/relationship-contracts, @metis/resources, @metis/server, @metis/web, @repo/rdx-ddd, @repo/rdx-ui, @repo/rdx-utils, @repo/shadcn-ui, @repo/typescript-config
• Running build in 13 packages
• Remote caching disabled
@metis/server:build: cache bypass, force executing cd9cb21b866f866f
@metis/web:build: cache bypass, force executing ca8ca295b02da9d1
@metis/web:build: $ vite build
@metis/server:build: $ tsup --config tsup.config.ts
@metis/web:build: vite v8.3.0 building client environment for production...
@metis/web:build: transforming...
@metis/server:build: CLI Building entry: src/index.ts
@metis/server:build: CLI Using tsconfig: tsconfig.json
@metis/server:build: CLI tsup v8.5.1
@metis/server:build: CLI Using tsup config: /home/david/dev/metis-desk/apps/server/tsup.config.ts
@metis/server:build: CLI Target: node24
@metis/server:build: CLI Cleaning output folder
@metis/server:build: ESM Build start
@metis/server:build: ESM dist/index.js 1.70 MB
@metis/server:build: ESM dist/index.js.map 3.22 MB
@metis/server:build: ESM ⚡️ Build success in 431ms
@metis/web:build: ✓ 10068 modules transformed.
@metis/web:build: rendering chunks...
@metis/web:build: computing gzip size...
@metis/web:build: dist/index.html 0.54 kB │ gzip: 0.35 kB
@metis/web:build: dist/assets/geist-mono-latin-ext-variable-Bwz-egvJ.woff2 14.69 kB
@metis/web:build: dist/assets/geist-mono-latin-variable-XN7g48iV.woff2 23.12 kB
@metis/web:build: dist/assets/roboto-serif-latin-ext-variable-B9BEn9Tg.woff2 49.84 kB
@metis/web:build: dist/assets/roboto-serif-latin-variable-CusprKnR.woff2 66.10 kB
@metis/web:build: dist/assets/inter-variable-DiVDrmQJ.woff2 352.24 kB
@metis/web:build: dist/assets/workplace-map-canvas-DLioOiRN.css 15.41 kB │ gzip: 2.56 kB
@metis/web:build: dist/assets/index-ZFzBcHiV.css 253.08 kB │ gzip: 38.86 kB
@metis/web:build: dist/assets/workplace-map-canvas-D7df2W27.js 227.75 kB │ gzip: 74.26 kB
@metis/web:build: dist/assets/index-DsAXQkUa.js 1,820.59 kB │ gzip: 510.82 kB
@metis/web:build:
@metis/web:build: [plugin builtin:vite-reporter]
@metis/web:build: (!) Some chunks are larger than 500 kB after minification. Consider:
@metis/web:build: - Using dynamic import() to code-split the application
@metis/web:build: - Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting
@metis/web:build: - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
@metis/web:build: ✓ built in 1.05s
Tasks: 2 successful, 2 total
Cached: 0 cached, 2 total
Time: 1.788s

View File

@ -0,0 +1,9 @@
/home/david/dev/metis-desk/modules/resources/src/common/locales/en.json
0:0 warning File ignored because no matching configuration was supplied
/home/david/dev/metis-desk/modules/resources/src/common/locales/es.json
0:0 warning File ignored because no matching configuration was supplied
✖ 2 problems (0 errors, 2 warnings)

View File

@ -0,0 +1,471 @@
$ biome lint .
docs/auditorias/ficha-puesto-trabajo/implementacion-v3/mediciones.json lint ━━━━━━━━━━━━━━━━━━━━━━━━
i The size of the file is 2.5 MiB, which exceeds the configured maximum of 1.0 MiB for this project.
Use the `files.maxSize` configuration to change the maximum size of files processed, or `files.includes` to ignore the file.
modules/companies/src/api/application/person-photo.use-cases.ts:33:9 lint/complexity/useSimplifiedLogicExpression FIXABLE ━━━━━━━━━━
i Logical expression contains unnecessary complexity.
32 │ async save(companyId: string, personId: string, data: Uint8Array) {
> 33 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
34 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
i Safe fix: Reduce the complexity of the logical expression.
31 31 │
32 32 │ async save(companyId: string, personId: string, data: Uint8Array) {
33 │ - ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)
33 │ + ····if·(!(z.uuid().safeParse(companyId).success·&&·z.uuid().safeParse(personId).success))
34 34 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
35 35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
modules/companies/src/api/application/person-photo.use-cases.ts:49:9 lint/complexity/useSimplifiedLogicExpression FIXABLE ━━━━━━━━━━
i Logical expression contains unnecessary complexity.
48 │ async remove(companyId: string, personId: string) {
> 49 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
50 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
51 │
i Safe fix: Reduce the complexity of the logical expression.
47 47 │
48 48 │ async remove(companyId: string, personId: string) {
49 │ - ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)
49 │ + ····if·(!(z.uuid().safeParse(companyId).success·&&·z.uuid().safeParse(personId).success))
50 50 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
51 51 │
modules/companies/src/api/infrastructure/person-photo-processor.ts:18:9 lint/complexity/useSimplifiedLogicExpression FIXABLE ━━━━━━━━━━
i Logical expression contains unnecessary complexity.
17 │ if (
> 18 │ !metadata.width ||
│ ^^^^^^^^^^^^^^^^^^
> 19 │ !metadata.height ||
│ ^^^^^^^^^^^^^^^^
20 │ metadata.width > PERSON_PHOTO_LIMITS.inputDimension ||
21 │ metadata.height > PERSON_PHOTO_LIMITS.inputDimension ||
i Safe fix: Reduce the complexity of the logical expression.
16 16 │
17 17 │ if (
18 │ - ········!metadata.width·||
19 │ - ········!metadata.height·||
18 │ + ········!(metadata.width·&&metadata.height·)·||
20 19 │ metadata.width > PERSON_PHOTO_LIMITS.inputDimension ||
21 20 │ metadata.height > PERSON_PHOTO_LIMITS.inputDimension ||
.agents/skills/shadcn/assets/showcase/color-pair.tsx:81:17 lint/style/noNegationElse FIXABLE ━━━━━━━━━━
! Invert blocks when performing a negation test.
79 │ }, [])
80 │
> 81 │ const level = !info
│ ^^^^^
> 82 │ ? ""
...
> 88 │ ? "AA Large"
> 89 │ : "Fail"
│ ^^^^^^^^
90 │
91 │ return (
i Safe fix: Invert the condition and the blocks.
79 79 │ }, [])
80 80 │
81 │ - ··const·level·=·!info
82 │ - ····?·""
83 │ - ····:·info.ratio·>=·7
81 │ + ··const·level·=·info
82 │ + ····?·info.ratio·>=·7
84 83 │ ? "AAA"
85 84 │ : info.ratio >= 4.5
······· │
87 86 │ : info.ratio >= 3
88 87 │ ? "AA Large"
89 │ - ··········:·"Fail"
88 │ + ··········:·"Fail"
89 │ + ····:·""
90 90 │
91 91 │ return (
.agents/skills/shadcn/assets/showcase/state-matrix.tsx:1:8 lint/style/useImportType FIXABLE ━━━━━━━━━━
× All these imports are only used as types.
> 1 │ import * as React from "react"
│ ^^^^^^^^^^^^^^^^^^^^^^^
2 │
3 │ import { cn } from "@/lib/utils"
i Importing the types with import type ensures that they are removed by the compilers and avoids loading unnecessary modules.
i Safe fix: Use import type.
1 │ import·type·*·as·React·from·"react"
│ +++++
modules/companies/src/api/application/person-photo.use-cases.ts:25:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
24 │ async get(personId: string) {
> 25 │ if (!z.uuid().safeParse(personId).success)
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 26 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
27 │ const photo = await this.repository.get(personId);
28 │
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
23 23 │
24 24 │ async get(personId: string) {
25 │ - ····if·(!z.uuid().safeParse(personId).success)
26 │ - ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
25 │ + ····if·(!z.uuid().safeParse(personId).success)·{
26 │ + ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
27 │ + ····}
27 28 │ const photo = await this.repository.get(personId);
28 29 │
modules/companies/src/api/application/person-photo.use-cases.ts:33:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
32 │ async save(companyId: string, personId: string, data: Uint8Array) {
> 33 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 34 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
36 │ return Result.fail(new PersonPhotoError("PHOTO_TOO_LARGE"));
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
31 31 │
32 32 │ async save(companyId: string, personId: string, data: Uint8Array) {
33 │ - ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)
34 │ - ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
33 │ + ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)·{
34 │ + ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
35 │ + ····}
35 36 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
36 37 │ return Result.fail(new PersonPhotoError("PHOTO_TOO_LARGE"));
modules/companies/src/api/application/person-photo.use-cases.ts:35:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
33 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
34 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
> 35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 36 │ return Result.fail(new PersonPhotoError("PHOTO_TOO_LARGE"));
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
37 │ if (!(await this.repository.exists(companyId, personId)))
38 │ return Result.fail(new PersonPhotoError("PERSON_NOT_FOUND"));
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
33 33 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
34 34 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
35 │ - ····if·(data.length·>·PERSON_PHOTO_LIMITS.inputBytes)
36 │ - ······return·Result.fail(new·PersonPhotoError("PHOTO_TOO_LARGE"));
35 │ + ····if·(data.length·>·PERSON_PHOTO_LIMITS.inputBytes)·{
36 │ + ······return·Result.fail(new·PersonPhotoError("PHOTO_TOO_LARGE"));
37 │ + ····}
37 38 │ if (!(await this.repository.exists(companyId, personId)))
38 39 │ return Result.fail(new PersonPhotoError("PERSON_NOT_FOUND"));
modules/companies/src/api/application/person-photo.use-cases.ts:37:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
36 │ return Result.fail(new PersonPhotoError("PHOTO_TOO_LARGE"));
> 37 │ if (!(await this.repository.exists(companyId, personId)))
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 38 │ return Result.fail(new PersonPhotoError("PERSON_NOT_FOUND"));
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
39 │ const processed = await this.processor.optimize(data);
40 │
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
35 35 │ if (data.length > PERSON_PHOTO_LIMITS.inputBytes)
36 36 │ return Result.fail(new PersonPhotoError("PHOTO_TOO_LARGE"));
37 │ - ····if·(!(await·this.repository.exists(companyId,·personId)))
38 │ - ······return·Result.fail(new·PersonPhotoError("PERSON_NOT_FOUND"));
37 │ + ····if·(!(await·this.repository.exists(companyId,·personId)))·{
38 │ + ······return·Result.fail(new·PersonPhotoError("PERSON_NOT_FOUND"));
39 │ + ····}
39 40 │ const processed = await this.processor.optimize(data);
40 41 │
modules/companies/src/api/application/person-photo.use-cases.ts:41:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
39 │ const processed = await this.processor.optimize(data);
40 │
> 41 │ if (!processed.isSuccess) return Result.fail(processed.error);
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
42 │
43 │ return (await this.repository.replace(companyId, personId, processed.data))
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
41 │ ····if·(!processed.isSuccess)·{·return·Result.fail(processed.error);·}
│ ++ ++
modules/companies/src/api/application/person-photo.use-cases.ts:49:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
48 │ async remove(companyId: string, personId: string) {
> 49 │ if (!z.uuid().safeParse(companyId).success || !z.uuid().safeParse(personId).success)
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 50 │ return Result.fail(new PersonPhotoError("INVALID_REQUEST"));
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
51 │
52 │ return (await this.repository.replace(companyId, personId, null))
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
47 47 │
48 48 │ async remove(companyId: string, personId: string) {
49 │ - ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)
50 │ - ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
49 │ + ····if·(!z.uuid().safeParse(companyId).success·||·!z.uuid().safeParse(personId).success)·{
50 │ + ······return·Result.fail(new·PersonPhotoError("INVALID_REQUEST"));
51 │ + ····}
51 52 │
52 53 │ return (await this.repository.replace(companyId, personId, null))
modules/companies/src/api/application/process.use-cases.ts:127:5 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
125 │ const process = UniqueID.create(id);
126 │
> 127 │ if (!(company.isSuccess && process.isSuccess))
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 128 │ return Promise.resolve(Result.fail(new ProcessError("INVALID_REQUEST")));
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
129 │
130 │ return this.repository.remove(company.data, process.data);
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
125 125 │ const process = UniqueID.create(id);
126 126 │
127 │ - ····if·(!(company.isSuccess·&&·process.isSuccess))
128 │ - ······return·Promise.resolve(Result.fail(new·ProcessError("INVALID_REQUEST")));
127 │ + ····if·(!(company.isSuccess·&&·process.isSuccess))·{
128 │ + ······return·Promise.resolve(Result.fail(new·ProcessError("INVALID_REQUEST")));
129 │ + ····}
129 130 │
130 131 │ return this.repository.remove(company.data, process.data);
modules/companies/src/api/infrastructure/persistence/migrate-people.ts:11:3 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
9 │ const columns = await query.describeTable("company_contacts");
10 │
> 11 │ if (!columns.photoVersion)
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^
> 12 │ await query.addColumn("company_contacts", "photoVersion", {
> 13 │ type: DataTypes.STRING(64),
> 14 │ allowNull: true,
> 15 │ });
│ ^^^
16 │ for (const [name, length] of [
17 │ ["position", 100],
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
9 9 │ const columns = await query.describeTable("company_contacts");
10 10 │
11 │ - ··if·(!columns.photoVersion)
11 │ + ··if·(!columns.photoVersion)·{
12 12 │ await query.addColumn("company_contacts", "photoVersion", {
13 13 │ type: DataTypes.STRING(64),
14 14 │ allowNull: true,
15 │ - ····});
15 │ + ····});
16 │ + ··}
16 17 │ for (const [name, length] of [
17 18 │ ["position", 100],
modules/companies/src/api/infrastructure/persistence/person-photo-migration.ts:10:3 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
8 │ );
9 │
> 10 │ if (rows.some((row) => row.name === "person_photos")) return;
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
11 │ await database
12 │ .getQueryInterface()
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
10 │ ··if·(rows.some((row)·=>·row.name·===·"person_photos"))·{·return;·}
│ ++ ++
modules/companies/src/api/infrastructure/persistence/sequelize-person-photo.repository.ts:49:7 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
47 │ });
48 │
> 49 │ if (!person) return false;
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^
50 │ if (photo)
51 │ await PersonPhotoModel.upsert(
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
49 │ ······if·(!person)·{·return·false;·}
│ ++ ++
modules/companies/src/api/infrastructure/persistence/sequelize-person-photo.repository.ts:50:7 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
49 │ if (!person) return false;
> 50 │ if (photo)
│ ^^^^^^^^^^
> 51 │ await PersonPhotoModel.upsert(
> 52 │ { personId, companyId, data: Buffer.from(photo.data), version: photo.version },
> 53 │ { transaction }
> 54 │ );
> 55 │ else await PersonPhotoModel.destroy({ where: { personId }, transaction });
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
56 │ await person.update({ photoVersion: photo?.version ?? null }, { transaction });
57 │
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
48 48 │
49 49 │ if (!person) return false;
50 │ - ······if·(photo)
50 │ + ······if·(photo)·{
51 51 │ await PersonPhotoModel.upsert(
52 52 │ { personId, companyId, data: Buffer.from(photo.data), version: photo.version },
53 53 │ { transaction }
54 │ - ········);
54 │ + ········);
55 │ + ······}
55 56 │ else await PersonPhotoModel.destroy({ where: { personId }, transaction });
56 57 │ await person.update({ photoVersion: photo?.version ?? null }, { transaction });
modules/companies/src/api/infrastructure/persistence/sequelize-person-photo.repository.ts:55:7 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
53 │ { transaction }
54 │ );
> 55 │ else await PersonPhotoModel.destroy({ where: { personId }, transaction });
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
56 │ await person.update({ photoVersion: photo?.version ?? null }, { transaction });
57 │
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
55 │ ······else·{·await·PersonPhotoModel.destroy({·where:·{·personId·},·transaction·});·}
│ ++ ++
modules/companies/src/api/infrastructure/person-photo-processor.ts:17:7 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
15 │ }).metadata();
16 │
> 17 │ if (
│ ^^^^
> 18 │ !metadata.width ||
...
> 23 │ )
> 24 │ return Result.fail(new PersonPhotoError("INVALID_PHOTO"));
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
25 │ const output = await sharp(input)
26 │ .rotate()
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
21 21 │ metadata.height > PERSON_PHOTO_LIMITS.inputDimension ||
22 22 │ !["jpeg", "png", "webp"].includes(metadata.format ?? "")
23 │ - ······)
24 │ - ········return·Result.fail(new·PersonPhotoError("INVALID_PHOTO"));
23 │ + ······)·{
24 │ + ········return·Result.fail(new·PersonPhotoError("INVALID_PHOTO"));
25 │ + ······}
25 26 │ const output = await sharp(input)
26 27 │ .rotate()
modules/companies/src/api/infrastructure/person-photo-processor.ts:34:7 lint/style/useBlockStatements FIXABLE ━━━━━━━━━━
× Block statements are preferred in this position.
32 │ .toBuffer();
33 │
> 34 │ if (output.length > PERSON_PHOTO_LIMITS.outputBytes)
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 35 │ return Result.fail(new PersonPhotoError("INVALID_PHOTO"));
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
36 │
37 │ return Result.ok({
i Unsafe fix: Wrap the statement with a `JsBlockStatement`
32 32 │ .toBuffer();
33 33 │
34 │ - ······if·(output.length·>·PERSON_PHOTO_LIMITS.outputBytes)
35 │ - ········return·Result.fail(new·PersonPhotoError("INVALID_PHOTO"));
34 │ + ······if·(output.length·>·PERSON_PHOTO_LIMITS.outputBytes)·{
35 │ + ········return·Result.fail(new·PersonPhotoError("INVALID_PHOTO"));
36 │ + ······}
36 37 │
37 38 │ return Result.ok({
The number of diagnostics exceeds the limit allowed. Use --max-diagnostics to increase it.
Diagnostics not shown: 1.
Checked 2231 files in 995ms. No fixes applied.
Found 15 errors.
Found 1 warning.
Found 5 infos.
lint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
× Some errors were emitted while running checks.
[ELIFECYCLE] Command failed with exit code 1.

View File

@ -0,0 +1,8 @@
/home/david/dev/metis-desk/apps/server/dist/index.js:23345
import { createRequire } from "module";
^^^^^^^^^^^^^
SyntaxError: Identifier 'createRequire' has already been declared
at checkSyntax (node:internal/main/check_syntax:72:5)
Node.js v24.20.0

View File

@ -0,0 +1 @@
{"diagnostic":"TLS termination followed by HTTP to backend","trustProxy":false,"http":200,"secure":false,"sessionCookieIssued":false,"databaseUsed":false}

View File

@ -0,0 +1 @@
$ tsc -p tsconfig.json

View File

@ -0,0 +1 @@
$ tsc -p tsconfig.json

View File

@ -0,0 +1,133 @@
$ TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/application/application-coincidence.test.ts src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/api/domain/resource-template-propagation.test.ts src/api/application/resource-lifecycle.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/list/helpers/resource-columns.test.tsx src/web/resources/list/helpers/resource-list-preferences.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx src/api/domain/directory-domain.test.ts src/api/domain/network-share.test.ts src/api/domain/resource-scope.test.ts src/api/infrastructure/persistence/resource-scope-reader.test.ts src/api/application/resource-scope.test.ts src/api/application/resource-reference-contracts.test.ts src/api/domain/global-resource.test.ts src/web/global-applications/shared/global-applications.test.tsx src/api/domain/operating-system-identity.test.ts src/api/application/global-operating-system-contracts.test.ts src/api/application/global-operating-system-policy.test.ts src/web/resources/shared/ui/resource-characteristic-control.test.tsx
✔ R2.6: nombre y proveedor son señales, nunca equivalencia confirmada (1.231616ms)
✔ R2.6: preserva tildes, puntuación, abreviaturas, ediciones, versiones y números (0.376204ms)
✔ R2.6: homónimos genéricos se mantienen débiles y los UUID nunca se sustituyen (22.305088ms)
✔ F2: SO global público tipado, sin contaminación de aplicaciones ni perfiles (8.075287ms)
✔ F2: elegibilidad de SO exige stock real y campos globales (0.322503ms)
✔ F2: contrato 3 y gates de SO independientes; aplicaciones no abren SO (0.299603ms)
✔ F3: cualquier escritor valida identidad publicada, corrección documental e historial (34.291168ms)
✔ recurso: activar/desactivar conserva datos e historial y repetir estado no añade revisiones (12.676836ms)
✔ recurso: valida identidades y rechaza actuar sobre recursos ajenos (2.464926ms)
✔ recurso: propaga el bloqueo por asociaciones y permite desactivar (2.204824ms)
✔ recurso: elimina y la repetición devuelve ausencia sin efectos adicionales (6.674072ms)
✔ elegibilidad global exige serie exacta y campos globales; las copias no elevan ámbito (1.905821ms)
✔ schemas discriminan owner, propósito, OS local y proyecciones privadas (12.243932ms)
✔ requests acotan lotes, consulta, paginación y rechazan privilegios o integración (4.950553ms)
✔ búsqueda literal conserva ñ, puntuación y espacios; pagina tras filtrar con desempate UUID (5.450259ms)
✔ R2.5 admite gates configurables; los valores predeterminados siguen cerrados (0.405605ms)
✔ R2.5: software y perfil se habilitan por separado y requieren catálogo (0.437904ms)
✔ R2.5: perfil no depende de software ni abre asociaciones con catálogo cerrado (0.317003ms)
✔ lectores locales: excluyen global y otra compañía incluso si el repositorio los entrega; stockKey no depende del nombre (17.228284ms)
✔ HTTP actual sin MariaDB: aislamiento con 15 compañías, 50 recursos locales, alta, edición, estado e historial (396.325645ms)
✔ DNS: normalización explícita, etiqueta única heredada, punto raíz y política ASCII IDN (2.207924ms)
✔ capacidad estable: la plantilla renombrada sigue siendo dominio y una copia arbitraria no (2.272924ms)
✔ alta, edición, DNS y archivo usan Resource y conservan su identidad e historial (6.923375ms)
✔ catálogo: DNS obligatorio protegido, NetBIOS opcional e inicialización idempotente (15.121263ms)
✔ identificadores de campo: mayúsculas no cambian identidad ni permiten duplicados (0.825809ms)
✔ global: crear exige UUID v7 y conserva identidad y propietario en edición e idempotencia (12.355032ms)
✔ global: captura por stockKey, sin depender del nombre traducido de la plantilla (3.562338ms)
✔ SMB acepta \\NAS-01\Finanzas (2.271825ms)
✔ SMB acepta \\files.example.local\Docs (0.293204ms)
✔ SMB acepta \\192.168.5.10\C$ (0.164602ms)
✔ SMB acepta \\samba\Diseño\Año 2026 (0.422204ms)
✔ SMB acepta \\NAS\Docs\ (0.330904ms)
✔ SMB rechaza "" (0.228603ms)
✔ SMB rechaza "\\\\" (0.250303ms)
✔ SMB rechaza "\\\\host" (0.133901ms)
✔ SMB rechaza "\\\\\\share" (2.418226ms)
✔ SMB rechaza "\\\\host\\\\" (0.550606ms)
✔ SMB rechaza "C:\\Datos" (0.152901ms)
✔ SMB rechaza "https://host/share" (0.100001ms)
✔ SMB rechaza "\\\\host\\share\\.." (0.321703ms)
✔ SMB rechaza "\\\\host\\share\\." (0.099201ms)
✔ SMB rechaza "\\\\?\\UNC\\host\\share" (0.164002ms)
✔ SMB rechaza "\\\\host\\share\\\\child" (0.210603ms)
✔ SMB rechaza "\\\\host\\share." (0.262203ms)
✔ SMB rechaza "\\\\host\\ share" (0.093101ms)
✔ SMB rechaza "\\\\ho st\\share" (0.0716ms)
✔ SMB rechaza "\\\\host\\bad\u0000" (0.092901ms)
✔ SMB rechaza "\\\\host\\bad\ud800" (0.118101ms)
✔ SMB rechaza "\\\\host\\xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" (0.0877ms)
✔ SMB: comparación conservadora, alias y Unicode sin transformaciones destructivas (1.280214ms)
✔ SMB: semántica por stockKey y protección del catálogo sin bloquear etiquetas u obligatoriedad (19.661911ms)
✔ SMB: alta validada y diagnóstico histórico sin conversión ni revisión (17.875892ms)
✔ SMB: nuevas altas fijan integración access y rechazan contexto incompatible (8.36469ms)
✔ F1: identidad completa, edición conocida/no aplicable/desconocida e inactivos (4.554549ms)
✔ F1: normalización conservadora no equipara ediciones, releases ni ausencia desconocida (0.822309ms)
✔ F1: evolución de stock personalizada e idempotente; no elimina campos ni cambia obligatoriedad (32.804952ms)
✔ F1: un catálogo anterior evoluciona sin sobrescribir bindings ni recursos particulares (14.087251ms)
✔ catálogo de recursos: serie idempotente, UUID v7, características opcionales y protección (37.378401ms)
✔ ámbitos, tipos, listas, campos externos y disponibilidad por compañía (8.182888ms)
✔ licencias: ampliar un catálogo previo conserva identidades y disponibilidad (19.636311ms)
✔ plantillas globales de serie editables: conserva identidad, asociaciones, disponibilidad y cambios al reiniciar (12.740137ms)
✔ campos globales de serie editables: listas ampliables, tipo fijo y opciones existentes conservadas (11.259221ms)
✔ sistema operativo: plantilla con uso explícito, copia y ampliación sin sobrescribir definiciones (8.40179ms)
✔ iconos de recursos: todas las plantillas de serie tienen su icono y el arranque no repone los retirados (18.962003ms)
✔ iconos de recursos: cambiar y retirar el icono conserva identidad y asociaciones, con guardado idempotente (11.14542ms)
✔ iconos de recursos: claves desconocidas, cadenas vacías e iconos de campos se rechazan (7.788183ms)
✔ ámbito: pares válidos y rechazo runtime de propietarios contradictorios o ausentes (14.326653ms)
✔ ámbito: proyección preparada separada; HTTP local conserva exactamente su contrato (5.966463ms)
✔ ámbito: edición y propagación locales conservan propietario, UUID e historia; globales son solo lectura (2.87503ms)
✔ Resource: propaga altas, orden y retiradas conservando cero/falso, valores e historial; repetir no genera revisiones (29.87312ms)
✔ Resource: los campos retirados no impiden añadir una plantilla completa y no se fusionan por nombre (5.910163ms)
✔ Resource: retirar secciones conserva solo las necesarias y mover o reincorporar campos aplica el destino (0.721708ms)
✔ recursos: tipos, valores opcionales y obligatorios, cero/falso y campos desconocidos (11.846427ms)
✔ recurso: identidad estable, historial, desactivación e idempotencia (4.007143ms)
✔ buzón como recurso: correo válido, cuenta opcional, cambios auditados y tipo fijo (3.488938ms)
✔ secciones: nombres, identidades, referencias y un solo nivel (11.626625ms)
✔ secciones: igualdad, orden, identidad y persistencia JSON (6.419668ms)
✔ secciones: plantillas globales y locales conservan separación y UUID al editar (6.713372ms)
✔ secciones de serie: el arranque conserva una plantilla sin grupos y su disponibilidad (21.021825ms)
✔ especificación: secciones capturadas, edición posterior y obligatoriedad independiente (13.471245ms)
✔ lector legacy: roundtrip conserva UUID, revisiones, valores, actor, fecha y DTO sin columna scope (18.6734ms)
✔ lector: solo el adaptador legacy conocido interpreta ausencia; ningún ámbito inválido usa fallback (6.083365ms)
✔ proyecciones operativas: global sintético se rechaza antes de SQL, el recurso local sin integración no crea proyección (99.064659ms)
✔ escritor scoped: envía company explícito, preserva la salida legacy y rechaza global (1.365715ms)
✔ selección múltiple añade un lote sin duplicados y conserva las reglas y el orden existentes (4.082543ms)
✔ añadir desde una sección asigna solo los campos nuevos a su destino y conserva los existentes (1.319414ms)
✔ la selección respeta el límite del schema, valida identificadores y rechaza el conjunto vacío (2.85603ms)
✔ el listado reutiliza tabla, búsqueda y casillas traducidas y conserva las selecciones bloqueadas (108.042052ms)
✔ L04.2: campos de recursos recuperan orden y página sin heredar otra pestaña (167.398384ms)
✔ el catálogo muestra plantillas de serie y acción de alta sin formularios junto al listado (115.143825ms)
✔ los formularios tienen ayuda inferior y selección de campos en diálogo con alta rápida (50.526938ms)
✔ iconos de recursos: catálogo y selector traducido conservan la selección al editar y copiar (229.571942ms)
✔ catálogo global: lectura mínima y navegación traducidas, sin administración para ordinarios (36.045985ms)
✔ R2.4: mutación global invalida solo instalaciones cacheadas que usan su UUID (3.423236ms)
✔ R2.5: cambio global invalida configuraciones afectadas y preserva otras compañías (1.158612ms)
✔ SO global: lectura pública es/en conserva identidad estructurada y navegación propia (11.661124ms)
✔ SO global: invalida software y su catálogo sin refrescar aplicaciones ni perfiles (1.589217ms)
✔ columnas de características de recursos: identidad, campos nuevos de catálogo, instantáneas y nombres repetidos (23.45335ms)
✔ columnas elegidas es/en: cero, falso, fecha localizada, unidades y guion para campos ajenos (73.833287ms)
✔ los campos compartidos conservan un único ID y todos sus contextos de catálogo e instantánea (2.829631ms)
✔ columnas generales e integración es/en: notas, fechas, autores y referencias semánticas (31.745039ms)
✔ orden de recursos: aislamiento por compañía y pestaña, incluida Todo (16.513977ms)
✔ orden de recursos: una URL explícita prevalece sobre la preferencia sin sobrescribirla (1.108812ms)
✔ preferencias opcionales: datos inválidos y almacenamiento bloqueado no impiden listar (1.174612ms)
✔ cambiar pestaña limpia el orden y la paginación anteriores conservando la búsqueda (0.422305ms)
✔ la integración de buzón conserva su configuración explícita y no inventa referencias (3.047833ms)
✔ los valores iniciales toleran catálogo pendiente y limitan el nombre sin cambiar la entrada (1.86692ms)
✔ nombre propuesto: prioriza los identificadores de cada plantilla y limita su longitud (19.457508ms)
✔ nombre propuesto: contempla campos personalizados y descarta valores no identificativos (1.662218ms)
✔ paso de campos: exige valores obligatorios y acepta cero y respuestas negativas (0.523206ms)
✔ paso de campos: requiere la dirección de buzón aunque la plantilla la marque opcional (13.524045ms)
✔ recursos: listado con alta modal y sin formulario visible, en español e inglés (127.894465ms)
✔ Recursos: las pestañas incluyen plantillas vacías e inactivas y combinan identidad y búsqueda (421.335414ms)
✔ recurso: ayuda inferior, plantilla con alta rápida y sin asignaciones de recursos (17.78119ms)
✔ licencia: formulario reutilizable con campos opcionales y ayuda antes de guardar (32.084143ms)
✔ asistencia: alta de recurso con plantilla específica y características opcionales (6.850473ms)
✔ alta por pasos: solo muestra la plantilla inicialmente y traduce su navegación (14.455054ms)
✔ indicador de alta: conserva la plantilla elegida y explica el paso actual en ambos idiomas (12.246331ms)
✔ recursos: resumen de plantilla con unidades, booleanos traducidos, cero y límite de cuatro (24.829866ms)
✔ recursos: ficha lateral compacta traducida con cierre, acciones y contenido relacionado (18.940503ms)
✔ recursos: inventario y panel usan el icono vigente de la plantilla sin cambiar la especificación (23.082547ms)
✔ dominio: el formulario traduce el contexto pendiente y solo presenta un entorno confirmado (19.031704ms)
✔ SMB: formulario explica acceso automático o histórico sin selectores incompatibles, es/en (9.3907ms)
✔ campo declarativo: traduce opciones propias y conserva valores que coinciden con el prototipo (33.183154ms)
ℹ tests 124
ℹ suites 0
ℹ pass 124
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 5080.204188

View File

@ -0,0 +1,17 @@
✔ las columnas elegidas se restauran al volver a crear la tabla, incluidas las que llegan después (63.643378ms)
✔ las preferencias quedan aisladas por lista y no se aplican cuando no se solicita persistencia (10.891116ms)
✔ datos locales inválidos no rompen el listado y solo se aceptan valores booleanos (0.372704ms)
✔ almacenamiento bloqueado o sin cuota permite seguir utilizando la selección local (0.403304ms)
✔ buscar columnas ignora tildes y mayúsculas, conserva ñ y no modifica visibilidad (1.039011ms)
✔ los grupos conservan orden, omiten vacíos y deduplican IDs sin perder columnas sin grupo (0.485705ms)
✔ restaurar preferencias vacías recupera defaults actuales sin tocar otras tablas (9.4554ms)
✔ los contadores distinguen visibilidad, total y coincidencias en es/en (0.262903ms)
✔ listado remoto: recupera columnas por ámbito y protege nombre y acciones (20.791321ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1969.971224

View File

@ -0,0 +1,29 @@
$ turbo run typecheck
• turbo 2.9.14
• Packages in scope: @metis/assets, @metis/companies, @metis/core, @metis/identity, @metis/relationship-contracts, @metis/resources, @metis/server, @metis/web, @repo/rdx-ddd, @repo/rdx-ui, @repo/rdx-utils, @repo/shadcn-ui, @repo/typescript-config
• Running typecheck in 13 packages
• Remote caching disabled
@repo/rdx-utils:typecheck: cache hit, replaying logs e660b2f14d16d88c
@repo/rdx-utils:typecheck: $ tsc -p tsconfig.json
@repo/shadcn-ui:typecheck: cache miss, executing 3a02880bf260fd89
@repo/rdx-ddd:typecheck: cache hit, replaying logs 315b0dccfb6d76d3
@repo/rdx-ddd:typecheck: $ tsc -p tsconfig.json
@metis/relationship-contracts:typecheck: cache hit, replaying logs 6ecbe82d1a36ae07
@metis/relationship-contracts:typecheck: $ tsc -p tsconfig.json
@repo/shadcn-ui:typecheck: $ tsc -p tsconfig.json
@repo/shadcn-ui:typecheck: src/components/calendar.tsx(165,12): error TS2375: Type '{ day: CalendarDay; modifiers: Modifiers; disabled?: boolean | undefined; form?: string | undefined; formAction?: string | ((formData: FormData) => void | Promise<...>) | undefined; ... 285 more ...; locale: Partial<...> | undefined; }' is not assignable to type '{ locale?: Partial<DayPickerLocale>; }' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
@repo/shadcn-ui:typecheck: Types of property 'locale' are incompatible.
@repo/shadcn-ui:typecheck: Type 'Partial<DayPickerLocale> | undefined' is not assignable to type 'Partial<DayPickerLocale>'.
@repo/shadcn-ui:typecheck: Type 'undefined' is not assignable to type 'Partial<DayPickerLocale>'.
@repo/shadcn-ui:typecheck: [ELIFECYCLE] Command failed with exit code 2.
ERROR @repo/shadcn-ui#typecheck: command (/home/david/dev/metis-desk/packages/shadcn-ui) /home/david/.nvm/versions/node/v24.20.0/bin/pnpm run typecheck exited (2)
Tasks: 3 successful, 4 total
Cached: 3 cached, 4 total
Time: 4.958s
Failed: @repo/shadcn-ui#typecheck
ERROR run failed: command exited (2)
[ELIFECYCLE] Command failed with exit code 2.

View File

@ -0,0 +1 @@
$ tsc -p tsconfig.json

View File

@ -0,0 +1,112 @@
{
"head": "6992a4ac745acf6b474a0f22d1a859b861119452",
"branch": "main",
"initialWorkingTree": "clean",
"baseGoHead": "d2bf856c973419277f0b30f6437242cd82f7d720",
"unchangedRecordedSources": 1875,
"changedRecordedSources": [
{
"path": "modules/resources/src/common/locales/en.json",
"before": "a1dd1e416af87cd94ffa2c80855e9e809e3b62b1ddb679a4688d2adbd67ccc9e",
"candidate": "38b1298bdee2c8f4f547669f5d766faaceb6d22412479da301fd96165f17f93b"
},
{
"path": "modules/resources/src/common/locales/es.json",
"before": "edc6151d0bfac31da3a1fed555231aa7eae5b7cb71779533ea503dbe38eb986c",
"candidate": "f95606f21c1a25b58696a9d10e4853190af43894ff536971d59f31ece3ba008e"
},
{
"path": "modules/resources/src/web/resources/list/controllers/use-company-applications-controller.ts",
"before": "050abdeb0f351ba85ea001ad94a450fc174d0d6d0c871da41949fee1732620a3",
"candidate": "7ce972f6a5f0542656b096f886ae058bd92dc9aef7beee21e1e327a4348cc28f"
},
{
"path": "modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts",
"before": "10d3101bbad439537af0fb6195e199484641d2a9bc59d50a1ddc29044ebe285c",
"candidate": "72a55dedce077c6372272ef95b05575f9efced513b4e6ea912c4af423cf3cc11"
},
{
"path": "modules/resources/src/web/resources/list/ui/company-applications-list.tsx",
"before": "73dced13ad6d1adaff9d10a1b8ad78a402e3b69fd54230d7c13c2a2353123660",
"candidate": "e9290d45c77ed97f28c205e64358465a4e1cd6674bad393b2d0e1d11696b9162"
},
{
"path": "modules/resources/src/web/resources/list/ui/resource-inventory-table.tsx",
"before": "de3d16f1df8dd869a1e692be3e35a23927475c5efdd5ec88aa1a651783052400",
"candidate": "b3e42461e88e9e1941fedab69b6c2e49ca539fb9cb3d90ccd3cb529587da156b"
},
{
"path": "modules/resources/src/web/resources/list/ui/resources-page.tsx",
"before": "d87fa2506625f88da5fdb42b79d046f06620e1e98cf278c28f55d87eae06d528",
"candidate": "c9419e1661531f363a949473b0a41b5b345afdad3b8c0b9c70973e5c23379e2a"
},
{
"path": "packages/rdx-ui/src/components/cursor-entity-table.tsx",
"before": "6505c4f913b639038a71b41b169cd2382583359585795ac32fbbcfb4c92f25e7",
"candidate": "9a5398dc82dd3935682c39e59e8e6a6e1e023f5fa60496afa6b2ee851fa335b2"
},
{
"path": "packages/rdx-ui/src/entity-column-visibility.test.tsx",
"before": "95aa7c35e5f70f8d61cd1ae5e08edfb591d24c3d85466d7eba4cdf2a4f3e3145",
"candidate": "5ead7a4b1dd78a73a6ed54d1f7fa1238d33822496cc41391215edc1799aedc6d"
}
],
"addedSources": [
{
"path": "modules/resources/src/web/resources/list/controllers/use-resource-list-navigation.ts",
"sha256": "7d773755a67618becca56290322668112fac7ec0a82373a23b5b904c589cc707"
},
{
"path": "modules/resources/src/web/resources/list/helpers/resource-additional-columns.tsx",
"sha256": "21a6391388f44a486165dce21e98d0eec5a84414682d9a586751479a68c7078e"
},
{
"path": "modules/resources/src/web/resources/list/helpers/resource-characteristic-columns.ts",
"sha256": "8865cbc2a88a235b17100d276be8a98d005cf948102b5816a73c1b711f75c6b0"
},
{
"path": "modules/resources/src/web/resources/list/helpers/resource-columns.test.tsx",
"sha256": "38ef794093816780b13973f2a3d8444a73b0f2159bc89d5d1d50bb36aff54487"
},
{
"path": "modules/resources/src/web/resources/list/helpers/resource-list-preferences.test.tsx",
"sha256": "5cddfa7c3dd91484d42e504e8d95898c3df2b8753dce2ce869a669a023b55f22"
},
{
"path": "modules/resources/src/web/resources/list/helpers/resource-list-preferences.ts",
"sha256": "e7bd6e27facdf032d18e1a5260b0e56cbe3950ccadd1e7bcee52836a5b5a7633"
}
],
"workingGoDifferences": ["docs/componentes-de-interfaz.md", "modules/assets/src/web/index.ts"],
"lockfileSha256": "bcd468ab1f127fffeb7f37ef3bdb5f2c5e85d40c192f3bc4a7cae5b2209b7c17",
"functionalBackendChanges": false,
"newVisualValidationRequired": true,
"concurrentUnvalidatedSources": [
{
"path": "modules/assets/src/web/index.ts",
"sha256": "d13dfe0b910d694643a878dff9d2439e7b68bc83045e6c3521f8a79e29453929"
},
{
"path": "modules/assets/src/web/infrastructure/list/ui/infrastructure-page.tsx",
"sha256": "e50db875ea633ceecc6763f4d4f4c683efadd3be2e85d5249d3f1fdc23e79198"
},
{
"path": "modules/assets/src/web/infrastructure/shared/infrastructure.test.tsx",
"sha256": "d73da6731f6de334f216fa901c65b5127b791916a93ab8c001153610025ddacf"
},
{
"path": "modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts",
"sha256": "b774f445c5b97f31e42251df9a642b06661dc57ad26361eb8b9e8a5d0c54ef8c"
},
{
"path": "modules/companies/src/web/companies/shared/companies-page.test.tsx",
"sha256": "2db363fb74b145aed6a6749f62e1e0c77a6a1f77d410a1714aef82d79bb4a10f"
},
{
"path": "modules/assets/src/web/infrastructure/shared/helpers/infrastructure-items.ts",
"sha256": "e5455d36a326e82fb23ea68a74578dcd4938fa745b74cf81867522615e3b01e6"
}
],
"recordedAtUtc": "2026-10-26T11:00:51.729096+00:00",
"comparisonMethod": "GO hashes compared to git archive of candidate HEAD; subsequent workspace changes recorded separately"
}

View File

@ -2,6 +2,8 @@
Fecha: **26 de octubre de 2026**, Europe/Madrid. Inspección iniciada el día 25. Ámbito: repositorio local y diagnósticos locales sin BD. **No se ha conectado a producción, leído `.env`, ejecutado migraciones, modificado datos reales, cambiado gates ni desplegado.** Fecha: **26 de octubre de 2026**, Europe/Madrid. Inspección iniciada el día 25. Ámbito: repositorio local y diagnósticos locales sin BD. **No se ha conectado a producción, leído `.env`, ejecutado migraciones, modificado datos reales, cambiado gates ni desplegado.**
**Reevaluación vigente: commit `6992a4ac745acf6b474a0f22d1a859b861119452`, árbol inicialmente limpio, NO PREPARADO.** Los apartados 1–14 conservan el preflight del candidato anterior; el apartado 15 identifica las diferencias, las ejecuciones nuevas y el alcance actual de sus evidencias.
## 1. Resumen y dictamen ## 1. Resumen y dictamen
**NO PREPARADO para desplegar el artefacto de producción evaluado.** Se conserva la correspondencia de fuentes con el GO técnico, pero el preflight descubre dos bloqueos de ejecución/configuración que ese GO no había ensayado: **NO PREPARADO para desplegar el artefacto de producción evaluado.** Se conserva la correspondencia de fuentes con el GO técnico, pero el preflight descubre dos bloqueos de ejecución/configuración que ese GO no había ensayado:
@ -236,3 +238,76 @@ Responsables requeridos: mantenedor del build para B-01; backend y operación pa
Condiciones antes de autorizar: artefacto compilado válido y arranque aislado comprobado; sesiones Secure a través de la topología aprobada; identidad de release congelada; destino identificado; inventario de clientes; metadata/datos verificados con autorización; backup/restauración aprobados; smoke y responsables acordados; tratamiento explícito de CI no verde. No cambiar flags como sustituto de cumplir estas condiciones. Condiciones antes de autorizar: artefacto compilado válido y arranque aislado comprobado; sesiones Secure a través de la topología aprobada; identidad de release congelada; destino identificado; inventario de clientes; metadata/datos verificados con autorización; backup/restauración aprobados; smoke y responsables acordados; tratamiento explícito de CI no verde. No cambiar flags como sustituto de cumplir estas condiciones.
**Dictamen final: NO PREPARADO**, referido al snapshot reproducido y al artefacto productivo generado localmente. El entorno real sigue pendiente. El informe prepara las decisiones y acciones necesarias; no solicita, concede ni ejecuta autorización de despliegue o activación. **Dictamen final: NO PREPARADO**, referido al snapshot reproducido y al artefacto productivo generado localmente. El entorno real sigue pendiente. El informe prepara las decisiones y acciones necesarias; no solicita, concede ni ejecuta autorización de despliegue o activación.
## 15. Reevaluación del candidato confirmado en Git
Fecha: 26 de octubre de 2026. Solicitud reiterada de preflight; no autoriza corregir funcionalidad, migrar, desplegar ni activar. Se utilizó `rdx-solid` para limitar las comprobaciones a diferencias reales y a los bloqueos productivos ya detectados.
### 15.1. Identificación y diferencias respecto al GO
| Requisito | Estado actual | Evidencia |
| --- | --- | --- |
| Candidato actual | VERIFICADO | `main`, `6992a4ac745acf6b474a0f22d1a859b861119452`; `git status --short` vacío antes de intervenir |
| Trazabilidad del código candidato | VERIFICADO | Ahora está confirmado en Git; reproducible mediante checkout del commit y dependencias fijadas. No necesita el overlay anterior para identificar sus fuentes |
| Igualdad completa con el GO | NO CUMPLE | De 1884 fuentes registradas: **1875 iguales y 9 modificadas**; hay **6 fuentes adicionales** en los mismos ámbitos |
| Archivos de trabajo del GO | VERIFICADO con diferencia documental | De los 40 registrados, 39 coinciden; cambió `docs/componentes-de-interfaz.md` |
| Node/PNPM y dependencias | VERIFICADO en instalación/manifests | 24.20.0 / 11.25.0; lockfile conserva SHA-256 `bcd468ab1f127fffeb7f37ef3bdb5f2c5e85d40c192f3bc4a7cae5b2209b7c17` |
| Instalación limpia de dependencias del destino | PENDIENTE DE VERIFICACIÓN | No se ejecuta install ni se cambia configuración privada |
Los commits `07082001` y `6992a4ac` incorporan el trabajo previamente sin commit y cambios adicionales. Se compararon contenidos con los manifiestos del GO; no se atribuye todo `git diff d2bf856c..HEAD` a una diferencia funcional posterior, porque también contiene aquel trabajo ya validado.
Durante la reevaluación aparecieron cambios concurrentes sin commit en `modules/assets/src/web/index.ts`, `infrastructure-page.tsx`, `use-company-detail-controller.ts`, el nuevo helper `infrastructure-items.ts` y pruebas `infrastructure.test.tsx` / `companies-page.test.tsx`. Se preservaron. **El candidato de esta tabla es el commit, no el árbol concurrente final**: sus diferencias se verificaron leyendo los objetos de `git archive HEAD`. Los hashes del trabajo concurrente se registran aparte como no validados en la fecha de captura; las ejecuciones de esta intervención no certifican esas modificaciones posteriores. Una release que las incorpore necesita nueva identificación y análisis de impacto.
| Diferencia | Clasificación | Impacto y validación |
| --- | --- | --- |
| Columnas adicionales de características/recursos y sus pruebas | Frontend | Cambia el contenido configurable del inventario; Resources reejecutado |
| Preferencias por compañía/plantilla, precedencia de URL y persistencia de orden | Frontend | Afecta navegación y contexto de listados; pruebas nuevas incluidas en Resources |
| `CursorEntityTable` y prueba de visibilidad | Frontend compartido | Puede afectar también listados globales; reejecutada la suite específica de columnas/tablas. Validación visual actual pendiente |
| Traducciones es/en de Resources | Frontend/textos | Fuentes distintas del GO; no certificar con capturas anteriores |
| Guía de listados y componentes | Documentación | Revisada como patrón vigente; no cambia esquema/contrato |
| Script de test en `modules/resources/package.json` | Configuración de validación | Añade nuevas pruebas; no cambia dependencias runtime ni lockfile |
| DTO HTTP, lógica backend, persistencia, seguridad, gates y configuración de build | Sin diferencia funcional identificada respecto al GO | Las diferencias de `common` son locales es/en; los nuevos archivos server son las fixtures/tests del cierre ya registrados en el GO |
Las nueve fuentes modificadas y seis nuevas, con hashes, están enumeradas en [traceability.json](../../.artifacts/resources-global-operating-systems-preflight-6992a4ac/traceability.json). No hay bloqueo de identificación del candidato. **No se traslada el GO íntegro al frontend cambiado**: corresponde validar visualmente los nuevos listados/componente compartido antes de una futura aprobación de release.
### 15.2. Artefactos y bloqueos reproducidos
Se ejecutó `pnpm exec turbo run build --force`: servidor y web compilan, dos tareas correctas. Se calcularon hashes de los 12 archivos dist. **El dist del servidor coincide byte a byte con el del preflight anterior; el dist web es distinto**. Por tanto, la correspondencia del frontend antiguo con MCP descrita en el apartado 3 no se aplica al candidato actual.
- **B-01 sigue NO CUMPLE:** nuevo `node --check apps/server/dist/index.js` devuelve 1, `Identifier 'createRequire' has already been declared`. `tsup.config.ts` conserva el banner y el bundle incluye el segundo import procedente de sharp. La compilación correcta no acredita ejecución; no se arrancó ese servidor ni se abrió BD.
- **B-02 sigue NO CUMPLE para TLS→HTTP:** el diagnóstico conservado se ejecutó con el `createApp` actual. Resultado nuevo: `trustProxy=false`, `http=200`, `secure=false`, `sessionCookieIssued=false`, `databaseUsed=false`. Salida cero del diagnóstico significa que terminó el experimento, no que la sesión de producción funcione.
No se ha configurado confianza en proxy, relajado Secure, cambiado `NODE_ENV`, editado el bundle ni modificado sharp para eludir los bloqueos. La solución de TLS/proxy debe ser aprobada y acotada al proxy real, sin confiar indiscriminadamente en cabeceras reenviadas.
### 15.3. Validaciones dirigidas nuevas y deuda actual
| Comando / ámbito | Resultado nuevo |
| --- | --- |
| `pnpm --filter @metis/resources test`, con loopback permitido | **124/124**, incluido caché, contratos y pruebas nuevas de columnas/preferencias |
| `node --import tsx --test --test-isolation=none src/entity-column-visibility.test.tsx`, desde rdx-ui con TSX_TSCONFIG_PATH del paquete | **9/9** |
| Typecheck directo Resources, Web y rdx-ui | Los tres correctos |
| Biome y ESLint sobre las 15 fuentes modificadas/nuevas | Ambos correctos |
| Build forzado | Dos tareas correctas; advertencia de tamaño de chunk web |
| Parse del servidor compilado | **Fallo bloqueante B-01**, salida 1 |
| Diagnóstico de sesión local | Experimento completado; demuestra **B-02** |
| `pnpm typecheck` raíz | Falla en calendario vendor por `locale` opcional, salida 2 |
| `pnpm lint` raíz | 15 errores, 1 warning, 5 infos; salida 1 |
| `pnpm check` raíz | 173 errores, 1 warning, 5 infos; salida 1 |
No se han corregido incidencias ajenas. Los propietarios y clasificación de deuda del apartado 12 siguen aplicándose: el parse inválido es bloqueante, no una excepción aceptable por antigüedad; los fallos generales requieren tratamiento explícito de CI. Los dos fallos previos de ayuda de Companies son antecedentes, no una ejecución nueva. No se repitió la suite completa; posteriormente se modificó concurrentemente `companies-page.test.tsx`, por lo que su estado en ese trabajo posterior queda pendiente, sin afirmar que sus dos fallos persistan o estén resueltos allí.
No se repitieron MariaDB/HTTP-SQL con fixtures ni Playwright con preparación persistente: no cambió el backend/contrato/persistencia y este preflight no autoriza escrituras de negocio o migraciones. Las 29/29 + 50/50 y las 35 comprobaciones/69 capturas siguen siendo antecedentes con su alcance anterior. Para el nuevo frontend se requiere validación visual dirigida de columnas, preferencias, contexto y listados globales, en entorno aislado autorizado para preparar datos; las pruebas de componente no sustituyen esa comprobación.
### 15.4. Entorno, gates, rollback y autorización
No hay nueva evidencia autorizada del destino. Motor/versión/esquema aplicado, plantilla real, FKs/CHECK/índices, clientes desplegados, proxy, sesiones, supervisor, backup/restore y valores efectivos de gates continúan **PENDIENTES DE VERIFICACIÓN**. No se infieren de defaults ni del compose de desarrollo. El SQL del apartado 6 sigue preparado y **no ejecutado**; sus tablas/migraciones no cambiaron.
Se verificó que los seis nombres de flags y sus dependencias siguen iguales. El plan de los apartados 8–11 sigue vigente como propuesta: persistencia compatible → lectores v3 → frontend coordinado → OS_CATALOG administrativo → OS_SOFTWARE, que abre **selección y asociación juntas**. No hay gate por compañía ni se añaden flags para simularlo. Mantener los cuatro flags application según su autorización independiente.
El rollback propuesto conserva lectores v3, UUID, productos, instalaciones e historiales; cierra nuevas dependencias y altas, permite las retiradas legítimas ensayadas y no activa D08.2. No es un procedimiento productivo ya ejecutado. Los smoke tests de lectura y escritura siguen separados; ninguna escritura, conexión al destino o activación se ejecutó en esta reevaluación.
### 15.5. Evidencias y dictamen vigente
Ejecuciones nuevas, diferencias y hashes: [manifiesto 6992a4ac](../../.artifacts/resources-global-operating-systems-preflight-6992a4ac/manifest.json). El preflight anterior y sus evidencias se conservan sin sobrescribirlos. Esta intervención modifica únicamente este informe y añade evidencias diagnósticas; los cambios concurrentes ajenos permanecen sin certificar.
**NO PREPARADO**, para `6992a4ac745acf6b474a0f22d1a859b861119452` y sus artefactos generados localmente. Primero deben resolverse B-01 y la configuración aplicable de B-02, verificarse el artefacto ejecutable y revalidarse el frontend cambiado. Después faltan las comprobaciones y autorizaciones del destino enumeradas arriba. El código está identificado, pero eso no acredita una release operativa ni autoriza publicarla.

View File

@ -1,5 +1,29 @@
# Componentes de interfaz # Componentes de interfaz
## Submenú de Personas en la navegación de compañía
`CompanySectionNavigation` compone `SidebarMenu` y `SidebarMenuItem` de shadcn/ui
dentro del contenedor contextual existente. `CompanyPeopleNavigation` utiliza
`SidebarMenuSub`, `SidebarMenuSubItem` y `SidebarMenuSubButton` con `BrowserLink`
mediante `render`. Mantiene las tres vistas de Personas en el panel fijo y en el Sheet,
con selección por URL, listas anidadas semánticas y expansión al entrar en la sección.
No requiere otro SidebarProvider ni cambia el controlador responsive del shell.
Los contadores de las entradas principales conservan `CountBadge` de rdx-ui.
Verificado el 26 de octubre de 2026 mediante Playwright MCP en una instalación
desechable de `metis_desk_test`: 15 compañías y una principal con 50 personas,
51 activos y 66 recursos. Las vistas presentan 12 contactos, 43 activas y 7 inactivas.
Se comprobaron 1440×900, 390×844 y 320×640, teclado, selección, cierre del Sheet y
retorno del foco, recarga, vuelta de ficha, búsqueda/orden, reinicio de página,
vacío y limpieza de filtros. Evidencias locales en
`.artifacts/playwright/authenticated-6t1tczdv/people-navigation*.{png,json}`;
las capturas de escritorio y móvil se abrieron e inspeccionaron. No se declara
una auditoría completa de accesibilidad ni de estados de error de red.
Skills consultadas: `ux-navigation` para jerarquía y orientación, `shadcn` para la
composición mediante `render`, `vercel-react-best-practices` para derivar selección
de la URL y `rdx-solid` para responsabilidades y pruebas. No se modificó vendor.
Metis Desk usa shadcn/ui, con Base UI y el estilo base-vega, siguiendo la organización de factuges-erp. Metis Desk usa shadcn/ui, con Base UI y el estilo base-vega, siguiendo la organización de factuges-erp.
`packages/shadcn-ui` contiene únicamente el código del registro oficial y no se adapta ni se `packages/shadcn-ui` contiene únicamente el código del registro oficial y no se adapta ni se
extiende desde este proyecto. Se consume mediante `@repo/shadcn-ui`. extiende desde este proyecto. Se consume mediante `@repo/shadcn-ui`.

View File

@ -23,6 +23,14 @@ autenticar se recupera ese destino. No se aceptan redirecciones a otros orígene
## Espacio de compañía ## Espacio de compañía
En Personas, `/companies/:companyId/people?contactsOnly=true` abre los contactos de la
compañía; `?active=true` y `?active=false` muestran las personas activas e inactivas.
Sin estos filtros se muestran todas. El menú despliega estas tres subentradas al entrar
en Personas, tanto en el panel fijo como en el Sheet. Cada enlace conserva búsqueda y
orden, reinicia la página y reemplaza los filtros de la vista anterior. El filtro de estado
del listado comparte la misma URL; puede combinarse con Solo contactos. La ficha y el
retorno conservan ambos criterios. `active` no habilita cursor ni pageSize en Personas.
Todas las secciones siguientes cuelgan de `/companies/:companyId`: Todas las secciones siguientes cuelgan de `/companies/:companyId`:
```text ```text

View File

@ -14,6 +14,7 @@ export {
infrastructureApi, infrastructureApi,
infrastructureKeys, infrastructureKeys,
} from "./infrastructure/shared/api/infrastructure-api"; } from "./infrastructure/shared/api/infrastructure-api";
export { getInfrastructureItems } from "./infrastructure/shared/helpers/infrastructure-items";
export { NetworkSharePublications } from "./network-share-publications/detail/ui/network-share-publications"; export { NetworkSharePublications } from "./network-share-publications/detail/ui/network-share-publications";
export { PublicationPicker } from "./network-share-publications/select/ui/publication-picker"; export { PublicationPicker } from "./network-share-publications/select/ui/publication-picker";
export { assetsEnglish, assetsSpanish } from "./shared"; export { assetsEnglish, assetsSpanish } from "./shared";

View File

@ -17,6 +17,7 @@ import { InventoryFeedback } from "../../../shared";
import { inventoryApi } from "../../../shared/api"; import { inventoryApi } from "../../../shared/api";
import { inventoryKeys } from "../../../shared/queries"; import { inventoryKeys } from "../../../shared/queries";
import { infrastructureApi, infrastructureKeys } from "../../shared/api/infrastructure-api"; import { infrastructureApi, infrastructureKeys } from "../../shared/api/infrastructure-api";
import { getInfrastructureItems } from "../../shared/helpers/infrastructure-items";
import { infrastructureRoles } from "../../shared/helpers/infrastructure-roles"; import { infrastructureRoles } from "../../shared/helpers/infrastructure-roles";
import { InfrastructureEditorDialog } from "../../shared/ui/infrastructure-editor-dialog"; import { InfrastructureEditorDialog } from "../../shared/ui/infrastructure-editor-dialog";
@ -39,16 +40,11 @@ export function InfrastructurePage({ companyId }: { companyId: string }) {
}); });
const assets = inventory.data?.assets ?? []; const assets = inventory.data?.assets ?? [];
const editing = assets.find((asset) => asset.id === editingId); const editing = assets.find((asset) => asset.id === editingId);
const items = assets.flatMap((asset) => { const items = getInfrastructureItems(
const config = network.data?.items.find((item) => item.assetId === asset.id); assets,
catalog.data?.items ?? [],
const roles = infrastructureRoles(asset.values.templateId, catalog.data?.items ?? [], config); network.data?.items ?? []
).filter((asset) => !role || asset.config.roles.some((value) => value === role));
return (roles.length || config?.values.interfaces.length) &&
(!role || roles.some((value) => value === role))
? [{ ...asset, config: { roles, interfaces: config?.values.interfaces ?? [] } }]
: [];
});
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@ -0,0 +1,21 @@
import type { CatalogItemDTO } from "../../../../common";
import type { InfrastructureDTO } from "../../../../common/infrastructure";
import type { AssetDTO } from "../../../../common/inventory";
import { infrastructureRoles } from "./infrastructure-roles";
/** Activos que pertenecen a infraestructura, con su clasificación e interfaces efectivas. */
export function getInfrastructureItems(
assets: readonly AssetDTO[],
catalog: readonly CatalogItemDTO[],
configurations: readonly InfrastructureDTO[]
) {
return assets.flatMap((asset) => {
const config = configurations.find((item) => item.assetId === asset.id);
const roles = infrastructureRoles(asset.values.templateId, catalog, config);
return roles.length || config?.values.interfaces.length
? [{ ...asset, config: { roles, interfaces: config?.values.interfaces ?? [] } }]
: [];
});
}

View File

@ -20,6 +20,7 @@ import { inventoryKeys } from "../../shared/queries";
import { InfrastructurePage } from "../list/ui/infrastructure-page"; import { InfrastructurePage } from "../list/ui/infrastructure-page";
import { infrastructureKeys } from "./api/infrastructure-api"; import { infrastructureKeys } from "./api/infrastructure-api";
import { getInfrastructureItems } from "./helpers/infrastructure-items";
import { infrastructureRoles } from "./helpers/infrastructure-roles"; import { infrastructureRoles } from "./helpers/infrastructure-roles";
import { AssetInfrastructurePanel } from "./ui/asset-infrastructure-panel"; import { AssetInfrastructurePanel } from "./ui/asset-infrastructure-panel";
@ -117,6 +118,19 @@ test("infraestructura: clasificación de serie independiente del nombre/icono y
assert.deepEqual(infrastructureRoles(templateId, [{ ...renamed, builtinKey: null }]), []); assert.deepEqual(infrastructureRoles(templateId, [{ ...renamed, builtinKey: null }]), []);
}); });
test("infraestructura: el total incluye roles e interfaces, respeta exclusiones manuales y no duplica activos", () => {
assert.equal(getInfrastructureItems([], catalog.items, []).length, 0);
assert.equal(getInfrastructureItems([asset], catalog.items, []).length, 1);
assert.equal(getInfrastructureItems([asset], catalog.items, [config]).length, 1);
assert.equal(getInfrastructureItems([asset], [], []).length, 0);
const interfacesOnly = { ...config, values: { ...config.values, roles: [] } };
const excluded = { ...config, values: { ...config.values, roles: [], interfaces: [] } };
assert.equal(getInfrastructureItems([asset], [], [interfacesOnly]).length, 1);
assert.equal(getInfrastructureItems([asset], catalog.items, [excluded]).length, 0);
assert.equal(getInfrastructureItems([asset], [], [{ ...config, assetId: companyId }]).length, 0);
});
test("infraestructura: listado y ficha presentan interfaces/IP en español e inglés, sin puertos", async () => { test("infraestructura: listado y ficha presentan interfaces/IP en español e inglés, sin puertos", async () => {
for (const [language, title, edit] of [ for (const [language, title, edit] of [
["es", "Infraestructura", "Editar infraestructura"], ["es", "Infraestructura", "Editar infraestructura"],

View File

@ -26,6 +26,12 @@
"close": "Close navigation", "close": "Close navigation",
"skip": "Skip to content" "skip": "Skip to content"
}, },
"peopleNavigation": {
"contacts": "Company contacts",
"active": "Active",
"inactive": "Inactive",
"all": "All people"
},
"navigationCounts": { "navigationCounts": {
"loading": "Loading total…", "loading": "Loading total…",
"error": "The total could not be refreshed." "error": "The total could not be refreshed."

View File

@ -26,6 +26,12 @@
"close": "Cerrar navegación", "close": "Cerrar navegación",
"skip": "Saltar al contenido" "skip": "Saltar al contenido"
}, },
"peopleNavigation": {
"contacts": "Contactos de la compañía",
"active": "Activas",
"inactive": "Inactivas",
"all": "Todas las personas"
},
"navigationCounts": { "navigationCounts": {
"loading": "Cargando total…", "loading": "Cargando total…",
"error": "No se ha podido actualizar el total." "error": "No se ha podido actualizar el total."

View File

@ -1,6 +1,11 @@
import { import {
catalogApi as assetCatalogApi,
catalogKeys as assetCatalogKeys,
inventoryApi as assetInventoryApi, inventoryApi as assetInventoryApi,
inventoryKeys as assetInventoryKeys, inventoryKeys as assetInventoryKeys,
getInfrastructureItems,
infrastructureApi,
infrastructureKeys,
} from "@metis/assets/client"; } from "@metis/assets/client";
import { useListQuery } from "@metis/core/client"; import { useListQuery } from "@metis/core/client";
import { import {
@ -41,6 +46,16 @@ export function useCompanyDetailController({
queryFn: () => resourceInventoryApi.get(id), queryFn: () => resourceInventoryApi.get(id),
enabled: Boolean(company), enabled: Boolean(company),
}); });
const infrastructure = useListQuery({
queryKey: infrastructureKeys.company(id),
queryFn: () => infrastructureApi.get(id),
enabled: Boolean(company),
});
const assetCatalog = useListQuery({
queryKey: assetCatalogKeys.scope(id),
queryFn: () => assetCatalogApi.get(id),
enabled: Boolean(company),
});
const profiles = useListQuery({ const profiles = useListQuery({
queryKey: profileKeys.company(id), queryKey: profileKeys.company(id),
queryFn: () => profileApi.get(id), queryFn: () => profileApi.get(id),
@ -51,6 +66,17 @@ export function useCompanyDetailController({
departments: { count: company?.departments.length, failed: false }, departments: { count: company?.departments.length, failed: false },
sites: { count: company?.sites.length, failed: false }, sites: { count: company?.sites.length, failed: false },
inventory: { count: inventory.data?.assets.length, failed: inventory.isError }, inventory: { count: inventory.data?.assets.length, failed: inventory.isError },
infrastructure: {
count:
inventory.data && infrastructure.data && assetCatalog.data
? getInfrastructureItems(
inventory.data.assets,
assetCatalog.data.items,
infrastructure.data.items
).length
: undefined,
failed: inventory.isError || infrastructure.isError || assetCatalog.isError,
},
workstations: { count: inventory.data?.workstations.length, failed: inventory.isError }, workstations: { count: inventory.data?.workstations.length, failed: inventory.isError },
resources: { count: resources.data?.resources.length, failed: resources.isError }, resources: { count: resources.data?.resources.length, failed: resources.isError },
profiles: { count: profiles.data?.profiles.length, failed: profiles.isError }, profiles: { count: profiles.data?.profiles.length, failed: profiles.isError },

View File

@ -0,0 +1,69 @@
import { BrowserLink } from "@metis/core/client";
import {
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@repo/shadcn-ui/components/sidebar";
import type { MouseEventHandler } from "react";
import { useTranslation } from "react-i18next";
import { companySectionHref } from "../../../workspace/shared/helpers";
/** Vistas del listado de Personas; cada destino conserva búsqueda y orden, y reinicia página. */
export function CompanyPeopleNavigation({
companyId,
searchParams,
onNavigate,
}: {
companyId: string;
searchParams: URLSearchParams;
onNavigate?: MouseEventHandler<HTMLElement> | undefined;
}) {
const { t } = useTranslation("companies");
const contactsOnly = searchParams.get("contactsOnly") === "true";
const active = searchParams.get("active");
const entries = [
{ key: "contacts", label: t("peopleNavigation.contacts"), selected: contactsOnly },
{
key: "active",
label: t("peopleNavigation.active"),
selected: !contactsOnly && active === "true",
},
{
key: "inactive",
label: t("peopleNavigation.inactive"),
selected: !contactsOnly && active === "false",
},
] as const;
return (
<SidebarMenuSub aria-label={t("people")} className="my-1 border-border">
{entries.map((entry) => {
const params = new URLSearchParams(searchParams);
params.delete("page");
params.delete("contactsOnly");
params.delete("active");
if (entry.key === "contacts") {
params.set("contactsOnly", "true");
} else {
params.set("active", String(entry.key === "active"));
}
const href = `${companySectionHref(companyId, "people")}?${params.toString()}`;
return (
<SidebarMenuSubItem key={entry.key}>
<SidebarMenuSubButton
aria-current={entry.selected ? "page" : undefined}
className="h-auto min-h-10 whitespace-normal py-2 text-foreground hover:bg-accent hover:text-accent-foreground data-active:bg-accent data-active:text-accent-foreground"
isActive={entry.selected}
render={<BrowserLink href={href} onClick={onNavigate} />}
>
{entry.label}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
);
}

View File

@ -1,6 +1,8 @@
import { BrowserLink } from "@metis/core/client"; import { BrowserLink, useBrowserSearchParams } from "@metis/core/client";
import { CountBadge } from "@repo/rdx-ui/components/count-badge"; import { CountBadge } from "@repo/rdx-ui/components/count-badge";
import { ChevronDownIcon, ChevronRightIcon } from "@repo/rdx-ui/lib/icons";
import { Button, buttonVariants } from "@repo/shadcn-ui/components/button"; import { Button, buttonVariants } from "@repo/shadcn-ui/components/button";
import { SidebarMenu, SidebarMenuItem } from "@repo/shadcn-ui/components/sidebar";
import type { MouseEventHandler } from "react"; import type { MouseEventHandler } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -8,6 +10,8 @@ import { companySectionHref } from "../../../workspace/shared/helpers";
import type { CompanyTab } from "../../shared/types"; import type { CompanyTab } from "../../shared/types";
import type { useCompanyNavigationEntries } from "../helpers/use-company-navigation-entries"; import type { useCompanyNavigationEntries } from "../helpers/use-company-navigation-entries";
import { CompanyPeopleNavigation } from "./company-people-navigation";
export function CompanySectionNavigation({ export function CompanySectionNavigation({
companyId, companyId,
activeSection, activeSection,
@ -26,6 +30,14 @@ export function CompanySectionNavigation({
onSectionChange?: (section: CompanyTab) => void; onSectionChange?: (section: CompanyTab) => void;
}) { }) {
const { t, i18n } = useTranslation("companies"); const { t, i18n } = useTranslation("companies");
const { searchParams } = useBrowserSearchParams();
const peopleExpanded = activeSection === "people";
const PeopleChevron = peopleExpanded ? ChevronDownIcon : ChevronRightIcon;
const itemClassName =
layout === "sidebar"
? "group/company-section cursor-pointer text-foreground hover:text-primary h-auto min-h-10 w-full justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] data-[active=true]:bg-accent data-[active=true]:hover:bg-accent dark:data-[active=true]:hover:bg-accent data-[active=true]:text-accent-foreground shell:text-sm"
: "group/company-section cursor-pointer text-foreground hover:text-primary";
const peopleFiltered = searchParams.get("contactsOnly") === "true" || searchParams.has("active");
return ( return (
<nav <nav
@ -36,65 +48,76 @@ export function CompanySectionNavigation({
: "flex flex-wrap gap-2" : "flex flex-wrap gap-2"
} }
> >
{entries.map(([section, label, Icon]) => { <SidebarMenu className={layout === "inline" ? "flex-row flex-wrap gap-2" : undefined}>
const count = counts[section]; {entries.map(([section, label, Icon]) => {
const count = counts[section];
const itemClassName = const isPeople = section === "people";
layout === "sidebar" const showPeople = isPeople && peopleExpanded;
? "group/company-section cursor-pointer text-foreground hover:text-primary h-auto min-h-10 w-full justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] data-[active=true]:bg-accent data-[active=true]:hover:bg-accent dark:data-[active=true]:hover:bg-accent data-[active=true]:text-accent-foreground shell:text-sm" const active = activeSection === section;
: "group/company-section cursor-pointer text-foreground hover:text-primary"; const filteredParent = isPeople && peopleFiltered;
const content = ( const currentPage = active && !filteredParent ? "page" : undefined;
<> const containerClassName = ["people", "inventory"].includes(section)
{" "} ? "basis-full border-t border-border pt-2 mt-2 shell:basis-auto"
<Icon aria-hidden="true" className="size-4 shrink-0" /> : undefined;
<span className="min-w-0 flex-1 text-left">{label}</span> const branchProps = {
{count && ( "aria-expanded": isPeople ? peopleExpanded : undefined,
<CountBadge "data-active": active,
className="ms-auto group-data-[active=true]/company-section:bg-primary/10 group-data-[active=true]/company-section:text-primary" };
count={count.count} const content = (
errorLabel={t("navigationCounts.error")} <>
failed={count.failed} {" "}
loadingLabel={t("navigationCounts.loading")} <Icon aria-hidden="true" className="size-4 shrink-0" />
locale={i18n.language} <span className="min-w-0 flex-1 text-left">{label}</span>
/> {isPeople && <PeopleChevron aria-hidden="true" className="size-4 shrink-0" />}
)}{" "} {count && (
</> <CountBadge
); className="ms-auto group-data-[active=true]/company-section:bg-primary/10 group-data-[active=true]/company-section:text-primary"
count={count.count}
errorLabel={t("navigationCounts.error")}
failed={count.failed}
loadingLabel={t("navigationCounts.loading")}
locale={i18n.language}
/>
)}{" "}
</>
);
return ( return (
<div <SidebarMenuItem className={containerClassName} key={section}>
className={ {onSectionChange ? (
section === "people" || section === "inventory" <Button
? "basis-full border-t border-border pt-2 mt-2 shell:basis-auto" {...branchProps}
: "contents" aria-pressed={active}
} className={itemClassName}
key={section} onClick={() => onSectionChange(section)}
> variant="ghost"
{onSectionChange ? ( >
<Button {content}
aria-pressed={activeSection === section} </Button>
className={itemClassName} ) : (
data-active={activeSection === section} <BrowserLink
onClick={() => onSectionChange(section)} {...branchProps}
variant="ghost" aria-current={currentPage}
> className={`${buttonVariants({ variant: "ghost" })} ${itemClassName}`}
{content} data-slot="button"
</Button> href={companySectionHref(companyId, section)}
) : ( onClick={onNavigate}
<BrowserLink >
aria-current={activeSection === section ? "page" : undefined} {content}
className={`${buttonVariants({ variant: "ghost" })} ${itemClassName}`} </BrowserLink>
data-active={activeSection === section} )}
data-slot="button" {showPeople && (
href={companySectionHref(companyId, section)} <CompanyPeopleNavigation
onClick={onNavigate} companyId={companyId}
> onNavigate={onNavigate}
{content} searchParams={searchParams}
</BrowserLink> />
)} )}
</div> </SidebarMenuItem>
); );
})} })}
</SidebarMenu>
</nav> </nav>
); );
} }

View File

@ -1,7 +1,13 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { assetsEnglish, assetsSpanish } from "@metis/assets/client"; import {
catalogKeys as assetCatalogKeys,
inventoryKeys as assetInventoryKeys,
assetsEnglish,
assetsSpanish,
infrastructureKeys,
} from "@metis/assets/client";
import { resourcesEnglish, resourcesSpanish } from "@metis/resources/client"; import { resourcesEnglish, resourcesSpanish } from "@metis/resources/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next"; import { createInstance } from "i18next";
@ -18,12 +24,55 @@ import { SiteSection } from "../../sites/list";
import { CatalogOverview } from "../../workspace/catalogs/ui/catalog-overview"; import { CatalogOverview } from "../../workspace/catalogs/ui/catalog-overview";
import { CompanyDetail } from "../detail"; import { CompanyDetail } from "../detail";
import { CompanyOverview } from "../detail/ui/company-overview"; import { CompanyOverview } from "../detail/ui/company-overview";
import { CompanyPeopleNavigation } from "../detail/ui/company-people-navigation";
import { CompaniesPage } from "../list"; import { CompaniesPage } from "../list";
import { companyKeys } from "./queries"; import { companyKeys } from "./queries";
const companyId = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4"; const companyId = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4";
test("las subentradas de Personas conservan contexto y reemplazan el filtro de la vista", async () => {
for (const language of ["es", "en"]) {
const html = await renderPage(
language,
<CompanyPeopleNavigation
companyId={companyId}
searchParams={
new URLSearchParams(
"active=false&contactsOnly=true&q=Ana&sort=name&direction=desc&page=3"
)
}
/>,
() => undefined
);
const links = Array.from(
html.matchAll(/href="([^"]+)"/g),
(match) => new URL((match[1] ?? "").replaceAll("&amp;", "&"), "http://metis.invalid")
);
assert.equal(links.length, 3);
for (const link of links) {
assert.equal(link.pathname, `/companies/${companyId}/people`);
assert.equal(link.searchParams.get("q"), "Ana");
assert.equal(link.searchParams.get("sort"), "name");
assert.equal(link.searchParams.get("direction"), "desc");
assert.equal(link.searchParams.has("page"), false);
}
assert.equal(links[0]?.searchParams.get("contactsOnly"), "true");
assert.equal(links[0]?.searchParams.has("active"), false);
assert.equal(links[1]?.searchParams.get("active"), "true");
assert.equal(links[1]?.searchParams.has("contactsOnly"), false);
assert.equal(links[2]?.searchParams.get("active"), "false");
assert.equal(links[2]?.searchParams.has("contactsOnly"), false);
assert.equal((html.match(/aria-current="page"/g) ?? []).length, 1);
const translations = language === "es" ? companiesSpanish : companiesEnglish;
assert.ok(html.includes(translations.peopleNavigation.contacts));
assert.ok(html.includes(translations.peopleNavigation.active));
assert.ok(html.includes(translations.peopleNavigation.inactive));
}
});
async function renderPage( async function renderPage(
language: string, language: string,
children: ReactNode, children: ReactNode,
@ -343,6 +392,72 @@ test("el menú de compañía ordena trabajo y personas por grupos sin repetir la
assert.ok(html.includes('data-slot="avatar-badge"')); assert.ok(html.includes('data-slot="avatar-badge"'));
}); });
for (const state of ["ready", "pending", "error"] as const) {
test(`el menú muestra el contador de infraestructura: ${state}`, async () => {
for (const language of ["es", "en"]) {
const html = await renderPage(
language,
<CompanyDetail
embedded
id={companyId}
onBack={() => undefined}
onSectionChange={() => undefined}
/>,
(client) => {
client.setQueryData(companyKeys.detail(companyId), {
company: {
id: companyId,
name: "Empresa",
active: true,
people: [],
contacts: [],
departments: [],
assignments: [],
sites: [],
},
});
client.setQueryData(assetInventoryKeys.company(companyId), {
assets: [],
workstations: [],
relationships: [],
});
client.setQueryData(assetCatalogKeys.scope(companyId), { items: [] });
if (state === "ready") {
client.setQueryData(infrastructureKeys.company(companyId), { items: [] });
} else if (state === "error") {
client.setQueryDefaults(infrastructureKeys.company(companyId), { retryOnMount: false });
client
.getQueryCache()
.build(client, {
queryKey: infrastructureKeys.company(companyId),
})
.setState({ status: "error", error: new Error("NETWORK_ERROR") });
}
}
);
const navigation = html.match(/<nav[^>]*>[\s\S]*?<\/nav>/)?.[0];
const entry = navigation?.match(
new RegExp(`<a[^>]*href="/companies/${companyId}/infrastructure"[^>]*>[\\s\\S]*?</a>`)
)?.[0];
assert.ok(entry);
assert.ok(entry.includes('data-slot="badge"'));
assert.ok(entry.includes(state === "ready" ? ">0</span>" : ">—</span>"));
if (state !== "ready") {
const translations = language === "es" ? companiesSpanish : companiesEnglish;
assert.ok(
entry.includes(
state === "error"
? translations.navigationCounts.error
: translations.navigationCounts.loading
)
);
}
}
});
}
test("sedes: listado traducido con filtro de archivadas y solo sedes activas por defecto", async () => { test("sedes: listado traducido con filtro de archivadas y solo sedes activas por defecto", async () => {
const company = { const company = {
id: companyId, id: companyId,

View File

@ -24,9 +24,15 @@ export function usePeopleListContext(
} = useEntityNavigation(`/companies/${companyId}/people`, initialId); } = useEntityNavigation(`/companies/${companyId}/people`, initialId);
const [localSearch, setLocalSearch] = useState(""); const [localSearch, setLocalSearch] = useState("");
const [localContactsOnly, setLocalContactsOnly] = useState(false); const [localContactsOnly, setLocalContactsOnly] = useState(false);
const [localActive, setLocalActive] = useState<boolean | undefined>(undefined);
const { searchParams, setSearchParams } = useBrowserSearchParams(); const { searchParams, setSearchParams } = useBrowserSearchParams();
const search = routed ? (searchParams.get("q") ?? "") : localSearch; const search = routed ? (searchParams.get("q") ?? "") : localSearch;
const contactsOnly = routed ? searchParams.get("contactsOnly") === "true" : localContactsOnly; const contactsOnly = routed ? searchParams.get("contactsOnly") === "true" : localContactsOnly;
const active = routed
? searchParams.has("active")
? searchParams.get("active") === "true"
: undefined
: localActive;
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const navigation = useListLocation( const navigation = useListLocation(
{ sort: "sort", direction: "direction", page: "page" }, { sort: "sort", direction: "direction", page: "page" },
@ -75,6 +81,36 @@ export function usePeopleListContext(
}); });
} }
function setActive(value: boolean | undefined) {
if (!routed) {
setLocalActive(value);
return;
}
setSearchParams((params) => {
params.delete("page");
if (value === undefined) {
params.delete("active");
} else {
params.set("active", String(value));
}
});
}
function clearFilters() {
if (!routed) {
setLocalContactsOnly(false);
setLocalActive(undefined);
return;
}
setSearchParams((params) => {
params.delete("page");
params.delete("contactsOnly");
params.delete("active");
});
}
useEffect(() => { useEffect(() => {
if ( if (
routed && routed &&
@ -112,6 +148,9 @@ export function usePeopleListContext(
setSearch, setSearch,
contactsOnly, contactsOnly,
setContactsOnly, setContactsOnly,
active,
setActive,
clearFilters,
rootRef, rootRef,
navigation, navigation,
selectPerson, selectPerson,

View File

@ -26,7 +26,7 @@ test("Personas conserva búsqueda por nombre, contactos, consulta y distinción
position: null, position: null,
email: "exclusive@example.test", email: "exclusive@example.test",
phone: null, phone: null,
active: true, active: index !== 3,
isContact: index === 0, isContact: index === 0,
}) })
), ),
@ -48,19 +48,28 @@ test("Personas conserva búsqueda por nombre, contactos, consulta y distinción
lng: language, lng: language,
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } }, resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
}); });
for (const [query, contactsOnly, expected] of [ for (const [query, contactsOnly, active, expected] of [
[" alvaro ", false, [0, 1, 2]], [" alvaro ", false, undefined, [0, 1, 2]],
["Pena", false, [1]], ["Pena", false, undefined, [1]],
["PEN\u0303A", false, [0, 2]], ["PEN\u0303A", false, undefined, [0, 2]],
["alvaro", true, [0]], ["alvaro", true, undefined, [0]],
["exclusive@example.test", false, []], ["exclusive@example.test", false, undefined, []],
] satisfies [string, boolean, number[]][]) { ["", false, true, [0, 1, 2]],
["", false, false, [3]],
["", true, false, []],
["María", false, true, []],
] satisfies [string, boolean, boolean | undefined, number[]][]) {
const params = new URLSearchParams({ q: query, contactsOnly: String(contactsOnly) });
if (active !== undefined) {
params.set("active", String(active));
}
Object.defineProperty(globalThis, "window", { Object.defineProperty(globalThis, "window", {
configurable: true, configurable: true,
value: { value: {
location: { location: {
pathname: `/companies/${companyId}/people`, pathname: `/companies/${companyId}/people`,
search: `?${new URLSearchParams({ q: query, contactsOnly: String(contactsOnly) })}`, search: `?${params.toString()}`,
}, },
}, },
}); });
@ -77,7 +86,10 @@ test("Personas conserva búsqueda por nombre, contactos, consulta y distinción
); );
company.people.forEach((person, index) => { company.people.forEach((person, index) => {
assert.equal(html.includes(`data-entity-id="${person.id}"`), expected.includes(index)); assert.equal(
html.includes(`data-entity-id="${person.id}"`),
expected.some((value) => value === index)
);
}); });
assert.ok(html.includes(`value="${query}"`)); assert.ok(html.includes(`value="${query}"`));
} finally { } finally {

View File

@ -9,6 +9,7 @@ import {
} from "@repo/rdx-ui/components/entity-list"; } from "@repo/rdx-ui/components/entity-list";
import { FormLabel } from "@repo/rdx-ui/components/form-label"; import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { PhoneLink } from "@repo/rdx-ui/components/phone-link"; import { PhoneLink } from "@repo/rdx-ui/components/phone-link";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { EyeIcon, PersonIcon } from "@repo/rdx-ui/lib/icons"; import { EyeIcon, PersonIcon } from "@repo/rdx-ui/lib/icons";
import { createTextMatcher } from "@repo/rdx-ui/lib/text-search"; import { createTextMatcher } from "@repo/rdx-ui/lib/text-search";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
@ -46,6 +47,9 @@ export function PeopleSection({
setSearch, setSearch,
contactsOnly, contactsOnly,
setContactsOnly, setContactsOnly,
active,
setActive,
clearFilters,
rootRef, rootRef,
navigation, navigation,
selectPerson, selectPerson,
@ -99,7 +103,10 @@ export function PeopleSection({
} }
const matchesSearch = createTextMatcher(search); const matchesSearch = createTextMatcher(search);
const people = company.people.filter( const people = company.people.filter(
(person) => (!contactsOnly || person.isContact) && matchesSearch(person.name) (person) =>
(!contactsOnly || person.isContact) &&
(active === undefined || person.active === active) &&
matchesSearch(person.name)
); );
return ( return (
@ -277,22 +284,35 @@ export function PeopleSection({
}, },
]} ]}
emptyMessage={t("noPeople")} emptyMessage={t("noPeople")}
filterKey={String(contactsOnly)} filterKey={`${contactsOnly}:${active ?? "all"}`}
filters={ filters={
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-4">
<Checkbox <SelectWithAction
checked={contactsOnly} id={`${filterId}-active`}
id={`${filterId}-contact`} label={t("status")}
onCheckedChange={(checked) => setContactsOnly(checked === true)} onValueChange={(value) => setActive(value === "" ? undefined : value === "true")}
options={[
{ value: "true", label: t("peopleNavigation.active") },
{ value: "false", label: t("peopleNavigation.inactive") },
]}
placeholder={t("peopleNavigation.all")}
value={active === undefined ? "" : String(active)}
/> />
<FormLabel htmlFor={`${filterId}-contact`}>{t("contactsOnly")}</FormLabel> <div className="flex items-center gap-2">
<Checkbox
checked={contactsOnly}
id={`${filterId}-contact`}
onCheckedChange={(checked) => setContactsOnly(checked === true)}
/>
<FormLabel htmlFor={`${filterId}-contact`}>{t("contactsOnly")}</FormLabel>
</div>
</div> </div>
} }
filtersApplied={contactsOnly} filtersApplied={contactsOnly || active !== undefined}
items={people} items={people}
locale={i18n.language} locale={i18n.language}
navigation={routed ? navigation : undefined} navigation={routed ? navigation : undefined}
onClearFilters={() => setContactsOnly(false)} onClearFilters={clearFilters}
onSearchChange={setSearch} onSearchChange={setSearch}
search={search} search={search}
searchPlaceholder={t("searchPerson")} searchPlaceholder={t("searchPerson")}

View File

@ -159,39 +159,38 @@ export function parseWorkspaceUrl(href: string): WorkspaceLocation {
} }
} }
function globalApplicationQueryMatches( function collectionQueryMatches(
location: WorkspaceLocation, location: WorkspaceLocation,
query: z.infer<typeof RouteQuerySchema> query: z.infer<typeof RouteQuerySchema>
): boolean { ): boolean {
const globalApplications = const globalApplications =
location.area === "catalogs" && location.area === "catalogs" &&
["global-applications", "global-operating-systems"].includes(location.catalog ?? ""); ["global-applications", "global-operating-systems"].includes(location.catalog ?? "");
const companyApplications = const companySection = location.area === "company" ? location.section : undefined;
location.area === "company" && location.section === "resources" && query.template !== undefined; const companyApplications = companySection === "resources" && query.template !== undefined;
const applicationCatalog = globalApplications || companyApplications; const applicationCatalog = globalApplications || companyApplications;
const activeAllowed = applicationCatalog || companySection === "people";
const paginationRequested = query.cursor !== undefined || query.pageSize !== undefined;
if ( if (
(query.active !== undefined || query.cursor !== undefined || query.pageSize !== undefined) && (query.active !== undefined && !activeAllowed) ||
!applicationCatalog (paginationRequested && !applicationCatalog)
) { ) {
return false; return false;
} }
if ( if (!applicationCatalog) {
applicationCatalog && return true;
((query.q?.length ?? 0) > 100 ||
(query.page !== undefined && !/^[1-9][0-9]{0,5}$/u.test(query.page)))
) {
return false;
} }
return true; return (query.q?.length ?? 0) <= 100 && /^[1-9][0-9]{0,5}$/u.test(query.page ?? "1");
} }
function queryMatchesLocation( function queryMatchesLocation(
location: WorkspaceLocation, location: WorkspaceLocation,
query: z.infer<typeof RouteQuerySchema> query: z.infer<typeof RouteQuerySchema>
): boolean { ): boolean {
if (!globalApplicationQueryMatches(location, query)) { if (!collectionQueryMatches(location, query)) {
return false; return false;
} }
if (location.area === "company" && query.companyId && query.companyId !== location.id) { if (location.area === "company" && query.companyId && query.companyId !== location.id) {

View File

@ -12,6 +12,37 @@ const COMPANY_ID = "019b8838-44af-776d-a406-af74452b6f26";
const ENTITY_ID = "019b8838-44af-776d-a406-af74452b6f27"; const ENTITY_ID = "019b8838-44af-776d-a406-af74452b6f27";
const TEMPLATE_ID = "01a1276d-2138-711e-b3d2-99258a02a80f"; const TEMPLATE_ID = "01a1276d-2138-711e-b3d2-99258a02a80f";
test("Personas admite actividad en listado y ficha, con los demás criterios de contexto", () => {
for (const active of ["true", "false"]) {
for (const suffix of ["", `/${ENTITY_ID}`]) {
assert.deepEqual(
parseWorkspaceUrl(
`/companies/${COMPANY_ID}/people${suffix}?active=${active}&contactsOnly=true&q=Ana&sort=name&direction=desc&page=2`
),
{
area: "company",
id: COMPANY_ID,
section: "people",
...(suffix ? { entityId: ENTITY_ID } : {}),
}
);
}
}
for (const query of [
"active=invalid",
"active=true&active=false",
"active=true&cursor=abc",
"active=false&pageSize=25",
]) {
assert.deepEqual(parseWorkspaceUrl(`/companies/${COMPANY_ID}/people?${query}`), {
area: "invalid-route",
});
}
assert.deepEqual(parseWorkspaceUrl(`/companies/${COMPANY_ID}/assets?active=true`), {
area: "invalid-route",
});
});
test("el catálogo de aplicaciones de compañía conserva cursor, filtros y retorno a ficha", () => { test("el catálogo de aplicaciones de compañía conserva cursor, filtros y retorno a ficha", () => {
const query = `template=${TEMPLATE_ID}&q=Ágil&active=false&pageSize=25&page=2&cursor=abc`; const query = `template=${TEMPLATE_ID}&q=Ágil&active=false&pageSize=25&page=2&cursor=abc`;