diff --git a/package-lock.json b/package-lock.json index 4f48a98..60224ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,21 +41,33 @@ }, "devDependencies": { "@eslint/js": "^9.30.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.7.2", "@types/proj4": "^2.5.6", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "^2.1.9", "eslint": "^9.30.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "jsdom": "^25.0.1", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", "vite": "^5.4.10", - "vite-plugin-html": "^3.2.2" + "vite-plugin-html": "^3.2.2", + "vitest": "^2.1.9" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -70,6 +82,25 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, "node_modules/@azure/msal-browser": { "version": "4.16.0", "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.16.0.tgz", @@ -338,6 +369,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -386,6 +426,122 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -997,6 +1153,32 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", @@ -1030,11 +1212,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", - "dev": true, - "license": "MIT" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.29", @@ -1106,6 +1287,16 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -1462,6 +1653,98 @@ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1983,6 +2266,165 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", @@ -2054,6 +2496,15 @@ "node": ">=0.8" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2071,6 +2522,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2094,12 +2554,30 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2299,6 +2777,15 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2392,6 +2879,22 @@ "node": ">=0.8" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2449,6 +2952,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -2665,6 +3177,31 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2781,6 +3318,19 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2798,6 +3348,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -2816,6 +3372,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2886,6 +3451,13 @@ "jszip": ">=3.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "peer": true + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -3009,6 +3581,12 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3047,6 +3625,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -3084,6 +3668,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3393,6 +3983,15 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -3683,6 +4282,22 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -3844,6 +4459,27 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3857,6 +4493,30 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "16.3.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", @@ -3995,6 +4655,24 @@ "he": "bin/he" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -4043,6 +4721,32 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -4106,6 +4810,15 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4179,6 +4892,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4224,6 +4946,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -4236,6 +4964,71 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -4282,6 +5075,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4552,6 +5385,12 @@ "underscore": "^1.13.1" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -4572,6 +5411,63 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mammoth": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", @@ -5569,6 +6465,15 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5582,6 +6487,15 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/monaco-editor": { "version": "0.55.1", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", @@ -5716,6 +6630,12 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5804,6 +6724,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -5858,6 +6784,30 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5906,6 +6856,28 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -5919,6 +6891,15 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -6106,6 +7087,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "peer": true + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -6452,6 +7468,19 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -6607,6 +7636,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6657,6 +7692,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -6848,6 +7895,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6914,6 +7979,12 @@ "node": ">=0.8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -6928,6 +7999,12 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -6941,6 +8018,56 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -6955,6 +8082,58 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6999,6 +8178,12 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/terser": { "version": "5.44.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", @@ -7025,11 +8210,118 @@ "dev": true, "license": "MIT" }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7052,6 +8344,30 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -7469,6 +8785,34 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, "node_modules/vite-plugin-html": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/vite-plugin-html/-/vite-plugin-html-3.2.2.tgz", @@ -7493,6 +8837,145 @@ "vite": ">=2.0.0" } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7509,6 +8992,22 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wkt-parser": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.2.tgz", @@ -7541,6 +9040,106 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xlsx": { "version": "0.18.5", "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", @@ -7561,6 +9160,15 @@ "node": ">=0.8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", @@ -7569,6 +9177,12 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/xstate": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.20.1.tgz", diff --git a/package.json b/package.json index e94b6ae..2145710 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,11 @@ "build:prod": "tsc -b && vite build --mode prod", "build:int": "tsc -b && vite build --mode int", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@azure/msal-browser": "^4.12.0", @@ -47,18 +51,24 @@ }, "devDependencies": { "@eslint/js": "^9.30.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.7.2", "@types/proj4": "^2.5.6", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "^2.1.9", "eslint": "^9.30.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "jsdom": "^25.0.1", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", "vite": "^5.4.10", - "vite-plugin-html": "^3.2.2" + "vite-plugin-html": "^3.2.2", + "vitest": "^2.1.9" } } diff --git a/src/api.ts b/src/api.ts index 8771019..ac02d60 100644 --- a/src/api.ts +++ b/src/api.ts @@ -46,7 +46,14 @@ import { getApiBaseUrl } from '../config/config'; const api = axios.create({ baseURL: getApiBaseUrl(), - withCredentials: true + withCredentials: true, + // FastAPI expects repeat-style array query params (``?ids=1&ids=2``). + // Axios v1.x default would render ``?ids[]=1&ids[]=2``, which FastAPI + // silently drops -- e.g. ``trackerIds`` filters on the Redmine stats + // endpoint never reach the route. Setting ``indexes: null`` switches + // the URLSearchParams visitor to repeat format. Applies globally so + // every endpoint with array query params gets it for free. + paramsSerializer: { indexes: null }, }); // Add a request interceptor to add the auth token, context headers, and log backend IP diff --git a/src/api/attributesApi.ts b/src/api/attributesApi.ts index 1776fb9..b01f5b9 100644 --- a/src/api/attributesApi.ts +++ b/src/api/attributesApi.ts @@ -1,4 +1,7 @@ import { ApiRequestOptions } from '../hooks/useApi'; +import type { AttributeType } from '../utils/attributeTypeMapper'; + +export type { AttributeType }; // ============================================================================ // TYPES & INTERFACES @@ -7,7 +10,7 @@ import { ApiRequestOptions } from '../hooks/useApi'; export interface AttributeDefinition { name: string; label: string; - type: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'text' | 'email' | 'checkbox' | 'select' | 'multiselect' | 'textarea'; + type: AttributeType; sortable?: boolean; filterable?: boolean; searchable?: boolean; diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 930749d..cc05e44 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -29,7 +29,7 @@ export interface BillingTransaction { aicoreProvider?: string; aicoreModel?: string; createdByUserId?: string; - createdAt?: string; + sysCreatedAt?: string; mandateId?: string; mandateName?: string; userId?: string; diff --git a/src/api/teamsbotApi.ts b/src/api/teamsbotApi.ts index b6387ac..462268d 100644 --- a/src/api/teamsbotApi.ts +++ b/src/api/teamsbotApi.ts @@ -169,11 +169,63 @@ export interface MfaChallengeEvent { // SSE Event Types export interface TeamsbotSSEEvent { - type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState' | 'ttsDeliveryStatus' | 'mfaChallenge' | 'mfaResolved' | 'chatSendFailed'; + type: + | 'transcript' + | 'botResponse' + | 'analysis' + | 'suggestedResponse' + | 'statusChange' + | 'error' + | 'ping' + | 'sessionState' + | 'ttsDeliveryStatus' + | 'mfaChallenge' + | 'mfaResolved' + | 'chatSendFailed' + | 'directorPrompt' + | 'agentRun' + | 'botConnectionState'; data: any; timestamp?: string; } +// ========================================================================= +// Director Prompts (private operator instructions during a live meeting) +// ========================================================================= + +export type DirectorPromptMode = 'oneShot' | 'persistent'; +export type DirectorPromptStatus = + | 'queued' + | 'running' + | 'succeeded' + | 'failed' + | 'consumed'; + +export const DIRECTOR_PROMPT_TEXT_LIMIT = 8000; +export const DIRECTOR_PROMPT_FILE_LIMIT = 10; + +export interface DirectorPrompt { + id: string; + sessionId: string; + instanceId: string; + operatorUserId: string; + text: string; + mode: DirectorPromptMode; + fileIds: string[]; + status: DirectorPromptStatus; + statusMessage?: string; + createdAt: string; + consumedAt?: string; + agentRunId?: string; + responseText?: string; +} + +export interface DirectorPromptCreateRequest { + text: string; + mode: DirectorPromptMode; + fileIds?: string[]; +} + // ============================================================================ // API FUNCTIONS // ============================================================================ @@ -289,6 +341,29 @@ export async function listSystemBots(instanceId: string): Promise<{ bots: System return response.data; } +/** + * Create a new system bot account. The password is encrypted server-side + * before storage; the API never returns the password back. SysAdmin only. + */ +export async function createSystemBot( + instanceId: string, + payload: { email: string; password: string; name?: string }, +): Promise<{ bot: SystemBot }> { + const response = await api.post(`/api/teamsbot/${instanceId}/system-bots`, payload); + return response.data; +} + +/** + * Delete a system bot account. SysAdmin only. + */ +export async function deleteSystemBot( + instanceId: string, + botId: string, +): Promise<{ deleted: boolean }> { + const response = await api.delete(`/api/teamsbot/${instanceId}/system-bots/${botId}`); + return response.data; +} + /** * Test TTS voice with AI-generated sample text. Returns base64-encoded audio. */ @@ -452,3 +527,50 @@ export async function submitMfaCode( }); return response.data; } + +// ========================================================================= +// Director Prompts +// ========================================================================= + +/** + * Submit a private director prompt to the running bot. Triggers the full + * agent path (web, mail, RAG, etc.) and delivers the answer into the meeting. + */ +export async function submitDirectorPrompt( + instanceId: string, + sessionId: string, + body: DirectorPromptCreateRequest, +): Promise<{ prompt: DirectorPrompt }> { + const response = await api.post( + `/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`, + body, + ); + return response.data; +} + +/** + * List director prompts for a session (operator's own prompts only). + */ +export async function listDirectorPrompts( + instanceId: string, + sessionId: string, +): Promise<{ prompts: DirectorPrompt[] }> { + const response = await api.get( + `/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`, + ); + return response.data; +} + +/** + * Remove a (typically persistent) director prompt. + */ +export async function deleteDirectorPrompt( + instanceId: string, + sessionId: string, + promptId: string, +): Promise<{ deleted: boolean; promptId: string }> { + const response = await api.delete( + `/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts/${promptId}`, + ); + return response.data; +} diff --git a/src/api/trusteeApi.ts b/src/api/trusteeApi.ts index c3eb5da..f21a369 100644 --- a/src/api/trusteeApi.ts +++ b/src/api/trusteeApi.ts @@ -853,16 +853,46 @@ export async function fetchChartOfAccounts( }); } +/** + * Submits a background job that pushes positions to the accounting system and + * polls `/api/jobs/{jobId}` until the job reaches a terminal status. Returns + * the same `{ total, success, skipped, errors, results }` payload that the + * legacy synchronous endpoint used to return -- but does NOT block the user + * while the (potentially long) external accounting calls run in the worker. + */ export async function syncPositionsToAccounting( request: ApiRequestFunction, instanceId: string, - positionIds: string[] -): Promise<{ total: number; success: number; errors: number; results: any[] }> { - return await request({ + positionIds: string[], + opts?: { pollMs?: number; onProgress?: (progress: number, message?: string | null) => void } +): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> { + const submission = await request({ url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`, method: 'post', data: { positionIds } }); + + const jobId: string | undefined = submission?.jobId; + if (!jobId) { + throw new Error('Background job could not be started (missing jobId).'); + } + + const pollMs = opts?.pollMs ?? 1500; + const TERMINAL = new Set(['SUCCESS', 'ERROR', 'CANCELLED']); + + while (true) { + const job = await request({ url: `/api/jobs/${jobId}`, method: 'get' }); + if (opts?.onProgress) { + opts.onProgress(Number(job?.progress ?? 0), job?.progressMessage ?? null); + } + if (job?.status && TERMINAL.has(job.status)) { + if (job.status === 'SUCCESS' && job.result) { + return job.result; + } + throw new Error(job?.errorMessage || 'Sync-Job fehlgeschlagen'); + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } } export async function fetchSyncStatus( diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index de467e5..47c33c9 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -29,6 +29,7 @@ export interface PortField { /** Plain string or per-language map from the API catalog. */ description: string | Record; required: boolean; + enumValues?: string[] | null; } export interface PortSchema { @@ -40,8 +41,14 @@ export interface InputPortDef { accepts: string[]; } +/** Graph-defined output schema (e.g. form fields from node parameters). */ +export interface GraphDefinedSchemaRef { + kind: 'fromGraph'; + parameter: string; +} + export interface OutputPortDef { - schema: string; + schema: string | GraphDefinedSchemaRef; dynamic?: boolean; deriveFrom?: string; } @@ -90,7 +97,7 @@ export interface Automation2GraphNode { type: string; parameters?: Record; inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>; - outputPorts?: Array<{ name: string; schema: string }>; + outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>; } export interface Automation2Connection { @@ -109,6 +116,10 @@ export interface ExecuteGraphResponse { success: boolean; nodeOutputs?: Record; error?: string; + /** Soft, non-blocking message displayed alongside a successful response. + * Used e.g. by the Save flow to surface "Gespeichert mit X Pflicht-Fehlern" + * without flipping `success` to `false`. */ + warning?: string; stopped?: boolean; failedNode?: string; paused?: boolean; @@ -145,8 +156,8 @@ export interface Automation2Workflow { stuckAtNodeId?: string; /** Enriched: human-readable label for stuck node */ stuckAtNodeLabel?: string; - /** Enriched: created timestamp (seconds) */ - createdAt?: number; + /** From PowerOnModel base — record creation timestamp (seconds) */ + sysCreatedAt?: number; /** Enriched: last run started timestamp (seconds) */ lastStartedAt?: number; } @@ -264,8 +275,55 @@ export async function fetchNodeTypes( }); const nodeTypes = data?.nodeTypes ?? []; const categories = data?.categories ?? []; - console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`); - return { nodeTypes, categories }; + const portTypeCatalog = data?.portTypeCatalog ?? undefined; + const systemVariables = data?.systemVariables ?? undefined; + console.log( + `${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` + + `${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` + + `${systemVariables ? Object.keys(systemVariables).length : 0} sysVars` + ); + return { nodeTypes, categories, portTypeCatalog, systemVariables }; +} + +export interface UpstreamPathEntry { + producerNodeId: string; + producerLabel?: string; + path: (string | number)[]; + type: string; + label: string; + scopeOrigin: 'data' | 'loop' | 'system'; +} + +/** + * POST /api/workflows/{instanceId}/upstream-paths — pickable upstream paths for DataPicker / AI. + */ +export async function postUpstreamPaths( + request: ApiRequestFunction, + instanceId: string, + graph: Automation2Graph, + nodeId: string +): Promise<{ paths: UpstreamPathEntry[] }> { + const data = await request({ + url: `/api/workflows/${instanceId}/upstream-paths`, + method: 'post', + data: { graph, nodeId }, + }); + return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; +} + +/** GET saved workflow graph variant of upstream-paths (requires workflowId). */ +export async function getUpstreamPathsSaved( + request: ApiRequestFunction, + instanceId: string, + workflowId: string, + nodeId: string +): Promise<{ paths: UpstreamPathEntry[] }> { + const data = await request({ + url: `/api/workflows/${instanceId}/upstream-paths/${encodeURIComponent(nodeId)}`, + method: 'get', + params: { workflowId }, + }); + return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; } /** diff --git a/src/components/FlowEditor/context/Automation2DataFlowContext.tsx b/src/components/FlowEditor/context/Automation2DataFlowContext.tsx index 8fb4419..8be4ea9 100644 --- a/src/components/FlowEditor/context/Automation2DataFlowContext.tsx +++ b/src/components/FlowEditor/context/Automation2DataFlowContext.tsx @@ -6,7 +6,7 @@ import React, { createContext, useContext, useMemo } from 'react'; import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas'; import { getAvailableSources } from '../nodes/shared/dataFlowGraph'; -import type { NodeType, PortSchema, SystemVariable } from '../../../api/workflowApi'; +import type { ApiRequestFunction, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi'; export interface Automation2DataFlowContextValue { currentNodeId: string; @@ -19,6 +19,11 @@ export interface Automation2DataFlowContextValue { systemVariables: Record; getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string; getAvailableSourceIds: () => string[]; + /** Present when rendered inside the flow editor (ConnectionPicker / tools). */ + instanceId?: string; + request?: ApiRequestFunction; + /** Build FormPayload-like schema from ``parameters[parameterKey]`` (fieldBuilder JSON). */ + parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null; } const Automation2DataFlowContext = createContext(null); @@ -36,6 +41,8 @@ interface Automation2DataFlowProviderProps { language: string; portTypeCatalog?: Record; systemVariables?: Record; + instanceId?: string; + request?: ApiRequestFunction; children: React.ReactNode; } @@ -48,10 +55,52 @@ export const Automation2DataFlowProvider: React.FC { const value = useMemo((): Automation2DataFlowContextValue | null => { if (!node) return null; + const parseGraphDefinedSchema = (parameterKey: string): PortSchema | null => { + const raw = node.parameters?.[parameterKey]; + if (!Array.isArray(raw)) return null; + const fields: PortField[] = []; + for (const item of raw) { + if (typeof item !== 'object' || item === null) continue; + const rec = item as Record; + if (typeof rec.name !== 'string') continue; + const lab = rec.label; + const desc = + typeof lab === 'string' ? lab : typeof lab === 'object' && lab !== null ? String((lab as Record).de ?? '') : ''; + const ftype = typeof rec.type === 'string' ? rec.type : 'str'; + if (ftype === 'group' && Array.isArray(rec.fields)) { + for (const sub of rec.fields as Record[]) { + if (!sub || typeof sub.name !== 'string') continue; + const sl = sub.label; + const sdesc = + typeof sl === 'string' + ? sl + : typeof sl === 'object' && sl !== null + ? String((sl as Record).de ?? '') + : ''; + fields.push({ + name: `${rec.name}.${sub.name}`, + type: typeof sub.type === 'string' ? sub.type : 'str', + description: (sdesc && sdesc.trim()) || `${rec.name}.${sub.name}`, + required: Boolean(sub.required), + }); + } + continue; + } + fields.push({ + name: rec.name, + type: ftype, + description: (desc && desc.trim()) || rec.name, + required: Boolean(rec.required), + }); + } + return fields.length ? { name: 'FormPayload_dynamic', fields } : null; + }; return { currentNodeId: node.id, nodes, @@ -64,8 +113,11 @@ export const Automation2DataFlowProvider: React.FC n.title ?? n.label ?? n.type ?? n.id, getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections), + instanceId, + request, + parseGraphDefinedSchema, }; - }, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables]); + }, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, instanceId, request]); return ( diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css index 5f1e938..67a4261 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css @@ -256,6 +256,225 @@ background: var(--bg-primary, #fff); } +/* Toolbar: context (load + name) is fluid with ellipsis; actions stay right-aligned. */ +.canvasHeaderRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + width: 100%; +} + +@media (max-width: 900px) { + .canvasHeaderRow { + grid-template-columns: 1fr; + } +} + +.canvasHeaderContext { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + flex: 1; +} + +/* Closed setNameValue(e.target.value)} - onBlur={_commitNameEdit} - onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }} - style={{ padding: '0.25rem 0.4rem', fontSize: '0.95rem', fontWeight: 600, border: '1px solid var(--primary-color, #007bff)', borderRadius: 4, outline: 'none', minWidth: 140, maxWidth: 300 }} - /> - ) : ( -

- {currentWorkflow.label} -

- ) - ) : ( -

- {t('Neuer Workflow')} -

- )} - {onWorkflowSettings && ( - - - {newMenuOpen && ( -
- - {onNewFromTemplate && ( - - )} -
)} - - - {onAutoLayout && ( - - )} - - {/* Save as template */} - {currentWorkflowId && onSaveAsTemplate && ( -
- - {templateMenuOpen && ( -
- {(['user', 'instance', 'mandate'] as const).map((s) => ( +
+
+
+ + +
+ {newMenuOpen && ( +
+ + {onNewFromTemplate && ( - ))} + )}
)}
- )} - - - {onToggleChat && ( - - )} + + {onAutoLayout && ( + + )} + + {currentWorkflowId && onSaveAsTemplate && ( +
+ + {templateMenuOpen && ( +
+ {(['user', 'instance', 'mandate'] as const).map((s) => ( + + ))} +
+ )} +
+ )} + + + {onToggleChat && ( + + )} + {_isSysAdmin && onVerboseSchemaChange && ( + + )} +
- {/* Version Selector */} {currentWorkflowId && versions && versions.length > 0 && ( -
- {t('Version:')} +
+ {t('Version:')} { const next = [...fields]; - const fieldType = e.target.value; - next[i] = { - ...next[i], - type: fieldType, - ...(fieldType === 'clickup_tasks' - ? { clickupStatusOptions: undefined } - : fieldType === 'clickup_status' - ? { clickupConnectionId: undefined, clickupListId: undefined } - : { - clickupConnectionId: undefined, - clickupListId: undefined, - clickupStatusOptions: undefined, - }), - }; + next[i] = { name: f.name, label: f.label, type: e.target.value as FormField['type'], required: f.required }; updateParam('fields', next); }} style={{ width: 'auto', minWidth: 90 }} > - - - - - - + {FORM_FIELD_TYPES.map(ft => ( + + ))}
- {f.type === 'clickup_status' ? ( -
- {Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? ( -

- {t( - 'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).', - { count: String(f.clickupStatusOptions.length) } - )} -

- ) : ( -

- {t( - 'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.' - )} -

- )} -
- ) : null} - {f.type === 'clickup_tasks' ? ( -
- - - - { - const next = [...fields]; - next[i] = { ...next[i], clickupListId: e.target.value }; - updateParam('fields', next); - }} - style={{ width: '100%' }} - /> -

- {t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '} - {'{ add: [taskId], rem: [] }'}{' '} - {t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')} -

-
- ) : null}
))} +
+ )} + + {isMissing && ( +
+ {t('Pflicht-Bindung fehlt — Quelle aus Upstream-Node wählen.')} +
+ )} + + + + {dataFlow && ( + setPickerOpen(false)} + onPick={onPick} + availableSourceIds={sourceIds} + nodes={dataFlow.nodes} + nodeOutputsPreview={dataFlow.nodeOutputsPreview} + getNodeLabel={dataFlow.getNodeLabel} + expectedParamType={param.type} + /> + )} + + ); +}; diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/FeatureInstancePicker.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/FeatureInstancePicker.tsx new file mode 100644 index 0000000..925e310 --- /dev/null +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/FeatureInstancePicker.tsx @@ -0,0 +1,158 @@ +/** + * FeatureInstancePicker — renderer for frontendType="featureInstance". + * + * Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered + * by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via + * GET /api/workflows/{instanceId}/options/feature.instance?featureCode= + * + * Behavior matches the rest of the editor: + * - 0 results -> hint to create a feature instance for this mandate + * - 1 result -> auto-pick (no manual click required) + * - N results -> onChange(e.target.value)} + style={{ + width: '100%', + maxWidth: '100%', + boxSizing: 'border-box', + padding: '4px 8px', + borderRadius: 4, + border: '1px solid var(--border-color)', + background: 'var(--bg-primary)', + color: 'var(--text-primary)', + }} + > + + {instances.map((c) => ( + + ))} + + )} + {loadError && ( +
+ {t('Mandanten-Liste konnte nicht geladen werden')} +
+ )} + + ); +}; + +export default FeatureInstancePicker; diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx index 3d86c7f..595850f 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx @@ -6,6 +6,7 @@ import type { ComponentType } from 'react'; import type { NodeTypeParameter } from '../../../../api/workflowApi'; import type { ApiRequestFunction } from '../../../../api/workflowApi'; +import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; export interface FieldRendererProps { param: NodeTypeParameter; @@ -26,6 +27,12 @@ export type FieldRendererComponent = ComponentType; import React from 'react'; import { useLanguage } from '../../../../providers/language/LanguageContext'; +import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; +import { toApiGraph } from '../shared/graphUtils'; +import { postUpstreamPaths } from '../../../../api/workflowApi'; +import type { CanvasNode } from '../../editor/FlowCanvas'; +import { DataRefRenderer } from './DataRefRenderer'; +import { FeatureInstancePicker } from './FeatureInstancePicker'; const TextInput: React.FC = ({ param, value, onChange }) => (
@@ -152,8 +159,11 @@ const HiddenInput: React.FC = () => null; const ConnectionPicker: React.FC = ({ param, value, onChange, instanceId, request }) => { const { t } = useLanguage(); + const dataFlow = useAutomation2DataFlow(); const [connections, setConnections] = React.useState>([]); const [loadError, setLoadError] = React.useState(null); + const [upstreamBindOptions, setUpstreamBindOptions] = React.useState>([]); + const autoSingleRef = React.useRef(false); const authority = (param.frontendOptions?.authority as string | undefined) || undefined; React.useEffect(() => { if (!instanceId || !request) return; @@ -170,26 +180,100 @@ const ConnectionPicker: React.FC = ({ param, value, onChange setLoadError(err instanceof Error ? err.message : String(err)); }); }, [instanceId, request, authority]); + + React.useEffect(() => { + if (!instanceId || !request || !dataFlow?.currentNodeId) { + setUpstreamBindOptions([]); + return; + } + const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections); + postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId) + .then(({ paths }) => { + const opts = paths + .filter( + (p) => + p.path.length > 0 + && (String(p.path[p.path.length - 1]) === 'id' || p.path.join('.').includes('connection')), + ) + .map((p, i) => ({ + key: `${p.producerNodeId}:${p.path.join('.')}:${i}`, + label: `${p.producerLabel ?? p.producerNodeId} → ${p.label}`, + ref: { + type: 'ref', + nodeId: p.producerNodeId, + path: p.path, + expectedType: p.type, + }, + })); + setUpstreamBindOptions(opts); + }) + .catch(() => setUpstreamBindOptions([])); + }, [instanceId, request, dataFlow?.currentNodeId, dataFlow?.nodes, dataFlow?.connections]); + + React.useEffect(() => { + if (connections.length !== 1 || autoSingleRef.current) return; + if (value !== '' && value !== undefined && value !== null) return; + autoSingleRef.current = true; + onChange(connections[0].id); + }, [connections, value, onChange]); + + const strVal = typeof value === 'string' ? value : ''; + const isRef = typeof value === 'object' && value !== null && (value as { type?: string }).type === 'ref'; + const selectedUpstreamKey = + isRef + ? upstreamBindOptions.find((o) => { + const r = o.ref as { nodeId?: string; path?: unknown[] }; + const v = value as { nodeId?: string; path?: unknown[] }; + return r.nodeId === v.nodeId && JSON.stringify(r.path ?? []) === JSON.stringify(v.path ?? []); + })?.key ?? '' + : ''; + return (
- - {!loadError && connections.length === 0 && ( -
+ {connections.length === 0 && !loadError && ( +
{authority ? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority }) : t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
)} + {connections.length === 1 && ( +
+ {connections[0].label} +
+ )} + {connections.length > 1 && ( + + )} + {upstreamBindOptions.length > 0 && ( +
+
{t('Oder aus vorherigem Node (DataRef)')}
+ +
+ )} {loadError && (
{t('Verbindungen konnten nicht geladen werden')}
)} @@ -464,18 +548,69 @@ const FieldBuilderEditor: React.FC = ({ param, value, onChan
updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} /> updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} /> + {String(f.type) === 'group' && ( +
+
{t('Unterfelder')}
+ {(Array.isArray(f.fields) ? f.fields : []).map((sub: Record, j: number) => ( +
+ { + const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; + nextFields[j] = { ...sub, name: e.target.value }; + updateField(i, 'fields', nextFields); + }} + style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} + /> + + +
+ ))} + +
+ )}
))} @@ -618,7 +753,9 @@ export const FRONTEND_TYPE_RENDERERS: Record = { json: JsonEditor, file: TextInput, hidden: HiddenInput, + dataRef: DataRefRenderer, userConnection: ConnectionPicker, + featureInstance: FeatureInstancePicker, sharepointFolder: SharepointPathPicker, sharepointFile: SharepointPathPicker, clickupList: FolderPicker, @@ -630,6 +767,7 @@ export const FRONTEND_TYPE_RENDERERS: Record = { condition: ConditionBuilder, mappingTable: MappingTableEditor, filterExpression: FilterExpressionEditor, + attachmentBuilder: JsonEditor, }; export default FRONTEND_TYPE_RENDERERS; diff --git a/src/components/FlowEditor/nodes/shared/DataPicker.test.tsx b/src/components/FlowEditor/nodes/shared/DataPicker.test.tsx new file mode 100644 index 0000000..2df5ba5 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/DataPicker.test.tsx @@ -0,0 +1,194 @@ +// Copyright (c) 2025 Patrick Motsch +// All rights reserved. +// +// Plan #2 — Track A1.2 / A1.3 +// T7: DataPicker strict-type filtering (only compatible candidates rendered). +// T8: DataPicker generic object drill-down via wildcard '*' segment when the +// schema declares List[X] of a known X. + +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; +import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi'; +import type { DataRef, SystemVarRef } from './dataRef'; + +vi.mock('../../../../providers/language/LanguageContext', () => ({ + useLanguage: () => ({ t: (s: string) => s }), +})); + +let _ctxValue: unknown = null; +vi.mock('../../context/Automation2DataFlowContext', () => ({ + useAutomation2DataFlow: () => _ctxValue, +})); + +import { DataPicker } from './DataPicker'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function _field(name: string, type: string): PortField { + return { name, type, description: '', required: false }; +} + +const _docListSchema: PortSchema = { + name: 'DocumentList', + fields: [ + _field('documents', 'List[UdmDocument]'), + _field('count', 'int'), + _field('meta', 'str'), + ], +}; +const _udmDocumentSchema: PortSchema = { + name: 'UdmDocument', + fields: [ + _field('name', 'str'), + _field('mimeType', 'str'), + _field('sizeBytes', 'int'), + ], +}; + +const _portCatalog: Record = { + DocumentList: _docListSchema, + UdmDocument: _udmDocumentSchema, +}; + +function _setContext(opts: { + consumerNodeId: string; + nodes: CanvasNode[]; + connections: CanvasConnection[]; + nodeTypes: NodeType[]; +}) { + _ctxValue = { + currentNodeId: opts.consumerNodeId, + nodes: opts.nodes, + connections: opts.connections, + nodeTypes: opts.nodeTypes, + portTypeCatalog: _portCatalog, + nodeOutputsPreview: {}, + systemVariables: {}, + language: 'de', + getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id, + getAvailableSourceIds: () => opts.nodes.filter((n) => n.id !== opts.consumerNodeId).map((n) => n.id), + parseGraphDefinedSchema: () => null, + }; +} + +function _node(id: string, type: string): CanvasNode { + return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} }; +} +function _conn(id: string, src: string, tgt: string): CanvasConnection { + return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 }; +} +function _nodeType(id: string, outputSchema: string): NodeType { + return { + id, + label: id, + description: id, + category: 'test', + parameters: [], + inputs: 1, + outputs: 1, + outputPorts: [{ schema: outputSchema }], + } as unknown as NodeType; +} + +function _renderPicker(props?: { expectedParamType?: string; onPick?: (r: DataRef | SystemVarRef) => void }) { + const upstream = _node('up', 'sharepoint.readDocs'); + const consumer = _node('cons', 'ai.summarize'); + _setContext({ + consumerNodeId: 'cons', + nodes: [upstream, consumer], + connections: [_conn('c1', 'up', 'cons')], + nodeTypes: [_nodeType('sharepoint.readDocs', 'DocumentList'), _nodeType('ai.summarize', 'AiResult')], + }); + return render( + {}} + onPick={props?.onPick ?? (() => {})} + availableSourceIds={['up']} + nodes={[upstream]} + nodeOutputsPreview={{}} + getNodeLabel={(n) => n.title ?? n.id} + expectedParamType={props?.expectedParamType} + />, + ); +} + +// --------------------------------------------------------------------------- +// T8: Wildcard drill-down +// --------------------------------------------------------------------------- + +describe('DataPicker — generic-object drill-down (T8)', () => { + it('renders the wildcard "documents → * → name" path when drilling into List[UdmDocument]', async () => { + _renderPicker(); + await userEvent.click(screen.getByText(/^up$/)); + expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument(); + expect(screen.getByText(/documents → \* → mimeType/)).toBeInTheDocument(); + }); + + it('lists the wholeOutput, top-level fields, and drilled fields together', async () => { + _renderPicker(); + await userEvent.click(screen.getByText(/^up$/)); + expect(screen.getByText('documents')).toBeInTheDocument(); + expect(screen.getByText('count')).toBeInTheDocument(); + expect(screen.getByText('meta')).toBeInTheDocument(); + // Multiple drilled candidates exist (name, mimeType, sizeBytes, _success, _error). + expect(screen.getAllByText(/documents → \*/).length).toBeGreaterThanOrEqual(2); + }); +}); + +// --------------------------------------------------------------------------- +// T7: Strict type filter +// --------------------------------------------------------------------------- + +describe('DataPicker — strict type filtering (T7)', () => { + it('hides hard-mismatch fields when expectedParamType is set + strict toggle is on (default)', async () => { + _renderPicker({ expectedParamType: 'str' }); + expect(screen.getByLabelText(/Nur kompatible/i)).toBeChecked(); + await userEvent.click(screen.getByText(/^up$/)); + // documents (List[UdmDocument]) is a hard mismatch → must be hidden. + expect(screen.queryByText('documents')).not.toBeInTheDocument(); + // meta (str) is exact match → kept. + expect(screen.getByText('meta')).toBeInTheDocument(); + // count (int) is "coerce" against str → kept (coerce is allowed in strict mode). + expect(screen.getByText('count')).toBeInTheDocument(); + // Drilled wildcard candidates of type str (name, mimeType) remain. + expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument(); + }); + + it('shows all fields after the user disables the strict toggle', async () => { + _renderPicker({ expectedParamType: 'str' }); + await userEvent.click(screen.getByLabelText(/Nur kompatible/i)); + await userEvent.click(screen.getByText(/^up$/)); + expect(screen.getByText('documents')).toBeInTheDocument(); + expect(screen.getByText('count')).toBeInTheDocument(); + expect(screen.getByText('meta')).toBeInTheDocument(); + }); + + it('shows the iterieren-button on List[X] candidates that match expectedParamType=X (T6)', async () => { + _renderPicker({ expectedParamType: 'UdmDocument' }); + await userEvent.click(screen.getByText(/^up$/)); + // documents (List[UdmDocument]) is the only candidate with expectedParamType=UdmDocument + expect(screen.getByText('documents')).toBeInTheDocument(); + expect(screen.getByText('iterieren')).toBeInTheDocument(); + }); + + it('emits a wildcard ref when the user clicks "iterieren"', async () => { + const onPick = vi.fn(); + _renderPicker({ expectedParamType: 'UdmDocument', onPick }); + await userEvent.click(screen.getByText(/^up$/)); + await userEvent.click(screen.getByText('iterieren')); + expect(onPick).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ref', + nodeId: 'up', + path: ['documents', '*'], + expectedType: 'UdmDocument', + }), + ); + }); +}); diff --git a/src/components/FlowEditor/nodes/shared/DataPicker.tsx b/src/components/FlowEditor/nodes/shared/DataPicker.tsx index 8735008..4edadc7 100644 --- a/src/components/FlowEditor/nodes/shared/DataPicker.tsx +++ b/src/components/FlowEditor/nodes/shared/DataPicker.tsx @@ -5,10 +5,12 @@ * Includes a System Variables section. */ -import React, { useState } from 'react'; -import { createRef, createSystemVar, type DataRef, type SystemVarRef } from './dataRef'; +import React, { useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; -import type { NodeType, PortSchema } from '../../../../api/workflowApi'; +import type { GraphDefinedSchemaRef, NodeType, PortSchema } from '../../../../api/workflowApi'; +import { findLoopAncestorIds } from './scopeHelpers'; import styles from '../../editor/Automation2FlowEditor.module.css'; import { useLanguage } from '../../../../providers/language/LanguageContext'; @@ -18,33 +20,103 @@ interface DataPickerProps { onClose: () => void; onPick: (ref: DataRef | SystemVarRef) => void; availableSourceIds: string[]; - nodes: Array<{ id: string; title?: string; type?: string }>; + nodes: Array<{ id: string; title?: string; type?: string; parameters?: Record }>; nodeOutputsPreview: Record; getNodeLabel: (node: { id: string; title?: string }) => string; + /** When set, the picker can hide incompatible candidates (strict toggle) and + * surfaces "Iterieren als Loop" affordances for List[X]→X candidates. */ + expectedParamType?: string; } interface PickablePath { path: (string | number)[]; label: string; type?: string; + /** True iff this path produces `List[X]` and the consumer expects `X` — + * picking with iterate=true appends the wildcard segment. */ + iterable?: boolean; } +const _LIST_INNER_RE = /^List\[(.+)\]$/; + function _buildPathsFromSchema( schema: PortSchema | undefined, + catalog: Record, basePath: (string | number)[] = [], + depth = 0, ): PickablePath[] { - if (!schema || !schema.fields) return []; + if (!schema || !schema.fields || depth > 8) return []; const result: PickablePath[] = []; for (const field of schema.fields) { const fieldPath = [...basePath, field.name]; const label = fieldPath.map(String).join(' → '); result.push({ path: fieldPath, label, type: field.type }); + const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null; + const inner = m?.[1]?.trim(); + if (inner && catalog[inner]) { + // Generic List drill-down: use '*' wildcard so the engine maps each item. + result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1)); + } } result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' }); result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' }); return result; } +/** Annotate each candidate with `iterable=true` if it is `List[X]` and the + * consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */ +function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] { + if (!expectedParamType) return paths; + return paths.map((p) => { + if (!p.type) return p; + const m = p.type.match(_LIST_INNER_RE); + if (m && m[1].trim() === expectedParamType) return { ...p, iterable: true }; + return p; + }); +} + +function _deriveFormPortSchemaFromParams( + node: { parameters?: Record }, + paramKey: string, +): PortSchema | undefined { + const raw = node.parameters?.[paramKey]; + if (!Array.isArray(raw)) return undefined; + const fields: Array<{ name: string; type: string; description: string | Record; required: boolean }> = []; + for (const item of raw) { + if (typeof item !== 'object' || item === null) continue; + const rec = item as Record; + if (typeof rec.name !== 'string') continue; + const lab = rec.label; + let description: string | Record = rec.name; + if (typeof lab === 'string') description = lab; + else if (lab && typeof lab === 'object') description = lab as Record; + const ftype = typeof rec.type === 'string' ? rec.type : 'str'; + if (ftype === 'group' && Array.isArray(rec.fields)) { + for (const sub of rec.fields as Record[]) { + if (!sub || typeof sub.name !== 'string') continue; + const sl = sub.label; + let sdesc: string | Record = `${rec.name}.${sub.name}`; + if (typeof sl === 'string') sdesc = sl; + else if (sl && typeof sl === 'object') sdesc = sl as Record; + fields.push({ + name: `${rec.name}.${sub.name}`, + type: typeof sub.type === 'string' ? sub.type : 'str', + description: sdesc, + required: Boolean(sub.required), + }); + } + continue; + } + fields.push({ + name: rec.name, + type: ftype, + description, + required: Boolean(rec.required), + }); + } + return fields.length ? { name: 'FormPayload_dynamic', fields } : undefined; +} + function _buildPathsFromPreview( obj: unknown, basePath: (string | number)[] = [], @@ -74,7 +146,7 @@ function _buildPathsFromPreview( function _resolveSchemaForNode( nodeId: string, - nodes: Array<{ id: string; type?: string }>, + nodes: Array<{ id: string; type?: string; parameters?: Record }>, nodeTypes: NodeType[], connections: Array<{ source: string; target: string; sourceOutput?: number }>, catalog: Record, @@ -88,11 +160,23 @@ function _resolveSchemaForNode( const typeDef = nodeTypes.find((nt) => nt.id === node.type); if (!typeDef?.outputPorts) return undefined; - const port0 = typeDef.outputPorts[0]; + const port0 = typeDef.outputPorts[0] as { + schema?: string | GraphDefinedSchemaRef; + dynamic?: boolean; + deriveFrom?: string; + }; if (!port0) return undefined; - if (port0.schema !== 'Transit') { - return catalog[port0.schema]; + const schemaSpec = port0.schema; + if (typeof schemaSpec === 'object' && schemaSpec !== null && schemaSpec.kind === 'fromGraph') { + const paramKey = schemaSpec.parameter ?? 'fields'; + return _deriveFormPortSchemaFromParams(node, paramKey); + } + if (port0.dynamic && port0.deriveFrom) { + return _deriveFormPortSchemaFromParams(node, port0.deriveFrom); + } + if (typeof schemaSpec === 'string' && schemaSpec !== 'Transit') { + return catalog[schemaSpec]; } // Transit: follow the incoming connection to find the real producer @@ -108,23 +192,42 @@ export const DataPicker: React.FC = ({ open, nodes, nodeOutputsPreview, getNodeLabel, + expectedParamType, }) => { const { t } = useLanguage(); const [expandedNodes, setExpandedNodes] = useState>(new Set()); const [showSystem, setShowSystem] = useState(false); + // Default: when the consumer declares an expected type, show only compatible + // candidates ("strict" mode). User can override per-session via the toggle. + const [strictFilter, setStrictFilter] = useState(Boolean(expectedParamType)); const ctx = useAutomation2DataFlow(); + // NOTE: All hooks must be called unconditionally on every render to satisfy + // the Rules of Hooks. The `if (!open) return null;` early-return therefore + // has to live BELOW every hook in this component. Adding a useMemo (or any + // other hook) below it would change the hook count when the picker toggles + // open/closed and crash the whole tree (white screen). + const connectionsRaw = ctx?.connections ?? []; + const connections = useMemo( + () => + connectionsRaw.map((c) => ({ + source: c.sourceId, + target: c.targetId, + sourceOutput: c.sourceHandle, + })), + [connectionsRaw], + ); + const loopAncestorIds = useMemo(() => { + const cid = ctx?.currentNodeId; + if (!cid) return [] as string[]; + return findLoopAncestorIds(nodes, connections, cid); + }, [ctx?.currentNodeId, nodes, connections]); + if (!open) return null; const catalog = ctx?.portTypeCatalog ?? {}; const systemVars = ctx?.systemVariables ?? {}; const nodeTypes = ctx?.nodeTypes ?? []; - const connectionsRaw = ctx?.connections ?? []; - const connections = connectionsRaw.map((c) => ({ - source: c.sourceId, - target: c.targetId, - sourceOutput: c.sourceHandle, - })); const toggleExpand = (nodeId: string) => { setExpandedNodes((prev) => { @@ -135,8 +238,15 @@ export const DataPicker: React.FC = ({ open, }); }; - const handlePick = (nodeId: string, path: (string | number)[]) => { - onPick(createRef(nodeId, path)); + const handlePick = (nodeId: string, path: (string | number)[], expectedType?: string) => { + onPick(createRef(nodeId, path, expectedType)); + onClose(); + }; + + /** Loop-Vorschlag: for List[X]→X candidates, append the '*' wildcard so the + * engine maps the consumer over each element (executionEngine wildcard). */ + const handlePickIterate = (nodeId: string, path: (string | number)[], expectedType?: string) => { + onPick(createRef(nodeId, [...path, '*'], expectedType)); onClose(); }; @@ -145,17 +255,98 @@ export const DataPicker: React.FC = ({ open, onClose(); }; - return ( -
-
e.stopPropagation()}> + const _dialog = ( +
e.key === 'Escape' && onClose()} + role="presentation" + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="automation2DataPickerTitle" + >
-

{t('Datenquelle wählen')}

- +

+ {t('Datenquelle wählen')} + {expectedParamType && ( + + {expectedParamType} + + )} +

+
+ {expectedParamType && ( + + )} + +
{/* System Variables Section */} + {loopAncestorIds.length > 0 && ( +
+
+ {t('Schleife (lexikalisch)')} +
+
+ {loopAncestorIds.map((loopId) => { + const loopNode = nodes.find((n) => n.id === loopId); + const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId; + const loopSchema = catalog.LoopItem; + const loopPaths = loopSchema + ? _buildPathsFromSchema(loopSchema, catalog, [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_')) + : [ + { path: ['currentItem'], label: 'currentItem', type: 'Any' }, + { path: ['currentIndex'], label: 'currentIndex', type: 'int' }, + { path: ['count'], label: 'count', type: 'int' }, + ]; + return ( +
+
{loopLabel}
+ {loopPaths.map((p, i) => { + const compat = expectedParamType && p.type + ? isCompatible(p.type, expectedParamType) + : 'ok'; + return ( + + ); + })} +
+ ); + })} +
+
+ )} + {Object.keys(systemVars).length > 0 && (
))}
@@ -199,12 +390,26 @@ export const DataPicker: React.FC = ({ open, const isExpanded = expandedNodes.has(nodeId); const resolvedSchema = _resolveSchemaForNode( - nodeId, nodes, nodeTypes, connections, catalog, + nodeId, + nodes, + nodeTypes, + connections, + catalog, ); - const schemaPaths = _buildPathsFromSchema(resolvedSchema); - const paths = schemaPaths.length > 0 - ? schemaPaths - : _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')); + const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog); + const annotated = _markIterableCandidates( + schemaPaths.length > 0 + ? schemaPaths + : _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')), + expectedParamType, + ); + const paths = strictFilter && expectedParamType + ? annotated.filter((p) => { + if (p.iterable) return true; + if (!p.type) return false; + return isCompatible(p.type, expectedParamType) !== 'mismatch'; + }) + : annotated; return (
@@ -216,28 +421,52 @@ export const DataPicker: React.FC = ({ open, {isExpanded ? '▼' : '▶'} {label} {resolvedSchema && ( - + ({resolvedSchema.name}) )} {isExpanded && (
- {paths.map((p, i) => ( - - ))} + {paths.length === 0 && ( +
+ {t('(keine kompatiblen Felder — Filter „Nur kompatible“ deaktivieren)')} +
+ )} + {paths.map((p, i) => { + const compat = + expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok'; + return ( +
+ + {p.iterable && ( + + )} +
+ ); + })}
)}
@@ -248,4 +477,6 @@ export const DataPicker: React.FC = ({ open,
); + + return createPortal(_dialog, document.body); }; diff --git a/src/components/FlowEditor/nodes/shared/RefSourceSelect.tsx b/src/components/FlowEditor/nodes/shared/RefSourceSelect.tsx index 62e8dc1..e1d39ff 100644 --- a/src/components/FlowEditor/nodes/shared/RefSourceSelect.tsx +++ b/src/components/FlowEditor/nodes/shared/RefSourceSelect.tsx @@ -358,8 +358,6 @@ function getFormFieldType( if (rawFieldType === 'email') return 'email'; if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date'; if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean'; - if (rawFieldType === 'clickup_tasks') return 'string'; - if (rawFieldType === 'clickup_status') return 'string'; return 'string'; } diff --git a/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.test.tsx b/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.test.tsx new file mode 100644 index 0000000..e7c2e77 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.test.tsx @@ -0,0 +1,243 @@ +// Copyright (c) 2025 Patrick Motsch +// All rights reserved. +// +// Plan #2 — Track A1.1: Component-level tests for RequiredAttributePicker. +// Validates the 0/1/N rendering logic that orchestrates DataPicker selection +// + the iterierens-suggestion (T5, T6). +// +// We mock the two consumed contexts (LanguageContext + Automation2DataFlow) +// and the DataPicker child so we can assert on the picker UI in isolation. + +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; +import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi'; +import type { DataRef, SystemVarRef } from './dataRef'; + +// --------------------------------------------------------------------------- +// Module mocks — must be registered before importing the SUT +// --------------------------------------------------------------------------- + +vi.mock('../../../../providers/language/LanguageContext', () => ({ + useLanguage: () => ({ t: (s: string) => s }), +})); + +let _ctxValue: unknown = null; +vi.mock('../../context/Automation2DataFlowContext', () => ({ + useAutomation2DataFlow: () => _ctxValue, +})); + +vi.mock('./DataPicker', () => ({ + DataPicker: (props: { + open: boolean; + onClose: () => void; + onPick: (ref: DataRef | SystemVarRef) => void; + }) => { + if (!props.open) return null; + return ( +
+ + +
+ ); + }, +})); + +// SUT imported AFTER mocks (so mocks are applied) +import { RequiredAttributePicker } from './RequiredAttributePicker'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function _field(name: string, type: string): PortField { + return { name, type, description: '', required: false }; +} + +const _docListSchema: PortSchema = { + name: 'DocumentList', + fields: [_field('documents', 'List[UdmDocument]'), _field('count', 'int')], +}; +const _udmDocumentSchema: PortSchema = { + name: 'UdmDocument', + fields: [_field('name', 'str'), _field('mimeType', 'str')], +}; +const _portCatalog: Record = { + DocumentList: _docListSchema, + UdmDocument: _udmDocumentSchema, +}; + +function _setContext(opts: { + consumerNodeId: string; + nodes: CanvasNode[]; + connections: CanvasConnection[]; + nodeTypes: NodeType[]; +}) { + _ctxValue = { + currentNodeId: opts.consumerNodeId, + nodes: opts.nodes, + connections: opts.connections, + nodeTypes: opts.nodeTypes, + portTypeCatalog: _portCatalog, + nodeOutputsPreview: {}, + systemVariables: {}, + language: 'de', + getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id, + getAvailableSourceIds: () => opts.nodes.map((n) => n.id).filter((id) => id !== opts.consumerNodeId), + parseGraphDefinedSchema: () => null, + }; +} + +function _node(id: string, type: string): CanvasNode { + return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} }; +} + +function _conn(id: string, src: string, tgt: string): CanvasConnection { + return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 }; +} + +function _nodeType(id: string, outputSchema: string): NodeType { + return { + id, + label: id, + description: id, + category: 'test', + parameters: [], + inputs: 1, + outputs: 1, + outputPorts: [{ schema: outputSchema }], + } as unknown as NodeType; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('RequiredAttributePicker — 0/1/N rendering (T5/T6)', () => { + it('shows red "no source" pill when no upstream candidate matches (0-case)', () => { + _setContext({ + consumerNodeId: 'cons', + nodes: [_node('cons', 'ai.summarizeDocument')], + connections: [], + nodeTypes: [_nodeType('ai.summarizeDocument', 'AiResult')], + }); + render( + {}} + />, + ); + expect( + screen.getByText(/Keine typkompatible Quelle vorhanden/i), + ).toBeInTheDocument(); + }); + + it('shows auto-bind suggestion when exactly one candidate matches (1-case)', () => { + _setContext({ + consumerNodeId: 'cons', + nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')], + connections: [_conn('c1', 'up', 'cons')], + nodeTypes: [ + _nodeType('sharepoint.readDocs', 'DocumentList'), + _nodeType('ai.summarizeDocument', 'AiResult'), + ], + }); + render( + {}} + />, + ); + expect(screen.getByText(/Vorschlag übernehmen/i)).toBeInTheDocument(); + }); + + it('shows iterieren-suggestion when upstream is List[X] and required is X (T6)', () => { + _setContext({ + consumerNodeId: 'cons', + nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')], + connections: [_conn('c1', 'up', 'cons')], + nodeTypes: [ + _nodeType('sharepoint.readDocs', 'DocumentList'), + _nodeType('ai.summarizeDocument', 'AiResult'), + ], + }); + render( + {}} + />, + ); + expect(screen.getByText(/iterieren/i)).toBeInTheDocument(); + }); + + it('renders bound chip + "Andere wählen" when value is already a DataRef', async () => { + _setContext({ + consumerNodeId: 'cons', + nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')], + connections: [_conn('c1', 'up', 'cons')], + nodeTypes: [ + _nodeType('sharepoint.readDocs', 'DocumentList'), + _nodeType('ai.summarizeDocument', 'AiResult'), + ], + }); + const onChange = vi.fn(); + render( + , + ); + expect(screen.getByText('up')).toBeInTheDocument(); + const clearButton = screen.getByTitle(/Bindung entfernen/i); + await userEvent.click(clearButton); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('opens DataPicker via "Andere wählen" and forwards the picked ref to onChange', async () => { + _setContext({ + consumerNodeId: 'cons', + nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')], + connections: [_conn('c1', 'up', 'cons')], + nodeTypes: [ + _nodeType('sharepoint.readDocs', 'DocumentList'), + _nodeType('ai.summarizeDocument', 'AiResult'), + ], + }); + const onChange = vi.fn(); + render( + , + ); + const otherButton = screen.getByText(/Andere wählen…/i); + await userEvent.click(otherButton); + expect(screen.getByTestId('mock-data-picker')).toBeInTheDocument(); + await userEvent.click(screen.getByText('mock-pick')); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ref', nodeId: 'picked', expectedType: 'DocumentList' }), + ); + }); +}); diff --git a/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.tsx b/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.tsx new file mode 100644 index 0000000..13d84c4 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.tsx @@ -0,0 +1,282 @@ +/** + * RequiredAttributePicker — Phase-4 Schicht-4 binding affordance for + * required parameters of a Schicht-3 Adapter (Editor-Node). + * + * 0/1/N logic, applied on the set of typed source candidates: + * - 0 candidates → red pill: "Keine typkompatible Quelle vorhanden" + * (user must add an upstream node first) + * - 1 candidate → auto-bound chip with a "Andere wählen…" override button + * (still shown explicitly so the user sees what was chosen) + * - N candidates → "Quelle wählen…" button that opens the DataPicker + * pre-filtered to the expected type + * + * The picker also surfaces a "Iterieren als Loop" hint when the expected type + * is `X` and an upstream candidate is `List[X]` — see paramValidation.ts. + */ + +import React, { useMemo, useState } from 'react'; +import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; +import { DataPicker } from './DataPicker'; +import { createRef, formatRefLabel, isRef, type DataRef, type SystemVarRef } from './dataRef'; +import { findSourceCandidates, strictlyCompatible, type SourceCandidate } from './paramValidation'; +import styles from '../../editor/Automation2FlowEditor.module.css'; + +import { useLanguage } from '../../../../providers/language/LanguageContext'; + +export interface RequiredAttributePickerProps { + /** Display label for the parameter (already localized). */ + label: string; + /** Type expected by the bound action argument (e.g. "DocumentList", "str"). */ + expectedType?: string; + /** Current bound value (DataRef, SystemVarRef, or unset). */ + value: unknown; + /** Persist a new binding (or `null` to clear). */ + onChange: (next: DataRef | SystemVarRef | null) => void; + /** Optional description shown beneath the picker. */ + description?: React.ReactNode; +} + +export const RequiredAttributePicker: React.FC = ({ + label, + expectedType, + value, + onChange, + description, +}) => { + const { t } = useLanguage(); + const ctx = useAutomation2DataFlow(); + const [pickerOpen, setPickerOpen] = useState(false); + + const consumerNodeId = ctx?.currentNodeId ?? ''; + const nodes = ctx?.nodes ?? []; + const connections = ctx?.connections ?? []; + const nodeTypes = ctx?.nodeTypes ?? []; + const catalog = ctx?.portTypeCatalog ?? {}; + + const allCandidates: SourceCandidate[] = useMemo(() => { + if (!consumerNodeId) return []; + return findSourceCandidates({ + consumerNodeId, + expectedType, + nodes, + connections: connections.map((c) => ({ + id: c.id, + sourceId: c.sourceId, + sourceHandle: c.sourceHandle, + targetId: c.targetId, + targetHandle: c.targetHandle, + })), + nodeTypes, + portTypeCatalog: catalog, + }); + }, [consumerNodeId, expectedType, nodes, connections, nodeTypes, catalog]); + + const compatibleCandidates = useMemo(() => strictlyCompatible(allCandidates), [allCandidates]); + + const isBoundRef = isRef(value); + const boundLabel = isBoundRef ? formatRefLabel(value as DataRef, nodes) : null; + + // 0/1/N + const candidateCount = compatibleCandidates.length; + const single = candidateCount === 1 ? compatibleCandidates[0] : null; + + const handleAutoBind = () => { + if (!single) return; + const ref = createRef(single.nodeId, single.iterable && expectedType ? [...single.path, '*'] : single.path, expectedType); + onChange(ref); + }; + + const handlePicked = (picked: DataRef | SystemVarRef) => { + onChange(picked); + }; + + return ( +
+ {/* Header: label always takes the full row (flex-basis 100 %), badge + wraps below — prevents long type names like List[ActionDocument] + from escaping the panel frame on the right. */} +
+ + {expectedType && ( + + {expectedType} + + )} +
+ + {isBoundRef ? ( +
+ + {boundLabel} + + + +
+ ) : candidateCount === 0 ? ( +
+ + + {t('Keine typkompatible Quelle vorhanden — füge zuerst einen Knoten ein, der ')} + {expectedType ?? '?'} + {t(' liefert.')} + +
+ ) : single ? ( +
+ + +
+ ) : ( +
+ +
+ )} + + {description && ( +
+ {description} +
+ )} + + {pickerOpen && ( + setPickerOpen(false)} + onPick={(picked) => { + handlePicked(picked); + setPickerOpen(false); + }} + availableSourceIds={ctx?.getAvailableSourceIds() ?? []} + nodes={nodes} + nodeOutputsPreview={ctx?.nodeOutputsPreview ?? {}} + getNodeLabel={(n) => + ctx?.getNodeLabel(n as { id: string; title?: string; label?: string; type?: string }) ?? n.id + } + expectedParamType={expectedType} + /> + )} +
+ ); +}; diff --git a/src/components/FlowEditor/nodes/shared/clickupFormSync.ts b/src/components/FlowEditor/nodes/shared/clickupFormSync.ts deleted file mode 100644 index 5aab3d3..0000000 --- a/src/components/FlowEditor/nodes/shared/clickupFormSync.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * Sync input.form / trigger.form fields + ClickUp "Aufgabe erstellen" refs from a selected ClickUp list. - */ - -import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; -import type { FormField } from './types'; -import { createRef } from './dataRef'; - -export type ClickUpFieldLike = Record; - -function buildReverseAdjacency(connections: CanvasConnection[]): Record { - const rev: Record = {}; - for (const c of connections) { - if (!rev[c.targetId]) rev[c.targetId] = []; - rev[c.targetId].push(c.sourceId); - } - return rev; -} - -/** Nearest form node upstream (toward triggers) of the ClickUp node. */ -export function findClosestUpstreamFormNode( - targetNodeId: string, - nodes: CanvasNode[], - connections: CanvasConnection[] -): CanvasNode | null { - const nodeById = new Map(nodes.map((n) => [n.id, n])); - const rev = buildReverseAdjacency(connections); - const queue: string[] = [...(rev[targetNodeId] ?? [])]; - const visited = new Set(); - while (queue.length > 0) { - const nid = queue.shift()!; - if (visited.has(nid)) continue; - visited.add(nid); - const n = nodeById.get(nid); - if (!n) continue; - if (n.type === 'input.form' || n.type === 'trigger.form') return n; - for (const p of rev[nid] ?? []) { - if (!visited.has(p)) queue.push(p); - } - } - return null; -} - -export function normalizeClickUpFieldType(raw: unknown): string { - return String(raw ?? 'short_text') - .trim() - .toLowerCase() - .replace(/-/g, '_') - .replace(/\s+/g, '_'); -} - -function linkedListIdFromRelationshipField(field: ClickUpFieldLike): string | null { - const tc = (field.type_config ?? {}) as Record; - const asId = (v: unknown): string | null => { - if (typeof v === 'string' && v.trim()) return v.trim(); - if (typeof v === 'number' && Number.isFinite(v)) return String(v); - return null; - }; - const keys = [ - 'linked_list_id', - 'list_id', - 'related_list_id', - 'relationship_list_id', - 'resource_id', - ]; - for (const k of keys) { - const raw = tc[k]; - const id = asId(raw); - if (id) return id; - if (raw && typeof raw === 'object' && raw !== null) { - const nested = asId((raw as Record).id); - if (nested) return nested; - } - } - const rel = tc.relationship; - if (rel && typeof rel === 'object' && rel !== null) { - const r = rel as Record; - const fromRel = asId(r.list_id ?? r.id ?? r.target_id ?? r.linked_list_id ?? r.resource_id); - if (fromRel) return fromRel; - } - return null; -} - -function fieldUnsupported(ft: string): boolean { - return ['tasks', 'user', 'users'].includes(ft); -} - -function mapCuToInputFormField( - field: ClickUpFieldLike, - connectionId: string, - parentListId: string -): FormField | null { - const fid = String(field.id ?? ''); - if (!fid) return null; - const fname = String(field.name ?? fid); - const ft = normalizeClickUpFieldType(field.type); - if (fieldUnsupported(ft)) return null; - const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`; - const label = fname || name; - - if (ft === 'list_relationship') { - const lid = linkedListIdFromRelationshipField(field) ?? parentListId; - return { - name, - label, - type: 'clickup_tasks', - required: false, - clickupConnectionId: connectionId, - clickupListId: lid, - }; - } - if ( - ft === 'drop_down' || - ft === 'dropdown' || - ft === 'text' || - ft === 'long_text' || - ft === 'short_text' || - ft === 'email' || - ft === 'phone' || - ft === 'url' - ) { - return { name, label, type: 'string', required: false }; - } - if (ft === 'number' || ft === 'currency') { - return { name, label, type: 'number', required: false }; - } - if (ft === 'date') { - return { name, label, type: 'date', required: false }; - } - if (ft === 'checkbox') { - return { name, label, type: 'boolean', required: false }; - } - return { name, label, type: 'string', required: false }; -} - -/** trigger.form row; `clickup_status` carries options from the same list API as the ClickUp node dropdown. */ -export type TriggerFormFieldRow = { - name: string; - label: string; - type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status'; - statusOptions?: Array<{ value: string; label: string }>; -}; - -function mapCuToTriggerFormField(field: ClickUpFieldLike, _connectionId: string, _parentListId: string): TriggerFormFieldRow | null { - const fid = String(field.id ?? ''); - if (!fid) return null; - const fname = String(field.name ?? fid); - const ft = normalizeClickUpFieldType(field.type); - if (fieldUnsupported(ft)) return null; - const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`; - const label = fname || name; - if (ft === 'list_relationship') { - return { name, label, type: 'text' }; - } - if (ft === 'number' || ft === 'currency') { - return { name, label, type: 'number' }; - } - if (ft === 'date') { - return { name, label, type: 'date' }; - } - if (ft === 'checkbox') { - return { name, label, type: 'boolean' }; - } - if (ft === 'email') { - return { name, label, type: 'email' }; - } - return { name, label, type: 'text' }; -} - -export const PAYLOAD_TITLE = 'title'; -export const PAYLOAD_DESCRIPTION = 'description'; -export const PAYLOAD_STATUS = 'clickup_status'; -export const PAYLOAD_PRIORITY = 'clickup_priority'; -export const PAYLOAD_DUE = 'clickup_due_date'; -export const PAYLOAD_TIME_H = 'clickup_time_estimate_h'; - -/** Same ordering as ClickUp list `statuses` (GET /list/{id}). */ -export function statusOptionsFromListStatuses( - listStatuses: Array<{ status: string; orderindex: number }> -): Array<{ value: string; label: string }> { - return [...listStatuses] - .sort((a, b) => a.orderindex - b.orderindex) - .map((s) => ({ value: s.status, label: s.status })); -} - -export interface SyncFromListResult { - inputFormFields: FormField[]; - triggerFormFields: TriggerFormFieldRow[]; - clickupPatch: Record; -} - -/** - * Build form field rows + ClickUp createTask parameter patch (refs → payload.*). - */ -export function buildSyncFromClickUpList(args: { - formNodeId: string; - listFields: ClickUpFieldLike[]; - /** From GET /list/{id} → list.statuses (same source as the ClickUp node status dropdown). */ - listStatuses: Array<{ status: string; orderindex: number }>; - connectionId: string; - teamId: string; - listId: string; -}): SyncFromListResult { - const { formNodeId, listFields, listStatuses, connectionId, teamId, listId } = args; - const ref = (key: string) => createRef(formNodeId, ['payload', key]); - - const statusOpts = statusOptionsFromListStatuses(listStatuses); - - const standardInput: FormField[] = [ - { name: PAYLOAD_TITLE, label: 'Titel', type: 'string', required: true }, - { name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'string', required: false }, - ...(statusOpts.length > 0 - ? [ - { - name: PAYLOAD_STATUS, - label: 'Status', - type: 'clickup_status', - required: false, - clickupStatusOptions: statusOpts, - } as FormField, - ] - : []), - { name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number', required: false }, - { name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date', required: false }, - { name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false }, - ]; - - const statusTriggerRow: TriggerFormFieldRow | null = - statusOpts.length > 0 - ? { - name: PAYLOAD_STATUS, - label: 'Status', - type: 'clickup_status', - statusOptions: statusOpts, - } - : null; - - const standardTrigger: TriggerFormFieldRow[] = [ - { name: PAYLOAD_TITLE, label: 'Titel', type: 'text' }, - { name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' }, - ...(statusTriggerRow ? [statusTriggerRow] : []), - { name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number' }, - { name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' }, - { name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' }, - ]; - if (statusOpts.length > 0) { - standardTrigger.splice(2, 0, { - name: PAYLOAD_STATUS, - label: 'Status', - type: 'clickup_status', - statusOptions: statusOpts, - }); - } - - const customInput: FormField[] = []; - const customTrigger: TriggerFormFieldRow[] = []; - const customRefs: Record = {}; - - for (const f of listFields) { - if (!f || typeof f !== 'object') continue; - const inf = mapCuToInputFormField(f as ClickUpFieldLike, connectionId, listId); - const tr = mapCuToTriggerFormField(f as ClickUpFieldLike, connectionId, listId); - if (inf) customInput.push(inf); - if (tr) customTrigger.push(tr); - const fid = String((f as ClickUpFieldLike).id ?? ''); - const payloadKey = inf?.name; - if (fid && payloadKey) { - customRefs[fid] = createRef(formNodeId, ['payload', payloadKey]); - } - } - - const inputFormFields = [...standardInput, ...customInput]; - const triggerFormFields = [...standardTrigger, ...customTrigger]; - - const clickupPatch: Record = { - connectionId, - teamId, - listId, - path: `/team/${teamId}/list/${listId}`, - name: ref(PAYLOAD_TITLE), - description: ref(PAYLOAD_DESCRIPTION), - taskPriority: ref(PAYLOAD_PRIORITY), - taskDueDateMs: ref(PAYLOAD_DUE), - taskTimeEstimateHours: ref(PAYLOAD_TIME_H), - }; - if (statusOpts.length > 0) { - clickupPatch.taskStatus = ref(PAYLOAD_STATUS); - } - if (Object.keys(customRefs).length) { - clickupPatch.customFieldValues = customRefs; - } - - return { inputFormFields, triggerFormFields, clickupPatch }; -} diff --git a/src/components/FlowEditor/nodes/shared/dataRef.ts b/src/components/FlowEditor/nodes/shared/dataRef.ts index 72fd11b..097fbe2 100644 --- a/src/components/FlowEditor/nodes/shared/dataRef.ts +++ b/src/components/FlowEditor/nodes/shared/dataRef.ts @@ -8,6 +8,8 @@ export interface DataRef { type: 'ref'; nodeId: string; path: (string | number)[]; + /** Optional declared type at bind time (for UI / validation hints) */ + expectedType?: string; } /** Explicit static value wrapper */ @@ -63,8 +65,18 @@ export function createSystemVar(variable: string): SystemVarRef { } /** Create a reference object */ -export function createRef(nodeId: string, path: (string | number)[] = []): DataRef { - return { type: 'ref', nodeId, path }; +export function createRef(nodeId: string, path: (string | number)[] = [], expectedType?: string): DataRef { + return { type: 'ref', nodeId, path, ...(expectedType ? { expectedType } : {}) }; +} + +/** Structural type compatibility (best-effort; same as gateway soft rules). */ +export function isCompatible(producedType: string, expectedType: string): 'ok' | 'coerce' | 'mismatch' { + if (!expectedType || !producedType) return 'ok'; + if (producedType === expectedType) return 'ok'; + if (expectedType === 'Any' || producedType === 'Any') return 'ok'; + if (expectedType === 'str' && (producedType === 'int' || producedType === 'float')) return 'coerce'; + if (expectedType === 'int' && producedType === 'str') return 'coerce'; + return 'mismatch'; } /** Create a value wrapper */ diff --git a/src/components/FlowEditor/nodes/shared/graphUtils.ts b/src/components/FlowEditor/nodes/shared/graphUtils.ts index 14524c3..2f63a22 100644 --- a/src/components/FlowEditor/nodes/shared/graphUtils.ts +++ b/src/components/FlowEditor/nodes/shared/graphUtils.ts @@ -8,6 +8,7 @@ import type { Automation2Graph, Automation2GraphNode, Automation2Connection, + GraphDefinedSchemaRef, } from '../../../../api/workflowApi'; import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas'; @@ -42,7 +43,10 @@ export function fromApiGraph( ? Object.entries(nt.inputPorts).map(([, v]) => ({ name: '', schema: '', accepts: (v as { accepts?: string[] }).accepts })) : undefined, outputPorts: nt?.outputPorts - ? Object.entries(nt.outputPorts).map(([, v]) => ({ name: '', schema: (v as { schema?: string }).schema ?? '' })) + ? Object.entries(nt.outputPorts).map(([, v]) => ({ + name: '', + schema: (v as { schema?: string | GraphDefinedSchemaRef }).schema ?? '', + })) : undefined, }; }); diff --git a/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts b/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts index 3f02c89..e0bcd44 100644 --- a/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts +++ b/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts @@ -69,6 +69,10 @@ export function buildNodeOutputPreview( return { _transit: true, _meta: {}, data: {} }; } + if (typeof port0.schema !== 'string') { + return {}; + } + return _buildSchemaPreview(port0.schema); } diff --git a/src/components/FlowEditor/nodes/shared/paramValidation.test.ts b/src/components/FlowEditor/nodes/shared/paramValidation.test.ts new file mode 100644 index 0000000..abdca6d --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/paramValidation.test.ts @@ -0,0 +1,318 @@ +// Copyright (c) 2025 Patrick Motsch +// All rights reserved. +// +// Plan #2 — Track A1 / FE-Tests +// T5/T6 (RequiredAttributePicker): 0/1/N candidate logic + iterierens-suggestion +// T7 (DataPicker): strict type filtering +// T8 (DataPicker): generic-object drill-down via wildcard segment '*' +// +// We test the pure helpers in paramValidation.ts directly. The component +// pickers are thin shells over these helpers, so covering the helpers covers +// the deterministic core of the binding affordance. + +import { describe, expect, it } from 'vitest'; + +import { + findGraphErrors, + findRequiredErrors, + findSourceCandidates, + isParamBound, + strictlyCompatible, + type SourceCandidate, +} from './paramValidation'; +import { createRef, createSystemVar, createValue } from './dataRef'; +import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; +import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function _field(name: string, type: string): PortField { + return { name, type, description: '', required: false }; +} + +function _schema(name: string, fields: PortField[]): PortSchema { + return { name, fields }; +} + +const _docListSchema: PortSchema = _schema('DocumentList', [ + _field('documents', 'List[UdmDocument]'), + _field('count', 'int'), +]); + +const _udmDocumentSchema: PortSchema = _schema('UdmDocument', [ + _field('name', 'str'), + _field('mimeType', 'str'), + _field('sizeBytes', 'int'), +]); + +const _aiResultSchema: PortSchema = _schema('AiResult', [ + _field('text', 'str'), + _field('tokensUsed', 'int'), +]); + +const _portCatalog: Record = { + DocumentList: _docListSchema, + UdmDocument: _udmDocumentSchema, + AiResult: _aiResultSchema, +}; + +function _makeNode(id: string, type: string, parameters: Record = {}): CanvasNode { + return { + id, + type, + title: `${id} (${type})`, + x: 0, + y: 0, + inputs: 1, + outputs: 1, + parameters, + }; +} + +function _makeConnection(id: string, sourceId: string, targetId: string): CanvasConnection { + return { + id, + sourceId, + sourceHandle: 0, + targetId, + targetHandle: 0, + }; +} + +function _makeNodeType( + id: string, + outputSchema: string, + parameters: NodeType['parameters'] = [], +): NodeType { + return { + id, + label: id, + description: id, + category: 'test', + parameters, + inputs: 1, + outputs: 1, + outputPorts: [{ schema: outputSchema }], + } as unknown as NodeType; +} + +// --------------------------------------------------------------------------- +// isParamBound +// --------------------------------------------------------------------------- + +describe('isParamBound', () => { + it('returns false for null/undefined/empty string', () => { + expect(isParamBound(null)).toBe(false); + expect(isParamBound(undefined)).toBe(false); + expect(isParamBound('')).toBe(false); + }); + + it('returns true for non-empty string/number/boolean', () => { + expect(isParamBound('hello')).toBe(true); + expect(isParamBound(0)).toBe(true); + expect(isParamBound(false)).toBe(true); + }); + + it('returns true for a valid DataRef and false for one without nodeId', () => { + expect(isParamBound(createRef('node-1', ['x']))).toBe(true); + expect(isParamBound({ type: 'ref', nodeId: '', path: [] })).toBe(false); + }); + + it('returns true for a SystemVarRef with a variable name', () => { + expect(isParamBound(createSystemVar('user.id'))).toBe(true); + expect(isParamBound({ type: 'system', variable: '' })).toBe(false); + }); + + it('treats {type:"value", value:""} as unbound but {value:0} as bound', () => { + expect(isParamBound(createValue(''))).toBe(false); + expect(isParamBound(createValue(0))).toBe(true); + expect(isParamBound(createValue([]))).toBe(false); + expect(isParamBound(createValue(['a']))).toBe(true); + }); + + it('counts non-empty arrays/objects as bound', () => { + expect(isParamBound([])).toBe(false); + expect(isParamBound([1])).toBe(true); + expect(isParamBound({})).toBe(false); + expect(isParamBound({ k: 1 })).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// findRequiredErrors / findGraphErrors +// --------------------------------------------------------------------------- + +describe('findRequiredErrors', () => { + it('returns empty when all required params are bound', () => { + const node = _makeNode('n1', 'ai.process', { + aiPrompt: 'hello', + documentList: createRef('upstream', ['documents']), + }); + const nodeType = _makeNodeType('ai.process', 'AiResult', [ + { name: 'aiPrompt', type: 'str', required: true }, + { name: 'documentList', type: 'DocumentList', required: true }, + { name: 'optional', type: 'str', required: false }, + ]); + expect(findRequiredErrors(node, nodeType)).toEqual([]); + }); + + it('flags every unbound required param with its name + type', () => { + const node = _makeNode('n1', 'ai.process', {}); + const nodeType = _makeNodeType('ai.process', 'AiResult', [ + { name: 'aiPrompt', type: 'str', required: true }, + { name: 'documentList', type: 'DocumentList', required: true }, + { name: 'optional', type: 'str', required: false }, + ]); + const errs = findRequiredErrors(node, nodeType); + expect(errs).toHaveLength(2); + expect(errs.map((e) => e.paramName)).toEqual(['aiPrompt', 'documentList']); + }); + + it('returns empty list when nodeType is unknown', () => { + const node = _makeNode('n1', 'ghost.node'); + expect(findRequiredErrors(node, undefined)).toEqual([]); + }); + + it('skips required params with frontendType="hidden" (UI safety net)', () => { + // Hidden params have no UI surface, so reporting them as + // "Pflichtfeld ohne Quelle" would create a phantom error the user can + // not resolve. They are auto-set by adapters / system defaults. + const node = _makeNode('n1', 'trustee.extractFromFiles', {}); + const nodeType = _makeNodeType('trustee.extractFromFiles', 'AiResult', [ + { name: 'prompt', type: 'str', required: true }, + { name: 'systemContext', type: 'str', required: true, frontendType: 'hidden' }, + ]); + const errs = findRequiredErrors(node, nodeType); + expect(errs).toHaveLength(1); + expect(errs[0]!.paramName).toBe('prompt'); + }); +}); + +describe('findGraphErrors', () => { + it('aggregates per-node errors and omits clean nodes', () => { + const cleanNodeType = _makeNodeType('clean.node', 'AiResult', [ + { name: 'p1', type: 'str', required: true }, + ]); + const dirtyNodeType = _makeNodeType('dirty.node', 'AiResult', [ + { name: 'p1', type: 'str', required: true }, + { name: 'p2', type: 'str', required: true }, + ]); + const nodes: CanvasNode[] = [ + _makeNode('clean', 'clean.node', { p1: 'value' }), + _makeNode('dirty', 'dirty.node', { p1: 'set' }), + ]; + const result = findGraphErrors(nodes, [cleanNodeType, dirtyNodeType]); + expect(Object.keys(result)).toEqual(['dirty']); + expect(result['dirty']!.map((e) => e.paramName)).toEqual(['p2']); + }); +}); + +// --------------------------------------------------------------------------- +// findSourceCandidates — T5/T6/T7/T8 core +// --------------------------------------------------------------------------- + +describe('findSourceCandidates', () => { + function _makeFixture() { + const upstreamType = _makeNodeType('sharepoint.readDocs', 'DocumentList'); + const consumerType = _makeNodeType('ai.summarize', 'AiResult', [ + { name: 'documentList', type: 'DocumentList', required: true }, + ]); + const upstream = _makeNode('up', 'sharepoint.readDocs'); + const consumer = _makeNode('cons', 'ai.summarize'); + const conns = [_makeConnection('c1', 'up', 'cons')]; + return { nodes: [upstream, consumer], connections: conns, nodeTypes: [upstreamType, consumerType] }; + } + + it('returns the whole-output candidate first (path=[]) for the upstream', () => { + const f = _makeFixture(); + const candidates = findSourceCandidates({ + consumerNodeId: 'cons', + expectedType: 'DocumentList', + ...f, + portTypeCatalog: _portCatalog, + }); + const wholeOutput = candidates.find((c) => c.nodeId === 'up' && c.path.length === 0); + expect(wholeOutput).toBeDefined(); + expect(wholeOutput!.type).toBe('DocumentList'); + expect(wholeOutput!.compat).toBe('ok'); + }); + + it('drills into List[X] elements via wildcard "*" segment (T8 generic drill-down)', () => { + const f = _makeFixture(); + const candidates = findSourceCandidates({ + consumerNodeId: 'cons', + expectedType: 'str', + ...f, + portTypeCatalog: _portCatalog, + }); + const wildcardCandidate = candidates.find( + (c) => + c.nodeId === 'up' && + c.path[0] === 'documents' && + c.path[1] === '*' && + c.path[2] === 'name', + ); + expect(wildcardCandidate).toBeDefined(); + expect(wildcardCandidate!.type).toBe('str'); + expect(wildcardCandidate!.compat).toBe('ok'); + }); + + it('marks List[X] → X as iterable (T6 "iterieren"-Vorschlag)', () => { + const f = _makeFixture(); + const candidates = findSourceCandidates({ + consumerNodeId: 'cons', + expectedType: 'UdmDocument', + ...f, + portTypeCatalog: _portCatalog, + }); + const iterable = candidates.find( + (c) => c.nodeId === 'up' && c.path.length === 1 && c.path[0] === 'documents' && c.iterable, + ); + expect(iterable).toBeDefined(); + expect(iterable!.type).toBe('List[UdmDocument]'); + }); + + it('returns no candidates when no upstream is connected (T5: 0-case)', () => { + const f = _makeFixture(); + const isolated = _makeNode('iso', 'ai.summarize'); + const candidates = findSourceCandidates({ + consumerNodeId: 'iso', + expectedType: 'DocumentList', + nodes: [...f.nodes, isolated], + connections: f.connections, + nodeTypes: f.nodeTypes, + portTypeCatalog: _portCatalog, + }); + expect(candidates).toEqual([]); + }); + + it('returns plain candidates (compat="ok") when expectedType is omitted', () => { + const f = _makeFixture(); + const candidates = findSourceCandidates({ + consumerNodeId: 'cons', + ...f, + portTypeCatalog: _portCatalog, + }); + expect(candidates.length).toBeGreaterThan(0); + expect(candidates.every((c) => c.compat === 'ok')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// strictlyCompatible — T7 strict type filter +// --------------------------------------------------------------------------- + +describe('strictlyCompatible', () => { + it('keeps only ok / coerce / iterable candidates and drops mismatch', () => { + const all: SourceCandidate[] = [ + { nodeId: 'a', path: [], type: 'DocumentList', compat: 'ok' }, + { nodeId: 'a', path: ['documents'], type: 'List[UdmDocument]', compat: 'mismatch', iterable: true }, + { nodeId: 'a', path: ['count'], type: 'int', compat: 'coerce' }, + { nodeId: 'a', path: ['junk'], type: 'object', compat: 'mismatch' }, + ]; + const out = strictlyCompatible(all); + expect(out.map((c) => c.path)).toEqual([[], ['documents'], ['count']]); + }); +}); diff --git a/src/components/FlowEditor/nodes/shared/paramValidation.ts b/src/components/FlowEditor/nodes/shared/paramValidation.ts new file mode 100644 index 0000000..14d70af --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/paramValidation.ts @@ -0,0 +1,216 @@ +/** + * Phase-4 Schicht-4 (Instanz-Bindings) — Validation utilities. + * + * Single source of truth for two questions every UI surface needs to answer: + * 1. "Is this required parameter on this node bound to anything?" + * 2. "Which upstream nodes are type-compatible sources for this parameter?" + * + * Used by: + * - RequiredAttributePicker (renders 0/1/N affordance based on candidate count) + * - NodeConfigPanel (orders required params first, surfaces missing-source pill) + * - FlowCanvas (red error badge per node when any required param is unbound) + * - CanvasHeader (Run button disabled when any node has unbound required params) + * + * The required check is deliberately conservative: a param counts as "bound" + * if it has any non-empty value, a non-empty static value-wrapper, a ref, or a + * system-var ref. Empty string / null / undefined / { type: 'value', value: '' } + * all count as unbound. + */ + +import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; +import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, OutputPortDef, PortSchema } from '../../../../api/workflowApi'; +import { isCompatible, isRef, isSystemVar, isValue } from './dataRef'; +import { getAvailableSources } from './dataFlowGraph'; + +const _LIST_INNER_RE = /^List\[(.+)\]$/; + +/** A candidate path on an upstream node that could satisfy a parameter binding. */ +export interface SourceCandidate { + nodeId: string; + /** JSON path on the node output, e.g. ['documents', 0, 'name']. */ + path: (string | number)[]; + /** Type as declared by the schema field at this path (best-effort). */ + type?: string; + /** Compatibility verdict against the requested type. */ + compat: 'ok' | 'coerce' | 'mismatch'; + /** True iff the candidate is a List that, by element-iteration ('*'), would + * satisfy the requested scalar type — the "iterieren als Loop-Vorschlag". */ + iterable?: boolean; +} + +/** Decide whether a parameter value counts as "bound" for required-check purposes. */ +export function isParamBound(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === 'string') return value.length > 0; + if (typeof value === 'number' || typeof value === 'boolean') return true; + if (isRef(value)) return Boolean(value.nodeId); + if (isSystemVar(value)) return Boolean(value.variable); + if (isValue(value)) { + const inner = value.value; + if (inner === null || inner === undefined) return false; + if (typeof inner === 'string') return inner.length > 0; + if (Array.isArray(inner)) return inner.length > 0; + return true; + } + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'object') return Object.keys(value as object).length > 0; + return false; +} + +/** A "required" param on a node that has no value and no incoming binding. */ +export interface RequiredParamError { + paramName: string; + paramLabel: string; + paramType?: string; +} + +/** Walk a node's parameter spec + values and flag every required-but-unbound. + * + * Safety net: params with `frontendType: 'hidden'` are excluded — they have + * no UI surface (the panel skips them entirely), so reporting them as + * "Pflichtfeld ohne Quelle" would create a phantom error the user cannot + * resolve. Hidden-required params should be auto-set by the adapter or + * caught in tests, never surfaced to end users. + */ +export function findRequiredErrors( + node: CanvasNode, + nodeType: NodeType | undefined, + resolveLabel: (param: NodeTypeParameter) => string = (p) => p.name, +): RequiredParamError[] { + if (!nodeType) return []; + const errors: RequiredParamError[] = []; + const values = node.parameters ?? {}; + for (const param of nodeType.parameters ?? []) { + if (!param.required) continue; + if (param.frontendType === 'hidden') continue; + if (isParamBound(values[param.name])) continue; + errors.push({ paramName: param.name, paramLabel: resolveLabel(param), paramType: param.type }); + } + return errors; +} + +/** Map of nodeId → required errors. Empty entries are omitted. */ +export function findGraphErrors( + nodes: CanvasNode[], + nodeTypes: NodeType[], + resolveLabel?: (param: NodeTypeParameter) => string, +): Record { + const byId: Record = {}; + const byTypeId = new Map(nodeTypes.map((nt) => [nt.id, nt])); + for (const n of nodes) { + const errs = findRequiredErrors(n, byTypeId.get(n.type), resolveLabel); + if (errs.length) byId[n.id] = errs; + } + return byId; +} + +/** Resolve the schema produced by an output port (Transit follows incoming connection). */ +function _resolveOutputSchemaName( + nodeId: string, + nodes: CanvasNode[], + connections: CanvasConnection[], + nodeTypes: NodeType[], + visited: Set = new Set(), +): { schemaName?: string; node?: CanvasNode; portDef?: OutputPortDef } { + if (visited.has(nodeId)) return {}; + visited.add(nodeId); + const node = nodes.find((n) => n.id === nodeId); + if (!node) return {}; + const typeDef = nodeTypes.find((nt) => nt.id === node.type); + const port0 = typeDef?.outputPorts?.[0] as OutputPortDef | undefined; + if (!port0) return { node }; + const spec = port0.schema as string | GraphDefinedSchemaRef | undefined; + if (typeof spec === 'object' && spec !== null && spec.kind === 'fromGraph') { + return { schemaName: 'FormPayload_dynamic', node, portDef: port0 }; + } + if (port0.dynamic) { + return { schemaName: 'FormPayload_dynamic', node, portDef: port0 }; + } + if (typeof spec === 'string' && spec !== 'Transit') { + return { schemaName: spec, node, portDef: port0 }; + } + // Transit: follow upstream + const incoming = connections.find((c) => c.targetId === nodeId); + if (!incoming) return { node }; + return _resolveOutputSchemaName(incoming.sourceId, nodes, connections, nodeTypes, visited); +} + +/** Build candidate paths from a schema, recursing into List-element schemas one level deep. */ +function _candidatesFromSchema( + schema: PortSchema | undefined, + catalog: Record, + basePath: (string | number)[] = [], + depth = 0, +): Array<{ path: (string | number)[]; type?: string }> { + if (!schema || !schema.fields || depth > 6) return []; + const out: Array<{ path: (string | number)[]; type?: string }> = []; + for (const field of schema.fields) { + const fieldPath = [...basePath, field.name]; + out.push({ path: fieldPath, type: field.type }); + const inner = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE)?.[1]?.trim() : undefined; + if (inner && catalog[inner]) { + out.push(..._candidatesFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1)); + } + } + return out; +} + +/** + * Compute every typed source candidate that could satisfy `expectedType` + * for the given consumer node. Includes ranked compatibility per candidate + * and a `iterable` flag for List-X→X "iterate as Loop" suggestions. + * + * If `expectedType` is omitted, returns all candidates (all marked 'ok'). + */ +export function findSourceCandidates(args: { + consumerNodeId: string; + expectedType?: string; + nodes: CanvasNode[]; + connections: CanvasConnection[]; + nodeTypes: NodeType[]; + portTypeCatalog: Record; +}): SourceCandidate[] { + const { consumerNodeId, expectedType, nodes, connections, nodeTypes, portTypeCatalog } = args; + const sourceIds = getAvailableSources(consumerNodeId, nodes, connections).filter((id) => { + const n = nodes.find((x) => x.id === id); + return n?.type !== 'trigger.manual'; + }); + + const results: SourceCandidate[] = []; + for (const nid of sourceIds) { + const { schemaName } = _resolveOutputSchemaName(nid, nodes, connections, nodeTypes); + const schema = schemaName ? portTypeCatalog[schemaName] : undefined; + const wholeType = schemaName ?? undefined; + results.push({ + nodeId: nid, + path: [], + type: wholeType, + compat: expectedType && wholeType ? isCompatible(wholeType, expectedType) : 'ok', + iterable: _isIterableMatch(wholeType, expectedType), + }); + for (const cand of _candidatesFromSchema(schema, portTypeCatalog)) { + const compat = expectedType && cand.type ? isCompatible(cand.type, expectedType) : 'ok'; + results.push({ + nodeId: nid, + path: cand.path, + type: cand.type, + compat, + iterable: _isIterableMatch(cand.type, expectedType), + }); + } + } + return results; +} + +/** True iff `producedType` is `List[X]` and `expectedType` equals `X`. */ +function _isIterableMatch(producedType?: string, expectedType?: string): boolean { + if (!producedType || !expectedType) return false; + const m = producedType.match(_LIST_INNER_RE); + if (!m) return false; + return m[1].trim() === expectedType; +} + +/** Filter candidates to only those that satisfy `expectedType` (strict mode). */ +export function strictlyCompatible(candidates: SourceCandidate[]): SourceCandidate[] { + return candidates.filter((c) => c.compat === 'ok' || c.compat === 'coerce' || c.iterable === true); +} diff --git a/src/components/FlowEditor/nodes/shared/scopeHelpers.ts b/src/components/FlowEditor/nodes/shared/scopeHelpers.ts new file mode 100644 index 0000000..2157c2f --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/scopeHelpers.ts @@ -0,0 +1,55 @@ +/** + * Lexical scope for DataPicker: ancestor node ids reachable backward on the graph. + */ + +export interface GraphEdgeLike { + source: string; + target: string; +} + +export interface GraphNodeLike { + id: string; + type?: string; +} + +/** All node ids that can reach targetNodeId via incoming edges (excluding target). */ +export function computeAncestorNodeIds( + _nodes: GraphNodeLike[], + connections: GraphEdgeLike[], + targetNodeId: string +): Set { + const preds = new Map>(); + for (const c of connections) { + const src = c.source; + const tgt = c.target; + if (!src || !tgt) continue; + if (!preds.has(tgt)) preds.set(tgt, new Set()); + preds.get(tgt)!.add(src); + } + const seen = new Set(); + const stack = [targetNodeId]; + while (stack.length) { + const cur = stack.pop()!; + const ps = preds.get(cur); + if (!ps) continue; + for (const p of ps) { + if (!seen.has(p)) { + seen.add(p); + stack.push(p); + } + } + } + seen.delete(targetNodeId); + return seen; +} + +/** Node ids of flow.loop ancestors (subset of ancestors). */ +export function findLoopAncestorIds( + nodes: GraphNodeLike[], + connections: GraphEdgeLike[], + targetNodeId: string +): string[] { + const anc = computeAncestorNodeIds(nodes, connections, targetNodeId); + const byId = new Map(nodes.map((n) => [n.id, n])); + return [...anc].filter((id) => byId.get(id)?.type === 'flow.loop'); +} diff --git a/src/components/FlowEditor/nodes/shared/types.ts b/src/components/FlowEditor/nodes/shared/types.ts index 7113d59..44e4ba0 100644 --- a/src/components/FlowEditor/nodes/shared/types.ts +++ b/src/components/FlowEditor/nodes/shared/types.ts @@ -3,17 +3,15 @@ */ import type { ApiRequestFunction } from '../../../../api/workflowApi'; +import type { AttributeType } from '../../../../utils/attributeTypeMapper'; -/** input.form / trigger.form field row. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */ +/** input.form / trigger.form field row. */ export type FormField = { name?: string; - type?: string; + type?: AttributeType; label?: string; required?: boolean; - clickupConnectionId?: string; - clickupListId?: string; - /** ClickUp list status names from GET /list/{id} — only for type `clickup_status`. */ - clickupStatusOptions?: Array<{ value: string; label: string }>; + options?: Array<{ value: string; label: string }>; }; export interface NodeConfigRendererProps { diff --git a/src/components/FlowEditor/nodes/start/FormStartNodeConfig.tsx b/src/components/FlowEditor/nodes/start/FormStartNodeConfig.tsx index 57cb7da..44cccdc 100644 --- a/src/components/FlowEditor/nodes/start/FormStartNodeConfig.tsx +++ b/src/components/FlowEditor/nodes/start/FormStartNodeConfig.tsx @@ -4,40 +4,23 @@ import React, { useMemo } from 'react'; import type { NodeConfigRendererProps } from '../shared/types'; +import type { FormField } from '../shared/types'; +import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; import styles from '../../editor/Automation2FlowEditor.module.css'; import { useLanguage } from '../../../../providers/language/LanguageContext'; -type FormField = { - name: string; - label: string; - type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status'; - statusOptions?: Array<{ value: string; label: string }>; -}; - -const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const; - function _parseFields(params: Record, t: (key: string) => string): FormField[] { const raw = params.formFields; if (!Array.isArray(raw)) return [{ name: 'field1', label: t('Feld 1'), type: 'text' }]; return raw.map((f, i) => { if (f && typeof f === 'object' && !Array.isArray(f)) { const o = f as Record; - const fieldType = String(o.type ?? 'text'); + const rawType = String(o.type ?? 'text'); const name = String(o.name ?? `field${i + 1}`); const label = String(o.label ?? `${t('Feld')} ${i + 1}`); - const type = ( - FORM_FIELD_TYPES.includes(fieldType as (typeof FORM_FIELD_TYPES)[number]) ? fieldType : 'text' - ) as FormField['type']; - if (type === 'clickup_status' && Array.isArray(o.statusOptions)) { - return { - name, - label, - type: 'clickup_status', - statusOptions: o.statusOptions as Array<{ value: string; label: string }>, - }; - } - return { name, label, type }; + const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text'; + return { name, label, type } as FormField; } return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const }; }); @@ -64,7 +47,7 @@ export const FormStartNodeConfig: React.FC = ({ params, { const next = [...fields]; next[idx] = { ...f, name: e.target.value }; @@ -74,7 +57,7 @@ export const FormStartNodeConfig: React.FC = ({ params, { const next = [...fields]; next[idx] = { ...f, label: e.target.value }; @@ -83,24 +66,16 @@ export const FormStartNodeConfig: React.FC = ({ params, />
+ {op === 'between' ? ( + <> +
+ + setFrom(e.target.value)} + onClick={(e) => e.stopPropagation()} + /> +
+
+ + setTo(e.target.value)} + onClick={(e) => e.stopPropagation()} + /> +
+ + ) : ( +
+ + setSingle(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + _apply(); + } + }} + onClick={(e) => e.stopPropagation()} + /> +
+ )} +
+ +
+
+ ); +} + export function FormGeneratorTable>({ data, columns: providedColumns, @@ -514,16 +737,46 @@ export function FormGeneratorTable>({ const [currentPageSize, setCurrentPageSize] = useState(pageSize); const [openFilterColumn, setOpenFilterColumn] = useState(null); const filterDropdownRef = useRef(null); + + useLayoutEffect(() => { + if (!openFilterColumn) return; + const dd = filterDropdownRef.current; + if (!dd) return; + const positionDropdown = () => { + const th = dd.closest('th'); + if (!th) return; + const r = th.getBoundingClientRect(); + const margin = 8; + const maxW = 320; + const w = Math.min(Math.max(dd.offsetWidth || maxW, 200), maxW, window.innerWidth - 2 * margin); + let left = r.left; + if (left + w > window.innerWidth - margin) { + left = window.innerWidth - margin - w; + } + if (left < margin) left = margin; + const approxH = dd.offsetHeight || 280; + let top = r.bottom + 4; + if (top + approxH > window.innerHeight - margin) { + top = Math.max(margin, r.top - 4 - approxH); + } + dd.style.position = 'fixed'; + dd.style.left = `${left}px`; + dd.style.top = `${top}px`; + dd.style.right = 'auto'; + dd.style.bottom = 'auto'; + dd.style.width = `${w}px`; + dd.style.maxWidth = `${maxW}px`; + dd.style.zIndex = '2000'; + }; + positionDropdown(); + const id = requestAnimationFrame(() => positionDropdown()); + return () => cancelAnimationFrame(id); + }, [openFilterColumn]); // Grouping: Track expanded groups const [expandedGroups, setExpandedGroups] = useState>(() => new Set()); const [groupsInitialized, setGroupsInitialized] = useState(false); - // FK Resolution: Cache for resolved FK values (fkSource -> { id -> displayLabel }) - const [fkCache, setFkCache] = useState({}); - const [fkLoading, setFkLoading] = useState>({}); - const fkLoadedSourcesRef = useRef>(new Set()); - // Generate a storage key based on column names for localStorage persistence const storageKey = useMemo(() => { if (detectedColumns.length === 0) return null; @@ -586,7 +839,7 @@ export function FormGeneratorTable>({ // Note: Date/timestamp filters are disabled in column config, so they won't appear here const activeFilters: Record = {}; Object.entries(filters).forEach(([key, value]) => { - if (value !== undefined && value !== '') { + if (value !== undefined) { activeFilters[key] = value; } }); @@ -739,133 +992,6 @@ export function FormGeneratorTable>({ } }).current; - const convertToDisplayString = useCallback((fieldValue: any, _language: string): string => { - if (fieldValue === null || fieldValue === undefined) { - return '-'; - } - - // Boolean → language-neutral symbols (✓/✗) - if (typeof fieldValue === 'boolean') { - return fieldValue ? '✓' : '✗'; - } - - // Number → String - if (typeof fieldValue === 'number') { - return String(fieldValue); - } - - // String → direct - if (typeof fieldValue === 'string') { - return fieldValue; - } - - if (typeof fieldValue === 'object' && fieldValue !== null) { - return _objectToDisplayString(fieldValue as Record); - } - - // Fallback - return String(fieldValue); - }, []); - - // FK Resolution: Load FK data in bulk for columns with fkSource - useEffect(() => { - if (data.length === 0 || detectedColumns.length === 0) return; - - // Find columns with fkSource that haven't been loaded yet - const fkColumns = detectedColumns.filter(col => - col.fkSource && !fkLoadedSourcesRef.current.has(col.fkSource) - ); - - if (fkColumns.length === 0) return; - - // For each FK column, collect unique IDs from data and fetch them - const loadFkData = async () => { - for (const column of fkColumns) { - const fkSource = column.fkSource!; - const displayField = column.fkDisplayField; // Explicit field from Pydantic model - - // Skip if already loading - if (fkLoading[fkSource]) continue; - - // Collect unique IDs from data for this column - const uniqueIds = new Set(); - data.forEach(row => { - const value = row[column.key]; - if (value && typeof value === 'string' && value.length > 0) { - uniqueIds.add(value); - } - }); - - if (uniqueIds.size === 0) { - fkLoadedSourcesRef.current.add(fkSource); - continue; - } - - // Mark as loading - setFkLoading(prev => ({ ...prev, [fkSource]: true })); - - try { - // Fetch all items from the FK source endpoint - const response = await api.get(fkSource); - - // Build cache: id -> display label - const cacheForSource: Record = {}; - const items = Array.isArray(response.data) ? response.data : response.data?.items || []; - - items.forEach((item: any) => { - if (!item || !item.id) return; - - let displayLabel = item.id; // Fallback to ID - - // Use the EXPLICIT display field from Pydantic model (fkDisplayField) - if (displayField && item[displayField] != null && item[displayField] !== '') { - displayLabel = convertToDisplayString(item[displayField], currentLanguage); - } else { - // Fallback: if no displayField specified, try common fields - // This should rarely happen if models are properly configured - const fallbackFields = ['name', 'label', 'username', 'roleLabel', 'title']; - for (const field of fallbackFields) { - if (item[field] !== undefined) { - displayLabel = convertToDisplayString(item[field], currentLanguage); - break; - } - } - } - - cacheForSource[item.id] = displayLabel; - }); - - // Update cache - setFkCache(prev => ({ - ...prev, - [fkSource]: { ...(prev[fkSource] || {}), ...cacheForSource } - })); - - // Mark as loaded - fkLoadedSourcesRef.current.add(fkSource); - } catch (error) { - console.error(`Failed to load FK data from ${fkSource}:`, error); - // Mark as loaded to prevent infinite retries - fkLoadedSourcesRef.current.add(fkSource); - } finally { - setFkLoading(prev => ({ ...prev, [fkSource]: false })); - } - } - }; - - loadFkData(); - }, [data, detectedColumns, currentLanguage, fkLoading, convertToDisplayString]); - - // Helper function to resolve FK value to display label - const resolveFkValue = useCallback((value: string, fkSource: string): string => { - const sourceCache = fkCache[fkSource]; - if (sourceCache && sourceCache[value]) { - return sourceCache[value]; - } - // Return truncated ID while loading or if not found - return value.length > 8 ? `${value.substring(0, 8)}...` : value; - }, [fkCache]); - // Data is already filtered, sorted, and paginated by the backend. // Client-side only filters out rows that were just optimistically deleted // so the UI updates instantly before the server's next refetch response. @@ -986,8 +1112,10 @@ export function FormGeneratorTable>({ const handleFilter = (key: string, value: any, keepOpen = false) => { setFilters(prev => { const newFilters = { ...prev }; - if (value === undefined || value === '' || value === null) { + if (value === undefined) { delete newFilters[key]; + } else if (value === _EMPTY_FILTER_SENTINEL) { + newFilters[key] = null; } else { newFilters[key] = value; } @@ -1024,7 +1152,7 @@ export function FormGeneratorTable>({ }, [filters]); // Track which filter columns show all values (expanded beyond initial 100) - const [asyncFilterValues, setAsyncFilterValues] = useState>({}); + const [asyncFilterValues, setAsyncFilterValues] = useState>({}); const [filterValuesLoading, setFilterValuesLoading] = useState>({}); // Invalidate cached filter values when filters change (cross-filtering) @@ -1045,9 +1173,23 @@ export function FormGeneratorTable>({ // Skip if column has static filterOptions (enum) – those are used directly if (column?.filterOptions && column.filterOptions.length > 0) return; - // FK columns with backend pagination: still fetch from backend (data is only one page) - // FK columns without backend pagination: skip (data is the full dataset, extracted below) - if (column?.fkSource && !supportsBackendPagination) return; + // Boolean / date / number columns use dedicated filter UIs — no distinct-value fetch + const colT = column?.type as AttributeType | undefined; + const auditTs = column?.key ? _auditTimestampColumnKey(column.key) : false; + const treatAsDate = !!colT && ( + isDateTimeType(colT) + || (isNumberType(colT) && auditTs) + ); + if (column?.type && ( + isCheckboxType(colT as AttributeType) + || treatAsDate + || (isNumberType(colT as AttributeType) && !auditTs) + )) { + return; + } + + // displayField + local full dataset: filter values are derived from `data` (see getUniqueValuesForColumn) + if (column?.displayField && !supportsBackendPagination) return; // Skip if already loaded or currently loading if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return; @@ -1055,7 +1197,7 @@ export function FormGeneratorTable>({ const _fetchValues = async (columnKey: string) => { setFilterValuesLoading(prev => ({ ...prev, [columnKey]: true })); try { - let values: string[]; + let values: FilterValue[]; if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') { const crossFilters: Record = {}; Object.entries(filters).forEach(([k, v]) => { @@ -1084,32 +1226,38 @@ export function FormGeneratorTable>({ }, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData, apiEndpoint, supportsBackendPagination, filters]); // Get unique values for a column (for filter dropdown) - // Sources: 1) column.filterOptions (static enum) - // 2) asyncFilterValues (loaded from backend via hookData.fetchFilterValues) - // 3) data — ONLY when no backend pagination (data = full dataset) + // Sources: 1) column.filterOptions (static enum, page-defined) + // 2) column.options (backend-provided frontend_options on Pydantic field) + // 3) asyncFilterValues (loaded from backend via hookData.fetchFilterValues) + // 4) data — ONLY when no backend pagination (data = full dataset) // With backend pagination, data is a single page, so extracting filter // values from it would be incomplete and misleading. - const getUniqueValuesForColumn = useCallback((columnKey: string): string[] => { + const getUniqueValuesForColumn = useCallback((columnKey: string): FilterValue[] => { const column = detectedColumns.find(c => c.key === columnKey); if (column?.filterOptions && column.filterOptions.length > 0) { return column.filterOptions; } - // FK columns without backend pagination: extract from full local data - if (column?.fkSource && !supportsBackendPagination) { - const seen = new Set(); + if (column?.options && column.options.length > 0) { + return column.options.map((o) => ({ value: String(o.value), label: o.label })); + } + + // displayField + local full dataset: { value, label } from enriched rows + if (column?.displayField && !supportsBackendPagination) { + const showKey = column.displayField; + const byVal = new Map(); data.forEach(row => { const val = row[columnKey]; - if (val && typeof val === 'string' && val.trim()) { - seen.add(val); - } - }); - return Array.from(seen).sort((a, b) => { - const labelA = fkCache[column.fkSource!]?.[a] || a; - const labelB = fkCache[column.fkSource!]?.[b] || b; - return labelA.localeCompare(labelB); + if (val == null || val === '') return; + const raw = String(val); + const d = row[showKey]; + const label = d != null && d !== '' ? String(d) : `NA(${raw})`; + if (!byVal.has(raw)) byVal.set(raw, label); }); + return Array.from(byVal.entries()) + .sort((a, b) => a[1].localeCompare(b[1])) + .map(([value, label]) => ({ value, label })); } if (asyncFilterValues[columnKey] && asyncFilterValues[columnKey].length > 0) { @@ -1125,7 +1273,7 @@ export function FormGeneratorTable>({ ); } return []; - }, [detectedColumns, asyncFilterValues, apiEndpoint, hookData, data, fkCache]); + }, [detectedColumns, asyncFilterValues, apiEndpoint, hookData, data, supportsBackendPagination]); // Close filter dropdown when clicking outside useEffect(() => { @@ -1500,10 +1648,11 @@ export function FormGeneratorTable>({ return detectedColumns.map(col => { let cellValue = row[col.key]; - // FK resolution - if (col.fkSource && typeof cellValue === 'string' && cellValue.length > 0) { - const resolved = fkCache[col.fkSource]?.[cellValue]; - if (resolved) cellValue = resolved; + if (col.displayField) { + const displayValue = row[col.displayField]; + if (displayValue != null && displayValue !== '') { + cellValue = displayValue; + } } // Timestamp formatting @@ -1541,7 +1690,7 @@ export function FormGeneratorTable>({ } finally { setCsvExporting(false); } - }, [csvExporting, detectedColumns, apiEndpoint, currentLanguage, fkCache]); + }, [csvExporting, detectedColumns, apiEndpoint, currentLanguage]); // Check if inline editing is allowed for a column (based on RBAC permissions) const canInlineEdit = useMemo(() => { @@ -1718,6 +1867,15 @@ export function FormGeneratorTable>({ return false; }; + const _columnAlignStyle = (column: ColumnConfig): React.CSSProperties => { + const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer'; + const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : ''; + if (formatAlign === 'R') return { textAlign: 'right' }; + if (formatAlign === 'M') return { textAlign: 'center' }; + if (formatAlign === 'L') return { textAlign: 'left' }; + return isNumeric ? { textAlign: 'right' } : {}; + }; + // Format cell value const formatCellValue = (value: any, column: ColumnConfig, row: T) => { // Custom formatter must run even when value is null/undefined (e.g. synthetic columns like _documentRefs) @@ -1725,6 +1883,15 @@ export function FormGeneratorTable>({ return column.formatter(value, row); } + // displayField: backend-enriched label takes priority over raw value. + // Falls back to the raw value when displayField is null/undefined (unresolved FK). + if (column.displayField) { + const displayValue = row[column.displayField]; + if (displayValue != null && displayValue !== '') return String(displayValue); + if (value != null && value !== '') return `NA(${value})`; + return '-'; + } + if (value === null || value === undefined) { return '-'; } @@ -1750,17 +1917,13 @@ export function FormGeneratorTable>({ return renderBooleanCell(value, column, row); } - // FK Resolution: If column has fkSource and value is a string (UUID), resolve to display label - if (column.fkSource && typeof value === 'string' && value.length > 0) { - const resolvedLabel = resolveFkValue(value, column.fkSource); - const isLoading = fkLoading[column.fkSource]; - - // Show loading indicator or resolved label - if (isLoading && !fkCache[column.fkSource]?.[value]) { - return {value.substring(0, 8)}...; - } - - return resolvedLabel; + // Select / enum cells: translate raw value to backend-provided label. + // Pages MUST NOT hardcode value→label maps; they must declare + // `frontend_options` on the Pydantic field, and the API forwards them + // (already translated server-side via i18n) into `column.options`. + if (column.options && column.options.length > 0 && (value !== null && value !== undefined && value !== '')) { + const match = column.options.find((opt) => String(opt.value) === String(value)); + if (match) return match.label; } // Check if this is an ID or hash field that should be truncated and copyable @@ -2083,7 +2246,11 @@ export function FormGeneratorTable>({ )} )} - {detectedColumns.map(column => ( + {detectedColumns.map(column => { + const colAlign = _columnAlignStyle(column); + const headerJustify = colAlign.textAlign === 'right' ? 'flex-end' + : colAlign.textAlign === 'center' ? 'center' : 'flex-start'; + return ( >({ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, - position: 'relative' + position: 'relative', + ...colAlign, }} > -
+
{/* Filter icon */} {filterable && column.filterable !== false && (
@@ -2150,8 +2319,8 @@ export function FormGeneratorTable>({ onClick={(e) => e.stopPropagation()} >
- {t('Filter')}: {column.label} - {filters[column.key] && ( + {t('Filter')}: {column.label ?? column.key} + {column.key in filters && ( )}
+ {(() => { + const colType = column.type || 'text'; + const auditTs = _auditTimestampColumnKey(column.key); + const isDateCol = isDateTimeType(colType as AttributeType) + || (isNumberType(colType as AttributeType) && auditTs); + + if (isDateCol) { + const filterVal = filters[column.key]; + const periodVal: PeriodValue | null = (() => { + if (!filterVal || typeof filterVal !== 'object') return null; + const v = (filterVal as any).value; + if (!v || typeof v !== 'object') return null; + const { from, to } = v as { from?: string; to?: string }; + if (!from && !to) return null; + const storedPresetKind = (filterVal as any).presetKind; + let preset: PeriodValue['preset']; + if (storedPresetKind === 'lastN' || storedPresetKind === 'nextN') { + const amount = (filterVal as any).presetAmount ?? 7; + const unit = (filterVal as any).presetUnit ?? 'day'; + preset = { kind: storedPresetKind, amount, unit }; + } else if (storedPresetKind) { + preset = { kind: storedPresetKind } as PeriodValue['preset']; + } else { + preset = { kind: 'custom' as const }; + } + return { preset, fromDate: from || '', toDate: to || '' }; + })(); + return ( +
+ { + if (next.preset.kind === 'allTime') { + clearFilter(column.key); + } else { + const filterPayload: any = { + operator: 'between', + value: { from: next.fromDate, to: next.toDate }, + presetKind: next.preset.kind, + }; + if (next.preset.kind === 'lastN' || next.preset.kind === 'nextN') { + filterPayload.presetAmount = (next.preset as any).amount; + filterPayload.presetUnit = (next.preset as any).unit; + } + handleFilter(column.key, filterPayload, true); + } + }} + direction="past" + enabledPresets={[ + 'allTime', 'ytd', 'lastYear', 'last12Months', + 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', + 'lastN', 'custom', + ]} + placeholder={t('Zeitraum wählen')} + /> +
+ ); + } + + return null; + })()}
{(() => { const colType = column.type || 'text'; const isBool = isCheckboxType(colType as AttributeType); - const isDate = isDateTimeType(colType as AttributeType); + const auditTs = _auditTimestampColumnKey(column.key); + const isDateCol = isDateTimeType(colType as AttributeType) + || (isNumberType(colType as AttributeType) && auditTs); + const isNum = isNumberType(colType as AttributeType) && !auditTs; + + if (isDateCol) return null; if (isBool) { const currentVal = filters[column.key]; @@ -2193,62 +2428,37 @@ export function FormGeneratorTable>({ ); } - if (isDate) { - const rangeVal = (typeof filters[column.key] === 'object' && filters[column.key]?.value) || {}; + if (isNum) { return ( -
-
clearFilter(column.key)} - > - ({t('Alle')}) -
- - { - const from = e.target.value; - const to = rangeVal.to || ''; - if (!from && !to) { - clearFilter(column.key); - } else { - handleFilter(column.key, { operator: 'between', value: { from, to } }, true); - } - }} - /> - - { - const to = e.target.value; - const from = rangeVal.from || ''; - if (!from && !to) { - clearFilter(column.key); - } else { - handleFilter(column.key, { operator: 'between', value: { from, to } }, true); - } - }} - /> -
+ handleFilter(column.key, payload, keepOpen)} + onClear={() => clearFilter(column.key)} + t={t} + /> ); } + const columnFilterValues = getUniqueValuesForColumn(column.key); + const hasEmptyOption = columnFilterValues.some(v => v === null); + return ( <>
clearFilter(column.key)} > ({t('Alle')})
+ {hasEmptyOption && ( +
handleFilter(column.key, _EMPTY_FILTER_SENTINEL)} + > + ({t('Leer')}) +
+ )} {filterValuesLoading[column.key] ? (
{t('Lade Filterwerte...')} @@ -2256,10 +2466,18 @@ export function FormGeneratorTable>({ ) : ( handleFilter(column.key, value)} - resolveLabel={column.filterLabelResolver || (column.fkSource ? (val) => fkCache[column.fkSource!]?.[val] || val : undefined)} + resolveLabel={ + column.filterLabelResolver + ?? (column.options && column.options.length > 0 + ? (v: string) => { + const m = column.options!.find((o) => String(o.value) === String(v)); + return m ? m.label : v; + } + : undefined) + } /> )} @@ -2276,7 +2494,8 @@ export function FormGeneratorTable>({ /> )} - ))} + ); + })} @@ -2454,15 +2673,7 @@ export function FormGeneratorTable>({ const cellValue = row[column.key]; const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; const combinedClassName = `${styles.td} ${customClassName}`.trim(); - const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer'; - const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : ''; - const alignStyle: React.CSSProperties = formatAlign === 'R' - ? { textAlign: 'right' } - : formatAlign === 'M' - ? { textAlign: 'center' } - : formatAlign === 'L' - ? { textAlign: 'left' } - : isNumeric ? { textAlign: 'right' } : {}; + const alignStyle = _columnAlignStyle(column); return ( @@ -2578,17 +2789,7 @@ export function FormGeneratorTable>({ const cellValue = row[column.key]; const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; const combinedClassName = `${styles.td} ${customClassName}`.trim(); - const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer'; - // ``frontendFormat`` may carry an explicit alignment prefix - // ("L:", "M:", "R:") that overrides the numeric default. - const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : ''; - const alignStyle: React.CSSProperties = formatAlign === 'R' - ? { textAlign: 'right' } - : formatAlign === 'M' - ? { textAlign: 'center' } - : formatAlign === 'L' - ? { textAlign: 'left' } - : isNumeric ? { textAlign: 'right' } : {}; + const alignStyle = _columnAlignStyle(column); return ( diff --git a/src/components/PeriodPicker/PeriodPicker.tsx b/src/components/PeriodPicker/PeriodPicker.tsx index 1b15a4e..00e7eb3 100644 --- a/src/components/PeriodPicker/PeriodPicker.tsx +++ b/src/components/PeriodPicker/PeriodPicker.tsx @@ -34,6 +34,9 @@ const _DEFAULT_PRESET: PeriodPreset = { kind: 'ytd' }; function _formatTriggerLabel(value: PeriodValue | null, t: (k: string) => string, placeholder: string): string { if (!value) return placeholder; + // "Alle" intentionally skips the range suffix: the sentinel dates + // (1970-2999) would be noise in the trigger. + if (value.preset.kind === 'allTime') return t('Alle'); const range = `${formatIsoDateDe(value.fromDate)} – ${formatIsoDateDe(value.toDate)}`; switch (value.preset.kind) { case 'ytd': return `${t('Laufendes Jahr')} · ${range}`; diff --git a/src/components/PeriodPicker/PeriodPickerLogic.ts b/src/components/PeriodPicker/PeriodPickerLogic.ts index 627de6a..26e6fac 100644 --- a/src/components/PeriodPicker/PeriodPickerLogic.ts +++ b/src/components/PeriodPicker/PeriodPickerLogic.ts @@ -84,9 +84,19 @@ function _shiftBy(d: Date, amount: number, unit: PeriodUnit): Date { // Preset resolver // --------------------------------------------------------------------------- +// Sentinel bounds used when the user picked ``Alle`` (no date filter). We keep +// the values *inside* ``PeriodValue`` so downstream code that reads +// ``fromDate``/``toDate`` doesn't break; callers that want to forward "no +// filter" to the backend should check ``preset.kind === 'allTime'`` and drop +// the dates explicitly before building the request. +export const ALL_TIME_FROM = '1970-01-01'; +export const ALL_TIME_TO = '2999-12-31'; + export function resolvePeriod(preset: PeriodPreset, prevValue?: PeriodValue | null): { fromDate: string; toDate: string } { const today = todayDate(); switch (preset.kind) { + case 'allTime': + return { fromDate: ALL_TIME_FROM, toDate: ALL_TIME_TO }; case 'ytd': return { fromDate: toIsoDate(_startOfYear(today)), toDate: toIsoDate(today) }; case 'lastYear': { @@ -164,6 +174,20 @@ export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints return true; } +// Clamp an ISO date to the direction/min/max window defined by ``cfg``. Used +// for ```` ``min``/``max`` attributes so the browser +// refuses invalid years instead of us silently falling back to the default +// preset afterwards. +export function clampIsoDate(_iso: string | undefined, cfg: PeriodConstraints, side: 'min' | 'max'): string | undefined { + const today = toIsoDate(todayDate()); + let lo: string | undefined = cfg.minDate; + let hi: string | undefined = cfg.maxDate; + if (cfg.direction === 'past') hi = hi && hi < today ? hi : today; + if (cfg.direction === 'future') lo = lo && lo > today ? lo : today; + if (side === 'min') return lo; + return hi; +} + // --------------------------------------------------------------------------- // Label formatting // --------------------------------------------------------------------------- @@ -174,6 +198,7 @@ export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints */ export function presetLiteralKey(kind: PeriodPresetKind): string { switch (kind) { + case 'allTime': return 'Alle'; case 'ytd': return 'Laufendes Jahr'; case 'lastYear': return 'Letztes Jahr'; case 'nextYear': return 'Nächstes Jahr'; diff --git a/src/components/PeriodPicker/PeriodPickerPopover.tsx b/src/components/PeriodPicker/PeriodPickerPopover.tsx index a1c521a..d1c5aec 100644 --- a/src/components/PeriodPicker/PeriodPickerPopover.tsx +++ b/src/components/PeriodPicker/PeriodPickerPopover.tsx @@ -5,10 +5,11 @@ * actual commit to the parent via `onApply` / `onCancel`. */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useLanguage } from '../../providers/language/LanguageContext'; import PeriodPickerCalendar from './PeriodPickerCalendar'; import { + clampIsoDate, fromIsoDate, isPresetDisabled, presetLiteralKey, @@ -27,6 +28,7 @@ import type { import styles from './PeriodPicker.module.css'; const PRESETS_ORDER: PeriodPresetKind[] = [ + 'allTime', 'ytd', 'lastYear', 'nextYear', @@ -41,6 +43,7 @@ const PRESETS_ORDER: PeriodPresetKind[] = [ function _presetLabel(kind: PeriodPresetKind, t: (k: string) => string): string { switch (kind) { + case 'allTime': return t('Alle'); case 'ytd': return t('Laufendes Jahr'); case 'lastYear': return t('Letztes Jahr'); case 'nextYear': return t('Nächstes Jahr'); @@ -107,13 +110,21 @@ const PeriodPickerPopover: React.FC = (props) => { const _selectPreset = useCallback((kind: PeriodPresetKind) => { if (isPresetDisabled(kind, constraints)) return; if (kind === 'custom') { + // Switching from ``allTime`` back to custom: don't carry the 1970-2999 + // sentinel. Seed with today/today so the user gets a sensible starting + // point and the calendar has a real anchor. + const isFromAllTime = draft.preset.kind === 'allTime'; + const seedFrom = isFromAllTime ? toIsoDate(todayDate()) : draft.fromDate; + const seedTo = isFromAllTime ? toIsoDate(todayDate()) : draft.toDate; const next: PeriodValue = { preset: { kind: 'custom' }, - fromDate: draft.fromDate, - toDate: draft.toDate, + fromDate: seedFrom, + toDate: seedTo, }; setDraft(next); setRangePick({ from: fromIsoDate(next.fromDate), to: fromIsoDate(next.toDate) }); + const anchor = fromIsoDate(seedFrom); + if (anchor) setCalAnchor(startOfMonth(anchor)); return; } const preset: PeriodPreset = { kind } as PeriodPreset; @@ -152,15 +163,32 @@ const PeriodPickerPopover: React.FC = (props) => { }, [rangePick]); const _onFooterFromChange = useCallback((iso: string) => { + // Empty string = user cleared the input; ignore so ``draft`` keeps a valid ISO. + if (!iso) return; + const d = fromIsoDate(iso); setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, fromDate: iso })); - setRangePick((prev) => ({ from: fromIsoDate(iso), to: prev.to })); + setRangePick((prev) => ({ from: d, to: prev.to })); + // Jump the calendar to the typed month so the user immediately sees the + // selection move. Without this, the calendar stays on the current month + // and it *looks* like the input was ignored. + if (d) setCalAnchor(startOfMonth(d)); }, []); const _onFooterToChange = useCallback((iso: string) => { + if (!iso) return; + const d = fromIsoDate(iso); setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, toDate: iso })); - setRangePick((prev) => ({ from: prev.from, to: fromIsoDate(iso) })); + setRangePick((prev) => ({ from: prev.from, to: d })); + if (d) setCalAnchor(startOfMonth(d)); }, []); + // ``min``/``max`` on the native date inputs — prevents the user from typing + // a date that would be silently reverted by the parent's + // ``isValueAllowed`` fallback (which would replace it with ``defaultPreset`` + // and lose the custom year). + const footerMin = clampIsoDate(undefined, constraints, 'min'); + const footerMax = clampIsoDate(undefined, constraints, 'max'); + // Keyboard: Esc cancels, Enter applies const popRef = useRef(null); useEffect(() => { @@ -172,6 +200,36 @@ const PeriodPickerPopover: React.FC = (props) => { return () => window.removeEventListener('keydown', _onKey); }, [draft, onApply, onCancel]); + useLayoutEffect(() => { + const pop = popRef.current; + if (!pop) return; + const _clamp = () => { + const parent = pop.parentElement; + if (!parent) return; + const pRect = parent.getBoundingClientRect(); + const margin = 8; + const popW = pop.offsetWidth || 720; + const popH = pop.offsetHeight || 400; + let left = pRect.left; + let top = pRect.bottom + 6; + if (left + popW > window.innerWidth - margin) { + left = window.innerWidth - margin - popW; + } + if (left < margin) left = margin; + if (top + popH > window.innerHeight - margin) { + top = Math.max(margin, pRect.top - 6 - popH); + } + pop.style.position = 'fixed'; + pop.style.left = `${left}px`; + pop.style.top = `${top}px`; + pop.style.right = 'auto'; + pop.style.zIndex = '2001'; + }; + _clamp(); + const id = requestAnimationFrame(() => _clamp()); + return () => cancelAnimationFrame(id); + }, []); + return (
@@ -269,18 +327,20 @@ const PeriodPickerPopover: React.FC = (props) => { _onFooterFromChange(e.target.value)} /> {t('Bis')} _onFooterToChange(e.target.value)} /> diff --git a/src/components/PeriodPicker/PeriodPickerTypes.ts b/src/components/PeriodPicker/PeriodPickerTypes.ts index e4fd4fe..48d9a17 100644 --- a/src/components/PeriodPicker/PeriodPickerTypes.ts +++ b/src/components/PeriodPicker/PeriodPickerTypes.ts @@ -9,6 +9,7 @@ export type PeriodUnit = 'day' | 'week' | 'month' | 'year'; export type PeriodPresetKind = + | 'allTime' | 'ytd' | 'lastYear' | 'nextYear' @@ -23,6 +24,7 @@ export type PeriodPresetKind = | 'custom'; export type PeriodPreset = + | { kind: 'allTime' } | { kind: 'ytd' } | { kind: 'lastYear' } | { kind: 'nextYear' } diff --git a/src/hooks/useInvitations.ts b/src/hooks/useInvitations.ts index caf9d41..7377e3e 100644 --- a/src/hooks/useInvitations.ts +++ b/src/hooks/useInvitations.ts @@ -32,8 +32,8 @@ export interface Invitation { roleIds: string[]; targetUsername: string; email?: string; - createdBy: string; - createdAt: number; + sysCreatedBy: string; + sysCreatedAt: number; expiresAt: number; usedBy?: string; usedAt?: number; @@ -41,9 +41,11 @@ export interface Invitation { maxUses: number; currentUses: number; inviteUrl: string; - emailSent?: boolean; - isExpired?: boolean; - isUsedUp?: boolean; + // Backend-driven flags (computed @ Pydantic model + view enrichment) + emailSentFlag?: boolean; + emailSentAt?: number; + expiredFlag?: boolean; + usedUpFlag?: boolean; } export interface InvitationCreate { diff --git a/src/hooks/useMandates.ts b/src/hooks/useMandates.ts index ebb31f9..9444acd 100644 --- a/src/hooks/useMandates.ts +++ b/src/hooks/useMandates.ts @@ -24,6 +24,8 @@ import { import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm'; import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge'; import { validateMandateName } from '../utils/mandateNameUtils'; +import { resolveColumnTypes } from '../utils/columnTypeResolver'; +import type { ColumnConfig } from '../components/FormGenerator/FormGeneratorTable'; // Re-export types export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams }; @@ -153,20 +155,21 @@ export function useAdminMandates() { return await fetchMandateByIdApi(request, mandateId); }, [request]); - // Generate columns from attributes (including fkSource/fkDisplayField for FK resolution) - const columns = attributes.map(attr => ({ - key: attr.name, - label: attr.label || attr.name, - type: attr.type as any, - sortable: attr.sortable !== false, - filterable: attr.filterable !== false, - searchable: attr.searchable !== false, - width: attr.width || 150, - minWidth: attr.minWidth || 100, - maxWidth: attr.maxWidth || 400, - fkSource: (attr as any).fkSource, // API endpoint for FK data - fkDisplayField: (attr as any).fkDisplayField, // Which field of FK target to display - })); + // Generate columns from attributes (types merged via resolveColumnTypes) + const columns: ColumnConfig[] = useMemo(() => { + const raw = attributes.map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + displayField: (attr as any).displayField, + })); + return resolveColumnTypes(raw, attributes); + }, [attributes]); // Create mandate const handleCreate = useCallback(async (mandateData: Partial): Promise => { diff --git a/src/hooks/useTrustee.ts b/src/hooks/useTrustee.ts index 83e0823..c9aad43 100644 --- a/src/hooks/useTrustee.ts +++ b/src/hooks/useTrustee.ts @@ -110,6 +110,11 @@ export interface AttributeDefinition { interface TrusteeEntityConfig { entityName: string; + /** Optional override: name of the *view* model (e.g. ``TrusteePositionView``) + * used purely for the `/attributes/...` lookup so synthetic display columns + * resolve via `resolveColumnTypes`. Falls back to `entityName` when absent. + * Permissions and CRUD operations always use `entityName`. */ + attributesEntityName?: string; fetchAll: (request: any, instanceId: string, params?: PaginationParams) => Promise; fetchById: (request: any, instanceId: string, id: string) => Promise; create: (request: any, instanceId: string, data: Partial) => Promise; @@ -138,7 +143,8 @@ function _createTrusteeEntityHook(config: TrusteeEntit if (!instanceId) return []; try { - const response = await api.get(`/api/trustee/${instanceId}/attributes/${config.entityName}`); + const attrEntity = config.attributesEntityName ?? config.entityName; + const response = await api.get(`/api/trustee/${instanceId}/attributes/${attrEntity}`); let attrs: AttributeDefinition[] = []; if (response.data?.attributes && Array.isArray(response.data.attributes)) { attrs = response.data.attributes; @@ -571,6 +577,9 @@ export const useTrusteeDocumentOperations = _createTrusteeOperationsHook(documen const positionConfig: TrusteeEntityConfig = { entityName: 'TrusteePosition', + // Use the view model so the table picks up `syncStatus` / `syncErrorMessage` + // attributes (computed at the route layer from `TrusteeAccountingSync`). + attributesEntityName: 'TrusteePositionView', fetchAll: fetchPositionsApi, fetchById: fetchPositionByIdApi, create: createPositionApi, diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx index d3a23d3..3941bd6 100644 --- a/src/pages/AutomationsDashboardPage.tsx +++ b/src/pages/AutomationsDashboardPage.tsx @@ -16,6 +16,9 @@ import { usePrompt } from '../hooks/usePrompt'; import { useApiRequest } from '../hooks/useApi'; import { formatUnixTimestamp } from '../utils/time'; import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi'; +import { fetchAttributes } from '../api/attributesApi'; +import type { AttributeDefinition } from '../api/attributesApi'; +import { resolveColumnTypes } from '../utils/columnTypeResolver'; import api from '../api'; import { useLanguage } from '../providers/language/LanguageContext'; import { useNavigation, type DynamicBlock } from '../hooks/useNavigation'; @@ -423,6 +426,7 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) => const _DashboardTab: React.FC = () => { const { t } = useLanguage(); + const { request } = useApiRequest(); const { showError } = useToast(); const [metrics, setMetrics] = useState(null); @@ -431,15 +435,24 @@ const _DashboardTab: React.FC = () => { const [paginationMeta, setPaginationMeta] = useState(null); const [tracingRun, setTracingRun] = useState(null); const lastPaginationParamsRef = useRef(null); + const [backendAttributes, setBackendAttributes] = useState([]); + + useEffect(() => { + fetchAttributes(request, 'AutoRun') + .then(setBackendAttributes) + .catch((err) => { console.error('[automations] fetchAttributes AutoRun failed', err); }); + }, [request]); const _loadMetrics = useCallback(async () => { try { const resp = await api.get('/api/system/workflow-runs/metrics'); setMetrics(resp.data); - } catch (e) { + } catch (e: any) { + const msg = e?.response?.data?.detail || e?.message || String(e); console.error('[automations] metrics load failed', e); + showError(t('Metriken konnten nicht geladen werden: {msg}', { msg })); } - }, []); + }, [showError, t]); const _loadRuns = useCallback(async (paginationParams?: any) => { if (paginationParams !== undefined) { @@ -518,20 +531,10 @@ const _DashboardTab: React.FC = () => { } }, [showError, t]); - const _STATUS_LABELS: Record = useMemo(() => ({ - running: t('Laufend'), - completed: t('Abgeschlossen'), - failed: t('Fehlgeschlagen'), - cancelled: t('Abgebrochen'), - paused: t('Pausiert'), - stopped: t('Gestoppt'), - }), [t]); - - const _runColumns: ColumnConfig[] = useMemo(() => [ + const _rawRunColumns: ColumnConfig[] = useMemo(() => [ { key: 'workflowLabel', label: t('Workflow'), - type: 'string', width: 200, sortable: true, formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'), @@ -539,55 +542,42 @@ const _DashboardTab: React.FC = () => { { key: 'mandateId', label: t('Mandant'), - type: 'string', width: 140, sortable: true, filterable: true, - fkSource: '/api/mandates/', - fkDisplayField: 'label', + displayField: 'mandateLabel', }, { key: 'featureInstanceId', label: t('Instanz'), - type: 'string', width: 140, sortable: true, filterable: true, - fkSource: '/api/features/instances', - fkDisplayField: 'label', + displayField: 'instanceLabel', }, + { key: 'status', width: 110, sortable: true, filterable: true }, { - key: 'status', - label: t('Status'), - type: 'string', - width: 110, + key: 'startedAt', + label: t('Gestartet'), + width: 150, sortable: true, filterable: true, - filterOptions: ['running', 'completed', 'failed', 'cancelled', 'paused'], - filterLabelResolver: (v: string) => _STATUS_LABELS[v] || v, - formatter: (v: string) => ( - - {_STATUS_LABELS[v] || v} - - ), - }, - { - key: 'sysCreatedAt', - label: t('Gestartet'), - type: 'number', - width: 150, - sortable: true, formatter: (v: number) => _formatTs(v), }, { - key: 'sysModifiedAt', + key: 'completedAt', label: t('Beendet'), - type: 'number', width: 150, sortable: true, + filterable: true, formatter: (v: number) => _formatTs(v), }, - ], [t, _STATUS_LABELS]); + ], [t]); + + const _runColumns = useMemo( + () => resolveColumnTypes(_rawRunColumns, backendAttributes), + [_rawRunColumns, backendAttributes], + ); const _hookData = useMemo(() => ({ refetch: _loadRuns, @@ -665,7 +655,7 @@ const _DashboardTab: React.FC = () => { filterable={true} sortable={true} selectable={true} - initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]} + initialSort={[{ key: 'startedAt', direction: 'desc' }]} apiEndpoint="/api/system/workflow-runs" customActions={[ { @@ -711,6 +701,13 @@ const _WorkflowsTab: React.FC = () => { const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all'); const [paginationMeta, setPaginationMeta] = useState(null); const lastPaginationParamsRef = useRef(null); + const [backendAttributes, setBackendAttributes] = useState([]); + + useEffect(() => { + fetchAttributes(request, 'Automation2WorkflowView') + .then(setBackendAttributes) + .catch((err) => { console.error('[automations] fetchAttributes Automation2WorkflowView failed', err); }); + }, [request]); const _load = useCallback(async (paginationParams?: any) => { if (paginationParams !== undefined) { @@ -723,7 +720,7 @@ const _WorkflowsTab: React.FC = () => { if (activeFilter === 'active') params.active = true; if (activeFilter === 'inactive') params.active = false; - const defaultSort = [{ field: 'createdAt', direction: 'desc' }]; + const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }]; const pag = { page: effectiveParams?.page || 1, pageSize: effectiveParams?.pageSize || 25, @@ -818,30 +815,45 @@ const _WorkflowsTab: React.FC = () => { const _handleExecute = useCallback(async (row: SystemWorkflow) => { if (!row.featureInstanceId) return; setExecutingId(row.id); + // Track outcome of the fire-and-forget executeGraph promise so the + // intermediate "Workflow gestartet" toast is only shown when the call has + // not already failed/finished within the 1s observation window. Without + // this we always toasted "gestartet" — even when the run had already + // errored — producing contradictory toasts and hiding real failures. + let observedFailure = false; + let observedSuccess = false; try { const invs = row.invocations || []; const primary = invs.find((i) => i.enabled && i.kind === 'manual') || invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api')); const emptyGraph = { nodes: [], connections: [] }; - executeGraph(request, row.featureInstanceId, emptyGraph as any, row.id, { + const exec = executeGraph(request, row.featureInstanceId, emptyGraph as any, row.id, { ...(primary ? { entryPointId: primary.id } : {}), }).then((result) => { if (result?.success) { + observedSuccess = true; showSuccess(result?.paused ? t('Workflow pausiert bei Human Task.') : t('Workflow abgeschlossen')); } else { + observedFailure = true; showError(result?.error || t('Ausführung fehlgeschlagen')); } _load(); }).catch((e: any) => { + observedFailure = true; showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') })); _load(); }); - await new Promise((r) => setTimeout(r, 1000)); + await Promise.race([ + exec, + new Promise((r) => setTimeout(r, 1000)), + ]); await _load(); - showSuccess(t('Workflow gestartet')); + if (!observedFailure && !observedSuccess) { + showSuccess(t('Workflow gestartet')); + } } finally { setExecutingId(null); } @@ -868,14 +880,27 @@ const _WorkflowsTab: React.FC = () => { return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api')); }, []); - const _columns: ColumnConfig[] = useMemo(() => [ - { key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true, filterable: true }, - { key: 'mandateId', label: t('Mandant'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/mandates/', fkDisplayField: 'label' }, - { key: 'featureInstanceId', label: t('Instanz'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/features/instances', fkDisplayField: 'label' }, + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true }, + { + key: 'mandateId', + label: t('Mandant'), + width: 140, + sortable: true, + filterable: true, + displayField: 'mandateLabel', + }, + { + key: 'featureInstanceId', + label: t('Instanz'), + width: 140, + sortable: true, + filterable: true, + displayField: 'instanceLabel', + }, { key: 'active', label: t('Aktiv'), - type: 'boolean', width: 80, sortable: true, filterable: true, @@ -883,33 +908,41 @@ const _WorkflowsTab: React.FC = () => { { key: 'isRunning', label: t('Läuft'), - type: 'boolean', width: 80, + sortable: true, + filterable: true, }, { key: 'sysCreatedAt', label: t('Erstellt'), - type: 'number', width: 140, sortable: true, + filterable: true, formatter: (v: number) => _formatTs(v), }, { key: 'lastStartedAt', label: t('Zuletzt gestartet'), - type: 'number', width: 160, + sortable: true, + filterable: true, formatter: (v: number) => _formatTs(v), }, { key: 'runCount', label: t('Läufe'), - type: 'number', width: 80, + sortable: true, + filterable: true, formatter: (v: number) => (v != null ? String(v) : '0'), }, ], [t]); + const _columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + const _hookData = useMemo(() => ({ refetch: _load, handleDelete: (id: string) => _handleDelete(id), @@ -954,7 +987,7 @@ const _WorkflowsTab: React.FC = () => { filterable={true} sortable={true} selectable={true} - initialSort={[{ key: 'createdAt', direction: 'desc' }]} + initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]} apiEndpoint="/api/system/workflow-runs/workflows" actionButtons={[ { diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx index 7d70bce..ab8c7b4 100644 --- a/src/pages/ComplianceAuditPage.tsx +++ b/src/pages/ComplianceAuditPage.tsx @@ -14,6 +14,10 @@ import { } from 'recharts'; import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa'; import api from '../api'; +import { useApiRequest } from '../hooks/useApi'; +import { fetchAttributes } from '../api/attributesApi'; +import type { AttributeDefinition } from '../api/attributesApi'; +import { resolveColumnTypes } from '../utils/columnTypeResolver'; import { useLanguage } from '../providers/language/LanguageContext'; import { useUserMandates } from '../hooks/useUserMandates'; import { useConfirm } from '../hooks/useConfirm'; @@ -139,9 +143,19 @@ const _NEUT_PAGE_SIZE = 100; export const ComplianceAuditPage: React.FC = () => { const { t } = useLanguage(); + const { request } = useApiRequest(); + const [aiAuditAttrs, setAiAuditAttrs] = useState([]); + const [auditLogAttrs, setAuditLogAttrs] = useState([]); + const [neutAttrs, setNeutAttrs] = useState([]); const { fetchMandates } = useUserMandates(); const { confirm, ConfirmDialog } = useConfirm(); + useEffect(() => { + fetchAttributes(request, 'AiAuditLogEntry').then(setAiAuditAttrs).catch(() => setAiAuditAttrs([])); + fetchAttributes(request, 'AuditLogEntry').then(setAuditLogAttrs).catch(() => setAuditLogAttrs([])); + fetchAttributes(request, 'DataNeutralizerAttributesView').then(setNeutAttrs).catch(() => setNeutAttrs([])); + }, [request]); + const [mandates, setMandates] = useState([]); const [mandatesLoading, setMandatesLoading] = useState(true); const [selectedMandateId, setSelectedMandateId] = useState(null); @@ -433,19 +447,31 @@ export const ComplianceAuditPage: React.FC = () => { // ── Column definitions ── - const aiLogColumns: ColumnConfig[] = useMemo(() => [ - { key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 }, + const _rawAiLogColumns: ColumnConfig[] = useMemo(() => [ + { key: 'timestamp', label: t('Zeitpunkt'), sortable: true, width: 160 }, { - key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140, - formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'), + key: 'username', + label: t('Benutzer'), + sortable: true, + searchable: true, + width: 140, + formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'), }, { - key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160, + key: 'instanceLabel', + label: t('Feature-Instanz'), + sortable: true, + filterable: true, + width: 160, formatter: (val: any, row: any) => val || row?.featureCode || '–', }, - { key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 }, + { key: 'aiModel', label: t('AI-Modell'), sortable: true, filterable: true, width: 160 }, { - key: 'aiProvider', label: t('Provider / Typ'), type: 'text' as any, sortable: true, filterable: true, width: 140, + key: 'aiProvider', + label: t('Provider / Typ'), + sortable: true, + filterable: true, + width: 140, formatter: (val: any, row: any) => { const provider = val || '–'; const op = row?.operationType; @@ -453,63 +479,109 @@ export const ComplianceAuditPage: React.FC = () => { }, }, { - key: 'priceCHF', label: t('Kosten (CHF)'), type: 'number' as any, sortable: true, width: 110, - formatter: (val: any) => val != null ? Number(val).toFixed(4) : '–', + key: 'priceCHF', + label: t('Kosten (CHF)'), + sortable: true, + width: 110, + formatter: (val: any) => (val != null ? Number(val).toFixed(4) : '–'), }, { - key: 'neutralizationActive', label: t('Neutralisierung'), type: 'text' as any, sortable: true, width: 100, - formatter: (val: any) => val ? '✓' : '–', + key: 'neutralizationActive', + label: t('Neutralisierung'), + sortable: true, + width: 100, + formatter: (val: any) => (val ? '✓' : '–'), }, { - key: 'success', label: t('Status'), type: 'text' as any, sortable: true, filterable: true, width: 80, - formatter: (val: any) => val ? t('OK') : t('Fehler'), - cellClassName: (val: any) => val ? styles.statusOk : styles.statusError, + key: 'success', + label: t('Status'), + sortable: true, + filterable: true, + width: 80, + cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError), }, ], [t]); - const auditLogColumns: ColumnConfig[] = useMemo(() => [ - { key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 }, + const aiLogColumns: ColumnConfig[] = useMemo( + () => resolveColumnTypes(_rawAiLogColumns, aiAuditAttrs), + [_rawAiLogColumns, aiAuditAttrs], + ); + + const _rawAuditLogColumns: ColumnConfig[] = useMemo(() => [ + { key: 'timestamp', label: t('Zeitpunkt'), sortable: true, width: 160 }, { - key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140, - formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'), + key: 'username', + label: t('Benutzer'), + sortable: true, + searchable: true, + width: 140, + formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'), }, { - key: 'category', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 110, + key: 'category', + label: t('Kategorie'), + sortable: true, + filterable: true, + width: 110, cellClassName: (val: any) => { const color = _CATEGORY_COLORS[val as string]; return color ? styles[`cat_${val}`] || '' : ''; }, formatter: (val: any) => val || '–', }, - { key: 'action', label: t('Aktion'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 140 }, - { key: 'resourceType', label: t('Ressource'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, - { key: 'details', label: t('Details'), type: 'text' as any, searchable: true, width: 250 }, + { key: 'action', label: t('Aktion'), sortable: true, filterable: true, searchable: true, width: 140 }, + { key: 'resourceType', label: t('Ressource'), sortable: true, filterable: true, width: 120 }, + { key: 'details', label: t('Details'), searchable: true, width: 250 }, { - key: 'success', label: t('Status'), type: 'text' as any, sortable: true, width: 70, - formatter: (val: any) => val ? '✓' : '✗', - cellClassName: (val: any) => val ? styles.statusOk : styles.statusError, + key: 'success', + label: t('Status'), + sortable: true, + width: 70, + formatter: (val: any) => (val ? '✓' : '✗'), + cellClassName: (val: any) => (val ? styles.statusOk : styles.statusError), }, - { key: 'ipAddress', label: t('IP'), type: 'text' as any, width: 120 }, + { key: 'ipAddress', label: t('IP'), width: 120 }, ], [t]); - const neutColumns: ColumnConfig[] = useMemo(() => [ - { key: 'placeholder', label: t('Platzhalter'), type: 'text' as any, sortable: true, searchable: true, width: 220 }, - { key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 }, - { key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, + const auditLogColumns: ColumnConfig[] = useMemo( + () => resolveColumnTypes(_rawAuditLogColumns, auditLogAttrs), + [_rawAuditLogColumns, auditLogAttrs], + ); + + const _rawNeutColumns: ColumnConfig[] = useMemo(() => [ + { key: 'placeholder', label: t('Platzhalter'), sortable: true, searchable: true, width: 220 }, + { key: 'originalText', label: t('Originaltext'), sortable: true, searchable: true, width: 240 }, + { key: 'patternType', label: t('Kategorie'), sortable: true, filterable: true, width: 120 }, { - key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140, - formatter: (val: any, row: any) => val || (row?.userId ? String(row.userId).slice(0, 8) + '…' : '–'), + key: 'username', + label: t('Benutzer'), + sortable: true, + filterable: true, + width: 140, + formatter: (val: any, row: any) => val || (row?.userId ? `NA(${row.userId})` : '–'), }, { - key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160, - formatter: (val: any, row: any) => val || (row?.featureInstanceId ? String(row.featureInstanceId).slice(0, 8) + '…' : '–'), + key: 'instanceLabel', + label: t('Feature-Instanz'), + sortable: true, + filterable: true, + width: 160, + formatter: (val: any, row: any) => val || (row?.featureInstanceId ? `NA(${row.featureInstanceId})` : '–'), }, { - key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140, - formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–', + key: 'fileId', + label: t('Datei'), + sortable: true, + width: 140, + formatter: (val: any) => (val ? `${String(val).slice(0, 8)}…` : '–'), }, ], [t]); + const neutColumns: ColumnConfig[] = useMemo( + () => resolveColumnTypes(_rawNeutColumns, neutAttrs), + [_rawNeutColumns, neutAttrs], + ); + // ── fetchFilterValues for autofilter dropdowns ── const _makeFetchFilterValues = useCallback( diff --git a/src/pages/admin/AdminDatabaseHealthPage.tsx b/src/pages/admin/AdminDatabaseHealthPage.tsx index 49270ec..7e4f41d 100644 --- a/src/pages/admin/AdminDatabaseHealthPage.tsx +++ b/src/pages/admin/AdminDatabaseHealthPage.tsx @@ -375,12 +375,20 @@ const OrphansTab: React.FC = () => { const [downloading, setDownloading] = useState(null); const [cleaningAll, setCleaningAll] = useState(false); const [onlyProblems, setOnlyProblems] = useState(true); + // Default ON: deleted-user remnants belong to a dedicated purge workflow, + // not to generic FK cleanup. Hiding them by default prevents confusion + // (and accidental "Alle bereinigen" runs) when the SysAdmin scans for + // genuine FK drift. + const [excludeUserFks, setExcludeUserFks] = useState(true); const [dbFilter, setDbFilter] = useState(''); const _fetchOrphans = useCallback(async () => { try { setLoading(true); - const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : ''; + const qs = new URLSearchParams(); + if (dbFilter) qs.set('db', dbFilter); + if (excludeUserFks) qs.set('excludeUserFks', 'true'); + const params = qs.toString() ? `?${qs.toString()}` : ''; const res = await api.get(`/api/admin/database-health/orphans${params}`); const rows = (res.data.orphans || []).map((o: any, i: number) => ({ ...o, @@ -392,7 +400,7 @@ const OrphansTab: React.FC = () => { } finally { setLoading(false); } - }, [dbFilter]); + }, [dbFilter, excludeUserFks]); useEffect(() => { _fetchOrphans(); }, [_fetchOrphans]); @@ -515,7 +523,7 @@ const OrphansTab: React.FC = () => { if (!ok) return; setCleaningAll(true); try { - const res = await api.post('/api/admin/database-health/orphans/clean-all', { force }); + const res = await api.post('/api/admin/database-health/orphans/clean-all', { force, excludeUserFks }); const results: CleanResult[] = res.data.results || []; const totalDeleted = results.reduce((s, r) => s + r.deleted, 0); const errors = results.filter(r => r.error); @@ -636,6 +644,19 @@ const OrphansTab: React.FC = () => { {t('Nur Probleme')}
+
+ +
)} @@ -126,6 +149,9 @@ export const AdminDemoConfigPage: React.FC = () => {

{cfg.label}

{cfg.description}

{cfg.code} + {cfg.credentials && cfg.credentials.length > 0 && ( + <_CredentialsBox credentials={cfg.credentials} compact /> + )}
+
+
+ {t('Passwort')}: + {pwd} + +
+ {cred.email && ( +
+ {t('E-Mail')}: + {cred.email} +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index 4fbd307..7ac9bab 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -14,6 +14,10 @@ import { FormGeneratorForm, type AttributeDefinition } from '../../components/Fo import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import api from '../../api'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { ChatbotConfigSection } from './ChatbotConfigSection'; import { TextField } from '../../components/UiComponents/TextField'; import styles from './Admin.module.css'; @@ -42,6 +46,7 @@ export const AdminFeatureAccessPage: React.FC = () => { const { fetchMandates } = useUserMandates(); const { showSuccess, showError } = useToast(); const { loadFeatures } = useFeatureStore(); + const { request } = useApiRequest(); // State const [mandates, setMandates] = useState([]); @@ -88,18 +93,28 @@ export const AdminFeatureAccessPage: React.FC = () => { } }, [selectedMandateId, fetchInstances]); - // Table columns - const columns = useMemo(() => [ - { key: 'label', label: t('Name'), type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 200 }, - { key: 'featureCode', label: t('Feature'), type: 'string' as const, sortable: true, filterable: true, width: 150, - render: (value: string) => { + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { key: 'label', label: t('Name'), sortable: true, filterable: true, searchable: true, width: 200 }, + { + key: 'featureCode', + label: t('Feature'), + sortable: true, + filterable: true, + width: 150, + formatter: (value: string) => { const feature = features.find(f => f.code === value); - return feature ? (feature.label || value) : value; - } + const label = feature ? (feature.label || value) : value; + return label; + }, }, - { key: 'enabled', label: t('Aktiv'), type: 'boolean' as const, sortable: true, filterable: true, width: 80 }, + { key: 'enabled', label: t('Aktiv'), sortable: true, filterable: true, width: 80 }, ], [features, t]); + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + // Form attributes from backend - merge with dynamic feature options // Exclude featureCode, config, and label since we handle them separately const createFields: AttributeDefinition[] = useMemo(() => { diff --git a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx index b91937d..0fc90f6 100644 --- a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx +++ b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx @@ -14,6 +14,10 @@ import { FaPlus, FaSync, FaBuilding, FaCube } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import { useFeatureStore } from '../../stores/featureStore'; import api from '../../api'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -38,6 +42,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { const { fetchMandates } = useUserMandates(); const { showSuccess, showError } = useToast(); const { loadFeatures } = useFeatureStore(); + const { request } = useApiRequest(); + const [backendAttributes, setBackendAttributes] = useState([]); // Combined instance option type interface CombinedInstanceOption { @@ -72,6 +78,12 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { return selectedCombinedKey.split(':')[1] || ''; }, [selectedCombinedKey]); + useEffect(() => { + fetchAttributes(request, 'FeatureAccessView') + .then(setBackendAttributes) + .catch(() => setBackendAttributes([])); + }, [request]); + // Load mandates and features on mount, then build combined options useEffect(() => { fetchFeatures(); @@ -199,12 +211,10 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { return allUsers.filter(u => !existingUserIds.has(u.id)); }, [allUsers, instanceUsers]); - // Table columns - const columns = useMemo(() => [ + const _rawColumns: ColumnConfig[] = useMemo(() => [ { key: 'username', label: t('Benutzername'), - type: 'text' as const, sortable: true, filterable: true, searchable: true, @@ -213,7 +223,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { { key: 'email', label: t('E-Mail'), - type: 'text' as const, sortable: true, filterable: true, searchable: true, @@ -222,7 +231,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { { key: 'fullName', label: t('Vollständiger Name'), - type: 'text' as const, sortable: true, filterable: true, searchable: true, @@ -231,12 +239,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { { key: 'roleLabels', label: t('Rollen'), - type: 'text' as const, sortable: false, filterable: false, searchable: true, width: 200, - render: (value: string[]) => { + formatter: (value: string[]) => { if (!value || value.length === 0) return '-'; return value.join(', '); }, @@ -244,7 +251,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { { key: 'enabled', label: t('Aktiv'), - type: 'boolean' as const, sortable: true, filterable: true, searchable: false, @@ -252,6 +258,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { }, ], [t]); + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + // Dynamic options for forms (users and roles) const userOptions = useMemo(() => availableUsers.map(u => ({ diff --git a/src/pages/admin/AdminFeatureRolesPage.tsx b/src/pages/admin/AdminFeatureRolesPage.tsx index 48227b5..c1259ac 100644 --- a/src/pages/admin/AdminFeatureRolesPage.tsx +++ b/src/pages/admin/AdminFeatureRolesPage.tsx @@ -17,6 +17,10 @@ import { AccessRulesEditor } from '../../components/AccessRules'; import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import api from '../../api'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -45,6 +49,9 @@ export const AdminFeatureRolesPage: React.FC = () => { const { t } = useLanguage(); const { showError } = useToast(); + const { request } = useApiRequest(); + const [roleTableAttributes, setRoleTableAttributes] = useState([]); + // State const [features, setFeatures] = useState([]); const [selectedFeatureCode, setSelectedFeatureCode] = useState(''); @@ -56,6 +63,12 @@ export const AdminFeatureRolesPage: React.FC = () => { const [isSubmitting, setIsSubmitting] = useState(false); const [permissionsRole, setPermissionsRole] = useState(null); + useEffect(() => { + fetchAttributes(request, 'Role') + .then(setRoleTableAttributes) + .catch(() => setRoleTableAttributes([])); + }, [request]); + // Load features on mount useEffect(() => { const loadFeatures = async () => { @@ -130,40 +143,41 @@ export const AdminFeatureRolesPage: React.FC = () => { return String(value); }; - // Table columns - const columns = useMemo(() => [ - { - key: 'roleLabel', - label: t('Rollen-Label'), - type: 'string' as const, - sortable: true, - filterable: true, - searchable: true, - width: 180 + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { + key: 'roleLabel', + label: t('Rollen-Label'), + sortable: true, + filterable: true, + searchable: true, + width: 180, }, - { - key: 'description', - label: t('Beschreibung'), - type: 'string' as const, - sortable: false, + { + key: 'description', + label: t('Beschreibung'), + sortable: false, width: 300, - formatter: (value: string) => getTextValue(value) + formatter: (value: string) => getTextValue(value), }, - { - key: 'featureCode', - label: t('Feature'), - type: 'string' as const, - sortable: true, - filterable: true, + { + key: 'featureCode', + label: t('Feature'), + sortable: true, + filterable: true, width: 120, formatter: (value: string) => ( {value} - ) + ), }, ], [t]); + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, roleTableAttributes), + [_rawColumns, roleTableAttributes], + ); + // Form attributes for create const createFields: AttributeDefinition[] = useMemo(() => { const fields: AttributeDefinition[] = [ diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index 2f76da0..1f1c73c 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -12,7 +12,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; -import api from '../../api'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -22,6 +25,7 @@ export const AdminInvitationsPage: React.FC = () => { const { t } = useLanguage(); const { showError } = useToast(); + const { request } = useApiRequest(); const { invitations, loading, @@ -56,12 +60,10 @@ export const AdminInvitationsPage: React.FC = () => { } }; loadMandates(); - // Fetch Invitation attributes from backend - api.get('/api/attributes/Invitation').then(response => { - const attrs = response.data?.attributes || response.data || []; - setBackendAttributes(Array.isArray(attrs) ? attrs : []); - }).catch(() => setBackendAttributes([])); - }, [fetchMandates]); + fetchAttributes(request, 'Invitation') + .then(setBackendAttributes) + .catch(() => setBackendAttributes([])); + }, [fetchMandates, request]); // Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin) useEffect(() => { @@ -71,7 +73,7 @@ export const AdminInvitationsPage: React.FC = () => { } }, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchRoles]); - // Format timestamp + // Format timestamp (used by URL modal only). const formatDate = (timestamp: number) => { if (!timestamp) return '-'; const date = new Date(timestamp * 1000); @@ -84,86 +86,45 @@ export const AdminInvitationsPage: React.FC = () => { }); }; - // Table columns - const columns = useMemo(() => [ - { - key: 'targetUsername', - label: t('Benutzername'), - type: 'string' as const, - sortable: true, - filterable: true, - searchable: true, + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { key: 'targetUsername', sortable: true, filterable: true, searchable: true, width: 150 }, + { key: 'email', sortable: true, filterable: true, width: 180 }, + { key: 'emailSentFlag', sortable: true, filterable: true, width: 90 }, + { key: 'emailSentAt', sortable: true, filterable: true, width: 150 }, + { + key: 'roleIds', + sortable: false, + filterable: false, width: 150, - }, - { - key: 'email', - label: t('E-Mail'), - type: 'string' as const, - sortable: true, - filterable: true, - width: 180, - render: (value: string, row: Invitation) => { - const emailText = value || '-'; - const emailSent = (row as any).emailSent; - return ( - - {emailText} {emailSent && '✓'} - - ); - } - }, - { - key: 'roleIds', - label: t('Rollen'), - type: 'string', // Array rendered as string - sortable: false, - filterable: false, - width: 150, - render: (value: string[]) => { + formatter: (value: string[]) => { if (!value || value.length === 0) return '-'; - return value.map(roleId => { + return value.map((roleId) => { const role = roles.find(r => r.id === roleId); return role?.roleLabel || roleId; }).join(', '); - } - } as any, - { - key: 'expiresAt', - label: t('Gültig bis'), - type: 'number' as const, - sortable: true, - width: 150, - render: (value: number) => { - const text = formatDate(value); - const isExpired = value < Date.now() / 1000; - return ( - - {text} {isExpired && '(abgelaufen)'} - - ); - } + }, }, - { - key: 'currentUses', - label: t('Verwendet'), - type: 'string' as const, - sortable: true, + { key: 'expiresAt', sortable: true, filterable: true, width: 150 }, + { key: 'expiredFlag', sortable: true, filterable: true, width: 90 }, + { + key: 'currentUses', + sortable: true, + filterable: true, width: 100, - render: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}` + formatter: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`, }, - { - key: 'createdAt', - label: t('Erstellt'), - type: 'number' as const, - sortable: true, - width: 150, - render: (value: number) => formatDate(value) - }, - ], [roles, t]); + { key: 'usedUpFlag', sortable: true, filterable: true, width: 90 }, + { key: 'sysCreatedAt', sortable: true, filterable: true, width: 150 }, + ], [roles]); + + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); // Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin) const createFields: AttributeDefinition[] = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'token', 'createdBy', 'createdAt', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId']; + const excludedFields = ['id', 'mandateId', 'token', 'sysCreatedBy', 'sysCreatedAt', 'sysUpdatedAt', 'sysUpdatedBy', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId']; // Mandate-level roles (user, viewer, admin) - same as when adding mandate members const roleOptions = roles @@ -445,8 +406,8 @@ export const AdminInvitationsPage: React.FC = () => { {t('verwendet werden.')}

{showUrlModal.email && ( -

- {showUrlModal.emailSent +

+ {showUrlModal.emailSentFlag ? `✓ ${t('E-Mail wurde an {email} gesendet', { email: showUrlModal.email })}` : `${t('E-Mail-Adresse')}: ${showUrlModal.email} (${t('nicht gesendet')})`}

diff --git a/src/pages/admin/AdminLanguagesPage.tsx b/src/pages/admin/AdminLanguagesPage.tsx index beb771d..8e324ca 100644 --- a/src/pages/admin/AdminLanguagesPage.tsx +++ b/src/pages/admin/AdminLanguagesPage.tsx @@ -8,6 +8,10 @@ import api from '../../api'; import axios from 'axios'; import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable'; import { useConfirm } from '../../hooks/useConfirm'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import type { AttributeDefinition } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { useLanguage } from '../../providers/language/LanguageContext'; import styles from './Admin.module.css'; @@ -39,45 +43,13 @@ type ProgressInfo = { keysTranslated?: number; }; -function _getColumns(t: (key: string) => string): ColumnConfig[] { - return [ - { key: 'id', label: t('Code'), type: 'text', sortable: true, filterable: true, width: 90 }, - { key: 'label', label: t('Bezeichnung'), type: 'text', sortable: true, filterable: true, width: 200 }, - { - key: 'status', - label: t('Status'), - type: 'text', - sortable: true, - filterable: true, - width: 160, - formatter: (_val: any, row: any) => { - const r = row as LangRow; - if (r.updating) { - return ( - - - {t('wird aktualisiert…')} - - ); - } - if (r.status === 'generating') { - return ( - - - {t('wird erzeugt…')} - - ); - } - return r.status; - }, - }, - { key: 'uiCount', label: t('UI'), type: 'number', sortable: true, width: 80 }, - { key: 'gatewayCount', label: t('API'), type: 'number', sortable: true, width: 80 }, - { key: 'entriesCount', label: t('Gesamt'), type: 'number', sortable: true, width: 80 }, - ]; -} - -const _PRIORITY_CODES = ['de', 'gsw', 'en', 'fr', 'it']; +// ISO 639 catalog (codes + native labels + priority order) is provided by the +// gateway via GET /api/i18n/iso-choices. We must NOT keep a local copy here -- +// any divergence between frontend and backend caused subtle bugs (e.g. user +// could create a language code that the AI translation prompt did not know how +// to label). The catalog is fetched once on mount and held in component state. +type IsoChoice = { value: string; label: string }; +type IsoCatalogResponse = { priorityCodes: string[]; choices: IsoChoice[] }; function _isAbortError(e: unknown): boolean { if (axios.isCancel(e)) return true; @@ -88,55 +60,6 @@ function _isAbortError(e: unknown): boolean { return false; } -const _isoChoices: { value: string; label: string }[] = [ - { value: 'de', label: 'de — Deutsch' }, - { value: 'gsw', label: 'gsw — Schweizerdeutsch' }, - { value: 'en', label: 'en — English' }, - { value: 'fr', label: 'fr — Français' }, { value: 'it', label: 'it — Italiano' }, - { value: 'es', label: 'es — Español' }, { value: 'pt', label: 'pt — Português' }, - { value: 'nl', label: 'nl — Nederlands' }, { value: 'pl', label: 'pl — Polski' }, - { value: 'cs', label: 'cs — Čeština' }, { value: 'sk', label: 'sk — Slovenčina' }, - { value: 'sv', label: 'sv — Svenska' }, { value: 'no', label: 'no — Norsk' }, - { value: 'da', label: 'da — Dansk' }, { value: 'fi', label: 'fi — Suomi' }, - { value: 'hu', label: 'hu — Magyar' }, { value: 'ro', label: 'ro — Română' }, - { value: 'bg', label: 'bg — Български' }, { value: 'hr', label: 'hr — Hrvatski' }, - { value: 'sl', label: 'sl — Slovenščina' }, { value: 'et', label: 'et — Eesti' }, - { value: 'lv', label: 'lv — Latviešu' }, { value: 'lt', label: 'lt — Lietuvių' }, - { value: 'el', label: 'el — Ελληνικά' }, { value: 'tr', label: 'tr — Türkçe' }, - { value: 'ru', label: 'ru — Русский' }, { value: 'uk', label: 'uk — Українська' }, - { value: 'ar', label: 'ar — العربية' }, { value: 'he', label: 'he — עברית' }, - { value: 'zh', label: 'zh — 中文' }, { value: 'ja', label: 'ja — 日本語' }, - { value: 'ko', label: 'ko — 한국어' }, { value: 'hi', label: 'hi — हिन्दी' }, - { value: 'th', label: 'th — ไทย' }, { value: 'vi', label: 'vi — Tiếng Việt' }, - { value: 'id', label: 'id — Bahasa Indonesia' }, { value: 'ms', label: 'ms — Bahasa Melayu' }, - { value: 'tl', label: 'tl — Filipino' }, { value: 'sw', label: 'sw — Kiswahili' }, - { value: 'af', label: 'af — Afrikaans' }, { value: 'sq', label: 'sq — Shqip' }, - { value: 'am', label: 'am — አማርኛ' }, { value: 'hy', label: 'hy — Հայերեն' }, - { value: 'az', label: 'az — Azərbaycan' }, { value: 'eu', label: 'eu — Euskara' }, - { value: 'be', label: 'be — Беларуская' }, { value: 'bn', label: 'bn — বাংলা' }, - { value: 'bs', label: 'bs — Bosanski' }, { value: 'ca', label: 'ca — Català' }, - { value: 'cy', label: 'cy — Cymraeg' }, { value: 'eo', label: 'eo — Esperanto' }, - { value: 'fa', label: 'fa — فارسی' }, { value: 'ga', label: 'ga — Gaeilge' }, - { value: 'gl', label: 'gl — Galego' }, { value: 'gu', label: 'gu — ગુજરાતી' }, - { value: 'ha', label: 'ha — Hausa' }, { value: 'is', label: 'is — Íslenska' }, - { value: 'jv', label: 'jv — Basa Jawa' }, { value: 'ka', label: 'ka — ქართული' }, - { value: 'kk', label: 'kk — Қазақ' }, { value: 'km', label: 'km — ខ្មែរ' }, - { value: 'kn', label: 'kn — ಕನ್ನಡ' }, { value: 'ku', label: 'ku — Kurdî' }, - { value: 'ky', label: 'ky — Кыргызча' }, { value: 'la', label: 'la — Latina' }, - { value: 'lb', label: 'lb — Lëtzebuergesch' }, { value: 'lo', label: 'lo — ລາວ' }, - { value: 'mk', label: 'mk — Македонски' }, { value: 'ml', label: 'ml — മലയാളം' }, - { value: 'mn', label: 'mn — Монгол' }, { value: 'mr', label: 'mr — मराठी' }, - { value: 'mt', label: 'mt — Malti' }, { value: 'my', label: 'my — မြန်မာ' }, - { value: 'ne', label: 'ne — नेपाली' }, { value: 'or', label: 'or — ଓଡ଼ିଆ' }, - { value: 'pa', label: 'pa — ਪੰਜਾਬੀ' }, { value: 'ps', label: 'ps — پښتو' }, - { value: 'si', label: 'si — සිංහල' }, { value: 'so', label: 'so — Soomaali' }, - { value: 'sr', label: 'sr — Српски' }, { value: 'su', label: 'su — Basa Sunda' }, - { value: 'ta', label: 'ta — தமிழ்' }, { value: 'te', label: 'te — తెలుగు' }, - { value: 'tg', label: 'tg — Тоҷикӣ' }, { value: 'tk', label: 'tk — Türkmen' }, - { value: 'ur', label: 'ur — اردو' }, { value: 'uz', label: 'uz — Oʻzbek' }, - { value: 'yo', label: 'yo — Yorùbá' }, { value: 'zu', label: 'zu — isiZulu' }, -]; - // --------------------------------------------------------------------------- // Progress overlay component // --------------------------------------------------------------------------- @@ -321,15 +244,44 @@ const _ProgressOverlay: React.FC<{ export const AdminLanguagesPage: React.FC = () => { const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage(); const { confirm, ConfirmDialog } = useConfirm(); + const { request } = useApiRequest(); + const [langSetAttributes, setLangSetAttributes] = useState([]); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [addCode, setAddCode] = useState(''); const [progress, setProgress] = useState(null); const [search, setSearch] = useState(''); + const [isoCatalog, setIsoCatalog] = useState({ priorityCodes: [], choices: [] }); const busyRef = useRef(false); const abortRef = useRef(null); + useEffect(() => { + fetchAttributes(request, 'UiLanguageSetView') + .then(setLangSetAttributes) + .catch(() => setLangSetAttributes([])); + }, [request]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await api.get('/api/i18n/iso-choices'); + if (cancelled) return; + const data = res.data as IsoCatalogResponse; + setIsoCatalog({ + priorityCodes: Array.isArray(data?.priorityCodes) ? data.priorityCodes : [], + choices: Array.isArray(data?.choices) ? data.choices : [], + }); + } catch (e) { + console.error('Failed to load ISO language catalog from /api/i18n/iso-choices:', e); + } + })(); + return () => { + cancelled = true; + }; + }, []); + const _endProgressSoon = useCallback((ms: number) => { window.setTimeout(() => { setProgress(null); @@ -415,20 +367,98 @@ export const AdminLanguagesPage: React.FC = () => { }); }, [rows, search]); + const _fetchFilterValues = useCallback( + async (columnKey: string, crossFilters?: Record): Promise<(string | null)[]> => { + let source = displayRows; + if (crossFilters && Object.keys(crossFilters).length > 0) { + source = source.filter((row) => { + for (const [key, val] of Object.entries(crossFilters)) { + if (val === undefined || val === null || val === '') continue; + const cell = (row as any)[key]; + if (Array.isArray(val)) { + if (val.length > 0 && !val.includes(String(cell ?? ''))) return false; + } else if (String(cell ?? '') !== String(val)) { + return false; + } + } + return true; + }); + } + const seen = new Set(); + let hasEmpty = false; + for (const row of source) { + const v = (row as any)[columnKey]; + if (v === undefined || v === null || v === '') { + hasEmpty = true; + continue; + } + seen.add(String(v)); + } + const out: (string | null)[] = Array.from(seen).sort((a, b) => a.localeCompare(b)); + if (hasEmpty) out.push(null); + return out; + }, + [displayRows], + ); + + const _hookData = useMemo( + () => ({ fetchFilterValues: _fetchFilterValues }), + [_fetchFilterValues], + ); + + const columns = useMemo(() => { + const raw: ColumnConfig[] = [ + { key: 'id', label: t('Code'), sortable: true, filterable: true, width: 90 }, + { key: 'label', label: t('Bezeichnung'), sortable: true, filterable: true, width: 200 }, + { + key: 'status', + label: t('Status'), + sortable: true, + filterable: true, + width: 160, + formatter: (_val: any, row: any) => { + const r = row as LangRow; + if (r.updating) { + return ( + + + {t('wird aktualisiert…')} + + ); + } + if (r.status === 'generating') { + return ( + + + {t('wird erzeugt…')} + + ); + } + return r.status; + }, + }, + { key: 'uiCount', label: t('UI'), sortable: true, width: 80 }, + { key: 'gatewayCount', label: t('API'), sortable: true, width: 80 }, + { key: 'entriesCount', label: t('Gesamt'), sortable: true, width: 80 }, + ]; + return resolveColumnTypes(raw, langSetAttributes); + }, [t, langSetAttributes]); + const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]); const addChoices = useMemo(() => { - const available = _isoChoices.filter((c) => !existingCodes.has(c.value)); + const available = isoCatalog.choices.filter((c) => !existingCodes.has(c.value)); + const priority = isoCatalog.priorityCodes; available.sort((a, b) => { - const aPrio = _PRIORITY_CODES.indexOf(a.value); - const bPrio = _PRIORITY_CODES.indexOf(b.value); + const aPrio = priority.indexOf(a.value); + const bPrio = priority.indexOf(b.value); if (aPrio !== -1 && bPrio !== -1) return aPrio - bPrio; if (aPrio !== -1) return -1; if (bPrio !== -1) return 1; return a.label.localeCompare(b.label); }); return available; - }, [existingCodes]); + }, [existingCodes, isoCatalog]); useEffect(() => { if (addChoices.length > 0 && (!addCode || !addChoices.find((c) => c.value === addCode))) { @@ -890,11 +920,12 @@ export const AdminLanguagesPage: React.FC = () => {
{ const { t, currentLanguage } = useLanguage(); const navigate = useNavigate(); + const { request } = useApiRequest(); const { showError, showWarning } = useToast(); const { roles, @@ -68,12 +72,10 @@ export const AdminMandateRolesPage: React.FC = () => { } }; loadMandates(); - // Fetch Role attributes from backend - api.get('/api/attributes/Role').then(response => { - const attrs = response.data?.attributes || response.data || []; - setBackendAttributes(Array.isArray(attrs) ? attrs : []); - }).catch(() => setBackendAttributes([])); - }, [fetchMandates]); + fetchAttributes(request, 'RoleView') + .then(setBackendAttributes) + .catch(() => setBackendAttributes([])); + }, [fetchMandates, request]); // Load roles when mandate or scopeFilter changes useEffect(() => { @@ -102,56 +104,23 @@ export const AdminMandateRolesPage: React.FC = () => { return String(desc); }; - // Table columns - scopeType is now a backend-computed field - const columns = useMemo(() => [ - { - key: 'roleLabel', - label: t('Bezeichnung'), - type: 'string' as const, - sortable: true, - filterable: true, - searchable: true, - width: 150 - }, - { - key: 'description', - label: t('Beschreibung'), - type: 'string' as const, - sortable: false, + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { key: 'roleLabel', sortable: true, filterable: true, searchable: true, width: 150 }, + { + key: 'description', + sortable: false, filterable: false, width: 250, - formatter: (value: string) => getDescriptionText(value) + formatter: (value: string) => getDescriptionText(value), }, - { - key: 'scopeType', - label: t('Geltungsbereich'), - type: 'string' as const, - sortable: true, - filterable: true, - width: 140, - formatter: (value: string) => { - if (value === 'system') { - return ( - - {t('System-Template')} - - ); - } - if (value === 'global') { - return ( - - {t('Template')} - - ); - } - return ( - - {t('Mandant')} - - ); - } - }, - ], [t]); + { key: 'scopeType', sortable: true, filterable: true, width: 160 }, + { key: 'userCount', sortable: true, filterable: true, width: 100 }, + ], []); + + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); // Form attributes from backend - for create form const createFields: AttributeDefinition[] = useMemo(() => { diff --git a/src/pages/admin/AdminUserMandatesPage.tsx b/src/pages/admin/AdminUserMandatesPage.tsx index 2ff9715..8d9f5fa 100644 --- a/src/pages/admin/AdminUserMandatesPage.tsx +++ b/src/pages/admin/AdminUserMandatesPage.tsx @@ -11,7 +11,10 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; -import api from '../../api'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -21,6 +24,7 @@ export const AdminUserMandatesPage: React.FC = () => { const { t } = useLanguage(); const { showError } = useToast(); + const { request } = useApiRequest(); const { users, loading, @@ -59,12 +63,10 @@ export const AdminUserMandatesPage: React.FC = () => { } }; loadMandates(); - // Fetch UserMandate attributes from backend (for table columns) - api.get('/api/attributes/UserMandate').then(response => { - const attrs = response.data?.attributes || response.data || []; - setBackendAttributes(Array.isArray(attrs) ? attrs : []); - }).catch(() => setBackendAttributes([])); - }, [fetchMandates]); + fetchAttributes(request, 'UserMandateView') + .then(setBackendAttributes) + .catch(() => setBackendAttributes([])); + }, [fetchMandates, request]); // Load users when mandate changes useEffect(() => { @@ -97,60 +99,57 @@ export const AdminUserMandatesPage: React.FC = () => { return allUsers.filter(u => !existingUserIds.has(u.id)); }, [allUsers, users]); - // Table columns - based on MandateUserInfo response structure - const columns = useMemo(() => { - return [ - { - key: 'username', - label: t('Benutzername'), - type: 'text' as any, - sortable: true, - filterable: true, - searchable: true, - width: 150, + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { + key: 'username', + label: t('Benutzername'), + sortable: true, + filterable: true, + searchable: true, + width: 150, + }, + { + key: 'email', + label: t('E-Mail'), + sortable: true, + filterable: true, + searchable: true, + width: 200, + }, + { + key: 'fullName', + label: t('Vollständiger Name'), + sortable: true, + filterable: true, + searchable: true, + width: 180, + }, + { + key: 'roleLabels', + label: t('Rollen'), + sortable: false, + filterable: false, + searchable: true, + width: 200, + formatter: (value: string[]) => { + if (!value || value.length === 0) return '-'; + return value.join(', '); }, - { - key: 'email', - label: t('E-Mail'), - type: 'text' as any, - sortable: true, - filterable: true, - searchable: true, - width: 200, - }, - { - key: 'fullName', - label: t('Vollständiger Name'), - type: 'text' as any, - sortable: true, - filterable: true, - searchable: true, - width: 180, - }, - { - key: 'roleLabels', - label: t('Rollen'), - type: 'text' as any, - sortable: false, - filterable: false, - searchable: true, - width: 200, - render: (value: string[]) => { - if (!value || value.length === 0) return '-'; - return value.join(', '); - }, - }, - { - key: 'enabled', - label: t('Aktiv'), - type: 'boolean' as any, - sortable: true, - filterable: true, - searchable: false, - width: 80, - }, - ]; - }, [t]); + }, + { + key: 'enabled', + label: t('Aktiv'), + sortable: true, + filterable: true, + searchable: false, + width: 80, + }, + ], [t]); + + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); // Dynamic options for forms (users and roles) const userOptions = useMemo(() => diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx index 88b3881..20f350d 100644 --- a/src/pages/admin/AdminUsersPage.tsx +++ b/src/pages/admin/AdminUsersPage.tsx @@ -14,6 +14,7 @@ import styles from './Admin.module.css'; import { getUserDataCache } from '../../utils/userCache'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const; @@ -57,21 +58,20 @@ export const AdminUsersPage: React.FC = () => { const [showCreateModal, setShowCreateModal] = useState(false); const [editingUser, setEditingUser] = useState(null); - // Generate columns from attributes + // Generate columns from attributes; types from backend via resolveColumnTypes const columns = useMemo(() => { - return (attributes || []).map(attr => ({ + const raw = (attributes || []).map(attr => ({ key: attr.name, label: attr.label || attr.name, - type: attr.type as any, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, width: attr.width || 150, minWidth: attr.minWidth || 100, maxWidth: attr.maxWidth || 400, - fkSource: (attr as any).fkSource, - fkDisplayField: (attr as any).fkDisplayField, + displayField: (attr as any).displayField, })); + return resolveColumnTypes(raw, attributes || []); }, [attributes]); // Check permissions diff --git a/src/pages/admin/wizards/AdminInvitationWizardPage.tsx b/src/pages/admin/wizards/AdminInvitationWizardPage.tsx index d1c35c0..89d28ed 100644 --- a/src/pages/admin/wizards/AdminInvitationWizardPage.tsx +++ b/src/pages/admin/wizards/AdminInvitationWizardPage.tsx @@ -38,7 +38,7 @@ interface DispatchResult { username?: string; success: boolean; error?: string; - emailSent?: boolean; + emailSentFlag?: boolean; } // ============================================================================= @@ -254,7 +254,7 @@ export const AdminInvitationWizardPage: React.FC = () => { email: emailTrim, username: inv.username, success: true, - emailSent: result.data?.emailSent, + emailSentFlag: result.data?.emailSentFlag, }); } else { results.push({ @@ -731,7 +731,7 @@ export const AdminInvitationWizardPage: React.FC = () => { {r.success ? t('Erfolgreich') : r.error || t('Fehler')} - {r.emailSent ? t('Ja') : t('—')} + {r.emailSentFlag ? t('Ja') : t('—')} ))} diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 0baa236..9636e82 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -14,6 +14,7 @@ import { getApiBaseUrl } from '../../../config/config'; import styles from '../admin/Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; export const ConnectionsPage: React.FC = () => { const { t } = useLanguage(); @@ -54,33 +55,25 @@ export const ConnectionsPage: React.FC = () => { const columns = useMemo(() => { const hiddenColumns = ['id', 'externalId', 'tokenStatus', 'tokenExpiresAt', 'grantedScopes']; - return (attributes || []) + const raw = (attributes || []) .filter(attr => !hiddenColumns.includes(attr.name)) .map(attr => { const col: any = { key: attr.name, - label: attr.label || attr.name, - type: attr.type as any, + label: attr.name === 'userId' ? t('Benutzer') : attr.label || attr.name, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, width: attr.width || 150, minWidth: attr.minWidth || 100, maxWidth: attr.maxWidth || 400, - fkSource: (attr as any).fkSource, - fkDisplayField: (attr as any).fkDisplayField, + displayField: (attr as any).displayField, frontendFormat: (attr as any).frontendFormat, frontendFormatLabels: (attr as any).frontendFormatLabels, }; - - if (attr.name === 'userId') { - col.fkSource = '/api/users/'; - col.fkDisplayField = 'username'; - col.label = t('Benutzer'); - } - return col; }); + return resolveColumnTypes(raw, attributes || []); }, [attributes, t]); // Check permissions diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index 78f3cbb..a65ad5b 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -20,6 +20,7 @@ import { usePrompt } from '../../hooks/usePrompt'; import styles from '../admin/Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; import { getUserDataCache } from '../../utils/userCache'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; interface UserFile { id: string; @@ -203,32 +204,28 @@ export const FilesPage: React.FC = () => { .map(attr => ({ key: attr.name, label: attr.label || attr.name, - type: attr.type as any, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, width: attr.width || 150, minWidth: attr.minWidth || 100, maxWidth: attr.maxWidth || 400, - fkSource: (attr as any).fkSource, - fkDisplayField: (attr as any).fkDisplayField, + displayField: (attr as any).displayField, frontendFormat: (attr as any).frontendFormat, frontendFormatLabels: (attr as any).frontendFormatLabels, })); cols.push({ key: 'sysCreatedBy', label: t('Erstellt von'), - type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250, - fkSource: '/api/users/', - fkDisplayField: 'username', + displayField: 'sysCreatedByLabel', } as any); - return cols; + return resolveColumnTypes(cols, attributes || []); }, [attributes, t]); const canCreate = permissions?.create !== 'n'; diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index ae55350..3fa1bdf 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -13,6 +13,7 @@ import { FaSync, FaPlus } from 'react-icons/fa'; import styles from '../admin/Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; interface Prompt { id: string; @@ -76,15 +77,13 @@ export const PromptsPage: React.FC = () => { .map(attr => ({ key: attr.name, label: attr.label || attr.name, - type: attr.type as any, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, width: attr.name === 'content' ? 300 : attr.width || 150, minWidth: attr.minWidth || 100, maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400, - fkSource: (attr as any).fkSource, - fkDisplayField: (attr as any).fkDisplayField, + displayField: (attr as any).displayField, frontendFormat: (attr as any).frontendFormat, frontendFormatLabels: (attr as any).frontendFormatLabels, })); @@ -93,20 +92,18 @@ export const PromptsPage: React.FC = () => { cols.push({ key: 'sysCreatedBy', label: t('Erstellt von'), - type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250, - fkSource: '/api/users/', - fkDisplayField: 'username', + displayField: 'sysCreatedByLabel', frontendFormat: undefined, frontendFormatLabels: undefined, }); - return cols; + return resolveColumnTypes(cols, attributes || []); }, [attributes, t]); // Check permissions diff --git a/src/pages/billing/AdminSubscriptionsPage.tsx b/src/pages/billing/AdminSubscriptionsPage.tsx index dc2a81c..f6d7403 100644 --- a/src/pages/billing/AdminSubscriptionsPage.tsx +++ b/src/pages/billing/AdminSubscriptionsPage.tsx @@ -1,7 +1,11 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions'; import { useConfirm } from '../../hooks/useConfirm'; +import { useApiRequest } from '../../hooks/useApi'; +import { fetchAttributes } from '../../api/attributesApi'; +import type { AttributeDefinition } from '../../api/attributesApi'; +import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import api from '../../api'; import styles from './Billing.module.css'; @@ -9,28 +13,39 @@ import { useLanguage } from '../../providers/language/LanguageContext'; const _TERMINAL_STATUSES = new Set(['EXPIRED']); -function _getColumns(t: (key: string) => string): ColumnConfig[] { - return [ - { key: 'mandateName', label: t('Mandant'), type: 'text', sortable: true, filterable: true, width: 180 }, - { key: 'planTitle', label: t('Plan'), type: 'text', sortable: true, filterable: true, width: 180 }, - { key: 'status', label: t('Status'), type: 'text', sortable: true, filterable: true, width: 110 }, - { key: 'recurring', label: t('Wiederkehrend'), type: 'boolean', sortable: true, filterable: true, width: 120 }, - { key: 'activeUsers', label: t('Benutzer'), type: 'number', sortable: true, width: 70 }, - { key: 'activeInstances', label: t('Module'), type: 'number', sortable: true, width: 90 }, - { key: 'monthlyRevenueCHF', label: t('Umsatz pro Monat'), type: 'number', sortable: true, width: 140 }, - { key: 'startedAt', label: t('Gestartet'), type: 'date', sortable: true, filterable: true, width: 130 }, - { key: 'currentPeriodEnd', label: t('Periodenende'), type: 'date', sortable: true, filterable: true, width: 130 }, - { key: 'snapshotPricePerUserCHF', label: t('Preis pro Benutzer'), type: 'number', sortable: true, width: 100 }, - { key: 'snapshotPricePerInstanceCHF', label: t('Preis pro Modul'), type: 'number', sortable: true, width: 110 }, - ]; -} - const AdminSubscriptionsPage: React.FC = () => { - const { t } = useLanguage(); + const { t } = useLanguage(); + const { request } = useApiRequest(); + const [backendAttributes, setBackendAttributes] = useState([]); const { confirm, ConfirmDialog } = useConfirm(); const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions(); + useEffect(() => { + fetchAttributes(request, 'MandateSubscriptionView') + .then(setBackendAttributes) + .catch(() => setBackendAttributes([])); + }, [request]); + + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, width: 180 }, + { key: 'planTitle', label: t('Plan'), sortable: true, filterable: true, width: 180 }, + { key: 'status', label: t('Status'), sortable: true, filterable: true, width: 110 }, + { key: 'recurring', label: t('Wiederkehrend'), sortable: true, filterable: true, width: 120 }, + { key: 'activeUsers', label: t('Benutzer'), sortable: true, width: 70 }, + { key: 'activeInstances', label: t('Module'), sortable: true, width: 90 }, + { key: 'monthlyRevenueCHF', label: t('Umsatz pro Monat'), sortable: true, width: 140 }, + { key: 'startedAt', label: t('Gestartet'), sortable: true, filterable: true, width: 130 }, + { key: 'currentPeriodEnd', label: t('Periodenende'), sortable: true, filterable: true, width: 130 }, + { key: 'snapshotPricePerUserCHF', label: t('Preis pro Benutzer'), sortable: true, width: 100 }, + { key: 'snapshotPricePerInstanceCHF', label: t('Preis pro Modul'), sortable: true, width: 110 }, + ], [t]); + + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + const _handleForceCancel = useCallback(async (row: any) => { const ok = await confirm( t('Subscription «{plan}» für Mandant «{mandate}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.', { plan: row.planTitle, mandate: row.mandateName }), @@ -44,7 +59,7 @@ const AdminSubscriptionsPage: React.FC = () => { } catch (err) { console.error('Force cancel failed:', err); } - }, [confirm, refetch]); + }, [confirm, refetch, t]); return (
@@ -56,7 +71,7 @@ const AdminSubscriptionsPage: React.FC = () => {
{ const { t } = useLanguage(); + const { request } = useApiRequest(); + const [billingTxnAttributes, setBillingTxnAttributes] = useState([]); const [activeTab, setActiveTab] = useState('overview'); const [searchParams, setSearchParams] = useSearchParams(); const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -338,6 +344,12 @@ export const BillingDataView: React.FC = () => { const [transactionsError, setTransactionsError] = useState(null); const [transactionsPagination, setTransactionsPagination] = useState(null); + useEffect(() => { + fetchAttributes(request, 'BillingTransactionView') + .then(setBillingTxnAttributes) + .catch(() => setBillingTxnAttributes([])); + }, [request]); + // Unified scope params -- single source of truth for all tab API calls // "nur meine Daten" is an additional filter on top of the dropdown scope const _scopeParams = useMemo((): Record => { @@ -512,19 +524,23 @@ export const BillingDataView: React.FC = () => { fetchFilterValues: _fetchTransactionFilterValues, }), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]); - // Table column definitions - const columns: ColumnConfig[] = useMemo(() => [ - { key: 'createdAt', label: t('Datum'), type: 'timestamp' as any, sortable: true, width: 160 }, - { key: 'mandateName', label: t('Mandant'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 }, - { key: 'userName', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 }, - { key: 'transactionType', label: t('Typ'), type: 'text' as any, sortable: true, filterable: true, width: 100 }, - { key: 'description', label: t('Beschreibung'), type: 'text' as any, searchable: true, width: 250 }, - { key: 'aicoreProvider', label: t('Anbieter'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, - { key: 'aicoreModel', label: t('Modell'), type: 'text' as any, sortable: true, filterable: true, width: 150 }, - { key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, - { key: 'amount', label: t('Betrag (CHF)'), type: 'number' as any, sortable: true, searchable: true, width: 120 }, + const _rawTransactionColumns: ColumnConfig[] = useMemo(() => [ + { key: 'sysCreatedAt', label: t('Datum'), sortable: true, width: 160 }, + { key: 'mandateName', label: t('Mandant'), sortable: true, filterable: true, searchable: true, width: 150 }, + { key: 'userName', label: t('Benutzer'), sortable: true, filterable: true, searchable: true, width: 150 }, + { key: 'transactionType', label: t('Typ'), sortable: true, filterable: true, width: 100 }, + { key: 'description', label: t('Beschreibung'), searchable: true, width: 250 }, + { key: 'aicoreProvider', label: t('Anbieter'), sortable: true, filterable: true, width: 120 }, + { key: 'aicoreModel', label: t('Modell'), sortable: true, filterable: true, width: 150 }, + { key: 'featureCode', label: t('Feature'), sortable: true, filterable: true, width: 120 }, + { key: 'amount', label: t('Betrag (CHF)'), sortable: true, searchable: true, width: 120 }, ], [t]); + const columns: ColumnConfig[] = useMemo( + () => resolveColumnTypes(_rawTransactionColumns, billingTxnAttributes), + [_rawTransactionColumns, billingTxnAttributes], + ); + const totalBalance = useMemo(() => { const filtered = selectedScope === 'personal' || selectedScope === 'all' ? balances diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx index bf2b575..db9c174 100644 --- a/src/pages/billing/BillingMandateView.tsx +++ b/src/pages/billing/BillingMandateView.tsx @@ -145,7 +145,7 @@ const TransactionTable: React.FC = ({ transactions }) => {transactions.map((txn) => ( - {formatDate(txn.createdAt)} + {formatDate(txn.sysCreatedAt)} {txn.mandateName || '-'} diff --git a/src/pages/billing/BillingTransactions.tsx b/src/pages/billing/BillingTransactions.tsx index 533513f..b176ff8 100644 --- a/src/pages/billing/BillingTransactions.tsx +++ b/src/pages/billing/BillingTransactions.tsx @@ -58,7 +58,7 @@ const TransactionRow: React.FC = ({ transaction }) => { return ( - {formatDate(transaction.createdAt)} + {formatDate(transaction.sysCreatedAt)} {transaction.mandateName || '-'} diff --git a/src/pages/billing/BillingUserView.tsx b/src/pages/billing/BillingUserView.tsx index 644f2ef..3e9abb1 100644 --- a/src/pages/billing/BillingUserView.tsx +++ b/src/pages/billing/BillingUserView.tsx @@ -239,7 +239,7 @@ const UserTransactionTable: React.FC = ({ {filteredTransactions.map((txn) => ( - {formatDate(txn.createdAt)} + {formatDate(txn.sysCreatedAt)} {txn.mandateName || '-'} {txn.userName || '-'} diff --git a/src/pages/views/commcoach/CommcoachKeepAlive.tsx b/src/pages/views/commcoach/CommcoachKeepAlive.tsx index 81f1ae0..16025ac 100644 --- a/src/pages/views/commcoach/CommcoachKeepAlive.tsx +++ b/src/pages/views/commcoach/CommcoachKeepAlive.tsx @@ -4,6 +4,10 @@ * Keeps the CommCoach dossier/coaching page mounted across route changes. * Visibility is toggled via CSS so session state, messages, and input state * stay alive when the user leaves and later returns. + * + * Persistence is scoped per `(mandateId, instanceId)` — switching to a + * different mandate or instance via the navigator unmounts the previous + * view and mounts a fresh one. */ import React, { useRef } from 'react'; @@ -30,6 +34,7 @@ export const CommcoachKeepAlive: React.FC = ({ isVisibl const mandateId = cachedMandateIdRef.current; const instanceId = cachedInstanceIdRef.current; if (!mandateId || !instanceId) return null; + const scopeKey = `${mandateId}:${instanceId}`; return (
= ({ isVisibl }} > diff --git a/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.test.tsx b/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.test.tsx new file mode 100644 index 0000000..c7a73b5 --- /dev/null +++ b/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.test.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2025 Patrick Motsch +// All rights reserved. +// +// Persistence is per (mandateId, instanceId): switching to a different mandate +// or instance must remount the editor page so its internal state (loaded +// workflow, currentWorkflowId, …) is reset and saves go to the right tenant. + +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { MemoryRouter, useNavigate } from 'react-router-dom'; + +const _mountCount = { value: 0 }; + +vi.mock('./GraphicalEditorPage', () => ({ + GraphicalEditorPage: ({ persistentMandateId, persistentInstanceId }: { persistentMandateId?: string; persistentInstanceId?: string }) => { + React.useEffect(() => { + _mountCount.value += 1; + }, []); + return
{persistentMandateId}::{persistentInstanceId}
; + }, +})); + +import { GraphicalEditorKeepAlive } from './GraphicalEditorKeepAlive'; + +let _navigateTo: ((path: string) => void) | null = null; +const _NavCapture: React.FC = () => { + _navigateTo = useNavigate(); + return null; +}; + +function _renderHarness(initialPath: string) { + return render( + + <_NavCapture /> + + , + ); +} + +function _navigate(path: string) { + act(() => { + _navigateTo?.(path); + }); +} + +describe('GraphicalEditorKeepAlive — persistence per (mandate, instance)', () => { + it('remounts the page when the mandate changes', () => { + _mountCount.value = 0; + _renderHarness('/mandates/mA/graphicalEditor/iA/editor'); + expect(_mountCount.value).toBe(1); + expect(screen.getByTestId('ge-page').textContent).toBe('mA::iA'); + + _navigate('/mandates/mB/graphicalEditor/iA/editor'); + + expect(_mountCount.value).toBe(2); + expect(screen.getByTestId('ge-page').textContent).toBe('mB::iA'); + }); + + it('remounts the page when the instance changes', () => { + _mountCount.value = 0; + _renderHarness('/mandates/mA/graphicalEditor/iA/editor'); + expect(_mountCount.value).toBe(1); + + _navigate('/mandates/mA/graphicalEditor/iZ/editor'); + + expect(_mountCount.value).toBe(2); + expect(screen.getByTestId('ge-page').textContent).toBe('mA::iZ'); + }); + + it('does NOT remount when the route stays on the same (mandate, instance)', () => { + _mountCount.value = 0; + _renderHarness('/mandates/mA/graphicalEditor/iA/editor'); + expect(_mountCount.value).toBe(1); + + _navigate('/mandates/mA/graphicalEditor/iA/editor'); + + expect(_mountCount.value).toBe(1); + }); + + it('keeps the cached page mounted (no remount) when the user navigates AWAY and BACK to the same scope', () => { + _mountCount.value = 0; + _renderHarness('/mandates/mA/graphicalEditor/iA/editor'); + expect(_mountCount.value).toBe(1); + + // Away to a non-editor route: the regex match fails, refs keep their + // previous values — the cached page must not remount. + _navigate('/admin/languages'); + expect(_mountCount.value).toBe(1); + expect(screen.getByTestId('ge-page').textContent).toBe('mA::iA'); + + // Back to the same (mandate, instance) — still no remount. + _navigate('/mandates/mA/graphicalEditor/iA/editor'); + expect(_mountCount.value).toBe(1); + }); +}); diff --git a/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.tsx b/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.tsx index 0730802..fdf5b78 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.tsx @@ -4,9 +4,16 @@ * Keeps the GraphicalEditorPage mounted across route changes so the canvas * state, SSE connections, and editor context survive navigation to ANY page * (other features, admin, settings, etc.). - * Visibility is toggled via CSS `display` instead of mount / unmount. - * Cached mandateId/instanceId are passed as props so the page does not - * depend on URL params (which disappear on non-feature routes). + * + * Persistence is scoped per `(mandateId, instanceId)`: when the user switches + * to a DIFFERENT mandate or instance via the navigator, the previous editor + * mount is discarded and a fresh page is mounted. Otherwise stale state from + * mandate A leaks into mandate B and saves end up hitting the wrong tenant + * (HTTP 404 / "not found"). + * + * Implementation: feeds the cached `(mandate, instance)` tuple into both + * `props` and `key`. React reuses the mount as long as the tuple stays + * identical and unmounts/remounts on change. */ import React, { useRef } from 'react'; @@ -34,6 +41,10 @@ export const GraphicalEditorKeepAlive: React.FC = if (!hasEverMountedRef.current) return null; + const mandateId = cachedMandateIdRef.current; + const instanceId = cachedInstanceIdRef.current; + const scopeKey = `${mandateId}:${instanceId}`; + return (
= }} >
); diff --git a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx index 2cc4b2e..8cb5615 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx @@ -22,6 +22,9 @@ import { type AutoWorkflowTemplate, type AutoTemplateScope, } from '../../../api/workflowApi'; +import { fetchAttributes } from '../../../api/attributesApi'; +import type { AttributeDefinition } from '../../../api/attributesApi'; +import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; import { useToast } from '../../../contexts/ToastContext'; import { formatUnixTimestamp } from '../../../utils/time'; import styles from '../../../pages/admin/Admin.module.css'; @@ -68,6 +71,15 @@ export const GraphicalEditorTemplatesPage: React.FC = () => { const [sharingId, setSharingId] = useState(null); const [paginationMeta, setPaginationMeta] = useState(null); + const [backendAttributes, setBackendAttributes] = useState([]); + + useEffect(() => { + fetchAttributes(request, 'Automation2WorkflowView') + .then(setBackendAttributes) + .catch((err) => { + console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err); + }); + }, [request]); const load = useCallback(async (paginationParams?: any) => { if (!instanceId) return; @@ -173,45 +185,20 @@ export const GraphicalEditorTemplatesPage: React.FC = () => { [mandateId, instanceId, navigate] ); - const columns: ColumnConfig[] = useMemo( + const _rawColumns: ColumnConfig[] = useMemo( () => [ - { key: 'label', label: t('Vorlage'), type: 'string', width: 220, sortable: true }, - { - key: 'templateScope', - label: t('Bereich'), - type: 'string', - width: 100, - formatter: (v: string) => scopeLabels[v as AutoTemplateScope] ?? v ?? '—', - }, - { - key: 'sharedReadOnly', - label: t('Freigegeben'), - type: 'boolean', - width: 100, - formatter: (v: boolean) => - v ? ( - {t('Ja')} - ) : ( - {t('Nein')} - ), - }, - { - key: 'sysCreatedBy', - label: t('Erstellt von'), - type: 'string', - width: 140, - fkSource: '/api/users/', - fkDisplayField: 'username', - }, - { - key: 'sysCreatedAt', - label: t('Erstellt'), - type: 'number', - width: 140, - formatter: (v: number) => _formatTs(v), - }, + { key: 'label', label: t('Vorlage'), width: 220, sortable: true, filterable: true }, + { key: 'templateScope', width: 100, sortable: true, filterable: true }, + { key: 'sharedReadOnly', width: 100, sortable: true, filterable: true }, + { key: 'sysCreatedBy', width: 140, sortable: true, filterable: true, displayField: 'sysCreatedByLabel' }, + { key: 'sysCreatedAt', width: 140, sortable: true, filterable: true, formatter: (v: number) => _formatTs(v) }, ], - [t, scopeLabels], + [t], + ); + + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], ); if (!instanceId) { @@ -264,6 +251,7 @@ export const GraphicalEditorTemplatesPage: React.FC = () => { filterable={true} sortable={true} selectable={true} + apiEndpoint={`/api/workflows/${instanceId}/templates`} actionButtons={[ { type: 'edit', diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx index 4d0c0ad..0f12750 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx @@ -6,7 +6,7 @@ * Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger). */ -import React, { useState, useCallback, useEffect, useRef } from 'react'; +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa'; import { usePrompt } from '../../../hooks/usePrompt'; @@ -26,6 +26,9 @@ import { type Automation2Workflow, type WorkflowFileEnvelope, } from '../../../api/workflowApi'; +import { fetchAttributes } from '../../../api/attributesApi'; +import type { AttributeDefinition } from '../../../api/attributesApi'; +import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; import { useToast } from '../../../contexts/ToastContext'; import { formatUnixTimestamp } from '../../../utils/time'; import styles from '../../../pages/admin/Admin.module.css'; @@ -64,6 +67,15 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { const [paginationMeta, setPaginationMeta] = useState(null); const [importing, setImporting] = useState(false); const importFileInputRef = useRef(null); + const [backendAttributes, setBackendAttributes] = useState([]); + + useEffect(() => { + fetchAttributes(request, 'Automation2WorkflowView') + .then(setBackendAttributes) + .catch((err) => { + console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err); + }); + }, [request]); const load = useCallback(async (paginationParams?: any) => { if (!instanceId) return; @@ -251,64 +263,51 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { [instanceId, request, showSuccess, showError, load, t], ); - const columns: ColumnConfig[] = [ - { key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true }, - { - key: 'active', - label: t('Aktiv (Spalte)'), - type: 'boolean', - width: 80, - formatter: (value: boolean) => - value !== false ? ( - Ja - ) : ( - Nein - ), - }, - { - key: 'isRunning', - label: t('läuft'), - type: 'boolean', - width: 80, - formatter: (value: boolean) => - value ? ( - {t('Ja')} - ) : ( - Nein - ), - }, + const _rawColumns: ColumnConfig[] = useMemo(() => [ + { key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true }, + { key: 'active', width: 80, sortable: true, filterable: true }, + { key: 'isRunning', width: 80, sortable: true, filterable: true }, { key: 'stuckAtNodeLabel', label: t('steht bei'), - type: 'string', width: 160, + sortable: false, + filterable: false, formatter: (value: string, row: Automation2Workflow) => row.isRunning && (value || row.stuckAtNodeId) ? value || row.stuckAtNodeId || '—' : '—', }, { - key: 'createdAt', + key: 'sysCreatedAt', label: t('Erstellt'), - type: 'number', width: 140, + sortable: true, + filterable: true, formatter: (v: number) => formatTs(v), }, { key: 'lastStartedAt', label: t('zuletzt gestartet'), - type: 'number', width: 160, + sortable: true, + filterable: true, formatter: (v: number) => formatTs(v), }, { key: 'runCount', label: t('Läufe'), - type: 'number', width: 80, + sortable: true, + filterable: true, formatter: (v: number) => (v != null ? String(v) : '0'), }, - ]; + ], [t]); + + const columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); const hookData = { refetch: load, @@ -381,6 +380,7 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => { filterable={true} sortable={true} selectable={true} + apiEndpoint={`/api/workflows/${instanceId}/workflows`} actionButtons={[ { type: 'edit', diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx index 174c50e..983cdb3 100644 --- a/src/pages/views/realestate/RealEstateParcelsView.tsx +++ b/src/pages/views/realestate/RealEstateParcelsView.tsx @@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa'; import styles from '../../admin/Admin.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; export const RealEstateParcelsView: React.FC = () => { const { t } = useLanguage(); @@ -54,17 +55,18 @@ export const RealEstateParcelsView: React.FC = () => { }, [instanceId, refetch]); const columns = useMemo(() => { - return (attributes || []).map(attr => ({ + const raw = (attributes || []).map(attr => ({ key: attr.name, label: attr.label || attr.name, - type: attr.type as 'string' | 'number' | 'date' | 'boolean', sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, width: attr.width || 150, minWidth: attr.minWidth || 100, maxWidth: attr.maxWidth || 400, + displayField: (attr as any).displayField, })); + return resolveColumnTypes(raw, attributes || []); }, [attributes]); const canCreate = permissions?.create !== 'n'; @@ -177,6 +179,7 @@ export const RealEstateParcelsView: React.FC = () => { filterable={true} sortable={true} selectable={true} + apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined} actionButtons={[ ...(canUpdate ? [ diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx index 07f425f..f531109 100644 --- a/src/pages/views/realestate/RealEstateProjectsView.tsx +++ b/src/pages/views/realestate/RealEstateProjectsView.tsx @@ -18,6 +18,7 @@ import { FaSync } from 'react-icons/fa'; import styles from '../../admin/Admin.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; export const RealEstateProjectsView: React.FC = () => { const { t } = useLanguage(); @@ -52,17 +53,18 @@ export const RealEstateProjectsView: React.FC = () => { }, [instanceId, refetch]); const columns = useMemo(() => { - return (attributes || []).map(attr => ({ + const raw = (attributes || []).map(attr => ({ key: attr.name, label: attr.label || attr.name, - type: (attr.type || 'string') as 'string' | 'number' | 'date' | 'boolean', sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, width: attr.width || 150, minWidth: attr.minWidth || 100, maxWidth: attr.maxWidth || 400, + displayField: (attr as any).displayField, })); + return resolveColumnTypes(raw, attributes || []); }, [attributes]); const canCreate = permissions?.create !== 'n'; @@ -163,6 +165,7 @@ export const RealEstateProjectsView: React.FC = () => { filterable={true} sortable={true} selectable={true} + apiEndpoint={instanceId ? `/api/realestate/${instanceId}/projects` : undefined} actionButtons={[ ...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: t('Bearbeiten') }] : []), ...(canDelete ? [{ type: 'delete' as const, title: t('Löschen'), loading: (row: RealEstateProject) => deletingItems.has(row.id) }] : []), diff --git a/src/pages/views/redmine/RedmineStatsView.tsx b/src/pages/views/redmine/RedmineStatsView.tsx index e8cbfb7..d98f1fe 100644 --- a/src/pages/views/redmine/RedmineStatsView.tsx +++ b/src/pages/views/redmine/RedmineStatsView.tsx @@ -273,6 +273,7 @@ export const RedmineStatsView: React.FC = () => { direction: 'past', defaultPresetKind: 'thisQuarter', enabledPresets: [ + 'allTime', 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', 'ytd', 'lastYear', 'last12Months', 'lastN', 'custom', ], @@ -332,8 +333,15 @@ export const RedmineStatsView: React.FC = () => { const _handleFilterChange = useCallback((filterState: ReportFilterState) => { if (filterState.periodValue) { - setDateFrom(filterState.periodValue.fromDate); - setDateTo(filterState.periodValue.toDate); + // "Alle" = no date filter. Drop the sentinel range so the backend + // aggregates over the full history instead of clamping to 1970--2999. + if (filterState.periodValue.preset.kind === 'allTime') { + setDateFrom(undefined); + setDateTo(undefined); + } else { + setDateFrom(filterState.periodValue.fromDate); + setDateTo(filterState.periodValue.toDate); + } } else if (filterState.dateRange) { setDateFrom(toIsoDate(filterState.dateRange.from)); setDateTo(toIsoDate(filterState.dateRange.to)); diff --git a/src/pages/views/teamsbot/Teamsbot.module.css b/src/pages/views/teamsbot/Teamsbot.module.css index 26afc09..57c31bd 100644 --- a/src/pages/views/teamsbot/Teamsbot.module.css +++ b/src/pages/views/teamsbot/Teamsbot.module.css @@ -416,6 +416,366 @@ height: 100%; } +/* ----- Session Layout (UDB Sidebar + Main) ------------------------------- */ + +.sessionLayout { + display: flex; + flex: 1; + min-height: 0; + gap: 1rem; +} + +.sessionMain { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + gap: 1rem; +} + +.udbSidebar { + width: 280px; + min-width: 280px; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + display: flex; + flex-direction: column; + background: var(--bg-card, #fff); + overflow: hidden; + position: relative; + transition: width 0.2s, min-width 0.2s; +} + +.udbSidebarCollapsed { + width: 36px; + min-width: 36px; +} + +.udbToggle { + position: absolute; + top: 8px; + right: 4px; + z-index: 2; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--border-color, #ddd); + border-radius: 4px; + background: var(--bg-card, #fff); + cursor: pointer; + font-size: 0.65rem; + color: var(--text-secondary, #888); + display: flex; + align-items: center; + justify-content: center; +} + +.udbToggle:hover { + background: var(--bg-hover, #f5f5f5); + color: var(--primary-color, #F25843); +} + +@media (max-width: 768px) { + .sessionLayout { + flex-direction: column; + } + .udbSidebar { + width: 100%; + min-width: 0; + max-height: 220px; + } + .udbSidebarCollapsed { + display: none; + } +} + +/* ----- Director Prompt Panel --------------------------------------------- */ + +.directorPanel { + background: var(--surface-color, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + transition: outline-color 0.15s, background 0.15s; +} + +.directorPanelDragOver { + outline: 2px dashed var(--primary-color, #F25843); + outline-offset: -4px; + background: var(--primary-dark-bg, rgba(242, 88, 67, 0.06)); +} + +.botStatusDot { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 50%; + margin-left: 0.25rem; +} + +.botStatusDotLive { + background: #15803d; + box-shadow: 0 0 0 2px rgba(21, 128, 61, 0.18); +} + +.botStatusDotIdle { + background: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.18); + animation: directorPulse 1.6s ease-in-out infinite; +} + +@keyframes directorPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +.directorAttachBtn { + border: 1px solid var(--border-color, #ddd); + background: var(--bg-card, #fff); + border-radius: 6px; + padding: 0.25rem 0.6rem; + font-size: 0.75rem; + cursor: pointer; + color: var(--text-secondary, #666); +} + +.directorAttachBtn:hover:not(:disabled) { + border-color: var(--primary-color, #F25843); + color: var(--primary-color, #F25843); +} + +.directorAttachBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.directorHint { + font-size: 0.75rem; + color: var(--text-secondary, #888); + background: var(--surface-alt, #fafafa); + padding: 0.4rem 0.6rem; + border-radius: 6px; + border: 1px dashed var(--border-color, #ddd); +} + +.directorHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + background: var(--surface-alt, #fafafa); +} + +.directorHeaderLeft { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.directorTitle { + margin: 0; + font-size: 0.9rem; + font-weight: 600; +} + +.directorBadge { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 10px; + background: var(--primary-color, #F25843); + color: #fff; + font-weight: 600; +} + +.directorBody { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1rem; +} + +.directorTextarea { + width: 100%; + min-height: 70px; + max-height: 200px; + resize: vertical; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-family: inherit; + font-size: 0.9rem; + background: var(--bg-card, #fff); + color: var(--text-primary, #333); +} + +.directorTextarea:focus { + outline: none; + border-color: var(--primary-color, #F25843); +} + +.directorRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; +} + +.directorChips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0.25rem 0; +} + +.directorChip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.5rem; + background: var(--surface-alt, #f0f4f8); + border: 1px solid var(--border-color, #ddd); + border-radius: 12px; + font-size: 0.75rem; + max-width: 180px; +} + +.directorChipName { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.directorChipRemove { + border: none; + background: transparent; + cursor: pointer; + color: var(--text-secondary, #888); + font-size: 0.85rem; + line-height: 1; + padding: 0; +} + +.directorChipRemove:hover { + color: var(--primary-color, #F25843); +} + +.directorActions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.directorMeta { + display: flex; + gap: 0.75rem; + font-size: 0.72rem; + color: var(--text-secondary, #888); +} + +.directorSubmit { + padding: 0.4rem 0.9rem; + background: var(--primary-color, #F25843); + color: #fff; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.directorSubmit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.directorModeToggle { + display: inline-flex; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + overflow: hidden; +} + +.directorModeButton { + border: none; + background: var(--bg-card, #fff); + padding: 0.25rem 0.6rem; + font-size: 0.75rem; + cursor: pointer; + color: var(--text-secondary, #666); +} + +.directorModeButtonActive { + background: var(--primary-color, #F25843); + color: #fff; +} + +.directorHistory { + border-top: 1px dashed var(--border-color, #e0e0e0); + padding: 0.5rem 1rem; + max-height: 180px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.directorHistoryItem { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.4rem 0.5rem; + border: 1px solid var(--border-color, #eee); + border-radius: 6px; + background: var(--surface-alt, #fafafa); + font-size: 0.78rem; +} + +.directorHistoryHead { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + color: var(--text-secondary, #666); + font-size: 0.7rem; +} + +.directorHistoryText { + color: var(--text-primary, #333); + white-space: pre-wrap; + word-break: break-word; +} + +.directorStatus { + font-size: 0.7rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; +} + +.directorStatusQueued { background: #e6efff; color: #1d4ed8; } +.directorStatusRunning { background: #fff7e0; color: #b45309; } +.directorStatusSucceeded { background: #e6f7ec; color: #15803d; } +.directorStatusFailed { background: #fde2e1; color: #b91c1c; } +.directorStatusConsumed { background: #eee; color: #555; } + +.directorRemoveBtn { + border: none; + background: transparent; + cursor: pointer; + color: var(--text-secondary, #888); + font-size: 0.8rem; +} + +.directorRemoveBtn:hover { + color: var(--primary-color, #F25843); +} + .sessionViewHeader { display: flex; justify-content: space-between; @@ -579,6 +939,35 @@ max-width: 720px; } +/* Tabs */ +.settingsTabs { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + margin-bottom: 1rem; +} + +.settingsTab { + padding: 0.6rem 1.1rem; + background: transparent; + color: var(--text-secondary, #666); + border: none; + border-bottom: 2px solid transparent; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease; +} + +.settingsTab:hover { + color: var(--text-color, #333); +} + +.settingsTabActive { + color: var(--primary-color, #4A90D9); + border-bottom-color: var(--primary-color, #4A90D9); +} + .settingsCard { background: var(--surface-color, #fff); border: 1px solid var(--border-color, #e0e0e0); diff --git a/src/pages/views/teamsbot/TeamsbotSessionView.tsx b/src/pages/views/teamsbot/TeamsbotSessionView.tsx index f12a2f0..72970e2 100644 --- a/src/pages/views/teamsbot/TeamsbotSessionView.tsx +++ b/src/pages/views/teamsbot/TeamsbotSessionView.tsx @@ -2,8 +2,23 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useSearchParams } from 'react-router-dom'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import * as teamsbotApi from '../../../api/teamsbotApi'; -import type { TeamsbotSession, TeamsbotTranscript, TeamsbotBotResponse, TeamsbotSSEEvent, ScreenshotInfo } from '../../../api/teamsbotApi'; +import type { + TeamsbotSession, + TeamsbotTranscript, + TeamsbotBotResponse, + TeamsbotSSEEvent, + ScreenshotInfo, + DirectorPrompt, + DirectorPromptMode, +} from '../../../api/teamsbotApi'; +import { + DIRECTOR_PROMPT_TEXT_LIMIT, + DIRECTOR_PROMPT_FILE_LIMIT, +} from '../../../api/teamsbotApi'; import { getUserDataCache } from '../../../utils/userCache'; +import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; +import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; +import { useFileContext } from '../../../contexts/FileContext'; import styles from './Teamsbot.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; @@ -41,6 +56,32 @@ export const TeamsbotSessionView: React.FC = () => { timestamp: string; }>>([]); + // Director Prompt panel state + const [directorPrompts, setDirectorPrompts] = useState([]); + const [directorText, setDirectorText] = useState(''); + const [directorMode, setDirectorMode] = useState('oneShot'); + const [directorFiles, setDirectorFiles] = useState>([]); + const [directorSubmitting, setDirectorSubmitting] = useState(false); + const [directorError, setDirectorError] = useState(null); + const [directorDragOver, setDirectorDragOver] = useState(false); + const [directorUploading, setDirectorUploading] = useState(false); + const directorDragCounterRef = useRef(0); + const directorFileInputRef = useRef(null); + + // Bot WebSocket connection state (separate from session.status: the session + // can be 'active' before the bot has actually opened its WebSocket back to + // the gateway. Director prompts can only be processed once botConnected=true.) + const [botConnected, setBotConnected] = useState(false); + + // UDB Sidebar state + const [udbCollapsed, setUdbCollapsed] = useState(false); + const [udbTab, setUdbTab] = useState('files'); + const _udbContext: UdbContext | null = instanceId + ? { instanceId, featureInstanceId: instanceId } + : null; + + const fileCtx = useFileContext(); + const transcriptEndRef = useRef(null); const eventSourceRef = useRef(null); @@ -98,14 +139,38 @@ export const TeamsbotSessionView: React.FC = () => { _loadSession(); }, [_loadSession]); - // SSE Live Stream - connect once per session, don't re-create on status changes - const sseSessionRef = useRef(null); - const sessionStatus = session?.status; + // Load director prompt history when session changes useEffect(() => { - if (!instanceId || !sessionId || !sessionStatus) return; - if (!['active', 'joining', 'pending'].includes(sessionStatus)) return; + if (!instanceId || !sessionId) return; + let cancelled = false; + teamsbotApi + .listDirectorPrompts(instanceId, sessionId) + .then((res) => { + if (!cancelled) setDirectorPrompts(res.prompts || []); + }) + .catch(() => { + if (!cancelled) setDirectorPrompts([]); + }); + return () => { + cancelled = true; + }; + }, [instanceId, sessionId]); + + // SSE Live Stream - connect once per session, don't re-create on status changes. + // We deliberately depend ONLY on (instanceId, sessionId), not on session.status, + // so transient status transitions (pending -> joining -> active) don't tear down + // and rebuild the EventSource (which used to flicker botConnected and spawn + // multiple parallel /stream connections to the gateway). + const sseSessionRef = useRef(null); + const sessionStatusRef = useRef(session?.status); + sessionStatusRef.current = session?.status; + useEffect(() => { + if (!instanceId || !sessionId) return; // Avoid reconnecting if already streaming this session if (sseSessionRef.current === sessionId && eventSourceRef.current) return; + // Don't open a stream for sessions that are known to already be terminal. + const initialStatus = sessionStatusRef.current; + if (initialStatus && !['active', 'joining', 'pending'].includes(initialStatus)) return; eventSourceRef.current?.close(); sseSessionRef.current = sessionId; @@ -200,6 +265,34 @@ export const TeamsbotSessionView: React.FC = () => { break; } + case 'botConnectionState': { + const data = sseEvent.data || {}; + setBotConnected(Boolean(data.connected)); + _dlog('BOT-WS', data.connected ? 'connected' : 'disconnected'); + break; + } + + case 'directorPrompt': { + const prompt = sseEvent.data as DirectorPrompt | undefined; + if (!prompt || !prompt.id) break; + setDirectorPrompts((prev) => { + const idx = prev.findIndex((p) => p.id === prompt.id); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], ...prompt }; + return updated; + } + return [prompt, ...prev]; + }); + break; + } + + case 'agentRun': { + const data = sseEvent.data || {}; + _dlog('AGENT', `${data.status || ''} ${data.reason || ''}`.trim()); + break; + } + case 'error': { const errData = sseEvent.data || {}; const errMsg = errData.message || t('Unbekannter Fehler'); @@ -229,8 +322,10 @@ export const TeamsbotSessionView: React.FC = () => { eventSourceRef.current = null; sseSessionRef.current = null; setIsLive(false); + setBotConnected(false); }; - }, [instanceId, sessionId, sessionStatus, _dlog, t]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [instanceId, sessionId]); // Polling fallback: refresh session data every 5s when SSE is not connected const pollRef = useRef | null>(null); @@ -274,6 +369,193 @@ export const TeamsbotSessionView: React.FC = () => { } }; + const _addDirectorFile = useCallback((fileId: string, fileName?: string) => { + setDirectorFiles((prev) => { + if (prev.some((f) => f.id === fileId)) return prev; + if (prev.length >= DIRECTOR_PROMPT_FILE_LIMIT) { + setDirectorError( + t('Maximal {n} Dateien pro Regieanweisung.', { n: String(DIRECTOR_PROMPT_FILE_LIMIT) }), + ); + return prev; + } + setDirectorError(null); + return [...prev, { id: fileId, name: fileName || fileId }]; + }); + }, [t]); + + const _handleUdbFileSelect = _addDirectorFile; + + const _removeDirectorFile = (fileId: string) => { + setDirectorFiles((prev) => prev.filter((f) => f.id !== fileId)); + }; + + const _uploadAndAttachDirectorFile = useCallback(async (file: File) => { + if (!fileCtx?.handleFileUpload) return; + setDirectorUploading(true); + setDirectorError(null); + try { + const result = await fileCtx.handleFileUpload(file); + if (result?.success) { + const data: any = (result.fileData as any)?.file || result.fileData; + const id = data?.id || (result.fileData as any)?.id; + if (id) { + _addDirectorFile(id, data?.fileName || file.name); + } else { + setDirectorError(t('Upload erfolgreich, aber keine Datei-ID erhalten.')); + } + } else { + setDirectorError(result?.error || t('Upload fehlgeschlagen.')); + } + } catch (err: any) { + setDirectorError(err?.message || t('Upload fehlgeschlagen.')); + } finally { + setDirectorUploading(false); + } + }, [fileCtx, _addDirectorFile, t]); + + const _onDirectorDragEnter = useCallback((e: React.DragEvent) => { + if ( + e.dataTransfer.types.includes('Files') || + e.dataTransfer.types.includes('application/file-id') || + e.dataTransfer.types.includes('application/file-ids') || + e.dataTransfer.types.includes('application/tree-items') + ) { + e.preventDefault(); + e.stopPropagation(); + directorDragCounterRef.current += 1; + setDirectorDragOver(true); + } + }, []); + + const _onDirectorDragOver = useCallback((e: React.DragEvent) => { + if ( + e.dataTransfer.types.includes('Files') || + e.dataTransfer.types.includes('application/file-id') || + e.dataTransfer.types.includes('application/file-ids') || + e.dataTransfer.types.includes('application/tree-items') + ) { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + } + }, []); + + const _onDirectorDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + directorDragCounterRef.current = Math.max(0, directorDragCounterRef.current - 1); + if (directorDragCounterRef.current === 0) setDirectorDragOver(false); + }, []); + + const _onDirectorDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + directorDragCounterRef.current = 0; + setDirectorDragOver(false); + + const fileIdsJson = e.dataTransfer.getData('application/file-ids'); + if (fileIdsJson) { + try { + const ids: string[] = JSON.parse(fileIdsJson); + ids.forEach((id) => _addDirectorFile(id)); + } catch { /* ignore malformed */ } + return; + } + + const singleFileId = e.dataTransfer.getData('application/file-id'); + if (singleFileId) { + const label = e.dataTransfer.getData('text/plain'); + _addDirectorFile(singleFileId, label || undefined); + return; + } + + const treeItemsJson = e.dataTransfer.getData('application/tree-items'); + if (treeItemsJson) { + try { + const items: Array<{ id: string; type: 'file' | 'folder'; name: string }> = JSON.parse(treeItemsJson); + items.filter((it) => it.type === 'file').forEach((it) => _addDirectorFile(it.id, it.name)); + } catch { /* ignore malformed */ } + return; + } + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + if (directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT) { + setDirectorError( + t('Maximal {n} Dateien pro Regieanweisung.', { n: String(DIRECTOR_PROMPT_FILE_LIMIT) }), + ); + break; + } + await _uploadAndAttachDirectorFile(file); + } + } + }, [_addDirectorFile, _uploadAndAttachDirectorFile, directorFiles.length, t]); + + const _onDirectorFileInput = useCallback(async (e: React.ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0) return; + for (const file of Array.from(e.target.files)) { + if (directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT) break; + await _uploadAndAttachDirectorFile(file); + } + e.target.value = ''; + }, [_uploadAndAttachDirectorFile, directorFiles.length]); + + const _submitDirectorPrompt = async () => { + if (!instanceId || !sessionId) return; + const trimmed = directorText.trim(); + if (!trimmed) { + setDirectorError(t('Bitte gib eine Anweisung ein.')); + return; + } + if (trimmed.length > DIRECTOR_PROMPT_TEXT_LIMIT) { + setDirectorError( + t('Text zu lang (max. {n} Zeichen).', { n: String(DIRECTOR_PROMPT_TEXT_LIMIT) }), + ); + return; + } + setDirectorSubmitting(true); + setDirectorError(null); + try { + const res = await teamsbotApi.submitDirectorPrompt(instanceId, sessionId, { + text: trimmed, + mode: directorMode, + fileIds: directorFiles.map((f) => f.id), + }); + if (res.prompt) { + setDirectorPrompts((prev) => { + const idx = prev.findIndex((p) => p.id === res.prompt.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = res.prompt; + return next; + } + return [res.prompt, ...prev]; + }); + } + setDirectorText(''); + setDirectorFiles([]); + } catch (err: any) { + setDirectorError(err?.response?.data?.detail || err?.message || t('Senden fehlgeschlagen.')); + } finally { + setDirectorSubmitting(false); + } + }; + + const _removeDirectorPrompt = async (promptId: string) => { + if (!instanceId || !sessionId) return; + try { + await teamsbotApi.deleteDirectorPrompt(instanceId, sessionId, promptId); + setDirectorPrompts((prev) => prev.filter((p) => p.id !== promptId)); + } catch (err: any) { + setDirectorError(err?.message || t('Entfernen fehlgeschlagen.')); + } + }; + + const activePersistentCount = useMemo( + () => directorPrompts.filter((p) => p.mode === 'persistent' && p.status !== 'consumed').length, + [directorPrompts], + ); + const _getSpeakerColor = (speaker: string) => { const colors = ['#4A90D9', '#D94A4A', '#4AD99A', '#D9A84A', '#9A4AD9', '#4AD9D9']; let hash = 0; @@ -341,6 +623,227 @@ export const TeamsbotSessionView: React.FC = () => { {error &&
{error}
} + {/* Layout: UDB Sidebar + Main */} +
+ {/* UDB Sidebar (Files / Sources) */} + {_udbContext && ( +
+ + {!udbCollapsed && ( + + )} +
+ )} + + {/* Main column */} +
+ + {/* Director Prompt Panel (private operator instructions) */} + {['active', 'joining', 'pending'].includes(session.status) && ( +
+ {(() => { + const sStatus = session?.status; + const isSessionLaunching = !!sStatus && ['pending', 'joining'].includes(sStatus); + const isSessionActive = sStatus === 'active'; + // Bot has joined the meeting (session active) but the WebSocket back + // to the gateway is missing -> usually means the browser-bot service + // can't reach this gateway (e.g. localhost gateway + remote bot, or + // bot behind firewall). Audio + transcripts won't flow. + const isBotUnreachable = isSessionActive && !botConnected; + const statusLabel = botConnected + ? t('Bot live') + : isBotUnreachable + ? t('Bot ist im Meeting, aber nicht mit dem Gateway verbunden') + : isSessionLaunching + ? t('Bot startet ...') + : t('Keine aktive Session'); + const statusTitle = botConnected + ? t('Bot ist live im Meeting verbunden und liefert Transkripte') + : isBotUnreachable + ? t('Der Browser-Bot hat den WebSocket nicht zurueck zum Gateway geoeffnet. Pruefe TEAMSBOT_BROWSER_BOT_URL und APP_API_URL: bei lokalem Gateway muss der Bot ebenfalls lokal laufen oder das Gateway ueber einen Tunnel erreichbar sein.') + : isSessionLaunching + ? t('Bot tritt dem Meeting bei und oeffnet die WebSocket-Verbindung ...') + : t('Es laeuft keine aktive Bot-Session'); + return ( +
+
+

{t('Regieanweisungen')}

+ + + {statusLabel} + + {activePersistentCount > 0 && ( + + {activePersistentCount} + + )} +
+
+ {t('Privat - nur fuer den Bot sichtbar')} +
+
+ ); + })()} + +
+