Merge pull request #42 from valueonag/int

Int
This commit is contained in:
Patrick Motsch 2026-04-17 21:54:34 +02:00 committed by GitHub
commit bbd78696e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 3707 additions and 1253 deletions

308
package-lock.json generated
View file

@ -14,6 +14,7 @@
"@types/leaflet": "^1.9.21",
"@xstate/react": "^5.0.0",
"axios": "^1.8.3",
"docx-preview": "^0.3.7",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"framer-motion": "^12.7.3",
@ -22,6 +23,7 @@
"jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"mammoth": "^1.12.0",
"motion": "^12.7.3",
"pg": "^8.8.0",
"proj4": "^2.20.2",
@ -34,6 +36,7 @@
"react-router-dom": "^7.7.1",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"xlsx": "^0.18.5",
"xstate": "^5.20.1"
},
"devDependencies": {
@ -1980,6 +1983,14 @@
"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": {
"version": "5.0.5",
"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"
}
},
"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": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -2131,6 +2150,30 @@
"dev": true,
"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": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@ -2337,6 +2380,18 @@
"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": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -2415,6 +2470,14 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2541,6 +2604,22 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"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": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2794,6 +2873,19 @@
"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": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@ -2895,6 +2987,14 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -3608,6 +3708,14 @@
"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": {
"version": "12.23.9",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.9.tgz",
@ -3957,6 +4065,11 @@
"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": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
@ -4111,6 +4224,11 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -4258,6 +4376,17 @@
"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": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
@ -4318,6 +4447,14 @@
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -4405,6 +4542,16 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@ -4425,6 +4572,37 @@
"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": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@ -5571,6 +5749,11 @@
"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": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -5621,6 +5804,11 @@
"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": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@ -5700,6 +5888,14 @@
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -5910,6 +6106,11 @@
"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": {
"version": "2.20.2",
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.2.tgz",
@ -6206,6 +6407,25 @@
"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": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
@ -6522,6 +6742,11 @@
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"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": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -6673,6 +6898,22 @@
"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": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
@ -6687,6 +6928,19 @@
"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": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@ -6901,6 +7155,11 @@
"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": {
"version": "7.14.0",
"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"
}
},
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@ -7251,6 +7515,22 @@
"integrity": "sha512-1ZUiV1FTwSiSrgWzV9KXJuOF2BVW91KY/mau04BhnmgOdroRQea7Q0s5TVqwGLm0D2tZwObd/tBYXW49sSxp3Q==",
"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": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -7261,6 +7541,34 @@
"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": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/xstate/-/xstate-5.20.1.tgz",

View file

@ -20,6 +20,7 @@
"@types/leaflet": "^1.9.21",
"@xstate/react": "^5.0.0",
"axios": "^1.8.3",
"docx-preview": "^0.3.7",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"framer-motion": "^12.7.3",
@ -28,6 +29,7 @@
"jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"mammoth": "^1.12.0",
"motion": "^12.7.3",
"pg": "^8.8.0",
"proj4": "^2.20.2",
@ -40,6 +42,7 @@
"react-router-dom": "^7.7.1",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"xlsx": "^0.18.5",
"xstate": "^5.20.1"
},
"devDependencies": {

View file

@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
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 { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
@ -213,6 +213,7 @@ function App() {
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="languages" element={null} />
<Route path="database-health" element={<AdminDatabaseHealthPage />} />
<Route path="demo-config" element={<AdminDemoConfigPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />

View file

@ -60,6 +60,8 @@ export interface NodeType {
meta?: {
icon?: string;
color?: string;
/** True if this node performs an LLM / AI call (credits). */
usesAi?: boolean;
method?: string;
action?: string;
};

View file

@ -845,3 +845,121 @@
/* 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;
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { IoIosDownload, IoIosCopy } from 'react-icons/io';
@ -14,7 +14,11 @@ import {
ApplicationRenderer,
UnsupportedRenderer,
LoadingRenderer,
ErrorRenderer
ErrorRenderer,
WordRenderer,
ExcelRenderer,
isWordMimeType,
isExcelMimeType,
} from './renderers';
import styles from './ContentPreview.module.css';
@ -31,93 +35,86 @@ export function ContentPreview({
onClose,
fileId,
fileName,
mimeType
mimeType,
}: ContentPreviewProps) {
const { t } = useLanguage();
const { handleFilePreview, handleFileDownload, previewingFiles, previewError, downloadingFiles } = useFileOperations();
const {
handleFilePreview,
handleFileDownload,
previewingFiles,
previewError,
downloadingFiles,
} = useFileOperations();
// Debug logging to see what data we're receiving
useEffect(() => {
if (isOpen && import.meta.env.DEV) {
console.log('ContentPreview received:', { fileId, fileName, mimeType });
}
}, [isOpen, fileId, fileName, mimeType]);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewContent, setPreviewContent] = useState<string | null>(null);
const [blob, setBlob] = useState<Blob | null>(null);
const [textContent, setTextContent] = useState<string | null>(null);
const [resolvedMime, setResolvedMime] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState<boolean>(false);
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(() => {
return () => {
if (previewUrl) {
window.URL.revokeObjectURL(previewUrl);
}
if (previewUrl) window.URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
// Load preview when modal opens
useEffect(() => {
if (isOpen && fileId) {
// 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 effectiveMime = resolvedMime ?? mimeType;
const isPreviewing = previewingFiles.has(fileId);
const hasError = error || previewError;
const handleCopyContent = async () => {
if (!textContent) return;
try {
if (previewContent) {
await navigator.clipboard.writeText(previewContent);
setCopySuccess(true);
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);
}
}
await navigator.clipboard.writeText(textContent);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error('Failed to copy content:', err);
}
@ -131,90 +128,63 @@ 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[] = [
// Copy Content Button - only show for text-based files (exclude PDFs and images) or corrupted PDFs
...(mimeType !== 'application/pdf' && !mimeType?.startsWith('image/') && (mimeType?.startsWith('text/') || mimeType === 'application/json' || previewContent) ? [{
label: copySuccess ? t('In die Zwischenablage kopiert') : t(''),
icon: copySuccess ? '✓' : <IoIosCopy />,
onClick: handleCopyContent,
disabled: !previewContent && !previewUrl,
variant: 'primary' as const
}] : []),
// Download Button - hide for corrupted PDFs
...(isCorruptedPdf ? [] : [{
label: String(''),
...(textContent
? [
{
label: copySuccess ? t('In die Zwischenablage kopiert') : '',
icon: copySuccess ? '✓' : <IoIosCopy />,
onClick: handleCopyContent,
disabled: !textContent,
variant: 'primary' as const,
},
]
: []),
{
label: '',
icon: downloadingFiles.has(fileId) ? undefined : <IoIosDownload />,
onClick: handleDownloadFile,
disabled: downloadingFiles.has(fileId),
loading: downloadingFiles.has(fileId),
variant: 'success' as const
}])
variant: 'success' as const,
},
];
const renderPreview = () => {
// Handle text content in PDF files (corrupted files) - check this first
if (previewContent && !previewUrl && mimeType === 'application/pdf') {
console.log('🔍 ContentPreview: Rendering corrupted PDF with text content');
if (isPreviewing) return <LoadingRenderer />;
if (hasError) return <ErrorRenderer error={hasError} onRetry={loadPreview} />;
if (!blob || !effectiveMime) return null;
if (effectiveMime === 'application/json' && textContent) {
return <JsonRenderer previewContent={textContent} fileName={fileName} />;
}
if (isWordMimeType(effectiveMime, fileName)) {
return (
<PdfRenderer
previewUrl={undefined}
previewContent={previewContent}
<WordRenderer
blob={blob}
fileName={fileName}
onError={() => setError(t('PDF-Vorschau konnte nicht geladen werden'))}
mimeType={effectiveMime}
onError={msg => setError(msg)}
/>
);
}
if (!previewUrl) {
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') {
if (isExcelMimeType(effectiveMime, fileName)) {
return (
<div className={styles.jsonContainer}>
<div className={styles.jsonHeader}>
<span className={styles.jsonTitle}>{t('JSON-Vorschau als Fallback')}</span>
<div className={styles.jsonHeaderRight}>
<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>
<ExcelRenderer
blob={blob}
fileName={fileName}
onError={msg => setError(msg)}
/>
);
}
// Determine preview type based on MIME type
const mimePrefix = mimeType?.split('/')[0];
const mimePrefix = effectiveMime.split('/')[0];
switch (mimePrefix) {
case 'image':
if (!previewUrl) return null;
return (
<ImageRenderer
previewUrl={previewUrl}
@ -224,8 +194,7 @@ export function ContentPreview({
);
case 'text':
// Special handling for HTML files
if (mimeType === 'text/html') {
if (effectiveMime === 'text/html' && previewUrl) {
return (
<HtmlRenderer
previewUrl={previewUrl}
@ -234,35 +203,27 @@ export function ContentPreview({
/>
);
}
return (
<TextRenderer
previewUrl={previewUrl}
previewUrl={previewUrl ?? undefined}
previewContent={textContent ?? undefined}
fileName={fileName}
mimeType={mimeType}
mimeType={effectiveMime}
onError={() => setError(t('Textvorschau konnte nicht geladen werden'))}
/>
);
case 'application':
if (mimeType === 'application/pdf') {
console.log('🔍 ContentPreview passing normal PDF to PdfRenderer:', {
previewUrl,
previewContent: previewContent ? `${previewContent.substring(0, 50)}...` : null,
fileName,
mimeType
});
if (effectiveMime === 'application/pdf' && previewUrl) {
return (
<PdfRenderer
previewUrl={previewUrl}
previewContent={previewContent || undefined}
fileName={fileName}
onError={() => setError(t('PDF-Vorschau konnte nicht geladen werden'))}
/>
);
}
if (mimeType === 'application/html') {
if (effectiveMime === 'application/html' && previewUrl) {
return (
<HtmlRenderer
previewUrl={previewUrl}
@ -271,20 +232,19 @@ export function ContentPreview({
/>
);
}
return (
<ApplicationRenderer
previewUrl={previewUrl}
previewUrl={previewUrl ?? ''}
fileName={fileName}
mimeType={mimeType}
onError={() => setError(t('Vorschau wird für dieses Format nicht unterstützt'))}
mimeType={effectiveMime}
onError={() =>
setError(t('Vorschau wird für dieses Format nicht unterstützt'))
}
/>
);
default:
return <UnsupportedRenderer previewUrl={previewUrl} fileName={fileName} />;
return <UnsupportedRenderer previewUrl={previewUrl ?? ''} fileName={fileName} />;
}
};
@ -297,12 +257,9 @@ export function ContentPreview({
className={styles.contentPreviewPopup}
actions={actions}
>
<div className={styles.previewContainer}>
{renderPreview()}
</div>
<div className={styles.previewContainer}>{renderPreview()}</div>
</Popup>
);
}
export default ContentPreview;

View 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>
);
}

View 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>
);
}

View file

@ -7,4 +7,6 @@ export { ApplicationRenderer } from './ApplicationRenderer';
export { UnsupportedRenderer } from './UnsupportedRenderer';
export { LoadingRenderer } from './LoadingRenderer';
export { ErrorRenderer } from './ErrorRenderer';
export { WordRenderer, isWordMimeType } from './WordRenderer';
export { ExcelRenderer, isExcelMimeType } from './ExcelRenderer';

View file

@ -152,8 +152,18 @@
min-width: 0;
}
.nodeItemLabelRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
width: 100%;
}
.nodeItemLabel {
display: block;
flex: 1;
min-width: 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);

View file

@ -31,7 +31,7 @@ import {
type AutoVersion,
type AutoTemplateScope,
} 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 { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
@ -587,6 +587,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
}
}, [request, instanceId]);
const handleAutoLayout = useCallback(() => {
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
}, [canvasConnections]);
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
const renderSidebar = () => {
@ -708,6 +712,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
templateSaving={templateSaving}
onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename}
onAutoLayout={handleAutoLayout}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>

View file

@ -3,7 +3,7 @@
*/
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 styles from './Automation2FlowEditor.module.css';
@ -34,6 +34,7 @@ interface CanvasHeaderProps {
templateSaving?: boolean;
onNewFromTemplate?: () => void;
onWorkflowRename?: (workflowId: string, newName: string) => void;
onAutoLayout?: () => void;
}
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
@ -68,6 +69,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
templateSaving,
onNewFromTemplate,
onWorkflowRename,
onAutoLayout,
}) => {
const { t } = useLanguage();
const statusBadge = _getStatusBadge(t);
@ -216,6 +218,19 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
</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 */}
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>

View file

@ -8,6 +8,7 @@ import type { NodeType } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { AiBadge } from '../nodes/shared/AiBadge';
export interface CanvasNode {
id: string;
@ -37,6 +38,75 @@ const NODE_WIDTH = 200;
const NODE_HEIGHT = 72;
const HANDLE_SIZE = 12;
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' */
function _checkConnectionCompatibility(
@ -164,26 +234,16 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
const w = NODE_WIDTH;
const h = NODE_HEIGHT;
const centerY = node.y + h / 2;
const centerX = node.x + w / 2;
if (isOutput) {
if (ioCount === 1) return { x: node.x + w, y: centerY, side: 'right' };
if (ioCount === 2) {
return ioIndex === 0
? { 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' };
if (ioCount === 1) return { x: centerX, y: node.y + h, side: 'bottom' };
const step = w / (ioCount + 1);
return { x: node.x + step * (ioIndex + 1), y: node.y + h, side: 'bottom' };
} else {
if (ioCount === 1) return { x: node.x, y: centerY, side: 'left' };
if (ioCount === 2) {
return ioIndex === 0
? { 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' };
if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' };
const step = w / (ioCount + 1);
return { x: node.x + step * (ioIndex + 1), y: node.y, side: 'top' };
}
},
[]
@ -639,8 +699,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
if (!srcNode || !tgtNode) return null;
const src = getHandlePosition(srcNode, c.sourceHandle);
const tgt = getHandlePosition(tgtNode, c.targetHandle);
const dx = tgt.x - src.x;
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 dy = tgt.y - src.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 isWarning = connectionWarnings[c.id];
const strokeColor = isSelected
@ -739,6 +799,12 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
handleNodeMouseDown(e, node.id);
}}
>
{nt?.meta?.usesAi === true && (
<AiBadge
variant="canvas"
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
/>
)}
{handles.map(({ index, isOutput }) => {
const pos = getHandlePosition(node, index);
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
@ -756,12 +822,12 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
key={index}
className={styles.handleWrapper}
style={{
left: pos.side === 'left' ? -HANDLE_OFFSET : undefined,
right: pos.side === 'right' ? -HANDLE_OFFSET : undefined,
top: pos.y - node.y - HANDLE_OFFSET,
top: pos.side === 'top' ? -HANDLE_OFFSET : undefined,
bottom: pos.side === 'bottom' ? -HANDLE_OFFSET : undefined,
left: pos.x - node.x - HANDLE_OFFSET,
}}
>
{outputLabel && pos.side === 'right' && (
{outputLabel && pos.side === 'bottom' && (
<span className={styles.handleLabel}>{outputLabel}</span>
)}
<div
@ -778,7 +844,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
: undefined)
}
/>
{outputLabel && pos.side === 'left' && (
{outputLabel && pos.side === 'top' && (
<span className={styles.handleLabel}>{outputLabel}</span>
)}
</div>

View file

@ -5,9 +5,11 @@
import React from 'react';
import type { NodeType } from '../../../api/workflowApi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getCategoryIcon } from '../nodes/shared/utils';
import type { GetLabelFn } from '../nodes/shared/utils';
import styles from './Automation2FlowEditor.module.css';
import { AiBadge } from '../nodes/shared/AiBadge';
interface NodeListItemProps {
node: NodeType;
@ -22,6 +24,7 @@ export const NodeListItem: React.FC<NodeListItemProps> = ({
getLabel,
getCategoryIcon: getIcon = getCategoryIcon,
}) => {
const { t } = useLanguage();
const desc = getLabel(node.description, language);
return (
<div
@ -44,7 +47,15 @@ export const NodeListItem: React.FC<NodeListItemProps> = ({
{getIcon(node.category)}
</div>
<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>
</div>
{desc && <div className={styles.nodeItemTooltip}>{desc}</div>}

View 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;
}

View 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>
);
};

View file

@ -12,9 +12,11 @@ export const CATEGORY_ORDER = [
'input',
'flow',
'data',
'context',
'ai',
'file',
'email',
'sharepoint',
'clickup',
'trustee',
] as const;

View file

@ -29,6 +29,7 @@ export interface FolderNode {
isProtected?: boolean;
isReadonly?: boolean;
icon?: string;
neutralize?: boolean;
}
export interface FileNode {
@ -75,6 +76,8 @@ export interface FolderTreeProps {
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
}
/* ── Helpers ───────────────────────────────────────────────────────────── */
@ -180,6 +183,7 @@ interface SelectionCtx {
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
}
/* ── File node (leaf) ─────────────────────────────────────────────────── */
@ -262,6 +266,11 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
{!renaming && (
<span className={styles.rightZone}>
<span className={styles.actions}>
{sel.onSendToChat && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
{'\u{1F4AC}'}
</button>
)}
{sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
<FaPen />
@ -351,6 +360,7 @@ interface TreeNodeProps {
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
}
function _TreeNode({
@ -358,7 +368,7 @@ function _TreeNode({
promptFolderName,
onToggle, onSelect,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onDownloadFolder,
onDownloadFolder, onFolderNeutralizeToggle,
}: TreeNodeProps) {
const { t } = useLanguage();
const [renaming, setRenaming] = useState(false);
@ -514,11 +524,26 @@ function _TreeNode({
)}
{!isProtected && (
<span className={styles.actions}>
{sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
{'\u{1F4AC}'}
</button>
)}
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
<FaDownload />
</button>
)}
{onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); onFolderNeutralizeToggle(node.id, !node.neutralize); }}
title={node.neutralize ? t('Ordner-Neutralisierung aktiv, klicken zum Deaktivieren') : t('Ordner-Neutralisierung aus, klicken zum Aktivieren')}
style={{ fontSize: 14, opacity: node.neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
)}
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
<FaPlus />
@ -575,6 +600,7 @@ function _TreeNode({
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder}
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/>
))}
{folderFiles.map((file) => (
@ -594,7 +620,7 @@ export default function FolderTree({
expandedIds: externalExpandedIds, onToggleExpand,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
onScopeChange, onNeutralizeToggle,
onScopeChange, onNeutralizeToggle, onFolderNeutralizeToggle, onSendToChat,
}: FolderTreeProps) {
const { t } = useLanguage();
@ -735,8 +761,9 @@ export default function FolderTree({
onDeleteFolders,
onScopeChange,
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)
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
@ -821,6 +848,7 @@ export default function FolderTree({
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder}
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/>
))}
{rootFiles.map((file) => (

View file

@ -118,15 +118,11 @@ export function DeleteActionButton<T = any>({
const success = await handleDelete(itemId);
if (success) {
// If we used optimistic removal, don't refetch immediately
// The item is already removed from UI, and refetch might bring it back
if (removeOptimistically) {
// Only refetch if there was an error or if we need to sync other changes
// For now, we trust the optimistic removal worked
} else {
// No optimistic removal, refetch immediately
refetch();
}
// Always refetch after a successful delete. The server has actually
// removed the row, so fresh data won't bring it back — and this is
// what re-syncs pagination.totalItems (and clears any optimistic
// hidden-row state maintained by FormGeneratorTable).
refetch();
onSuccess?.(row);
} else {
// Refetch to restore the item in case of failure

View file

@ -185,8 +185,8 @@ export interface FormGeneratorTableProps<T = any> {
getRowDataAttributes?: (row: T, index: number) => Record<string, string>;
// For passing hook data to action buttons
hookData?: any; // Contains all hook data: refetch, operations, loading states, etc.
// Custom empty message when table is empty
emptyMessage?: string;
// Custom empty message when table is empty (string or custom ReactNode)
emptyMessage?: React.ReactNode;
// API endpoint for CSV export (e.g. "/api/users/"). If provided, the CSV export button is shown.
apiEndpoint?: string;
// Grouping configuration
@ -339,7 +339,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onRefresh,
className = '',
getRowDataAttributes,
hookData,
hookData: hookDataProp,
emptyMessage,
apiEndpoint,
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
const onDeleteMultiple = onDeleteMultipleProp ?? (onDelete ? (rows: T[]) => rows.forEach((r) => onDelete(r)) : undefined);
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
// 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)
@ -430,6 +532,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Debounce search term for backend calls
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(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(prev => {
@ -441,6 +547,18 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return () => clearTimeout(timer);
}, [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
useEffect(() => {
if (!supportsBackendPagination || !hookData?.refetch) return;
@ -494,7 +612,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
console.error('❌ FormGeneratorTable: Backend refetch failed:', error);
});
// 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
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;
}, [fkCache]);
// Data is already filtered, sorted, and paginated by the backend
// No client-side processing needed
const displayData = data;
// Data is already filtered, sorted, and paginated by the backend.
// Client-side only filters out rows that were just optimistically deleted
// so the UI updates instantly before the server's next refetch response.
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
const groupedData = useMemo(() => {
@ -1802,7 +1924,22 @@ export function FormGeneratorTable<T extends Record<string, any>>({
},
};
}) : 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}
selectable={selectable}
loading={loading}

View file

@ -140,8 +140,8 @@ export const UserSection: React.FC = () => {
{/* Legal Modal */}
{showLegalModal && (
<div className={styles.modalOverlay} onClick={() => setShowLegalModal(false)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2>{t('Legal notices')}</h2>
<button

View file

@ -0,0 +1,31 @@
.wrapper {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.icon {
font-size: 0.85rem;
opacity: 0.6;
flex-shrink: 0;
}
.select {
appearance: none;
background: transparent;
border: none;
color: inherit;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
padding: 0.15rem 0.3rem;
border-radius: 4px;
opacity: 0.7;
transition: opacity 0.15s;
}
.select:hover,
.select:focus {
opacity: 1;
outline: none;
}

View file

@ -0,0 +1,28 @@
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();
if (availableLanguages.length <= 1) return null;
return (
<div className={styles.wrapper}>
<FaGlobe className={styles.icon} />
<select
className={styles.select}
value={currentLanguage}
onChange={(e) => setLanguage(e.target.value as typeof currentLanguage)}
>
{availableLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label || lang.code.toUpperCase()}
</option>
))}
</select>
</div>
);
}
export default LanguageSelector;

View file

@ -0,0 +1,2 @@
export { LanguageSelector } from './LanguageSelector';
export { default } from './LanguageSelector';

View file

@ -23,6 +23,8 @@ export interface PopupProps {
className?: string;
size?: 'small' | 'medium' | 'large' | 'fullscreen';
closable?: boolean;
closeOnBackdropClick?: boolean;
closeOnEscape?: boolean;
actions?: PopupAction[];
}
@ -36,6 +38,8 @@ export function Popup({
className = '',
size = 'medium',
closable = true,
closeOnBackdropClick = false,
closeOnEscape = true,
actions = []
}: PopupProps) {
const { t } = useLanguage();
@ -43,7 +47,7 @@ export function Popup({
// Handle escape key
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && closable) {
if (e.key === 'Escape' && closable && closeOnEscape) {
onClose();
}
};
@ -58,13 +62,13 @@ export function Popup({
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [isOpen, closable, onClose]);
}, [isOpen, closable, closeOnEscape, onClose]);
if (!isOpen) return null;
// Handle backdrop click
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && closable) {
if (e.target === e.currentTarget && closable && closeOnBackdropClick) {
onClose();
}
};

View file

@ -10,9 +10,10 @@ import { useLanguage } from '../../providers/language/LanguageContext';
interface FilesTabProps {
context: UdbContext;
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 [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
@ -46,6 +47,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
name: f.name,
parentId: f.parentId ?? null,
fileCount: f.fileCount ?? 0,
neutralize: (f as any).neutralize ?? false,
}));
}, [folders]);
@ -166,6 +168,16 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
}
}, [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]);
if (treeFilesLoading && treeFileNodes.length === 0) {
return <div className={styles.loading}>{t('Dateien laden')}</div>;
}
@ -256,6 +268,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
onDownloadFolder={handleDownloadFolder}
onScopeChange={_onScopeChange}
onNeutralizeToggle={_onNeutralizeToggle}
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
onSendToChat={onSendToChat}
/>
{_fileNodes.length === 0 && (

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,20 @@ export interface UdbContext {
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 {
context: UdbContext;
activeTab?: UdbTab;
@ -27,6 +41,9 @@ interface UnifiedDataBarProps {
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
onFileSelect?: (fileId: string, fileName?: string) => void;
onSourcesChanged?: () => void;
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
onAttachDataSource?: (dsId: string) => void;
className?: string;
}
@ -52,6 +69,9 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
onChatDragStart,
onFileSelect,
onSourcesChanged,
onSendToChat_Files,
onSendToChat_FeatureSource,
onAttachDataSource,
className,
}) => {
const { t } = useLanguage();
@ -95,10 +115,16 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
<FilesTab
context={context}
onFileSelect={onFileSelect}
onSendToChat={onSendToChat_Files}
/>
)}
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
<SourcesTab context={context} onSourcesChanged={onSourcesChanged} />
<SourcesTab
context={context}
onSourcesChanged={onSourcesChanged}
onSendToChat_FeatureSource={onSendToChat_FeatureSource}
onAttachDataSource={onAttachDataSource}
/>
)}
</div>
</div>

View file

@ -1,3 +1,3 @@
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';

View file

@ -83,6 +83,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.automation-logs': <FaClipboardList />,
'page.admin.logs': <FaFileAlt />,
'page.admin.languages': <FaGlobe />,
'page.admin.databaseHealth': <FaDatabase />,
'page.admin.database-health': <FaDatabase />,
'page.admin.demoConfig': <FaCubes />,
'page.admin.demo-config': <FaCubes />,
'page.admin.mandate-wizard': <FaHatWizard />,

View file

@ -1,7 +1,7 @@
import React, { createContext, useContext, useCallback, useState, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import api from '../api';
import { useFileOperations } from '../hooks/useFiles';
import { useFileOperations, type FilePreviewResult } from '../hooks/useFiles';
import type { FolderInfo } from '../api/fileApi';
import type { FileNode } from '../components/FolderTree/FolderTree';
@ -31,7 +31,7 @@ interface FileContextType {
handleDownloadFolder: (folderId: string, folderName: string) => Promise<void>;
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
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>;
uploadingFile: boolean;
deletingFiles: Set<string>;
@ -316,7 +316,7 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
handleDownloadFolder,
handleFileDelete,
handleFileUpload,
handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'],
handleFilePreview,
handleFileDownload: async (fileId: string, fileName: string) => {
await handleFileDownload(fileId, fileName);
},

View file

@ -333,6 +333,8 @@ export function useConnections() {
// Create Google connection and open OAuth popup
const createGoogleConnectionAndAuth = async (): Promise<void> => {
if (isConnecting) return;
setIsConnecting(true);
try {
// Step 1: Create a Google connection
const newConnection = await createConnection({
@ -354,7 +356,7 @@ export function useConnections() {
authUrl = `${apiBaseUrl}${authUrl}`;
}
return new Promise((resolve, reject) => {
return await new Promise<void>((resolve, reject) => {
const popup = window.open(
authUrl,
'google-connection',
@ -362,6 +364,7 @@ export function useConnections() {
);
if (!popup) {
setIsConnecting(false);
reject(new Error('Popup was blocked. Please allow popups and try again.'));
return;
}
@ -371,6 +374,7 @@ export function useConnections() {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
setIsConnecting(false);
console.log('Google OAuth popup closed');
// Refresh connections in case it succeeded
fetchConnections();
@ -390,6 +394,7 @@ export function useConnections() {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
console.log('Google connection successful');
// Refresh connections
fetchConnections();
@ -398,6 +403,7 @@ export function useConnections() {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
reject(new Error(event.data.error || 'Google connection failed'));
}
};
@ -405,6 +411,7 @@ export function useConnections() {
window.addEventListener('message', messageListener);
});
} catch (error) {
setIsConnecting(false);
console.error('Error creating Google connection:', error);
throw error;
}
@ -412,6 +419,8 @@ export function useConnections() {
// Create ClickUp connection and open OAuth popup
const createClickupConnectionAndAuth = async (): Promise<void> => {
if (isConnecting) return;
setIsConnecting(true);
try {
const newConnection = await createConnection({
type: 'clickup',
@ -430,7 +439,7 @@ export function useConnections() {
authUrl = `${apiBaseUrl}${authUrl}`;
}
return new Promise((resolve, reject) => {
return await new Promise<void>((resolve, reject) => {
const popup = window.open(
authUrl,
'clickup-connection',
@ -438,6 +447,7 @@ export function useConnections() {
);
if (!popup) {
setIsConnecting(false);
reject(new Error('Popup was blocked. Please allow popups and try again.'));
return;
}
@ -446,6 +456,7 @@ export function useConnections() {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
setIsConnecting(false);
console.log('ClickUp OAuth popup closed');
fetchConnections();
resolve();
@ -462,6 +473,7 @@ export function useConnections() {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
console.log('ClickUp connection successful');
fetchConnections();
resolve();
@ -469,6 +481,7 @@ export function useConnections() {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
reject(new Error(event.data.error || 'ClickUp connection failed'));
}
};
@ -476,6 +489,7 @@ export function useConnections() {
window.addEventListener('message', messageListener);
});
} catch (error) {
setIsConnecting(false);
console.error('Error creating ClickUp connection:', error);
throw error;
}
@ -483,6 +497,8 @@ export function useConnections() {
// Create Microsoft connection and open OAuth popup
const createMicrosoftConnectionAndAuth = async (): Promise<void> => {
if (isConnecting) return;
setIsConnecting(true);
try {
// Step 1: Create a Microsoft connection
const newConnection = await createConnection({
@ -504,7 +520,7 @@ export function useConnections() {
authUrl = `${apiBaseUrl}${authUrl}`;
}
return new Promise((resolve, reject) => {
return await new Promise<void>((resolve, reject) => {
const popup = window.open(
authUrl,
'msft-connection',
@ -512,6 +528,7 @@ export function useConnections() {
);
if (!popup) {
setIsConnecting(false);
reject(new Error('Popup was blocked. Please allow popups and try again.'));
return;
}
@ -521,6 +538,7 @@ export function useConnections() {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
setIsConnecting(false);
console.log('Microsoft OAuth popup closed');
// Refresh connections in case it succeeded
fetchConnections();
@ -540,6 +558,7 @@ export function useConnections() {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
console.log('Microsoft connection successful');
// Refresh connections
fetchConnections();
@ -548,6 +567,7 @@ export function useConnections() {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
reject(new Error(event.data.error || 'Microsoft connection failed'));
}
};
@ -555,6 +575,7 @@ export function useConnections() {
window.addEventListener('message', messageListener);
});
} catch (error) {
setIsConnecting(false);
console.error('Error creating Microsoft connection:', error);
throw error;
}

View file

@ -15,6 +15,15 @@ import {
type FolderInfo,
} 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
export interface FileInfo {
id: string;
@ -480,7 +489,12 @@ export function useFileOperations() {
* - Removed workflowId from FileItem creation in interfaceComponentObjects.py
* - 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);
setUploadingFile(true);
@ -504,6 +518,9 @@ export function useFileOperations() {
if (featureInstanceId) {
formData.append('featureInstanceId', featureInstanceId);
}
if (folderId) {
formData.append('folderId', folderId);
}
// FormData is now correctly configured for backend
@ -580,331 +597,64 @@ 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);
setPreviewingFiles(prev => new Set(prev).add(fileId));
try {
const response = await api.get(`/api/files/${fileId}/preview`, {
responseType: 'json',
});
const data = response.data;
// For PDF files, try JSON response first (API returns base64-encoded PDF)
if (mimeType === 'application/pdf') {
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 base64-encoded PDF content
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 };
}
if (!data || typeof data !== 'object' || typeof data.content !== 'string' || !data.mimeType) {
throw new Error('Invalid preview response from server');
}
// For image files, try JSON response first (API returns base64-encoded images)
if (mimeType?.startsWith('image/')) {
const { content, mimeType: responseMime, isText } = data as {
content: string;
mimeType: string;
isText?: boolean;
encoding?: string | null;
};
let blob: Blob;
let textContent: string | null = null;
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 base64-encoded image content
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 {
const decodedString = atob(content);
// Check if it's JSON (nested structure) or direct image data
if (decodedString.startsWith('{')) {
// It's JSON, parse it
const nestedJson = JSON.parse(decodedString);
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 };
if (isText) {
textContent = content;
blob = new Blob([content], { type: responseMime });
} else {
const binaryString = atob(content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
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;
const previewUrl = window.URL.createObjectURL(blob);
// 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';
// 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);
}
} catch (decodeError) {
decodedContent = content;
}
// Create a blob from the (possibly 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 };
} 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 };
}
return {
success: true,
previewUrl,
blob,
mimeType: responseMime,
textContent,
};
} catch (error: any) {
console.error(`Preview failed for ${fileName}:`, error);
let errorMessage = error.message;
console.error(`Preview failed for ${fileName}:`, error);
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.`;
} else if (error.response?.status === 403) {
} else if (error?.response?.status === 403) {
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.`;
}

View file

@ -18,6 +18,7 @@ import { formatUnixTimestamp } from '../utils/time';
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
import api from '../api';
import { useLanguage } from '../providers/language/LanguageContext';
import { useNavigation, type DynamicBlock } from '../hooks/useNavigation';
import styles from './admin/Admin.module.css';
// ---------------------------------------------------------------------------
@ -53,6 +54,7 @@ interface SystemWorkflow {
id: string;
mandateId: string;
featureInstanceId: string;
featureCode?: string;
label: string;
active: boolean;
isRunning?: boolean;
@ -72,6 +74,43 @@ interface SystemWorkflow {
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 {
if (ts == null || ts <= 0) return '—';
const sec = ts < 1e12 ? ts : ts / 1000;
@ -664,6 +703,7 @@ const _WorkflowsTab: React.FC = () => {
const { request } = useApiRequest();
const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt();
const { dynamicBlock } = useNavigation();
const [workflows, setWorkflows] = useState<SystemWorkflow[]>([]);
const [loading, setLoading] = useState(true);
@ -718,10 +758,19 @@ const _WorkflowsTab: React.FC = () => {
}, [hasRunningWorkflows, _load]);
const _handleEdit = useCallback((row: SystemWorkflow) => {
if (!row.mandateId || !row.featureInstanceId) return;
const fc = (row as any).featureCode || 'graphicalEditor';
navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`);
}, [navigate]);
if (!row.mandateId) return;
const fc = row.featureCode || '';
if (_FEATURES_WITH_EDITOR.has(fc)) {
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> => {
try {

View file

@ -133,7 +133,7 @@ export const ComplianceAuditPage: React.FC = () => {
const [mandates, setMandates] = useState<Mandate[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
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 ──
const [aiEntries, setAiEntries] = useState<any[]>([]);
@ -193,8 +193,13 @@ export const ComplianceAuditPage: React.FC = () => {
const pageSize = paginationParams?.pageSize ?? _AI_LOG_PAGE_SIZE;
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', {
params: { limit: pageSize, offset },
params,
headers: _mandateHeaders(),
});
const items: any[] = data?.items ?? [];
@ -220,8 +225,13 @@ export const ComplianceAuditPage: React.FC = () => {
const pageSize = paginationParams?.pageSize ?? _AUDIT_LOG_PAGE_SIZE;
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', {
params: { limit: pageSize, offset },
params,
headers: _mandateHeaders(),
});
const items: any[] = data?.items ?? [];
@ -262,8 +272,13 @@ export const ComplianceAuditPage: React.FC = () => {
const pageSize = paginationParams?.pageSize ?? _NEUT_PAGE_SIZE;
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', {
params: { limit: pageSize, offset },
params: neutParams,
headers: _mandateHeaders(),
});
const items: any[] = data?.items ?? [];
@ -410,8 +425,8 @@ export const ComplianceAuditPage: React.FC = () => {
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,
formatter: (val: any, row: any) => row?.instanceLabel || val || '',
key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
formatter: (val: any, row: any) => val || row?.featureCode || '',
},
{ key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 },
{
@ -467,12 +482,12 @@ export const ComplianceAuditPage: React.FC = () => {
{ 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: 'userId', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140,
formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '',
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140,
formatter: (val: any, row: any) => val || (row?.userId ? String(row.userId).slice(0, 8) + '…' : ''),
},
{
key: 'featureInstanceId', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
formatter: (val: any) => val || '',
key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
formatter: (val: any, row: any) => val || (row?.featureInstanceId ? String(row.featureInstanceId).slice(0, 8) + '…' : ''),
},
{
key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140,
@ -480,26 +495,46 @@ export const ComplianceAuditPage: React.FC = () => {
},
], [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 ──
const aiLogHookData = useMemo(() => ({
refetch: _loadAiLog,
pagination: aiPagination,
}), [_loadAiLog, aiPagination]);
fetchFilterValues: _makeFetchFilterValues('/api/audit/ai-log'),
}), [_loadAiLog, aiPagination, _makeFetchFilterValues]);
const auditLogHookData = useMemo(() => ({
refetch: _loadAuditLog,
pagination: auditPagination,
}), [_loadAuditLog, auditPagination]);
fetchFilterValues: _makeFetchFilterValues('/api/audit/log'),
}), [_loadAuditLog, auditPagination, _makeFetchFilterValues]);
const neutHookData = useMemo(() => ({
refetch: _loadNeutMappings,
pagination: neutPagination,
}), [_loadNeutMappings, neutPagination]);
fetchFilterValues: _makeFetchFilterValues('/api/audit/neutralization-mappings'),
}), [_loadNeutMappings, neutPagination, _makeFetchFilterValues]);
// ── Render ──
const _tabs: TabId[] = ['ai-log', 'audit-log', 'stats', 'neutralization'];
const _tabs: TabId[] = ['audit-log', 'ai-log', 'neutralization', 'stats'];
return (
<div className={styles.wrap}>
@ -780,8 +815,8 @@ export const ComplianceAuditPage: React.FC = () => {
{/* ── Content View Modal ── */}
{contentModal && (
<div className={styles.modalOverlay} onClick={() => setContentModal(null)}>
<div className={styles.modalContainer} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modalContainer}>
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>{t('AI-Audit Inhalt')}</h3>
<div className={styles.modalMeta}>

View file

@ -8,7 +8,7 @@ import { PENDING_INVITATION_KEY } from './InvitePage';
import OnboardingWizard from '../components/OnboardingWizard';
import styles from './Login.module.css';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext';
@ -131,6 +131,9 @@ function Login() {
return (
<div className={styles.container}>
<div className={styles.mainContent}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '-1.5rem' }}>
<LanguageSelector />
</div>
<div className={styles.logo}>
<img
src="/logos/poweron-logo.png"

View file

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
import styles from './PasswordResetRequest.module.css';
import { usePasswordResetRequest } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext';
@ -57,6 +58,9 @@ function PasswordResetRequest() {
return (
<div className={styles.container}>
<div className={styles.mainContent}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '-1.5rem' }}>
<LanguageSelector />
</div>
<div className={styles.logo}>
<img
src="/logos/poweron-logo.png"

View file

@ -6,6 +6,7 @@ import styles from './Register.module.css';
import { useRegister, useMsalRegister, useUsernameAvailability } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { PENDING_INVITATION_KEY } from './InvitePage';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext';
@ -16,7 +17,7 @@ interface RegisterFormData {
}
function Register() {
const { t } = useLanguage();
const { t, currentLanguage } = useLanguage();
const navigate = useNavigate();
const location = useLocation();
const { register, error: registerError, isLoading } = useRegister();
@ -91,7 +92,7 @@ function Register() {
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.');
if (hasPendingInvitation) {
@ -125,6 +126,9 @@ function Register() {
return (
<div className={styles.container}>
<div className={styles.mainContent}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '-1.5rem' }}>
<LanguageSelector />
</div>
<div className={styles.logo}>
<img
src="/logos/poweron-logo.png"

View file

@ -4,6 +4,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import styles from './Reset.module.css';
import { usePasswordReset } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext';
@ -98,6 +99,9 @@ function Reset() {
return (
<div className={styles.container}>
<div className={styles.mainContent}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '-1.5rem' }}>
<LanguageSelector />
</div>
<div className={styles.logo}>
<img
src="/logos/poweron-logo.png"
@ -138,6 +142,9 @@ function Reset() {
return (
<div className={styles.container}>
<div className={styles.mainContent}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '-1.5rem' }}>
<LanguageSelector />
</div>
<div className={styles.logo}>
<img
src="/logos/poweron-logo.png"

View file

@ -17,15 +17,14 @@ import styles from './Settings.module.css';
// TYPES
// =============================================================================
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy';
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy';
function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] {
return [
{ key: 'profile', label: t('Tab Profil') },
{ key: 'appearance', label: t('Tab Darstellung') },
{ key: 'voice', label: t('Tab Stimme & Sprache') },
{ key: 'neutralization', label: t('Tab Neutralisierung') },
{ key: 'privacy', label: t('Tab Datenschutz') },
{ key: 'profile', label: t('Profil') },
{ key: 'appearance', label: t('Darstellung') },
{ key: 'voice', label: t('Stimme & Sprache') },
{ key: 'privacy', label: t('Datenschutz') },
];
}
@ -563,19 +562,20 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'voice' && <VoiceSettingsTab />}
{activeTab === 'neutralization' && <NeutralizationMappingsTab />}
{activeTab === 'privacy' && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Datenschutz')}</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
{t('Datenschutzbeschreibung')}
</p>
<div className={styles.settingRow}>
<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.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">{t('GDPR öffnen')}</Link></div>
</div>
</section>
<>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Datenschutz')}</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
{t('Datenschutzbeschreibung')}
</p>
<div className={styles.settingRow}>
<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.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">{t('GDPR öffnen')}</Link></div>
</div>
</section>
<NeutralizationMappingsTab />
</>
)}
</main>

View file

@ -5,7 +5,7 @@
*/
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 { useStore } from '../hooks/useStore';
import type { StoreFeature, UserMandate } from '../api/storeApi';
@ -18,6 +18,7 @@ const FEATURE_ICONS: Record<string, React.ReactNode> = {
teamsbot: <FaHeadset />,
workspace: <FaComments />,
commcoach: <FaComments />,
trustee: <FaShieldAlt />,
};
/** Fallback when GET /store/features omits description (German i18n keys). */
@ -27,6 +28,7 @@ const STORE_FEATURE_DESCRIPTION_FALLBACK: Record<string, string> = {
teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
workspace: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.',
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 {

View file

@ -0,0 +1,6 @@
/**
* AdminDatabaseHealthPage Styles
*
* Minimal table rendering is handled by FormGeneratorTable.
* Only page-specific overrides live here.
*/

View 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;

View file

@ -15,7 +15,6 @@ import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import { ChatbotConfigSection } from './ChatbotConfigSection';
import { DropdownSelect } from '../../components/UiComponents/DropdownSelect';
import { TextField } from '../../components/UiComponents/TextField';
import styles from './Admin.module.css';
@ -512,8 +511,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
{/* Create Instance Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Feature-Instanz erstellen')}</h2>
<button
@ -533,35 +532,38 @@ export const AdminFeatureAccessPage: React.FC = () => {
</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)' }}>
<label className={styles.configLabel} style={{ fontWeight: 600 }}>
{t('Feature auswählen')}: <span style={{ color: 'var(--error-color)' }}>*</span>
</label>
<DropdownSelect
items={features.map(f => ({
id: f.code,
label: f.label || f.code,
value: f.code
}))}
selectedItemId={createFeatureCode}
onSelect={(item) => {
const selectedCode = item?.value || '';
setCreateFeatureCode(selectedCode);
// Reset chatbot config when switching
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}}
placeholder={t('Feature-Auswahl erforderlich')}
className={styles.configSelect}
/>
{!createFeatureCode && (
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
{t('Bitte wählen Sie ein Feature aus, um fortzufahren.')}
</p>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '0.5rem' }}>
{features.map(f => (
<button
key={f.code}
type="button"
className={styles.secondaryButton}
style={{
padding: '0.5rem 1rem',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: createFeatureCode === f.code ? 600 : 400,
background: createFeatureCode === f.code ? 'var(--primary-color)' : undefined,
color: createFeatureCode === f.code ? '#fff' : undefined,
borderColor: createFeatureCode === f.code ? 'var(--primary-color)' : undefined,
}}
onClick={() => {
setCreateFeatureCode(f.code);
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}}
>
{f.label || f.code}
</button>
))}
</div>
</div>
{/* Chatbot Configuration Title - Show when chatbot is selected */}
@ -634,8 +636,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
{/* Edit Instance Modal */}
{showEditModal && editingInstance && (
<div className={styles.modalOverlay} onClick={() => { setShowEditModal(false); setEditingInstance(null); }}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Feature-Instanz bearbeiten')}</h2>
<button

View file

@ -561,8 +561,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{/* Add User Modal */}
{showAddModal && (
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Benutzer zur Feature-Instanz hinzufügen')}</h2>
<button
@ -594,8 +594,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{/* Edit Roles Modal */}
{editingUser && (
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{t('Rollen bearbeiten')}: {editingUser.username}

View file

@ -397,8 +397,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
{/* Create Role Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Feature-Rolle erstellen')}</h2>
<button
@ -430,8 +430,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
{/* Edit Role Modal */}
{editingRole && (
<div className={styles.modalOverlay} onClick={() => setEditingRole(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Feature-Rolle bearbeiten')}</h2>
<button
@ -462,8 +462,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
{/* Permissions Modal */}
{permissionsRole && (
<div className={styles.modalOverlay} onClick={() => setPermissionsRole(null)}>
<div className={styles.modal} style={{ maxWidth: '900px', width: '90%' }} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal} style={{ maxWidth: '900px', width: '90%' }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
<FaShieldAlt style={{ marginRight: 8 }} />

View file

@ -372,8 +372,8 @@ export const AdminInvitationsPage: React.FC = () => {
{/* Create Invitation Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Einladung erstellen')}</h2>
<button
@ -411,8 +411,8 @@ export const AdminInvitationsPage: React.FC = () => {
{/* URL Display Modal */}
{showUrlModal && (
<div className={styles.modalOverlay} onClick={() => setShowUrlModal(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Einladungs-Link')}</h2>
<button

View file

@ -434,8 +434,8 @@ export const AdminMandateRolesPage: React.FC = () => {
{/* Create Role Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Rolle erstellen')}</h2>
<button
@ -468,8 +468,8 @@ export const AdminMandateRolesPage: React.FC = () => {
{/* Edit Role Modal */}
{editingRole && (
<div className={styles.modalOverlay} onClick={() => setEditingRole(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{t('Rolle bearbeiten')}: {editingRole.roleLabel}

View file

@ -4,7 +4,7 @@
* 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 { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
import { useApiRequest } from '../../hooks/useApi';
@ -16,8 +16,9 @@ import {
import { useToast } from '../../contexts/ToastContext';
import { usePrompt } from '../../hooks/usePrompt';
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 { getUserDataCache } from '../../utils/userCache';
import styles from './Admin.module.css';
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 [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
const isSysAdmin = getUserDataCache()?.isSysAdmin === true;
// MandateAdmin: only label + billing fields editable; rest readonly
const _MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']);
const editFormAttrs: AttributeDefinition[] = useMemo(() => {
if (isSysAdmin) return formAttributesWithBilling;
return formAttributesWithBilling.map(attr =>
_MANDATE_ADMIN_EDITABLE.has(attr.name) ? attr : { ...attr, editable: false, readonly: true }
);
}, [formAttributesWithBilling, isSysAdmin]);
// Check if user can create
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
@ -106,7 +118,10 @@ export const AdminMandatesPage: React.FC = () => {
const mandateId = String(editingFormData.id);
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
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 {
await updateSettingsAdmin(request, mandateId, billingUpdate);
showSuccess(t('Gespeichert'), t('Mandant und Abrechnung aktualisiert.'));
@ -253,8 +268,8 @@ export const AdminMandatesPage: React.FC = () => {
{/* Create Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neuer Mandant')}</h2>
<button
@ -293,14 +308,8 @@ export const AdminMandatesPage: React.FC = () => {
{/* Edit Modal */}
{editingFormData && (
<div
className={styles.modalOverlay}
onClick={() => {
setEditingFormData(null);
setEditingBillingWarning(null);
}}
>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Mandant bearbeiten')}</h2>
<button
@ -338,7 +347,7 @@ export const AdminMandatesPage: React.FC = () => {
</div>
) : (
<FormGeneratorForm
attributes={formAttributesWithBilling}
attributes={editFormAttrs}
data={editingFormData}
mode="edit"
onSubmit={handleEditSubmit}

View file

@ -375,8 +375,8 @@ export const AdminUserMandatesPage: React.FC = () => {
{/* Add User Modal */}
{showAddModal && (
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Benutzer zum Mandanten hinzufügen')}</h2>
<button
@ -411,8 +411,8 @@ export const AdminUserMandatesPage: React.FC = () => {
{/* Edit Roles Modal */}
{editingUser && (
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{t('Rollen bearbeiten')}: {editingUser.username}

View file

@ -230,8 +230,8 @@ export const AdminUsersPage: React.FC = () => {
{/* Create Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neuer Benutzer')}</h2>
<button
@ -264,8 +264,8 @@ export const AdminUsersPage: React.FC = () => {
{/* Edit Modal */}
{editingUser && (
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Benutzer bearbeiten')}</h2>
<button

View file

@ -311,8 +311,8 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
</div>
{showAddModal && (
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Benutzer hinzufügen')}</h2>
<button type="button" className={styles.modalClose} onClick={() => setShowAddModal(false)}>
@ -340,8 +340,8 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
)}
{editingUser && (
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{t('Rollen')}: {editingUser.username}

View file

@ -18,3 +18,4 @@ export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
export { AdminLogsPage } from './AdminLogsPage';
export { AdminLanguagesPage } from './AdminLanguagesPage';
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';

View file

@ -15,10 +15,6 @@ import styles from '../admin/Admin.module.css';
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 = () => {
const { t } = useLanguage();
@ -101,12 +97,15 @@ export const ConnectionsPage: React.FC = () => {
// Handle edit submit
const handleEditSubmit = async (data: Partial<Connection>) => {
if (!editingConnection) return;
// Note: updateConnection is handled through the hook
try {
// Ensure authority is properly typed - filter and validate authority value
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 === 'local' ||
@ -116,7 +115,6 @@ export const ConnectionsPage: React.FC = () => {
) {
updateData.authority = data.authority;
} else {
// Remove invalid authority value
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 () => {
if (isConnecting) return;
try {
await createGoogleConnectionAndAuth();
refetch();
@ -187,8 +187,8 @@ export const ConnectionsPage: React.FC = () => {
}
};
// Handle create Microsoft connection
const handleCreateMicrosoft = async () => {
if (isConnecting) return;
try {
await createMicrosoftConnectionAndAuth();
refetch();
@ -197,9 +197,8 @@ export const ConnectionsPage: React.FC = () => {
}
};
// Handle create ClickUp connection (UI kann per Flag abgeschaltet sein)
const handleCreateClickup = async () => {
if (!isClickupConnectionUiEnabled) return;
if (isConnecting) return;
try {
await createClickupConnectionAndAuth();
refetch();
@ -228,7 +227,12 @@ export const ConnectionsPage: React.FC = () => {
// Form attributes for edit modal
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 || [])
.filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
@ -253,9 +257,7 @@ export const ConnectionsPage: React.FC = () => {
<div>
<h1 className={styles.pageTitle}>{t('Verbindungen')}</h1>
<p className={styles.pageSubtitle}>
{isClickupConnectionUiEnabled
? t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp)')
: t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft)')}
{t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp)')}
</p>
</div>
<div className={styles.headerActions}>
@ -263,7 +265,7 @@ export const ConnectionsPage: React.FC = () => {
className={styles.secondaryButton}
onClick={handleAdminConsent}
disabled={adminConsentPending}
title={t('Microsoft Admin-Zustimmung erteilt der')}
title={t('Microsoft Admin-Zustimmung für die gesamte Organisation erteilen')}
>
<FaShieldAlt /> {t('Admin-Zustimmung')}
</button>
@ -290,17 +292,15 @@ export const ConnectionsPage: React.FC = () => {
>
<FaMicrosoft /> Microsoft
</button>
{isClickupConnectionUiEnabled && (
<button
type="button"
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
title={t('ClickUp-Konto verbinden')}
>
<FaTasks /> ClickUp
</button>
)}
<button
type="button"
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
title={t('ClickUp-Konto verbinden (OAuth oder Personal Token nach Anmeldung)')}
>
<FaTasks /> ClickUp
</button>
</>
)}
</div>
@ -336,9 +336,7 @@ export const ConnectionsPage: React.FC = () => {
icon: <FaLink />,
onClick: handleConnect,
title: t('Verbinden'),
visible: (row: Connection) =>
row.status !== 'active' &&
(isClickupConnectionUiEnabled || row.authority !== 'clickup'),
visible: (row: Connection) => row.status !== 'active',
loading: () => isConnecting,
},
{
@ -365,8 +363,8 @@ export const ConnectionsPage: React.FC = () => {
{/* Edit Modal */}
{editingConnection && (
<div className={styles.modalOverlay} onClick={() => setEditingConnection(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Verbindung bearbeiten')}</h2>
<button

View file

@ -14,7 +14,7 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import FolderTree from '../../components/FolderTree/FolderTree';
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 { usePrompt } from '../../hooks/usePrompt';
import styles from '../admin/Admin.module.css';
@ -66,7 +66,6 @@ export const FilesPage: React.FC = () => {
handleFileDeleteMultiple,
handleFileUpload,
handleFileUpdate,
handleFilePreview,
handleInlineUpdate,
deletingFiles,
downloadingFiles,
@ -153,6 +152,29 @@ export const FilesPage: React.FC = () => {
}));
}, [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 ───────────────────────────────────────────────────────────
const columns = useMemo(() => {
const hiddenColumns = ['id', 'fileHash', 'folderId'];
@ -278,13 +300,6 @@ export const FilesPage: React.FC = () => {
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 handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -293,7 +308,7 @@ export const FilesPage: React.FC = () => {
let successCount = 0;
let errorCount = 0;
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 (fileInputRef.current) fileInputRef.current.value = '';
@ -465,6 +480,15 @@ export const FilesPage: React.FC = () => {
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
}
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 ? [{
type: 'edit' as const,
onAction: handleEditClick,
@ -486,13 +510,6 @@ export const FilesPage: React.FC = () => {
title: t('Herunterladen'),
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}
onDeleteMultiple={handleDeleteMultiple}
@ -503,16 +520,17 @@ export const FilesPage: React.FC = () => {
handleDelete: handleFileDelete,
handleInlineUpdate,
updateOptimistically: updateFileOptimistically,
previewingFiles,
}}
emptyMessage={t('Keine Dateien gefunden')}
emptyMessage={emptyTableMessage}
/>
</div>
</div>
</div>
{editingFile && (
<div className={styles.modalOverlay} onClick={() => setEditingFile(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Datei bearbeiten')}</h2>
<button className={styles.modalClose} onClick={() => setEditingFile(null)}></button>

View file

@ -5,7 +5,7 @@
* 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 { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
@ -49,10 +49,22 @@ export const PromptsPage: React.FC = () => {
const [showCreateModal, setShowCreateModal] = useState(false);
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
useEffect(() => {
refetch();
}, []);
_tableRefetch({ page: 1, pageSize: 25 });
}, [_tableRefetch]);
// Generate columns from attributes - exclude ID fields from display
const columns = useMemo(() => {
@ -114,7 +126,7 @@ export const PromptsPage: React.FC = () => {
});
if (result?.success) {
setShowCreateModal(false);
refetch();
_refreshAll();
}
};
@ -127,7 +139,7 @@ export const PromptsPage: React.FC = () => {
});
if (result.success) {
setEditingPrompt(null);
refetch();
_refreshAll();
}
};
@ -135,7 +147,7 @@ export const PromptsPage: React.FC = () => {
const handleDelete = async (prompt: Prompt) => {
const success = await handlePromptDelete(prompt.id);
if (success) {
refetch();
_refreshAll();
}
};
@ -152,7 +164,7 @@ export const PromptsPage: React.FC = () => {
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<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')}
</button>
</div>
@ -170,7 +182,7 @@ export const PromptsPage: React.FC = () => {
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
onClick={() => _refreshAll()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
@ -217,7 +229,7 @@ export const PromptsPage: React.FC = () => {
]}
onDelete={handleDelete}
hookData={{
refetch,
refetch: _tableRefetch,
permissions,
pagination,
handleDelete: handlePromptDelete,
@ -230,8 +242,8 @@ export const PromptsPage: React.FC = () => {
{/* Create Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neuer Prompt')}</h2>
<button
@ -264,8 +276,8 @@ export const PromptsPage: React.FC = () => {
{/* Edit Modal */}
{editingPrompt && (
<div className={styles.modalOverlay} onClick={() => setEditingPrompt(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Prompt bearbeiten')}</h2>
<button

View file

@ -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: 'aicoreModel', label: t('Modell'), type: 'text' as any, sortable: true, filterable: true, width: 150 },
{ key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
{ key: 'amount', label: t('Betrag (CHF)'), type: 'number' as any, sortable: true, width: 120 },
{ key: 'amount', label: t('Betrag (CHF)'), type: 'number' as any, sortable: true, searchable: true, width: 120 },
], [t]);
const totalBalance = useMemo(() => {
@ -609,7 +609,7 @@ export const BillingDataView: React.FC = () => {
fontWeight: chartMode === 'pie' ? 600 : 400,
}}
>
{t('Pie')}
{t('Kreis')}
</button>
<button
onClick={() => setChartMode('bar')}

View file

@ -149,3 +149,90 @@
font-size: 0.9rem;
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; }
}

View file

@ -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(
(category: string) => {
const labels: Record<string, string> = {
@ -88,11 +94,16 @@ export const CommcoachDashboardView: React.FC = () => {
{/* Active Contexts */}
<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 ? (
<div className={styles.emptyState}>
<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 className={styles.contextGrid}>

View file

@ -22,6 +22,21 @@
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 {
position: absolute;
top: 8px;
@ -51,9 +66,17 @@
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
@media (max-width: 768px) {
.dossier {
overflow-y: auto;
overflow-x: hidden;
}
}
/* Context Selector */
.contextSelector {
display: flex;
@ -65,6 +88,29 @@
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 {
display: flex;
align-items: center;
@ -166,6 +212,31 @@
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 {
font-size: 1.3rem;
font-weight: 600;
@ -273,6 +344,36 @@
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 {
padding: 0.6rem 1.25rem;
background: transparent;
@ -325,6 +426,12 @@
.personaSelector { margin-bottom: 1rem; }
.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; }
@media (max-width: 768px) {
.personaGrid { gap: 0.35rem; }
.personaChip { font-size: 0.75rem; padding: 0.3rem 0.6rem; }
.sessionStart { padding: 1rem; }
}
.personaChip {
display: flex; align-items: center; gap: 0.3rem;
padding: 0.4rem 0.8rem;
@ -350,6 +457,17 @@
.sessionLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); }
.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 {
flex: 1;
@ -361,9 +479,25 @@
}
.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; }
.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 {
padding: 0.75rem 1rem;
border-radius: 12px;
@ -528,6 +662,25 @@
.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 {
flex: 1; min-width: 0;
padding: 0.6rem 0.75rem;

View file

@ -7,6 +7,7 @@
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useCommcoach } from '../../../hooks/useCommcoach';
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
import { useApiRequest } from '../../../hooks/useApi';
@ -73,6 +74,7 @@ export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ pers
const mandateId = persistentMandateId || routeMandateId;
const coach = useCommcoach(instanceId);
const { request } = useApiRequest();
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
const [showNewContext, setShowNewContext] = useState(false);
@ -144,6 +146,14 @@ export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ pers
}
}, [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
useEffect(() => {
if (!instanceId || !coach.selectedContextId) return;

View file

@ -41,7 +41,6 @@ export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisibl
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<CommcoachDossierView

View file

@ -154,3 +154,25 @@
cursor: not-allowed;
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; }
}

View file

@ -249,8 +249,8 @@ export const TrusteePositionDocumentsView: React.FC = () => {
{/* Edit Modal */}
{editingLink && (
<div className={styles.modalOverlay} onClick={() => setEditingLink(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Verknüpfung bearbeiten')}</h2>
<button

View file

@ -55,6 +55,10 @@ interface WorkspaceInputProps {
onProviderSelectionChange?: (selection: ProviderSelection) => void;
isMobile?: boolean;
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
onFeatureSourceDrop?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
pendingAttachDsId?: string;
onPendingAttachDsConsumed?: () => void;
onPasteAsFile?: (file: File) => void;
draftAppend?: string;
onDraftAppendConsumed?: () => void;
@ -75,6 +79,10 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
onProviderSelectionChange,
isMobile = false,
onTreeItemsDrop,
onFeatureSourceDrop,
onDataSourceDrop,
pendingAttachDsId,
onPendingAttachDsConsumed,
onPasteAsFile,
draftAppend,
onDraftAppendConsumed,
@ -101,6 +109,15 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
}
}, [draftAppend, onDraftAppendConsumed]);
useEffect(() => {
if (pendingAttachDsId) {
setAttachedDataSourceIds(prev =>
prev.includes(pendingAttachDsId) ? prev : [...prev, pendingAttachDsId],
);
onPendingAttachDsConsumed?.();
}
}, [pendingAttachDsId, onPendingAttachDsConsumed]);
const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef('');
const currentInterimRef = useRef('');
@ -142,7 +159,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options);
setPrompt('');
setShowAutocomplete(false);
setShowSourcePicker(false);
setAttachedFileIds([]);
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
@ -197,14 +213,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId));
}, []);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const _toggleDataSource = useCallback((dsId: string) => {
setAttachedDataSourceIds(prev =>
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
);
}, []);
const _toggleFeatureDataSource = useCallback((fdsId: string) => {
setAttachedFeatureDataSourceIds(prev =>
prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
@ -288,7 +296,9 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
if (
e.dataTransfer.types.includes('application/tree-items') ||
e.dataTransfer.types.includes('application/chat-id')
e.dataTransfer.types.includes('application/chat-id') ||
e.dataTransfer.types.includes('application/feature-source') ||
e.dataTransfer.types.includes('application/datasource')
) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
@ -311,6 +321,24 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
return;
}
const featureSourceJson = e.dataTransfer.getData('application/feature-source');
if (featureSourceJson && onFeatureSourceDrop) {
e.preventDefault();
e.stopPropagation();
const params = JSON.parse(featureSourceJson);
onFeatureSourceDrop(params);
return;
}
const dataSourceJson = e.dataTransfer.getData('application/datasource');
if (dataSourceJson && onDataSourceDrop) {
e.preventDefault();
e.stopPropagation();
const params = JSON.parse(dataSourceJson);
onDataSourceDrop(params);
return;
}
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson && onTreeItemsDrop) {
e.preventDefault();
@ -318,7 +346,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
onTreeItemsDrop(items);
}
}, [onTreeItemsDrop]);
}, [onTreeItemsDrop, onFeatureSourceDrop, onDataSourceDrop]);
return (
<div
@ -545,116 +573,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
{uploading ? '...' : '+'}
</button>
{(dataSources.length > 0 || featureDataSources.length > 0) && (
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowSourcePicker(prev => !prev)}
disabled={isProcessing}
title={t('Datenquellen anhängen')}
style={{
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
background: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#2e7d32' : '#666',
cursor: isProcessing ? 'not-allowed' : 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: isProcessing ? 0.5 : 1,
position: 'relative',
}}
>
🔗
{(attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 && (
<span style={{
position: 'absolute', top: -4, right: -4,
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{attachedDataSourceIds.length + attachedFeatureDataSourceIds.length}
</span>
)}
</button>
{showSourcePicker && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 240, maxHeight: 260, overflowY: 'auto',
}}>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
Active Sources auswählen
</div>
{dataSources.map(ds => {
const isSelected = attachedDataSourceIds.includes(ds.id);
return (
<div
key={ds.id}
onClick={() => _toggleDataSource(ds.id)}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#e8f5e9' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }}
>
<span style={{
width: 16, height: 16, borderRadius: 3,
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
background: isSelected ? '#2e7d32' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
}}>
{isSelected ? '✓' : ''}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label || ds.path || ds.id}
</span>
</div>
);
})}
{featureDataSources.length > 0 && (
<>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
Feature Data Sources
</div>
{featureDataSources.map(fds => {
const isSelected = attachedFeatureDataSourceIds.includes(fds.id);
return (
<div
key={fds.id}
onClick={() => _toggleFeatureDataSource(fds.id)}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#f3e5f5' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }}
>
<span style={{
width: 16, height: 16, borderRadius: 3,
border: isSelected ? '2px solid #7b1fa2' : '2px solid #ccc',
background: isSelected ? '#7b1fa2' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
}}>
{isSelected ? '✓' : ''}
</span>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 13, color: '#7b1fa2', flexShrink: 0 }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fds.label || fds.featureCode} {fds.tableName}
</span>
</div>
);
})}
</>
)}
</div>
)}
</div>
)}
{/* Source picker removed — data sources are now attached directly from the UDB Sources/Files tabs via "send to chat" buttons */}
{onProviderSelectionChange && providerSelection && (
<ProviderMultiSelect

View file

@ -17,7 +17,7 @@ import { WorkspaceInput } from './WorkspaceInput';
import { FilePreview } from './FilePreview';
import { ToolActivityLog } from './ToolActivityLog';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar';
import api from '../../../api';
import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector';
@ -279,6 +279,59 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
workspace.refreshFeatureDataSources();
}, [workspace]);
const _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => {
setPendingFiles(prev => {
const existing = new Set(prev.map(f => f.fileId));
const toAdd: PendingFile[] = [];
for (const item of items) {
if (!existing.has(item.id)) {
toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type });
existing.add(item.id);
}
}
return [...prev, ...toAdd];
});
}, []);
const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => {
try {
await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: params.featureInstanceId,
featureCode: params.featureCode,
tableName: params.tableName || '',
objectKey: params.objectKey,
label: params.label,
});
workspace.refreshFeatureDataSources();
} catch (err) {
console.error('Failed to add feature source to chat:', err);
}
}, [instanceId, workspace]);
const [pendingAttachDsId, setPendingAttachDsId] = useState<string>('');
const _handleAttachDataSource = useCallback((dsId: string) => {
setPendingAttachDsId(dsId);
}, []);
const _handleDataSourceDrop = useCallback(async (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => {
try {
const res = await api.post(`/api/workspace/${instanceId}/datasources`, {
connectionId: params.connectionId,
sourceType: params.sourceType,
path: params.path,
label: params.label,
displayPath: params.displayPath || params.label,
});
const newId = res.data?.id || res.data?.dataSource?.id;
if (newId) {
setPendingAttachDsId(newId);
workspace.refreshDataSources();
}
} catch (err) {
console.error('Failed to drop data source to chat:', err);
}
}, [instanceId, workspace]);
const _leftPanelBody = (
<UnifiedDataBar
context={_udbContext}
@ -291,6 +344,9 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
onDeleteChat={_handleDeleteChat}
onFileSelect={_handleFileSelect}
onSourcesChanged={_handleSourcesChanged}
onSendToChat_Files={_handleSendToChat_Files}
onSendToChat_FeatureSource={_handleSendToChat_FeatureSource}
onAttachDataSource={_handleAttachDataSource}
/>
);
@ -461,6 +517,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
onProviderSelectionChange={setProviderSelection}
isMobile={isMobile}
onTreeItemsDrop={_handleTreeItemsDrop}
onFeatureSourceDrop={_handleSendToChat_FeatureSource}
onDataSourceDrop={_handleDataSourceDrop}
pendingAttachDsId={pendingAttachDsId}
onPendingAttachDsConsumed={() => setPendingAttachDsId('')}
onPasteAsFile={_uploadAndAttach}
draftAppend={draftAppend}
onDraftAppendConsumed={() => setDraftAppend('')}