Compare commits
33 commits
0178de9650
...
13af1dbb05
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13af1dbb05 | ||
|
|
2a09579f45 | ||
|
|
24150f36d5 | ||
|
|
3710172e65 | ||
|
|
d0c1484136 | ||
|
|
dcd1182521 | ||
|
|
13f4574098 | ||
|
|
d814a76660 | ||
|
|
2e00f3ac44 | ||
|
|
bbd78696e6 | ||
|
|
5f71ba04d4 | ||
|
|
73a0ba151b | ||
|
|
7ab2cdff2d | ||
|
|
c431fe28bc | ||
| ec4592c056 | |||
| 5dd4741a0f | |||
| dc174f570c | |||
| 82844f0cbe | |||
| ab256bb094 | |||
| 238dd6ae16 | |||
| f09f43666a | |||
|
|
dbec57d647 | ||
|
|
4c959538ac | ||
|
|
74d0ce429a | ||
|
|
378135efca | ||
|
|
a79da7c337 | ||
|
|
e4bf486de2 | ||
|
|
7a0880e064 | ||
|
|
bae6fbc690 | ||
|
|
d743222a2b | ||
|
|
771a86e989 | ||
|
|
92f293825f | ||
|
|
d55b24ab6a |
112 changed files with 5762 additions and 1828 deletions
308
package-lock.json
generated
308
package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@xstate/react": "^5.0.0",
|
"@xstate/react": "^5.0.0",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
|
"docx-preview": "^0.3.7",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"framer-motion": "^12.7.3",
|
"framer-motion": "^12.7.3",
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
"motion": "^12.7.3",
|
"motion": "^12.7.3",
|
||||||
"pg": "^8.8.0",
|
"pg": "^8.8.0",
|
||||||
"proj4": "^2.20.2",
|
"proj4": "^2.20.2",
|
||||||
|
|
@ -34,6 +36,7 @@
|
||||||
"react-router-dom": "^7.7.1",
|
"react-router-dom": "^7.7.1",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"xstate": "^5.20.1"
|
"xstate": "^5.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1980,6 +1983,14 @@
|
||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.8.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||||
|
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xstate/react": {
|
"node_modules/@xstate/react": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@xstate/react/-/react-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@xstate/react/-/react-5.0.5.tgz",
|
||||||
|
|
@ -2035,6 +2046,14 @@
|
||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
|
|
@ -2131,6 +2150,30 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/bluebird": {
|
||||||
|
"version": "3.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||||
|
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||||
|
|
@ -2337,6 +2380,18 @@
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -2415,6 +2470,14 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -2541,6 +2604,22 @@
|
||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||||
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -2794,6 +2873,19 @@
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dingbat-to-unicode": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="
|
||||||
|
},
|
||||||
|
"node_modules/docx-preview": {
|
||||||
|
"version": "0.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz",
|
||||||
|
"integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==",
|
||||||
|
"dependencies": {
|
||||||
|
"jszip": ">=3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dom-serializer": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
|
||||||
|
|
@ -2895,6 +2987,14 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/duck": {
|
||||||
|
"version": "0.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
|
||||||
|
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
|
||||||
|
"dependencies": {
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|
@ -3608,6 +3708,14 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "12.23.9",
|
"version": "12.23.9",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.9.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.9.tgz",
|
||||||
|
|
@ -3957,6 +4065,11 @@
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||||
|
},
|
||||||
"node_modules/immer": {
|
"node_modules/immer": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
|
@ -4111,6 +4224,11 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
|
@ -4258,6 +4376,17 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jwa": {
|
"node_modules/jwa": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
|
|
@ -4318,6 +4447,14 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|
@ -4405,6 +4542,16 @@
|
||||||
"loose-envify": "cli.js"
|
"loose-envify": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lop": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
|
||||||
|
"dependencies": {
|
||||||
|
"duck": "^0.1.12",
|
||||||
|
"option": "~0.2.1",
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lower-case": {
|
"node_modules/lower-case": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||||
|
|
@ -4425,6 +4572,37 @@
|
||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mammoth": {
|
||||||
|
"version": "1.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
|
||||||
|
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.8.6",
|
||||||
|
"argparse": "~1.0.3",
|
||||||
|
"base64-js": "^1.5.1",
|
||||||
|
"bluebird": "~3.4.0",
|
||||||
|
"dingbat-to-unicode": "^1.0.1",
|
||||||
|
"jszip": "^3.7.1",
|
||||||
|
"lop": "^0.4.2",
|
||||||
|
"path-is-absolute": "^1.0.0",
|
||||||
|
"underscore": "^1.13.1",
|
||||||
|
"xmlbuilder": "^10.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mammoth": "bin/mammoth"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mammoth/node_modules/argparse": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||||
|
"dependencies": {
|
||||||
|
"sprintf-js": "~1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/markdown-table": {
|
"node_modules/markdown-table": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||||
|
|
@ -5571,6 +5749,11 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/option": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|
@ -5621,6 +5804,11 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||||
|
},
|
||||||
"node_modules/param-case": {
|
"node_modules/param-case": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||||
|
|
@ -5700,6 +5888,14 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-is-absolute": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
|
@ -5910,6 +6106,11 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||||
|
},
|
||||||
"node_modules/proj4": {
|
"node_modules/proj4": {
|
||||||
"version": "2.20.2",
|
"version": "2.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.2.tgz",
|
||||||
|
|
@ -6206,6 +6407,25 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||||
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "3.7.0",
|
"version": "3.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
|
|
@ -6522,6 +6742,11 @@
|
||||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|
@ -6673,6 +6898,22 @@
|
||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sprintf-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
|
||||||
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/state-local": {
|
"node_modules/state-local": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||||
|
|
@ -6687,6 +6928,19 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||||
|
},
|
||||||
"node_modules/stringify-entities": {
|
"node_modules/stringify-entities": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||||
|
|
@ -6901,6 +7155,11 @@
|
||||||
"typescript": ">=4.8.4 <5.9.0"
|
"typescript": ">=4.8.4 <5.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/underscore": {
|
||||||
|
"version": "1.13.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
|
||||||
|
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.14.0",
|
"version": "7.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
||||||
|
|
@ -7078,6 +7337,11 @@
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
|
@ -7251,6 +7515,22 @@
|
||||||
"integrity": "sha512-1ZUiV1FTwSiSrgWzV9KXJuOF2BVW91KY/mau04BhnmgOdroRQea7Q0s5TVqwGLm0D2tZwObd/tBYXW49sSxp3Q==",
|
"integrity": "sha512-1ZUiV1FTwSiSrgWzV9KXJuOF2BVW91KY/mau04BhnmgOdroRQea7Q0s5TVqwGLm0D2tZwObd/tBYXW49sSxp3Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|
@ -7261,6 +7541,34 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xstate": {
|
"node_modules/xstate": {
|
||||||
"version": "5.20.1",
|
"version": "5.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/xstate/-/xstate-5.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/xstate/-/xstate-5.20.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@xstate/react": "^5.0.0",
|
"@xstate/react": "^5.0.0",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
|
"docx-preview": "^0.3.7",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"framer-motion": "^12.7.3",
|
"framer-motion": "^12.7.3",
|
||||||
|
|
@ -28,6 +29,7 @@
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
"motion": "^12.7.3",
|
"motion": "^12.7.3",
|
||||||
"pg": "^8.8.0",
|
"pg": "^8.8.0",
|
||||||
"proj4": "^2.20.2",
|
"proj4": "^2.20.2",
|
||||||
|
|
@ -40,6 +42,7 @@
|
||||||
"react-router-dom": "^7.7.1",
|
"react-router-dom": "^7.7.1",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"xstate": "^5.20.1"
|
"xstate": "^5.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { LanguageProvider } from './providers/language/LanguageContext';
|
||||||
import { ToastProvider } from './contexts/ToastContext';
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
||||||
import { FileProvider } from './contexts/FileContext';
|
import { FileProvider } from './contexts/FileContext';
|
||||||
|
import { VoiceCatalogProvider } from './contexts/VoiceCatalogContext';
|
||||||
import { MainLayout } from './layouts/MainLayout';
|
import { MainLayout } from './layouts/MainLayout';
|
||||||
import { FeatureLayout } from './layouts/FeatureLayout';
|
import { FeatureLayout } from './layouts/FeatureLayout';
|
||||||
import { DashboardPage } from './pages/Dashboard';
|
import { DashboardPage } from './pages/Dashboard';
|
||||||
|
|
@ -39,7 +40,7 @@ import { GDPRPage } from './pages/GDPR';
|
||||||
import StorePage from './pages/Store';
|
import StorePage from './pages/Store';
|
||||||
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
import { FeatureViewPage } from './pages/FeatureView';
|
||||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
|
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } from './pages/admin';
|
||||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
|
|
@ -72,6 +73,7 @@ function App() {
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<VoiceCatalogProvider>
|
||||||
<WorkflowSelectionProvider>
|
<WorkflowSelectionProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -213,6 +215,7 @@ function App() {
|
||||||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
<Route path="languages" element={null} />
|
<Route path="languages" element={null} />
|
||||||
|
<Route path="database-health" element={<AdminDatabaseHealthPage />} />
|
||||||
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
||||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||||
|
|
@ -230,6 +233,7 @@ function App() {
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
</WorkflowSelectionProvider>
|
</WorkflowSelectionProvider>
|
||||||
|
</VoiceCatalogProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ export interface AuthUser {
|
||||||
roleLabels?: string[];
|
roleLabels?: string[];
|
||||||
authenticationAuthority: string;
|
authenticationAuthority: string;
|
||||||
isSysAdmin?: boolean;
|
isSysAdmin?: boolean;
|
||||||
|
isPlatformAdmin?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
InstancePermissions,
|
InstancePermissions,
|
||||||
AccessLevel,
|
AccessLevel,
|
||||||
} from '../types/mandate';
|
} from '../types/mandate';
|
||||||
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MOCK DATA (Temporär bis Backend bereit)
|
// MOCK DATA (Temporär bis Backend bereit)
|
||||||
|
|
@ -71,7 +72,8 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
|
||||||
mandates: [
|
mandates: [
|
||||||
{
|
{
|
||||||
id: 'mand-soha',
|
id: 'mand-soha',
|
||||||
name: 'Soha Treuhand',
|
name: 'soha-treuhand',
|
||||||
|
label: 'Soha Treuhand',
|
||||||
code: 'soha',
|
code: 'soha',
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
|
|
@ -119,7 +121,8 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mand-swiss',
|
id: 'mand-swiss',
|
||||||
name: 'SwissTreu',
|
name: 'swisstreu',
|
||||||
|
label: 'SwissTreu',
|
||||||
code: 'swisstreu',
|
code: 'swisstreu',
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
|
|
@ -189,7 +192,7 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
|
||||||
if (feature.code === 'chatbot') {
|
if (feature.code === 'chatbot') {
|
||||||
console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', {
|
console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', {
|
||||||
mandateId: mandate.id,
|
mandateId: mandate.id,
|
||||||
mandateName: mandate.label || mandate.name,
|
mandateName: mandateDisplayLabel(mandate),
|
||||||
featureCode: feature.code,
|
featureCode: feature.code,
|
||||||
instanceCount: feature.instances.length,
|
instanceCount: feature.instances.length,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,8 @@ export interface FolderInfo {
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
|
scope?: string;
|
||||||
|
neutralize?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchFolders(
|
export async function fetchFolders(
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,40 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
// TYPES & INTERFACES
|
// TYPES & INTERFACES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mandate (Mandant) — represents one tenant in PowerOn PORTA.
|
||||||
|
*
|
||||||
|
* Field semantics (must stay in sync with the backend `Mandate` Pydantic model):
|
||||||
|
* - `id` — UUID, immutable.
|
||||||
|
* - `name` — Kurzzeichen / slug. Globally unique, lowercase [a-z0-9] with
|
||||||
|
* hyphen-separated segments (length 2–32). Used for audit/tracking
|
||||||
|
* and stable references. Only PlatformAdmin can change it after
|
||||||
|
* creation.
|
||||||
|
* - `label` — Voller Name. Mandatory, human-readable display name shown in the
|
||||||
|
* UI. Freely changeable by a Mandate-Admin.
|
||||||
|
*/
|
||||||
export interface Mandate {
|
export interface Mandate {
|
||||||
id: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
isSystem?: boolean;
|
||||||
|
deletedAt?: number | null;
|
||||||
[key: string]: any; // Allow additional properties from backend
|
[key: string]: any; // Allow additional properties from backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Payload for creating a mandate. `label` is required, `name` is optional. */
|
||||||
|
export interface MandateCreateData {
|
||||||
|
label: string;
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for updating a mandate. Only PlatformAdmin may change `name`;
|
||||||
|
* Mandate-Admin can update `label` and other UI fields.
|
||||||
|
*/
|
||||||
export type MandateUpdateData = Partial<Omit<Mandate, 'id'>>;
|
export type MandateUpdateData = Partial<Omit<Mandate, 'id'>>;
|
||||||
|
|
||||||
export interface PaginationParams {
|
export interface PaginationParams {
|
||||||
|
|
@ -112,7 +141,7 @@ export async function updateMandate(
|
||||||
*/
|
*/
|
||||||
export async function createMandate(
|
export async function createMandate(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
mandateData: Partial<Mandate>
|
mandateData: MandateCreateData | Partial<Mandate>
|
||||||
): Promise<Mandate> {
|
): Promise<Mandate> {
|
||||||
return await request({
|
return await request({
|
||||||
url: '/api/mandates/',
|
url: '/api/mandates/',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import type { VoiceOption } from './voiceCatalogApi';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES & INTERFACES
|
// TYPES & INTERFACES
|
||||||
|
|
@ -102,18 +103,11 @@ export interface ConfigUpdateRequest {
|
||||||
debugMode?: boolean;
|
debugMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice/Language Types (from Google TTS API)
|
// Voice option type re-exported from the central voice catalog API
|
||||||
export interface VoiceLanguage {
|
// (imported above so it's also in scope for local signatures below).
|
||||||
code: string;
|
// The legacy teamsbot-specific {code,name} language type is gone — consumers
|
||||||
name: string;
|
// should use VoiceLanguage from voiceCatalogApi (catalog SSOT).
|
||||||
}
|
export type { VoiceOption };
|
||||||
|
|
||||||
export interface VoiceOption {
|
|
||||||
name: string;
|
|
||||||
languageCodes: string[];
|
|
||||||
ssmlGender: string;
|
|
||||||
naturalSampleRateHertz: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth Detection Test Types
|
// Auth Detection Test Types
|
||||||
export interface StepScreenshot {
|
export interface StepScreenshot {
|
||||||
|
|
@ -313,25 +307,19 @@ export async function testVoice(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch available TTS languages from Google Cloud.
|
* Fetch the curated voice/language catalog (single source of truth).
|
||||||
* Returns array of language codes (e.g. ["de-DE", "en-US", ...])
|
* Re-exports the central voiceCatalogApi.fetchVoiceCatalog so legacy
|
||||||
|
* teamsbot consumers stay on one import surface.
|
||||||
*/
|
*/
|
||||||
export async function fetchLanguages(): Promise<string[]> {
|
export { fetchVoiceCatalog as fetchLanguages } from './voiceCatalogApi';
|
||||||
try {
|
|
||||||
const response = await api.get('/voice-google/languages');
|
|
||||||
return response.data?.languages || [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch available TTS voices for a language from Google Cloud.
|
* Fetch available TTS voices for a language from Google Cloud.
|
||||||
*/
|
*/
|
||||||
export async function fetchVoices(languageCode: string): Promise<VoiceOption[]> {
|
export async function fetchVoices(languageCode: string): Promise<VoiceOption[]> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/voice-google/voices', {
|
const response = await api.get('/api/voice/voices', {
|
||||||
params: { languageCode },
|
params: { language: languageCode },
|
||||||
});
|
});
|
||||||
return response.data?.voices || [];
|
return response.data?.voices || [];
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ export interface User {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
|
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
|
||||||
authenticationAuthority: string;
|
authenticationAuthority: string;
|
||||||
isSysAdmin?: boolean; // System-Administrator Flag
|
isSysAdmin?: boolean; // Infrastructure/System Operator (RBAC bypass)
|
||||||
|
isPlatformAdmin?: boolean; // Cross-Mandate Governance (no RBAC bypass)
|
||||||
// mandateId ist nicht mehr Teil des User-Objekts (Multi-Tenant-Konzept)
|
// mandateId ist nicht mehr Teil des User-Objekts (Multi-Tenant-Konzept)
|
||||||
// Der Mandant-Kontext wird über Feature-Instanzen bestimmt
|
// Der Mandant-Kontext wird über Feature-Instanzen bestimmt
|
||||||
[key: string]: any; // Allow additional properties
|
[key: string]: any; // Allow additional properties
|
||||||
|
|
|
||||||
47
src/api/voiceCatalogApi.ts
Normal file
47
src/api/voiceCatalogApi.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* Voice / Language Catalog API.
|
||||||
|
*
|
||||||
|
* Single source of truth for every voice-language picker, default-voice
|
||||||
|
* lookup, and ISO ⇄ BCP-47 mapping in the frontend. Mirrors
|
||||||
|
* gateway/modules/shared/voiceCatalog.py 1:1.
|
||||||
|
*
|
||||||
|
* Hard-coded language lists or ad-hoc maps in components are forbidden —
|
||||||
|
* consume `useVoiceCatalog()` instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export interface VoiceLanguage {
|
||||||
|
bcp47: string;
|
||||||
|
iso: string;
|
||||||
|
label: string;
|
||||||
|
flag: string;
|
||||||
|
defaultVoice: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceOption {
|
||||||
|
name: string;
|
||||||
|
languageCodes: string[];
|
||||||
|
ssmlGender: string;
|
||||||
|
naturalSampleRateHertz: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogResponse {
|
||||||
|
languages: VoiceLanguage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoicesResponse {
|
||||||
|
voices: VoiceOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVoiceCatalog(): Promise<VoiceLanguage[]> {
|
||||||
|
const response = await api.get<CatalogResponse>('/api/voice/languages');
|
||||||
|
return response.data?.languages ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVoicesForLanguage(bcp47: string): Promise<VoiceOption[]> {
|
||||||
|
const response = await api.get<VoicesResponse>('/api/voice/voices', {
|
||||||
|
params: { language: bcp47 },
|
||||||
|
});
|
||||||
|
return response.data?.voices ?? [];
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,8 @@ export interface NodeType {
|
||||||
meta?: {
|
meta?: {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/** True if this node performs an LLM / AI call (credits). */
|
||||||
|
usesAi?: boolean;
|
||||||
method?: string;
|
method?: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -845,3 +845,121 @@
|
||||||
/* Popup-specific styles if needed */
|
/* Popup-specific styles if needed */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Word (docx-preview) ────────────────────────────────────────────── */
|
||||||
|
.docxContainer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background: #e5e5e5;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docxContainer * {
|
||||||
|
background-color: initial !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docxLoading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The docx-preview library creates a wrapper with .docx-wrapper containing
|
||||||
|
section elements that are sized and styled like real pages. */
|
||||||
|
.docxContainer :global(.docx-wrapper) {
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docxContainer :global(.docx-wrapper > section.docx) {
|
||||||
|
background: #fff !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||||
|
margin: 0 auto 1.5rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docxContainer :global(section.docx) * {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Excel (manual table) ───────────────────────────────────────────── */
|
||||||
|
.excelTabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excelTab {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excelTabActive {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-on-primary, #fff);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.excelSheet {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.excelTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: Calibri, "Segoe UI", Arial, sans-serif;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excelTable td,
|
||||||
|
.excelTable th {
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
padding: 2px 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excelCorner,
|
||||||
|
.excelColHeader,
|
||||||
|
.excelRowHeader {
|
||||||
|
background: var(--color-surface, #f3f4f6) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary, #4b5563);
|
||||||
|
text-align: center !important;
|
||||||
|
position: sticky;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excelColHeader {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excelRowHeader {
|
||||||
|
left: 0;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excelCorner {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excelCell {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { IoIosDownload, IoIosCopy } from 'react-icons/io';
|
import { IoIosDownload, IoIosCopy } from 'react-icons/io';
|
||||||
|
|
||||||
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';
|
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { useFileOperations } from '../../hooks/useFiles';
|
import { useFileOperations } from '../../hooks/useFiles';
|
||||||
import {
|
import {
|
||||||
JsonRenderer,
|
JsonRenderer,
|
||||||
ImageRenderer,
|
ImageRenderer,
|
||||||
TextRenderer,
|
TextRenderer,
|
||||||
PdfRenderer,
|
PdfRenderer,
|
||||||
HtmlRenderer,
|
HtmlRenderer,
|
||||||
ApplicationRenderer,
|
ApplicationRenderer,
|
||||||
UnsupportedRenderer,
|
UnsupportedRenderer,
|
||||||
LoadingRenderer,
|
LoadingRenderer,
|
||||||
ErrorRenderer
|
ErrorRenderer,
|
||||||
|
WordRenderer,
|
||||||
|
ExcelRenderer,
|
||||||
|
isWordMimeType,
|
||||||
|
isExcelMimeType,
|
||||||
} from './renderers';
|
} from './renderers';
|
||||||
import styles from './ContentPreview.module.css';
|
import styles from './ContentPreview.module.css';
|
||||||
|
|
||||||
|
|
@ -26,98 +30,91 @@ export interface ContentPreviewProps {
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContentPreview({
|
export function ContentPreview({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
fileId,
|
fileId,
|
||||||
fileName,
|
fileName,
|
||||||
mimeType
|
mimeType,
|
||||||
}: ContentPreviewProps) {
|
}: ContentPreviewProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { handleFilePreview, handleFileDownload, previewingFiles, previewError, downloadingFiles } = useFileOperations();
|
const {
|
||||||
|
handleFilePreview,
|
||||||
// Debug logging to see what data we're receiving
|
handleFileDownload,
|
||||||
useEffect(() => {
|
previewingFiles,
|
||||||
if (isOpen && import.meta.env.DEV) {
|
previewError,
|
||||||
console.log('ContentPreview received:', { fileId, fileName, mimeType });
|
downloadingFiles,
|
||||||
}
|
} = useFileOperations();
|
||||||
}, [isOpen, fileId, fileName, mimeType]);
|
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [previewContent, setPreviewContent] = useState<string | null>(null);
|
const [blob, setBlob] = useState<Blob | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [textContent, setTextContent] = useState<string | null>(null);
|
||||||
const [copySuccess, setCopySuccess] = useState<boolean>(false);
|
const [resolvedMime, setResolvedMime] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
setPreviewUrl(prev => {
|
||||||
|
if (prev) window.URL.revokeObjectURL(prev);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
setBlob(null);
|
||||||
|
setTextContent(null);
|
||||||
|
setResolvedMime(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPreview = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const result = await handleFilePreview(fileId, fileName, mimeType);
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error || t('Vorschau konnte nicht geladen werden.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewUrl(result.previewUrl ?? null);
|
||||||
|
setBlob(result.blob ?? null);
|
||||||
|
setTextContent(result.textContent ?? null);
|
||||||
|
setResolvedMime(result.mimeType ?? mimeType ?? null);
|
||||||
|
}, [cleanup, fileId, fileName, handleFilePreview, mimeType, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !fileId) {
|
||||||
|
cleanup();
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileId === 'undefined' || fileId === 'null') {
|
||||||
|
setError(t('Ungültige Datei-ID'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fileName || fileName === 'Unknown Item' || fileName === 'Unbekanntes Element') {
|
||||||
|
setError(t('Dateiname nicht verfügbar'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPreview();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen, fileId, fileName]);
|
||||||
|
|
||||||
// Clean up blob URL when component unmounts or preview changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (previewUrl) {
|
if (previewUrl) window.URL.revokeObjectURL(previewUrl);
|
||||||
window.URL.revokeObjectURL(previewUrl);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [previewUrl]);
|
}, [previewUrl]);
|
||||||
|
|
||||||
// Load preview when modal opens
|
const effectiveMime = resolvedMime ?? mimeType;
|
||||||
useEffect(() => {
|
const isPreviewing = previewingFiles.has(fileId);
|
||||||
if (isOpen && fileId) {
|
const hasError = error || previewError;
|
||||||
// Check if we have valid data
|
|
||||||
if (!fileId || fileId === 'undefined' || fileId === 'null') {
|
|
||||||
setError(t('Ungültige Datei-ID'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!fileName || fileName === 'Unknown Item' || fileName === 'Unbekanntes Element') {
|
|
||||||
setError(t('Dateiname nicht verfügbar'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadPreview();
|
|
||||||
} else {
|
|
||||||
// Clean up when modal closes
|
|
||||||
if (previewUrl) {
|
|
||||||
window.URL.revokeObjectURL(previewUrl);
|
|
||||||
setPreviewUrl(null);
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}, [isOpen, fileId, fileName, t]);
|
|
||||||
|
|
||||||
|
|
||||||
const loadPreview = async () => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
setPreviewContent(null);
|
|
||||||
const result = await handleFilePreview(fileId, fileName, mimeType);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
if (result.previewUrl) {
|
|
||||||
setPreviewUrl(result.previewUrl);
|
|
||||||
}
|
|
||||||
if (result.decodedContent) {
|
|
||||||
setPreviewContent(result.decodedContent);
|
|
||||||
}
|
|
||||||
// If it's text content but MIME type says PDF, we'll handle it in renderPreview
|
|
||||||
} else {
|
|
||||||
setError(result.error || t('Vorschau konnte nicht geladen werden.'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(t('Ein unerwarteter Fehler ist aufgetreten, während'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyContent = async () => {
|
const handleCopyContent = async () => {
|
||||||
|
if (!textContent) return;
|
||||||
try {
|
try {
|
||||||
if (previewContent) {
|
await navigator.clipboard.writeText(textContent);
|
||||||
await navigator.clipboard.writeText(previewContent);
|
setCopySuccess(true);
|
||||||
setCopySuccess(true);
|
setTimeout(() => setCopySuccess(false), 2000);
|
||||||
setTimeout(() => setCopySuccess(false), 2000);
|
|
||||||
} else {
|
|
||||||
// Fallback: try to copy from preview URL if it's a text file
|
|
||||||
if (previewUrl && mimeType?.startsWith('text/')) {
|
|
||||||
const response = await fetch(previewUrl);
|
|
||||||
const text = await response.text();
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setCopySuccess(true);
|
|
||||||
setTimeout(() => setCopySuccess(false), 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to copy content:', err);
|
console.error('Failed to copy content:', err);
|
||||||
}
|
}
|
||||||
|
|
@ -131,160 +128,123 @@ export function ContentPreview({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPreviewing = previewingFiles.has(fileId);
|
|
||||||
const hasError = error || previewError;
|
|
||||||
|
|
||||||
// Check if this is a corrupted PDF (text content instead of PDF)
|
|
||||||
const isCorruptedPdf = mimeType === 'application/pdf' && previewContent && !previewUrl;
|
|
||||||
|
|
||||||
// Create action buttons for the popup header
|
|
||||||
const actions: PopupAction[] = [
|
const actions: PopupAction[] = [
|
||||||
// Copy Content Button - only show for text-based files (exclude PDFs and images) or corrupted PDFs
|
...(textContent
|
||||||
...(mimeType !== 'application/pdf' && !mimeType?.startsWith('image/') && (mimeType?.startsWith('text/') || mimeType === 'application/json' || previewContent) ? [{
|
? [
|
||||||
label: copySuccess ? t('In die Zwischenablage kopiert') : t(''),
|
{
|
||||||
icon: copySuccess ? '✓' : <IoIosCopy />,
|
label: copySuccess ? t('In die Zwischenablage kopiert') : '',
|
||||||
onClick: handleCopyContent,
|
icon: copySuccess ? '✓' : <IoIosCopy />,
|
||||||
disabled: !previewContent && !previewUrl,
|
onClick: handleCopyContent,
|
||||||
variant: 'primary' as const
|
disabled: !textContent,
|
||||||
}] : []),
|
variant: 'primary' as const,
|
||||||
|
},
|
||||||
// Download Button - hide for corrupted PDFs
|
]
|
||||||
...(isCorruptedPdf ? [] : [{
|
: []),
|
||||||
label: String(''),
|
{
|
||||||
|
label: '',
|
||||||
icon: downloadingFiles.has(fileId) ? undefined : <IoIosDownload />,
|
icon: downloadingFiles.has(fileId) ? undefined : <IoIosDownload />,
|
||||||
onClick: handleDownloadFile,
|
onClick: handleDownloadFile,
|
||||||
disabled: downloadingFiles.has(fileId),
|
disabled: downloadingFiles.has(fileId),
|
||||||
loading: downloadingFiles.has(fileId),
|
loading: downloadingFiles.has(fileId),
|
||||||
variant: 'success' as const
|
variant: 'success' as const,
|
||||||
}])
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderPreview = () => {
|
const renderPreview = () => {
|
||||||
// Handle text content in PDF files (corrupted files) - check this first
|
if (isPreviewing) return <LoadingRenderer />;
|
||||||
if (previewContent && !previewUrl && mimeType === 'application/pdf') {
|
if (hasError) return <ErrorRenderer error={hasError} onRetry={loadPreview} />;
|
||||||
console.log('🔍 ContentPreview: Rendering corrupted PDF with text content');
|
if (!blob || !effectiveMime) return null;
|
||||||
|
|
||||||
|
if (effectiveMime === 'application/json' && textContent) {
|
||||||
|
return <JsonRenderer previewContent={textContent} fileName={fileName} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWordMimeType(effectiveMime, fileName)) {
|
||||||
return (
|
return (
|
||||||
<PdfRenderer
|
<WordRenderer
|
||||||
previewUrl={undefined}
|
blob={blob}
|
||||||
previewContent={previewContent}
|
fileName={fileName}
|
||||||
fileName={fileName}
|
mimeType={effectiveMime}
|
||||||
onError={() => setError(t('PDF-Vorschau konnte nicht geladen werden'))}
|
onError={msg => setError(msg)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!previewUrl) {
|
if (isExcelMimeType(effectiveMime, fileName)) {
|
||||||
if (isPreviewing) {
|
|
||||||
return <LoadingRenderer />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
return <ErrorRenderer error={hasError} onRetry={loadPreview} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For JSON files with decoded content, use JsonRenderer
|
|
||||||
if (previewContent && mimeType === 'application/json') {
|
|
||||||
return <JsonRenderer previewContent={previewContent} fileName={fileName} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mimeType === 'application/json') {
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.jsonContainer}>
|
<ExcelRenderer
|
||||||
<div className={styles.jsonHeader}>
|
blob={blob}
|
||||||
<span className={styles.jsonTitle}>{t('JSON-Vorschau als Fallback')}</span>
|
fileName={fileName}
|
||||||
<div className={styles.jsonHeaderRight}>
|
onError={msg => setError(msg)}
|
||||||
<span className={styles.jsonSize}>{t('Rohinhalt')}</span>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<pre className={styles.jsonPreview}>
|
|
||||||
<code className={styles.jsonCode}>
|
|
||||||
{previewContent || t('Kein Inhalt verfügbar')}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine preview type based on MIME type
|
const mimePrefix = effectiveMime.split('/')[0];
|
||||||
const mimePrefix = mimeType?.split('/')[0];
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
switch (mimePrefix) {
|
switch (mimePrefix) {
|
||||||
case 'image':
|
case 'image':
|
||||||
|
if (!previewUrl) return null;
|
||||||
return (
|
return (
|
||||||
<ImageRenderer
|
<ImageRenderer
|
||||||
previewUrl={previewUrl}
|
previewUrl={previewUrl}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
onError={() => setError(t('Bildvorschau konnte nicht geladen werden'))}
|
onError={() => setError(t('Bildvorschau konnte nicht geladen werden'))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'text':
|
case 'text':
|
||||||
// Special handling for HTML files
|
if (effectiveMime === 'text/html' && previewUrl) {
|
||||||
if (mimeType === 'text/html') {
|
|
||||||
return (
|
return (
|
||||||
<HtmlRenderer
|
<HtmlRenderer
|
||||||
previewUrl={previewUrl}
|
previewUrl={previewUrl}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
onError={() => setError(t('HTML-Vorschau konnte nicht geladen werden'))}
|
onError={() => setError(t('HTML-Vorschau konnte nicht geladen werden'))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextRenderer
|
<TextRenderer
|
||||||
previewUrl={previewUrl}
|
previewUrl={previewUrl ?? undefined}
|
||||||
fileName={fileName}
|
previewContent={textContent ?? undefined}
|
||||||
mimeType={mimeType}
|
fileName={fileName}
|
||||||
|
mimeType={effectiveMime}
|
||||||
onError={() => setError(t('Textvorschau konnte nicht geladen werden'))}
|
onError={() => setError(t('Textvorschau konnte nicht geladen werden'))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'application':
|
case 'application':
|
||||||
if (mimeType === 'application/pdf') {
|
if (effectiveMime === 'application/pdf' && previewUrl) {
|
||||||
console.log('🔍 ContentPreview passing normal PDF to PdfRenderer:', {
|
|
||||||
previewUrl,
|
|
||||||
previewContent: previewContent ? `${previewContent.substring(0, 50)}...` : null,
|
|
||||||
fileName,
|
|
||||||
mimeType
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<PdfRenderer
|
<PdfRenderer
|
||||||
previewUrl={previewUrl}
|
previewUrl={previewUrl}
|
||||||
previewContent={previewContent || undefined}
|
fileName={fileName}
|
||||||
fileName={fileName}
|
|
||||||
onError={() => setError(t('PDF-Vorschau konnte nicht geladen werden'))}
|
onError={() => setError(t('PDF-Vorschau konnte nicht geladen werden'))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (effectiveMime === 'application/html' && previewUrl) {
|
||||||
if (mimeType === 'application/html') {
|
|
||||||
return (
|
return (
|
||||||
<HtmlRenderer
|
<HtmlRenderer
|
||||||
previewUrl={previewUrl}
|
previewUrl={previewUrl}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
onError={() => setError(t('HTML-Vorschau konnte nicht geladen werden'))}
|
onError={() => setError(t('HTML-Vorschau konnte nicht geladen werden'))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ApplicationRenderer
|
<ApplicationRenderer
|
||||||
previewUrl={previewUrl}
|
previewUrl={previewUrl ?? ''}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
mimeType={mimeType}
|
mimeType={effectiveMime}
|
||||||
onError={() => setError(t('Vorschau wird für dieses Format nicht unterstützt'))}
|
onError={() =>
|
||||||
|
setError(t('Vorschau wird für dieses Format nicht unterstützt'))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <UnsupportedRenderer previewUrl={previewUrl} fileName={fileName} />;
|
return <UnsupportedRenderer previewUrl={previewUrl ?? ''} fileName={fileName} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -297,12 +257,9 @@ export function ContentPreview({
|
||||||
className={styles.contentPreviewPopup}
|
className={styles.contentPreviewPopup}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
>
|
>
|
||||||
<div className={styles.previewContainer}>
|
<div className={styles.previewContainer}>{renderPreview()}</div>
|
||||||
{renderPreview()}
|
|
||||||
</div>
|
|
||||||
</Popup>
|
</Popup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ContentPreview;
|
export default ContentPreview;
|
||||||
|
|
||||||
|
|
|
||||||
281
src/components/ContentPreview/renderers/ExcelRenderer.tsx
Normal file
281
src/components/ContentPreview/renderers/ExcelRenderer.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
|
interface ExcelRendererProps {
|
||||||
|
blob: Blob;
|
||||||
|
fileName: string;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXCEL_MIME_TYPES = new Set([
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'text/csv',
|
||||||
|
'application/csv',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isExcelMimeType(mimeType?: string, fileName?: string): boolean {
|
||||||
|
if (mimeType && EXCEL_MIME_TYPES.has(mimeType)) return true;
|
||||||
|
if (fileName && /\.(xlsx|xls|xlsm|xlsb|ods|csv)$/i.test(fileName)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderedCell {
|
||||||
|
display: string;
|
||||||
|
rawType: 'n' | 's' | 'b' | 'd' | 'e' | 'z' | string;
|
||||||
|
rowspan: number;
|
||||||
|
colspan: number;
|
||||||
|
skip: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderedSheet {
|
||||||
|
name: string;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
colWidthsPx: number[];
|
||||||
|
rowHeightsPx: (number | null)[];
|
||||||
|
cells: RenderedCell[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSheet(ws: XLSX.WorkSheet, name: string): RenderedSheet {
|
||||||
|
const ref = ws['!ref'];
|
||||||
|
if (!ref) {
|
||||||
|
return { name, cols: 0, rows: 0, colWidthsPx: [], rowHeightsPx: [], cells: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = XLSX.utils.decode_range(ref);
|
||||||
|
const rows = range.e.r - range.s.r + 1;
|
||||||
|
const cols = range.e.c - range.s.c + 1;
|
||||||
|
|
||||||
|
const cells: RenderedCell[][] = Array.from({ length: rows }, () =>
|
||||||
|
Array.from({ length: cols }, () => ({
|
||||||
|
display: '',
|
||||||
|
rawType: 'z',
|
||||||
|
rowspan: 1,
|
||||||
|
colspan: 1,
|
||||||
|
skip: false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const address = XLSX.utils.encode_cell({ r: r + range.s.r, c: c + range.s.c });
|
||||||
|
const cell = ws[address] as XLSX.CellObject | undefined;
|
||||||
|
if (!cell) continue;
|
||||||
|
const display = cell.w ?? (cell.v !== undefined && cell.v !== null ? String(cell.v) : '');
|
||||||
|
cells[r][c].display = display;
|
||||||
|
cells[r][c].rawType = cell.t ?? 'z';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const merges = ws['!merges'] ?? [];
|
||||||
|
for (const merge of merges) {
|
||||||
|
const rs = merge.s.r - range.s.r;
|
||||||
|
const cs = merge.s.c - range.s.c;
|
||||||
|
const re = merge.e.r - range.s.r;
|
||||||
|
const ce = merge.e.c - range.s.c;
|
||||||
|
if (rs < 0 || cs < 0 || re >= rows || ce >= cols) continue;
|
||||||
|
|
||||||
|
cells[rs][cs].rowspan = re - rs + 1;
|
||||||
|
cells[rs][cs].colspan = ce - cs + 1;
|
||||||
|
|
||||||
|
for (let r = rs; r <= re; r++) {
|
||||||
|
for (let c = cs; c <= ce; c++) {
|
||||||
|
if (r === rs && c === cs) continue;
|
||||||
|
cells[r][c].skip = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const colWidthsPx: number[] = [];
|
||||||
|
const colsMeta = ws['!cols'] ?? [];
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const meta = colsMeta[c + range.s.c];
|
||||||
|
if (meta?.wpx) {
|
||||||
|
colWidthsPx.push(meta.wpx);
|
||||||
|
} else if (meta?.wch) {
|
||||||
|
colWidthsPx.push(Math.round(meta.wch * 7 + 8));
|
||||||
|
} else if (meta?.width) {
|
||||||
|
colWidthsPx.push(Math.round(meta.width * 7 + 8));
|
||||||
|
} else {
|
||||||
|
colWidthsPx.push(80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowHeightsPx: (number | null)[] = [];
|
||||||
|
const rowsMeta = ws['!rows'] ?? [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const meta = rowsMeta[r + range.s.r];
|
||||||
|
if (meta?.hpx) {
|
||||||
|
rowHeightsPx.push(meta.hpx);
|
||||||
|
} else if (meta?.hpt) {
|
||||||
|
rowHeightsPx.push(Math.round((meta.hpt * 4) / 3));
|
||||||
|
} else {
|
||||||
|
rowHeightsPx.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, cols, rows, colWidthsPx, rowHeightsPx, cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignmentForCell(cell: RenderedCell): 'left' | 'right' | 'center' {
|
||||||
|
if (cell.rawType === 'n' || cell.rawType === 'd') return 'right';
|
||||||
|
if (cell.rawType === 'b') return 'center';
|
||||||
|
return 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
function colLabel(index: number): string {
|
||||||
|
let result = '';
|
||||||
|
let n = index;
|
||||||
|
do {
|
||||||
|
result = String.fromCharCode(65 + (n % 26)) + result;
|
||||||
|
n = Math.floor(n / 26) - 1;
|
||||||
|
} while (n >= 0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExcelRenderer({ blob, fileName, onError }: ExcelRendererProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [sheets, setSheets] = useState<RenderedSheet[]>([]);
|
||||||
|
const [activeSheet, setActiveSheet] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setLocalError(null);
|
||||||
|
|
||||||
|
blob
|
||||||
|
.arrayBuffer()
|
||||||
|
.then(buffer => {
|
||||||
|
const workbook = XLSX.read(buffer, {
|
||||||
|
type: 'array',
|
||||||
|
cellDates: true,
|
||||||
|
cellNF: true,
|
||||||
|
cellStyles: true,
|
||||||
|
});
|
||||||
|
const parsed = workbook.SheetNames.map(name =>
|
||||||
|
renderSheet(workbook.Sheets[name], name),
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
setSheets(parsed);
|
||||||
|
setActiveSheet(parsed[0]?.name ?? null);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const msg = err?.message ?? t('Tabelle konnte nicht gerendert werden.');
|
||||||
|
setLocalError(msg);
|
||||||
|
setLoading(false);
|
||||||
|
onError(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [blob, onError, t]);
|
||||||
|
|
||||||
|
const current = useMemo(
|
||||||
|
() => sheets.find(s => s.name === activeSheet) ?? null,
|
||||||
|
[sheets, activeSheet],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.textContainer}>
|
||||||
|
<div className={styles.textHeader}>
|
||||||
|
<span className={styles.textTitle}>{t('Tabelle wird geladen...')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localError) {
|
||||||
|
return (
|
||||||
|
<div className={styles.textContainer}>
|
||||||
|
<div className={styles.textHeader}>
|
||||||
|
<span className={styles.textTitle}>{fileName}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '1rem', color: 'var(--color-error)' }}>{localError}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.textContainer}>
|
||||||
|
<div className={styles.textHeader}>
|
||||||
|
<span className={styles.textTitle}>{fileName}</span>
|
||||||
|
{sheets.length > 1 && (
|
||||||
|
<div className={styles.excelTabs}>
|
||||||
|
{sheets.map(sheet => (
|
||||||
|
<button
|
||||||
|
key={sheet.name}
|
||||||
|
onClick={() => setActiveSheet(sheet.name)}
|
||||||
|
className={`${styles.excelTab} ${
|
||||||
|
sheet.name === activeSheet ? styles.excelTabActive : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sheet.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.excelSheet}>
|
||||||
|
{current && current.rows > 0 ? (
|
||||||
|
<table className={styles.excelTable}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: 40 }} />
|
||||||
|
{current.colWidthsPx.map((w, i) => (
|
||||||
|
<col key={i} style={{ width: w }} />
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={styles.excelCorner} />
|
||||||
|
{current.colWidthsPx.map((_, i) => (
|
||||||
|
<th key={i} className={styles.excelColHeader}>
|
||||||
|
{colLabel(i)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{current.cells.map((row, rIdx) => (
|
||||||
|
<tr
|
||||||
|
key={rIdx}
|
||||||
|
style={{
|
||||||
|
height: current.rowHeightsPx[rIdx] ?? undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<th className={styles.excelRowHeader}>{rIdx + 1}</th>
|
||||||
|
{row.map((cell, cIdx) => {
|
||||||
|
if (cell.skip) return null;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={cIdx}
|
||||||
|
rowSpan={cell.rowspan}
|
||||||
|
colSpan={cell.colspan}
|
||||||
|
className={styles.excelCell}
|
||||||
|
style={{ textAlign: alignmentForCell(cell) }}
|
||||||
|
>
|
||||||
|
{cell.display}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '1rem', color: 'var(--color-text-secondary)' }}>
|
||||||
|
{t('Dieses Arbeitsblatt ist leer.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/components/ContentPreview/renderers/WordRenderer.tsx
Normal file
110
src/components/ContentPreview/renderers/WordRenderer.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { renderAsync } from 'docx-preview';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
|
interface WordRendererProps {
|
||||||
|
blob: Blob;
|
||||||
|
fileName: string;
|
||||||
|
mimeType?: string;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_MIME_TYPES = new Set([
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isWordMimeType(mimeType?: string, fileName?: string): boolean {
|
||||||
|
if (mimeType && SUPPORTED_MIME_TYPES.has(mimeType)) return true;
|
||||||
|
if (fileName && /\.docx$/i.test(fileName)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WordRenderer({ blob, fileName, mimeType, onError }: WordRendererProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const styleRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isLegacyDoc = useMemo(
|
||||||
|
() => mimeType === 'application/msword' || /\.doc$/i.test(fileName),
|
||||||
|
[mimeType, fileName],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (isLegacyDoc) {
|
||||||
|
const msg = t(
|
||||||
|
'Das alte Word-Format (.doc) wird nicht unterstützt. Bitte konvertiere die Datei in .docx.',
|
||||||
|
);
|
||||||
|
setLocalError(msg);
|
||||||
|
setLoading(false);
|
||||||
|
onError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = bodyRef.current;
|
||||||
|
const styleContainer = styleRef.current;
|
||||||
|
if (!body || !styleContainer) return;
|
||||||
|
|
||||||
|
body.innerHTML = '';
|
||||||
|
styleContainer.innerHTML = '';
|
||||||
|
setLoading(true);
|
||||||
|
setLocalError(null);
|
||||||
|
|
||||||
|
renderAsync(blob, body, styleContainer, {
|
||||||
|
className: 'docx-preview',
|
||||||
|
inWrapper: true,
|
||||||
|
ignoreWidth: false,
|
||||||
|
ignoreHeight: false,
|
||||||
|
ignoreFonts: false,
|
||||||
|
breakPages: true,
|
||||||
|
experimental: true,
|
||||||
|
trimXmlDeclaration: true,
|
||||||
|
useBase64URL: true,
|
||||||
|
renderHeaders: true,
|
||||||
|
renderFooters: true,
|
||||||
|
renderFootnotes: true,
|
||||||
|
renderEndnotes: true,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const msg =
|
||||||
|
err?.message ?? t('Word-Dokument konnte nicht gerendert werden.');
|
||||||
|
setLocalError(msg);
|
||||||
|
setLoading(false);
|
||||||
|
onError(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [blob, isLegacyDoc, onError, t]);
|
||||||
|
|
||||||
|
if (localError) {
|
||||||
|
return (
|
||||||
|
<div className={styles.textContainer}>
|
||||||
|
<div className={styles.textHeader}>
|
||||||
|
<span className={styles.textTitle}>{fileName}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '1rem', color: 'var(--color-error)' }}>{localError}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.docxContainer}>
|
||||||
|
{loading && (
|
||||||
|
<div className={styles.docxLoading}>{t('Word-Dokument wird geladen...')}</div>
|
||||||
|
)}
|
||||||
|
<div ref={styleRef} />
|
||||||
|
<div ref={bodyRef} className={styles.docxBody} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,4 +7,6 @@ export { ApplicationRenderer } from './ApplicationRenderer';
|
||||||
export { UnsupportedRenderer } from './UnsupportedRenderer';
|
export { UnsupportedRenderer } from './UnsupportedRenderer';
|
||||||
export { LoadingRenderer } from './LoadingRenderer';
|
export { LoadingRenderer } from './LoadingRenderer';
|
||||||
export { ErrorRenderer } from './ErrorRenderer';
|
export { ErrorRenderer } from './ErrorRenderer';
|
||||||
|
export { WordRenderer, isWordMimeType } from './WordRenderer';
|
||||||
|
export { ExcelRenderer, isExcelMimeType } from './ExcelRenderer';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,18 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodeItemLabelRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.nodeItemLabel {
|
.nodeItemLabel {
|
||||||
display: block;
|
display: block;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary, #333);
|
color: var(--text-primary, #333);
|
||||||
|
|
@ -267,7 +277,7 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
/* Infinite grid: on viewport, moves with pan/zoom via inline style */
|
/* Infinite grid: on viewport, moves with pan/zoom via inline style */
|
||||||
background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px);
|
background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px);
|
||||||
background-repeat: repeat;
|
background-repeat: repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ import {
|
||||||
type AutoVersion,
|
type AutoVersion,
|
||||||
type AutoTemplateScope,
|
type AutoTemplateScope,
|
||||||
} from '../../../api/workflowApi';
|
} from '../../../api/workflowApi';
|
||||||
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
import { FlowCanvas, computeAutoLayout, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
||||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
import { NodeSidebar } from './NodeSidebar';
|
import { NodeSidebar } from './NodeSidebar';
|
||||||
import { CanvasHeader } from './CanvasHeader';
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
|
|
@ -48,6 +48,7 @@ import { Automation2DataFlowProvider } from '../context/Automation2DataFlowConte
|
||||||
import { usePrompt } from '../../../hooks/usePrompt';
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
import { EditorChatPanel } from './EditorChatPanel';
|
import { EditorChatPanel } from './EditorChatPanel';
|
||||||
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
|
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
|
||||||
|
import { EditorWorkflowChatList } from './EditorWorkflowChatList';
|
||||||
import { RunTracingPanel } from './RunTracingPanel';
|
import { RunTracingPanel } from './RunTracingPanel';
|
||||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||||
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||||
|
|
@ -114,7 +115,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
||||||
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
||||||
const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes');
|
const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes');
|
||||||
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
|
type LeftTab = UdbTab | 'ai';
|
||||||
|
const [udbTab, setUdbTab] = useState<LeftTab>('ai');
|
||||||
|
|
||||||
const udbContext: UdbContext = useMemo(() => ({
|
const udbContext: UdbContext = useMemo(() => ({
|
||||||
instanceId,
|
instanceId,
|
||||||
|
|
@ -587,6 +589,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
}
|
}
|
||||||
}, [request, instanceId]);
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const handleAutoLayout = useCallback(() => {
|
||||||
|
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
|
||||||
|
}, [canvasConnections]);
|
||||||
|
|
||||||
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
|
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
|
||||||
|
|
||||||
const renderSidebar = () => {
|
const renderSidebar = () => {
|
||||||
|
|
@ -645,19 +651,30 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
{leftPanelOpen && (<>
|
{leftPanelOpen && (<>
|
||||||
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
|
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
|
||||||
<div className={styles.rightTabBar}>
|
<div className={styles.rightTabBar}>
|
||||||
{(['chats', 'files', 'sources'] as const).map((tab) => (
|
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`}
|
className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`}
|
||||||
onClick={() => setUdbTab(tab)}
|
onClick={() => setUdbTab(tab)}
|
||||||
>
|
>
|
||||||
{{ chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]}
|
{{ ai: t('KI'), chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||||
{udbTab === 'chats' ? (
|
{/*
|
||||||
|
KI-Panel bleibt gemountet, damit der Chatverlauf beim Tab-Wechsel
|
||||||
|
(Chats / Dateien / Quellen) erhalten bleibt. Nur per CSS umblenden.
|
||||||
|
`key={currentWorkflowId}` setzt den Verlauf sauber zurück, wenn der
|
||||||
|
Nutzer einen anderen Workflow wählt.
|
||||||
|
*/}
|
||||||
|
<div style={{
|
||||||
|
display: udbTab === 'ai' ? 'flex' : 'none',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}}>
|
||||||
<EditorChatPanel
|
<EditorChatPanel
|
||||||
|
key={currentWorkflowId || '__noWorkflow__'}
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
workflowId={currentWorkflowId}
|
workflowId={currentWorkflowId}
|
||||||
onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }}
|
onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }}
|
||||||
|
|
@ -666,11 +683,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
dataSources={dataSources}
|
dataSources={dataSources}
|
||||||
featureDataSources={featureDataSources}
|
featureDataSources={featureDataSources}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
|
{udbTab === 'chats' && (
|
||||||
|
<EditorWorkflowChatList
|
||||||
|
workflows={workflows}
|
||||||
|
currentWorkflowId={currentWorkflowId}
|
||||||
|
onSelect={handleWorkflowSelect}
|
||||||
|
onNew={handleNew}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(udbTab === 'files' || udbTab === 'sources') && (
|
||||||
<UnifiedDataBar
|
<UnifiedDataBar
|
||||||
context={udbContext}
|
context={udbContext}
|
||||||
activeTab={udbTab}
|
activeTab={udbTab as UdbTab}
|
||||||
onTabChange={setUdbTab}
|
onTabChange={(tab) => setUdbTab(tab as LeftTab)}
|
||||||
hideTabs={['chats']}
|
hideTabs={['chats']}
|
||||||
onFileSelect={onFileSelect}
|
onFileSelect={onFileSelect}
|
||||||
onSourcesChanged={onSourcesChanged}
|
onSourcesChanged={onSourcesChanged}
|
||||||
|
|
@ -708,6 +735,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
templateSaving={templateSaving}
|
templateSaving={templateSaving}
|
||||||
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
||||||
onWorkflowRename={handleWorkflowRename}
|
onWorkflowRename={handleWorkflowRename}
|
||||||
|
onAutoLayout={handleAutoLayout}
|
||||||
/>
|
/>
|
||||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown } from 'react-icons/fa';
|
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa';
|
||||||
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
|
@ -34,6 +34,7 @@ interface CanvasHeaderProps {
|
||||||
templateSaving?: boolean;
|
templateSaving?: boolean;
|
||||||
onNewFromTemplate?: () => void;
|
onNewFromTemplate?: () => void;
|
||||||
onWorkflowRename?: (workflowId: string, newName: string) => void;
|
onWorkflowRename?: (workflowId: string, newName: string) => void;
|
||||||
|
onAutoLayout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
||||||
|
|
@ -68,6 +69,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
templateSaving,
|
templateSaving,
|
||||||
onNewFromTemplate,
|
onNewFromTemplate,
|
||||||
onWorkflowRename,
|
onWorkflowRename,
|
||||||
|
onAutoLayout,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const statusBadge = _getStatusBadge(t);
|
const statusBadge = _getStatusBadge(t);
|
||||||
|
|
@ -216,6 +218,19 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{onAutoLayout && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={onAutoLayout}
|
||||||
|
disabled={!hasNodes}
|
||||||
|
title={t('Knoten automatisch anordnen')}
|
||||||
|
>
|
||||||
|
<FaSitemap style={{ marginRight: '0.4rem' }} />
|
||||||
|
{t('Anordnen')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Save as template */}
|
{/* Save as template */}
|
||||||
{currentWorkflowId && onSaveAsTemplate && (
|
{currentWorkflowId && onSaveAsTemplate && (
|
||||||
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,25 @@
|
||||||
* - Files: drag & drop from FolderTree onto input area, or click in UDB
|
* - Files: drag & drop from FolderTree onto input area, or click in UDB
|
||||||
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
|
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
|
||||||
*/
|
*/
|
||||||
import React, { useState, useCallback, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { startSseStream } from '../../../utils/sseClient';
|
import { startSseStream } from '../../../utils/sseClient';
|
||||||
import { ChatMessageList } from '../../Chat';
|
import { ChatMessageList } from '../../Chat';
|
||||||
import type { ChatMessage } from '../../Chat';
|
import type { ChatMessage } from '../../Chat';
|
||||||
import { getPageIcon } from '../../../config/pageRegistry';
|
import { getPageIcon } from '../../../config/pageRegistry';
|
||||||
|
import api from '../../../api';
|
||||||
|
|
||||||
|
interface PersistedEditorChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
timestamp?: number;
|
||||||
|
sequenceNr?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistedEditorChatResponse {
|
||||||
|
chatWorkflowId: string | null;
|
||||||
|
messages: PersistedEditorChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -58,16 +72,55 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||||
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||||
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
||||||
const [treeDropOver, setTreeDropOver] = useState(false);
|
const [treeDropOver, setTreeDropOver] = useState(false);
|
||||||
|
const [stopping, setStopping] = useState(false);
|
||||||
const abortRef = useRef<(() => void) | null>(null);
|
const abortRef = useRef<(() => void) | null>(null);
|
||||||
|
const assistantIdRef = useRef<string | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const pickerRef = useRef<HTMLDivElement>(null);
|
const pickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Load persisted chat history from the backend whenever the workflow changes.
|
||||||
|
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
|
||||||
|
// returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`.
|
||||||
|
// For an unsaved workflow (workflowId == null) we just clear the panel.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workflowId) {
|
||||||
|
setMessages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const _loadHistory = async () => {
|
||||||
|
setHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get<PersistedEditorChatResponse>(
|
||||||
|
`/api/workflows/${instanceId}/${workflowId}/chat/messages`,
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
|
||||||
|
id: m.id || `persisted-${++_msgCounter}`,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
timestamp: m.timestamp ? Math.round(Number(m.timestamp) * 1000) : Date.now(),
|
||||||
|
}));
|
||||||
|
setMessages(persisted);
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.warn('EditorChatPanel: failed to load chat history', err);
|
||||||
|
setMessages([]);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_loadHistory();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [instanceId, workflowId]);
|
||||||
|
|
||||||
const _toggleDataSource = useCallback((dsId: string) => {
|
const _toggleDataSource = useCallback((dsId: string) => {
|
||||||
setAttachedDataSourceIds(prev =>
|
setAttachedDataSourceIds(prev =>
|
||||||
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
|
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
|
||||||
|
|
@ -85,9 +138,10 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
if (!workflowId || loading || !trimmed) return;
|
if (!workflowId || loading || !trimmed) return;
|
||||||
|
|
||||||
const fileIds = pendingFiles.map(f => f.fileId);
|
const fileIds = pendingFiles.map(f => f.fileId);
|
||||||
|
// Note: conversationHistory is no longer sent — the backend loads it
|
||||||
|
// server-side from the persisted ChatWorkflow (linkedWorkflowId).
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
message: trimmed,
|
message: trimmed,
|
||||||
conversationHistory: messages.map(m => ({ role: m.role, message: m.content })),
|
|
||||||
userLanguage: navigator.language?.slice(0, 2) || 'de',
|
userLanguage: navigator.language?.slice(0, 2) || 'de',
|
||||||
};
|
};
|
||||||
if (fileIds.length > 0) body.fileIds = fileIds;
|
if (fileIds.length > 0) body.fileIds = fileIds;
|
||||||
|
|
@ -106,11 +160,13 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const assistantId = `asst-${++_msgCounter}`;
|
const assistantId = `asst-${++_msgCounter}`;
|
||||||
|
assistantIdRef.current = assistantId;
|
||||||
let accumulated = '';
|
let accumulated = '';
|
||||||
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
|
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
|
||||||
|
|
||||||
|
const baseURL = api.defaults.baseURL || '';
|
||||||
const cleanup = startSseStream({
|
const cleanup = startSseStream({
|
||||||
url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`,
|
url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`,
|
||||||
body,
|
body,
|
||||||
handlers: {
|
handlers: {
|
||||||
onChunk: (event) => {
|
onChunk: (event) => {
|
||||||
|
|
@ -142,17 +198,40 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
},
|
},
|
||||||
onStopped: () => setLoading(false),
|
onStopped: () => {
|
||||||
|
setMessages(prev => prev.map(m => m.id === assistantId
|
||||||
|
? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Gestoppt.')}_` }
|
||||||
|
: m));
|
||||||
|
setLoading(false);
|
||||||
|
setStopping(false);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onConnectionError: (err) => {
|
onConnectionError: (err) => {
|
||||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m));
|
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setStopping(false);
|
||||||
},
|
},
|
||||||
onStreamEnd: () => setLoading(false),
|
onStreamEnd: () => { setLoading(false); setStopping(false); },
|
||||||
});
|
});
|
||||||
|
|
||||||
abortRef.current = cleanup;
|
abortRef.current = cleanup;
|
||||||
}, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]);
|
}, [prompt, loading, workflowId, instanceId, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]);
|
||||||
|
|
||||||
|
const _handleStop = useCallback(async () => {
|
||||||
|
if (!workflowId || stopping) return;
|
||||||
|
setStopping(true);
|
||||||
|
const assistantId = assistantIdRef.current;
|
||||||
|
if (assistantId) {
|
||||||
|
setMessages(prev => prev.map(m => m.id === assistantId
|
||||||
|
? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Stoppen…')}_` }
|
||||||
|
: m));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
abortRef.current?.();
|
||||||
|
}, [workflowId, instanceId, stopping, t]);
|
||||||
|
|
||||||
const _handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const _handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
|
@ -188,8 +267,8 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
|
||||||
<ChatMessageList
|
<ChatMessageList
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isProcessing={loading}
|
isProcessing={loading || historyLoading}
|
||||||
emptyMessage={t('Beschreiben Sie, was Sie tun möchten')}
|
emptyMessage={historyLoading ? t('Lade Verlauf…') : t('Beschreiben Sie, was Sie tun möchten')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pending files (from UDB drag/click) */}
|
{/* Pending files (from UDB drag/click) */}
|
||||||
|
|
@ -391,10 +470,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<button onClick={() => abortRef.current?.()} style={{
|
<button onClick={_handleStop} disabled={stopping} title={stopping ? t('Stoppen…') : t('Anfrage stoppen')} style={{
|
||||||
padding: '8px 14px', borderRadius: 8, border: 'none',
|
padding: '8px 14px', borderRadius: 8, border: 'none',
|
||||||
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: 12,
|
background: stopping ? '#9e9e9e' : '#f44336', color: '#fff',
|
||||||
}}>{t('Stopp')}</button>
|
cursor: stopping ? 'wait' : 'pointer', fontWeight: 600, fontSize: 12,
|
||||||
|
opacity: stopping ? 0.7 : 1,
|
||||||
|
}}>{stopping ? t('Stoppen…') : t('Stopp')}</button>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
|
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
|
||||||
padding: '8px 14px', borderRadius: 8, border: 'none',
|
padding: '8px 14px', borderRadius: 8, border: 'none',
|
||||||
|
|
|
||||||
127
src/components/FlowEditor/editor/EditorWorkflowChatList.tsx
Normal file
127
src/components/FlowEditor/editor/EditorWorkflowChatList.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
/**
|
||||||
|
* EditorWorkflowChatList
|
||||||
|
*
|
||||||
|
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated
|
||||||
|
* as one editor chat session. Lists workflows already loaded by the parent
|
||||||
|
* editor (no extra fetch), supports search and "+ Neu" to start a fresh
|
||||||
|
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
|
||||||
|
* GraphicalEditor data instead of the workspace endpoint.
|
||||||
|
*/
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import type { Automation2Workflow } from '../../../api/workflowApi';
|
||||||
|
|
||||||
|
interface EditorWorkflowChatListProps {
|
||||||
|
workflows: Automation2Workflow[];
|
||||||
|
currentWorkflowId: string | null;
|
||||||
|
onSelect: (workflowId: string | null) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatRelative(ts?: number): string {
|
||||||
|
if (!ts) return '';
|
||||||
|
const date = new Date(ts * 1000);
|
||||||
|
if (isNaN(date.getTime())) return '';
|
||||||
|
const diffMs = Date.now() - date.getTime();
|
||||||
|
const diffMin = Math.floor(diffMs / 60_000);
|
||||||
|
const diffH = Math.floor(diffMs / 3_600_000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||||
|
if (diffMin < 1) return 'gerade eben';
|
||||||
|
if (diffMin < 60) return `${diffMin}m`;
|
||||||
|
if (diffH < 24) return `${diffH}h`;
|
||||||
|
if (diffDays === 1) return 'gestern';
|
||||||
|
if (diffDays < 7) return `vor ${diffDays}d`;
|
||||||
|
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
|
||||||
|
workflows,
|
||||||
|
currentWorkflowId,
|
||||||
|
onSelect,
|
||||||
|
onNew,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const list = q
|
||||||
|
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
|
||||||
|
: [...workflows];
|
||||||
|
list.sort((a, b) => (b.lastStartedAt || b.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0));
|
||||||
|
return list;
|
||||||
|
}, [workflows, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-primary, #fff)' }}>
|
||||||
|
<div style={{ padding: '8px 10px', display: 'flex', gap: 6, borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t('Workflow suchen…')}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '6px 8px', borderRadius: 6,
|
||||||
|
border: '1px solid var(--border-color, #ddd)', fontSize: 12, outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onNew}
|
||||||
|
title={t('Neuer Workflow')}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px', borderRadius: 6, border: '1px solid var(--border-color, #ddd)',
|
||||||
|
background: 'var(--secondary-bg, #f5f5f5)', cursor: 'pointer', fontSize: 12, fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>+ {t('Neu')}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div style={{ padding: 16, fontSize: 12, color: '#999', textAlign: 'center' }}>
|
||||||
|
{workflows.length === 0
|
||||||
|
? t('Noch keine Workflows. Klicken Sie auf „+ Neu", um einen Workflow-Chat zu starten.')
|
||||||
|
: t('Keine Treffer.')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((wf) => {
|
||||||
|
const isActive = wf.id === currentWorkflowId;
|
||||||
|
const ts = wf.lastStartedAt || wf.createdAt;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={wf.id}
|
||||||
|
onClick={() => onSelect(wf.id)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px', cursor: 'pointer',
|
||||||
|
borderBottom: '1px solid var(--border-color-soft, #f0f0f0)',
|
||||||
|
background: isActive ? 'rgba(242, 88, 67, 0.08)' : 'transparent',
|
||||||
|
borderLeft: isActive ? '3px solid var(--primary-color, #F25843)' : '3px solid transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = '#f7f7f7'; }}
|
||||||
|
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500, color: '#333', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{wf.label || t('(unbenannt)')}
|
||||||
|
</span>
|
||||||
|
{wf.isRunning && (
|
||||||
|
<span title={t('läuft')} style={{
|
||||||
|
width: 8, height: 8, borderRadius: '50%', background: '#4caf50', flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 4, fontSize: 11, color: '#999' }}>
|
||||||
|
{typeof wf.runCount === 'number' && (
|
||||||
|
<span>{wf.runCount} {wf.runCount === 1 ? t('Lauf') : t('Läufe')}</span>
|
||||||
|
)}
|
||||||
|
{ts ? <span>· {_formatRelative(ts)}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditorWorkflowChatList;
|
||||||
|
|
@ -8,6 +8,7 @@ import type { NodeType } from '../../../api/workflowApi';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { AiBadge } from '../nodes/shared/AiBadge';
|
||||||
|
|
||||||
export interface CanvasNode {
|
export interface CanvasNode {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -37,6 +38,75 @@ const NODE_WIDTH = 200;
|
||||||
const NODE_HEIGHT = 72;
|
const NODE_HEIGHT = 72;
|
||||||
const HANDLE_SIZE = 12;
|
const HANDLE_SIZE = 12;
|
||||||
const HANDLE_OFFSET = HANDLE_SIZE / 2;
|
const HANDLE_OFFSET = HANDLE_SIZE / 2;
|
||||||
|
const LAYOUT_V_GAP = 80;
|
||||||
|
const LAYOUT_H_GAP = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topological-sort based auto-layout: arranges nodes top-to-bottom in layers.
|
||||||
|
* Disconnected nodes are appended as extra roots.
|
||||||
|
*/
|
||||||
|
export function computeAutoLayout(
|
||||||
|
nodes: CanvasNode[],
|
||||||
|
connections: CanvasConnection[],
|
||||||
|
): CanvasNode[] {
|
||||||
|
if (nodes.length === 0) return nodes;
|
||||||
|
|
||||||
|
const inDegree = new Map<string, number>();
|
||||||
|
const children = new Map<string, string[]>();
|
||||||
|
for (const n of nodes) {
|
||||||
|
inDegree.set(n.id, 0);
|
||||||
|
children.set(n.id, []);
|
||||||
|
}
|
||||||
|
for (const c of connections) {
|
||||||
|
inDegree.set(c.targetId, (inDegree.get(c.targetId) ?? 0) + 1);
|
||||||
|
children.get(c.sourceId)?.push(c.targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers: string[][] = [];
|
||||||
|
const layerOf = new Map<string, number>();
|
||||||
|
const queue: string[] = [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
if ((inDegree.get(n.id) ?? 0) === 0) queue.push(n.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const batch: string[] = [...queue];
|
||||||
|
queue.length = 0;
|
||||||
|
const layerIdx = layers.length;
|
||||||
|
layers.push(batch);
|
||||||
|
for (const id of batch) {
|
||||||
|
layerOf.set(id, layerIdx);
|
||||||
|
for (const childId of children.get(id) ?? []) {
|
||||||
|
const deg = (inDegree.get(childId) ?? 1) - 1;
|
||||||
|
inDegree.set(childId, deg);
|
||||||
|
if (deg === 0) queue.push(childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const placed = new Set(layerOf.keys());
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (!placed.has(n.id)) {
|
||||||
|
const layerIdx = layers.length;
|
||||||
|
layers.push([n.id]);
|
||||||
|
layerOf.set(n.id, layerIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startX = 40;
|
||||||
|
const startY = 40;
|
||||||
|
|
||||||
|
return nodes.map((n) => {
|
||||||
|
const layer = layerOf.get(n.id) ?? 0;
|
||||||
|
const siblings = layers[layer];
|
||||||
|
const idxInLayer = siblings.indexOf(n.id);
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
x: startX + idxInLayer * (NODE_WIDTH + LAYOUT_H_GAP),
|
||||||
|
y: startY + layer * (NODE_HEIGHT + LAYOUT_V_GAP),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
|
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
|
||||||
function _checkConnectionCompatibility(
|
function _checkConnectionCompatibility(
|
||||||
|
|
@ -164,26 +234,16 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
|
|
||||||
const w = NODE_WIDTH;
|
const w = NODE_WIDTH;
|
||||||
const h = NODE_HEIGHT;
|
const h = NODE_HEIGHT;
|
||||||
const centerY = node.y + h / 2;
|
const centerX = node.x + w / 2;
|
||||||
|
|
||||||
if (isOutput) {
|
if (isOutput) {
|
||||||
if (ioCount === 1) return { x: node.x + w, y: centerY, side: 'right' };
|
if (ioCount === 1) return { x: centerX, y: node.y + h, side: 'bottom' };
|
||||||
if (ioCount === 2) {
|
const step = w / (ioCount + 1);
|
||||||
return ioIndex === 0
|
return { x: node.x + step * (ioIndex + 1), y: node.y + h, side: 'bottom' };
|
||||||
? { x: node.x + w, y: node.y + h / 3, side: 'right' }
|
|
||||||
: { x: node.x + w, y: node.y + (2 * h) / 3, side: 'right' };
|
|
||||||
}
|
|
||||||
const step = h / (ioCount + 1);
|
|
||||||
return { x: node.x + w, y: node.y + step * (ioIndex + 1), side: 'right' };
|
|
||||||
} else {
|
} else {
|
||||||
if (ioCount === 1) return { x: node.x, y: centerY, side: 'left' };
|
if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' };
|
||||||
if (ioCount === 2) {
|
const step = w / (ioCount + 1);
|
||||||
return ioIndex === 0
|
return { x: node.x + step * (ioIndex + 1), y: node.y, side: 'top' };
|
||||||
? { x: node.x, y: node.y + h / 3, side: 'left' }
|
|
||||||
: { x: node.x, y: node.y + (2 * h) / 3, side: 'left' };
|
|
||||||
}
|
|
||||||
const step = h / (ioCount + 1);
|
|
||||||
return { x: node.x, y: node.y + step * (ioIndex + 1), side: 'left' };
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
|
|
@ -639,8 +699,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
if (!srcNode || !tgtNode) return null;
|
if (!srcNode || !tgtNode) return null;
|
||||||
const src = getHandlePosition(srcNode, c.sourceHandle);
|
const src = getHandlePosition(srcNode, c.sourceHandle);
|
||||||
const tgt = getHandlePosition(tgtNode, c.targetHandle);
|
const tgt = getHandlePosition(tgtNode, c.targetHandle);
|
||||||
const dx = tgt.x - src.x;
|
const dy = tgt.y - src.y;
|
||||||
const pathD = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`;
|
const pathD = `M ${src.x} ${src.y} C ${src.x} ${src.y + Math.abs(dy) / 2}, ${tgt.x} ${tgt.y - Math.abs(dy) / 2}, ${tgt.x} ${tgt.y}`;
|
||||||
const isSelected = selectedConnectionId === c.id;
|
const isSelected = selectedConnectionId === c.id;
|
||||||
const isWarning = connectionWarnings[c.id];
|
const isWarning = connectionWarnings[c.id];
|
||||||
const strokeColor = isSelected
|
const strokeColor = isSelected
|
||||||
|
|
@ -739,6 +799,12 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
handleNodeMouseDown(e, node.id);
|
handleNodeMouseDown(e, node.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{nt?.meta?.usesAi === true && (
|
||||||
|
<AiBadge
|
||||||
|
variant="canvas"
|
||||||
|
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{handles.map(({ index, isOutput }) => {
|
{handles.map(({ index, isOutput }) => {
|
||||||
const pos = getHandlePosition(node, index);
|
const pos = getHandlePosition(node, index);
|
||||||
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
|
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
|
||||||
|
|
@ -756,12 +822,12 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
key={index}
|
key={index}
|
||||||
className={styles.handleWrapper}
|
className={styles.handleWrapper}
|
||||||
style={{
|
style={{
|
||||||
left: pos.side === 'left' ? -HANDLE_OFFSET : undefined,
|
top: pos.side === 'top' ? -HANDLE_OFFSET : undefined,
|
||||||
right: pos.side === 'right' ? -HANDLE_OFFSET : undefined,
|
bottom: pos.side === 'bottom' ? -HANDLE_OFFSET : undefined,
|
||||||
top: pos.y - node.y - HANDLE_OFFSET,
|
left: pos.x - node.x - HANDLE_OFFSET,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{outputLabel && pos.side === 'right' && (
|
{outputLabel && pos.side === 'bottom' && (
|
||||||
<span className={styles.handleLabel}>{outputLabel}</span>
|
<span className={styles.handleLabel}>{outputLabel}</span>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|
@ -778,7 +844,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
: undefined)
|
: undefined)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{outputLabel && pos.side === 'left' && (
|
{outputLabel && pos.side === 'top' && (
|
||||||
<span className={styles.handleLabel}>{outputLabel}</span>
|
<span className={styles.handleLabel}>{outputLabel}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { NodeType } from '../../../api/workflowApi';
|
import type { NodeType } from '../../../api/workflowApi';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||||
import type { GetLabelFn } from '../nodes/shared/utils';
|
import type { GetLabelFn } from '../nodes/shared/utils';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
import { AiBadge } from '../nodes/shared/AiBadge';
|
||||||
|
|
||||||
interface NodeListItemProps {
|
interface NodeListItemProps {
|
||||||
node: NodeType;
|
node: NodeType;
|
||||||
|
|
@ -22,6 +24,7 @@ export const NodeListItem: React.FC<NodeListItemProps> = ({
|
||||||
getLabel,
|
getLabel,
|
||||||
getCategoryIcon: getIcon = getCategoryIcon,
|
getCategoryIcon: getIcon = getCategoryIcon,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const desc = getLabel(node.description, language);
|
const desc = getLabel(node.description, language);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -44,7 +47,15 @@ export const NodeListItem: React.FC<NodeListItemProps> = ({
|
||||||
{getIcon(node.category)}
|
{getIcon(node.category)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.nodeItemInfo}>
|
<div className={styles.nodeItemInfo}>
|
||||||
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
|
<span className={styles.nodeItemLabelRow}>
|
||||||
|
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
|
||||||
|
{node.meta?.usesAi === true && (
|
||||||
|
<AiBadge
|
||||||
|
variant="palette"
|
||||||
|
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className={styles.nodeItemDesc}>{desc}</span>
|
<span className={styles.nodeItemDesc}>{desc}</span>
|
||||||
</div>
|
</div>
|
||||||
{desc && <div className={styles.nodeItemTooltip}>{desc}</div>}
|
{desc && <div className={styles.nodeItemTooltip}>{desc}</div>}
|
||||||
|
|
|
||||||
24
src/components/FlowEditor/nodes/shared/AiBadge.module.css
Normal file
24
src/components/FlowEditor/nodes/shared/AiBadge.module.css
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.12rem 0.38rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: linear-gradient(135deg, #7c4dff 0%, #9c27b0 100%);
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeCanvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 6px;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
25
src/components/FlowEditor/nodes/shared/AiBadge.tsx
Normal file
25
src/components/FlowEditor/nodes/shared/AiBadge.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Small label for workflow nodes that consume AI credits (LLM calls).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import badgeStyles from './AiBadge.module.css';
|
||||||
|
|
||||||
|
export interface AiBadgeProps {
|
||||||
|
/** Tooltip (e.g. cost / credits hint). */
|
||||||
|
title: string;
|
||||||
|
/** Canvas nodes: fixed top-right on the node card. */
|
||||||
|
variant?: 'canvas' | 'palette';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AiBadge: React.FC<AiBadgeProps> = ({ title, variant = 'palette' }) => {
|
||||||
|
const cls =
|
||||||
|
variant === 'canvas'
|
||||||
|
? `${badgeStyles.badge} ${badgeStyles.badgeCanvas}`
|
||||||
|
: badgeStyles.badge;
|
||||||
|
return (
|
||||||
|
<span className={cls} title={title} aria-label={title}>
|
||||||
|
AI
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -12,9 +12,11 @@ export const CATEGORY_ORDER = [
|
||||||
'input',
|
'input',
|
||||||
'flow',
|
'flow',
|
||||||
'data',
|
'data',
|
||||||
|
'context',
|
||||||
'ai',
|
'ai',
|
||||||
'file',
|
'file',
|
||||||
'email',
|
'email',
|
||||||
'sharepoint',
|
'sharepoint',
|
||||||
'clickup',
|
'clickup',
|
||||||
|
'trustee',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,21 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Right zone: contains dynamic on-hover actions + always-visible stable trio.
|
||||||
|
* The stable trio (chat / scope / neutralize) sits at the right edge in a
|
||||||
|
* fixed slot order so icons never jump. Dynamic actions appear on hover
|
||||||
|
* to the left of the trio without displacing it. */
|
||||||
|
.rightZone {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: none;
|
display: none;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-left: auto;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,6 +108,26 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stableActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconSlot {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconSlot.placeholder {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.actionBtn {
|
.actionBtn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -148,25 +179,6 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scopeIcons {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rightZone {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-left: auto;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rightZone .actions {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rootActions {
|
.rootActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ export interface FolderNode {
|
||||||
isProtected?: boolean;
|
isProtected?: boolean;
|
||||||
isReadonly?: boolean;
|
isReadonly?: boolean;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
neutralize?: boolean;
|
||||||
|
scope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileNode {
|
export interface FileNode {
|
||||||
|
|
@ -75,6 +77,9 @@ export interface FolderTreeProps {
|
||||||
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||||
onScopeChange?: (fileId: string, newScope: string) => void;
|
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||||
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||||
|
onFolderScopeChange?: (folderId: string, newScope: string) => void;
|
||||||
|
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
|
||||||
|
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
||||||
|
|
@ -180,6 +185,79 @@ interface SelectionCtx {
|
||||||
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||||
onScopeChange?: (fileId: string, newScope: string) => void;
|
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||||
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||||
|
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stable trio (chat | scope | neutralize) ──────────────────────────────
|
||||||
|
* Always rendered in this order, always at the right edge of the row.
|
||||||
|
* Each slot has a fixed width so missing actions render an invisible
|
||||||
|
* placeholder — icons never jump position between rows. */
|
||||||
|
|
||||||
|
interface StableTrioProps {
|
||||||
|
scope?: string;
|
||||||
|
neutralize?: boolean;
|
||||||
|
scopeLabels: Record<string, string>;
|
||||||
|
onChat?: () => void;
|
||||||
|
onScopeChange?: (newScope: string) => void;
|
||||||
|
onNeutralizeToggle?: (newValue: boolean) => void;
|
||||||
|
chatTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _StableTrio({
|
||||||
|
scope, neutralize,
|
||||||
|
scopeLabels,
|
||||||
|
onChat, onScopeChange, onNeutralizeToggle,
|
||||||
|
chatTitle,
|
||||||
|
}: StableTrioProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const _cycleScope = (current: string | undefined) => {
|
||||||
|
const idx = _SCOPE_CYCLE.indexOf(current ?? 'personal');
|
||||||
|
return _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.stableActions}>
|
||||||
|
{/* Slot 1: Chat */}
|
||||||
|
{onChat ? (
|
||||||
|
<button
|
||||||
|
className={`${styles.actionBtn} ${styles.iconSlot}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onChat(); }}
|
||||||
|
title={chatTitle}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{'\u{1F4AC}'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\u{1F4AC}'}</span>
|
||||||
|
)}
|
||||||
|
{/* Slot 2: Scope */}
|
||||||
|
{onScopeChange && scope != null ? (
|
||||||
|
<button
|
||||||
|
className={`${styles.actionBtn} ${styles.iconSlot}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onScopeChange(_cycleScope(scope)); }}
|
||||||
|
title={`${t('Scope')}: ${scopeLabels[scope] || scope} (${t('klicken zum Wechseln')})`}
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
>
|
||||||
|
{_SCOPE_ICONS[scope] || _SCOPE_ICONS.personal}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{_SCOPE_ICONS.personal}</span>
|
||||||
|
)}
|
||||||
|
{/* Slot 3: Neutralize */}
|
||||||
|
{onNeutralizeToggle ? (
|
||||||
|
<button
|
||||||
|
className={`${styles.actionBtn} ${styles.iconSlot}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNeutralizeToggle(!neutralize); }}
|
||||||
|
title={neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
|
||||||
|
style={{ fontSize: 14, opacity: neutralize ? 1 : 0.4 }}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDD12'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\uD83D\uDD12'}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── File node (leaf) ─────────────────────────────────────────────────── */
|
/* ── File node (leaf) ─────────────────────────────────────────────────── */
|
||||||
|
|
@ -261,6 +339,11 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
||||||
)}
|
)}
|
||||||
{!renaming && (
|
{!renaming && (
|
||||||
<span className={styles.rightZone}>
|
<span className={styles.rightZone}>
|
||||||
|
{file.fileSize != null && (
|
||||||
|
<span className={styles.fileSize}>
|
||||||
|
{(file.fileSize / 1024).toFixed(0)}K
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className={styles.actions}>
|
<span className={styles.actions}>
|
||||||
{sel.onRenameFile && !multiSelected && (
|
{sel.onRenameFile && !multiSelected && (
|
||||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
|
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
|
||||||
|
|
@ -290,40 +373,15 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{file.fileSize != null && (
|
<_StableTrio
|
||||||
<span className={styles.fileSize}>
|
scope={file.scope}
|
||||||
{(file.fileSize / 1024).toFixed(0)}K
|
neutralize={file.neutralize}
|
||||||
</span>
|
scopeLabels={scopeLabels}
|
||||||
)}
|
onChat={sel.onSendToChat ? () => sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]) : undefined}
|
||||||
{file.scope != null && (
|
onScopeChange={sel.onScopeChange ? (next) => sel.onScopeChange!(file.id, next) : undefined}
|
||||||
<span className={styles.scopeIcons}>
|
onNeutralizeToggle={sel.onNeutralizeToggle ? (next) => sel.onNeutralizeToggle!(file.id, next) : undefined}
|
||||||
<button
|
chatTitle={t('In Chat senden')}
|
||||||
className={styles.actionBtn}
|
/>
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!sel.onScopeChange) return;
|
|
||||||
const idx = _SCOPE_CYCLE.indexOf(file.scope!);
|
|
||||||
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
|
|
||||||
sel.onScopeChange(file.id, next);
|
|
||||||
}}
|
|
||||||
title={`${t('Scope')}: ${scopeLabels[file.scope!] || file.scope} (${t('klicken zum Wechseln')})`}
|
|
||||||
style={{ fontSize: 14 }}
|
|
||||||
>
|
|
||||||
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={styles.actionBtn}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
|
|
||||||
}}
|
|
||||||
title={file.neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
|
|
||||||
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
|
|
||||||
>
|
|
||||||
{'\uD83D\uDD12'}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -351,6 +409,8 @@ interface TreeNodeProps {
|
||||||
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
|
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
|
||||||
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
|
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
|
||||||
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||||
|
onFolderScopeChange?: (folderId: string, newScope: string) => void;
|
||||||
|
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _TreeNode({
|
function _TreeNode({
|
||||||
|
|
@ -358,9 +418,15 @@ function _TreeNode({
|
||||||
promptFolderName,
|
promptFolderName,
|
||||||
onToggle, onSelect,
|
onToggle, onSelect,
|
||||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||||
onDownloadFolder,
|
onDownloadFolder, onFolderScopeChange, onFolderNeutralizeToggle,
|
||||||
}: TreeNodeProps) {
|
}: TreeNodeProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const scopeLabels = useMemo((): Record<string, string> => ({
|
||||||
|
personal: t('Persönlich'),
|
||||||
|
featureInstance: t('Instanz'),
|
||||||
|
mandate: t('Mandant'),
|
||||||
|
global: t('Global'),
|
||||||
|
}), [t]);
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const [renameValue, setRenameValue] = useState(node.name);
|
const [renameValue, setRenameValue] = useState(node.name);
|
||||||
const [dropOver, setDropOver] = useState(false);
|
const [dropOver, setDropOver] = useState(false);
|
||||||
|
|
@ -513,42 +579,53 @@ function _TreeNode({
|
||||||
<span className={styles.folderName} style={notEditable ? { fontWeight: 600 } : undefined}>{node.name}</span>
|
<span className={styles.folderName} style={notEditable ? { fontWeight: 600 } : undefined}>{node.name}</span>
|
||||||
)}
|
)}
|
||||||
{!isProtected && (
|
{!isProtected && (
|
||||||
<span className={styles.actions}>
|
<span className={styles.rightZone}>
|
||||||
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
<span className={styles.actions}>
|
||||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
|
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||||
<FaDownload />
|
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
|
||||||
</button>
|
<FaDownload />
|
||||||
)}
|
</button>
|
||||||
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
)}
|
||||||
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
|
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||||
<FaPlus />
|
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
|
||||||
</button>
|
<FaPlus />
|
||||||
)}
|
</button>
|
||||||
{!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
)}
|
||||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}>
|
{!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||||
<FaPen />
|
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}>
|
||||||
</button>
|
<FaPen />
|
||||||
)}
|
</button>
|
||||||
{isMultiSelected && sel.selectedItemIds.size > 1 ? (
|
)}
|
||||||
<>
|
{isMultiSelected && sel.selectedItemIds.size > 1 ? (
|
||||||
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
<>
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} ${t('Ordner löschen')}`}>
|
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
||||||
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} ${t('Ordner löschen')}`}>
|
||||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
||||||
</button>
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
||||||
)}
|
</button>
|
||||||
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
)}
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}>
|
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
||||||
<FaTrash />
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}>
|
||||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
<FaTrash />
|
||||||
</button>
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
||||||
)}
|
</button>
|
||||||
</>
|
)}
|
||||||
) : !notEditable && onDeleteFolder && (
|
</>
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
|
) : !notEditable && onDeleteFolder && (
|
||||||
<FaTrash />
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
|
||||||
</button>
|
<FaTrash />
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<_StableTrio
|
||||||
|
scope={node.scope}
|
||||||
|
neutralize={node.neutralize}
|
||||||
|
scopeLabels={scopeLabels}
|
||||||
|
onChat={(sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? () => sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]) : undefined}
|
||||||
|
onScopeChange={(onFolderScopeChange && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderScopeChange(node.id, next) : undefined}
|
||||||
|
onNeutralizeToggle={(onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderNeutralizeToggle(node.id, next) : undefined}
|
||||||
|
chatTitle={t('In Chat senden')}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -575,6 +652,8 @@ function _TreeNode({
|
||||||
onMoveFile={onMoveFile}
|
onMoveFile={onMoveFile}
|
||||||
onMoveFiles={onMoveFiles}
|
onMoveFiles={onMoveFiles}
|
||||||
onDownloadFolder={onDownloadFolder}
|
onDownloadFolder={onDownloadFolder}
|
||||||
|
onFolderScopeChange={onFolderScopeChange}
|
||||||
|
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{folderFiles.map((file) => (
|
{folderFiles.map((file) => (
|
||||||
|
|
@ -594,7 +673,7 @@ export default function FolderTree({
|
||||||
expandedIds: externalExpandedIds, onToggleExpand,
|
expandedIds: externalExpandedIds, onToggleExpand,
|
||||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||||
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
||||||
onScopeChange, onNeutralizeToggle,
|
onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat,
|
||||||
}: FolderTreeProps) {
|
}: FolderTreeProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
|
@ -735,8 +814,9 @@ export default function FolderTree({
|
||||||
onDeleteFolders,
|
onDeleteFolders,
|
||||||
onScopeChange,
|
onScopeChange,
|
||||||
onNeutralizeToggle,
|
onNeutralizeToggle,
|
||||||
|
onSendToChat,
|
||||||
};
|
};
|
||||||
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]);
|
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle, onSendToChat]);
|
||||||
|
|
||||||
// Root drop handler: items dropped on the empty area go to root (null)
|
// Root drop handler: items dropped on the empty area go to root (null)
|
||||||
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
|
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
|
@ -821,6 +901,8 @@ export default function FolderTree({
|
||||||
onMoveFile={onMoveFile}
|
onMoveFile={onMoveFile}
|
||||||
onMoveFiles={onMoveFiles}
|
onMoveFiles={onMoveFiles}
|
||||||
onDownloadFolder={onDownloadFolder}
|
onDownloadFolder={onDownloadFolder}
|
||||||
|
onFolderScopeChange={onFolderScopeChange}
|
||||||
|
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{rootFiles.map((file) => (
|
{rootFiles.map((file) => (
|
||||||
|
|
|
||||||
|
|
@ -118,15 +118,11 @@ export function DeleteActionButton<T = any>({
|
||||||
const success = await handleDelete(itemId);
|
const success = await handleDelete(itemId);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// If we used optimistic removal, don't refetch immediately
|
// Always refetch after a successful delete. The server has actually
|
||||||
// The item is already removed from UI, and refetch might bring it back
|
// removed the row, so fresh data won't bring it back — and this is
|
||||||
if (removeOptimistically) {
|
// what re-syncs pagination.totalItems (and clears any optimistic
|
||||||
// Only refetch if there was an error or if we need to sync other changes
|
// hidden-row state maintained by FormGeneratorTable).
|
||||||
// For now, we trust the optimistic removal worked
|
refetch();
|
||||||
} else {
|
|
||||||
// No optimistic removal, refetch immediately
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
onSuccess?.(row);
|
onSuccess?.(row);
|
||||||
} else {
|
} else {
|
||||||
// Refetch to restore the item in case of failure
|
// Refetch to restore the item in case of failure
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,21 @@ import {
|
||||||
getDefaultValueForType
|
getDefaultValueForType
|
||||||
} from '../../../utils/attributeTypeMapper';
|
} from '../../../utils/attributeTypeMapper';
|
||||||
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
||||||
|
import {
|
||||||
|
SLUG_HINT,
|
||||||
|
maskSlugInput,
|
||||||
|
slugify,
|
||||||
|
validateSlug,
|
||||||
|
} from '../../../utils/slugUtils';
|
||||||
|
|
||||||
|
const _isSlugType = (attrType: AttributeType | undefined): boolean => attrType === 'slug';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default source field used to auto-derive a slug in `create` mode. A specific
|
||||||
|
* attribute can override this by setting `slugSource` in its definition
|
||||||
|
* (json_schema_extra.slug_source on the backend).
|
||||||
|
*/
|
||||||
|
const _DEFAULT_SLUG_SOURCE_FIELD = 'label';
|
||||||
|
|
||||||
const isTextMultilingual = (value: any): boolean => {
|
const isTextMultilingual = (value: any): boolean => {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
||||||
|
|
@ -370,33 +385,58 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tracks slug fields that have been touched manually so we don't override them
|
||||||
|
// when the user keeps editing the source label afterwards.
|
||||||
|
const slugFieldsManuallyEdited = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// Handle field value changes
|
// Handle field value changes
|
||||||
// For timestamp fields: Convert datetime-local string to Unix timestamp (float in seconds)
|
// For timestamp fields: Convert datetime-local string to Unix timestamp (float in seconds)
|
||||||
const handleFieldChange = (fieldName: string, value: any, fieldType?: AttributeType) => {
|
const handleFieldChange = (fieldName: string, value: any, fieldType?: AttributeType) => {
|
||||||
let processedValue = value;
|
let processedValue = value;
|
||||||
|
|
||||||
// If field type is timestamp, convert datetime-local string to Unix timestamp
|
|
||||||
if (fieldType === 'timestamp' && typeof value === 'string' && value) {
|
if (fieldType === 'timestamp' && typeof value === 'string' && value) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (!isNaN(date.getTime())) {
|
if (!isNaN(date.getTime())) {
|
||||||
// Convert to Unix timestamp in seconds (float)
|
|
||||||
processedValue = date.getTime() / 1000;
|
processedValue = date.getTime() / 1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[fieldName]: processedValue
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clear error for this field when user starts typing
|
if (_isSlugType(fieldType)) {
|
||||||
if (errors[fieldName]) {
|
processedValue = maskSlugInput(String(value ?? ''));
|
||||||
setErrors(prev => {
|
slugFieldsManuallyEdited.current.add(fieldName);
|
||||||
const newErrors = { ...prev };
|
|
||||||
delete newErrors[fieldName];
|
|
||||||
return newErrors;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoFilledSlugFields = new Set<string>();
|
||||||
|
|
||||||
|
setFormData(prev => {
|
||||||
|
const next: any = { ...prev, [fieldName]: processedValue };
|
||||||
|
|
||||||
|
// Generic auto-suggest: any slug attribute can declare its source field
|
||||||
|
// via attr.slugSource (default: 'label'). When that source changes in
|
||||||
|
// create mode and the slug is still untouched, derive a suggestion.
|
||||||
|
if (mode === 'create' && !_isSlugType(fieldType)) {
|
||||||
|
const attrs = attributes ?? [];
|
||||||
|
for (const a of attrs) {
|
||||||
|
if (!_isSlugType(a.type as AttributeType)) continue;
|
||||||
|
const source = (a as any).slugSource || _DEFAULT_SLUG_SOURCE_FIELD;
|
||||||
|
if (source !== fieldName) continue;
|
||||||
|
if (slugFieldsManuallyEdited.current.has(a.name)) continue;
|
||||||
|
const sourceStr = typeof processedValue === 'string' ? processedValue : '';
|
||||||
|
if (sourceStr.trim().length === 0) continue;
|
||||||
|
next[a.name] = slugify(sourceStr);
|
||||||
|
autoFilledSlugFields.add(a.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setErrors(prev => {
|
||||||
|
if (!prev[fieldName] && autoFilledSlugFields.size === 0) return prev;
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
if (newErrors[fieldName]) delete newErrors[fieldName];
|
||||||
|
autoFilledSlugFields.forEach(n => delete newErrors[n]);
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert Unix timestamp (seconds) to datetime-local input format
|
// Convert Unix timestamp (seconds) to datetime-local input format
|
||||||
|
|
@ -509,6 +549,14 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isSlugType(attr.type as AttributeType)) {
|
||||||
|
const slugErr = validateSlug(String(value));
|
||||||
|
if (slugErr) {
|
||||||
|
newErrors[attr.name] = t(slugErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Select/Multiselect option validation
|
// Select/Multiselect option validation
|
||||||
if (isSelectType(attr.type)) {
|
if (isSelectType(attr.type)) {
|
||||||
const options = normalizeOptions(attr);
|
const options = normalizeOptions(attr);
|
||||||
|
|
@ -1019,6 +1067,38 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isSlugType(attr.type as AttributeType)) {
|
||||||
|
const slugValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
||||||
|
return (
|
||||||
|
<div className={styles.floatingLabelInput} key={attr.name}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slugValue}
|
||||||
|
inputMode="text"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
pattern="^[a-z0-9]+(-[a-z0-9]+)*$"
|
||||||
|
onChange={(e) => handleFieldChange(attr.name, e.target.value, 'slug')}
|
||||||
|
onFocus={() => handleFieldFocus(attr.name, true)}
|
||||||
|
onBlur={() => handleFieldFocus(attr.name, false)}
|
||||||
|
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
|
||||||
|
/>
|
||||||
|
<label className={getLabelClass(attr.name, slugValue)}>
|
||||||
|
{attr.label}
|
||||||
|
{attr.required && <span className={styles.required}>*</span>}
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className={styles.helperText ?? ''}
|
||||||
|
style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: '4px', display: 'block' }}
|
||||||
|
>
|
||||||
|
{t(SLUG_HINT)}
|
||||||
|
</span>
|
||||||
|
{hasError && <span className={styles.errorText}>{hasError}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Default input field (text, email, date, time, url, password, number, integer, float, timestamp)
|
// Default input field (text, email, date, time, url, password, number, integer, float, timestamp)
|
||||||
const inputType = attributeTypeToInputType(attr.type);
|
const inputType = attributeTypeToInputType(attr.type);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,8 +185,8 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
getRowDataAttributes?: (row: T, index: number) => Record<string, string>;
|
getRowDataAttributes?: (row: T, index: number) => Record<string, string>;
|
||||||
// For passing hook data to action buttons
|
// For passing hook data to action buttons
|
||||||
hookData?: any; // Contains all hook data: refetch, operations, loading states, etc.
|
hookData?: any; // Contains all hook data: refetch, operations, loading states, etc.
|
||||||
// Custom empty message when table is empty
|
// Custom empty message when table is empty (string or custom ReactNode)
|
||||||
emptyMessage?: string;
|
emptyMessage?: React.ReactNode;
|
||||||
// API endpoint for CSV export (e.g. "/api/users/"). If provided, the CSV export button is shown.
|
// API endpoint for CSV export (e.g. "/api/users/"). If provided, the CSV export button is shown.
|
||||||
apiEndpoint?: string;
|
apiEndpoint?: string;
|
||||||
// Grouping configuration
|
// Grouping configuration
|
||||||
|
|
@ -339,7 +339,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
className = '',
|
className = '',
|
||||||
getRowDataAttributes,
|
getRowDataAttributes,
|
||||||
hookData,
|
hookData: hookDataProp,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
apiEndpoint,
|
apiEndpoint,
|
||||||
groupBy,
|
groupBy,
|
||||||
|
|
@ -356,6 +356,108 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
|
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
|
||||||
const onDeleteMultiple = onDeleteMultipleProp ?? (onDelete ? (rows: T[]) => rows.forEach((r) => onDelete(r)) : undefined);
|
const onDeleteMultiple = onDeleteMultipleProp ?? (onDelete ? (rows: T[]) => rows.forEach((r) => onDelete(r)) : undefined);
|
||||||
const currentLanguage = useMemo(() => contextLanguage || 'en', [contextLanguage]);
|
const currentLanguage = useMemo(() => contextLanguage || 'en', [contextLanguage]);
|
||||||
|
|
||||||
|
// ── Optimistic row hiding + adjusted header count ───────────────────────
|
||||||
|
// We synthesize `removeOptimistically` at the FormGenerator layer so every
|
||||||
|
// page gets instant delete feedback (row hidden + "N Einträge" decremented)
|
||||||
|
// regardless of whether the underlying hook implements it.
|
||||||
|
//
|
||||||
|
// 1. `optimisticallyDeletedIds` tracks which rows are currently hidden.
|
||||||
|
// 2. `displayData` below is filtered to exclude those IDs.
|
||||||
|
// 3. `pagination.totalItems`/`totalPages` are reduced by the set size.
|
||||||
|
// 4. The set is cleared whenever a fresh `pagination` reference arrives
|
||||||
|
// from the hook (i.e. after a successful refetch establishes server
|
||||||
|
// truth).
|
||||||
|
const [optimisticallyDeletedIds, setOptimisticallyDeletedIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const previousPaginationRef = useRef<any>(hookDataProp?.pagination);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hookDataProp?.pagination !== previousPaginationRef.current) {
|
||||||
|
previousPaginationRef.current = hookDataProp?.pagination;
|
||||||
|
if (optimisticallyDeletedIds.size > 0) setOptimisticallyDeletedIds(new Set());
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hookDataProp?.pagination]);
|
||||||
|
|
||||||
|
// Snapshot of the current table state so a "naked" refetch() call (e.g.
|
||||||
|
// from DeleteActionButton) still includes page/pageSize/filters/sort/search.
|
||||||
|
// Without this, the hook issues a no-params GET which, for paginated
|
||||||
|
// endpoints, returns `pagination: null` and leaves `totalItems` stale.
|
||||||
|
const tableStateRef = useRef({
|
||||||
|
page: 1,
|
||||||
|
pageSize: pageSize,
|
||||||
|
search: '',
|
||||||
|
filters: {} as Record<string, any>,
|
||||||
|
sort: [] as Array<{ key: string; direction: 'asc' | 'desc' }>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hookData = useMemo(() => {
|
||||||
|
if (!hookDataProp) return hookDataProp;
|
||||||
|
|
||||||
|
const origRemove =
|
||||||
|
hookDataProp.removeOptimistically || hookDataProp.removeFileOptimistically;
|
||||||
|
const wrappedRemove = (id: string) => {
|
||||||
|
if (origRemove) origRemove(id);
|
||||||
|
setOptimisticallyDeletedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(String(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrappedRefetch = hookDataProp.refetch
|
||||||
|
? async (params?: any) => {
|
||||||
|
const hasPaginationInfo =
|
||||||
|
params && (params.page !== undefined || params.pageSize !== undefined);
|
||||||
|
if (hasPaginationInfo) {
|
||||||
|
return await hookDataProp.refetch(params);
|
||||||
|
}
|
||||||
|
const s = tableStateRef.current;
|
||||||
|
const finalParams: any = {
|
||||||
|
page: s.page,
|
||||||
|
pageSize: s.pageSize,
|
||||||
|
...(params || {}),
|
||||||
|
};
|
||||||
|
if (s.search && s.search.trim()) finalParams.search = s.search.trim();
|
||||||
|
const activeFilters: Record<string, any> = {};
|
||||||
|
Object.entries(s.filters).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== '') activeFilters[k] = v;
|
||||||
|
});
|
||||||
|
if (Object.keys(activeFilters).length) finalParams.filters = activeFilters;
|
||||||
|
if (s.sort.length) {
|
||||||
|
finalParams.sort = s.sort.map(sc => ({ field: sc.key, direction: sc.direction }));
|
||||||
|
}
|
||||||
|
return await hookDataProp.refetch(finalParams);
|
||||||
|
}
|
||||||
|
: hookDataProp.refetch;
|
||||||
|
|
||||||
|
const origPagination = hookDataProp.pagination;
|
||||||
|
const hasNumericTotal =
|
||||||
|
origPagination && typeof origPagination.totalItems === 'number';
|
||||||
|
const delta = -optimisticallyDeletedIds.size;
|
||||||
|
const adjustedTotalItems = hasNumericTotal
|
||||||
|
? Math.max(0, origPagination.totalItems + delta)
|
||||||
|
: undefined;
|
||||||
|
const adjustedTotalPages =
|
||||||
|
hasNumericTotal && origPagination.pageSize
|
||||||
|
? Math.max(1, Math.ceil((adjustedTotalItems ?? 0) / origPagination.pageSize))
|
||||||
|
: origPagination?.totalPages;
|
||||||
|
|
||||||
|
const adjustedPagination = hasNumericTotal
|
||||||
|
? {
|
||||||
|
...origPagination,
|
||||||
|
totalItems: adjustedTotalItems,
|
||||||
|
totalPages: adjustedTotalPages,
|
||||||
|
}
|
||||||
|
: origPagination;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...hookDataProp,
|
||||||
|
removeOptimistically: wrappedRemove,
|
||||||
|
removeFileOptimistically: wrappedRemove,
|
||||||
|
refetch: wrappedRefetch,
|
||||||
|
pagination: adjustedPagination,
|
||||||
|
};
|
||||||
|
}, [hookDataProp, optimisticallyDeletedIds]);
|
||||||
// Use provided columns from Pydantic attribute definitions
|
// Use provided columns from Pydantic attribute definitions
|
||||||
// NO AUTO-DETECTION - columns must come from backend attribute definitions
|
// NO AUTO-DETECTION - columns must come from backend attribute definitions
|
||||||
// Use a ref to cache columns so they persist across data changes (e.g., when filtering)
|
// Use a ref to cache columns so they persist across data changes (e.g., when filtering)
|
||||||
|
|
@ -429,6 +531,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
||||||
// Debounce search term for backend calls
|
// Debounce search term for backend calls
|
||||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
|
||||||
|
|
||||||
|
// Bumped by the refresh button so the backend-refetch useEffect re-runs even
|
||||||
|
// when no other state (page/filters/sort/search) changed.
|
||||||
|
const [refreshNonce, setRefreshNonce] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|
@ -440,6 +546,18 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
// Keep tableStateRef in sync so a "naked" refetch() call (from e.g.
|
||||||
|
// DeleteActionButton) can inject the current page/pageSize/filters/sort.
|
||||||
|
useEffect(() => {
|
||||||
|
tableStateRef.current = {
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: currentPageSize,
|
||||||
|
search: debouncedSearchTerm,
|
||||||
|
filters,
|
||||||
|
sort: sortConfigs,
|
||||||
|
};
|
||||||
|
}, [currentPage, currentPageSize, debouncedSearchTerm, filters, sortConfigs]);
|
||||||
|
|
||||||
// Call backend when filters/search/sort/pagination change
|
// Call backend when filters/search/sort/pagination change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -494,7 +612,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
console.error('❌ FormGeneratorTable: Backend refetch failed:', error);
|
console.error('❌ FormGeneratorTable: Backend refetch failed:', error);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [debouncedSearchTerm, filters, sortConfigs, currentPage, currentPageSize, supportsBackendPagination]);
|
}, [debouncedSearchTerm, filters, sortConfigs, currentPage, currentPageSize, supportsBackendPagination, refreshNonce]);
|
||||||
|
|
||||||
// Refs for action buttons containers to detect clicks outside
|
// Refs for action buttons containers to detect clicks outside
|
||||||
const actionButtonsRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
const actionButtonsRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
|
@ -740,9 +858,13 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return value.length > 8 ? `${value.substring(0, 8)}...` : value;
|
return value.length > 8 ? `${value.substring(0, 8)}...` : value;
|
||||||
}, [fkCache]);
|
}, [fkCache]);
|
||||||
|
|
||||||
// Data is already filtered, sorted, and paginated by the backend
|
// Data is already filtered, sorted, and paginated by the backend.
|
||||||
// No client-side processing needed
|
// Client-side only filters out rows that were just optimistically deleted
|
||||||
const displayData = data;
|
// so the UI updates instantly before the server's next refetch response.
|
||||||
|
const displayData = useMemo(() => {
|
||||||
|
if (optimisticallyDeletedIds.size === 0) return data;
|
||||||
|
return data.filter(row => !optimisticallyDeletedIds.has(String(row?.[_idField])));
|
||||||
|
}, [data, optimisticallyDeletedIds, _idField]);
|
||||||
|
|
||||||
// Grouping: Group data by groupBy field if specified
|
// Grouping: Group data by groupBy field if specified
|
||||||
const groupedData = useMemo(() => {
|
const groupedData = useMemo(() => {
|
||||||
|
|
@ -1431,62 +1553,95 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return isCheckboxType(column.type);
|
return isCheckboxType(column.type);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle inline toggle for boolean fields
|
// Always-current snapshot of `data` so a queued toggle reads the freshly
|
||||||
|
// refetched row (server truth from the previous PUT+refetch) instead of the
|
||||||
|
// stale `row` captured by React at render time.
|
||||||
|
const dataRef = useRef<T[]>(data);
|
||||||
|
useEffect(() => { dataRef.current = data; }, [data]);
|
||||||
|
|
||||||
|
// Per-row update queue: every toggle on the same row awaits the previous
|
||||||
|
// one so PUT + refetch are strictly serialized. Combined with a refetch
|
||||||
|
// after every PUT, this guarantees that the next queued PUT merges its
|
||||||
|
// payload from confirmed server state — never from an unconfirmed UI guess.
|
||||||
|
const inlineUpdateQueueRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||||
|
|
||||||
|
// Handle inline toggle for boolean fields.
|
||||||
|
//
|
||||||
|
// Design contract (no optimistic UI):
|
||||||
|
// 1. The cell shows a spinner immediately on click.
|
||||||
|
// 2. We send the PUT.
|
||||||
|
// 3. We always trigger a refetch — the table only ever displays values
|
||||||
|
// that the backend has returned.
|
||||||
|
// 4. The cell re-renders from the refetched server data.
|
||||||
|
//
|
||||||
|
// We deliberately do NOT call ``hookData.updateOptimistically`` here:
|
||||||
|
// flipping the cell client-side before the backend confirmed leads to
|
||||||
|
// (a) misleading UX (a click that silently reverts on error) and
|
||||||
|
// (b) clobber-PUTs when the user toggles a sibling cell while the previous
|
||||||
|
// change is still in flight (its payload would be merged from the
|
||||||
|
// unconfirmed local state).
|
||||||
const handleInlineToggle = useCallback(async (row: T, column: ColumnConfig, currentValue: boolean) => {
|
const handleInlineToggle = useCallback(async (row: T, column: ColumnConfig, currentValue: boolean) => {
|
||||||
if (!canInlineEdit || !isInlineEditableColumn(column)) return;
|
if (!canInlineEdit || !isInlineEditableColumn(column)) return;
|
||||||
|
|
||||||
const rowId = row[idField];
|
const rowId = row[idField];
|
||||||
const cellKey = `${rowId}-${column.key}`;
|
const cellKey = `${rowId}-${column.key}`;
|
||||||
|
|
||||||
// Check if update function is available (either from prop or hookData)
|
|
||||||
const updateFn = onInlineUpdate || hookData?.handleInlineUpdate;
|
const updateFn = onInlineUpdate || hookData?.handleInlineUpdate;
|
||||||
if (!updateFn) {
|
if (!updateFn) {
|
||||||
// Silent return - inline editing is optional, no warning needed
|
// Inline editing is optional — silently noop when no handler is wired.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark cell as updating
|
|
||||||
setUpdatingCells(prev => new Set(prev).add(cellKey));
|
setUpdatingCells(prev => new Set(prev).add(cellKey));
|
||||||
|
|
||||||
const newValue = !currentValue;
|
const newValue = !currentValue;
|
||||||
const hasOptimisticUpdate = !!hookData?.updateOptimistically;
|
|
||||||
|
const previous = inlineUpdateQueueRef.current.get(String(rowId)) || Promise.resolve();
|
||||||
// If updateOptimistically is available, use it for immediate UI feedback
|
|
||||||
if (hasOptimisticUpdate) {
|
const work: Promise<void> = previous
|
||||||
hookData.updateOptimistically(rowId, { [column.key]: newValue });
|
.catch(() => undefined)
|
||||||
}
|
.then(async () => {
|
||||||
|
try {
|
||||||
try {
|
// Re-resolve the row from the latest refetched snapshot so the
|
||||||
// Call the update function (generic - no entity-specific logic)
|
// merged payload reflects every server-confirmed change made by
|
||||||
if (onInlineUpdate) {
|
// earlier queued toggles on this row.
|
||||||
await onInlineUpdate(row, column.key, newValue);
|
const latestRow = (dataRef.current.find(
|
||||||
} else if (hookData?.handleInlineUpdate) {
|
(r: any) => String(r?.[idField]) === String(rowId),
|
||||||
// Pass row as third parameter for hooks that need to merge changes with existing data
|
) as T | undefined) ?? row;
|
||||||
await hookData.handleInlineUpdate(rowId, { [column.key]: newValue }, row);
|
|
||||||
}
|
if (onInlineUpdate) {
|
||||||
|
await onInlineUpdate(latestRow, column.key, newValue);
|
||||||
// Only refetch if we DON'T have optimistic update (to get fresh data)
|
} else if (hookData?.handleInlineUpdate) {
|
||||||
// With optimistic update, local state is already correct
|
await hookData.handleInlineUpdate(rowId, { [column.key]: newValue }, latestRow);
|
||||||
if (!hasOptimisticUpdate && hookData?.refetch) {
|
}
|
||||||
await hookData.refetch();
|
|
||||||
}
|
// Always refetch on success — the cell only ever shows backend truth.
|
||||||
} catch (error) {
|
if (hookData?.refetch) {
|
||||||
console.error('FormGeneratorTable: Inline update failed:', error);
|
await hookData.refetch();
|
||||||
// Revert optimistic update on error
|
}
|
||||||
if (hasOptimisticUpdate) {
|
} catch (error) {
|
||||||
hookData.updateOptimistically(rowId, { [column.key]: currentValue });
|
console.error('FormGeneratorTable: Inline update failed:', error);
|
||||||
}
|
// Refetch on error too: restores the row to confirmed server state
|
||||||
// Refetch to restore consistent state on error
|
// (the cell snaps back to the original value).
|
||||||
if (hookData?.refetch) {
|
if (hookData?.refetch) {
|
||||||
await hookData.refetch();
|
try { await hookData.refetch(); } catch { /* swallow */ }
|
||||||
}
|
}
|
||||||
} finally {
|
throw error;
|
||||||
// Remove cell from updating state
|
} finally {
|
||||||
setUpdatingCells(prev => {
|
setUpdatingCells(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(cellKey);
|
newSet.delete(cellKey);
|
||||||
return newSet;
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
inlineUpdateQueueRef.current.set(String(rowId), work);
|
||||||
|
try {
|
||||||
|
await work;
|
||||||
|
} finally {
|
||||||
|
if (inlineUpdateQueueRef.current.get(String(rowId)) === work) {
|
||||||
|
inlineUpdateQueueRef.current.delete(String(rowId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [canInlineEdit, isInlineEditableColumn, idField, onInlineUpdate, hookData]);
|
}, [canInlineEdit, isInlineEditableColumn, idField, onInlineUpdate, hookData]);
|
||||||
|
|
||||||
|
|
@ -1802,7 +1957,22 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}) : undefined}
|
}) : undefined}
|
||||||
onRefresh={onRefresh}
|
onRefresh={
|
||||||
|
// The refresh button resets pagination to page 1, clears any
|
||||||
|
// optimistic hidden-rows state, and bumps `refreshNonce` so the
|
||||||
|
// backend-refetch useEffect re-runs (even when no state changed).
|
||||||
|
// When the table supports backend pagination, the external
|
||||||
|
// onRefresh is skipped to avoid a double-fetch — the useEffect
|
||||||
|
// already calls hookData.refetch with the correct pagination.
|
||||||
|
onRefresh
|
||||||
|
? () => {
|
||||||
|
if (optimisticallyDeletedIds.size > 0) setOptimisticallyDeletedIds(new Set());
|
||||||
|
setCurrentPage(1);
|
||||||
|
setRefreshNonce(n => n + 1);
|
||||||
|
if (!supportsBackendPagination) onRefresh();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
searchable={searchable}
|
searchable={searchable}
|
||||||
selectable={selectable}
|
selectable={selectable}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|
|
||||||
|
|
@ -140,8 +140,8 @@ export const UserSection: React.FC = () => {
|
||||||
|
|
||||||
{/* Legal Modal */}
|
{/* Legal Modal */}
|
||||||
{showLegalModal && (
|
{showLegalModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowLegalModal(false)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2>{t('Legal notices')}</h2>
|
<h2>{t('Legal notices')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
.wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-surface, rgba(255, 255, 255, 0.6));
|
||||||
|
color: var(--color-text, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.05rem 0.5rem 0.05rem 0.15rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.95;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:hover,
|
||||||
|
.select:focus {
|
||||||
|
opacity: 1;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { FaGlobe } from 'react-icons/fa';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import styles from './LanguageSelector.module.css';
|
||||||
|
|
||||||
|
export function LanguageSelector() {
|
||||||
|
const { currentLanguage, setLanguage, availableLanguages } = useLanguage();
|
||||||
|
|
||||||
|
// Always show the selector. If the backend has not (yet) returned a list,
|
||||||
|
// fall back to a static option for the currently active language so the
|
||||||
|
// control is visible even on pre-login screens / before the codes endpoint
|
||||||
|
// resolves.
|
||||||
|
const optionList = availableLanguages.length > 0
|
||||||
|
? availableLanguages
|
||||||
|
: [{ code: currentLanguage, label: currentLanguage.toUpperCase() } as { code: string; label: string }];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<FaGlobe className={styles.icon} />
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={currentLanguage}
|
||||||
|
onChange={(e) => setLanguage(e.target.value as typeof currentLanguage)}
|
||||||
|
aria-label="Sprache / Language"
|
||||||
|
>
|
||||||
|
{optionList.map((lang) => (
|
||||||
|
<option key={lang.code} value={lang.code}>
|
||||||
|
{lang.label || lang.code.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LanguageSelector;
|
||||||
2
src/components/UiComponents/LanguageSelector/index.ts
Normal file
2
src/components/UiComponents/LanguageSelector/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { LanguageSelector } from './LanguageSelector';
|
||||||
|
export { default } from './LanguageSelector';
|
||||||
77
src/components/UiComponents/Modal/Modal.module.css
Normal file
77
src/components/UiComponents/Modal/Modal.module.css
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sizeSm { max-width: 420px; }
|
||||||
|
.sizeMd { max-width: 600px; }
|
||||||
|
.sizeLg { max-width: 880px; }
|
||||||
|
.sizeXl { max-width: 1200px; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
133
src/components/UiComponents/Modal/Modal.tsx
Normal file
133
src/components/UiComponents/Modal/Modal.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
/**
|
||||||
|
* Modal — central, consistent dialog component for the whole UI.
|
||||||
|
*
|
||||||
|
* Behavior contract (intentional, documented):
|
||||||
|
* - The dialog stays open until the user explicitly closes it via the X button
|
||||||
|
* (top-right) or an explicit Cancel/OK button rendered by the consumer.
|
||||||
|
* - Clicking on the dimmed overlay does NOT close the dialog (default: false).
|
||||||
|
* - Pressing Escape does NOT close the dialog (default: false).
|
||||||
|
* - Both behaviors can be opted-in via ``closeOnOverlayClick`` /
|
||||||
|
* ``closeOnEscape`` for the rare cases where this is desired.
|
||||||
|
*
|
||||||
|
* Layout: standard 3-row flex (header / scrollable content / optional footer).
|
||||||
|
* The component traps body scroll while open and is accessible via ``role=dialog``.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import styles from './Modal.module.css';
|
||||||
|
|
||||||
|
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
size?: ModalSize;
|
||||||
|
closeOnOverlayClick?: boolean;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
|
hideCloseButton?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _SIZE_CLASS: Record<ModalSize, string> = {
|
||||||
|
sm: styles.sizeSm,
|
||||||
|
md: styles.sizeMd,
|
||||||
|
lg: styles.sizeLg,
|
||||||
|
xl: styles.sizeXl,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Modal: React.FC<ModalProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
size = 'md',
|
||||||
|
closeOnOverlayClick = false,
|
||||||
|
closeOnEscape = false,
|
||||||
|
hideCloseButton = false,
|
||||||
|
ariaLabel,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
testId,
|
||||||
|
}) => {
|
||||||
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !closeOnEscape) return;
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
|
}, [open, closeOnEscape, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!closeOnOverlayClick) return;
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleId = title ? 'modal-title' : undefined;
|
||||||
|
|
||||||
|
const node = (
|
||||||
|
<div
|
||||||
|
className={styles.overlay}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
className={`${styles.modal} ${_SIZE_CLASS[size]} ${className ?? ''}`.trim()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-label={!title ? ariaLabel : undefined}
|
||||||
|
>
|
||||||
|
{(title || !hideCloseButton) && (
|
||||||
|
<div className={styles.header}>
|
||||||
|
{title ? (
|
||||||
|
<h2 id={titleId} className={styles.title}>{title}</h2>
|
||||||
|
) : <span aria-hidden="true" />}
|
||||||
|
{!hideCloseButton && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeButton}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`${styles.content} ${contentClassName ?? ''}`.trim()}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer && <div className={styles.footer}>{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(node, document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
3
src/components/UiComponents/Modal/index.ts
Normal file
3
src/components/UiComponents/Modal/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { Modal } from './Modal';
|
||||||
|
export type { ModalProps, ModalSize } from './Modal';
|
||||||
|
export { default } from './Modal';
|
||||||
|
|
@ -23,6 +23,8 @@ export interface PopupProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: 'small' | 'medium' | 'large' | 'fullscreen';
|
size?: 'small' | 'medium' | 'large' | 'fullscreen';
|
||||||
closable?: boolean;
|
closable?: boolean;
|
||||||
|
closeOnBackdropClick?: boolean;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
actions?: PopupAction[];
|
actions?: PopupAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +38,8 @@ export function Popup({
|
||||||
className = '',
|
className = '',
|
||||||
size = 'medium',
|
size = 'medium',
|
||||||
closable = true,
|
closable = true,
|
||||||
|
closeOnBackdropClick = false,
|
||||||
|
closeOnEscape = true,
|
||||||
actions = []
|
actions = []
|
||||||
}: PopupProps) {
|
}: PopupProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -43,7 +47,7 @@ export function Popup({
|
||||||
// Handle escape key
|
// Handle escape key
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && closable) {
|
if (e.key === 'Escape' && closable && closeOnEscape) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -58,13 +62,13 @@ export function Popup({
|
||||||
document.removeEventListener('keydown', handleEscape);
|
document.removeEventListener('keydown', handleEscape);
|
||||||
document.body.style.overflow = 'unset';
|
document.body.style.overflow = 'unset';
|
||||||
};
|
};
|
||||||
}, [isOpen, closable, onClose]);
|
}, [isOpen, closable, closeOnEscape, onClose]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
// Handle backdrop click
|
// Handle backdrop click
|
||||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
if (e.target === e.currentTarget && closable) {
|
if (e.target === e.currentTarget && closable && closeOnBackdropClick) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,29 @@
|
||||||
/**
|
/**
|
||||||
* VoiceLanguageSelect
|
* VoiceLanguageSelect
|
||||||
*
|
*
|
||||||
* Reusable component for selecting voice/speech recognition language.
|
* Reusable picker for voice/speech-recognition language. Reads the language
|
||||||
* Defaults to user's profile language.
|
* list from the central VoiceCatalog (single source of truth) — never
|
||||||
* Can be used for speech-to-text, text-to-speech, and translation features.
|
* hard-coded here.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { useVoiceCatalog, useDefaultVoiceLocale } from '../../../contexts/VoiceCatalogContext';
|
||||||
|
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
|
||||||
import styles from './VoiceLanguageSelect.module.css';
|
import styles from './VoiceLanguageSelect.module.css';
|
||||||
|
|
||||||
// Voice language options with full locale codes for Google Cloud Speech
|
export type VoiceLanguageOption = VoiceLanguage;
|
||||||
export interface VoiceLanguageOption {
|
|
||||||
code: string; // Full locale code (e.g., 'de-DE')
|
|
||||||
label: string; // Display label
|
|
||||||
shortCode: string; // Short code for mapping (e.g., 'de')
|
|
||||||
flag?: string; // Optional flag emoji
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supported languages for speech recognition
|
|
||||||
export const voiceLanguages: VoiceLanguageOption[] = [
|
|
||||||
{ code: 'de-DE', label: 'Deutsch', shortCode: 'de', flag: '🇩🇪' },
|
|
||||||
{ code: 'de-CH', label: 'Deutsch (Schweiz)', shortCode: 'de', flag: '🇨🇭' },
|
|
||||||
{ code: 'en-US', label: 'English (US)', shortCode: 'en', flag: '🇺🇸' },
|
|
||||||
{ code: 'en-GB', label: 'English (UK)', shortCode: 'en', flag: '🇬🇧' },
|
|
||||||
{ code: 'fr-FR', label: 'Français', shortCode: 'fr', flag: '🇫🇷' },
|
|
||||||
{ code: 'fr-CH', label: 'Français (Suisse)', shortCode: 'fr', flag: '🇨🇭' },
|
|
||||||
{ code: 'it-IT', label: 'Italiano', shortCode: 'it', flag: '🇮🇹' },
|
|
||||||
{ code: 'it-CH', label: 'Italiano (Svizzera)', shortCode: 'it', flag: '🇨🇭' },
|
|
||||||
{ code: 'es-ES', label: 'Español', shortCode: 'es', flag: '🇪🇸' },
|
|
||||||
{ code: 'pt-BR', label: 'Português', shortCode: 'pt', flag: '🇧🇷' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Map user profile language (short code) to default voice language (full code)
|
|
||||||
const profileToVoiceLanguage: Record<string, string> = {
|
|
||||||
'de': 'de-DE',
|
|
||||||
'en': 'en-US',
|
|
||||||
'fr': 'fr-FR',
|
|
||||||
'it': 'it-IT',
|
|
||||||
'es': 'es-ES',
|
|
||||||
'pt': 'pt-BR',
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface VoiceLanguageSelectProps {
|
export interface VoiceLanguageSelectProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (languageCode: string) => void;
|
onChange: (languageCode: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
compact?: boolean; // Compact mode shows only flag/short code
|
compact?: boolean;
|
||||||
showFlags?: boolean; // Show flag emojis
|
showFlags?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default voice language based on user's profile language
|
|
||||||
*/
|
|
||||||
export const getDefaultVoiceLanguage = (profileLanguage?: string): string => {
|
|
||||||
if (profileLanguage && profileToVoiceLanguage[profileLanguage]) {
|
|
||||||
return profileToVoiceLanguage[profileLanguage];
|
|
||||||
}
|
|
||||||
return 'de-DE'; // Default fallback
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
|
export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
|
@ -71,23 +33,25 @@ export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
|
||||||
className = '',
|
className = '',
|
||||||
title = 'Sprache für Spracherkennung',
|
title = 'Sprache für Spracherkennung',
|
||||||
}) => {
|
}) => {
|
||||||
|
const { languages, isLoading } = useVoiceCatalog();
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
onChange(e.target.value);
|
onChange(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.container} ${compact ? styles.compact : ''} ${className}`}>
|
<div className={`${styles.container} ${compact ? styles.compact : ''} ${className}`}>
|
||||||
<select
|
<select
|
||||||
className={styles.select}
|
className={styles.select}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
disabled={disabled}
|
disabled={disabled || isLoading}
|
||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
{voiceLanguages.map((lang) => (
|
{languages.map((lang) => (
|
||||||
<option key={lang.code} value={lang.code}>
|
<option key={lang.bcp47} value={lang.bcp47}>
|
||||||
{showFlags && lang.flag ? `${lang.flag} ` : ''}
|
{showFlags && lang.flag ? `${lang.flag} ` : ''}
|
||||||
{compact ? lang.code.split('-')[0].toUpperCase() : lang.label}
|
{compact ? lang.iso.toUpperCase() : lang.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -96,37 +60,34 @@ export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to manage voice language state with user profile default
|
* Hook to manage voice language state with user profile default.
|
||||||
|
* Initial value falls back to the catalog-derived default for the profile language.
|
||||||
*/
|
*/
|
||||||
export const useVoiceLanguage = (initialValue?: string) => {
|
export const useVoiceLanguage = (initialValue?: string) => {
|
||||||
const { currentLanguage } = useLanguage();
|
const { currentLanguage } = useLanguage();
|
||||||
|
const { languages } = useVoiceCatalog();
|
||||||
// Track if user has manually changed the language
|
const defaultLocale = useDefaultVoiceLocale(currentLanguage);
|
||||||
|
|
||||||
const hasManuallyChanged = React.useRef(false);
|
const hasManuallyChanged = React.useRef(false);
|
||||||
|
|
||||||
// Initialize with user's profile language (or provided initial value)
|
|
||||||
const [voiceLanguage, setVoiceLanguage] = React.useState<string>(
|
const [voiceLanguage, setVoiceLanguage] = React.useState<string>(
|
||||||
initialValue || getDefaultVoiceLanguage(currentLanguage)
|
initialValue || defaultLocale,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update voice language when user profile language changes (only if not manually set)
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!initialValue && !hasManuallyChanged.current) {
|
if (!initialValue && !hasManuallyChanged.current) {
|
||||||
const newDefault = getDefaultVoiceLanguage(currentLanguage);
|
setVoiceLanguage(defaultLocale);
|
||||||
setVoiceLanguage(newDefault);
|
|
||||||
}
|
}
|
||||||
}, [currentLanguage, initialValue]);
|
}, [defaultLocale, initialValue]);
|
||||||
|
|
||||||
// Wrapper to track manual changes
|
|
||||||
const handleSetVoiceLanguage = React.useCallback((newLanguage: string) => {
|
const handleSetVoiceLanguage = React.useCallback((newLanguage: string) => {
|
||||||
hasManuallyChanged.current = true;
|
hasManuallyChanged.current = true;
|
||||||
setVoiceLanguage(newLanguage);
|
setVoiceLanguage(newLanguage);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
voiceLanguage,
|
voiceLanguage,
|
||||||
setVoiceLanguage: handleSetVoiceLanguage,
|
setVoiceLanguage: handleSetVoiceLanguage,
|
||||||
voiceLanguages,
|
voiceLanguages: languages,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
export {
|
export {
|
||||||
VoiceLanguageSelect,
|
VoiceLanguageSelect,
|
||||||
useVoiceLanguage,
|
useVoiceLanguage,
|
||||||
getDefaultVoiceLanguage,
|
|
||||||
voiceLanguages,
|
|
||||||
type VoiceLanguageOption,
|
type VoiceLanguageOption,
|
||||||
type VoiceLanguageSelectProps
|
type VoiceLanguageSelectProps,
|
||||||
} from './VoiceLanguageSelect';
|
} from './VoiceLanguageSelect';
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,5 @@ export * from './AutoScroll';
|
||||||
export * from './Tabs';
|
export * from './Tabs';
|
||||||
export type { TabsProps, Tab } from './Tabs';
|
export type { TabsProps, Tab } from './Tabs';
|
||||||
export * from './Toast';
|
export * from './Toast';
|
||||||
export * from './VoiceLanguageSelect';
|
export * from './VoiceLanguageSelect';
|
||||||
|
export * from './Modal';
|
||||||
|
|
@ -52,6 +52,22 @@ function _formatRelativeTime(dateStr?: string | number): string {
|
||||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp der letzten Aktivität eines Chats: `lastMessageAt` (Backend liefert
|
||||||
|
* den Zeitstempel der letzten Nachricht) bevorzugt, sonst Fallback auf
|
||||||
|
* `updatedAt` (Workflow-Lifecycle-Zeit). Wird sowohl für Anzeige als auch
|
||||||
|
* Sortierung verwendet, damit Liste und Label konsistent sind.
|
||||||
|
*/
|
||||||
|
function _lastTouchValue(chat: ChatItem): string | number | undefined {
|
||||||
|
return chat.lastMessageAt ?? chat.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _lastTouchTs(chat: ChatItem): number {
|
||||||
|
const v = _lastTouchValue(chat);
|
||||||
|
if (v === undefined || v === null) return 0;
|
||||||
|
return typeof v === 'number' ? v : new Date(v).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
onSelectChat,
|
onSelectChat,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
|
|
@ -113,13 +129,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = Array.from(groupMap.values());
|
const sorted = Array.from(groupMap.values());
|
||||||
sorted.forEach(g =>
|
sorted.forEach(g => g.chats.sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a)));
|
||||||
g.chats.sort((a, b) => {
|
|
||||||
const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime();
|
|
||||||
const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime();
|
|
||||||
return tb - ta;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setGroups(sorted);
|
setGroups(sorted);
|
||||||
|
|
||||||
if (expandedGroups.size === 0 && sorted.length > 0) {
|
if (expandedGroups.size === 0 && sorted.length > 0) {
|
||||||
|
|
@ -218,16 +228,9 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
.map(g => ({ ...g, chats: _applyFilter(g.chats) }))
|
.map(g => ({ ...g, chats: _applyFilter(g.chats) }))
|
||||||
.filter(g => g.chats.length > 0);
|
.filter(g => g.chats.length > 0);
|
||||||
|
|
||||||
const _toTs = (v?: string | number): number =>
|
|
||||||
typeof v === 'number' ? v : new Date(v || 0).getTime();
|
|
||||||
|
|
||||||
const _allChats = _filteredGroups
|
const _allChats = _filteredGroups
|
||||||
.flatMap(g => g.chats)
|
.flatMap(g => g.chats)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a));
|
||||||
const ta = _toTs(a.lastMessageAt ?? a.updatedAt);
|
|
||||||
const tb = _toTs(b.lastMessageAt ?? b.updatedAt);
|
|
||||||
return tb - ta;
|
|
||||||
});
|
|
||||||
|
|
||||||
const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0);
|
const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0);
|
||||||
const _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0);
|
const _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0);
|
||||||
|
|
@ -270,7 +273,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className={styles.chatDate}>
|
<span className={styles.chatDate}>
|
||||||
{_formatRelativeTime(chat.updatedAt)}
|
{_formatRelativeTime(_lastTouchValue(chat))}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={styles.chatLabel}
|
className={styles.chatLabel}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
interface FilesTabProps {
|
interface FilesTabProps {
|
||||||
context: UdbContext;
|
context: UdbContext;
|
||||||
onFileSelect?: (fileId: string, fileName?: string) => void;
|
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||||
|
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
@ -46,6 +47,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
||||||
name: f.name,
|
name: f.name,
|
||||||
parentId: f.parentId ?? null,
|
parentId: f.parentId ?? null,
|
||||||
fileCount: f.fileCount ?? 0,
|
fileCount: f.fileCount ?? 0,
|
||||||
|
neutralize: f.neutralize ?? false,
|
||||||
|
scope: f.scope ?? 'personal',
|
||||||
}));
|
}));
|
||||||
}, [folders]);
|
}, [folders]);
|
||||||
|
|
||||||
|
|
@ -166,6 +169,26 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
||||||
}
|
}
|
||||||
}, [updateTreeFileNode, refreshTreeFiles]);
|
}, [updateTreeFileNode, refreshTreeFiles]);
|
||||||
|
|
||||||
|
const _onFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue });
|
||||||
|
await refreshFolders();
|
||||||
|
await refreshTreeFiles();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to toggle folder neutralize:', err);
|
||||||
|
}
|
||||||
|
}, [refreshFolders, refreshTreeFiles]);
|
||||||
|
|
||||||
|
const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
|
||||||
|
await refreshFolders();
|
||||||
|
await refreshTreeFiles();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to change folder scope:', err);
|
||||||
|
}
|
||||||
|
}, [refreshFolders, refreshTreeFiles]);
|
||||||
|
|
||||||
if (treeFilesLoading && treeFileNodes.length === 0) {
|
if (treeFilesLoading && treeFileNodes.length === 0) {
|
||||||
return <div className={styles.loading}>{t('Dateien laden')}</div>;
|
return <div className={styles.loading}>{t('Dateien laden')}</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -256,6 +279,9 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
||||||
onDownloadFolder={handleDownloadFolder}
|
onDownloadFolder={handleDownloadFolder}
|
||||||
onScopeChange={_onScopeChange}
|
onScopeChange={_onScopeChange}
|
||||||
onNeutralizeToggle={_onNeutralizeToggle}
|
onNeutralizeToggle={_onNeutralizeToggle}
|
||||||
|
onFolderScopeChange={_onFolderScopeChange}
|
||||||
|
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
|
||||||
|
onSendToChat={onSendToChat}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{_fileNodes.length === 0 && (
|
{_fileNodes.length === 0 && (
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -14,6 +14,20 @@ export interface UdbContext {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddToChat_FileItem {
|
||||||
|
id: string;
|
||||||
|
type: 'file' | 'folder';
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddToChat_FeatureSource {
|
||||||
|
featureInstanceId: string;
|
||||||
|
featureCode: string;
|
||||||
|
tableName?: string;
|
||||||
|
objectKey: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface UnifiedDataBarProps {
|
interface UnifiedDataBarProps {
|
||||||
context: UdbContext;
|
context: UdbContext;
|
||||||
activeTab?: UdbTab;
|
activeTab?: UdbTab;
|
||||||
|
|
@ -27,6 +41,9 @@ interface UnifiedDataBarProps {
|
||||||
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||||
onFileSelect?: (fileId: string, fileName?: string) => void;
|
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||||
onSourcesChanged?: () => void;
|
onSourcesChanged?: () => void;
|
||||||
|
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
|
||||||
|
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
|
||||||
|
onAttachDataSource?: (dsId: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,6 +69,9 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
||||||
onChatDragStart,
|
onChatDragStart,
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
onSourcesChanged,
|
onSourcesChanged,
|
||||||
|
onSendToChat_Files,
|
||||||
|
onSendToChat_FeatureSource,
|
||||||
|
onAttachDataSource,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -95,10 +115,16 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
||||||
<FilesTab
|
<FilesTab
|
||||||
context={context}
|
context={context}
|
||||||
onFileSelect={onFileSelect}
|
onFileSelect={onFileSelect}
|
||||||
|
onSendToChat={onSendToChat_Files}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
|
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
|
||||||
<SourcesTab context={context} onSourcesChanged={onSourcesChanged} />
|
<SourcesTab
|
||||||
|
context={context}
|
||||||
|
onSourcesChanged={onSourcesChanged}
|
||||||
|
onSendToChat_FeatureSource={onSendToChat_FeatureSource}
|
||||||
|
onAttachDataSource={onAttachDataSource}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export { default as UnifiedDataBar } from './UnifiedDataBar';
|
export { default as UnifiedDataBar } from './UnifiedDataBar';
|
||||||
export type { UdbContext, UdbTab } from './UnifiedDataBar';
|
export type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from './UnifiedDataBar';
|
||||||
export { useUdlContext } from './useUdlContext';
|
export { useUdlContext } from './useUdlContext';
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.admin.automation-logs': <FaClipboardList />,
|
'page.admin.automation-logs': <FaClipboardList />,
|
||||||
'page.admin.logs': <FaFileAlt />,
|
'page.admin.logs': <FaFileAlt />,
|
||||||
'page.admin.languages': <FaGlobe />,
|
'page.admin.languages': <FaGlobe />,
|
||||||
|
'page.admin.databaseHealth': <FaDatabase />,
|
||||||
|
'page.admin.database-health': <FaDatabase />,
|
||||||
'page.admin.demoConfig': <FaCubes />,
|
'page.admin.demoConfig': <FaCubes />,
|
||||||
'page.admin.demo-config': <FaCubes />,
|
'page.admin.demo-config': <FaCubes />,
|
||||||
'page.admin.mandate-wizard': <FaHatWizard />,
|
'page.admin.mandate-wizard': <FaHatWizard />,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { createContext, useContext, useCallback, useState, useEffect, useMemo } from 'react';
|
import React, { createContext, useContext, useCallback, useState, useEffect, useMemo } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useFileOperations } from '../hooks/useFiles';
|
import { useFileOperations, type FilePreviewResult } from '../hooks/useFiles';
|
||||||
import type { FolderInfo } from '../api/fileApi';
|
import type { FolderInfo } from '../api/fileApi';
|
||||||
import type { FileNode } from '../components/FolderTree/FolderTree';
|
import type { FileNode } from '../components/FolderTree/FolderTree';
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@ interface FileContextType {
|
||||||
handleDownloadFolder: (folderId: string, folderName: string) => Promise<void>;
|
handleDownloadFolder: (folderId: string, folderName: string) => Promise<void>;
|
||||||
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
|
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
|
||||||
handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>;
|
handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>;
|
||||||
handleFilePreview: (fileId: string, fileName: string, mimeType?: string) => Promise<{ success: boolean; previewUrl?: string | null; blob?: Blob | null; isJsonContent?: boolean; decodedContent?: any; isTextContent?: boolean; error?: string }>;
|
handleFilePreview: (fileId: string, fileName: string, mimeType?: string) => Promise<FilePreviewResult>;
|
||||||
handleFileDownload: (fileId: string, fileName: string) => Promise<void>;
|
handleFileDownload: (fileId: string, fileName: string) => Promise<void>;
|
||||||
uploadingFile: boolean;
|
uploadingFile: boolean;
|
||||||
deletingFiles: Set<string>;
|
deletingFiles: Set<string>;
|
||||||
|
|
@ -316,7 +316,7 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
|
||||||
handleDownloadFolder,
|
handleDownloadFolder,
|
||||||
handleFileDelete,
|
handleFileDelete,
|
||||||
handleFileUpload,
|
handleFileUpload,
|
||||||
handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'],
|
handleFilePreview,
|
||||||
handleFileDownload: async (fileId: string, fileName: string) => {
|
handleFileDownload: async (fileId: string, fileName: string) => {
|
||||||
await handleFileDownload(fileId, fileName);
|
await handleFileDownload(fileId, fileName);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
151
src/contexts/VoiceCatalogContext.tsx
Normal file
151
src/contexts/VoiceCatalogContext.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* VoiceCatalogContext
|
||||||
|
*
|
||||||
|
* Loads the central voice/language catalog from the backend exactly once and
|
||||||
|
* makes it available to every component via `useVoiceCatalog()`.
|
||||||
|
*
|
||||||
|
* Provides convenience helpers for ISO ⇄ BCP-47 lookups and curated default
|
||||||
|
* voices, mirroring the backend `voiceCatalog` API. Components MUST NOT keep
|
||||||
|
* their own static language lists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { fetchVoiceCatalog, VoiceLanguage } from '../api/voiceCatalogApi';
|
||||||
|
|
||||||
|
interface VoiceCatalogContextType {
|
||||||
|
languages: VoiceLanguage[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
getByBcp47: (code: string | null | undefined) => VoiceLanguage | undefined;
|
||||||
|
getByIso: (iso: string | null | undefined) => VoiceLanguage | undefined;
|
||||||
|
isoToBcp47: (iso: string | null | undefined) => string | undefined;
|
||||||
|
getDefaultVoice: (bcp47: string | null | undefined) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VoiceCatalogContext = createContext<VoiceCatalogContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface VoiceCatalogProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VoiceCatalogProvider: React.FC<VoiceCatalogProviderProps> = ({ children }) => {
|
||||||
|
const [languages, setLanguages] = useState<VoiceLanguage[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchVoiceCatalog();
|
||||||
|
if (!cancelled) {
|
||||||
|
setLanguages(data);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err?.message || 'Failed to load voice catalog');
|
||||||
|
setLanguages([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const byBcp47 = useMemo(() => {
|
||||||
|
const map = new Map<string, VoiceLanguage>();
|
||||||
|
for (const v of languages) map.set(v.bcp47.toLowerCase(), v);
|
||||||
|
return map;
|
||||||
|
}, [languages]);
|
||||||
|
|
||||||
|
const byIso = useMemo(() => {
|
||||||
|
const map = new Map<string, VoiceLanguage>();
|
||||||
|
for (const v of languages) {
|
||||||
|
if (!map.has(v.iso.toLowerCase())) map.set(v.iso.toLowerCase(), v);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [languages]);
|
||||||
|
|
||||||
|
const getByBcp47 = useCallback(
|
||||||
|
(code: string | null | undefined) =>
|
||||||
|
code ? byBcp47.get(code.trim().toLowerCase()) : undefined,
|
||||||
|
[byBcp47],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getByIso = useCallback(
|
||||||
|
(iso: string | null | undefined) =>
|
||||||
|
iso ? byIso.get(iso.trim().toLowerCase()) : undefined,
|
||||||
|
[byIso],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isoToBcp47 = useCallback(
|
||||||
|
(iso: string | null | undefined): string | undefined => {
|
||||||
|
if (!iso) return undefined;
|
||||||
|
const trimmed = iso.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
if (trimmed.includes('-')) {
|
||||||
|
const canonical = byBcp47.get(trimmed.toLowerCase());
|
||||||
|
return canonical ? canonical.bcp47 : trimmed;
|
||||||
|
}
|
||||||
|
const entry = byIso.get(trimmed.toLowerCase());
|
||||||
|
if (entry) return entry.bcp47;
|
||||||
|
return `${trimmed.toLowerCase()}-${trimmed.toUpperCase()}`;
|
||||||
|
},
|
||||||
|
[byBcp47, byIso],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDefaultVoice = useCallback(
|
||||||
|
(bcp47: string | null | undefined): string | null => {
|
||||||
|
const entry = getByBcp47(bcp47);
|
||||||
|
return entry?.defaultVoice ?? null;
|
||||||
|
},
|
||||||
|
[getByBcp47],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<VoiceCatalogContextType>(
|
||||||
|
() => ({
|
||||||
|
languages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
getByBcp47,
|
||||||
|
getByIso,
|
||||||
|
isoToBcp47,
|
||||||
|
getDefaultVoice,
|
||||||
|
}),
|
||||||
|
[languages, isLoading, error, getByBcp47, getByIso, isoToBcp47, getDefaultVoice],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VoiceCatalogContext.Provider value={value}>{children}</VoiceCatalogContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useVoiceCatalog = (): VoiceCatalogContextType => {
|
||||||
|
const ctx = useContext(VoiceCatalogContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useVoiceCatalog must be used within VoiceCatalogProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a profile language (ISO short code) to a default voice locale.
|
||||||
|
* Returns the catalog's BCP-47 for the ISO if available, else falls back to
|
||||||
|
* `de-DE` so the UI always has a deterministic starting value.
|
||||||
|
*/
|
||||||
|
export const useDefaultVoiceLocale = (profileLanguage?: string | null): string => {
|
||||||
|
const { isoToBcp47 } = useVoiceCatalog();
|
||||||
|
return isoToBcp47(profileLanguage) || 'de-DE';
|
||||||
|
};
|
||||||
|
|
@ -333,6 +333,8 @@ export function useConnections() {
|
||||||
|
|
||||||
// Create Google connection and open OAuth popup
|
// Create Google connection and open OAuth popup
|
||||||
const createGoogleConnectionAndAuth = async (): Promise<void> => {
|
const createGoogleConnectionAndAuth = async (): Promise<void> => {
|
||||||
|
if (isConnecting) return;
|
||||||
|
setIsConnecting(true);
|
||||||
try {
|
try {
|
||||||
// Step 1: Create a Google connection
|
// Step 1: Create a Google connection
|
||||||
const newConnection = await createConnection({
|
const newConnection = await createConnection({
|
||||||
|
|
@ -354,7 +356,7 @@ export function useConnections() {
|
||||||
authUrl = `${apiBaseUrl}${authUrl}`;
|
authUrl = `${apiBaseUrl}${authUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return await new Promise<void>((resolve, reject) => {
|
||||||
const popup = window.open(
|
const popup = window.open(
|
||||||
authUrl,
|
authUrl,
|
||||||
'google-connection',
|
'google-connection',
|
||||||
|
|
@ -362,6 +364,7 @@ export function useConnections() {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!popup) {
|
if (!popup) {
|
||||||
|
setIsConnecting(false);
|
||||||
reject(new Error('Popup was blocked. Please allow popups and try again.'));
|
reject(new Error('Popup was blocked. Please allow popups and try again.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -371,6 +374,7 @@ export function useConnections() {
|
||||||
if (popup.closed) {
|
if (popup.closed) {
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
|
setIsConnecting(false);
|
||||||
console.log('Google OAuth popup closed');
|
console.log('Google OAuth popup closed');
|
||||||
// Refresh connections in case it succeeded
|
// Refresh connections in case it succeeded
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
|
|
@ -390,6 +394,7 @@ export function useConnections() {
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
popup.close();
|
popup.close();
|
||||||
|
setIsConnecting(false);
|
||||||
console.log('Google connection successful');
|
console.log('Google connection successful');
|
||||||
// Refresh connections
|
// Refresh connections
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
|
|
@ -398,6 +403,7 @@ export function useConnections() {
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
popup.close();
|
popup.close();
|
||||||
|
setIsConnecting(false);
|
||||||
reject(new Error(event.data.error || 'Google connection failed'));
|
reject(new Error(event.data.error || 'Google connection failed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -405,6 +411,7 @@ export function useConnections() {
|
||||||
window.addEventListener('message', messageListener);
|
window.addEventListener('message', messageListener);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setIsConnecting(false);
|
||||||
console.error('Error creating Google connection:', error);
|
console.error('Error creating Google connection:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -412,6 +419,8 @@ export function useConnections() {
|
||||||
|
|
||||||
// Create ClickUp connection and open OAuth popup
|
// Create ClickUp connection and open OAuth popup
|
||||||
const createClickupConnectionAndAuth = async (): Promise<void> => {
|
const createClickupConnectionAndAuth = async (): Promise<void> => {
|
||||||
|
if (isConnecting) return;
|
||||||
|
setIsConnecting(true);
|
||||||
try {
|
try {
|
||||||
const newConnection = await createConnection({
|
const newConnection = await createConnection({
|
||||||
type: 'clickup',
|
type: 'clickup',
|
||||||
|
|
@ -430,7 +439,7 @@ export function useConnections() {
|
||||||
authUrl = `${apiBaseUrl}${authUrl}`;
|
authUrl = `${apiBaseUrl}${authUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return await new Promise<void>((resolve, reject) => {
|
||||||
const popup = window.open(
|
const popup = window.open(
|
||||||
authUrl,
|
authUrl,
|
||||||
'clickup-connection',
|
'clickup-connection',
|
||||||
|
|
@ -438,6 +447,7 @@ export function useConnections() {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!popup) {
|
if (!popup) {
|
||||||
|
setIsConnecting(false);
|
||||||
reject(new Error('Popup was blocked. Please allow popups and try again.'));
|
reject(new Error('Popup was blocked. Please allow popups and try again.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -446,6 +456,7 @@ export function useConnections() {
|
||||||
if (popup.closed) {
|
if (popup.closed) {
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
|
setIsConnecting(false);
|
||||||
console.log('ClickUp OAuth popup closed');
|
console.log('ClickUp OAuth popup closed');
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
resolve();
|
resolve();
|
||||||
|
|
@ -462,6 +473,7 @@ export function useConnections() {
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
popup.close();
|
popup.close();
|
||||||
|
setIsConnecting(false);
|
||||||
console.log('ClickUp connection successful');
|
console.log('ClickUp connection successful');
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
resolve();
|
resolve();
|
||||||
|
|
@ -469,6 +481,7 @@ export function useConnections() {
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
popup.close();
|
popup.close();
|
||||||
|
setIsConnecting(false);
|
||||||
reject(new Error(event.data.error || 'ClickUp connection failed'));
|
reject(new Error(event.data.error || 'ClickUp connection failed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -476,6 +489,7 @@ export function useConnections() {
|
||||||
window.addEventListener('message', messageListener);
|
window.addEventListener('message', messageListener);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setIsConnecting(false);
|
||||||
console.error('Error creating ClickUp connection:', error);
|
console.error('Error creating ClickUp connection:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -483,6 +497,8 @@ export function useConnections() {
|
||||||
|
|
||||||
// Create Microsoft connection and open OAuth popup
|
// Create Microsoft connection and open OAuth popup
|
||||||
const createMicrosoftConnectionAndAuth = async (): Promise<void> => {
|
const createMicrosoftConnectionAndAuth = async (): Promise<void> => {
|
||||||
|
if (isConnecting) return;
|
||||||
|
setIsConnecting(true);
|
||||||
try {
|
try {
|
||||||
// Step 1: Create a Microsoft connection
|
// Step 1: Create a Microsoft connection
|
||||||
const newConnection = await createConnection({
|
const newConnection = await createConnection({
|
||||||
|
|
@ -504,7 +520,7 @@ export function useConnections() {
|
||||||
authUrl = `${apiBaseUrl}${authUrl}`;
|
authUrl = `${apiBaseUrl}${authUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return await new Promise<void>((resolve, reject) => {
|
||||||
const popup = window.open(
|
const popup = window.open(
|
||||||
authUrl,
|
authUrl,
|
||||||
'msft-connection',
|
'msft-connection',
|
||||||
|
|
@ -512,6 +528,7 @@ export function useConnections() {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!popup) {
|
if (!popup) {
|
||||||
|
setIsConnecting(false);
|
||||||
reject(new Error('Popup was blocked. Please allow popups and try again.'));
|
reject(new Error('Popup was blocked. Please allow popups and try again.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -521,6 +538,7 @@ export function useConnections() {
|
||||||
if (popup.closed) {
|
if (popup.closed) {
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
|
setIsConnecting(false);
|
||||||
console.log('Microsoft OAuth popup closed');
|
console.log('Microsoft OAuth popup closed');
|
||||||
// Refresh connections in case it succeeded
|
// Refresh connections in case it succeeded
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
|
|
@ -540,6 +558,7 @@ export function useConnections() {
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
popup.close();
|
popup.close();
|
||||||
|
setIsConnecting(false);
|
||||||
console.log('Microsoft connection successful');
|
console.log('Microsoft connection successful');
|
||||||
// Refresh connections
|
// Refresh connections
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
|
|
@ -548,6 +567,7 @@ export function useConnections() {
|
||||||
clearInterval(checkClosed);
|
clearInterval(checkClosed);
|
||||||
window.removeEventListener('message', messageListener);
|
window.removeEventListener('message', messageListener);
|
||||||
popup.close();
|
popup.close();
|
||||||
|
setIsConnecting(false);
|
||||||
reject(new Error(event.data.error || 'Microsoft connection failed'));
|
reject(new Error(event.data.error || 'Microsoft connection failed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -555,6 +575,7 @@ export function useConnections() {
|
||||||
window.addEventListener('message', messageListener);
|
window.addEventListener('message', messageListener);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setIsConnecting(false);
|
||||||
console.error('Error creating Microsoft connection:', error);
|
console.error('Error creating Microsoft connection:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,15 @@ import {
|
||||||
type FolderInfo,
|
type FolderInfo,
|
||||||
} from '../api/fileApi';
|
} from '../api/fileApi';
|
||||||
|
|
||||||
|
export interface FilePreviewResult {
|
||||||
|
success: boolean;
|
||||||
|
previewUrl?: string;
|
||||||
|
blob?: Blob;
|
||||||
|
mimeType?: string;
|
||||||
|
textContent?: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// File interfaces - exactly matching backend FileItem model
|
// File interfaces - exactly matching backend FileItem model
|
||||||
export interface FileInfo {
|
export interface FileInfo {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -480,7 +489,12 @@ export function useFileOperations() {
|
||||||
* - Removed workflowId from FileItem creation in interfaceComponentObjects.py
|
* - Removed workflowId from FileItem creation in interfaceComponentObjects.py
|
||||||
* - Upload should now work correctly
|
* - Upload should now work correctly
|
||||||
*/
|
*/
|
||||||
const handleFileUpload = async (file: globalThis.File, workflowId?: string, featureInstanceId?: string) => {
|
const handleFileUpload = async (
|
||||||
|
file: globalThis.File,
|
||||||
|
workflowId?: string,
|
||||||
|
featureInstanceId?: string,
|
||||||
|
folderId?: string | null,
|
||||||
|
) => {
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
setUploadingFile(true);
|
setUploadingFile(true);
|
||||||
|
|
||||||
|
|
@ -504,6 +518,9 @@ export function useFileOperations() {
|
||||||
if (featureInstanceId) {
|
if (featureInstanceId) {
|
||||||
formData.append('featureInstanceId', featureInstanceId);
|
formData.append('featureInstanceId', featureInstanceId);
|
||||||
}
|
}
|
||||||
|
if (folderId) {
|
||||||
|
formData.append('folderId', folderId);
|
||||||
|
}
|
||||||
|
|
||||||
// FormData is now correctly configured for backend
|
// FormData is now correctly configured for backend
|
||||||
|
|
||||||
|
|
@ -580,334 +597,67 @@ export function useFileOperations() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilePreview = async (fileId: string, fileName: string, mimeType?: string) => {
|
const handleFilePreview = async (
|
||||||
|
fileId: string,
|
||||||
|
fileName: string,
|
||||||
|
_mimeType?: string,
|
||||||
|
): Promise<FilePreviewResult> => {
|
||||||
setPreviewError(null);
|
setPreviewError(null);
|
||||||
setPreviewingFiles(prev => new Set(prev).add(fileId));
|
setPreviewingFiles(prev => new Set(prev).add(fileId));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const response = await api.get(`/api/files/${fileId}/preview`, {
|
||||||
// For PDF files, try JSON response first (API returns base64-encoded PDF)
|
responseType: 'json',
|
||||||
if (mimeType === 'application/pdf') {
|
});
|
||||||
|
const data = response.data;
|
||||||
try {
|
|
||||||
const response = await api.get(`/api/files/${fileId}/preview`, {
|
|
||||||
responseType: 'json',
|
|
||||||
validateStatus: function (status: number) {
|
|
||||||
return status >= 200 && status < 300;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const jsonResponse = response.data;
|
|
||||||
|
|
||||||
|
|
||||||
|
if (!data || typeof data !== 'object' || typeof data.content !== 'string' || !data.mimeType) {
|
||||||
// Check if response has base64-encoded PDF content
|
throw new Error('Invalid preview response from server');
|
||||||
if (jsonResponse && typeof jsonResponse === 'object' && 'content' in jsonResponse) {
|
|
||||||
let content = jsonResponse.content;
|
|
||||||
|
|
||||||
// The content field contains base64-encoded JSON, so decode it first
|
|
||||||
if (typeof content === 'string' && /^[A-Za-z0-9+/=]+$/.test(content)) {
|
|
||||||
try {
|
|
||||||
const decodedJsonString = atob(content);
|
|
||||||
|
|
||||||
// Parse the decoded JSON string
|
|
||||||
const nestedJson = JSON.parse(decodedJsonString);
|
|
||||||
|
|
||||||
if (nestedJson && typeof nestedJson === 'object' && 'content' in nestedJson) {
|
|
||||||
const innerContent = nestedJson.content;
|
|
||||||
const isBase64 = /^[A-Za-z0-9+/=]+$/.test(innerContent);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (isBase64) {
|
|
||||||
// It's base64-encoded PDF content
|
|
||||||
content = innerContent;
|
|
||||||
} else {
|
|
||||||
// It's plain text content, not a PDF
|
|
||||||
// Return the text content for the FilePreview to handle as text
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
previewUrl: null,
|
|
||||||
blob: null,
|
|
||||||
isJsonContent: true,
|
|
||||||
decodedContent: innerContent,
|
|
||||||
isTextContent: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (decodeError) {
|
|
||||||
console.warn('⚠️ Failed to decode base64 content or parse JSON:', decodeError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Decode base64 content
|
|
||||||
let decodedContent;
|
|
||||||
try {
|
|
||||||
decodedContent = atob(content);
|
|
||||||
|
|
||||||
|
|
||||||
// Verify it's actually a PDF
|
|
||||||
const isPDF = decodedContent.startsWith('%PDF');
|
|
||||||
|
|
||||||
|
|
||||||
if (!isPDF) {
|
|
||||||
console.warn('⚠️ Decoded content does not appear to be a valid PDF');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (decodeError) {
|
|
||||||
console.error('❌ Failed to decode base64 PDF content:', decodeError);
|
|
||||||
throw new Error('Failed to decode PDF content');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a blob from the decoded PDF content
|
|
||||||
// Convert string to Uint8Array for proper binary handling
|
|
||||||
const uint8Array = new Uint8Array(decodedContent.length);
|
|
||||||
for (let i = 0; i < decodedContent.length; i++) {
|
|
||||||
uint8Array[i] = decodedContent.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([uint8Array], { type: 'application/pdf' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent };
|
|
||||||
} else {
|
|
||||||
throw new Error('No content field in PDF response');
|
|
||||||
}
|
|
||||||
} catch (jsonError) {
|
|
||||||
|
|
||||||
|
|
||||||
// Fallback to blob response
|
|
||||||
const response = await api.get(`/api/files/${fileId}/preview`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
validateStatus: function (status: number) {
|
|
||||||
return status >= 200 && status < 300;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const previewData = response.data;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(previewData);
|
|
||||||
|
|
||||||
return { success: true, previewUrl: url, blob: previewData, isJsonContent: false };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For image files, try JSON response first (API returns base64-encoded images)
|
|
||||||
if (mimeType?.startsWith('image/')) {
|
|
||||||
|
|
||||||
|
const { content, mimeType: responseMime, isText } = data as {
|
||||||
try {
|
content: string;
|
||||||
const response = await api.get(`/api/files/${fileId}/preview`, {
|
mimeType: string;
|
||||||
responseType: 'json',
|
isText?: boolean;
|
||||||
validateStatus: function (status: number) {
|
encoding?: string | null;
|
||||||
return status >= 200 && status < 300;
|
};
|
||||||
}
|
|
||||||
});
|
|
||||||
const jsonResponse = response.data;
|
|
||||||
|
|
||||||
|
|
||||||
|
let blob: Blob;
|
||||||
// Check if response has base64-encoded image content
|
let textContent: string | null = null;
|
||||||
if (jsonResponse && typeof jsonResponse === 'object' && 'content' in jsonResponse) {
|
|
||||||
let content = jsonResponse.content;
|
|
||||||
const responseMimeType = jsonResponse.mimeType || mimeType;
|
|
||||||
|
|
||||||
// The content field contains base64-encoded data, decode it first
|
|
||||||
if (typeof content === 'string' && /^[A-Za-z0-9+/=]+$/.test(content)) {
|
|
||||||
|
|
||||||
try {
|
if (isText) {
|
||||||
const decodedString = atob(content);
|
textContent = content;
|
||||||
|
blob = new Blob([content], { type: responseMime });
|
||||||
|
} else {
|
||||||
// Check if it's JSON (nested structure) or direct image data
|
const binaryString = atob(content);
|
||||||
if (decodedString.startsWith('{')) {
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
// It's JSON, parse it
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
const nestedJson = JSON.parse(decodedString);
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
|
||||||
|
|
||||||
if (nestedJson && typeof nestedJson === 'object' && 'content' in nestedJson) {
|
|
||||||
const innerContent = nestedJson.content;
|
|
||||||
const isBase64 = /^[A-Za-z0-9+/=]+$/.test(innerContent);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (isBase64) {
|
|
||||||
// It's base64-encoded image content
|
|
||||||
content = innerContent;
|
|
||||||
} else {
|
|
||||||
throw new Error('Inner content is not base64-encoded');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (decodedString.startsWith('\x89PNG') || decodedString.startsWith('\xFF\xD8\xFF') || decodedString.startsWith('GIF8') || decodedString.startsWith('RIFF')) {
|
|
||||||
// It's direct image data, use it as is
|
|
||||||
content = btoa(decodedString); // Re-encode as base64 for processing
|
|
||||||
} else {
|
|
||||||
throw new Error('Decoded content is neither JSON nor image data');
|
|
||||||
}
|
|
||||||
} catch (decodeError) {
|
|
||||||
console.warn('⚠️ Failed to decode base64 content:', decodeError);
|
|
||||||
throw decodeError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Decode base64 content
|
|
||||||
let decodedContent;
|
|
||||||
try {
|
|
||||||
decodedContent = atob(content);
|
|
||||||
|
|
||||||
|
|
||||||
// Verify it's actually an image by checking for common image headers
|
|
||||||
const isJPEG = decodedContent.startsWith('\xFF\xD8\xFF');
|
|
||||||
const isPNG = decodedContent.startsWith('\x89PNG\r\n\x1a\n');
|
|
||||||
const isGIF = decodedContent.startsWith('GIF8');
|
|
||||||
const isWebP = decodedContent.startsWith('RIFF') && decodedContent.includes('WEBP');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!isJPEG && !isPNG && !isGIF && !isWebP) {
|
|
||||||
console.warn('⚠️ Decoded content does not appear to be a valid image');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (decodeError) {
|
|
||||||
console.error('❌ Failed to decode base64 image content:', decodeError);
|
|
||||||
throw new Error('Failed to decode image content');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a blob from the decoded image content
|
|
||||||
// Convert string to Uint8Array for proper binary handling
|
|
||||||
const uint8Array = new Uint8Array(decodedContent.length);
|
|
||||||
for (let i = 0; i < decodedContent.length; i++) {
|
|
||||||
uint8Array[i] = decodedContent.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([uint8Array], { type: responseMimeType });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent };
|
|
||||||
} else {
|
|
||||||
throw new Error('No content field in image response');
|
|
||||||
}
|
|
||||||
} catch (jsonError) {
|
|
||||||
|
|
||||||
// Fallback to blob response
|
|
||||||
const response = await api.get(`/api/files/${fileId}/preview`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
validateStatus: function (status: number) {
|
|
||||||
return status >= 200 && status < 300;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const previewData = response.data;
|
|
||||||
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(previewData);
|
|
||||||
|
|
||||||
return { success: true, previewUrl: url, blob: previewData, isJsonContent: false };
|
|
||||||
}
|
}
|
||||||
|
blob = new Blob([bytes], { type: responseMime });
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other files, first try to get JSON response (for text-based files)
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/api/files/${fileId}/preview`, {
|
|
||||||
responseType: 'json',
|
|
||||||
validateStatus: function (status: number) {
|
|
||||||
return status >= 200 && status < 300;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const jsonResponse = response.data;
|
|
||||||
|
|
||||||
// Check if response has content field (structured response)
|
|
||||||
if (jsonResponse && typeof jsonResponse === 'object' && 'content' in jsonResponse) {
|
|
||||||
const content = jsonResponse.content;
|
|
||||||
const mimeType = jsonResponse.mimeType || 'text/plain';
|
|
||||||
|
|
||||||
|
|
||||||
|
const previewUrl = window.URL.createObjectURL(blob);
|
||||||
// Check if content is base64 encoded (common pattern)
|
|
||||||
let decodedContent = content;
|
|
||||||
try {
|
|
||||||
// Try to decode as base64 if it looks like base64
|
|
||||||
if (content && typeof content === 'string' && /^[A-Za-z0-9+/=]+$/.test(content)) {
|
|
||||||
decodedContent = atob(content);
|
|
||||||
|
|
||||||
}
|
return {
|
||||||
} catch (decodeError) {
|
success: true,
|
||||||
decodedContent = content;
|
previewUrl,
|
||||||
}
|
blob,
|
||||||
|
mimeType: responseMime,
|
||||||
// Create a blob from the (possibly decoded) content
|
textContent,
|
||||||
const blob = new Blob([decodedContent], { type: mimeType });
|
};
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
|
|
||||||
return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent };
|
|
||||||
} else if (jsonResponse && typeof jsonResponse === 'object' && 'result' in jsonResponse) {
|
|
||||||
// Handle base64 encoded content in 'result' field
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Decode base64 content
|
|
||||||
const decodedContent = atob(jsonResponse.result);
|
|
||||||
const mimeType = jsonResponse.mimeType || 'application/json';
|
|
||||||
|
|
||||||
|
|
||||||
// Create a blob from the decoded content
|
|
||||||
const blob = new Blob([decodedContent], { type: mimeType });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent };
|
|
||||||
} catch (decodeError) {
|
|
||||||
console.error('❌ Failed to decode base64 content:', decodeError);
|
|
||||||
// Fallback to treating as raw JSON
|
|
||||||
const blob = new Blob([JSON.stringify(jsonResponse, null, 2)], { type: 'application/json' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
return { success: true, previewUrl: url, blob: blob, isJsonContent: true };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If it's not structured JSON, treat as raw content
|
|
||||||
const blob = new Blob([JSON.stringify(jsonResponse, null, 2)], { type: 'application/json' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
return { success: true, previewUrl: url, blob: blob, isJsonContent: true };
|
|
||||||
}
|
|
||||||
} catch (jsonError) {
|
|
||||||
|
|
||||||
// Fallback to blob response for binary files
|
|
||||||
const response = await api.get(`/api/files/${fileId}/preview`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
validateStatus: function (status: number) {
|
|
||||||
return status >= 200 && status < 300;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const previewData = response.data;
|
|
||||||
|
|
||||||
|
|
||||||
// Create a blob URL for preview
|
|
||||||
const url = window.URL.createObjectURL(previewData);
|
|
||||||
|
|
||||||
return { success: true, previewUrl: url, blob: previewData, isJsonContent: false };
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`❌ Preview failed for ${fileName}:`, error);
|
console.error(`Preview failed for ${fileName}:`, error);
|
||||||
let errorMessage = error.message;
|
let errorMessage = error?.message ?? 'Unknown error';
|
||||||
|
|
||||||
if (error.response?.status === 404) {
|
if (error?.response?.status === 404) {
|
||||||
errorMessage = `File "${fileName}" not found or has been deleted.`;
|
errorMessage = `File "${fileName}" not found or has been deleted.`;
|
||||||
} else if (error.response?.status === 403) {
|
} else if (error?.response?.status === 403) {
|
||||||
errorMessage = `No permission to preview "${fileName}".`;
|
errorMessage = `No permission to preview "${fileName}".`;
|
||||||
} else if (error.response?.status === 415) {
|
} else if (error?.response?.status === 415) {
|
||||||
errorMessage = `File type "${fileName}" is not supported for preview.`;
|
errorMessage = `File type "${fileName}" is not supported for preview.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPreviewError(errorMessage);
|
setPreviewError(errorMessage);
|
||||||
return { success: false, error: errorMessage };
|
return { success: false, error: errorMessage };
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,16 @@ import {
|
||||||
deleteMandate as deleteMandateApi,
|
deleteMandate as deleteMandateApi,
|
||||||
hardDeleteMandate as hardDeleteMandateApi,
|
hardDeleteMandate as hardDeleteMandateApi,
|
||||||
type Mandate,
|
type Mandate,
|
||||||
|
type MandateCreateData,
|
||||||
type MandateUpdateData,
|
type MandateUpdateData,
|
||||||
type PaginationParams
|
type PaginationParams
|
||||||
} from '../api/mandateApi';
|
} from '../api/mandateApi';
|
||||||
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
|
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
|
||||||
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
|
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
|
||||||
|
import { validateMandateName } from '../utils/mandateNameUtils';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type { Mandate, MandateUpdateData, PaginationParams };
|
export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams };
|
||||||
|
|
||||||
export interface AttributeDefinition {
|
export interface AttributeDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -169,7 +171,19 @@ export function useAdminMandates() {
|
||||||
// Create mandate
|
// Create mandate
|
||||||
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
|
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
|
||||||
try {
|
try {
|
||||||
const created = await createMandateApi(request, mandateData);
|
const label = typeof mandateData.label === 'string' ? mandateData.label.trim() : '';
|
||||||
|
if (!label) {
|
||||||
|
console.error('createMandate: label (Voller Name) is required');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof mandateData.name === 'string' && mandateData.name.length > 0) {
|
||||||
|
const slugErr = validateMandateName(mandateData.name);
|
||||||
|
if (slugErr) {
|
||||||
|
console.error(`createMandate: invalid Kurzzeichen — ${slugErr}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const created = await createMandateApi(request, { ...mandateData, label } as MandateCreateData);
|
||||||
await fetchMandates();
|
await fetchMandates();
|
||||||
return created ?? null;
|
return created ?? null;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -181,6 +195,21 @@ export function useAdminMandates() {
|
||||||
// Update mandate
|
// Update mandate
|
||||||
const handleUpdate = useCallback(async (mandateId: string, updateData: MandateUpdateData): Promise<boolean> => {
|
const handleUpdate = useCallback(async (mandateId: string, updateData: MandateUpdateData): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
|
if ('label' in updateData) {
|
||||||
|
const lbl = typeof updateData.label === 'string' ? updateData.label.trim() : '';
|
||||||
|
if (!lbl) {
|
||||||
|
console.error('updateMandate: label (Voller Name) must not be empty');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
updateData = { ...updateData, label: lbl };
|
||||||
|
}
|
||||||
|
if ('name' in updateData && typeof updateData.name === 'string') {
|
||||||
|
const slugErr = validateMandateName(updateData.name);
|
||||||
|
if (slugErr) {
|
||||||
|
console.error(`updateMandate: invalid Kurzzeichen — ${slugErr}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
updateOptimistically(mandateId, updateData);
|
updateOptimistically(mandateId, updateData);
|
||||||
await updateMandateApi(request, mandateId, updateData);
|
await updateMandateApi(request, mandateId, updateData);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -295,9 +324,17 @@ export function useMandateFormAttributes() {
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const formAttributes: FormGenAttr[] = useMemo(() => {
|
const formAttributes: FormGenAttr[] = useMemo(() => {
|
||||||
return attributes
|
const list = attributes
|
||||||
.filter(attr => attr.name !== 'id')
|
.filter(attr => attr.name !== 'id')
|
||||||
.map(attr => ({ ...attr, type: attr.type })) as FormGenAttr[];
|
.map(attr => ({ ...attr, type: attr.type })) as FormGenAttr[];
|
||||||
|
|
||||||
|
const labelIdx = list.findIndex(a => a.name === 'label');
|
||||||
|
const nameIdx = list.findIndex(a => a.name === 'name');
|
||||||
|
if (labelIdx >= 0 && nameIdx >= 0 && nameIdx < labelIdx) {
|
||||||
|
const [labelAttr] = list.splice(labelIdx, 1);
|
||||||
|
list.splice(nameIdx, 0, labelAttr);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
const createFormAttributes: FormGenAttr[] = useMemo(
|
const createFormAttributes: FormGenAttr[] = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,23 @@ import {
|
||||||
} from '../api/storeApi';
|
} from '../api/storeApi';
|
||||||
import { useFeatureStore } from '../stores/featureStore';
|
import { useFeatureStore } from '../stores/featureStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a stable key identifying a single Store action button so the spinner
|
||||||
|
* can be scoped to exactly that button (one feature × one mandate / instance)
|
||||||
|
* instead of greying out every button of the feature.
|
||||||
|
*/
|
||||||
|
export const _storeActionKey = {
|
||||||
|
activate: (featureCode: string, mandateId?: string) => `activate:${featureCode}:${mandateId ?? ''}`,
|
||||||
|
deactivate: (featureCode: string, instanceId: string) => `deactivate:${featureCode}:${instanceId}`,
|
||||||
|
};
|
||||||
|
|
||||||
interface UseStoreReturn {
|
interface UseStoreReturn {
|
||||||
features: StoreFeature[];
|
features: StoreFeature[];
|
||||||
mandates: UserMandate[];
|
mandates: UserMandate[];
|
||||||
subscriptionInfo: SubscriptionInfo | null;
|
subscriptionInfo: SubscriptionInfo | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
actionLoading: string | null;
|
/** Set of in-flight action keys (see ``_storeActionKey``) — one entry per button currently processing. */
|
||||||
|
actionLoading: Set<string>;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
loadStore: () => Promise<void>;
|
loadStore: () => Promise<void>;
|
||||||
loadSubscriptionInfo: (mandateId?: string) => Promise<void>;
|
loadSubscriptionInfo: (mandateId?: string) => Promise<void>;
|
||||||
|
|
@ -37,10 +48,27 @@ export function useStore(): UseStoreReturn {
|
||||||
const [mandates, setMandates] = useState<UserMandate[]>([]);
|
const [mandates, setMandates] = useState<UserMandate[]>([]);
|
||||||
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
|
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<Set<string>>(() => new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const featureStore = useFeatureStore();
|
const featureStore = useFeatureStore();
|
||||||
|
|
||||||
|
const _markBusy = useCallback((key: string) => {
|
||||||
|
setActionLoading(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _markIdle = useCallback((key: string) => {
|
||||||
|
setActionLoading(prev => {
|
||||||
|
if (!prev.has(key)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadSubscriptionInfo = useCallback(async (mandateId?: string) => {
|
const loadSubscriptionInfo = useCallback(async (mandateId?: string) => {
|
||||||
try {
|
try {
|
||||||
const info = await fetchSubscriptionInfo(mandateId);
|
const info = await fetchSubscriptionInfo(mandateId);
|
||||||
|
|
@ -81,7 +109,8 @@ export function useStore(): UseStoreReturn {
|
||||||
}, [featureStore, loadStore]);
|
}, [featureStore, loadStore]);
|
||||||
|
|
||||||
const activate = useCallback(async (featureCode: string, mandateId?: string) => {
|
const activate = useCallback(async (featureCode: string, mandateId?: string) => {
|
||||||
setActionLoading(featureCode);
|
const key = _storeActionKey.activate(featureCode, mandateId);
|
||||||
|
_markBusy(key);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await activateStoreFeature(featureCode, mandateId);
|
await activateStoreFeature(featureCode, mandateId);
|
||||||
|
|
@ -90,12 +119,13 @@ export function useStore(): UseStoreReturn {
|
||||||
const msg = err instanceof Error ? err.message : 'Activation failed';
|
const msg = err instanceof Error ? err.message : 'Activation failed';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
_markIdle(key);
|
||||||
}
|
}
|
||||||
}, [_refreshAfterAction]);
|
}, [_refreshAfterAction, _markBusy, _markIdle]);
|
||||||
|
|
||||||
const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => {
|
const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => {
|
||||||
setActionLoading(featureCode);
|
const key = _storeActionKey.deactivate(featureCode, instanceId);
|
||||||
|
_markBusy(key);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await deactivateStoreFeature(featureCode, mandateId, instanceId);
|
await deactivateStoreFeature(featureCode, mandateId, instanceId);
|
||||||
|
|
@ -104,9 +134,9 @@ export function useStore(): UseStoreReturn {
|
||||||
const msg = err instanceof Error ? err.message : 'Deactivation failed';
|
const msg = err instanceof Error ? err.message : 'Deactivation failed';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
_markIdle(key);
|
||||||
}
|
}
|
||||||
}, [_refreshAfterAction]);
|
}, [_refreshAfterAction, _markBusy, _markIdle]);
|
||||||
|
|
||||||
return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate };
|
return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,12 @@ export function useCurrentUser() {
|
||||||
const cachedUser = getUserDataCache();
|
const cachedUser = getUserDataCache();
|
||||||
if (cachedUser && cachedUser.username) {
|
if (cachedUser && cachedUser.username) {
|
||||||
// Use cached user data - permissions are checked via RBAC API, not client-side
|
// Use cached user data - permissions are checked via RBAC API, not client-side
|
||||||
// Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin flag instead
|
// Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin/isPlatformAdmin flags instead
|
||||||
setUser(cachedUser);
|
setUser(cachedUser);
|
||||||
console.log('✅ Using cached user data from sessionStorage (persists during session):', {
|
console.log('✅ Using cached user data from sessionStorage (persists during session):', {
|
||||||
username: cachedUser.username,
|
username: cachedUser.username,
|
||||||
isSysAdmin: cachedUser.isSysAdmin
|
isSysAdmin: cachedUser.isSysAdmin,
|
||||||
|
isPlatformAdmin: cachedUser.isPlatformAdmin
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +73,7 @@ export function useCurrentUser() {
|
||||||
console.log('📦 User data received from API:', {
|
console.log('📦 User data received from API:', {
|
||||||
username: data?.username,
|
username: data?.username,
|
||||||
isSysAdmin: data?.isSysAdmin,
|
isSysAdmin: data?.isSysAdmin,
|
||||||
|
isPlatformAdmin: data?.isPlatformAdmin,
|
||||||
allKeys: data ? Object.keys(data) : []
|
allKeys: data ? Object.keys(data) : []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -85,11 +87,12 @@ export function useCurrentUser() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache user data (permissions are checked via RBAC API)
|
// Cache user data (permissions are checked via RBAC API)
|
||||||
// Note: roleLabels is deprecated - use isSysAdmin flag for admin checks
|
// Note: roleLabels is deprecated - use isSysAdmin/isPlatformAdmin flags for admin checks
|
||||||
setUserDataCache(data);
|
setUserDataCache(data);
|
||||||
console.log('✅ User data fetched from API and cached:', {
|
console.log('✅ User data fetched from API and cached:', {
|
||||||
username: data.username,
|
username: data.username,
|
||||||
isSysAdmin: data.isSysAdmin
|
isSysAdmin: data.isSysAdmin,
|
||||||
|
isPlatformAdmin: data.isPlatformAdmin
|
||||||
});
|
});
|
||||||
setUser(data);
|
setUser(data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -215,11 +218,12 @@ export function useCurrentUser() {
|
||||||
const cachedUser = getUserDataCache();
|
const cachedUser = getUserDataCache();
|
||||||
if (cachedUser && cachedUser.username) {
|
if (cachedUser && cachedUser.username) {
|
||||||
// Use cached user data - permissions are checked via RBAC API
|
// Use cached user data - permissions are checked via RBAC API
|
||||||
// Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin flag instead
|
// Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin/isPlatformAdmin flags instead
|
||||||
setUser(cachedUser);
|
setUser(cachedUser);
|
||||||
console.log('✅ Using cached user data from sessionStorage on mount:', {
|
console.log('✅ Using cached user data from sessionStorage on mount:', {
|
||||||
username: cachedUser.username,
|
username: cachedUser.username,
|
||||||
isSysAdmin: cachedUser.isSysAdmin
|
isSysAdmin: cachedUser.isSysAdmin,
|
||||||
|
isPlatformAdmin: cachedUser.isPlatformAdmin
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -800,24 +804,26 @@ export function useUserOperations() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generic inline update handler for FormGeneratorTable
|
// Generic inline update handler for FormGeneratorTable.
|
||||||
// Must merge changes with existing row data because backend requires full object
|
//
|
||||||
// The existingRow parameter is passed from FormGeneratorTable which has access to row data
|
// The User PUT endpoint accepts PARTIAL payloads — only fields explicitly
|
||||||
const handleInlineUpdate = async (userId: string, changes: Partial<UserUpdateData>, existingRow?: any) => {
|
// present are applied; missing fields keep their stored value. We therefore
|
||||||
if (!existingRow) {
|
// forward ONLY the changed cells. This avoids two classes of bugs:
|
||||||
throw new Error(`Existing row data required for inline update`);
|
// 1. Stale snapshot: spreading ``existingRow`` onto the payload would
|
||||||
|
// overwrite fields with whatever the client last loaded, even if the
|
||||||
|
// backend has been updated since (e.g. by a parallel admin action).
|
||||||
|
// 2. Missing-field default-flip: previously, any non-listed field (e.g.
|
||||||
|
// ``isSysAdmin`` while toggling ``isPlatformAdmin``) was absent from
|
||||||
|
// the merged payload and the Pydantic ``User`` body on the backend
|
||||||
|
// filled it with ``False``, silently dropping the other privileged flag.
|
||||||
|
//
|
||||||
|
// ``existingRow`` is kept in the signature for forward-compat with table
|
||||||
|
// hooks but is no longer consulted to build the payload.
|
||||||
|
const handleInlineUpdate = async (userId: string, changes: Partial<UserUpdateData>, _existingRow?: any) => {
|
||||||
|
if (!changes || Object.keys(changes).length === 0) {
|
||||||
|
throw new Error('No fields to update');
|
||||||
}
|
}
|
||||||
|
const result = await handleUserUpdate(userId, changes);
|
||||||
// Merge changes with existing row data (backend requires full object with required fields)
|
|
||||||
const mergedData: UserUpdateData = {
|
|
||||||
username: existingRow.username,
|
|
||||||
email: existingRow.email,
|
|
||||||
enabled: existingRow.enabled,
|
|
||||||
roleLabels: existingRow.roleLabels,
|
|
||||||
...changes
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await handleUserUpdate(userId, mergedData);
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Failed to update');
|
throw new Error(result.error || 'Failed to update');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||||
import { useFeaturesInitialized, useFeaturesLoading } from '../stores/featureStore';
|
import { useFeaturesInitialized, useFeaturesLoading } from '../stores/featureStore';
|
||||||
import useNavigation from '../hooks/useNavigation';
|
import useNavigation from '../hooks/useNavigation';
|
||||||
import styles from './FeatureLayout.module.css';
|
import styles from './FeatureLayout.module.css';
|
||||||
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -115,7 +116,9 @@ export const FeatureLayout: React.FC = () => {
|
||||||
{/* Header mit Instanz-Info */}
|
{/* Header mit Instanz-Info */}
|
||||||
<header className={styles.featureHeader}>
|
<header className={styles.featureHeader}>
|
||||||
<div className={styles.breadcrumb}>
|
<div className={styles.breadcrumb}>
|
||||||
<span className={styles.mandateName}>{navLabels?.mandate || mandate?.label || mandate?.name}</span>
|
<span className={styles.mandateName}>
|
||||||
|
{navLabels?.mandate || (mandate ? mandateDisplayLabel(mandate) : '')}
|
||||||
|
</span>
|
||||||
<span className={styles.separator}>/</span>
|
<span className={styles.separator}>/</span>
|
||||||
<span className={styles.featureName}>{navLabels?.feature || feature?.code}</span>
|
<span className={styles.featureName}>{navLabels?.feature || feature?.code}</span>
|
||||||
<span className={styles.separator}>/</span>
|
<span className={styles.separator}>/</span>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { formatUnixTimestamp } from '../utils/time';
|
||||||
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
|
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
import { useNavigation, type DynamicBlock } from '../hooks/useNavigation';
|
||||||
import styles from './admin/Admin.module.css';
|
import styles from './admin/Admin.module.css';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -53,6 +54,7 @@ interface SystemWorkflow {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
featureInstanceId: string;
|
featureInstanceId: string;
|
||||||
|
featureCode?: string;
|
||||||
label: string;
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
|
|
@ -72,6 +74,43 @@ interface SystemWorkflow {
|
||||||
graph?: Record<string, any>;
|
graph?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _FEATURES_WITH_EDITOR = new Set(['graphicalEditor', 'workspace']);
|
||||||
|
|
||||||
|
const _ROLE_PRIORITY: Record<string, number> = { admin: 3, user: 2, viewer: 1 };
|
||||||
|
|
||||||
|
function _bestEditorInstance(
|
||||||
|
dynamicBlock: DynamicBlock | null,
|
||||||
|
mandateId: string,
|
||||||
|
): { instanceId: string; featureCode: string } | null {
|
||||||
|
if (!dynamicBlock) return null;
|
||||||
|
const mandate = dynamicBlock.mandates.find((m) => m.id === mandateId);
|
||||||
|
if (!mandate) return null;
|
||||||
|
|
||||||
|
let best: { instanceId: string; featureCode: string; score: number } | null = null;
|
||||||
|
for (const feat of mandate.features) {
|
||||||
|
for (const inst of feat.instances) {
|
||||||
|
const fc = inst.featureCode
|
||||||
|
|| feat.uiComponent.replace(/^feature\./, '');
|
||||||
|
if (!_FEATURES_WITH_EDITOR.has(fc)) continue;
|
||||||
|
let score = 0;
|
||||||
|
if (inst.isAdmin) {
|
||||||
|
score = 10;
|
||||||
|
} else {
|
||||||
|
for (const v of inst.views) {
|
||||||
|
const key = v.objectKey || '';
|
||||||
|
for (const [suffix, prio] of Object.entries(_ROLE_PRIORITY)) {
|
||||||
|
if (key.endsWith(suffix) && prio > score) score = prio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best || score > best.score) {
|
||||||
|
best = { instanceId: inst.id, featureCode: fc, score };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ? { instanceId: best.instanceId, featureCode: best.featureCode } : null;
|
||||||
|
}
|
||||||
|
|
||||||
function _formatTs(ts?: number): string {
|
function _formatTs(ts?: number): string {
|
||||||
if (ts == null || ts <= 0) return '—';
|
if (ts == null || ts <= 0) return '—';
|
||||||
const sec = ts < 1e12 ? ts : ts / 1000;
|
const sec = ts < 1e12 ? ts : ts / 1000;
|
||||||
|
|
@ -281,11 +320,10 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) =>
|
||||||
}, [steps]);
|
}, [steps]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay} onClick={onClose}>
|
<div className={styles.modalOverlay}>
|
||||||
<div
|
<div
|
||||||
className={styles.modal}
|
className={styles.modal}
|
||||||
style={{ maxWidth: 800, height: '80vh' }}
|
style={{ maxWidth: 800, height: '80vh' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -664,6 +702,7 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||||
|
const { dynamicBlock } = useNavigation();
|
||||||
|
|
||||||
const [workflows, setWorkflows] = useState<SystemWorkflow[]>([]);
|
const [workflows, setWorkflows] = useState<SystemWorkflow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -718,10 +757,19 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
}, [hasRunningWorkflows, _load]);
|
}, [hasRunningWorkflows, _load]);
|
||||||
|
|
||||||
const _handleEdit = useCallback((row: SystemWorkflow) => {
|
const _handleEdit = useCallback((row: SystemWorkflow) => {
|
||||||
if (!row.mandateId || !row.featureInstanceId) return;
|
if (!row.mandateId) return;
|
||||||
const fc = (row as any).featureCode || 'graphicalEditor';
|
const fc = row.featureCode || '';
|
||||||
navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`);
|
if (_FEATURES_WITH_EDITOR.has(fc)) {
|
||||||
}, [navigate]);
|
navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const editor = _bestEditorInstance(dynamicBlock, row.mandateId);
|
||||||
|
if (!editor) {
|
||||||
|
showError(t('Kein Editor verfügbar für diesen Mandanten'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/mandates/${row.mandateId}/${editor.featureCode}/${editor.instanceId}/editor?workflowId=${row.id}`);
|
||||||
|
}, [navigate, showError, t, dynamicBlock]);
|
||||||
|
|
||||||
const _handleDelete = useCallback(async (workflowId: string): Promise<boolean> => {
|
const _handleDelete = useCallback(async (workflowId: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { useUserMandates } from '../hooks/useUserMandates';
|
||||||
import { useConfirm } from '../hooks/useConfirm';
|
import { useConfirm } from '../hooks/useConfirm';
|
||||||
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
||||||
import styles from './ComplianceAuditPage.module.css';
|
import styles from './ComplianceAuditPage.module.css';
|
||||||
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32'];
|
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32'];
|
||||||
|
|
||||||
|
|
@ -110,7 +111,11 @@ interface AuditStats {
|
||||||
neutralizationPercent: number;
|
neutralizationPercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Mandate { id: string; name?: string; label?: string; }
|
interface Mandate {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ContentModalData {
|
interface ContentModalData {
|
||||||
row: any;
|
row: any;
|
||||||
|
|
@ -133,7 +138,7 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('ai-log');
|
const [activeTab, setActiveTab] = useState<TabId>('audit-log');
|
||||||
|
|
||||||
// ── Tab A: AI-Log state ──
|
// ── Tab A: AI-Log state ──
|
||||||
const [aiEntries, setAiEntries] = useState<any[]>([]);
|
const [aiEntries, setAiEntries] = useState<any[]>([]);
|
||||||
|
|
@ -193,8 +198,13 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
const pageSize = paginationParams?.pageSize ?? _AI_LOG_PAGE_SIZE;
|
const pageSize = paginationParams?.pageSize ?? _AI_LOG_PAGE_SIZE;
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const params: any = { limit: pageSize, offset };
|
||||||
|
if (paginationParams?.sort?.length) params.sort = JSON.stringify(paginationParams.sort);
|
||||||
|
if (paginationParams?.filters && Object.keys(paginationParams.filters).length) params.filters = JSON.stringify(paginationParams.filters);
|
||||||
|
if (paginationParams?.search) params.search = paginationParams.search;
|
||||||
|
|
||||||
const { data } = await api.get('/api/audit/ai-log', {
|
const { data } = await api.get('/api/audit/ai-log', {
|
||||||
params: { limit: pageSize, offset },
|
params,
|
||||||
headers: _mandateHeaders(),
|
headers: _mandateHeaders(),
|
||||||
});
|
});
|
||||||
const items: any[] = data?.items ?? [];
|
const items: any[] = data?.items ?? [];
|
||||||
|
|
@ -220,8 +230,13 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
const pageSize = paginationParams?.pageSize ?? _AUDIT_LOG_PAGE_SIZE;
|
const pageSize = paginationParams?.pageSize ?? _AUDIT_LOG_PAGE_SIZE;
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const params: any = { limit: pageSize, offset };
|
||||||
|
if (paginationParams?.sort?.length) params.sort = JSON.stringify(paginationParams.sort);
|
||||||
|
if (paginationParams?.filters && Object.keys(paginationParams.filters).length) params.filters = JSON.stringify(paginationParams.filters);
|
||||||
|
if (paginationParams?.search) params.search = paginationParams.search;
|
||||||
|
|
||||||
const { data } = await api.get('/api/audit/log', {
|
const { data } = await api.get('/api/audit/log', {
|
||||||
params: { limit: pageSize, offset },
|
params,
|
||||||
headers: _mandateHeaders(),
|
headers: _mandateHeaders(),
|
||||||
});
|
});
|
||||||
const items: any[] = data?.items ?? [];
|
const items: any[] = data?.items ?? [];
|
||||||
|
|
@ -262,8 +277,13 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
const pageSize = paginationParams?.pageSize ?? _NEUT_PAGE_SIZE;
|
const pageSize = paginationParams?.pageSize ?? _NEUT_PAGE_SIZE;
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const neutParams: any = { limit: pageSize, offset };
|
||||||
|
if (paginationParams?.sort?.length) neutParams.sort = JSON.stringify(paginationParams.sort);
|
||||||
|
if (paginationParams?.filters && Object.keys(paginationParams.filters).length) neutParams.filters = JSON.stringify(paginationParams.filters);
|
||||||
|
if (paginationParams?.search) neutParams.search = paginationParams.search;
|
||||||
|
|
||||||
const { data } = await api.get('/api/audit/neutralization-mappings', {
|
const { data } = await api.get('/api/audit/neutralization-mappings', {
|
||||||
params: { limit: pageSize, offset },
|
params: neutParams,
|
||||||
headers: _mandateHeaders(),
|
headers: _mandateHeaders(),
|
||||||
});
|
});
|
||||||
const items: any[] = data?.items ?? [];
|
const items: any[] = data?.items ?? [];
|
||||||
|
|
@ -410,8 +430,8 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
|
formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 130,
|
key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
|
||||||
formatter: (val: any, row: any) => row?.instanceLabel || val || '–',
|
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'), type: 'text' as any, sortable: true, filterable: true, width: 160 },
|
||||||
{
|
{
|
||||||
|
|
@ -467,12 +487,12 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
{ key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 },
|
{ 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 },
|
{ key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||||
{
|
{
|
||||||
key: 'userId', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140,
|
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140,
|
||||||
formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–',
|
formatter: (val: any, row: any) => val || (row?.userId ? String(row.userId).slice(0, 8) + '…' : '–'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'featureInstanceId', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
|
key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
|
||||||
formatter: (val: any) => val || '–',
|
formatter: (val: any, row: any) => val || (row?.featureInstanceId ? String(row.featureInstanceId).slice(0, 8) + '…' : '–'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140,
|
key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140,
|
||||||
|
|
@ -480,26 +500,46 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
},
|
},
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
|
// ── fetchFilterValues for autofilter dropdowns ──
|
||||||
|
|
||||||
|
const _makeFetchFilterValues = useCallback(
|
||||||
|
(endpoint: string) => async (columnKey: string, crossFilters?: Record<string, any>) => {
|
||||||
|
if (!selectedMandateId) return [];
|
||||||
|
try {
|
||||||
|
const params: any = { mode: 'filterValues', column: columnKey };
|
||||||
|
if (crossFilters && Object.keys(crossFilters).length) {
|
||||||
|
params.filters = JSON.stringify(crossFilters);
|
||||||
|
}
|
||||||
|
const { data } = await api.get(endpoint, { params, headers: _mandateHeaders() });
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch { return []; }
|
||||||
|
},
|
||||||
|
[selectedMandateId], // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
);
|
||||||
|
|
||||||
// ── hookData for FormGeneratorTable ──
|
// ── hookData for FormGeneratorTable ──
|
||||||
|
|
||||||
const aiLogHookData = useMemo(() => ({
|
const aiLogHookData = useMemo(() => ({
|
||||||
refetch: _loadAiLog,
|
refetch: _loadAiLog,
|
||||||
pagination: aiPagination,
|
pagination: aiPagination,
|
||||||
}), [_loadAiLog, aiPagination]);
|
fetchFilterValues: _makeFetchFilterValues('/api/audit/ai-log'),
|
||||||
|
}), [_loadAiLog, aiPagination, _makeFetchFilterValues]);
|
||||||
|
|
||||||
const auditLogHookData = useMemo(() => ({
|
const auditLogHookData = useMemo(() => ({
|
||||||
refetch: _loadAuditLog,
|
refetch: _loadAuditLog,
|
||||||
pagination: auditPagination,
|
pagination: auditPagination,
|
||||||
}), [_loadAuditLog, auditPagination]);
|
fetchFilterValues: _makeFetchFilterValues('/api/audit/log'),
|
||||||
|
}), [_loadAuditLog, auditPagination, _makeFetchFilterValues]);
|
||||||
|
|
||||||
const neutHookData = useMemo(() => ({
|
const neutHookData = useMemo(() => ({
|
||||||
refetch: _loadNeutMappings,
|
refetch: _loadNeutMappings,
|
||||||
pagination: neutPagination,
|
pagination: neutPagination,
|
||||||
}), [_loadNeutMappings, neutPagination]);
|
fetchFilterValues: _makeFetchFilterValues('/api/audit/neutralization-mappings'),
|
||||||
|
}), [_loadNeutMappings, neutPagination, _makeFetchFilterValues]);
|
||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
|
||||||
const _tabs: TabId[] = ['ai-log', 'audit-log', 'stats', 'neutralization'];
|
const _tabs: TabId[] = ['audit-log', 'ai-log', 'neutralization', 'stats'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
|
|
@ -519,7 +559,7 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
<option value="">{mandatesLoading ? t('Lade…') : t('— Mandant wählen —')}</option>
|
<option value="">{mandatesLoading ? t('Lade…') : t('— Mandant wählen —')}</option>
|
||||||
{mandates.map(m => (
|
{mandates.map(m => (
|
||||||
<option key={m.id} value={m.id}>{m.label || m.name || m.id}</option>
|
<option key={m.id} value={m.id}>{mandateDisplayLabel(m)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -780,8 +820,8 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
|
|
||||||
{/* ── Content View Modal ── */}
|
{/* ── Content View Modal ── */}
|
||||||
{contentModal && (
|
{contentModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setContentModal(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modalContainer} onClick={e => e.stopPropagation()}>
|
<div className={styles.modalContainer}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h3 className={styles.modalTitle}>{t('AI-Audit Inhalt')}</h3>
|
<h3 className={styles.modalTitle}>{t('AI-Audit Inhalt')}</h3>
|
||||||
<div className={styles.modalMeta}>
|
<div className={styles.modalMeta}>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { PENDING_INVITATION_KEY } from './InvitePage';
|
||||||
import OnboardingWizard from '../components/OnboardingWizard';
|
import OnboardingWizard from '../components/OnboardingWizard';
|
||||||
|
|
||||||
import styles from './Login.module.css';
|
import styles from './Login.module.css';
|
||||||
|
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -131,6 +131,9 @@ function Login() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img
|
<img
|
||||||
src="/logos/poweron-logo.png"
|
src="/logos/poweron-logo.png"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||||
import styles from './PasswordResetRequest.module.css';
|
import styles from './PasswordResetRequest.module.css';
|
||||||
import { usePasswordResetRequest } from '../hooks/useAuthentication';
|
import { usePasswordResetRequest } from '../hooks/useAuthentication';
|
||||||
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
|
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -57,6 +58,9 @@ function PasswordResetRequest() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img
|
<img
|
||||||
src="/logos/poweron-logo.png"
|
src="/logos/poweron-logo.png"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import styles from './Register.module.css';
|
||||||
import { useRegister, useMsalRegister, useUsernameAvailability } from '../hooks/useAuthentication';
|
import { useRegister, useMsalRegister, useUsernameAvailability } from '../hooks/useAuthentication';
|
||||||
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
import { PENDING_INVITATION_KEY } from './InvitePage';
|
import { PENDING_INVITATION_KEY } from './InvitePage';
|
||||||
|
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -16,7 +17,7 @@ interface RegisterFormData {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Register() {
|
function Register() {
|
||||||
const { t } = useLanguage();
|
const { t, currentLanguage } = useLanguage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { register, error: registerError, isLoading } = useRegister();
|
const { register, error: registerError, isLoading } = useRegister();
|
||||||
|
|
@ -91,7 +92,7 @@ function Register() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await register({ ...formData, registrationType: 'personal' });
|
await register({ ...formData, language: currentLanguage, registrationType: 'personal' });
|
||||||
|
|
||||||
let message = t('Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.');
|
let message = t('Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.');
|
||||||
if (hasPendingInvitation) {
|
if (hasPendingInvitation) {
|
||||||
|
|
@ -125,6 +126,9 @@ function Register() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img
|
<img
|
||||||
src="/logos/poweron-logo.png"
|
src="/logos/poweron-logo.png"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import styles from './Reset.module.css';
|
import styles from './Reset.module.css';
|
||||||
import { usePasswordReset } from '../hooks/useAuthentication';
|
import { usePasswordReset } from '../hooks/useAuthentication';
|
||||||
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
|
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -98,6 +99,9 @@ function Reset() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img
|
<img
|
||||||
src="/logos/poweron-logo.png"
|
src="/logos/poweron-logo.png"
|
||||||
|
|
@ -138,6 +142,9 @@ function Reset() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img
|
<img
|
||||||
src="/logos/poweron-logo.png"
|
src="/logos/poweron-logo.png"
|
||||||
|
|
|
||||||
|
|
@ -11,21 +11,21 @@ import { setUserDataCache, getUserDataCache } from '../utils/userCache';
|
||||||
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
||||||
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
||||||
import { useApiRequest } from '../hooks/useApi';
|
import { useApiRequest } from '../hooks/useApi';
|
||||||
|
import { useVoiceCatalog } from '../contexts/VoiceCatalogContext';
|
||||||
import styles from './Settings.module.css';
|
import styles from './Settings.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy';
|
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy';
|
||||||
|
|
||||||
function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] {
|
function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] {
|
||||||
return [
|
return [
|
||||||
{ key: 'profile', label: t('Tab Profil') },
|
{ key: 'profile', label: t('Profil') },
|
||||||
{ key: 'appearance', label: t('Tab Darstellung') },
|
{ key: 'appearance', label: t('Darstellung') },
|
||||||
{ key: 'voice', label: t('Tab Stimme & Sprache') },
|
{ key: 'voice', label: t('Stimme & Sprache') },
|
||||||
{ key: 'neutralization', label: t('Tab Neutralisierung') },
|
{ key: 'privacy', label: t('Datenschutz') },
|
||||||
{ key: 'privacy', label: t('Tab Datenschutz') },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,8 +69,8 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay} onClick={onClose}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.modalContent}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2>{t('Profil bearbeiten')}</h2>
|
<h2>{t('Profil bearbeiten')}</h2>
|
||||||
<button className={styles.closeButton} onClick={onClose}>×</button>
|
<button className={styles.closeButton} onClick={onClose}>×</button>
|
||||||
|
|
@ -93,6 +93,7 @@ interface VoiceMapEntry { language: string; voiceName: string; }
|
||||||
const VoiceSettingsTab: React.FC = () => {
|
const VoiceSettingsTab: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
const { languages: voiceCatalog } = useVoiceCatalog();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
@ -101,7 +102,6 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
const [sttLanguage, setSttLanguage] = useState('de-DE');
|
const [sttLanguage, setSttLanguage] = useState('de-DE');
|
||||||
const [languages, setLanguages] = useState<any[]>([]);
|
|
||||||
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
|
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
|
||||||
|
|
||||||
const [addLanguage, setAddLanguage] = useState('de-DE');
|
const [addLanguage, setAddLanguage] = useState('de-DE');
|
||||||
|
|
@ -112,13 +112,7 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
const _loadSettings = useCallback(async () => {
|
const _loadSettings = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [prefsData, languagesData] = await Promise.all([
|
const prefsData = await request({ url: '/api/voice/preferences', method: 'get' });
|
||||||
request({ url: '/api/voice/preferences', method: 'get' }),
|
|
||||||
request({ url: '/api/voice/languages', method: 'get' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const langList = (languagesData as any)?.languages || [];
|
|
||||||
setLanguages(langList);
|
|
||||||
|
|
||||||
const prefs = prefsData as any;
|
const prefs = prefsData as any;
|
||||||
setSttLanguage(prefs?.sttLanguage || 'de-DE');
|
setSttLanguage(prefs?.sttLanguage || 'de-DE');
|
||||||
|
|
@ -204,16 +198,9 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
const _getLanguageName = useCallback((code: string) => {
|
const _getLanguageName = useCallback((code: string) => {
|
||||||
const found = languages.find((l: any) => (l.code || l) === code);
|
const entry = voiceCatalog.find(l => l.bcp47.toLowerCase() === code.toLowerCase());
|
||||||
return found?.name || found?.code || code;
|
return entry ? `${entry.flag ? entry.flag + ' ' : ''}${entry.label}` : code;
|
||||||
}, [languages]);
|
}, [voiceCatalog]);
|
||||||
|
|
||||||
const _defaultLangs = [
|
|
||||||
{ code: 'de-DE', name: 'Deutsch' }, { code: 'en-US', name: 'English (US)' },
|
|
||||||
{ code: 'fr-FR', name: 'Francais' }, { code: 'it-IT', name: 'Italiano' },
|
|
||||||
{ code: 'es-ES', name: 'Espanol' },
|
|
||||||
];
|
|
||||||
const _displayLanguages = languages.length > 0 ? languages : _defaultLangs;
|
|
||||||
|
|
||||||
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('Einstellungen werden geladen')}</div>;
|
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('Einstellungen werden geladen')}</div>;
|
||||||
|
|
||||||
|
|
@ -231,8 +218,10 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.settingControl}>
|
<div className={styles.settingControl}>
|
||||||
<select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}>
|
<select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}>
|
||||||
{_displayLanguages.map((lang: any) => (
|
{voiceCatalog.map(lang => (
|
||||||
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
|
<option key={lang.bcp47} value={lang.bcp47}>
|
||||||
|
{lang.flag ? `${lang.flag} ` : ''}{lang.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -275,8 +264,10 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>{t('Sprache')}</label>
|
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>{t('Sprache')}</label>
|
||||||
<select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}>
|
<select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}>
|
||||||
{_displayLanguages.map((lang: any) => (
|
{voiceCatalog.map(lang => (
|
||||||
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
|
<option key={lang.bcp47} value={lang.bcp47}>
|
||||||
|
{lang.flag ? `${lang.flag} ` : ''}{lang.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -563,19 +554,20 @@ export const SettingsPage: React.FC = () => {
|
||||||
|
|
||||||
{activeTab === 'voice' && <VoiceSettingsTab />}
|
{activeTab === 'voice' && <VoiceSettingsTab />}
|
||||||
|
|
||||||
{activeTab === 'neutralization' && <NeutralizationMappingsTab />}
|
|
||||||
|
|
||||||
{activeTab === 'privacy' && (
|
{activeTab === 'privacy' && (
|
||||||
<section className={styles.section}>
|
<>
|
||||||
<h2 className={styles.sectionTitle}>{t('Datenschutz')}</h2>
|
<section className={styles.section}>
|
||||||
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
<h2 className={styles.sectionTitle}>{t('Datenschutz')}</h2>
|
||||||
{t('Datenschutzbeschreibung')}
|
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||||
</p>
|
{t('Datenschutzbeschreibung')}
|
||||||
<div className={styles.settingRow}>
|
</p>
|
||||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('GDPR Datenschutz')}</label><p className={styles.settingDescription}>{t('Datenexport, Portabilität und Kontolöschung')}</p></div>
|
<div className={styles.settingRow}>
|
||||||
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">{t('GDPR öffnen')}</Link></div>
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('GDPR Datenschutz')}</label><p className={styles.settingDescription}>{t('Datenexport, Portabilität und Kontolöschung')}</p></div>
|
||||||
</div>
|
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">{t('GDPR öffnen')}</Link></div>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
<NeutralizationMappingsTab />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
.store {
|
.store {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1000px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem clamp(1rem, 2vw, 2.5rem);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,8 +78,9 @@
|
||||||
/* Grid */
|
/* Grid */
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(clamp(260px, 22vw, 340px), 1fr));
|
||||||
gap: 1.25rem;
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card */
|
/* Card */
|
||||||
|
|
@ -171,18 +172,47 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--surface-alt, rgba(0, 0, 0, 0.025));
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceRow {
|
.instanceRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--surface-color, #ffffff);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceInfo {
|
.instanceInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceMandate {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #555);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deactivateButtonSmall {
|
.deactivateButtonSmall {
|
||||||
|
|
@ -326,6 +356,23 @@
|
||||||
color: var(--success-color, #34d399);
|
color: var(--success-color, #34d399);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .instanceList {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .instanceRow {
|
||||||
|
background: var(--surface-dark, #1f1f1f);
|
||||||
|
border-color: var(--border-dark, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .instanceLabel {
|
||||||
|
color: var(--text-primary-dark, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .instanceMandate {
|
||||||
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.dark-theme) .statusInactive {
|
:global(.dark-theme) .statusInactive {
|
||||||
background: var(--surface-dark, #2a2a2a);
|
background: var(--surface-dark, #2a2a2a);
|
||||||
color: var(--text-secondary-dark, #aaa);
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
|
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram, FaShieldAlt } from 'react-icons/fa';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useStore } from '../hooks/useStore';
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
|
import { useStore, _storeActionKey } from '../hooks/useStore';
|
||||||
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
||||||
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
|
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
|
||||||
import styles from './Store.module.css';
|
import styles from './Store.module.css';
|
||||||
|
|
@ -18,6 +19,7 @@ const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
teamsbot: <FaHeadset />,
|
teamsbot: <FaHeadset />,
|
||||||
workspace: <FaComments />,
|
workspace: <FaComments />,
|
||||||
commcoach: <FaComments />,
|
commcoach: <FaComments />,
|
||||||
|
trustee: <FaShieldAlt />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fallback when GET /store/features omits description (German i18n keys). */
|
/** Fallback when GET /store/features omits description (German i18n keys). */
|
||||||
|
|
@ -27,6 +29,7 @@ const STORE_FEATURE_DESCRIPTION_FALLBACK: Record<string, string> = {
|
||||||
teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
||||||
workspace: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.',
|
workspace: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.',
|
||||||
commcoach: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.',
|
commcoach: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.',
|
||||||
|
trustee: 'Trustee: Intelligentes Dokumentenmanagement mit KI-gestützter Analyse und Verarbeitung.',
|
||||||
};
|
};
|
||||||
|
|
||||||
function _storeCardDescription(feature: StoreFeature): string {
|
function _storeCardDescription(feature: StoreFeature): string {
|
||||||
|
|
@ -37,7 +40,7 @@ function _storeCardDescription(feature: StoreFeature): string {
|
||||||
interface FeatureCardProps {
|
interface FeatureCardProps {
|
||||||
feature: StoreFeature;
|
feature: StoreFeature;
|
||||||
mandates: UserMandate[];
|
mandates: UserMandate[];
|
||||||
actionLoading: string | null;
|
actionLoading: Set<string>;
|
||||||
onActivate: (code: string, mandateId?: string) => void;
|
onActivate: (code: string, mandateId?: string) => void;
|
||||||
onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
|
onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +53,6 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
onDeactivate,
|
onDeactivate,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const isProcessing = actionLoading === feature.featureCode;
|
|
||||||
const icon = FEATURE_ICONS[feature.featureCode];
|
const icon = FEATURE_ICONS[feature.featureCode];
|
||||||
const activeInstances = feature.instances.filter(inst => inst.isActive);
|
const activeInstances = feature.instances.filter(inst => inst.isActive);
|
||||||
const hasActive = activeInstances.length > 0;
|
const hasActive = activeInstances.length > 0;
|
||||||
|
|
@ -72,23 +74,37 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
|
|
||||||
{activeInstances.length > 0 && (
|
{activeInstances.length > 0 && (
|
||||||
<div className={styles.instanceList}>
|
<div className={styles.instanceList}>
|
||||||
{activeInstances.map((inst) => (
|
{activeInstances.map((inst) => {
|
||||||
<div key={inst.instanceId} className={styles.instanceRow}>
|
const instanceLabel = (inst.label && inst.label.trim()) || feature.label;
|
||||||
<div className={styles.instanceInfo}>
|
const mandateLabel = inst.mandateName || '';
|
||||||
<span className={`${styles.statusBadge} ${styles.statusActive}`}>
|
const deactivateKey = _storeActionKey.deactivate(feature.featureCode, inst.instanceId);
|
||||||
<span className={styles.statusDot} />
|
const isDeactivating = actionLoading.has(deactivateKey);
|
||||||
{inst.mandateName || inst.label}
|
return (
|
||||||
</span>
|
<div key={inst.instanceId} className={styles.instanceRow}>
|
||||||
|
<div className={styles.instanceInfo}>
|
||||||
|
<span className={styles.instanceLabel}>
|
||||||
|
<span
|
||||||
|
className={`${styles.statusDot} ${styles.statusActive}`}
|
||||||
|
aria-label={t('Aktiv')}
|
||||||
|
/>
|
||||||
|
{instanceLabel}
|
||||||
|
</span>
|
||||||
|
{mandateLabel && (
|
||||||
|
<span className={styles.instanceMandate}>
|
||||||
|
{t('Mandant')}: {mandateLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.deactivateButtonSmall}
|
||||||
|
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
|
||||||
|
disabled={isDeactivating}
|
||||||
|
>
|
||||||
|
{isDeactivating ? '…' : t('Deaktivieren')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
);
|
||||||
className={styles.deactivateButtonSmall}
|
})}
|
||||||
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
{isProcessing ? '...' : t('Deaktivieren')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -102,18 +118,22 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.cardActions}>
|
<div className={styles.cardActions}>
|
||||||
{feature.canActivate && mandates.map((m) => (
|
{feature.canActivate && mandates.map((m) => {
|
||||||
<button
|
const activateKey = _storeActionKey.activate(feature.featureCode, m.id);
|
||||||
key={m.id}
|
const isActivating = actionLoading.has(activateKey);
|
||||||
className={styles.activateButton}
|
return (
|
||||||
onClick={() => onActivate(feature.featureCode, m.id)}
|
<button
|
||||||
disabled={isProcessing}
|
key={m.id}
|
||||||
>
|
className={styles.activateButton}
|
||||||
{isProcessing
|
onClick={() => onActivate(feature.featureCode, m.id)}
|
||||||
? t('Wird aktiviert…')
|
disabled={isActivating}
|
||||||
: t('Aktivieren für {name}', { name: String(m.label || m.name) })}
|
>
|
||||||
</button>
|
{isActivating
|
||||||
))}
|
? t('Wird aktiviert…')
|
||||||
|
: t('Aktivieren für {name}', { name: mandateDisplayLabel(m) })}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,10 @@ import { FeatureInstanceWizard } from './wizards/FeatureInstanceWizard';
|
||||||
import { InstanceHierarchyView } from './InstanceHierarchyView';
|
import { InstanceHierarchyView } from './InstanceHierarchyView';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
function getMandateName(mandate: Mandate): string {
|
function getMandateName(mandate: Mandate): string {
|
||||||
return mandate.label || mandate.name || mandate.id;
|
return mandateDisplayLabel(mandate);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFeatureLabel(feature: Feature): string {
|
function getFeatureLabel(feature: Feature): string {
|
||||||
|
|
|
||||||
6
src/pages/admin/AdminDatabaseHealthPage.module.css
Normal file
6
src/pages/admin/AdminDatabaseHealthPage.module.css
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/**
|
||||||
|
* AdminDatabaseHealthPage Styles
|
||||||
|
*
|
||||||
|
* Minimal — table rendering is handled by FormGeneratorTable.
|
||||||
|
* Only page-specific overrides live here.
|
||||||
|
*/
|
||||||
638
src/pages/admin/AdminDatabaseHealthPage.tsx
Normal file
638
src/pages/admin/AdminDatabaseHealthPage.tsx
Normal file
|
|
@ -0,0 +1,638 @@
|
||||||
|
/**
|
||||||
|
* AdminDatabaseHealthPage
|
||||||
|
*
|
||||||
|
* SysAdmin-only page with two tabs:
|
||||||
|
* 1. Table Statistics — pg_stat data for every table across all databases
|
||||||
|
* 2. Orphan Cleanup — FK orphan detection with per-relation + batch cleanup
|
||||||
|
*
|
||||||
|
* Both tabs use FormGeneratorTable with a client-side pagination/sort/filter
|
||||||
|
* adapter (the backend returns all rows at once; the dataset is small enough).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle } from 'react-icons/fa';
|
||||||
|
import api from '../../api';
|
||||||
|
import styles from './Admin.module.css';
|
||||||
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
import { Tabs } from '../../components/UiComponents/Tabs/Tabs';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TableStat {
|
||||||
|
id: string;
|
||||||
|
db: string;
|
||||||
|
table: string;
|
||||||
|
estimatedRows: number;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
indexSizeBytes: number;
|
||||||
|
lastVacuum: string | null;
|
||||||
|
lastAnalyze: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrphanEntry {
|
||||||
|
id: string;
|
||||||
|
sourceDb: string;
|
||||||
|
sourceTable: string;
|
||||||
|
sourceColumn: string;
|
||||||
|
targetDb: string;
|
||||||
|
targetTable: string;
|
||||||
|
targetColumn: string;
|
||||||
|
orphanCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CleanResult {
|
||||||
|
db: string;
|
||||||
|
table: string;
|
||||||
|
column: string;
|
||||||
|
deleted: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationMeta {
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
const value = bytes / Math.pow(1024, i);
|
||||||
|
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatNumber(n: number): string {
|
||||||
|
return n.toLocaleString('de-CH');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// useClientPagination — adapts a static array to FormGeneratorTable's
|
||||||
|
// hookData.refetch / hookData.pagination contract.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _useClientPagination<T extends Record<string, any>>(allData: T[]) {
|
||||||
|
const [visibleData, setVisibleData] = useState<T[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<PaginationMeta>({
|
||||||
|
currentPage: 1, pageSize: 50, totalItems: 0, totalPages: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allDataRef = useRef(allData);
|
||||||
|
allDataRef.current = allData;
|
||||||
|
|
||||||
|
const lastParamsRef = useRef<PaginationParams>({});
|
||||||
|
|
||||||
|
const fetchFilterValues = useCallback(async (columnKey: string, crossFilters?: Record<string, any>) => {
|
||||||
|
let source = allDataRef.current;
|
||||||
|
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 = String(row[key] ?? '');
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
if (val.length > 0 && !val.includes(cell)) return false;
|
||||||
|
} else {
|
||||||
|
if (cell !== String(val)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of source) {
|
||||||
|
const v = row[columnKey];
|
||||||
|
if (v !== undefined && v !== null && String(v).trim()) {
|
||||||
|
seen.add(String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(seen).sort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refetch = useCallback(async (params?: PaginationParams) => {
|
||||||
|
const p = params || lastParamsRef.current;
|
||||||
|
lastParamsRef.current = p;
|
||||||
|
const source = allDataRef.current;
|
||||||
|
|
||||||
|
const page = p.page || 1;
|
||||||
|
const pageSize = p.pageSize || 50;
|
||||||
|
const search = (p.search || '').toLowerCase();
|
||||||
|
const filters = p.filters || {};
|
||||||
|
const sorts = p.sort || [];
|
||||||
|
|
||||||
|
// 1) Filter
|
||||||
|
let filtered = source.filter(row => {
|
||||||
|
for (const [key, val] of Object.entries(filters)) {
|
||||||
|
if (val === undefined || val === null || val === '') continue;
|
||||||
|
const cell = String(row[key] ?? '');
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
if (val.length > 0 && !val.includes(cell)) return false;
|
||||||
|
} else {
|
||||||
|
if (cell !== String(val)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) Search
|
||||||
|
if (search) {
|
||||||
|
filtered = filtered.filter(row =>
|
||||||
|
Object.values(row).some(v => String(v ?? '').toLowerCase().includes(search)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Sort
|
||||||
|
if (sorts.length > 0) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
for (const s of sorts) {
|
||||||
|
const aVal = a[s.field];
|
||||||
|
const bVal = b[s.field];
|
||||||
|
let cmp = 0;
|
||||||
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||||
|
cmp = aVal - bVal;
|
||||||
|
} else {
|
||||||
|
cmp = String(aVal ?? '').localeCompare(String(bVal ?? ''));
|
||||||
|
}
|
||||||
|
if (cmp !== 0) return s.direction === 'desc' ? -cmp : cmp;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Paginate
|
||||||
|
const totalItems = filtered.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const start = (safePage - 1) * pageSize;
|
||||||
|
const paged = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
setVisibleData(paged);
|
||||||
|
setPagination({ currentPage: safePage, pageSize, totalItems, totalPages });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Re-apply whenever allData changes
|
||||||
|
useEffect(() => {
|
||||||
|
refetch(lastParamsRef.current);
|
||||||
|
}, [allData, refetch]);
|
||||||
|
|
||||||
|
return { visibleData, pagination, refetch, fetchFilterValues };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// StatsTab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const StatsTab: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [allStats, setAllStats] = useState<TableStat[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dbFilter, setDbFilter] = useState<string>('');
|
||||||
|
|
||||||
|
const _fetchStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : '';
|
||||||
|
const res = await api.get(`/api/admin/database-health/stats${params}`);
|
||||||
|
const rows = (res.data.stats || []).map((s: any, i: number) => ({
|
||||||
|
...s,
|
||||||
|
id: `${s.db}-${s.table}-${i}`,
|
||||||
|
}));
|
||||||
|
setAllStats(rows);
|
||||||
|
} catch {
|
||||||
|
setAllStats([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [dbFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { _fetchStats(); }, [_fetchStats]);
|
||||||
|
|
||||||
|
const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(allStats);
|
||||||
|
|
||||||
|
const databases = useMemo(
|
||||||
|
() => Array.from(new Set(allStats.map(s => s.db))).sort(),
|
||||||
|
[allStats],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
let rows = 0, size = 0, idx = 0;
|
||||||
|
for (const s of allStats) {
|
||||||
|
rows += s.estimatedRows;
|
||||||
|
size += s.totalSizeBytes;
|
||||||
|
idx += s.indexSizeBytes;
|
||||||
|
}
|
||||||
|
return { rows, size, idx, tables: allStats.length, dbs: databases.length };
|
||||||
|
}, [allStats, databases]);
|
||||||
|
|
||||||
|
const columns: ColumnConfig[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'db',
|
||||||
|
label: t('Datenbank'),
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 200,
|
||||||
|
filterOptions: databases,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'table',
|
||||||
|
label: t('Tabelle'),
|
||||||
|
sortable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'estimatedRows',
|
||||||
|
label: t('Zeilen (ca.)'),
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
width: 120,
|
||||||
|
formatter: (v: number) => _formatNumber(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalSizeBytes',
|
||||||
|
label: t('Total Size'),
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
width: 120,
|
||||||
|
formatter: (v: number) => _formatBytes(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'indexSizeBytes',
|
||||||
|
label: t('Index Size'),
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
width: 120,
|
||||||
|
formatter: (v: number) => _formatBytes(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastVacuum',
|
||||||
|
label: t('Last Vacuum'),
|
||||||
|
sortable: true,
|
||||||
|
width: 170,
|
||||||
|
formatter: (v: string | null) => v ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastAnalyze',
|
||||||
|
label: t('Last Analyze'),
|
||||||
|
sortable: true,
|
||||||
|
width: 170,
|
||||||
|
formatter: (v: string | null) => v ?? '—',
|
||||||
|
},
|
||||||
|
], [t, databases]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||||
|
{/* Controls */}
|
||||||
|
<div className={styles.filterSection}>
|
||||||
|
<div className={styles.filterGroup}>
|
||||||
|
<label className={styles.filterLabel}>{t('Datenbank')}</label>
|
||||||
|
<select
|
||||||
|
className={styles.filterSelect}
|
||||||
|
value={dbFilter}
|
||||||
|
onChange={e => setDbFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('Alle')}</option>
|
||||||
|
{databases.map(db => <option key={db} value={db}>{db}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button className={styles.secondaryButton} onClick={_fetchStats} disabled={loading}>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className={styles.filterSection} style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
|
<span className={styles.filterLabel}>{t('{dbs} Datenbanken', { dbs: totals.dbs })}</span>
|
||||||
|
<span className={styles.filterLabel}>{t('{tables} Tabellen', { tables: totals.tables })}</span>
|
||||||
|
<span className={styles.filterLabel}>{t('{rows} Zeilen (ca.)', { rows: _formatNumber(totals.rows) })}</span>
|
||||||
|
<span className={styles.filterLabel}>{t('Total {size}', { size: _formatBytes(totals.size) })}</span>
|
||||||
|
<span className={styles.filterLabel}>{t('Index {size}', { size: _formatBytes(totals.idx) })}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
<FormGeneratorTable
|
||||||
|
data={visibleData}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
sortable={true}
|
||||||
|
searchable={true}
|
||||||
|
filterable={true}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={50}
|
||||||
|
selectable={false}
|
||||||
|
hookData={{
|
||||||
|
refetch,
|
||||||
|
pagination,
|
||||||
|
fetchFilterValues,
|
||||||
|
}}
|
||||||
|
emptyMessage={t('Keine Tabellen gefunden')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OrphansTab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const OrphansTab: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const toast = useToast();
|
||||||
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
||||||
|
const [allOrphans, setAllOrphans] = useState<OrphanEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [cleaning, setCleaning] = useState<string | null>(null);
|
||||||
|
const [cleaningAll, setCleaningAll] = useState(false);
|
||||||
|
const [onlyProblems, setOnlyProblems] = useState(true);
|
||||||
|
const [dbFilter, setDbFilter] = useState<string>('');
|
||||||
|
|
||||||
|
const _fetchOrphans = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : '';
|
||||||
|
const res = await api.get(`/api/admin/database-health/orphans${params}`);
|
||||||
|
const rows = (res.data.orphans || []).map((o: any, i: number) => ({
|
||||||
|
...o,
|
||||||
|
id: `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}-${i}`,
|
||||||
|
}));
|
||||||
|
setAllOrphans(rows);
|
||||||
|
} catch {
|
||||||
|
setAllOrphans([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [dbFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { _fetchOrphans(); }, [_fetchOrphans]);
|
||||||
|
|
||||||
|
const displayed = useMemo(
|
||||||
|
() => onlyProblems ? allOrphans.filter(o => o.orphanCount > 0) : allOrphans,
|
||||||
|
[allOrphans, onlyProblems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(displayed);
|
||||||
|
|
||||||
|
const databases = useMemo(
|
||||||
|
() => Array.from(new Set(allOrphans.map(o => o.sourceDb))).sort(),
|
||||||
|
[allOrphans],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalOrphans = useMemo(() => allOrphans.reduce((s, o) => s + o.orphanCount, 0), [allOrphans]);
|
||||||
|
|
||||||
|
const _cleanOne = async (o: OrphanEntry) => {
|
||||||
|
const ok = await confirm(
|
||||||
|
t('{count} verwaiste Einträge in {table}.{column} löschen?', { count: o.orphanCount, table: o.sourceTable, column: o.sourceColumn }),
|
||||||
|
{ title: t('Orphans bereinigen'), variant: 'danger' },
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
|
||||||
|
setCleaning(key);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/admin/database-health/orphans/clean', {
|
||||||
|
db: o.sourceDb,
|
||||||
|
table: o.sourceTable,
|
||||||
|
column: o.sourceColumn,
|
||||||
|
});
|
||||||
|
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: res.data.deleted }));
|
||||||
|
_fetchOrphans();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen'));
|
||||||
|
} finally {
|
||||||
|
setCleaning(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _cleanAll = async () => {
|
||||||
|
const ok = await confirm(
|
||||||
|
t('{count} verwaiste Einträge in {relations} Beziehungen löschen?', {
|
||||||
|
count: totalOrphans,
|
||||||
|
relations: allOrphans.filter(o => o.orphanCount > 0).length,
|
||||||
|
}),
|
||||||
|
{ title: t('Alle Orphans bereinigen'), variant: 'danger' },
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
setCleaningAll(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/admin/database-health/orphans/clean-all');
|
||||||
|
const results: CleanResult[] = res.data.results || [];
|
||||||
|
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
|
||||||
|
const errors = results.filter(r => r.error);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
toast.showWarning(t('{deleted} gelöscht, {errors} Fehler', { deleted: totalDeleted, errors: errors.length }));
|
||||||
|
} else {
|
||||||
|
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: totalDeleted }));
|
||||||
|
}
|
||||||
|
_fetchOrphans();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen'));
|
||||||
|
} finally {
|
||||||
|
setCleaningAll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnConfig[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'sourceDb',
|
||||||
|
label: t('Source DB'),
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 180,
|
||||||
|
filterOptions: databases,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sourceTable',
|
||||||
|
label: t('Tabelle'),
|
||||||
|
sortable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sourceColumn',
|
||||||
|
label: t('FK-Spalte'),
|
||||||
|
sortable: true,
|
||||||
|
searchable: true,
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'targetTable',
|
||||||
|
label: t('Referenz'),
|
||||||
|
sortable: true,
|
||||||
|
width: 220,
|
||||||
|
formatter: (_val: string, row: OrphanEntry) => {
|
||||||
|
const isCrossDb = row.sourceDb !== row.targetDb;
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<code>{row.targetTable}.{row.targetColumn}</code>
|
||||||
|
{isCrossDb && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '0.4rem',
|
||||||
|
padding: '0.125rem 0.375rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.625rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
background: 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))',
|
||||||
|
color: 'var(--primary-color, #f25843)',
|
||||||
|
}}>
|
||||||
|
{t('cross-db')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'orphanCount',
|
||||||
|
label: t('Orphans'),
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
width: 100,
|
||||||
|
formatter: (v: number) => (
|
||||||
|
<span style={v > 0 ? { color: 'var(--danger-color, #e53e3e)', fontWeight: 600 } : undefined}>
|
||||||
|
{_formatNumber(v)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [t, databases]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||||
|
<ConfirmDialog />
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className={styles.filterSection}>
|
||||||
|
<div className={styles.filterGroup}>
|
||||||
|
<label className={styles.filterLabel}>{t('Datenbank')}</label>
|
||||||
|
<select
|
||||||
|
className={styles.filterSelect}
|
||||||
|
value={dbFilter}
|
||||||
|
onChange={e => setDbFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('Alle')}</option>
|
||||||
|
{databases.map(db => <option key={db} value={db}>{db}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={styles.filterGroup}>
|
||||||
|
<label className={styles.checkboxLabel}>
|
||||||
|
<input type="checkbox" checked={onlyProblems} onChange={e => setOnlyProblems(e.target.checked)} />
|
||||||
|
{t('Nur Probleme')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button className={styles.secondaryButton} onClick={_fetchOrphans} disabled={loading}>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
|
||||||
|
</button>
|
||||||
|
{totalOrphans > 0 && (
|
||||||
|
<button className={styles.dangerButton} onClick={_cleanAll} disabled={cleaningAll || loading}>
|
||||||
|
<FaBroom className={cleaningAll ? 'spinning' : ''} /> {t('Alle bereinigen')} ({_formatNumber(totalOrphans)})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalOrphans > 0 && (
|
||||||
|
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||||
|
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
||||||
|
{t('{count} verwaiste Einträge in {relations} Beziehungen gefunden', {
|
||||||
|
count: _formatNumber(totalOrphans),
|
||||||
|
relations: allOrphans.filter(o => o.orphanCount > 0).length,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
<FormGeneratorTable
|
||||||
|
data={visibleData}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
sortable={true}
|
||||||
|
searchable={true}
|
||||||
|
filterable={true}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={50}
|
||||||
|
selectable={false}
|
||||||
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'clean',
|
||||||
|
icon: <FaTrashAlt />,
|
||||||
|
onClick: (row: OrphanEntry) => _cleanOne(row),
|
||||||
|
visible: (row: OrphanEntry) => row.orphanCount > 0,
|
||||||
|
loading: (row: OrphanEntry) => cleaning === `${row.sourceDb}.${row.sourceTable}.${row.sourceColumn}` || cleaningAll,
|
||||||
|
title: t('Orphans löschen'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
hookData={{
|
||||||
|
refetch,
|
||||||
|
pagination,
|
||||||
|
fetchFilterValues,
|
||||||
|
}}
|
||||||
|
emptyMessage={onlyProblems ? t('Keine Orphans gefunden') : t('Keine FK-Beziehungen gefunden')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const AdminDatabaseHealthPage: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const tabs = useMemo(() => [
|
||||||
|
{
|
||||||
|
id: 'stats',
|
||||||
|
label: t('Statistiken'),
|
||||||
|
content: <StatsTab />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orphans',
|
||||||
|
label: t('Orphan Cleanup'),
|
||||||
|
content: <OrphansTab />,
|
||||||
|
},
|
||||||
|
], [t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
|
||||||
|
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken und verwaiste Datensätze')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs tabs={tabs} defaultTabId="stats" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminDatabaseHealthPage;
|
||||||
|
|
@ -15,11 +15,11 @@ import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { ChatbotConfigSection } from './ChatbotConfigSection';
|
import { ChatbotConfigSection } from './ChatbotConfigSection';
|
||||||
import { DropdownSelect } from '../../components/UiComponents/DropdownSelect';
|
|
||||||
import { TextField } from '../../components/UiComponents/TextField';
|
import { TextField } from '../../components/UiComponents/TextField';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
export const AdminFeatureAccessPage: React.FC = () => {
|
export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -337,11 +337,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get mandate name
|
|
||||||
const getMandateName = (mandate: Mandate) => {
|
|
||||||
return mandate.label || mandate.name || mandate.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get feature label
|
// Get feature label
|
||||||
const getFeatureLabel = (code: string) => {
|
const getFeatureLabel = (code: string) => {
|
||||||
const feature = features.find(f => f.code === code);
|
const feature = features.find(f => f.code === code);
|
||||||
|
|
@ -386,7 +381,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
<option value="">{t('Mandant wählen')}</option>
|
<option value="">{t('Mandant wählen')}</option>
|
||||||
{mandates.map(m => (
|
{mandates.map(m => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{getMandateName(m)}
|
{mandateDisplayLabel(m)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -512,8 +507,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Create Instance Modal */}
|
{/* Create Instance Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Neue Feature-Instanz erstellen')}</h2>
|
<h2 className={styles.modalTitle}>{t('Neue Feature-Instanz erstellen')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -533,35 +528,38 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{/* Feature Code Selector - Required for chatbot config */}
|
{/* Feature Code Selector — buttons instead of dropdown */}
|
||||||
<div className={styles.configField} style={{ marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border-color)' }}>
|
<div className={styles.configField} style={{ marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border-color)' }}>
|
||||||
<label className={styles.configLabel} style={{ fontWeight: 600 }}>
|
<label className={styles.configLabel} style={{ fontWeight: 600 }}>
|
||||||
{t('Feature auswählen')}: <span style={{ color: 'var(--error-color)' }}>*</span>
|
{t('Feature auswählen')}: <span style={{ color: 'var(--error-color)' }}>*</span>
|
||||||
</label>
|
</label>
|
||||||
<DropdownSelect
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
items={features.map(f => ({
|
{features.map(f => (
|
||||||
id: f.code,
|
<button
|
||||||
label: f.label || f.code,
|
key={f.code}
|
||||||
value: f.code
|
type="button"
|
||||||
}))}
|
className={styles.secondaryButton}
|
||||||
selectedItemId={createFeatureCode}
|
style={{
|
||||||
onSelect={(item) => {
|
padding: '0.5rem 1rem',
|
||||||
const selectedCode = item?.value || '';
|
borderRadius: '6px',
|
||||||
setCreateFeatureCode(selectedCode);
|
cursor: 'pointer',
|
||||||
// Reset chatbot config when switching
|
fontWeight: createFeatureCode === f.code ? 600 : 400,
|
||||||
setChatbotConnectors(['preprocessor']);
|
background: createFeatureCode === f.code ? 'var(--primary-color)' : undefined,
|
||||||
setChatbotSystemPrompt('');
|
color: createFeatureCode === f.code ? '#fff' : undefined,
|
||||||
setChatbotEnableWebResearch(true);
|
borderColor: createFeatureCode === f.code ? 'var(--primary-color)' : undefined,
|
||||||
setChatbotAllowedProviders([]);
|
}}
|
||||||
}}
|
onClick={() => {
|
||||||
placeholder={t('Feature-Auswahl erforderlich')}
|
setCreateFeatureCode(f.code);
|
||||||
className={styles.configSelect}
|
setChatbotConnectors(['preprocessor']);
|
||||||
/>
|
setChatbotSystemPrompt('');
|
||||||
{!createFeatureCode && (
|
setChatbotEnableWebResearch(true);
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
setChatbotAllowedProviders([]);
|
||||||
{t('Bitte wählen Sie ein Feature aus, um fortzufahren.')}
|
}}
|
||||||
</p>
|
>
|
||||||
)}
|
{f.label || f.code}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chatbot Configuration Title - Show when chatbot is selected */}
|
{/* Chatbot Configuration Title - Show when chatbot is selected */}
|
||||||
|
|
@ -634,8 +632,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Edit Instance Modal */}
|
{/* Edit Instance Modal */}
|
||||||
{showEditModal && editingInstance && (
|
{showEditModal && editingInstance && (
|
||||||
<div className={styles.modalOverlay} onClick={() => { setShowEditModal(false); setEditingInstance(null); }}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Feature-Instanz bearbeiten')}</h2>
|
<h2 className={styles.modalTitle}>{t('Feature-Instanz bearbeiten')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -92,7 +93,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
allOptions.push({
|
allOptions.push({
|
||||||
mandateId: mandate.id,
|
mandateId: mandate.id,
|
||||||
instanceId: inst.id,
|
instanceId: inst.id,
|
||||||
mandateName: mandate.label || mandate.name || mandate.id,
|
mandateName: mandateDisplayLabel(mandate),
|
||||||
instanceLabel: inst.label || inst.id,
|
instanceLabel: inst.label || inst.id,
|
||||||
featureCode: inst.featureCode,
|
featureCode: inst.featureCode,
|
||||||
combinedKey: `${mandate.id}:${inst.id}`,
|
combinedKey: `${mandate.id}:${inst.id}`,
|
||||||
|
|
@ -561,8 +562,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Add User Modal */}
|
{/* Add User Modal */}
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Benutzer zur Feature-Instanz hinzufügen')}</h2>
|
<h2 className={styles.modalTitle}>{t('Benutzer zur Feature-Instanz hinzufügen')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -594,8 +595,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Edit Roles Modal */}
|
{/* Edit Roles Modal */}
|
||||||
{editingUser && (
|
{editingUser && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>
|
<h2 className={styles.modalTitle}>
|
||||||
{t('Rollen bearbeiten')}: {editingUser.username}
|
{t('Rollen bearbeiten')}: {editingUser.username}
|
||||||
|
|
|
||||||
|
|
@ -397,8 +397,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Create Role Modal */}
|
{/* Create Role Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Neue Feature-Rolle erstellen')}</h2>
|
<h2 className={styles.modalTitle}>{t('Neue Feature-Rolle erstellen')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -430,8 +430,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Edit Role Modal */}
|
{/* Edit Role Modal */}
|
||||||
{editingRole && (
|
{editingRole && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setEditingRole(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Feature-Rolle bearbeiten')}</h2>
|
<h2 className={styles.modalTitle}>{t('Feature-Rolle bearbeiten')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -462,8 +462,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Permissions Modal */}
|
{/* Permissions Modal */}
|
||||||
{permissionsRole && (
|
{permissionsRole && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setPermissionsRole(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} style={{ maxWidth: '900px', width: '90%' }} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal} style={{ maxWidth: '900px', width: '90%' }}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>
|
<h2 className={styles.modalTitle}>
|
||||||
<FaShieldAlt style={{ marginRight: 8 }} />
|
<FaShieldAlt style={{ marginRight: 8 }} />
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
export const AdminInvitationsPage: React.FC = () => {
|
export const AdminInvitationsPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -235,10 +236,6 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get mandate name
|
|
||||||
const getMandateName = (mandate: Mandate) => {
|
|
||||||
return mandate.label || mandate.name || mandate.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -280,7 +277,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
<option value="">{t('Mandant wählen')}</option>
|
<option value="">{t('Mandant wählen')}</option>
|
||||||
{mandates.map(m => (
|
{mandates.map(m => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{getMandateName(m)}
|
{mandateDisplayLabel(m)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -372,8 +369,8 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Create Invitation Modal */}
|
{/* Create Invitation Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Neue Einladung erstellen')}</h2>
|
<h2 className={styles.modalTitle}>{t('Neue Einladung erstellen')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -411,8 +408,8 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
|
|
||||||
{/* URL Display Modal */}
|
{/* URL Display Modal */}
|
||||||
{showUrlModal && (
|
{showUrlModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowUrlModal(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Einladungs-Link')}</h2>
|
<h2 className={styles.modalTitle}>{t('Einladungs-Link')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
// Types for cleanup result
|
// Types for cleanup result
|
||||||
interface DuplicateGroup {
|
interface DuplicateGroup {
|
||||||
|
|
@ -279,7 +280,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
{mandates.map(mandate => (
|
{mandates.map(mandate => (
|
||||||
<option key={mandate.id} value={mandate.id}>
|
<option key={mandate.id} value={mandate.id}>
|
||||||
{mandate.label || getTextValue(mandate.name)}
|
{mandateDisplayLabel(mandate)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -388,8 +389,8 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Cleanup Duplicates Modal */}
|
{/* Cleanup Duplicates Modal */}
|
||||||
{showCleanupModal && (
|
{showCleanupModal && (
|
||||||
<div className={styles.modalOverlay} onClick={_closeCleanupModal}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} style={{ maxWidth: '750px' }} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.modal} style={{ maxWidth: '750px' }}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h3 className={styles.modalTitle}>
|
<h3 className={styles.modalTitle}>
|
||||||
<FaBroom style={{ marginRight: '0.5rem' }} />
|
<FaBroom style={{ marginRight: '0.5rem' }} />
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
export const AdminMandateRolesPage: React.FC = () => {
|
export const AdminMandateRolesPage: React.FC = () => {
|
||||||
const { t, currentLanguage } = useLanguage();
|
const { t, currentLanguage } = useLanguage();
|
||||||
|
|
@ -273,11 +274,6 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
setEditingRole(role);
|
setEditingRole(role);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get mandate name
|
|
||||||
const getMandateName = (mandate: Mandate) => {
|
|
||||||
return mandate.label || mandate.name || mandate.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
|
|
@ -334,7 +330,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
<option value="">{t('Mandant wählen')}</option>
|
<option value="">{t('Mandant wählen')}</option>
|
||||||
{mandates.map(m => (
|
{mandates.map(m => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{getMandateName(m)}
|
{mandateDisplayLabel(m)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -434,8 +430,8 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Create Role Modal */}
|
{/* Create Role Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Neue Rolle erstellen')}</h2>
|
<h2 className={styles.modalTitle}>{t('Neue Rolle erstellen')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -468,8 +464,8 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Edit Role Modal */}
|
{/* Edit Role Modal */}
|
||||||
{editingRole && (
|
{editingRole && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setEditingRole(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>
|
<h2 className={styles.modalTitle}>
|
||||||
{t('Rolle bearbeiten')}: {editingRole.roleLabel}
|
{t('Rolle bearbeiten')}: {editingRole.roleLabel}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
|
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
|
import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
|
|
@ -16,8 +16,9 @@ import {
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { usePrompt } from '../../hooks/usePrompt';
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
|
||||||
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
@ -59,6 +60,17 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
|
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
|
||||||
const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
|
const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true;
|
||||||
|
|
||||||
|
// MandateAdmin: only label + billing fields editable; rest readonly
|
||||||
|
const _MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']);
|
||||||
|
const editFormAttrs: AttributeDefinition[] = useMemo(() => {
|
||||||
|
if (isPlatformAdmin) return formAttributesWithBilling;
|
||||||
|
return formAttributesWithBilling.map(attr =>
|
||||||
|
_MANDATE_ADMIN_EDITABLE.has(attr.name) ? attr : { ...attr, editable: false, readonly: true }
|
||||||
|
);
|
||||||
|
}, [formAttributesWithBilling, isPlatformAdmin]);
|
||||||
|
|
||||||
// Check if user can create
|
// Check if user can create
|
||||||
const canCreate = permissions?.create !== 'n';
|
const canCreate = permissions?.create !== 'n';
|
||||||
const canUpdate = permissions?.update !== 'n';
|
const canUpdate = permissions?.update !== 'n';
|
||||||
|
|
@ -106,7 +118,10 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
const mandateId = String(editingFormData.id);
|
const mandateId = String(editingFormData.id);
|
||||||
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
||||||
const mandateOk = await handleUpdate(mandateId, mandatePayload as Partial<Mandate>);
|
const mandateOk = await handleUpdate(mandateId, mandatePayload as Partial<Mandate>);
|
||||||
if (!mandateOk) return;
|
if (!mandateOk) {
|
||||||
|
showWarning(t('Fehler'), t('Mandant konnte nicht gespeichert werden. Fehlende Berechtigung oder Serverfehler.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await updateSettingsAdmin(request, mandateId, billingUpdate);
|
await updateSettingsAdmin(request, mandateId, billingUpdate);
|
||||||
showSuccess(t('Gespeichert'), t('Mandant und Abrechnung aktualisiert.'));
|
showSuccess(t('Gespeichert'), t('Mandant und Abrechnung aktualisiert.'));
|
||||||
|
|
@ -123,12 +138,15 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entered = await prompt(
|
const entered = await prompt(
|
||||||
t('Um den Mandanten "{name}" zu deaktivieren (Soft-Delete), geben Sie den Namen ein:', { name: mandate.name }),
|
t(
|
||||||
|
'Um den Mandanten zu deaktivieren (Soft-Delete), geben Sie das Kurzzeichen «{slug}» exakt ein (Anzeigename: «{label}»).',
|
||||||
|
{ slug: mandate.name, label: mandate.label || mandate.name }
|
||||||
|
),
|
||||||
{ title: t('Mandat deaktivieren'), confirmLabel: t('Deaktivieren'), variant: 'danger', placeholder: mandate.name },
|
{ title: t('Mandat deaktivieren'), confirmLabel: t('Deaktivieren'), variant: 'danger', placeholder: mandate.name },
|
||||||
);
|
);
|
||||||
if (entered === null) return;
|
if (entered === null) return;
|
||||||
if (entered !== mandate.name) {
|
if (entered !== mandate.name) {
|
||||||
showWarning(t('Abgebrochen'), t('Der eingegebene Name stimmt nicht überein.'));
|
showWarning(t('Abgebrochen'), t('Das eingegebene Kurzzeichen stimmt nicht überein.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await handleDelete(mandate.id);
|
await handleDelete(mandate.id);
|
||||||
|
|
@ -140,17 +158,23 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entered = await prompt(
|
const entered = await prompt(
|
||||||
t('ACHTUNG: Dies löscht den Mandanten "{name}" unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie den exakten Namen ein:', { name: mandate.name }),
|
t(
|
||||||
|
'ACHTUNG: Dies löscht den Mandanten unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie das Kurzzeichen «{slug}» exakt ein (Anzeigename: «{label}»).',
|
||||||
|
{ slug: mandate.name, label: mandate.label || mandate.name }
|
||||||
|
),
|
||||||
{ title: t('Unwiderrufliches Löschen'), confirmLabel: t('Dauerhaft löschen'), variant: 'danger', placeholder: mandate.name },
|
{ title: t('Unwiderrufliches Löschen'), confirmLabel: t('Dauerhaft löschen'), variant: 'danger', placeholder: mandate.name },
|
||||||
);
|
);
|
||||||
if (entered === null) return;
|
if (entered === null) return;
|
||||||
if (entered !== mandate.name) {
|
if (entered !== mandate.name) {
|
||||||
showWarning(t('Abgebrochen'), t('Der eingegebene Name stimmt nicht überein.'));
|
showWarning(t('Abgebrochen'), t('Das eingegebene Kurzzeichen stimmt nicht überein.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await handleHardDelete(mandate.id, entered);
|
const ok = await handleHardDelete(mandate.id, entered);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
showSuccess(t('Gelöscht'), t('Mandant "{name}" wurde endgültig gelöscht.', { name: mandate.name }));
|
showSuccess(
|
||||||
|
t('Gelöscht'),
|
||||||
|
t('Mandant «{name}» wurde endgültig gelöscht.', { name: mandate.label || mandate.name })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -175,7 +199,13 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Mandanten')}</h1>
|
<h1 className={styles.pageTitle}>{t('Mandanten')}</h1>
|
||||||
<p className={styles.pageSubtitle}>{t('Verwalten Sie alle Mandanten im')}</p>
|
<p className={styles.pageSubtitle}>
|
||||||
|
{t('Verwalten Sie alle Mandanten im')}
|
||||||
|
{' '}
|
||||||
|
{t(
|
||||||
|
'Der Volle Name erscheint in der Oberfläche; das Kurzzeichen ist systemweit eindeutig und dient Referenzierung und Bestätigungsabfragen.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -253,8 +283,8 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Neuer Mandant')}</h2>
|
<h2 className={styles.modalTitle}>{t('Neuer Mandant')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -293,14 +323,8 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingFormData && (
|
{editingFormData && (
|
||||||
<div
|
<div className={styles.modalOverlay}>
|
||||||
className={styles.modalOverlay}
|
<div className={styles.modal}>
|
||||||
onClick={() => {
|
|
||||||
setEditingFormData(null);
|
|
||||||
setEditingBillingWarning(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Mandant bearbeiten')}</h2>
|
<h2 className={styles.modalTitle}>{t('Mandant bearbeiten')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -319,7 +343,9 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
||||||
<span>
|
<span>
|
||||||
{t('Dies ist ein')} <strong>{t('System-Mandant')}</strong>.{' '}
|
{t('Dies ist ein')} <strong>{t('System-Mandant')}</strong>.{' '}
|
||||||
{t('Er kann nicht gelöscht werden und der Name sollte nicht geändert werden.')}
|
{t(
|
||||||
|
'Er kann nicht gelöscht werden. Das Kurzzeichen (technischer Identifier) soll nicht geändert werden; der Volle Name kann bei Bedarf angepasst werden.'
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -338,7 +364,7 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FormGeneratorForm
|
<FormGeneratorForm
|
||||||
attributes={formAttributesWithBilling}
|
attributes={editFormAttrs}
|
||||||
data={editingFormData}
|
data={editingFormData}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLineLabelThenSlug } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
interface UserOption {
|
interface UserOption {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -18,6 +19,7 @@ interface UserOption {
|
||||||
email: string;
|
email: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
isSysAdmin: boolean;
|
isSysAdmin: boolean;
|
||||||
|
isPlatformAdmin: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,14 +62,6 @@ interface MandateInfo {
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function _mandateNameLine(mandate: MandateInfo): string {
|
|
||||||
const label = mandate.label?.trim();
|
|
||||||
if (label) {
|
|
||||||
return `${mandate.name} (${label})`;
|
|
||||||
}
|
|
||||||
return mandate.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _roleDescriptionLine(role: RoleInfo): string {
|
function _roleDescriptionLine(role: RoleInfo): string {
|
||||||
return role.description?.trim() || '';
|
return role.description?.trim() || '';
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +69,7 @@ function _roleDescriptionLine(role: RoleInfo): string {
|
||||||
interface UserAccessOverview {
|
interface UserAccessOverview {
|
||||||
user: UserOption;
|
user: UserOption;
|
||||||
isSysAdmin: boolean;
|
isSysAdmin: boolean;
|
||||||
|
isPlatformAdmin: boolean;
|
||||||
sysAdminNote?: string;
|
sysAdminNote?: string;
|
||||||
roles: RoleInfo[];
|
roles: RoleInfo[];
|
||||||
mandates: MandateInfo[];
|
mandates: MandateInfo[];
|
||||||
|
|
@ -201,7 +196,13 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
{overview.isSysAdmin && (
|
{overview.isSysAdmin && (
|
||||||
<div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}>
|
<div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}>
|
||||||
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
|
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
|
||||||
<span>{overview.sysAdminNote || t('Dieser Benutzer ist SysAdmin und hat vollen Systemzugriff.')}</span>
|
<span>{overview.sysAdminNote || t('Dieser Benutzer ist Systemadmin (Infrastruktur-Operator) und hat vollen Datenzugriff (RBAC-Bypass).')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{overview.isPlatformAdmin && (
|
||||||
|
<div className={styles.infoBox} style={{ background: '#dbeafe', borderColor: '#3b82f6' }}>
|
||||||
|
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#3b82f6' }} />
|
||||||
|
<span>{t('Dieser Benutzer ist Plattformadmin und kann mandantsübergreifend User, Mandate und RBAC-Regeln verwalten (kein RBAC-Bypass).')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -223,7 +224,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<FaChevronRight className={styles.expandIcon} />
|
<FaChevronRight className={styles.expandIcon} />
|
||||||
)}
|
)}
|
||||||
<span className={styles.roleLabel}>{_mandateNameLine(mandate)}</span>
|
<span className={styles.roleLabel}>{mandateDisplayLineLabelThenSlug(mandate)}</span>
|
||||||
<span className={styles.roleDescription}>
|
<span className={styles.roleDescription}>
|
||||||
{t('{r} Mandantenrolle(n) · {i} Feature-Instanz(en)', {
|
{t('{r} Mandantenrolle(n) · {i} Feature-Instanz(en)', {
|
||||||
r: mandateRoles.length,
|
r: mandateRoles.length,
|
||||||
|
|
@ -623,6 +624,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
<option key={user.id} value={user.id}>
|
<option key={user.id} value={user.id}>
|
||||||
{user.fullName || user.username} ({user.email})
|
{user.fullName || user.username} ({user.email})
|
||||||
{user.isSysAdmin && ` [${t('SysAdmin')}]`}
|
{user.isSysAdmin && ` [${t('SysAdmin')}]`}
|
||||||
|
{user.isPlatformAdmin && ` [${t('PlatformAdmin')}]`}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -668,6 +670,14 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{overview.isPlatformAdmin && (
|
||||||
|
<>
|
||||||
|
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
|
||||||
|
<span className={styles.badge} style={{ background: '#3b82f6', color: 'white' }}>
|
||||||
|
{t('PlatformAdmin')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
export const AdminUserMandatesPage: React.FC = () => {
|
export const AdminUserMandatesPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -250,11 +251,6 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
setEditingUser(user);
|
setEditingUser(user);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get mandate name
|
|
||||||
const getMandateName = (mandate: Mandate) => {
|
|
||||||
return mandate.label || mandate.name || mandate.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
|
|
@ -295,7 +291,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
<option value="">{t('Mandant wählen')}</option>
|
<option value="">{t('Mandant wählen')}</option>
|
||||||
{mandates.map(m => (
|
{mandates.map(m => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{getMandateName(m)}
|
{mandateDisplayLabel(m)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -375,8 +371,8 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Add User Modal */}
|
{/* Add User Modal */}
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Benutzer zum Mandanten hinzufügen')}</h2>
|
<h2 className={styles.modalTitle}>{t('Benutzer zum Mandanten hinzufügen')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -411,8 +407,8 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Edit Roles Modal */}
|
{/* Edit Roles Modal */}
|
||||||
{editingUser && (
|
{editingUser && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>
|
<h2 className={styles.modalTitle}>
|
||||||
{t('Rollen bearbeiten')}: {editingUser.username}
|
{t('Rollen bearbeiten')}: {editingUser.username}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,12 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const;
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -21,6 +24,7 @@ interface User {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
isSysAdmin?: boolean;
|
isSysAdmin?: boolean;
|
||||||
|
isPlatformAdmin?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,17 +119,41 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
await handleSendPasswordLink(user.id);
|
await handleSendPasswordLink(user.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Form attributes from backend - filter for create/edit forms
|
// Privileged-flag gating mirrors the backend rules in routeDataUsers.update_user
|
||||||
const formAttributes = useMemo(() => {
|
// and create_user: only a Platform-Admin may set isSysAdmin / isPlatformAdmin,
|
||||||
|
// and even then never on themselves (Self-Protection).
|
||||||
|
const currentUserCache = getUserDataCache();
|
||||||
|
const callerIsPlatformAdmin = currentUserCache?.isPlatformAdmin === true;
|
||||||
|
const callerId = currentUserCache?.id;
|
||||||
|
|
||||||
|
const _buildFormAttributes = (mode: 'create' | 'edit', targetUserId?: string) => {
|
||||||
const excludedFields = ['id', 'hashedPassword', 'authenticationAuthority'];
|
const excludedFields = ['id', 'hashedPassword', 'authenticationAuthority'];
|
||||||
|
const isSelfEdit = mode === 'edit' && targetUserId !== undefined && targetUserId === callerId;
|
||||||
|
// Caller may flip flags only when PlatformAdmin AND not editing themselves.
|
||||||
|
const flagsEditable = callerIsPlatformAdmin && !isSelfEdit;
|
||||||
|
|
||||||
return (attributes || [])
|
return (attributes || [])
|
||||||
.filter(attr => !excludedFields.includes(attr.name))
|
.filter(attr => !excludedFields.includes(attr.name))
|
||||||
.map(attr => ({
|
.map(attr => {
|
||||||
...attr,
|
if (_PRIVILEGED_FLAGS.includes(attr.name as any) && !flagsEditable) {
|
||||||
// Mark username as readonly for edit mode (will be handled by FormGeneratorForm)
|
return { ...attr, editable: false };
|
||||||
editable: attr.name === 'username' ? false : attr.editable,
|
}
|
||||||
}));
|
if (attr.name === 'username') {
|
||||||
}, [attributes]);
|
return { ...attr, editable: false };
|
||||||
|
}
|
||||||
|
return attr;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formAttributesCreate = useMemo(
|
||||||
|
() => _buildFormAttributes('create'),
|
||||||
|
[attributes, callerIsPlatformAdmin],
|
||||||
|
);
|
||||||
|
|
||||||
|
const formAttributesEdit = useMemo(
|
||||||
|
() => _buildFormAttributes('edit', editingUser?.id),
|
||||||
|
[attributes, callerIsPlatformAdmin, callerId, editingUser?.id],
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -230,8 +258,8 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Neuer Benutzer')}</h2>
|
<h2 className={styles.modalTitle}>{t('Neuer Benutzer')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -242,14 +270,14 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{formAttributes.length === 0 ? (
|
{formAttributesCreate.length === 0 ? (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
<span>{t('Lade Formular')}</span>
|
<span>{t('Lade Formular')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FormGeneratorForm
|
<FormGeneratorForm
|
||||||
attributes={formAttributes}
|
attributes={formAttributesCreate}
|
||||||
mode="create"
|
mode="create"
|
||||||
onSubmit={handleCreateSubmit}
|
onSubmit={handleCreateSubmit}
|
||||||
onCancel={() => setShowCreateModal(false)}
|
onCancel={() => setShowCreateModal(false)}
|
||||||
|
|
@ -264,8 +292,8 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingUser && (
|
{editingUser && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Benutzer bearbeiten')}</h2>
|
<h2 className={styles.modalTitle}>{t('Benutzer bearbeiten')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -276,14 +304,14 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{formAttributes.length === 0 ? (
|
{formAttributesEdit.length === 0 ? (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
<span>{t('Lade Formular')}</span>
|
<span>{t('Lade Formular')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FormGeneratorForm
|
<FormGeneratorForm
|
||||||
attributes={formAttributes}
|
attributes={formAttributesEdit}
|
||||||
data={editingUser}
|
data={editingUser}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
|
|
|
||||||
|
|
@ -292,8 +292,8 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay} onClick={onClose}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={`${styles.modal} ${modalStyles.modal}`} onClick={(e) => e.stopPropagation()}>
|
<div className={`${styles.modal} ${modalStyles.modal}`}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className={styles.modalTitle}>{instance.label}</h2>
|
<h2 className={styles.modalTitle}>{instance.label}</h2>
|
||||||
|
|
@ -311,8 +311,8 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Benutzer hinzufügen')}</h2>
|
<h2 className={styles.modalTitle}>{t('Benutzer hinzufügen')}</h2>
|
||||||
<button type="button" className={styles.modalClose} onClick={() => setShowAddModal(false)}>
|
<button type="button" className={styles.modalClose} onClick={() => setShowAddModal(false)}>
|
||||||
|
|
@ -340,8 +340,8 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingUser && (
|
{editingUser && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>
|
<h2 className={styles.modalTitle}>
|
||||||
{t('Rollen')}: {editingUser.username}
|
{t('Rollen')}: {editingUser.username}
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,4 @@ export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
||||||
export { AdminLogsPage } from './AdminLogsPage';
|
export { AdminLogsPage } from './AdminLogsPage';
|
||||||
export { AdminLanguagesPage } from './AdminLanguagesPage';
|
export { AdminLanguagesPage } from './AdminLanguagesPage';
|
||||||
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
|
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
|
||||||
|
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useToast } from '../../../contexts/ToastContext';
|
||||||
import styles from '../Admin.module.css';
|
import styles from '../Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
type InviteType = 'mandate' | 'featureInstance';
|
type InviteType = 'mandate' | 'featureInstance';
|
||||||
|
|
||||||
|
|
@ -114,9 +115,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
// HELPERS
|
// HELPERS
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
const getMandateName = (m: Mandate): string => {
|
const getMandateName = (m: Mandate): string => mandateDisplayLabel(m);
|
||||||
return m.label || m.name || m.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// DATA LOADING
|
// DATA LOADING
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,14 @@ import {
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useMandateFormAttributes } from '../../../hooks/useMandates';
|
import { useMandateFormAttributes } from '../../../hooks/useMandates';
|
||||||
import { createMandate } from '../../../api/mandateApi';
|
import { createMandate, type MandateCreateData } from '../../../api/mandateApi';
|
||||||
import { updateSettingsAdmin } from '../../../api/billingApi';
|
import { updateSettingsAdmin } from '../../../api/billingApi';
|
||||||
import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge';
|
import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge';
|
||||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||||
import styles from '../Admin.module.css';
|
import styles from '../Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
const TOTAL_STEPS = 4;
|
const TOTAL_STEPS = 4;
|
||||||
|
|
||||||
|
|
@ -103,9 +104,8 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
// HELPERS
|
// HELPERS
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const getMandateName = (m: Mandate | Record<string, any>): string => {
|
const getMandateName = (m: Mandate | Record<string, any>): string =>
|
||||||
return m.label || m.name || m.id;
|
mandateDisplayLabel(m as { label?: string | null; name?: string | null; id?: string });
|
||||||
};
|
|
||||||
|
|
||||||
const getFeatureLabel = (code: string): string => {
|
const getFeatureLabel = (code: string): string => {
|
||||||
const f = features.find(feat => feat.code === code);
|
const f = features.find(feat => feat.code === code);
|
||||||
|
|
@ -216,9 +216,10 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
||||||
const body = {
|
const body: MandateCreateData = {
|
||||||
...mandatePayload,
|
...(mandatePayload as Record<string, unknown>),
|
||||||
enabled: mandatePayload.enabled !== undefined ? mandatePayload.enabled : true,
|
label: String(mandatePayload.label ?? '').trim(),
|
||||||
|
enabled: typeof mandatePayload.enabled === 'boolean' ? mandatePayload.enabled : true,
|
||||||
};
|
};
|
||||||
const created = await createMandate(request, body);
|
const created = await createMandate(request, body);
|
||||||
let billingSaved = false;
|
let billingSaved = false;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,100 @@
|
||||||
.modal {
|
.modal {
|
||||||
max-width: 520px;
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step 1 — selectable cards (mandate / feature) */
|
||||||
|
|
||||||
|
.fieldLabel {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--primary-color, #f25843);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary, rgba(255, 255, 255, 0.03));
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardButton:hover {
|
||||||
|
border-color: rgba(242, 88, 67, 0.5);
|
||||||
|
background: rgba(242, 88, 67, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardButton:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color, #f25843);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardButtonActive {
|
||||||
|
border-color: rgba(242, 88, 67, 0.7);
|
||||||
|
background: rgba(242, 88, 67, 0.18);
|
||||||
|
color: var(--primary-color, #f25843);
|
||||||
|
box-shadow: 0 0 0 1px rgba(242, 88, 67, 0.3) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardButton:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color, #f25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldError {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--primary-color, #f25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldHint {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.steps {
|
.steps {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useFeatureAccess } from '../../../hooks/useFeatureAccess';
|
import { useFeatureAccess } from '../../../hooks/useFeatureAccess';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../../components/FormGenerator/FormGeneratorForm';
|
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import type { Mandate } from '../../../hooks/useUserMandates';
|
import type { Mandate } from '../../../hooks/useUserMandates';
|
||||||
|
|
@ -15,9 +14,10 @@ import styles from '../Admin.module.css';
|
||||||
import wizardStyles from './FeatureInstanceWizard.module.css';
|
import wizardStyles from './FeatureInstanceWizard.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
function getMandateName(m: Mandate): string {
|
function getMandateName(m: Mandate): string {
|
||||||
return m.label || m.name || m.id;
|
return mandateDisplayLabel(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureInstanceWizardProps {
|
export interface FeatureInstanceWizardProps {
|
||||||
|
|
@ -55,45 +55,25 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
|
||||||
const [mandateUsers, setMandateUsers] = useState<Array<{ id: string; username: string; email?: string }>>([]);
|
const [mandateUsers, setMandateUsers] = useState<Array<{ id: string; username: string; email?: string }>>([]);
|
||||||
const [instanceRoles, setInstanceRoles] = useState<Array<{ id: string; roleLabel: string }>>([]);
|
const [instanceRoles, setInstanceRoles] = useState<Array<{ id: string; roleLabel: string }>>([]);
|
||||||
const [selectedUserRoles, setSelectedUserRoles] = useState<Array<{ userId: string; roleIds: string[] }>>([]);
|
const [selectedUserRoles, setSelectedUserRoles] = useState<Array<{ userId: string; roleIds: string[] }>>([]);
|
||||||
|
const [labelTouched, setLabelTouched] = useState(false);
|
||||||
|
|
||||||
const featureOptions = useMemo(
|
const trimmedLabel = label.trim();
|
||||||
() => features.map((f) => ({ value: f.code, label: f.label || f.code })),
|
const labelMissing = trimmedLabel.length === 0;
|
||||||
[features]
|
const canSubmitStep1 = !!mandateId && !!featureCode && !labelMissing && !submitting;
|
||||||
);
|
|
||||||
const mandateOptions = useMemo(
|
|
||||||
() => mandates.map((m) => ({ value: m.id, label: getMandateName(m) })),
|
|
||||||
[mandates]
|
|
||||||
);
|
|
||||||
|
|
||||||
const createFields: AttributeDefinition[] = useMemo(
|
const handleStep1Submit = async () => {
|
||||||
() => [
|
setLabelTouched(true);
|
||||||
{ name: 'mandateId', label: t('Mandant'), type: 'enum' as const, required: true, options: mandateOptions },
|
if (!canSubmitStep1) return;
|
||||||
{ name: 'featureCode', label: t('Feature'), type: 'enum' as const, required: true, options: featureOptions },
|
|
||||||
{ name: 'label', label: t('Bezeichnung'), type: 'string' as const, required: true, editable: true },
|
|
||||||
{ name: 'enabled', label: t('Aktiv'), type: 'boolean' as const, required: false, editable: true },
|
|
||||||
],
|
|
||||||
[mandateOptions, featureOptions]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStep1Submit = async (data: {
|
|
||||||
mandateId: string;
|
|
||||||
featureCode: string;
|
|
||||||
label: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
}) => {
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await createInstance(data.mandateId, {
|
const result = await createInstance(mandateId, {
|
||||||
featureCode: data.featureCode,
|
featureCode,
|
||||||
label: data.label,
|
label: trimmedLabel,
|
||||||
enabled: data.enabled !== false,
|
enabled,
|
||||||
copyTemplateRoles: copyTemplateRoles,
|
copyTemplateRoles,
|
||||||
});
|
});
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setMandateId(data.mandateId);
|
setLabel(trimmedLabel);
|
||||||
setFeatureCode(data.featureCode);
|
|
||||||
setLabel(data.label);
|
|
||||||
setEnabled(data.enabled !== false);
|
|
||||||
setCreatedInstanceId(result.data.id);
|
setCreatedInstanceId(result.data.id);
|
||||||
setStep(1);
|
setStep(1);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -165,8 +145,8 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
|
||||||
const currentStepId = steps[step]?.id;
|
const currentStepId = steps[step]?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay} onClick={onClose}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={`${styles.modal} ${wizardStyles.modal}`} onClick={(e) => e.stopPropagation()}>
|
<div className={`${styles.modal} ${wizardStyles.modal}`}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Neue Feature-Instanz')}</h2>
|
<h2 className={styles.modalTitle}>{t('Neue Feature-Instanz')}</h2>
|
||||||
<button type="button" className={styles.modalClose} onClick={onClose} aria-label={t('Schließen')}>
|
<button type="button" className={styles.modalClose} onClick={onClose} aria-label={t('Schließen')}>
|
||||||
|
|
@ -189,20 +169,86 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{currentStepId === 'create' && (
|
{currentStepId === 'create' && (
|
||||||
<div className={wizardStyles.stepContent}>
|
<div className={wizardStyles.stepContent}>
|
||||||
<FormGeneratorForm
|
<div className={wizardStyles.fieldGroup}>
|
||||||
attributes={createFields}
|
<span className={wizardStyles.fieldLabel}>
|
||||||
mode="create"
|
{t('Mandant')}<span className={wizardStyles.required}>*</span>
|
||||||
data={{
|
</span>
|
||||||
mandateId: mandateId || (mandates[0]?.id ?? ''),
|
{mandates.length === 0 ? (
|
||||||
featureCode: featureCode || (features[0]?.code ?? ''),
|
<p className={wizardStyles.fieldHint}>{t('Keine Mandanten verfügbar')}</p>
|
||||||
label,
|
) : (
|
||||||
enabled,
|
<div className={wizardStyles.cardGrid}>
|
||||||
}}
|
{mandates.map((m) => {
|
||||||
onSubmit={handleStep1Submit}
|
const isActive = mandateId === m.id;
|
||||||
onCancel={onClose}
|
return (
|
||||||
submitButtonText={t('Weiter')}
|
<button
|
||||||
cancelButtonText={t('Abbrechen')}
|
key={m.id}
|
||||||
/>
|
type="button"
|
||||||
|
className={`${wizardStyles.cardButton} ${isActive ? wizardStyles.cardButtonActive : ''}`}
|
||||||
|
onClick={() => setMandateId(m.id)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
>
|
||||||
|
{getMandateName(m)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={wizardStyles.fieldGroup}>
|
||||||
|
<span className={wizardStyles.fieldLabel}>
|
||||||
|
{t('Feature')}<span className={wizardStyles.required}>*</span>
|
||||||
|
</span>
|
||||||
|
{features.length === 0 ? (
|
||||||
|
<p className={wizardStyles.fieldHint}>{t('Keine Features verfügbar')}</p>
|
||||||
|
) : (
|
||||||
|
<div className={wizardStyles.cardGrid}>
|
||||||
|
{features.map((f) => {
|
||||||
|
const isActive = featureCode === f.code;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.code}
|
||||||
|
type="button"
|
||||||
|
className={`${wizardStyles.cardButton} ${isActive ? wizardStyles.cardButtonActive : ''}`}
|
||||||
|
onClick={() => setFeatureCode(f.code)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
>
|
||||||
|
{f.label || f.code}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={wizardStyles.fieldGroup}>
|
||||||
|
<label className={wizardStyles.fieldLabel} htmlFor="featureInstanceLabel">
|
||||||
|
{t('Bezeichnung')}<span className={wizardStyles.required}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="featureInstanceLabel"
|
||||||
|
type="text"
|
||||||
|
className={wizardStyles.textInput}
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
onBlur={() => setLabelTouched(true)}
|
||||||
|
placeholder={t('z. B. Vertrieb DE')}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{labelTouched && labelMissing && (
|
||||||
|
<p className={wizardStyles.fieldError}>{t('Bezeichnung ist erforderlich.')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className={wizardStyles.checkLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('Aktiv')}
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className={wizardStyles.checkLabel}>
|
<label className={wizardStyles.checkLabel}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -211,6 +257,21 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
|
||||||
/>
|
/>
|
||||||
{t('Rollen von Feature-Vorlage übernehmen (empfohlen)')}
|
{t('Rollen von Feature-Vorlage übernehmen (empfohlen)')}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className={wizardStyles.stepActions}>
|
||||||
|
<button type="button" className={styles.secondaryButton} onClick={onClose}>
|
||||||
|
{t('Abbrechen')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleStep1Submit}
|
||||||
|
disabled={!canSubmitStep1}
|
||||||
|
title={!canSubmitStep1 ? t('Bitte Mandant, Feature und Bezeichnung wählen.') : undefined}
|
||||||
|
>
|
||||||
|
{submitting ? t('Speichern…') : t('Weiter')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,6 @@ import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
/** Wenn false: keine neue ClickUp-Verbindung über diese Seite (Buttons inaktiv). */
|
|
||||||
const isClickupConnectionUiEnabled = false;
|
|
||||||
|
|
||||||
|
|
||||||
export const ConnectionsPage: React.FC = () => {
|
export const ConnectionsPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
|
@ -101,12 +97,15 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
// Handle edit submit
|
// Handle edit submit
|
||||||
const handleEditSubmit = async (data: Partial<Connection>) => {
|
const handleEditSubmit = async (data: Partial<Connection>) => {
|
||||||
if (!editingConnection) return;
|
if (!editingConnection) return;
|
||||||
// Note: updateConnection is handled through the hook
|
|
||||||
try {
|
try {
|
||||||
// Ensure authority is properly typed - filter and validate authority value
|
|
||||||
const updateData: Partial<import('../../api/connectionApi').Connection> = { ...data };
|
const updateData: Partial<import('../../api/connectionApi').Connection> = { ...data };
|
||||||
|
|
||||||
// Validate and set authority if present
|
// Strip computed/read-only fields the backend cannot write.
|
||||||
|
delete (updateData as any).connectionReference;
|
||||||
|
delete (updateData as any).displayLabel;
|
||||||
|
delete (updateData as any).tokenStatus;
|
||||||
|
delete (updateData as any).tokenExpiresAt;
|
||||||
|
|
||||||
if (data.authority) {
|
if (data.authority) {
|
||||||
if (
|
if (
|
||||||
data.authority === 'local' ||
|
data.authority === 'local' ||
|
||||||
|
|
@ -116,7 +115,6 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
) {
|
) {
|
||||||
updateData.authority = data.authority;
|
updateData.authority = data.authority;
|
||||||
} else {
|
} else {
|
||||||
// Remove invalid authority value
|
|
||||||
delete (updateData as any).authority;
|
delete (updateData as any).authority;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -177,8 +175,10 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle create Google connection
|
// Guards prevent double-trigger while the OAuth popup is open, which would
|
||||||
|
// otherwise create additional orphan PENDING connections on every click.
|
||||||
const handleCreateGoogle = async () => {
|
const handleCreateGoogle = async () => {
|
||||||
|
if (isConnecting) return;
|
||||||
try {
|
try {
|
||||||
await createGoogleConnectionAndAuth();
|
await createGoogleConnectionAndAuth();
|
||||||
refetch();
|
refetch();
|
||||||
|
|
@ -187,8 +187,8 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle create Microsoft connection
|
|
||||||
const handleCreateMicrosoft = async () => {
|
const handleCreateMicrosoft = async () => {
|
||||||
|
if (isConnecting) return;
|
||||||
try {
|
try {
|
||||||
await createMicrosoftConnectionAndAuth();
|
await createMicrosoftConnectionAndAuth();
|
||||||
refetch();
|
refetch();
|
||||||
|
|
@ -197,9 +197,8 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle create ClickUp connection (UI kann per Flag abgeschaltet sein)
|
|
||||||
const handleCreateClickup = async () => {
|
const handleCreateClickup = async () => {
|
||||||
if (!isClickupConnectionUiEnabled) return;
|
if (isConnecting) return;
|
||||||
try {
|
try {
|
||||||
await createClickupConnectionAndAuth();
|
await createClickupConnectionAndAuth();
|
||||||
refetch();
|
refetch();
|
||||||
|
|
@ -228,7 +227,12 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
|
|
||||||
// Form attributes for edit modal
|
// Form attributes for edit modal
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'userId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'connectedAt', 'lastChecked'];
|
const excludedFields = [
|
||||||
|
'id', 'mandateId', 'userId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt',
|
||||||
|
'connectedAt', 'lastChecked',
|
||||||
|
// computed/read-only fields the backend rejects on write
|
||||||
|
'connectionReference', 'displayLabel', 'tokenStatus', 'tokenExpiresAt',
|
||||||
|
];
|
||||||
return (attributes || [])
|
return (attributes || [])
|
||||||
.filter(attr => !excludedFields.includes(attr.name));
|
.filter(attr => !excludedFields.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
@ -253,9 +257,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Verbindungen')}</h1>
|
<h1 className={styles.pageTitle}>{t('Verbindungen')}</h1>
|
||||||
<p className={styles.pageSubtitle}>
|
<p className={styles.pageSubtitle}>
|
||||||
{isClickupConnectionUiEnabled
|
{t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp)')}
|
||||||
? t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp)')
|
|
||||||
: t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft)')}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
|
|
@ -263,7 +265,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
onClick={handleAdminConsent}
|
onClick={handleAdminConsent}
|
||||||
disabled={adminConsentPending}
|
disabled={adminConsentPending}
|
||||||
title={t('Microsoft Admin-Zustimmung erteilt der')}
|
title={t('Microsoft Admin-Zustimmung für die gesamte Organisation erteilen')}
|
||||||
>
|
>
|
||||||
<FaShieldAlt /> {t('Admin-Zustimmung')}
|
<FaShieldAlt /> {t('Admin-Zustimmung')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -290,17 +292,15 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
<FaMicrosoft /> Microsoft
|
<FaMicrosoft /> Microsoft
|
||||||
</button>
|
</button>
|
||||||
{isClickupConnectionUiEnabled && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className={styles.clickupButton}
|
||||||
className={styles.clickupButton}
|
onClick={handleCreateClickup}
|
||||||
onClick={handleCreateClickup}
|
disabled={isConnecting}
|
||||||
disabled={isConnecting}
|
title={t('ClickUp-Konto verbinden (OAuth oder Personal Token nach Anmeldung)')}
|
||||||
title={t('ClickUp-Konto verbinden')}
|
>
|
||||||
>
|
<FaTasks /> ClickUp
|
||||||
<FaTasks /> ClickUp
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -336,9 +336,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
icon: <FaLink />,
|
icon: <FaLink />,
|
||||||
onClick: handleConnect,
|
onClick: handleConnect,
|
||||||
title: t('Verbinden'),
|
title: t('Verbinden'),
|
||||||
visible: (row: Connection) =>
|
visible: (row: Connection) => row.status !== 'active',
|
||||||
row.status !== 'active' &&
|
|
||||||
(isClickupConnectionUiEnabled || row.authority !== 'clickup'),
|
|
||||||
loading: () => isConnecting,
|
loading: () => isConnecting,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -365,8 +363,8 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingConnection && (
|
{editingConnection && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setEditingConnection(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Verbindung bearbeiten')}</h2>
|
<h2 className={styles.modalTitle}>{t('Verbindung bearbeiten')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import FolderTree from '../../components/FolderTree/FolderTree';
|
import FolderTree from '../../components/FolderTree/FolderTree';
|
||||||
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
||||||
import { FaSync, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
|
import { FaSync, FaUpload, FaDownload, FaFolderPlus } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { usePrompt } from '../../hooks/usePrompt';
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
@ -66,7 +66,6 @@ export const FilesPage: React.FC = () => {
|
||||||
handleFileDeleteMultiple,
|
handleFileDeleteMultiple,
|
||||||
handleFileUpload,
|
handleFileUpload,
|
||||||
handleFileUpdate,
|
handleFileUpdate,
|
||||||
handleFilePreview,
|
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
deletingFiles,
|
deletingFiles,
|
||||||
downloadingFiles,
|
downloadingFiles,
|
||||||
|
|
@ -143,6 +142,24 @@ export const FilesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]);
|
}, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]);
|
||||||
|
|
||||||
|
const _handleFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
|
||||||
|
await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update folder scope:', err);
|
||||||
|
}
|
||||||
|
}, [refreshFolders, refreshTreeFiles, _tableRefetch]);
|
||||||
|
|
||||||
|
const _handleFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue });
|
||||||
|
await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to toggle folder neutralize:', err);
|
||||||
|
}
|
||||||
|
}, [refreshFolders, refreshTreeFiles, _tableRefetch]);
|
||||||
|
|
||||||
// ── Folder nodes for tree (real folders only) ────────────────────────
|
// ── Folder nodes for tree (real folders only) ────────────────────────
|
||||||
const folderNodes = useMemo(() => {
|
const folderNodes = useMemo(() => {
|
||||||
return folders.map(f => ({
|
return folders.map(f => ({
|
||||||
|
|
@ -150,9 +167,34 @@ export const FilesPage: React.FC = () => {
|
||||||
name: f.name,
|
name: f.name,
|
||||||
parentId: f.parentId ?? null,
|
parentId: f.parentId ?? null,
|
||||||
fileCount: f.fileCount ?? 0,
|
fileCount: f.fileCount ?? 0,
|
||||||
|
neutralize: f.neutralize ?? false,
|
||||||
|
scope: f.scope ?? 'personal',
|
||||||
}));
|
}));
|
||||||
}, [folders]);
|
}, [folders]);
|
||||||
|
|
||||||
|
const selectedFolderName = useMemo(() => {
|
||||||
|
if (!selectedFolderId) return null;
|
||||||
|
return folders.find(f => f.id === selectedFolderId)?.name ?? null;
|
||||||
|
}, [folders, selectedFolderId]);
|
||||||
|
|
||||||
|
const emptyTableMessage = useMemo(() => {
|
||||||
|
if (!selectedFolderId) {
|
||||||
|
return t('Keine Dateien gefunden');
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', alignItems: 'center' }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>
|
||||||
|
{selectedFolderName
|
||||||
|
? t('Der Ordner „{name}" ist leer.', { name: selectedFolderName })
|
||||||
|
: t('Dieser Ordner ist leer.')}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--text-muted, #64748b)' }}>
|
||||||
|
{t('Lade eine neue Datei hoch oder verschiebe bestehende Dateien hierher.')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [selectedFolderId, selectedFolderName, t]);
|
||||||
|
|
||||||
// ── Columns ───────────────────────────────────────────────────────────
|
// ── Columns ───────────────────────────────────────────────────────────
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const hiddenColumns = ['id', 'fileHash', 'folderId'];
|
const hiddenColumns = ['id', 'fileHash', 'folderId'];
|
||||||
|
|
@ -278,13 +320,6 @@ export const FilesPage: React.FC = () => {
|
||||||
await handleFileDownload(file.id, file.fileName);
|
await handleFileDownload(file.id, file.fileName);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreview = async (file: UserFile) => {
|
|
||||||
const result = await handleFilePreview(file.id, file.fileName, file.mimeType);
|
|
||||||
if (result.success && result.previewUrl) {
|
|
||||||
window.open(result.previewUrl, '_blank');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadClick = () => { fileInputRef.current?.click(); };
|
const handleUploadClick = () => { fileInputRef.current?.click(); };
|
||||||
|
|
||||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -293,7 +328,7 @@ export const FilesPage: React.FC = () => {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
for (const file of Array.from(picked)) {
|
for (const file of Array.from(picked)) {
|
||||||
const result = await handleFileUpload(file);
|
const result = await handleFileUpload(file, undefined, undefined, selectedFolderId);
|
||||||
if (result?.success) successCount++; else errorCount++;
|
if (result?.success) successCount++; else errorCount++;
|
||||||
}
|
}
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
|
@ -411,6 +446,8 @@ export const FilesPage: React.FC = () => {
|
||||||
onDownloadFolder={handleDownloadFolder}
|
onDownloadFolder={handleDownloadFolder}
|
||||||
onScopeChange={_handleScopeChange}
|
onScopeChange={_handleScopeChange}
|
||||||
onNeutralizeToggle={_handleNeutralizeToggle}
|
onNeutralizeToggle={_handleNeutralizeToggle}
|
||||||
|
onFolderScopeChange={_handleFolderScopeChange}
|
||||||
|
onFolderNeutralizeToggle={_handleFolderNeutralizeToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -465,6 +502,15 @@ export const FilesPage: React.FC = () => {
|
||||||
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
|
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
|
||||||
}
|
}
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
|
{
|
||||||
|
type: 'view' as const,
|
||||||
|
onAction: () => { /* ContentPreview fetches the file itself once the popup opens */ },
|
||||||
|
title: t('Vorschau'),
|
||||||
|
idField: 'id',
|
||||||
|
nameField: 'fileName',
|
||||||
|
typeField: 'mimeType',
|
||||||
|
loadingStateName: 'previewingFiles',
|
||||||
|
},
|
||||||
...(canUpdate ? [{
|
...(canUpdate ? [{
|
||||||
type: 'edit' as const,
|
type: 'edit' as const,
|
||||||
onAction: handleEditClick,
|
onAction: handleEditClick,
|
||||||
|
|
@ -486,13 +532,6 @@ export const FilesPage: React.FC = () => {
|
||||||
title: t('Herunterladen'),
|
title: t('Herunterladen'),
|
||||||
loading: (row: UserFile) => downloadingFiles.has(row.id),
|
loading: (row: UserFile) => downloadingFiles.has(row.id),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'preview',
|
|
||||||
icon: <FaEye />,
|
|
||||||
onClick: handlePreview,
|
|
||||||
title: t('Vorschau'),
|
|
||||||
loading: (row: UserFile) => previewingFiles.has(row.id),
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onDeleteMultiple={handleDeleteMultiple}
|
onDeleteMultiple={handleDeleteMultiple}
|
||||||
|
|
@ -503,16 +542,17 @@ export const FilesPage: React.FC = () => {
|
||||||
handleDelete: handleFileDelete,
|
handleDelete: handleFileDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically: updateFileOptimistically,
|
updateOptimistically: updateFileOptimistically,
|
||||||
|
previewingFiles,
|
||||||
}}
|
}}
|
||||||
emptyMessage={t('Keine Dateien gefunden')}
|
emptyMessage={emptyTableMessage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingFile && (
|
{editingFile && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setEditingFile(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Datei bearbeiten')}</h2>
|
<h2 className={styles.modalTitle}>{t('Datei bearbeiten')}</h2>
|
||||||
<button className={styles.modalClose} onClick={() => setEditingFile(null)}>✕</button>
|
<button className={styles.modalClose} onClick={() => setEditingFile(null)}>✕</button>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Follows the pattern established in AdminUsersPage/WorkflowsPage.
|
* Follows the pattern established in AdminUsersPage/WorkflowsPage.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { usePrompts, usePromptOperations } from '../../hooks/usePrompts';
|
import { usePrompts, usePromptOperations } from '../../hooks/usePrompts';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
|
|
@ -49,10 +49,22 @@ export const PromptsPage: React.FC = () => {
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [editingPrompt, setEditingPrompt] = useState<Prompt | null>(null);
|
const [editingPrompt, setEditingPrompt] = useState<Prompt | null>(null);
|
||||||
|
|
||||||
|
// ── Table refetch wrapper (stable signature used by FormGeneratorTable) ──
|
||||||
|
const _tableRefetch = useCallback(async (params?: any) => {
|
||||||
|
await refetch(params);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
// ── Refresh-All for the header "Aktualisieren" button ────────────────────
|
||||||
|
// Forces a paginated request so the cache key matches what the table uses
|
||||||
|
// internally. This guarantees fresh (non-cached) data is pulled in.
|
||||||
|
const _refreshAll = useCallback(async () => {
|
||||||
|
await _tableRefetch({ page: 1, pageSize: 25 });
|
||||||
|
}, [_tableRefetch]);
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refetch();
|
_tableRefetch({ page: 1, pageSize: 25 });
|
||||||
}, []);
|
}, [_tableRefetch]);
|
||||||
|
|
||||||
// Generate columns from attributes - exclude ID fields from display
|
// Generate columns from attributes - exclude ID fields from display
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
|
|
@ -114,7 +126,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
refetch();
|
_refreshAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -127,7 +139,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setEditingPrompt(null);
|
setEditingPrompt(null);
|
||||||
refetch();
|
_refreshAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -135,7 +147,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
const handleDelete = async (prompt: Prompt) => {
|
const handleDelete = async (prompt: Prompt) => {
|
||||||
const success = await handlePromptDelete(prompt.id);
|
const success = await handlePromptDelete(prompt.id);
|
||||||
if (success) {
|
if (success) {
|
||||||
refetch();
|
_refreshAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -152,7 +164,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>{t('Fehler beim Laden der Prompts: {detail}', { detail: String(error) })}</p>
|
<p className={styles.errorMessage}>{t('Fehler beim Laden der Prompts: {detail}', { detail: String(error) })}</p>
|
||||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
<button className={styles.secondaryButton} onClick={() => _refreshAll()}>
|
||||||
<FaSync /> {t('Erneut versuchen')}
|
<FaSync /> {t('Erneut versuchen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -170,7 +182,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
onClick={() => refetch()}
|
onClick={() => _refreshAll()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
|
|
@ -217,7 +229,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
]}
|
]}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch,
|
refetch: _tableRefetch,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
pagination,
|
||||||
handleDelete: handlePromptDelete,
|
handleDelete: handlePromptDelete,
|
||||||
|
|
@ -230,8 +242,8 @@ export const PromptsPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Neuer Prompt')}</h2>
|
<h2 className={styles.modalTitle}>{t('Neuer Prompt')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -264,8 +276,8 @@ export const PromptsPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingPrompt && (
|
{editingPrompt && (
|
||||||
<div className={styles.modalOverlay} onClick={() => setEditingPrompt(null)}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Prompt bearbeiten')}</h2>
|
<h2 className={styles.modalTitle}>{t('Prompt bearbeiten')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { SubscriptionTab } from './SubscriptionTab';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { getUserDataCache } from '../../utils/userCache';
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
type AdminTabType = 'subscription' | 'settings' | 'credit';
|
type AdminTabType = 'subscription' | 'settings' | 'credit';
|
||||||
|
|
@ -28,9 +29,6 @@ const _formatCurrency = (amount: number) => {
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const _mandateDisplayLabel = (m: UserMandateRow): string => {
|
|
||||||
return m.label || m.name || m.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MANDATE SELECTOR
|
// MANDATE SELECTOR
|
||||||
|
|
@ -62,7 +60,7 @@ const MandateSelector: React.FC<MandateSelectorProps> = ({
|
||||||
<option value="">{t('Mandant wählen')}</option>
|
<option value="">{t('Mandant wählen')}</option>
|
||||||
{mandates.map(mandate => (
|
{mandates.map(mandate => (
|
||||||
<option key={mandate.id} value={mandate.id}>
|
<option key={mandate.id} value={mandate.id}>
|
||||||
{_mandateDisplayLabel(mandate)}
|
{mandateDisplayLabel(mandate)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -446,7 +444,7 @@ export const BillingAdmin: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { user: currentUser } = useCurrentUser();
|
const { user: currentUser } = useCurrentUser();
|
||||||
const isSysAdmin = currentUser?.isSysAdmin === true;
|
const isSysAdmin = currentUser?.isPlatformAdmin === true;
|
||||||
|
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(
|
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(
|
||||||
searchParams.get('mandate') || null
|
searchParams.get('mandate') || null
|
||||||
|
|
|
||||||
|
|
@ -475,7 +475,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
{ key: 'aicoreProvider', label: t('Anbieter'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
{ 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: '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: '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, width: 120 },
|
{ key: 'amount', label: t('Betrag (CHF)'), type: 'number' as any, sortable: true, searchable: true, width: 120 },
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
const totalBalance = useMemo(() => {
|
const totalBalance = useMemo(() => {
|
||||||
|
|
@ -609,7 +609,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
fontWeight: chartMode === 'pie' ? 600 : 400,
|
fontWeight: chartMode === 'pie' ? 600 : 400,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('Pie')}
|
{t('Kreis')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setChartMode('bar')}
|
onClick={() => setChartMode('bar')}
|
||||||
|
|
|
||||||
|
|
@ -149,3 +149,90 @@
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================ */
|
||||||
|
/* MOBILE RESPONSIVE */
|
||||||
|
/* ============================================================ */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
.kpiGrid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.kpiCard {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
.kpiValue {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.contextGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.badgeGrid {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.badgeCard {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.kpiGrid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.kpiCard {
|
||||||
|
padding: 0.65rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.kpiValue {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.kpiLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.newTopicBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
background: var(--primary-color, #F25843);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newTopicBtn:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard { padding: 0.75rem; }
|
||||||
|
|
||||||
|
.kpiGrid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpiCard { padding: 0.9rem; }
|
||||||
|
.kpiValue { font-size: 1.5rem; }
|
||||||
|
.kpiLabel { font-size: 0.78rem; }
|
||||||
|
.kpiSub { font-size: 0.7rem; }
|
||||||
|
|
||||||
|
.contextGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeGrid { gap: 0.5rem; }
|
||||||
|
.badgeCard { padding: 0.4rem 0.65rem; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.sectionTitle { font-size: 1rem; }
|
||||||
|
.tipCard { padding: 0.9rem; font-size: 0.85rem; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ export const CommcoachDashboardView: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _handleNewTopic = useCallback(() => {
|
||||||
|
if (mandateId && instanceId) {
|
||||||
|
navigate(`/mandates/${mandateId}/commcoach/${instanceId}/coaching?newContext=true`);
|
||||||
|
}
|
||||||
|
}, [mandateId, instanceId, navigate]);
|
||||||
|
|
||||||
const _categoryLabel = useCallback(
|
const _categoryLabel = useCallback(
|
||||||
(category: string) => {
|
(category: string) => {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
|
|
@ -88,11 +94,16 @@ export const CommcoachDashboardView: React.FC = () => {
|
||||||
|
|
||||||
{/* Active Contexts */}
|
{/* Active Contexts */}
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>{t('Aktive Coaching-Themen')}</h3>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<h3 className={styles.sectionTitle} style={{ margin: 0 }}>{t('Aktive Coaching-Themen')}</h3>
|
||||||
|
<button className={styles.newTopicBtn} onClick={_handleNewTopic}>
|
||||||
|
+ {t('Neues Thema')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{dashboard.contexts.length === 0 ? (
|
{dashboard.contexts.length === 0 ? (
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<p>{t('Noch keine Coaching-Themen angelegt.')}</p>
|
<p>{t('Noch keine Coaching-Themen angelegt.')}</p>
|
||||||
<p>{t('Wechseln Sie zum Tab Coaching, um ein Thema anzulegen.')}</p>
|
<p>{t('Klicken Sie auf "Neues Thema" um zu starten.')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.contextGrid}>
|
<div className={styles.contextGrid}>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,21 @@
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dossierLayout {
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - var(--mobile-topbar-height, 56px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbSidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbSidebarCollapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.udbToggle {
|
.udbToggle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
|
|
@ -51,9 +66,17 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dossier {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Context Selector */
|
/* Context Selector */
|
||||||
.contextSelector {
|
.contextSelector {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -65,6 +88,29 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.contextSelector {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
.contextSelector::-webkit-scrollbar { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.contextSelector {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.contextSelector::-webkit-scrollbar { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.contextChip {
|
.contextChip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -166,6 +212,31 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
.headerActions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
.headerActions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.title { font-size: 1.1rem; }
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -273,6 +344,36 @@
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tabs {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
|
.tab {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tabs {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
|
.tab {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
padding: 0.6rem 1.25rem;
|
padding: 0.6rem 1.25rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -325,6 +426,12 @@
|
||||||
.personaSelector { margin-bottom: 1rem; }
|
.personaSelector { margin-bottom: 1rem; }
|
||||||
.personaLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); display: block; margin-bottom: 0.5rem; }
|
.personaLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); display: block; margin-bottom: 0.5rem; }
|
||||||
.personaGrid { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }
|
.personaGrid { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.personaGrid { gap: 0.35rem; }
|
||||||
|
.personaChip { font-size: 0.75rem; padding: 0.3rem 0.6rem; }
|
||||||
|
.sessionStart { padding: 1rem; }
|
||||||
|
}
|
||||||
.personaChip {
|
.personaChip {
|
||||||
display: flex; align-items: center; gap: 0.3rem;
|
display: flex; align-items: center; gap: 0.3rem;
|
||||||
padding: 0.4rem 0.8rem;
|
padding: 0.4rem 0.8rem;
|
||||||
|
|
@ -350,6 +457,17 @@
|
||||||
.sessionLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); }
|
.sessionLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); }
|
||||||
.sessionActions { display: flex; gap: 0.5rem; }
|
.sessionActions { display: flex; gap: 0.5rem; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sessionHeader {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
.sessionActions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Messages */
|
/* Messages */
|
||||||
.messages {
|
.messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -361,9 +479,25 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.message { max-width: 80%; }
|
.message { max-width: 80%; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.message { max-width: 92%; }
|
||||||
|
.messages { padding: 0.75rem 0.5rem; gap: 0.5rem; }
|
||||||
|
}
|
||||||
.messageUser { align-self: flex-end; }
|
.messageUser { align-self: flex-end; }
|
||||||
.messageAssistant { align-self: flex-start; }
|
.messageAssistant { align-self: flex-start; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.message { max-width: 92%; }
|
||||||
|
.messages { padding: 0.75rem; gap: 0.5rem; }
|
||||||
|
.sessionHeader {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
}
|
||||||
|
.sessionActions { flex-wrap: wrap; gap: 0.3rem; }
|
||||||
|
}
|
||||||
|
|
||||||
.messageBubble {
|
.messageBubble {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
@ -528,6 +662,25 @@
|
||||||
|
|
||||||
.textInputRow { display: flex; gap: 0.5rem; align-items: flex-end; }
|
.textInputRow { display: flex; gap: 0.5rem; align-items: flex-end; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.inputArea {
|
||||||
|
padding: 0.5rem 0.5rem calc(env(safe-area-inset-bottom, 0px) + 0.5rem);
|
||||||
|
}
|
||||||
|
.textInputRow {
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.sendBtn {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.inputArea { padding: 0.5rem 0.75rem; }
|
||||||
|
.textInputRow { gap: 0.35rem; }
|
||||||
|
.sendBtn { padding: 0.5rem 0.75rem; font-size: 0.8rem; }
|
||||||
|
}
|
||||||
|
|
||||||
.textInput {
|
.textInput {
|
||||||
flex: 1; min-width: 0;
|
flex: 1; min-width: 0;
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useCommcoach } from '../../../hooks/useCommcoach';
|
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||||||
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
|
@ -73,6 +74,7 @@ export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ pers
|
||||||
const mandateId = persistentMandateId || routeMandateId;
|
const mandateId = persistentMandateId || routeMandateId;
|
||||||
const coach = useCommcoach(instanceId);
|
const coach = useCommcoach(instanceId);
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
||||||
const [showNewContext, setShowNewContext] = useState(false);
|
const [showNewContext, setShowNewContext] = useState(false);
|
||||||
|
|
@ -144,6 +146,14 @@ export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ pers
|
||||||
}
|
}
|
||||||
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('newContext') === 'true') {
|
||||||
|
setShowNewContext(true);
|
||||||
|
searchParams.delete('newContext');
|
||||||
|
setSearchParams(searchParams, { replace: true });
|
||||||
|
}
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
// Load scores, personas when context changes
|
// Load scores, personas when context changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId || !coach.selectedContextId) return;
|
if (!instanceId || !coach.selectedContextId) return;
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisibl
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CommcoachDossierView
|
<CommcoachDossierView
|
||||||
|
|
|
||||||
|
|
@ -154,3 +154,25 @@
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings {
|
||||||
|
padding: 0.75rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.statsGrid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.voiceRow {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings { padding: 0.75rem; max-width: 100%; }
|
||||||
|
.statsGrid { grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
||||||
|
.statItem { padding: 0.5rem; }
|
||||||
|
.statValue { font-size: 1.2rem; }
|
||||||
|
.voiceRow { flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -646,8 +646,8 @@ const PlaygroundTab: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{browseTarget && (
|
{browseTarget && (
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseBrowse}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.modalContent}>
|
||||||
<h3 className={styles.modalTitle}>
|
<h3 className={styles.modalTitle}>
|
||||||
{browseTarget === 'source'
|
{browseTarget === 'source'
|
||||||
? t('SharePoint-Quellordner durchsuchen')
|
? t('SharePoint-Quellordner durchsuchen')
|
||||||
|
|
|
||||||
|
|
@ -211,8 +211,8 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(editingParcel || isCreateMode) && (
|
{(editingParcel || isCreateMode) && (
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>
|
<h2 className={styles.modalTitle}>
|
||||||
{isCreateMode ? t('Neue Parzelle') : t('Parzelle bearbeiten')}
|
{isCreateMode ? t('Neue Parzelle') : t('Parzelle bearbeiten')}
|
||||||
|
|
|
||||||
|
|
@ -174,8 +174,8 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(editingProject || isCreateMode) && (
|
{(editingProject || isCreateMode) && (
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{isCreateMode ? t('Neues Projekt') : t('Projekt bearbeiten')}</h2>
|
<h2 className={styles.modalTitle}>{isCreateMode ? t('Neues Projekt') : t('Projekt bearbeiten')}</h2>
|
||||||
<button className={styles.modalClose} onClick={handleCloseModal}>✕</button>
|
<button className={styles.modalClose} onClick={handleCloseModal}>✕</button>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||||
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi';
|
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi';
|
||||||
|
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
|
||||||
import { FaPlay, FaSpinner } from 'react-icons/fa';
|
import { FaPlay, FaSpinner } from 'react-icons/fa';
|
||||||
import styles from './Teamsbot.module.css';
|
import styles from './Teamsbot.module.css';
|
||||||
|
|
||||||
|
|
@ -36,8 +37,8 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<ConfigUpdateRequest>({});
|
const [formData, setFormData] = useState<ConfigUpdateRequest>({});
|
||||||
|
|
||||||
// Dynamic voice data from Google TTS API
|
// Voice catalog (single source of truth) + dynamic voices for the selected language
|
||||||
const [languages, setLanguages] = useState<string[]>([]);
|
const [languages, setLanguages] = useState<VoiceLanguage[]>([]);
|
||||||
const [voices, setVoices] = useState<VoiceOption[]>([]);
|
const [voices, setVoices] = useState<VoiceOption[]>([]);
|
||||||
const [loadingVoices, setLoadingVoices] = useState(false);
|
const [loadingVoices, setLoadingVoices] = useState(false);
|
||||||
|
|
||||||
|
|
@ -247,19 +248,13 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
value={formData.language || 'de-DE'}
|
value={formData.language || 'de-DE'}
|
||||||
onChange={(e) => _handleLanguageChange(e.target.value)}
|
onChange={(e) => _handleLanguageChange(e.target.value)}
|
||||||
>
|
>
|
||||||
{languages.length > 0 ? (
|
{languages.map(lang => (
|
||||||
languages.map((langCode, idx) => (
|
<option key={lang.bcp47} value={lang.bcp47}>
|
||||||
<option key={`${langCode}-${idx}`} value={langCode}>{langCode}</option>
|
{lang.flag ? `${lang.flag} ` : ''}{lang.label} ({lang.bcp47})
|
||||||
))
|
</option>
|
||||||
) : (
|
))}
|
||||||
<>
|
|
||||||
<option value="de-DE">{t('Deutsch (Deutschland)')}</option>
|
|
||||||
<option value="en-US">{t('Englisch (US)')}</option>
|
|
||||||
<option value="fr-FR">Francais</option>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</select>
|
</select>
|
||||||
<span className={styles.hint}>Sprache fuer Captions und Sprachausgabe ({languages.length} Sprachen verfuegbar)</span>
|
<span className={styles.hint}>{t('Sprache für Captions und Sprachausgabe')} ({languages.length} {t('Sprachen verfügbar')})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
|
|
|
||||||
|
|
@ -266,8 +266,8 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
{(editingDocument || isCreateMode) && (
|
{(editingDocument || isCreateMode) && (
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>
|
<h2 className={styles.modalTitle}>
|
||||||
{isCreateMode ? t('Neues Dokument') : t('Dokument bearbeiten')}
|
{isCreateMode ? t('Neues Dokument') : t('Dokument bearbeiten')}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue