Merge pull request #6 from valueonag/feat/cost-control

Feat/cost control
This commit is contained in:
Patrick Motsch 2026-02-10 00:11:54 +01:00 committed by GitHub
commit af9e827efc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 7481 additions and 1273 deletions

346
package-lock.json generated
View file

@ -31,6 +31,7 @@
"react-leaflet": "^5.0.0",
"react-markdown": "^9.1.0",
"react-router-dom": "^7.7.1",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"xstate": "^5.20.1"
},
@ -1091,6 +1092,40 @@
"react-dom": "^19.0.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1392,6 +1427,16 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1437,6 +1482,60 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -1549,6 +1648,11 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz",
@ -2274,6 +2378,14 @@
"node": ">= 10.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2451,6 +2563,116 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -2468,6 +2690,11 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@ -2746,6 +2973,11 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@ -3018,6 +3250,11 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@ -3682,6 +3919,15 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -3721,6 +3967,14 @@
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -5813,6 +6067,28 @@
"react": ">=18"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -5870,6 +6146,45 @@
"node": ">=18"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/relateurl": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
@ -5946,6 +6261,11 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -6386,6 +6706,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -6734,6 +7059,27 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "5.4.19",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",

View file

@ -37,6 +37,7 @@
"react-leaflet": "^5.0.0",
"react-markdown": "^9.1.0",
"react-router-dom": "^7.7.1",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"xstate": "^5.20.1"
},

View file

@ -43,14 +43,11 @@ import { GDPRPage } from './pages/GDPR';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin';
// Workflow Pages (global)
import { PlaygroundPage, WorkflowsPage, AutomationsPage, AutomationTemplatesPage } from './pages/workflows';
// Basedata Pages (global)
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
// Migrate Pages (temporary - to be migrated to feature instances)
import { PekPage, SpeechPage } from './pages/migrate';
// Billing Pages
import { BillingDashboard, BillingDataView, BillingAdmin } from './pages/billing';
function App() {
// Load saved theme preference and set app name on app mount
@ -107,16 +104,6 @@ function App() {
<Route path="settings" element={<SettingsPage />} />
<Route path="gdpr" element={<GDPRPage />} />
{/* ============================================== */}
{/* WORKFLOWS ROUTES (global) */}
{/* ============================================== */}
<Route path="workflows">
<Route path="playground" element={<PlaygroundPage />} />
<Route path="list" element={<WorkflowsPage />} />
<Route path="automations" element={<AutomationsPage />} />
<Route path="automation-templates" element={<AutomationTemplatesPage />} />
</Route>
{/* ============================================== */}
{/* BASISDATEN ROUTES (global) */}
{/* ============================================== */}
@ -127,11 +114,12 @@ function App() {
</Route>
{/* ============================================== */}
{/* MIGRATE TO FEATURES (temporary) */}
{/* BILLING ROUTES */}
{/* ============================================== */}
<Route path="chatbot" element={<Navigate to="/" replace />} />
<Route path="pek" element={<PekPage />} />
<Route path="speech" element={<SpeechPage />} />
<Route path="billing">
<Route index element={<Navigate to="/billing/transactions" replace />} />
<Route path="transactions" element={<BillingDataView />} />
</Route>
{/* ============================================== */}
{/* FEATURE-INSTANZ ROUTES */}
@ -159,6 +147,15 @@ function App() {
<Route path="projects" element={<FeatureViewPage view="projects" />} />
<Route path="parcels" element={<FeatureViewPage view="parcels" />} />
{/* Chat Playground Feature Views */}
<Route path="playground" element={<FeatureViewPage view="playground" />} />
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
{/* Automation Feature Views */}
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
<Route path="templates" element={<FeatureViewPage view="templates" />} />
<Route path="logs" element={<FeatureViewPage view="logs" />} />
{/* Catch-all für unbekannte Sub-Pfade */}
<Route path="*" element={<FeatureViewPage view="not-found" />} />
</Route>
@ -179,6 +176,7 @@ function App() {
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
<Route path="billing" element={<BillingAdmin />} />
</Route>
</Route>

View file

@ -17,10 +17,12 @@ export interface Automation {
lastExecution?: number;
nextExecution?: number;
executionLogs?: AutomationLog[];
allowedProviders?: string[];
_createdAt?: number;
_updatedAt?: number;
_createdByUserName?: string;
mandateName?: string;
featureInstanceName?: string;
[key: string]: any;
}

380
src/api/billingApi.ts Normal file
View file

@ -0,0 +1,380 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER' | 'CREDIT_POSTPAY' | 'UNLIMITED';
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
export interface BillingAddress {
company: string;
street: string;
zip: string;
city: string;
country: string;
vatNumber?: string;
}
export interface BillingBalance {
mandateId: string;
mandateName: string;
billingModel: BillingModel;
balance: number;
currency: string;
warningThreshold: number;
isWarning: boolean;
creditLimit?: number;
}
export interface BillingTransaction {
id: string;
accountId: string;
transactionType: TransactionType;
amount: number;
description: string;
referenceType?: ReferenceType;
workflowId?: string;
featureInstanceId?: string;
featureCode?: string;
aicoreProvider?: string;
aicoreModel?: string;
createdByUserId?: string;
createdAt?: string;
mandateId?: string;
mandateName?: string;
userId?: string;
userName?: string;
}
export interface BillingSettings {
id: string;
mandateId: string;
billingModel: BillingModel;
defaultUserCredit: number;
warningThresholdPercent: number;
blockOnZeroBalance: boolean;
notifyOnWarning: boolean;
notifyEmails: string[];
billingAddress?: BillingAddress;
}
export interface BillingSettingsUpdate {
billingModel?: BillingModel;
defaultUserCredit?: number;
warningThresholdPercent?: number;
blockOnZeroBalance?: boolean;
notifyOnWarning?: boolean;
notifyEmails?: string[];
billingAddress?: BillingAddress;
}
export interface UsageReport {
period: string;
totalCost: number;
transactionCount: number;
costByProvider: Record<string, number>;
costByModel: Record<string, number>;
costByFeature: Record<string, number>;
}
export interface AccountSummary {
id: string;
mandateId: string;
userId?: string;
accountType: string;
balance: number;
creditLimit?: number;
warningThreshold: number;
enabled: boolean;
}
export interface CreditAddRequest {
userId?: string;
amount: number;
description?: string;
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// USER API FUNCTIONS
// ============================================================================
/**
* Fetch billing balances for all mandates the user belongs to
* Endpoint: GET /api/billing/balance
*/
export async function fetchBalances(
request: ApiRequestFunction
): Promise<BillingBalance[]> {
return await request({
url: '/api/billing/balance',
method: 'get'
});
}
/**
* Fetch billing balance for a specific mandate
* Endpoint: GET /api/billing/balance/{mandateId}
*/
export async function fetchBalanceForMandate(
request: ApiRequestFunction,
mandateId: string
): Promise<BillingBalance> {
return await request({
url: `/api/billing/balance/${mandateId}`,
method: 'get'
});
}
/**
* Fetch transaction history
* Endpoint: GET /api/billing/transactions
*/
export async function fetchTransactions(
request: ApiRequestFunction,
limit: number = 50,
offset: number = 0
): Promise<BillingTransaction[]> {
return await request({
url: '/api/billing/transactions',
method: 'get',
params: { limit, offset }
});
}
/**
* Fetch usage statistics
* Endpoint: GET /api/billing/statistics/{period}
*/
export async function fetchStatistics(
request: ApiRequestFunction,
period: 'day' | 'month' | 'year',
year: number,
month?: number
): Promise<UsageReport> {
const params: Record<string, any> = { year };
if (month !== undefined) {
params.month = month;
}
return await request({
url: `/api/billing/statistics/${period}`,
method: 'get',
params
});
}
/**
* Fetch allowed AICore providers
* Endpoint: GET /api/billing/providers
*/
export async function fetchAllowedProviders(
request: ApiRequestFunction
): Promise<string[]> {
return await request({
url: '/api/billing/providers',
method: 'get'
});
}
// ============================================================================
// ADMIN API FUNCTIONS
// ============================================================================
/**
* Fetch billing settings for a mandate (Admin)
* Endpoint: GET /api/billing/admin/settings/{mandateId}
*/
export async function fetchSettingsAdmin(
request: ApiRequestFunction,
mandateId: string
): Promise<BillingSettings> {
return await request({
url: `/api/billing/admin/settings/${mandateId}`,
method: 'get'
});
}
/**
* Create or update billing settings (Admin)
* Endpoint: POST /api/billing/admin/settings/{mandateId}
*/
export async function updateSettingsAdmin(
request: ApiRequestFunction,
mandateId: string,
settings: BillingSettingsUpdate
): Promise<BillingSettings> {
return await request({
url: `/api/billing/admin/settings/${mandateId}`,
method: 'post',
data: settings
});
}
/**
* Add credit to an account (Admin)
* Endpoint: POST /api/billing/admin/credit/{mandateId}
*/
export async function addCreditAdmin(
request: ApiRequestFunction,
mandateId: string,
creditRequest: CreditAddRequest
): Promise<BillingTransaction> {
return await request({
url: `/api/billing/admin/credit/${mandateId}`,
method: 'post',
data: creditRequest
});
}
/**
* Fetch all accounts for a mandate (Admin)
* Endpoint: GET /api/billing/admin/accounts/{mandateId}
*/
export async function fetchAccountsAdmin(
request: ApiRequestFunction,
mandateId: string
): Promise<AccountSummary[]> {
return await request({
url: `/api/billing/admin/accounts/${mandateId}`,
method: 'get'
});
}
/**
* Fetch all transactions for a mandate (Admin)
* Endpoint: GET /api/billing/admin/transactions/{mandateId}
*/
export async function fetchTransactionsAdmin(
request: ApiRequestFunction,
mandateId: string,
limit: number = 100
): Promise<BillingTransaction[]> {
return await request({
url: `/api/billing/admin/transactions/${mandateId}`,
method: 'get',
params: { limit }
});
}
/**
* User summary for billing admin
*/
export interface MandateUserSummary {
id: string;
username?: string;
email?: string;
firstName?: string;
lastName?: string;
displayName?: string;
}
/**
* Fetch all users for a mandate (Admin)
* Endpoint: GET /api/billing/admin/users/{mandateId}
*/
export async function fetchUsersForMandateAdmin(
request: ApiRequestFunction,
mandateId: string
): Promise<MandateUserSummary[]> {
return await request({
url: `/api/billing/admin/users/${mandateId}`,
method: 'get'
});
}
// ============================================================================
// MANDATE VIEW TYPES & API FUNCTIONS
// ============================================================================
export interface MandateBalance {
mandateId: string;
mandateName: string;
billingModel: BillingModel;
totalBalance: number;
userCount: number;
defaultUserCredit: number;
warningThresholdPercent: number;
blockOnZeroBalance: boolean;
}
/**
* Fetch mandate-level balances (SysAdmin only)
* Endpoint: GET /api/billing/view/mandates/balances
*/
export async function fetchMandateViewBalances(
request: ApiRequestFunction
): Promise<MandateBalance[]> {
return await request({
url: '/api/billing/view/mandates/balances',
method: 'get'
});
}
/**
* Fetch mandate-level transactions (SysAdmin only)
* Endpoint: GET /api/billing/view/mandates/transactions
*/
export async function fetchMandateViewTransactions(
request: ApiRequestFunction,
limit: number = 100
): Promise<BillingTransaction[]> {
return await request({
url: '/api/billing/view/mandates/transactions',
method: 'get',
params: { limit }
});
}
// ============================================================================
// USER VIEW TYPES & API FUNCTIONS
// ============================================================================
export interface UserBalance {
accountId: string;
mandateId: string;
mandateName: string;
userId: string;
userName: string;
balance: number;
warningThreshold: number;
isWarning: boolean;
enabled: boolean;
}
export interface UserTransaction extends BillingTransaction {
userId?: string;
userName?: string;
}
/**
* Fetch user-level balances (RBAC-based)
* Endpoint: GET /api/billing/view/users/balances
*/
export async function fetchUserViewBalances(
request: ApiRequestFunction
): Promise<UserBalance[]> {
return await request({
url: '/api/billing/view/users/balances',
method: 'get'
});
}
/**
* Fetch user-level transactions (RBAC-based)
* Endpoint: GET /api/billing/view/users/transactions
*/
export async function fetchUserViewTransactions(
request: ApiRequestFunction,
limit: number = 100
): Promise<UserTransaction[]> {
return await request({
url: '/api/billing/view/users/transactions',
method: 'get',
params: { limit }
});
}

View file

@ -47,6 +47,7 @@ export interface StartWorkflowRequest {
listFileId?: string[]; // Array of file ID strings (files must be uploaded first via /api/files/upload)
userLanguage?: string; // Optional, defaults to "en"
metadata?: Record<string, any>;
allowedProviders?: string[]; // Optional: Restrict AI calls to these providers (empty = all RBAC-permitted)
}
export interface StartWorkflowResponse extends Workflow {
@ -231,17 +232,18 @@ export async function fetchWorkflowLogs(
/**
* Fetch unified chat data (messages, logs, stats, documents)
* Endpoint: GET /api/chat/playground/{workflowId}/chatData
* Endpoint: GET /api/chatplayground/{instanceId}/{workflowId}/chatData
* Query params: afterTimestamp (optional) - fetch only data created after this time
*/
export async function fetchChatData(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
afterTimestamp?: number
): Promise<ChatDataResponse> {
const params = afterTimestamp ? { afterTimestamp: afterTimestamp.toString() } : undefined;
const requestConfig = {
url: `/api/chat/playground/${workflowId}/chatData`,
url: `/api/chatplayground/${instanceId}/${workflowId}/chatData`,
method: 'get' as const,
params
};
@ -314,11 +316,12 @@ export async function fetchChatData(
/**
* Start a new workflow or continue an existing one
* Endpoint: POST /api/chat/playground/start
* Query params: workflowId (optional), workflowMode (default: "Actionplan")
* Endpoint: POST /api/chatplayground/{instanceId}/start
* Query params: workflowId (optional), workflowMode (default: "Dynamic")
*/
export async function startWorkflowApi(
request: ApiRequestFunction,
instanceId: string,
workflowData: StartWorkflowRequest,
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
): Promise<StartWorkflowResponse> {
@ -341,11 +344,12 @@ export async function startWorkflowApi(
prompt: workflowData.prompt,
...(workflowData.listFileId && workflowData.listFileId.length > 0 && { listFileId: workflowData.listFileId }),
...(workflowData.userLanguage && { userLanguage: workflowData.userLanguage }),
...(workflowData.metadata && { metadata: workflowData.metadata })
...(workflowData.metadata && { metadata: workflowData.metadata }),
...(workflowData.allowedProviders && workflowData.allowedProviders.length > 0 && { allowedProviders: workflowData.allowedProviders })
};
const requestConfig = {
url: '/api/chat/playground/start',
url: `/api/chatplayground/${instanceId}/start`,
method: 'post' as const,
data: requestBody,
params: params // Always include workflowMode
@ -368,14 +372,15 @@ export async function startWorkflowApi(
/**
* Stop a running workflow
* Endpoint: POST /api/chat/playground/{workflowId}/stop
* Endpoint: POST /api/chatplayground/{instanceId}/{workflowId}/stop
*/
export async function stopWorkflowApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<void> {
await request({
url: `/api/chat/playground/${workflowId}/stop`,
url: `/api/chatplayground/${instanceId}/${workflowId}/stop`,
method: 'post'
});
}

View file

@ -13,6 +13,7 @@
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { FaTimes, FaSave, FaChevronLeft, FaChevronRight, FaRocket, FaFileAlt, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa';
import { ActionsPanel } from '../ActionsPanel';
import { ProviderMultiSelect } from '../ProviderSelector';
import { useToast } from '../../contexts/ToastContext';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useWorkflowActions } from '../../hooks/useAutomations';
@ -368,6 +369,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
const [label, setLabel] = useState('');
const [schedule, setSchedule] = useState('0 22 * * *');
const [active, setActive] = useState(false);
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
// Template multilingual fields
const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' });
@ -530,6 +532,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
setLabel(def.label || '');
setSchedule(def.schedule || '0 22 * * *');
setActive(def.active ?? false);
setAllowedProviders(def.allowedProviders || []);
}
// Extract template JSON
@ -684,7 +687,8 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
schedule,
active,
template: templateJson,
placeholders
placeholders,
allowedProviders
};
}
@ -700,7 +704,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
} finally {
setIsSaving(false);
}
}, [label, schedule, active, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]);
}, [label, schedule, active, allowedProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]);
// Computed values
const editorTitle = title || (mode === 'template'
@ -831,6 +835,18 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
Automatisierung ist aktiv und wird planmässig ausgeführt
</p>
</div>
{/* Allowed AI Providers */}
<div className={styles.formGroup}>
<ProviderMultiSelect
selectedProviders={allowedProviders}
onChange={setAllowedProviders}
label="Erlaubte AI-Provider"
/>
<p className={styles.formHint}>
Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt.
</p>
</div>
</div>
)}

View file

@ -37,6 +37,43 @@
white-space: nowrap;
}
/* CSV Export Button */
.csvExportButton {
display: inline-flex;
align-items: center;
gap: 5px;
height: 40px;
padding: 0 14px;
border: 1px solid var(--color-primary);
border-radius: 25px;
background: var(--color-bg);
color: var(--color-text);
font-size: 12px;
font-family: var(--font-family);
font-weight: 400;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
flex-shrink: 0;
}
.csvExportButton:hover:not(:disabled) {
background: var(--color-secondary);
color: var(--color-bg);
border-color: var(--color-secondary);
}
.csvExportButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.csvExportIcon {
font-size: 13px;
display: flex;
align-items: center;
}
.refreshButton {
display: flex;
align-items: center;

View file

@ -3,7 +3,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorControls.module.css';
import { Button } from '../../UiComponents/Button';
import { IoIosRefresh } from "react-icons/io";
import { FaTrash } from "react-icons/fa";
import { FaTrash, FaDownload } from "react-icons/fa";
import type { AttributeType } from '../../../utils/attributeTypeMapper';
// Generic field/column config interface
@ -62,6 +62,9 @@ export interface FormGeneratorControlsProps {
onPageSizeChange?: (pageSize: number) => void;
supportsBackendPagination?: boolean;
hookData?: any;
// CSV Export
onCsvExport?: () => void;
csvExporting?: boolean;
}
export function FormGeneratorControls({
@ -87,7 +90,9 @@ export function FormGeneratorControls({
onPageChange,
onPageSizeChange,
supportsBackendPagination = false,
hookData: _hookData // Reserved for future use
hookData: _hookData, // Reserved for future use
onCsvExport,
csvExporting = false
}: FormGeneratorControlsProps) {
void _hookData; // Suppress unused variable warning
const { t } = useLanguage();
@ -147,6 +152,17 @@ export function FormGeneratorControls({
{activeFiltersCount} {t('formgen.filter.active', 'filter(s)')}
</span>
)}
{onCsvExport && (
<button
onClick={onCsvExport}
className={styles.csvExportButton}
title={t('formgen.export.csv', 'Export all data as CSV')}
disabled={csvExporting}
>
<span className={styles.csvExportIcon}><FaDownload /></span>
{csvExporting ? t('formgen.export.exporting', 'Exporting...') : 'CSV'}
</button>
)}
{onRefresh && (
<button
onClick={onRefresh}

View file

@ -0,0 +1,409 @@
/* =============================================================================
FormGeneratorReport - Generic Reporting Component
============================================================================= */
/* --- Container --- */
.reportContainer {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.reportHeader {
margin-bottom: 0.5rem;
}
.reportTitle {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0 0 0.25rem 0;
}
.reportSubtitle {
font-size: 0.875rem;
color: var(--text-secondary, #888);
margin: 0;
}
/* --- Toolbar (Filters + Period) --- */
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.75rem 1rem;
background: var(--surface-color, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 10px;
}
.toolbarGroup {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toolbarLabel {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #888);
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.toolbarSeparator {
width: 1px;
height: 24px;
background: var(--border-color, #333);
margin: 0 0.25rem;
}
.select {
padding: 0.375rem 0.625rem;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
background: var(--bg-secondary, #2a2a2a);
color: var(--text-primary, #e0e0e0);
font-size: 0.8125rem;
cursor: pointer;
min-width: 80px;
}
.select:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.dateInput {
padding: 0.375rem 0.625rem;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
background: var(--bg-secondary, #2a2a2a);
color: var(--text-primary, #e0e0e0);
font-size: 0.8125rem;
cursor: pointer;
}
.dateInput:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.textInput {
padding: 0.375rem 0.625rem;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
background: var(--bg-secondary, #2a2a2a);
color: var(--text-primary, #e0e0e0);
font-size: 0.8125rem;
min-width: 120px;
}
.textInput:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
/* --- Sections Grid --- */
.sectionsGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.sectionFull {
grid-column: 1 / -1;
}
.sectionHalf {
grid-column: span 1;
}
/* --- Section Card --- */
.sectionCard {
background: var(--surface-color, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 1.25rem;
display: flex;
flex-direction: column;
}
.sectionTitle {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary, #888);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 0.75rem 0;
position: relative;
z-index: 1;
flex-shrink: 0;
}
.sectionDescription {
font-size: 0.8125rem;
color: var(--text-tertiary, #666);
margin: -0.5rem 0 0.75rem 0;
}
/* --- KPI Grid --- */
.kpiGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.kpiCard {
background: var(--surface-color, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.kpiLabel {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #888);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.kpiValue {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #e0e0e0);
}
.kpiSubtitle {
font-size: 0.75rem;
color: var(--text-tertiary, #666);
}
/* --- Charts (recharts wrappers) --- */
.chartWrapper {
width: 100%;
height: 280px;
min-height: 280px;
min-width: 0;
}
.chartWrapperSmall {
width: 100%;
height: 250px;
min-height: 250px;
min-width: 0;
}
/* --- Horizontal Bar Chart --- */
.horizontalBarList {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.horizontalBarRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.horizontalBarLabel {
width: 120px;
font-size: 0.8125rem;
color: var(--text-primary, #e0e0e0);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
flex-shrink: 0;
}
.horizontalBarTrack {
flex: 1;
height: 22px;
background: var(--bg-secondary, #2a2a2a);
border-radius: 4px;
overflow: hidden;
}
.horizontalBarFill {
height: 100%;
background: var(--primary-color, #f25843);
border-radius: 4px;
transition: width 0.3s ease;
min-width: 4px;
}
.horizontalBarValue {
width: 90px;
text-align: right;
font-size: 0.8125rem;
color: var(--text-secondary, #888);
font-family: monospace;
flex-shrink: 0;
}
/* --- Table --- */
.reportTable {
width: 100%;
border-collapse: collapse;
}
.reportTable th {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #888);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.625rem 0.75rem;
text-align: left;
border-bottom: 2px solid var(--border-color, #333);
}
.reportTable td {
font-size: 0.8125rem;
color: var(--text-primary, #e0e0e0);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color, #333);
}
.reportTable tr:last-child td {
border-bottom: none;
}
.reportTable tr:hover td {
background: var(--bg-secondary, #2a2a2a);
}
.alignRight {
text-align: right;
}
.alignCenter {
text-align: center;
}
.monoValue {
font-family: monospace;
}
.showMoreRow {
text-align: center;
padding: 0.5rem;
}
.showMoreButton {
background: none;
border: none;
color: var(--primary-color, #f25843);
font-size: 0.8125rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.showMoreButton:hover {
text-decoration: underline;
}
/* --- Loading / No Data --- */
.loadingContainer {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary, #888);
font-size: 0.875rem;
}
.noData {
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
color: var(--text-tertiary, #666);
font-size: 0.8125rem;
font-style: italic;
}
/* --- Recharts Custom Tooltip --- */
.customTooltip {
background: var(--surface-color, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
padding: 0.75rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.tooltipLabel {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin-bottom: 0.25rem;
}
.tooltipValue {
font-size: 0.8125rem;
color: var(--text-secondary, #888);
}
.tooltipValue span {
color: var(--text-primary, #e0e0e0);
font-weight: 600;
}
/* --- Responsive --- */
@media (max-width: 768px) {
.sectionsGrid {
grid-template-columns: 1fr;
}
.sectionHalf {
grid-column: span 1;
}
.toolbar {
flex-direction: column;
align-items: flex-start;
}
.toolbarSeparator {
width: 100%;
height: 1px;
margin: 0.25rem 0;
}
.kpiGrid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.horizontalBarLabel {
width: 80px;
font-size: 0.75rem;
}
.horizontalBarValue {
width: 70px;
font-size: 0.75rem;
}
.chartWrapper {
height: 220px;
}
}

View file

@ -0,0 +1,773 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
LineChart, Line, AreaChart, Area,
PieChart, Pie, Cell, Legend
} from 'recharts';
import styles from './FormGeneratorReport.module.css';
import type {
FormGeneratorReportProps,
ReportSection,
ReportSectionKpi,
ReportSectionBarChart,
ReportSectionHorizontalBar,
ReportSectionLineChart,
ReportSectionPieChart,
ReportSectionTable,
ReportSectionAreaChart,
ReportFilterState,
ReportPeriod,
ReportFilterConfig,
ReportTableColumn
} from './FormGeneratorReportTypes';
// =============================================================================
// CHART COLORS
// =============================================================================
const CHART_COLORS = [
'var(--primary-color, #f25843)',
'#4e79a7',
'#59a14f',
'#f28e2b',
'#b07aa1',
'#76b7b2',
'#e15759',
'#edc948',
'#9c755f',
'#bab0ac'
];
const MONTH_LABELS: Record<string, string> = {
'01': 'Jan', '02': 'Feb', '03': 'Mär', '04': 'Apr',
'05': 'Mai', '06': 'Jun', '07': 'Jul', '08': 'Aug',
'09': 'Sep', '10': 'Okt', '11': 'Nov', '12': 'Dez'
};
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function _defaultFormatCurrency(value: number, currencyCode: string): string {
return `${currencyCode} ${value.toFixed(2)}`;
}
function _formatDateLabel(dateStr: string): string {
const parts = dateStr.split('-');
if (parts.length === 3) {
return `${parseInt(parts[2], 10)}.`;
}
if (parts.length === 2) {
return MONTH_LABELS[parts[1]] || parts[1];
}
return dateStr;
}
// =============================================================================
// CUSTOM TOOLTIP
// =============================================================================
interface CustomTooltipProps {
active?: boolean;
payload?: any[];
label?: string;
formatValue?: (value: number) => string;
}
const _CustomTooltip: React.FC<CustomTooltipProps> = ({ active, payload, label, formatValue }) => {
if (!active || !payload?.length) return null;
const displayLabel = label ? _formatDateLabel(String(label)) : '';
return (
<div className={styles.customTooltip}>
{displayLabel && <div className={styles.tooltipLabel}>{displayLabel}</div>}
{payload.map((entry: any, i: number) => (
<div key={i} className={styles.tooltipValue}>
{entry.name}: <span>{formatValue ? formatValue(entry.value) : entry.value}</span>
</div>
))}
</div>
);
};
// =============================================================================
// SECTION RENDERERS
// =============================================================================
// --- KPI Grid ---
const _renderKpiGrid = (section: ReportSectionKpi): React.ReactNode => {
return (
<div className={styles.kpiGrid}>
{section.items.map((item, i) => (
<div key={i} className={styles.kpiCard} style={item.color ? { borderLeftColor: item.color, borderLeftWidth: 3 } : undefined}>
<span className={styles.kpiLabel}>{item.label}</span>
<span className={styles.kpiValue}>{item.value}</span>
{item.subtitle && <span className={styles.kpiSubtitle}>{item.subtitle}</span>}
</div>
))}
</div>
);
};
// --- Bar Chart (vertical) ---
const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string): React.ReactNode => {
if (!section.data?.length) {
return <div className={styles.noData}>Keine Daten</div>;
}
const chartData = section.data.map(d => ({
name: _formatDateLabel(d.key),
value: d.value,
rawKey: d.key
}));
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
return (
<div className={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
axisLine={{ stroke: 'var(--border-color, #333)' }}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
axisLine={{ stroke: 'var(--border-color, #333)' }}
tickFormatter={(v) => formatter(v)}
width={70}
/>
<Tooltip content={<_CustomTooltip formatValue={formatter} />} />
<Bar
dataKey="value"
fill={section.color || CHART_COLORS[0]}
radius={[4, 4, 0, 0]}
name="Wert"
/>
</BarChart>
</ResponsiveContainer>
</div>
);
};
// --- Horizontal Bar Chart ---
const _renderHorizontalBar = (section: ReportSectionHorizontalBar, currencyCode: string): React.ReactNode => {
if (!section.data?.length) {
return <div className={styles.noData}>Keine Daten</div>;
}
const maxValue = Math.max(...section.data.map(d => d.value), 0.01);
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
return (
<div className={styles.horizontalBarList}>
{section.data.map((item, i) => (
<div key={item.key} className={styles.horizontalBarRow}>
<span className={styles.horizontalBarLabel} title={item.key}>{item.key}</span>
<div className={styles.horizontalBarTrack}>
<div
className={styles.horizontalBarFill}
style={{
width: `${(item.value / maxValue) * 100}%`,
background: item.color || CHART_COLORS[i % CHART_COLORS.length]
}}
/>
</div>
<span className={styles.horizontalBarValue}>{formatter(item.value)}</span>
</div>
))}
</div>
);
};
// --- Line Chart ---
const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string): React.ReactNode => {
if (!section.data?.length) {
return <div className={styles.noData}>Keine Daten</div>;
}
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
return (
<div className={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
axisLine={{ stroke: 'var(--border-color, #333)' }}
tickFormatter={_formatDateLabel}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
axisLine={{ stroke: 'var(--border-color, #333)' }}
tickFormatter={formatter}
width={70}
/>
<Tooltip content={<_CustomTooltip formatValue={formatter} />} />
{section.series.map((s, i) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={s.color || CHART_COLORS[i % CHART_COLORS.length]}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
))}
{section.series.length > 1 && <Legend />}
</LineChart>
</ResponsiveContainer>
</div>
);
};
// --- Area Chart ---
const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string): React.ReactNode => {
if (!section.data?.length) {
return <div className={styles.noData}>Keine Daten</div>;
}
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
return (
<div className={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
axisLine={{ stroke: 'var(--border-color, #333)' }}
tickFormatter={_formatDateLabel}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
axisLine={{ stroke: 'var(--border-color, #333)' }}
tickFormatter={formatter}
width={70}
/>
<Tooltip content={<_CustomTooltip formatValue={formatter} />} />
{section.series.map((s, i) => (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={s.color || CHART_COLORS[i % CHART_COLORS.length]}
fill={s.color || CHART_COLORS[i % CHART_COLORS.length]}
fillOpacity={0.15}
strokeWidth={2}
/>
))}
{section.series.length > 1 && <Legend />}
</AreaChart>
</ResponsiveContainer>
</div>
);
};
// --- Pie Chart ---
const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string): React.ReactNode => {
if (!section.data?.length) {
return <div className={styles.noData}>Keine Daten</div>;
}
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
const total = section.data.reduce((sum, d) => sum + d.value, 0);
const chartData = section.data.map((d, i) => ({
name: d.key,
value: d.value,
color: d.color || CHART_COLORS[i % CHART_COLORS.length]
}));
const _renderLabel = ({ name, percent }: any) => {
if (percent < 0.05) return null;
return `${name} (${(percent * 100).toFixed(0)}%)`;
};
return (
<div className={styles.chartWrapperSmall}>
<ResponsiveContainer width="100%" height="100%">
<PieChart margin={{ top: 20, right: 10, left: 10, bottom: 5 }}>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={section.donut ? '45%' : 0}
outerRadius="65%"
paddingAngle={2}
dataKey="value"
label={_renderLabel}
labelLine={false}
>
{chartData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: number, name: string) => [formatter(value), name]}
/>
<Legend
formatter={(value: string) => {
const item = chartData.find(d => d.name === value);
return item ? `${value} (${((item.value / total) * 100).toFixed(1)}%)` : value;
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
};
// --- Table (proper component because it uses useState) ---
interface ReportTableSectionProps {
section: ReportSectionTable;
currencyCode: string;
}
const _ReportTableSection: React.FC<ReportTableSectionProps> = ({ section, currencyCode }) => {
const [showAll, setShowAll] = useState(false);
if (!section.rows?.length) {
return <div className={styles.noData}>Keine Daten</div>;
}
const maxRows = section.maxRows || 0;
const displayRows = maxRows > 0 && !showAll
? section.rows.slice(0, maxRows)
: section.rows;
const hasMore = maxRows > 0 && section.rows.length > maxRows;
const _formatCellValue = (col: ReportTableColumn, value: any, row: Record<string, any>): string => {
if (col.formatValue) return col.formatValue(value, row);
if (value == null) return '—';
switch (col.format) {
case 'currency':
return _defaultFormatCurrency(Number(value), currencyCode);
case 'number':
return Number(value).toLocaleString('de-CH');
case 'percent':
return `${(Number(value) * 100).toFixed(1)}%`;
case 'date':
return new Date(value).toLocaleDateString('de-CH');
default:
return String(value);
}
};
return (
<>
<table className={styles.reportTable}>
<thead>
<tr>
{section.columns.map(col => (
<th
key={col.key}
className={col.align === 'right' ? styles.alignRight : col.align === 'center' ? styles.alignCenter : undefined}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{displayRows.map((row, rowIdx) => (
<tr key={rowIdx}>
{section.columns.map(col => (
<td
key={col.key}
className={[
col.align === 'right' ? styles.alignRight : col.align === 'center' ? styles.alignCenter : '',
col.format === 'currency' || col.format === 'number' ? styles.monoValue : ''
].filter(Boolean).join(' ')}
>
{_formatCellValue(col, row[col.key], row)}
</td>
))}
</tr>
))}
</tbody>
</table>
{hasMore && !showAll && (
<div className={styles.showMoreRow}>
<button className={styles.showMoreButton} onClick={() => setShowAll(true)}>
Alle {section.rows.length} Einträge anzeigen
</button>
</div>
)}
</>
);
};
// =============================================================================
// SECTION WRAPPER
// =============================================================================
interface SectionWrapperProps {
section: ReportSection;
currencyCode: string;
}
const _SectionWrapper: React.FC<SectionWrapperProps> = ({ section, currencyCode }) => {
const spanClass = section.type === 'kpiGrid' || section.span === 'full'
? styles.sectionFull
: section.span === 'half'
? styles.sectionHalf
: styles.sectionFull;
// KPI grid renders without card wrapper
if (section.type === 'kpiGrid') {
return (
<div className={spanClass}>
{section.title && <h3 className={styles.sectionTitle}>{section.title}</h3>}
{_renderKpiGrid(section)}
</div>
);
}
const _renderContent = (): React.ReactNode => {
switch (section.type) {
case 'barChart':
return _renderBarChart(section, currencyCode);
case 'horizontalBar':
return _renderHorizontalBar(section, currencyCode);
case 'lineChart':
return _renderLineChart(section, currencyCode);
case 'areaChart':
return _renderAreaChart(section, currencyCode);
case 'pieChart':
return _renderPieChart(section, currencyCode);
case 'table':
return <_ReportTableSection section={section} currencyCode={currencyCode} />;
default:
return <div className={styles.noData}>Unbekannter Sektionstyp</div>;
}
};
return (
<div className={`${spanClass} ${styles.sectionCard}`}>
{section.title && <h3 className={styles.sectionTitle}>{section.title}</h3>}
{section.description && <p className={styles.sectionDescription}>{section.description}</p>}
{_renderContent()}
</div>
);
};
// =============================================================================
// TOOLBAR (FILTERS + PERIOD SELECTOR)
// =============================================================================
interface ToolbarProps {
periodSelector?: FormGeneratorReportProps['periodSelector'];
dateRangeSelector?: FormGeneratorReportProps['dateRangeSelector'];
filters?: ReportFilterConfig[];
filterState: ReportFilterState;
onFilterStateChange: (state: ReportFilterState) => void;
}
const _Toolbar: React.FC<ToolbarProps> = ({
periodSelector, dateRangeSelector, filters, filterState, onFilterStateChange
}) => {
const hasPeriod = !!periodSelector;
const hasDateRange = dateRangeSelector?.enabled;
const hasFilters = filters && filters.length > 0;
if (!hasPeriod && !hasDateRange && !hasFilters) return null;
const _handlePeriodChange = (period: ReportPeriod) => {
onFilterStateChange({ ...filterState, period });
};
const _handleYearChange = (year: number) => {
onFilterStateChange({ ...filterState, year });
};
const _handleMonthChange = (month: number) => {
onFilterStateChange({ ...filterState, month });
};
const _handleFilterChange = (key: string, value: string | string[]) => {
onFilterStateChange({
...filterState,
filters: { ...filterState.filters, [key]: value }
});
};
const _handleDateRangeChange = (field: 'from' | 'to', dateStr: string) => {
const dateRange = filterState.dateRange || { from: new Date(), to: new Date() };
onFilterStateChange({
...filterState,
dateRange: { ...dateRange, [field]: new Date(dateStr) }
});
};
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i);
const monthOptions = [
{ value: 1, label: 'Januar' }, { value: 2, label: 'Februar' },
{ value: 3, label: 'März' }, { value: 4, label: 'April' },
{ value: 5, label: 'Mai' }, { value: 6, label: 'Juni' },
{ value: 7, label: 'Juli' }, { value: 8, label: 'August' },
{ value: 9, label: 'September' }, { value: 10, label: 'Oktober' },
{ value: 11, label: 'November' }, { value: 12, label: 'Dezember' }
];
const _renderPeriodLabel = (p: ReportPeriod): string => {
const labels: Record<ReportPeriod, string> = {
day: 'Tagesansicht',
week: 'Wochenansicht',
month: 'Monatsansicht',
quarter: 'Quartalsansicht',
year: 'Jahresansicht'
};
return labels[p] || p;
};
return (
<div className={styles.toolbar}>
{/* Period Selector */}
{hasPeriod && (
<div className={styles.toolbarGroup}>
<span className={styles.toolbarLabel}>Zeitraum</span>
<select
className={styles.select}
value={filterState.period || periodSelector!.defaultPeriod}
onChange={(e) => _handlePeriodChange(e.target.value as ReportPeriod)}
>
{periodSelector!.periods.map(p => (
<option key={p} value={p}>{_renderPeriodLabel(p)}</option>
))}
</select>
{periodSelector!.showYear !== false && (
<select
className={styles.select}
value={filterState.year || currentYear}
onChange={(e) => _handleYearChange(Number(e.target.value))}
>
{yearOptions.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
)}
{periodSelector!.showMonth !== false && filterState.period === 'day' && (
<select
className={styles.select}
value={filterState.month || new Date().getMonth() + 1}
onChange={(e) => _handleMonthChange(Number(e.target.value))}
>
{monthOptions.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
)}
</div>
)}
{/* Separator */}
{hasPeriod && (hasDateRange || hasFilters) && (
<div className={styles.toolbarSeparator} />
)}
{/* Date Range */}
{hasDateRange && (
<div className={styles.toolbarGroup}>
<span className={styles.toolbarLabel}>Von</span>
<input
type="date"
className={styles.dateInput}
value={filterState.dateRange?.from?.toISOString().split('T')[0] || ''}
onChange={(e) => _handleDateRangeChange('from', e.target.value)}
/>
<span className={styles.toolbarLabel}>Bis</span>
<input
type="date"
className={styles.dateInput}
value={filterState.dateRange?.to?.toISOString().split('T')[0] || ''}
onChange={(e) => _handleDateRangeChange('to', e.target.value)}
/>
</div>
)}
{/* Separator */}
{hasDateRange && hasFilters && (
<div className={styles.toolbarSeparator} />
)}
{/* Custom Filters */}
{hasFilters && filters!.map(filter => (
<div key={filter.key} className={styles.toolbarGroup}>
<span className={styles.toolbarLabel}>{filter.label}</span>
{filter.type === 'text' ? (
<input
type="text"
className={styles.textInput}
placeholder={filter.placeholder || ''}
value={(filterState.filters[filter.key] as string) || ''}
onChange={(e) => _handleFilterChange(filter.key, e.target.value)}
/>
) : (
<select
className={styles.select}
value={(filterState.filters[filter.key] as string) || ''}
onChange={(e) => _handleFilterChange(filter.key, e.target.value)}
>
<option value="">{filter.placeholder || 'Alle'}</option>
{filter.options?.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
)}
</div>
))}
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const FormGeneratorReport: React.FC<FormGeneratorReportProps> = ({
title,
subtitle,
sections,
loading = false,
noDataMessage = 'Keine Daten verfügbar',
periodSelector,
dateRangeSelector,
filters,
onFilterChange,
currencyCode = 'CHF',
className
}) => {
// Build initial filter state
const initialFilterState = useMemo((): ReportFilterState => {
const state: ReportFilterState = { filters: {} };
if (periodSelector) {
state.period = periodSelector.defaultPeriod;
state.year = periodSelector.defaultYear || new Date().getFullYear();
state.month = periodSelector.defaultMonth || new Date().getMonth() + 1;
}
if (dateRangeSelector?.enabled) {
state.dateRange = {
from: dateRangeSelector.defaultFrom || new Date(new Date().getFullYear(), 0, 1),
to: dateRangeSelector.defaultTo || new Date()
};
}
if (filters) {
for (const f of filters) {
if (f.defaultValue !== undefined) {
state.filters[f.key] = f.defaultValue;
}
}
}
return state;
}, []); // intentionally empty - only compute once
const [filterState, setFilterState] = useState<ReportFilterState>(initialFilterState);
// Notify parent when filters change
const _handleFilterStateChange = useCallback((newState: ReportFilterState) => {
setFilterState(newState);
onFilterChange?.(newState);
}, [onFilterChange]);
// Initial load: notify parent of default filter state
useEffect(() => {
onFilterChange?.(initialFilterState);
}, []); // intentionally once on mount
// Loading state
if (loading) {
return (
<div className={`${styles.reportContainer} ${className || ''}`}>
{title && (
<div className={styles.reportHeader}>
<h2 className={styles.reportTitle}>{title}</h2>
{subtitle && <p className={styles.reportSubtitle}>{subtitle}</p>}
</div>
)}
<_Toolbar
periodSelector={periodSelector}
dateRangeSelector={dateRangeSelector}
filters={filters}
filterState={filterState}
onFilterStateChange={_handleFilterStateChange}
/>
<div className={styles.loadingContainer}>Lade Daten...</div>
</div>
);
}
// No sections
if (!sections || sections.length === 0) {
return (
<div className={`${styles.reportContainer} ${className || ''}`}>
{title && (
<div className={styles.reportHeader}>
<h2 className={styles.reportTitle}>{title}</h2>
{subtitle && <p className={styles.reportSubtitle}>{subtitle}</p>}
</div>
)}
<_Toolbar
periodSelector={periodSelector}
dateRangeSelector={dateRangeSelector}
filters={filters}
filterState={filterState}
onFilterStateChange={_handleFilterStateChange}
/>
<div className={styles.noData}>{noDataMessage}</div>
</div>
);
}
return (
<div className={`${styles.reportContainer} ${className || ''}`}>
{title && (
<div className={styles.reportHeader}>
<h2 className={styles.reportTitle}>{title}</h2>
{subtitle && <p className={styles.reportSubtitle}>{subtitle}</p>}
</div>
)}
<_Toolbar
periodSelector={periodSelector}
dateRangeSelector={dateRangeSelector}
filters={filters}
filterState={filterState}
onFilterStateChange={_handleFilterStateChange}
/>
<div className={styles.sectionsGrid}>
{sections.map((section, i) => (
<_SectionWrapper key={i} section={section} currencyCode={currencyCode} />
))}
</div>
</div>
);
};
export default FormGeneratorReport;

View file

@ -0,0 +1,255 @@
// =============================================================================
// FormGeneratorReport - Types
// Generic reporting component with charts, KPIs, tables, and filters
// =============================================================================
// =============================================================================
// FILTER TYPES
// =============================================================================
/** Period granularity for time-based reports */
export type ReportPeriod = 'day' | 'week' | 'month' | 'quarter' | 'year';
/** Date range with from/to */
export interface ReportDateRange {
from: Date;
to: Date;
}
/** Filter option for select/multiselect filters */
export interface ReportFilterOption {
value: string;
label: string;
}
/** A single filter definition */
export interface ReportFilterConfig {
/** Unique key for this filter */
key: string;
/** Display label */
label: string;
/** Filter type */
type: 'select' | 'multiselect' | 'text';
/** Available options (for select/multiselect) */
options?: ReportFilterOption[];
/** Default value */
defaultValue?: string | string[];
/** Placeholder text */
placeholder?: string;
}
/** Period selector configuration */
export interface ReportPeriodSelectorConfig {
/** Available periods */
periods: ReportPeriod[];
/** Default period */
defaultPeriod: ReportPeriod;
/** Whether to show year selector */
showYear?: boolean;
/** Whether to show month selector (when period is 'day') */
showMonth?: boolean;
/** Default year */
defaultYear?: number;
/** Default month (1-12) */
defaultMonth?: number;
}
/** Date range selector configuration */
export interface ReportDateRangeSelectorConfig {
/** Whether the date range selector is enabled */
enabled: boolean;
/** Default from date */
defaultFrom?: Date;
/** Default to date */
defaultTo?: Date;
}
/** Combined filter state passed to the data callback */
export interface ReportFilterState {
/** Selected period */
period?: ReportPeriod;
/** Selected year */
year?: number;
/** Selected month (1-12) */
month?: number;
/** Date range */
dateRange?: ReportDateRange;
/** Custom filter values: key -> value(s) */
filters: Record<string, string | string[]>;
}
// =============================================================================
// SECTION TYPES
// =============================================================================
/** KPI item for kpiGrid section */
export interface ReportKpiItem {
label: string;
value: string | number;
subtitle?: string;
/** Optional color (CSS variable or hex) */
color?: string;
}
/** Column definition for table sections */
export interface ReportTableColumn {
key: string;
label: string;
/** How to format the value */
format?: 'text' | 'number' | 'currency' | 'percent' | 'date';
/** Text alignment */
align?: 'left' | 'center' | 'right';
/** Custom formatter function */
formatValue?: (value: any, row: Record<string, any>) => string;
}
/** Data point for chart sections */
export interface ReportChartDataPoint {
/** Key/label for the data point (x-axis or category) */
key: string;
/** Numeric value */
value: number;
/** Optional secondary value */
value2?: number;
/** Optional color override */
color?: string;
}
/** Time series data point */
export interface ReportTimeSeriesPoint {
/** Date string (ISO format: "2026-02-08" or "2026-02") */
date: string;
/** Numeric values, keyed by series name */
[seriesKey: string]: string | number;
}
/** Series definition for multi-series charts */
export interface ReportChartSeries {
key: string;
label: string;
color?: string;
}
// =============================================================================
// SECTION DEFINITIONS
// =============================================================================
interface ReportSectionBase {
/** Optional section title */
title?: string;
/** Optional description text */
description?: string;
/** Grid span: 'full' takes full width, 'half' takes 50% */
span?: 'full' | 'half';
}
/** KPI grid: display metric cards */
export interface ReportSectionKpi extends ReportSectionBase {
type: 'kpiGrid';
items: ReportKpiItem[];
}
/** Vertical bar chart */
export interface ReportSectionBarChart extends ReportSectionBase {
type: 'barChart';
data: ReportChartDataPoint[];
/** Value format for tooltips/labels */
formatValue?: (value: number) => string;
/** Bar color */
color?: string;
}
/** Horizontal bar chart (for comparisons/rankings) */
export interface ReportSectionHorizontalBar extends ReportSectionBase {
type: 'horizontalBar';
data: ReportChartDataPoint[];
/** Value format for tooltips/labels */
formatValue?: (value: number) => string;
}
/** Line chart (trends over time) */
export interface ReportSectionLineChart extends ReportSectionBase {
type: 'lineChart';
data: ReportTimeSeriesPoint[];
series: ReportChartSeries[];
/** Value format for tooltips/labels */
formatValue?: (value: number) => string;
}
/** Pie/donut chart (distribution) */
export interface ReportSectionPieChart extends ReportSectionBase {
type: 'pieChart';
data: ReportChartDataPoint[];
/** Show as donut (hollow center) */
donut?: boolean;
/** Value format for tooltips/labels */
formatValue?: (value: number) => string;
}
/** Simple data table */
export interface ReportSectionTable extends ReportSectionBase {
type: 'table';
columns: ReportTableColumn[];
rows: Record<string, any>[];
/** Maximum rows to display (default: all) */
maxRows?: number;
}
/** Area chart (filled line chart) */
export interface ReportSectionAreaChart extends ReportSectionBase {
type: 'areaChart';
data: ReportTimeSeriesPoint[];
series: ReportChartSeries[];
/** Value format for tooltips/labels */
formatValue?: (value: number) => string;
}
/** Union of all section types */
export type ReportSection =
| ReportSectionKpi
| ReportSectionBarChart
| ReportSectionHorizontalBar
| ReportSectionLineChart
| ReportSectionPieChart
| ReportSectionTable
| ReportSectionAreaChart;
// =============================================================================
// MAIN COMPONENT PROPS
// =============================================================================
export interface FormGeneratorReportProps {
/** Report title (optional) */
title?: string;
/** Report subtitle/description (optional) */
subtitle?: string;
/** Report sections to render */
sections: ReportSection[];
/** Loading state */
loading?: boolean;
/** No data message */
noDataMessage?: string;
// --- Filter Configuration ---
/** Period selector config */
periodSelector?: ReportPeriodSelectorConfig;
/** Date range selector config */
dateRangeSelector?: ReportDateRangeSelectorConfig;
/** Custom filter definitions */
filters?: ReportFilterConfig[];
/** Called when any filter changes. Parent should reload data and update sections. */
onFilterChange?: (filterState: ReportFilterState) => void;
/** Currency code for formatting (default: 'CHF') */
currencyCode?: string;
/** Custom CSS class */
className?: string;
}

View file

@ -0,0 +1,24 @@
export { FormGeneratorReport, default } from './FormGeneratorReport';
export type {
FormGeneratorReportProps,
ReportSection,
ReportSectionKpi,
ReportSectionBarChart,
ReportSectionHorizontalBar,
ReportSectionLineChart,
ReportSectionPieChart,
ReportSectionTable,
ReportSectionAreaChart,
ReportFilterState,
ReportFilterConfig,
ReportFilterOption,
ReportPeriod,
ReportPeriodSelectorConfig,
ReportDateRangeSelectorConfig,
ReportDateRange,
ReportKpiItem,
ReportTableColumn,
ReportChartDataPoint,
ReportTimeSeriesPoint,
ReportChartSeries
} from './FormGeneratorReportTypes';

View file

@ -7,7 +7,11 @@
/* Fill available space and constrain height */
min-height: 0;
flex: 1;
/* No overflow - children handle their own scrolling */
/* Prevent overflow - constrain to parent height */
overflow: hidden;
/* Ensure container respects parent's height */
height: 100%;
max-height: 100%;
}
.title {
@ -18,18 +22,46 @@
margin-bottom: 10px;
}
/* Table Container - scrollable area for table data only */
.tableContainer {
position: relative;
overflow: auto;
/* Table wrapper - contains top scrollbar and table container */
.tableWrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
/* Constrain height to prevent growing beyond parent */
max-height: 100%;
overflow: hidden;
border: 1px solid var(--color-primary);
border-radius: 25px;
background: var(--color-bg);
/* Fill remaining space after controls */
flex: 1;
}
/* Top horizontal scrollbar - syncs with table container */
.topScrollbar {
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
background: var(--color-bg);
border-bottom: 1px solid var(--color-primary);
border-radius: 25px 25px 0 0;
}
/* Inner div that matches table width for proper scrollbar sizing */
.topScrollbarInner {
height: 1px; /* Minimal height - just need width to activate scrollbar */
}
/* Table Container - scrollable area for table data only (vertical only) */
.tableContainer {
position: relative;
overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */
overflow-y: auto;
background: var(--color-bg);
/* Fill remaining space but constrain to available height */
flex: 1 1 0;
min-height: 0;
/* Clip content to border-radius but allow sticky to work */
isolation: isolate;
max-height: 100%;
border-radius: 0 0 25px 25px;
}
/* Empty table styling - no extra space, just header */
@ -39,6 +71,11 @@
max-height: none;
}
/* Hide top scrollbar when table is empty */
.emptyTable .topScrollbar {
display: none;
}
/* Empty state styling */
.emptyState {
display: flex;
@ -73,7 +110,7 @@
/* Use separate borders for sticky header support */
border-collapse: separate;
border-spacing: 0;
font-size: 14px;
font-size: 12px;
background: var(--color-bg);
table-layout: fixed;
word-wrap: break-word;
@ -114,9 +151,10 @@
.th {
background: var(--color-bg);
padding: 10px 16px;
padding: 6px 10px;
text-align: left;
font-weight: 400;
font-size: 12px;
color: var(--color-text);
white-space: normal;
word-wrap: break-word;
@ -126,7 +164,6 @@
overflow: visible;
/* Border separates header from scrolled content */
border-bottom: 2px solid var(--color-primary);
/* Shadow on the row, not individual cells */
}
.th.actionsColumn {
@ -302,9 +339,11 @@
}
.td {
padding: 12px 16px;
padding: 4px 10px;
border-top: 1px solid var(--color-primary);
color: var(--color-text);
font-weight: 400;
font-size: 12px;
vertical-align: middle;
word-wrap: break-word;
overflow-wrap: break-word;
@ -339,7 +378,7 @@
/* Selection Column */
.selectColumn {
text-align: center;
padding: 8px !important;
padding: 4px !important;
background: var(--color-bg);
position: relative;
}
@ -356,9 +395,9 @@ tbody .selectColumn {
.selectColumn input[type="checkbox"] {
cursor: pointer;
transform: scale(1.3);
width: 16px;
height: 16px;
transform: scale(1.1);
width: 14px;
height: 14px;
accent-color: var(--color-secondary);
margin: 0;
padding: 0;
@ -390,7 +429,7 @@ tbody .selectColumn {
.actionsColumn {
white-space: nowrap;
text-align: center;
padding: 8px !important;
padding: 4px !important;
font-weight: 400;
box-sizing: border-box;
background: var(--color-bg);
@ -426,17 +465,17 @@ tbody .actionsColumn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
padding: 4px;
border: none;
border-radius: 50%;
font-size: 12px;
font-size: 11px;
font-family: var(--font-family);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
position: relative;
min-width: 28px;
min-height: 28px;
min-width: 24px;
min-height: 24px;
background: var(--color-secondary);
color: var(--color-bg);
}
@ -446,9 +485,9 @@ tbody .actionsColumn {
}
.actionIcon {
font-size: 16px;
height: 16px;
width: 16px;
font-size: 14px;
height: 14px;
width: 14px;
display: flex;
align-items: center;
justify-content: center;
@ -661,18 +700,20 @@ tbody .actionsColumn {
.th,
.td {
padding: 8px 12px;
font-size: 13px;
padding: 4px 8px;
font-size: 11px;
}
.actionButtons {
flex-direction: column;
gap: 4px;
gap: 2px;
}
.actionButton {
padding: 4px 8px;
font-size: 11px;
padding: 3px;
font-size: 10px;
min-width: 22px;
min-height: 22px;
}
.pagination {
@ -734,7 +775,7 @@ tbody .actionsColumn {
outline: none;
}
/* Custom scrollbar for table container */
/* Custom scrollbar for table container (vertical only) */
.tableContainer::-webkit-scrollbar {
width: 8px;
height: 8px;
@ -754,6 +795,25 @@ tbody .actionsColumn {
background: var(--color-secondary);
}
/* Custom scrollbar for top scrollbar (horizontal only) */
.topScrollbar::-webkit-scrollbar {
height: 8px;
}
.topScrollbar::-webkit-scrollbar-track {
background: var(--color-gray-disabled);
border-radius: 4px;
}
.topScrollbar::-webkit-scrollbar-thumb {
background: var(--color-gray);
border-radius: 4px;
}
.topScrollbar::-webkit-scrollbar-thumb:hover {
background: var(--color-secondary);
}
/* Loading State */
.loadingState {
display: flex;
@ -860,3 +920,4 @@ tbody .actionsColumn {
50% { opacity: 1; }
}

View file

@ -155,6 +155,8 @@ export interface FormGeneratorTableProps<T = any> {
hookData?: any; // Contains all hook data: refetch, operations, loading states, etc.
// Custom empty message when table is empty
emptyMessage?: string;
// API endpoint for CSV export (e.g. "/api/users/"). If provided, the CSV export button is shown.
apiEndpoint?: string;
}
export function FormGeneratorTable<T extends Record<string, any>>({
@ -185,7 +187,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
className = '',
getRowDataAttributes,
hookData,
emptyMessage
emptyMessage,
apiEndpoint
}: FormGeneratorTableProps<T>) {
const { t } = useLanguage();
// Get current language from localStorage or default to 'en'
@ -338,13 +341,23 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const tableRef = useRef<HTMLTableElement>(null);
const tableContainerRef = useRef<HTMLDivElement>(null);
// Refs for top scrollbar synchronization
const topScrollbarRef = useRef<HTMLDivElement>(null);
const topScrollbarInnerRef = useRef<HTMLDivElement>(null);
const isScrollingSyncRef = useRef<boolean>(false); // Prevent scroll sync loops
// Track container width for actions column 20% threshold
const [containerWidth, setContainerWidth] = useState<number>(0);
// Calculate default actions column width and track container width
// Minimum width always fits 4 icons (4 * 26px button + 3 * 2px gap + 8px padding = 122px)
const MIN_ACTIONS_WIDTH_FOR_4_ICONS = 122;
const defaultActionsWidth = useMemo(() => {
return actionButtons.length > 0 ? Math.max(60, actionButtons.length * 32 + 16) : 0;
}, [actionButtons.length]);
if (actionButtons.length === 0 && customActions.length === 0) return 0;
const totalButtons = actionButtons.length + customActions.length;
const calculatedWidth = totalButtons * 26 + (totalButtons - 1) * 2 + 8;
return Math.max(MIN_ACTIONS_WIDTH_FOR_4_ICONS, calculatedWidth);
}, [actionButtons.length, customActions.length]);
// Current actions column width (user-defined or default)
const currentActionsWidth = actionsColumnWidth ?? defaultActionsWidth;
@ -848,7 +861,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const cWidth = tableContainer.clientWidth;
// Calculate actions column width dynamically: ~32px per button + padding
const actionsColWidth = currentActionsWidth;
const selectColumnWidth = selectable ? 50 : 0;
const selectColumnWidth = selectable ? 40 : 0;
const fixedWidth = actionsColWidth + selectColumnWidth;
// Maximum allowed width - simple calculation to prevent overflow
@ -945,9 +958,178 @@ export function FormGeneratorTable<T extends Record<string, any>>({
};
}, []);
// Sync top scrollbar width with table width and handle scroll synchronization
useEffect(() => {
const tableContainer = tableContainerRef.current;
const topScrollbar = topScrollbarRef.current;
const topScrollbarInner = topScrollbarInnerRef.current;
const table = tableRef.current;
if (!tableContainer || !topScrollbar || !topScrollbarInner || !table) return;
// Update top scrollbar inner width to match table width
const updateScrollbarWidth = () => {
const tableWidth = table.scrollWidth;
topScrollbarInner.style.width = `${tableWidth}px`;
};
// Initial width calculation
updateScrollbarWidth();
// Observe table size changes
const resizeObserver = new ResizeObserver(updateScrollbarWidth);
resizeObserver.observe(table);
// Sync scroll positions
const syncTopToContainer = () => {
if (isScrollingSyncRef.current) return;
isScrollingSyncRef.current = true;
tableContainer.scrollLeft = topScrollbar.scrollLeft;
requestAnimationFrame(() => { isScrollingSyncRef.current = false; });
};
const syncContainerToTop = () => {
if (isScrollingSyncRef.current) return;
isScrollingSyncRef.current = true;
topScrollbar.scrollLeft = tableContainer.scrollLeft;
requestAnimationFrame(() => { isScrollingSyncRef.current = false; });
};
topScrollbar.addEventListener('scroll', syncTopToContainer);
tableContainer.addEventListener('scroll', syncContainerToTop);
return () => {
resizeObserver.disconnect();
topScrollbar.removeEventListener('scroll', syncTopToContainer);
tableContainer.removeEventListener('scroll', syncContainerToTop);
};
}, [displayData, detectedColumns, columnWidths]); // Re-run when data or columns change
// Track which cells are currently being updated (for loading state)
const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set());
// CSV Export state
const [csvExporting, setCsvExporting] = useState(false);
// CSV Export: fetch ALL data via direct API call (no state update, no table flicker)
// Fetches in batches of 1000 (backend max pageSize) until all data is loaded.
const handleCsvExport = useCallback(async () => {
if (csvExporting || detectedColumns.length === 0 || !apiEndpoint) return;
setCsvExporting(true);
try {
const batchSize = 1000; // Backend max pageSize
let allData: T[] = [];
let currentPage = 1;
let hasMore = true;
while (hasMore) {
const response = await api.get(apiEndpoint, {
params: { pagination: JSON.stringify({ page: currentPage, pageSize: batchSize }) }
});
let batchItems: T[];
if (response.data && typeof response.data === 'object' && 'items' in response.data) {
batchItems = Array.isArray(response.data.items) ? response.data.items : [];
// Use pagination metadata to determine if more pages exist
const totalPages = response.data.pagination?.totalPages || 1;
hasMore = currentPage < totalPages;
} else if (Array.isArray(response.data)) {
batchItems = response.data;
hasMore = false; // Non-paginated response — all data in one shot
} else {
batchItems = [];
hasMore = false;
}
allData = allData.concat(batchItems);
currentPage++;
// Safety: stop after 100 pages (100k records)
if (currentPage > 100) {
hasMore = false;
}
}
if (allData.length === 0) {
setCsvExporting(false);
return;
}
// Build CSV content
const separator = ';';
const _escapeCsvValue = (val: any): string => {
if (val === null || val === undefined) return '';
let str: string;
if (typeof val === 'boolean') {
str = val ? 'true' : 'false';
} else if (typeof val === 'object') {
if (isTextMultilingual(val)) {
str = formatTextMultilingual(val, currentLanguage);
} else {
try { str = JSON.stringify(val); } catch { str = String(val); }
}
} else {
str = String(val);
}
// Escape quotes and wrap in quotes if needed
if (str.includes(separator) || str.includes('"') || str.includes('\n') || str.includes('\r')) {
str = '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
// Header row
const headerRow = detectedColumns.map(col => _escapeCsvValue(col.label)).join(separator);
// Data rows
const dataRows = allData.map(row => {
return detectedColumns.map(col => {
let cellValue = row[col.key];
// FK resolution
if (col.fkSource && typeof cellValue === 'string' && cellValue.length > 0) {
const resolved = fkCache[col.fkSource]?.[cellValue];
if (resolved) cellValue = resolved;
}
// Timestamp formatting
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked|valuta)$/i.test(col.key);
const isExplicitDateType = col.type && isDateTimeType(col.type);
if ((isTimestampField || isExplicitDateType) && typeof cellValue === 'number') {
try {
let ts = cellValue < 10000000000 ? cellValue : cellValue / 1000;
const formatted = formatUnixTimestamp(ts, undefined, {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
});
cellValue = `${formatted.time} ${formatted.timezone}`;
} catch { /* keep original */ }
}
return _escapeCsvValue(cellValue);
}).join(separator);
});
const csvContent = '\ufeff' + [headerRow, ...dataRows].join('\r\n'); // BOM for Excel
// Trigger download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `export_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('CSV export failed:', error);
} finally {
setCsvExporting(false);
}
}, [csvExporting, detectedColumns, apiEndpoint, currentLanguage, fkCache]);
// Check if inline editing is allowed for a column (based on RBAC permissions)
const canInlineEdit = useMemo(() => {
// Auto-enable if inlineEditable is explicitly true OR if handleInlineUpdate is available
@ -1364,13 +1546,25 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onPageSizeChange={handlePageSizeChange}
supportsBackendPagination={supportsBackendPagination}
hookData={hookData}
onCsvExport={apiEndpoint ? handleCsvExport : undefined}
csvExporting={csvExporting}
/>
)}
{/* Table */}
{/* Table Wrapper - contains top scrollbar and table container */}
<div className={`${styles.tableWrapper} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}>
{/* Top horizontal scrollbar - syncs with table container */}
<div
ref={topScrollbarRef}
className={styles.topScrollbar}
>
<div ref={topScrollbarInnerRef} className={styles.topScrollbarInner} />
</div>
{/* Table Container - vertical scroll only */}
<div
ref={tableContainerRef}
className={`${styles.tableContainer} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}
className={styles.tableContainer}
>
{/* Loading overlay - shown while loading */}
{loading && (
@ -1390,7 +1584,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
<thead>
<tr>
{selectable && (
<th className={styles.selectColumn} style={{ width: '50px', minWidth: '50px', maxWidth: '50px' }}>
<th className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input
type="checkbox"
checked={(() => {
@ -1548,7 +1742,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
)}
>
{selectable && (
<td className={styles.selectColumn} style={{ width: '50px', minWidth: '50px', maxWidth: '50px' }}>
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input
type="checkbox"
checked={selectedRows.has(index)}
@ -1707,6 +1901,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
)}
</div>
</div>
</div>
);
}

View file

@ -3,6 +3,7 @@ export * from './FormGeneratorTable';
export * from './FormGeneratorList';
export * from './FormGeneratorForm';
export * from './FormGeneratorControls';
export * from './FormGeneratorReport';
// Alias FormGeneratorTable as FormGenerator for backward compatibility
export { FormGeneratorTable as FormGenerator, FormGeneratorTableComponent as FormGeneratorComponent } from './FormGeneratorTable';

View file

@ -8,26 +8,24 @@
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
* UI mappt uiComponent zu Icons via pageRegistry.
*
* Struktur (gemäss Navigation-API-Konzept):
* - SYSTEM (static block, order: 10)
* - MEINE FEATURES (dynamic block, order: 15)
* - Mandant 1
* - Feature A
* - Instanz 1 (mit Views)
* - WORKFLOWS (static block, order: 20)
* - BASISDATEN (static block, order: 30)
* - MIGRATE TO FEATURES (static block, order: 40)
* - ADMINISTRATION (static block, order: 200)
* TREE STRUCTURE (alles collapsible):
* Meine Sicht
* - Übersicht, Einstellungen, Prompts, Dateien, Verbindungen, Billing
*
* Mandant 1
* - 🎯 Instanz 1 (Feature-Icon + Instanz-Name)
* - 💼 Instanz 2 (Feature-Icon + Instanz-Name)
*
* Administration
* - Users, Mandates, Roles, ...
*/
import React, { useMemo } from 'react';
import { useNavigation } from '../../hooks/useNavigation';
import type {
StaticBlock,
DynamicBlock,
NavigationItem,
NavigationMandate,
MandateFeature,
FeatureInstance,
FeatureView
} from '../../hooks/useNavigation';
@ -53,13 +51,20 @@ function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem {
}
/**
* Convert a StaticBlock to TreeItem (section)
* Convert a list of NavigationItems into a collapsible TreeNodeItem container.
* Used for grouping static items under "Meine Sicht" and "Administration".
*/
function staticBlockToTreeItem(block: StaticBlock): TreeItem {
function _staticItemsToTreeNode(
id: string,
label: string,
items: NavigationItem[],
defaultExpanded: boolean = true,
): TreeNodeItem {
return {
type: 'section',
title: block.title,
children: block.items.map(navigationItemToTreeNode),
id,
label,
children: items.map(navigationItemToTreeNode),
defaultExpanded,
};
}
@ -75,58 +80,52 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
}
/**
* Convert a FeatureInstance to TreeNodeItem
* Instance node gets path to first view so clicking the instance name (e.g. PEK) navigates to dashboard.
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
* Instance node gets path to first view so clicking the instance name navigates to dashboard.
* Shows the feature icon next to the instance name for visual distinction.
*/
function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem {
const children = instance.views.map(featureViewToTreeNode);
return {
id: instance.id,
label: instance.uiLabel,
icon: getPageIcon(featureUiComponent), // Use feature icon for instance
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
children,
defaultExpanded: false,
};
}
/**
* Convert a MandateFeature to TreeNodeItem
*/
function mandateFeatureToTreeNode(feature: MandateFeature): TreeNodeItem | null {
if (feature.instances.length === 0) {
return null;
}
return {
id: feature.uiComponent,
label: feature.uiLabel,
icon: getPageIcon(feature.uiComponent),
badge: feature.instances.length,
children: feature.instances.map(featureInstanceToTreeNode),
defaultExpanded: false,
};
}
/**
* Convert a NavigationMandate to TreeNodeItem
*
* FLAT STRUCTURE: Instances are listed directly under mandate (no feature grouping).
* Each instance shows the feature's icon for visual distinction.
*
* Before: Mandate Feature Instance Views
* Now: Mandate Instance (with feature icon) Views
*/
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
if (mandate.features.length === 0) {
return null;
}
const children = mandate.features
.map(mandateFeatureToTreeNode)
.filter((node): node is TreeNodeItem => node !== null);
// Flatten: collect all instances from all features directly under mandate
const instanceNodes: TreeNodeItem[] = [];
for (const feature of mandate.features) {
for (const instance of feature.instances) {
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent));
}
}
if (children.length === 0) {
if (instanceNodes.length === 0) {
return null;
}
return {
id: mandate.id,
label: mandate.uiLabel,
children,
children: instanceNodes,
defaultExpanded: true,
};
}
@ -173,38 +172,47 @@ export const MandateNavigation: React.FC = () => {
const { blocks, loading } = useNavigation('de');
// Build navigation items from blocks
// Groups static items into collapsible containers:
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
// - "Administration": all admin static items
// - Dynamic block (mandates) renders between them
const navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = [];
// Process blocks in order (already sorted by backend)
// Collect static items by category
const meineSichtItems: NavigationItem[] = [];
let adminItems: NavigationItem[] = [];
for (const block of blocks) {
if (block.type === 'static') {
// Static block: system, workflows, basedata, migrate, admin
if (block.items.length > 0) {
// Add separator before admin block
if (block.id === 'admin') {
items.push({ type: 'separator' });
adminItems = [...block.items];
} else if (block.items.length > 0) {
meineSichtItems.push(...block.items);
}
}
items.push(staticBlockToTreeItem(block));
}
} else if (block.type === 'dynamic') {
// Dynamic block: features/mandates
// Add separator before dynamic block
items.push({ type: 'separator' });
// "Meine Sicht" - collapsible container for user-facing pages
if (meineSichtItems.length > 0) {
items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
}
// Dynamic block: mandates with feature instances
for (const block of blocks) {
if (block.type === 'dynamic') {
const mandateNodes = dynamicBlockToTreeNodes(block);
if (mandateNodes.length > 0) {
if (items.length > 0) items.push({ type: 'separator' });
items.push(...mandateNodes);
}
// Add separator after dynamic block (before next static blocks)
items.push({ type: 'separator' });
}
}
// Remove trailing separator if present
while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') {
items.pop();
// "Administration" - collapsible container for admin pages
if (adminItems.length > 0) {
if (items.length > 0) items.push({ type: 'separator' });
items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false));
}
return items;

View file

@ -1,16 +1,17 @@
/**
* TreeNavigation Styles
*
* Flexible hierarchical navigation with support for:
* - Dynamic sublevels
* - Sections and separators
* - Various visual states (active, disabled, hover)
* Modern minimal tree navigation (Notion/Linear style):
* - CSS-only disclosure triangle with smooth rotation
* - No guide lines clean indentation only
* - Depth-aware sizing via data-depth attribute
* - Hover-reveal toggle for subtle UX
*/
.treeNavigation {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 1px;
padding: 0 0.5rem;
}
@ -20,8 +21,8 @@
.separator {
height: 1px;
background: var(--border-color, #e0e0e0);
margin: 0.75rem 0.5rem;
background: var(--border-color, #e2e8f0);
margin: 0.5rem 0.75rem;
}
/* ============================================ */
@ -29,7 +30,7 @@
/* ============================================ */
.treeSection {
margin-bottom: 0.5rem;
margin-bottom: 0.25rem;
}
.sectionHeader {
@ -40,14 +41,14 @@
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.1em;
color: var(--text-tertiary, #888);
color: var(--text-tertiary, #94a3b8);
text-transform: uppercase;
}
.sectionContent {
display: flex;
flex-direction: column;
gap: 2px;
gap: 1px;
}
/* ============================================ */
@ -62,9 +63,9 @@
.treeNode {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.375rem;
width: 100%;
padding: 0.5rem 0.75rem;
padding: 0.375rem 0.5rem;
border: none;
border-radius: 6px;
background: transparent;
@ -72,9 +73,11 @@
text-decoration: none;
font-family: inherit;
text-align: left;
color: var(--text-secondary, #666);
font-size: 0.875rem;
transition: all 0.15s ease;
color: var(--text-secondary, #64748b);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.4;
transition: background 0.15s ease, color 0.15s ease;
}
.treeNode:hover {
@ -82,12 +85,18 @@
color: var(--text-primary, #1a1a1a);
}
/* Leaf node active — strong pill highlight */
.treeNode.active {
background: var(--primary-light, #e0e7ff);
color: var(--primary-color, #2563eb);
font-weight: 500;
}
/* Group/parent active — subtle text color only, no background */
.treeNode.activeGroup {
color: var(--primary-color, #2563eb);
}
.treeNode.disabled {
opacity: 0.5;
cursor: not-allowed;
@ -95,105 +104,109 @@
}
/* ============================================ */
/* LEVEL-SPECIFIC STYLES */
/* DEPTH-SPECIFIC STYLES (via data-depth) */
/* ============================================ */
/* Root level (level 0) */
.levelRoot {
.treeNode[data-depth="0"] {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
padding: 0.625rem 0.75rem;
padding: 0.5rem 0.5rem;
}
.levelRoot .nodeLabel {
flex: 1;
}
/* Level 1 */
.levelOne {
.treeNode[data-depth="1"] {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #666);
padding: 0.5rem 0.75rem;
}
/* Level 2 */
.levelTwo {
.treeNode[data-depth="2"],
.treeNode[data-depth="3"],
.treeNode[data-depth="4"],
.treeNode[data-depth="5"] {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #666);
padding: 0.375rem 0.5rem;
}
/* Level 3 */
.levelThree {
font-size: 0.75rem;
color: var(--text-secondary, #666);
padding: 0.375rem 0.5rem;
}
/* Deep levels (4+) */
.levelDeep {
font-size: 0.6875rem;
color: var(--text-tertiary, #888);
padding: 0.25rem 0.5rem;
font-weight: 400;
}
/* ============================================ */
/* NODE CHILDREN (INDENTATION) */
/* NODE CHILDREN (INDENTATION ONLY) */
/* ============================================ */
.treeNodeChildren {
margin-left: 0.25rem;
padding-left: 0.75rem;
border-left: 2px solid var(--border-color, #e0e0e0);
margin-left: 0.75rem;
padding-left: 0.5rem;
}
/* Active parent highlights the border */
.treeNodeContainer:has(> .treeNode.active) > .treeNodeChildren {
/* ============================================ */
/* TOGGLE (CSS-only disclosure triangle) */
/* ============================================ */
.toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s ease;
}
/* The triangle — pure CSS, no icon needed */
.toggle::after {
content: '';
display: block;
width: 0;
height: 0;
border-left: 4.5px solid var(--text-tertiary, #94a3b8);
border-top: 3.5px solid transparent;
border-bottom: 3.5px solid transparent;
transition: transform 0.2s ease, border-color 0.15s ease;
}
/* Rotate triangle when expanded */
.toggleExpanded::after {
transform: rotate(90deg);
}
/* Hover feedback */
.toggle:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
}
.toggle:hover::after {
border-left-color: var(--text-primary, #1a1a1a);
}
/* Active node toggle */
.treeNode.active .toggle::after,
.treeNode.activeGroup .toggle::after {
border-left-color: var(--primary-color, #2563eb);
}
/* Also highlight if any descendant is active */
.treeNodeContainer:has(.treeNode.active) > .treeNodeChildren {
border-left-color: var(--primary-light, #93c5fd);
/* Spacer for leaf nodes (keeps alignment with toggle nodes) */
.toggleSpacer {
width: 1.125rem;
flex-shrink: 0;
}
/* ============================================ */
/* NODE ELEMENTS */
/* ============================================ */
.chevron {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
font-size: 0.625rem;
color: var(--text-tertiary, #888);
flex-shrink: 0;
cursor: pointer;
border-radius: 3px;
transition: background 0.1s ease;
}
.chevron:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
}
.chevronSpacer {
width: 1rem;
flex-shrink: 0;
}
.nodeIcon {
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-size: 0.875rem;
flex-shrink: 0;
color: inherit;
opacity: 0.8;
}
.treeNode.active .nodeIcon,
.treeNode.activeGroup .nodeIcon {
opacity: 1;
}
.nodeLabel {
@ -208,7 +221,7 @@
padding: 0.0625rem 0.375rem;
background: var(--surface-color, #f0f0f0);
border-radius: 9999px;
color: var(--text-tertiary, #888);
color: var(--text-tertiary, #94a3b8);
text-transform: uppercase;
letter-spacing: 0.025em;
flex-shrink: 0;
@ -262,41 +275,45 @@
color: var(--primary-light, #93c5fd);
}
:global(.dark-theme) .levelRoot {
:global(.dark-theme) .treeNode.activeGroup {
color: var(--primary-light, #93c5fd);
}
:global(.dark-theme) .treeNode[data-depth="0"] {
color: var(--text-primary-dark, #fff);
}
:global(.dark-theme) .levelOne,
:global(.dark-theme) .levelTwo,
:global(.dark-theme) .levelThree {
color: var(--text-secondary-dark, #aaa);
:global(.dark-theme) .toggle::after {
border-left-color: var(--text-tertiary-dark, #555);
}
:global(.dark-theme) .levelDeep {
color: var(--text-tertiary-dark, #888);
:global(.dark-theme) .toggle:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.08));
}
:global(.dark-theme) .treeNodeChildren {
border-left-color: var(--border-dark, #444);
:global(.dark-theme) .toggle:hover::after {
border-left-color: var(--text-primary-dark, #ddd);
}
:global(.dark-theme) .treeNodeContainer:has(.treeNode.active) > .treeNodeChildren {
:global(.dark-theme) .treeNode.active .toggle::after,
:global(.dark-theme) .treeNode.activeGroup .toggle::after {
border-left-color: var(--primary-light, #93c5fd);
}
:global(.dark-theme) .nodeIcon {
opacity: 0.7;
}
:global(.dark-theme) .treeNode.active .nodeIcon,
:global(.dark-theme) .treeNode.activeGroup .nodeIcon {
opacity: 1;
}
:global(.dark-theme) .nodeBadge {
background: var(--surface-dark, #2a2a2a);
color: var(--text-tertiary-dark, #888);
}
:global(.dark-theme) .chevron {
color: var(--text-tertiary-dark, #666);
}
:global(.dark-theme) .chevron:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.1));
}
:global(.dark-theme) .treeNode.active .nodeBadge {
background: var(--primary-color, #2563eb);
color: white;

View file

@ -12,7 +12,6 @@
import React, { useState, useEffect, ReactNode } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import styles from './TreeNavigation.module.css';
// =============================================================================
@ -155,8 +154,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
}
}, [currentPath, autoExpandActive, node]);
// Check if this exact node is active
// Check if this node is active (exact match or ancestor of active path)
const isActive = node.path ? currentPath === node.path || currentPath.startsWith(node.path + '/') : false;
// Differentiate: leaf active (strong highlight) vs group active (subtle text only)
const isLeafActive = isActive && !hasChildren;
const isGroupActive = isActive && !!hasChildren;
// Handle click
const handleClick = (e: React.MouseEvent) => {
@ -186,34 +188,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
}
};
// Handle chevron click separately
const handleChevronClick = (e: React.MouseEvent) => {
// Handle toggle click separately (expand/collapse)
const handleToggleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded(!isExpanded);
};
// Get level-specific styles
const getLevelClass = () => {
switch (level) {
case 0: return styles.levelRoot;
case 1: return styles.levelOne;
case 2: return styles.levelTwo;
case 3: return styles.levelThree;
default: return styles.levelDeep;
}
};
// Render the node content
const nodeContent = (
<>
{isExpandable && (
<span className={styles.chevron} onClick={handleChevronClick}>
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
</span>
<span
className={`${styles.toggle} ${isExpanded ? styles.toggleExpanded : ''}`}
onClick={handleToggleClick}
/>
)}
{!isExpandable && hasChildren === false && (
<span className={styles.chevronSpacer} />
<span className={styles.toggleSpacer} />
)}
{node.icon && <span className={styles.nodeIcon}>{node.icon}</span>}
<span className={styles.nodeLabel} title={node.label}>{node.label}</span>
@ -228,7 +220,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
);
// Determine if we should render as NavLink or button
const nodeClasses = `${styles.treeNode} ${getLevelClass()} ${isActive ? styles.active : ''} ${node.disabled ? styles.disabled : ''} ${node.className || ''}`;
const nodeClasses = `${styles.treeNode} ${isLeafActive ? styles.active : ''} ${isGroupActive ? styles.activeGroup : ''} ${node.disabled ? styles.disabled : ''} ${node.className || ''}`;
const nodeElement = node.path ? (
<NavLink
@ -236,6 +228,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
className={nodeClasses}
onClick={handleClick}
data-id={node.dataId}
data-depth={level}
>
{nodeContent}
</NavLink>
@ -246,6 +239,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onClick={handleClick}
disabled={node.disabled}
data-id={node.dataId}
data-depth={level}
>
{nodeContent}
</button>

View file

@ -34,6 +34,11 @@ export const UserSection: React.FC = () => {
setShowMenu(false);
};
const handleBilling = () => {
navigate('/billing/transactions');
setShowMenu(false);
};
const handleLegal = () => {
setShowLegalModal(true);
setShowMenu(false);
@ -72,6 +77,14 @@ export const UserSection: React.FC = () => {
{showMenu && (
<div className={styles.menu}>
<button
className={styles.menuItem}
onClick={handleBilling}
>
<span className={styles.menuIcon}>💰</span>
Guthaben
</button>
<button
className={styles.menuItem}
onClick={handleSettings}

View file

@ -0,0 +1,225 @@
/* Provider Selector Component Styles */
/* ============================================================================
SINGLE SELECT
============================================================================ */
.providerSelect {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 4px);
}
.label {
font-size: var(--font-size-sm, 0.875rem);
font-weight: var(--font-weight-medium, 500);
color: var(--color-text-secondary);
}
.select {
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md, 6px);
background: var(--color-bg-input);
color: var(--color-text-primary);
font-size: var(--font-size-sm, 0.875rem);
cursor: pointer;
min-width: 150px;
}
.select:focus {
outline: none;
border-color: var(--color-primary);
}
.select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ============================================================================
MULTI SELECT
============================================================================ */
.providerMultiSelect {
position: relative;
display: inline-block;
}
/* Trigger Button - matches iconButton style from PlaygroundPage */
.triggerButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--border-color, #3a3a3a);
border-radius: 6px;
background: var(--surface-color, #2d2d2d);
color: var(--text-secondary, #888);
cursor: pointer;
transition: all 0.2s;
}
.triggerButton:hover:not(:disabled) {
background: var(--bg-secondary, #3a3a3a);
color: var(--text-primary, #fff);
}
.triggerButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.buttonIcon {
font-size: 1.1rem;
}
/* Dropdown Content - opens upward */
.dropdownContent {
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
z-index: 1000;
padding: 8px;
background: var(--surface-color, #2d2d2d);
border: 1px solid var(--border-color, #3a3a3a);
border-radius: 6px;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5);
min-width: 220px;
}
.dropdownHeader {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #888);
padding: 4px 8px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border-color, #3a3a3a);
}
.selectActions {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.actionButton {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--border-color, #3a3a3a);
border-radius: 4px;
background: var(--bg-secondary, #252525);
color: var(--text-secondary, #888);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.actionButton:hover:not(:disabled) {
background: var(--bg-hover, #3a3a3a);
color: var(--text-primary, #fff);
}
.actionButton.active {
background: var(--primary-color, #f25843);
border-color: var(--primary-color, #f25843);
color: #fff;
}
.actionButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkboxList {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px;
background: var(--bg-secondary, #252525);
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
}
.checkboxItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s ease;
color: var(--text-primary, #e0e0e0);
}
.checkboxItem:hover {
background: var(--bg-hover, #3a3a3a);
}
.checkboxItem.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkboxItem input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: inherit;
accent-color: var(--primary-color, #f25843);
}
.icon {
font-size: 1rem;
}
.providerName {
font-size: 0.8rem;
color: var(--text-primary, #e0e0e0);
}
.hint {
font-size: 0.7rem;
color: var(--text-tertiary, #666);
text-align: center;
padding: 4px 0;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
color: var(--text-secondary, #888);
font-size: 0.8rem;
}
/* ============================================================================
PROVIDER BADGES
============================================================================ */
.providerBadges {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs, 4px);
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm, 4px);
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-primary);
}
.allProviders {
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-secondary);
font-style: italic;
}

View file

@ -0,0 +1,302 @@
/**
* ProviderSelector Component
*
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
* Kann im Chat Playground und Automation Editor verwendet werden.
*
* Features:
* - Dropdown für Einzelauswahl
* - Checkbox-Liste für Mehrfachauswahl
* - Lädt verfügbare Provider aus dem Billing-System
*/
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useBilling } from '../../hooks/useBilling';
import styles from './ProviderSelector.module.css';
// Provider display names
const PROVIDER_LABELS: Record<string, string> = {
anthropic: 'Anthropic (Claude)',
openai: 'OpenAI (GPT)',
perplexity: 'Perplexity',
tavily: 'Tavily (Web Search)',
privatellm: 'Private LLM',
internal: 'Internal',
};
// Provider icons (emojis for simplicity)
const PROVIDER_ICONS: Record<string, string> = {
anthropic: '🤖',
openai: '💬',
perplexity: '🔍',
tavily: '🌐',
privatellm: '🔒',
internal: '🏠',
};
// ============================================================================
// SINGLE SELECT COMPONENT
// ============================================================================
interface ProviderSelectProps {
value: string;
onChange: (provider: string) => void;
disabled?: boolean;
className?: string;
label?: string;
showLabel?: boolean;
}
export const ProviderSelect: React.FC<ProviderSelectProps> = ({
value,
onChange,
disabled = false,
className,
label = 'AI-Provider',
showLabel = true,
}) => {
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => {
if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders();
}
}, []);
const providerOptions = useMemo(() => {
return allowedProviders.map((provider) => ({
value: provider,
label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`,
}));
}, [allowedProviders]);
return (
<div className={`${styles.providerSelect} ${className || ''}`}>
{showLabel && <label className={styles.label}>{label}</label>}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading}
className={styles.select}
>
<option value="">-- Auto --</option>
{providerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
};
// ============================================================================
// MULTI SELECT COMPONENT (Checkbox List)
// ============================================================================
interface ProviderMultiSelectProps {
selectedProviders: string[];
onChange: (providers: string[]) => void;
disabled?: boolean;
className?: string;
label?: string;
showLabel?: boolean;
defaultExpanded?: boolean;
excludeByDefault?: string[];
}
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
selectedProviders,
onChange,
disabled = false,
className,
label = 'AI-Provider',
showLabel = true,
defaultExpanded = false,
excludeByDefault = [],
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => {
if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders();
}
}, []);
// Apply default exclusions when providers first load
useEffect(() => {
if (
!initialExcludeApplied &&
allowedProviders.length > 0 &&
excludeByDefault.length > 0 &&
selectedProviders.length === 0
) {
const initialSelection = allowedProviders.filter(
(p) => !excludeByDefault.includes(p)
);
// Only apply if there's actually something to exclude
if (initialSelection.length < allowedProviders.length) {
onChange(initialSelection);
}
setInitialExcludeApplied(true);
}
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]);
// Click outside handler
const handleClickOutside = useCallback((event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
}, []);
useEffect(() => {
if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isExpanded, handleClickOutside]);
// Effective selection: empty array = all providers active (no restriction)
const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders;
// "Alle" is active when no restriction is set (empty array) OR all explicitly selected
const isAllSelected = selectedProviders.length === 0 ||
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
const handleToggle = (provider: string) => {
if (selectedProviders.length === 0) {
// Currently "all active" (no restriction) -> make explicit: all except the toggled one
onChange(allowedProviders.filter((p) => p !== provider));
} else if (selectedProviders.includes(provider)) {
// Deactivate: remove from selection
const remaining = selectedProviders.filter((p) => p !== provider);
// If removing leaves all others selected, reset to [] (= all, no restriction)
if (remaining.length === allowedProviders.length) {
onChange([]);
} else {
onChange(remaining);
}
} else {
// Activate: add to selection
const updated = [...selectedProviders, provider];
// If all are now selected, reset to [] (= all, no restriction)
if (updated.length === allowedProviders.length) {
onChange([]);
} else {
onChange(updated);
}
}
};
const handleSelectAll = () => {
onChange([]); // Empty = all active, no restriction
};
// Summary icon for button
const summaryIcon = useMemo(() => {
if (effectiveSelection.length === 1) {
return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
}
return '🤖';
}, [effectiveSelection]);
return (
<div
ref={containerRef}
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
>
{/* Trigger Button - styled like iconButton */}
<button
type="button"
className={styles.triggerButton}
onClick={() => setIsExpanded(!isExpanded)}
disabled={disabled}
title="Provider auswählen"
>
<span className={styles.buttonIcon}>{summaryIcon}</span>
</button>
{/* Dropdown Content */}
{isExpanded && (
<div className={styles.dropdownContent}>
{showLabel && <div className={styles.dropdownHeader}>{label}</div>}
<div className={styles.selectActions}>
<button
type="button"
onClick={handleSelectAll}
disabled={disabled}
className={`${styles.actionButton} ${isAllSelected ? styles.active : ''}`}
>
Alle
</button>
</div>
{loading ? (
<div className={styles.loading}>Lade...</div>
) : (
<div className={styles.checkboxList}>
{allowedProviders.map((provider) => (
<label
key={provider}
className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`}
>
<input
type="checkbox"
checked={effectiveSelection.includes(provider)}
onChange={() => handleToggle(provider)}
disabled={disabled}
/>
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span>
<span className={styles.providerName}>
{PROVIDER_LABELS[provider] || provider}
</span>
</label>
))}
</div>
)}
{isAllSelected && !loading && (
<div className={styles.hint}>
Alle Provider aktiv (kein Filter)
</div>
)}
</div>
)}
</div>
);
};
// ============================================================================
// COMPACT PROVIDER BADGE LIST
// ============================================================================
interface ProviderBadgesProps {
providers: string[];
className?: string;
}
export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
providers,
className,
}) => {
if (providers.length === 0) {
return <span className={styles.allProviders}>Alle Provider</span>;
}
return (
<div className={`${styles.providerBadges} ${className || ''}`}>
{providers.map((provider) => (
<span key={provider} className={styles.badge}>
{PROVIDER_ICONS[provider] || '🔌'} {PROVIDER_LABELS[provider] || provider}
</span>
))}
</div>
);
};
// Default export
export default ProviderSelect;

View file

@ -0,0 +1,10 @@
/**
* Provider Selector Component Exports
*/
export {
ProviderSelect,
ProviderMultiSelect,
ProviderBadges
} from './ProviderSelector';
export { default } from './ProviderSelector';

View file

@ -1,6 +1,7 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { FaExclamationTriangle } from 'react-icons/fa';
import { Message } from '../MessagesTypes';
import { formatTimestamp } from '../MessageUtils';
import { DocumentItem, ActionInfo } from '../MessageParts';
@ -40,16 +41,21 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
workflowId
}) => {
const isUser = message.role?.toLowerCase() === 'user';
const isError = message.actionProgress === 'fail' || message.actionProgress === 'error';
const messageClass = isUser ? styles.messageUser : styles.messageAssistant;
// Debug: Log documents if in dev mode
if (import.meta.env.DEV && message.documents) {
console.log('ChatMessage documents:', message.id, message.documents);
}
const errorClass = isError ? styles.messageError : '';
return (
<div className={`${styles.message} ${messageClass}`}>
<div className={`${styles.message} ${messageClass} ${errorClass}`}>
<div className={styles.messageBubble}>
{/* Error indicator for failed actions */}
{isError && (
<div className={styles.errorIndicator}>
<FaExclamationTriangle className={styles.errorIcon} />
<span>Aktion fehlgeschlagen</span>
</div>
)}
{/* Message content */}
{message.message && (
<div className={styles.messageContent}>

View file

@ -64,6 +64,29 @@
border-bottom-left-radius: 4px;
}
/* Error/Failed Messages */
.messageError .messageBubble {
background-color: var(--danger-bg, #fee2e2);
border: 1px solid var(--danger-color, #dc2626);
}
.errorIndicator {
display: flex;
align-items: center;
gap: 6px;
color: var(--danger-color, #dc2626);
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
background-color: rgba(220, 38, 38, 0.1);
border-radius: 4px;
margin-bottom: 4px;
}
.errorIcon {
font-size: 14px;
}
/* Message Metadata */
.messageMetadata {
display: flex;

View file

@ -20,7 +20,7 @@ import {
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone,
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
FaProjectDiagram, FaMapMarkedAlt
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt
} from 'react-icons/fa';
// =============================================================================
@ -47,6 +47,10 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.system.pek': <FaChartBar />,
'page.system.speech': <FaMicrophone />,
// Billing pages
'page.billing.dashboard': <FaWallet />,
'page.billing.transactions': <FaListAlt />,
// Admin pages
'page.admin.access': <FaBuilding />,
'page.admin.users': <FaUsers />,
@ -57,8 +61,10 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.user-mandates': <FaUserTag />,
'page.admin.feature-roles': <FaCube />,
'page.admin.feature-instances': <FaCubes />,
'page.admin.featureInstances': <FaCubes />,
'page.admin.feature-users': <FaUsersCog />,
'page.admin.user-access-overview': <FaUserShield />,
'page.admin.billing': <FaMoneyBillAlt />,
// Feature pages - Trustee
'page.feature.trustee.dashboard': <FaChartLine />,

View file

@ -10,7 +10,7 @@ import type { Workflow, WorkflowMessage } from '../../api/workflowApi';
import { useWorkflowLifecycle } from './useWorkflowLifecycle';
import { useWorkflows } from './useWorkflows';
import { useDashboardLogTree } from './useDashboardLogTree';
import { extractFileIdsFromMessage, convertFilesToDocuments, sortMessages } from './playgroundUtils';
import { convertFilesToDocuments, sortMessages } from './playgroundUtils';
import type { WorkflowLog as LogTypesWorkflowLog } from '../../components/UiComponents/Log/LogTypes';
export interface WorkflowFile {
@ -23,13 +23,14 @@ export interface WorkflowFile {
source?: 'user_uploaded' | 'ai_created';
}
export function useDashboardInputForm() {
export function useDashboardInputForm(instanceId: string) {
const [inputValue, setInputValue] = useState<string>('');
const [pendingFiles, setPendingFiles] = useState<WorkflowFile[]>([]);
const [isFileAttachmentPopupOpen, setIsFileAttachmentPopupOpen] = useState(false);
const [optimisticMessage, setOptimisticMessage] = useState<WorkflowMessage | null>(null);
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null);
const [selectedProviders, setSelectedProviders] = useState<string[]>([]); // AI provider selection (multiselect)
const { checkPermission, canView } = usePermissions();
const [playgroundUIPermission, setPlaygroundUIPermission] = useState<boolean>(false);
@ -54,7 +55,7 @@ export function useDashboardInputForm() {
resetWorkflow,
selectWorkflow,
setWorkflowStatusOptimistic
} = useWorkflowLifecycle();
} = useWorkflowLifecycle(instanceId);
// Dashboard log tree hook
const {
@ -278,44 +279,21 @@ export function useDashboardInputForm() {
useEffect(() => {
if (!messages || messages.length === 0) return;
if (!optimisticMessage) return;
const messageTexts = new Set<string>();
messages.forEach((message: WorkflowMessage) => {
if (message.message) {
messageTexts.add(message.message.trim());
}
});
if (optimisticMessage && optimisticMessage.message) {
const optimisticText = optimisticMessage.message.trim();
const optimisticFileIds = extractFileIdsFromMessage(optimisticMessage);
const matchingMessage = Array.from(messages).find((msg: WorkflowMessage) =>
msg.message && msg.message.trim() === optimisticText
// Clear optimistic message when backend's "first" user message arrives via polling.
// The backend message contains the normalizedRequest (which differs from the original prompt),
// so we match by status="first" instead of content comparison.
const hasFirstMessage = messages.some((msg: WorkflowMessage) =>
(msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
);
if (matchingMessage) {
const matchingFileIds = extractFileIdsFromMessage(matchingMessage);
if (optimisticFileIds.size > 0) {
const allFilesConfirmed = Array.from(optimisticFileIds).every(fileId =>
matchingFileIds.has(fileId)
);
if (allFilesConfirmed && matchingFileIds.size > 0) {
if (hasFirstMessage) {
setOptimisticMessage(null);
}
} else {
if (messageTexts.has(optimisticText)) {
setOptimisticMessage(null);
}
}
}
}
}, [messages, optimisticMessage]);
const displayMessages = useMemo(() => {
const optimisticText = optimisticMessage?.message?.trim();
const processedMessages = (messages || []).map((message: WorkflowMessage) => {
const files = (message as any).files as any[] | undefined;
const documents = (message as any).documents as MessageDocument[] | undefined;
@ -330,37 +308,19 @@ export function useDashboardInputForm() {
return message;
});
let replacedMessageTimestamp: number | undefined;
const filteredMessages = processedMessages.filter((message: WorkflowMessage) => {
const isUserMessage = message.role?.toLowerCase() === 'user';
const messageText = message.message?.trim();
if (optimisticMessage && optimisticText && isUserMessage && messageText === optimisticText) {
const documents = (message as any).documents as MessageDocument[] | undefined;
const files = (message as any).files as any[] | undefined;
const hasDocuments = documents && Array.isArray(documents) && documents.length > 0;
const hasFiles = files && Array.isArray(files) && files.length > 0;
if (hasDocuments || hasFiles) {
return true;
}
if (message.publishedAt !== undefined) {
replacedMessageTimestamp = message.publishedAt;
}
return false;
}
return true;
});
const allMessages = [...filteredMessages];
// If optimistic message is still active (backend "first" message not yet polled),
// show the optimistic message instead of any backend user messages to avoid duplicates.
const allMessages = [...processedMessages];
if (optimisticMessage) {
const optimisticWithTimestamp = replacedMessageTimestamp !== undefined
? { ...optimisticMessage, publishedAt: replacedMessageTimestamp }
: optimisticMessage;
allMessages.push(optimisticWithTimestamp);
// Find backend "first" user message to inherit its timestamp for correct ordering
const firstBackendMsg = processedMessages.find((msg: WorkflowMessage) =>
(msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
);
if (!firstBackendMsg) {
// Backend "first" message not yet arrived - show optimistic message
allMessages.push(optimisticMessage);
}
// If firstBackendMsg exists, the useEffect above will clear optimistic on next render
}
return allMessages.sort(sortMessages);
@ -594,9 +554,13 @@ export function useDashboardInputForm() {
const requestBody = {
prompt: trimmedInput,
listFileId: fileIdsToSend.length > 0 ? fileIdsToSend : undefined,
userLanguage: 'en'
userLanguage: 'en',
allowedProviders: selectedProviders.length > 0 ? selectedProviders : undefined // AI provider filter (multiselect)
};
// Debug: Log provider selection
console.log('🤖 Provider selection:', { selectedProviders, sentProviders: requestBody.allowedProviders });
const result = await startWorkflow(requestBody, workflowOptions);
if (result.success) {
@ -636,7 +600,7 @@ export function useDashboardInputForm() {
setWorkflowStatusOptimistic('idle');
}
}
}, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, setWorkflowStatusOptimistic]);
}, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, selectedProviders, setWorkflowStatusOptimistic]);
useEffect(() => {
const handleWorkflowCleared = () => {
@ -820,11 +784,14 @@ export function useDashboardInputForm() {
allUserFiles: fileContext.files || [],
handleFileAttach,
handleFileUploadAndAttach,
latestStats
latestStats,
// AI Provider selection (multiselect)
selectedProviders,
onProvidersChange: setSelectedProviders
};
}
export function createDashboardHook() {
return () => useDashboardInputForm();
export function createDashboardHook(instanceId: string) {
return () => useDashboardInputForm(instanceId);
}

View file

@ -18,7 +18,54 @@ interface UnifiedChatDataItem {
createdAt: number;
}
export function useWorkflowLifecycle() {
/**
* =============================================================================
* WORKFLOW LIFECYCLE STATE MACHINE
* =============================================================================
*
* WORKFLOW STATUS (from Backend):
* idle - No workflow
* running - Workflow is processing
* completed - Round finished (Backend processed "last" message)
* stopped - User stopped the workflow
* failed - Error occurred
*
* UI FLAG:
* hasRenderedLastMessage: boolean
* - true: "last" message was rendered in UI
* - false: "last" message not yet in UI
*
* POLLING LOGIC:
* POLL ACTIVE when:
* status === 'running'
* OR (status === 'completed' AND !hasRenderedLastMessage)
*
* POLL STOPS when:
* status === 'stopped'
* OR status === 'failed'
* OR hasRenderedLastMessage === true
*
* TRANSITIONS:
* [Send Button] (from any status):
* hasRenderedLastMessage = false (new round starts)
* afterTimestamp = now
* Start polling
*
* [Load Workflow]:
* Load all data
* Check if last message has status="last"
* If yes: hasRenderedLastMessage = true, no polling
* If no AND status=running: Start polling
*
* [Message with status="last" rendered]:
* hasRenderedLastMessage = true
* Stop polling
*
* =============================================================================
*/
export function useWorkflowLifecycle(instanceId: string) {
// === STATE ===
const [workflowId, setWorkflowId] = useState<string | null>(null);
const [workflowStatus, setWorkflowStatus] = useState<string>('idle');
const [currentRound, setCurrentRound] = useState<number | undefined>(undefined);
@ -26,48 +73,38 @@ export function useWorkflowLifecycle() {
const [logs, setLogs] = useState<WorkflowLog[]>([]);
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]);
const [statusChangedFromRunningAt, setStatusChangedFromRunningAt] = useState<number | null>(null);
const [latestStats, setLatestStats] = useState<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null);
const prevStatusRef = useRef<string>('idle');
// === REFS FOR SYNC ACCESS ===
const statusRef = useRef<string>('idle');
const statusChangedFromRunningAtRef = useRef<number | null>(null);
const lastRenderedTimestampRef = useRef<number | null>(null);
// Track processed stat IDs to avoid double-counting
const processedStatIdsRef = useRef<Set<string>>(new Set());
// Track cumulative stats
const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 });
// === KEY STATE MACHINE FLAG ===
// This flag tracks if the UI has rendered a message with status="last"
// Polling continues until this is true (even if backend status is "completed")
const hasRenderedLastMessageRef = useRef<boolean>(false);
const [hasRenderedLastMessage, setHasRenderedLastMessage] = useState<boolean>(false);
// Flag to prevent useEffect from stopping polling during active workflow start
const isStartingWorkflowRef = useRef<boolean>(false);
// === HOOKS ===
const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations();
const { request } = useApiRequest();
const pollingController = useWorkflowPolling();
// Store polling controller methods in refs to avoid dependency issues
const pollingControllerRef = useRef(pollingController);
pollingControllerRef.current = pollingController;
// Helper to update status and track transitions
// === HELPER: Update workflow status ===
const updateWorkflowStatus = useCallback((newStatus: string) => {
const prevStatus = prevStatusRef.current;
prevStatusRef.current = newStatus;
statusRef.current = newStatus;
setWorkflowStatus(newStatus);
// Track when status changes from 'running' to something else
if (prevStatus === 'running' && newStatus !== 'running') {
const timestamp = Date.now();
setStatusChangedFromRunningAt(timestamp);
statusChangedFromRunningAtRef.current = timestamp;
} else if (newStatus === 'running') {
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
}
console.log('📍 Status updated to:', newStatus);
}, []);
// Expose setWorkflowStatus for optimistic updates
const setWorkflowStatusOptimistic = useCallback((status: string) => {
updateWorkflowStatus(status);
}, [updateWorkflowStatus]);
// Convert backend log format to frontend format
// === HELPER: Convert backend log format to frontend format ===
const convertLogToFrontendFormat = useCallback((log: any): WorkflowLog => {
return {
id: log.id,
@ -83,15 +120,15 @@ export function useWorkflowLifecycle() {
};
}, [workflowId]);
// Process unified chat data chronologically
// === CORE: Process unified chat data ===
const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; stats: any[] }) => {
console.log('🔄 Processing unified chat data:', {
console.log('🔄 Processing chat data:', {
messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0,
stats: chatData.stats?.length || 0
});
// Build unified timeline of all items
// Build unified timeline
const timeline: UnifiedChatDataItem[] = [];
// Add messages
@ -112,154 +149,132 @@ export function useWorkflowLifecycle() {
});
});
// Add stats (if needed)
(chatData.stats || []).forEach((stat: any) => {
// Add stats
const rawStats = chatData.stats || [];
rawStats.forEach((stat: any) => {
timeline.push({
type: 'stat',
item: stat,
createdAt: stat.timestamp || stat.createdAt || Date.now()
createdAt: stat._createdAt || stat.createdAt || Date.now()
});
});
console.log('📋 Timeline created with', timeline.length, 'items');
// Sort chronologically
timeline.sort((a, b) => a.createdAt - b.createdAt);
// Process items sequentially to maintain chronological order
// Update lastRenderedTimestamp after processing all items (use latest timestamp)
// Update lastRenderedTimestamp
if (timeline.length > 0) {
const latestTimestamp = timeline[timeline.length - 1].createdAt;
lastRenderedTimestampRef.current = latestTimestamp;
lastRenderedTimestampRef.current = timeline[timeline.length - 1].createdAt;
}
// Use functional updates to avoid dependency on current state
setMessages(prevMessages => {
const newMessages: WorkflowMessage[] = [...prevMessages];
let hasChanges = false;
let messagesAdded = 0;
let messagesUpdated = 0;
// === CHECK FOR "LAST" MESSAGE ===
// This is the key state machine logic: detect when a "last" message arrives
let foundLastMessage = false;
timeline.forEach((item) => {
if (item.type === 'message') {
const message = item.item as WorkflowMessage;
if ((message as any).status === 'last') {
foundLastMessage = true;
console.log('🏁 Found "last" message:', message.id);
}
}
});
if (!message || !message.id) {
console.warn('⚠️ Invalid message in timeline:', message);
return;
// === STATE MACHINE: Handle "last" message ===
if (foundLastMessage && !hasRenderedLastMessageRef.current) {
console.log('🛑 "last" message detected - stopping polling');
hasRenderedLastMessageRef.current = true;
setHasRenderedLastMessage(true);
pollingControllerRef.current.stopPolling();
}
// Check if message already exists
// === UPDATE MESSAGES STATE ===
setMessages(prevMessages => {
const newMessages: WorkflowMessage[] = [...prevMessages];
let hasChanges = false;
timeline.forEach((item) => {
if (item.type === 'message') {
const message = item.item as WorkflowMessage;
if (!message || !message.id) return;
const existingIndex = newMessages.findIndex(m => m.id === message.id);
if (existingIndex >= 0) {
// Always update existing message (don't compare, just update)
newMessages[existingIndex] = message;
hasChanges = true;
messagesUpdated++;
} else {
newMessages.push(message);
hasChanges = true;
messagesAdded++;
}
}
});
console.log(`📨 Messages: ${messagesAdded} added, ${messagesUpdated} updated, total: ${newMessages.length}`);
if (messagesAdded > 0 || messagesUpdated > 0) {
console.log('📨 Sample messages:', newMessages.slice(0, 3).map(m => ({ id: m.id, message: m.message?.substring(0, 50) })));
}
// Always return sorted array if we processed any messages
if (hasChanges || timeline.some(item => item.type === 'message')) {
const sorted = [...newMessages].sort(sortMessages);
console.log(`✅ Returning ${sorted.length} sorted messages`);
return sorted;
return [...newMessages].sort(sortMessages);
}
console.log('⚠️ No changes detected, returning previous messages');
return prevMessages;
});
setDashboardLogs(prevDashboardLogs => {
const newDashboardLogs: WorkflowLog[] = [...prevDashboardLogs];
// === UPDATE DASHBOARD LOGS (with operationId) ===
setDashboardLogs(prevLogs => {
const newLogs: WorkflowLog[] = [...prevLogs];
let hasChanges = false;
timeline.forEach((item) => {
if (item.type === 'log') {
const backendLog = item.item as any;
const frontendLog = convertLogToFrontendFormat(backendLog);
// Route logs based on operationId
const frontendLog = convertLogToFrontendFormat(item.item);
if (frontendLog.operationId) {
// Logs WITH operationId → Dashboard
const existingIndex = newDashboardLogs.findIndex(l => l.id === frontendLog.id);
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
if (existingIndex >= 0) {
// Check if log actually changed
const existingLog = newDashboardLogs[existingIndex];
if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) {
newDashboardLogs[existingIndex] = frontendLog;
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
newLogs[existingIndex] = frontendLog;
hasChanges = true;
}
} else {
newDashboardLogs.push(frontendLog);
newLogs.push(frontendLog);
hasChanges = true;
}
}
}
});
// Only return new array if there are changes
if (!hasChanges) {
return prevDashboardLogs;
}
return [...newDashboardLogs].sort(sortLogs);
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
});
setUnifiedContentLogs(prevUnifiedContentLogs => {
const newUnifiedContentLogs: WorkflowLog[] = [...prevUnifiedContentLogs];
// === UPDATE UNIFIED CONTENT LOGS (without operationId) ===
setUnifiedContentLogs(prevLogs => {
const newLogs: WorkflowLog[] = [...prevLogs];
let hasChanges = false;
timeline.forEach((item) => {
if (item.type === 'log') {
const backendLog = item.item as any;
const frontendLog = convertLogToFrontendFormat(backendLog);
// Route logs based on operationId
const frontendLog = convertLogToFrontendFormat(item.item);
if (!frontendLog.operationId) {
// Logs WITHOUT operationId → Unified Content Area
const existingIndex = newUnifiedContentLogs.findIndex(l => l.id === frontendLog.id);
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
if (existingIndex >= 0) {
// Check if log actually changed
const existingLog = newUnifiedContentLogs[existingIndex];
if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) {
newUnifiedContentLogs[existingIndex] = frontendLog;
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
newLogs[existingIndex] = frontendLog;
hasChanges = true;
}
} else {
newUnifiedContentLogs.push(frontendLog);
newLogs.push(frontendLog);
hasChanges = true;
}
}
}
});
// Only return new array if there are changes
if (!hasChanges) {
return prevUnifiedContentLogs;
}
return [...newUnifiedContentLogs].sort(sortLogs);
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
});
// Update combined logs for backward compatibility (using functional update)
// === UPDATE COMBINED LOGS ===
setLogs(prevLogs => {
const allLogs: WorkflowLog[] = [...prevLogs];
timeline.forEach((item) => {
if (item.type === 'log') {
const backendLog = item.item as any;
const frontendLog = convertLogToFrontendFormat(backendLog);
const frontendLog = convertLogToFrontendFormat(item.item);
const existingIndex = allLogs.findIndex(l => l.id === frontendLog.id);
if (existingIndex >= 0) {
allLogs[existingIndex] = frontendLog;
@ -272,47 +287,36 @@ export function useWorkflowLifecycle() {
return [...allLogs].sort(sortLogs);
});
// Process stats - aggregate only NEW stat entries (avoid double-counting)
// === PROCESS STATS ===
const statsItems = timeline.filter(item => item.type === 'stat');
if (statsItems.length > 0) {
let hasNewStats = false;
statsItems.forEach(statItem => {
const statData = statItem.item || statItem;
const statId = statData?.id || (statItem as any).id;
const statData = statItem.item;
const statId = statData?.id;
// Skip if already processed
if (statId && processedStatIdsRef.current.has(statId)) {
return;
return; // Skip already processed
}
if (statData) {
hasNewStats = true;
// Mark as processed
if (statId) {
processedStatIdsRef.current.add(statId);
}
// Add to cumulative stats
if (statData.priceUsd !== undefined && statData.priceUsd !== null) {
cumulativeStatsRef.current.priceUsd += statData.priceUsd;
}
if (statData.processingTime !== undefined && statData.processingTime !== null) {
cumulativeStatsRef.current.processingTime += statData.processingTime;
}
if (statData.bytesSent !== undefined && statData.bytesSent !== null) {
cumulativeStatsRef.current.bytesSent += statData.bytesSent;
}
if (statData.bytesReceived !== undefined && statData.bytesReceived !== null) {
cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
}
// Accumulate stats
const price = statData.priceCHF ?? statData.priceUsd ?? 0;
if (price > 0) cumulativeStatsRef.current.priceUsd += price;
if (statData.processingTime) cumulativeStatsRef.current.processingTime += statData.processingTime;
if (statData.bytesSent) cumulativeStatsRef.current.bytesSent += statData.bytesSent;
if (statData.bytesReceived) cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
}
});
// Update state with cumulative totals
if (hasNewStats || (cumulativeStatsRef.current.bytesSent > 0 || cumulativeStatsRef.current.bytesReceived > 0 ||
cumulativeStatsRef.current.processingTime > 0 || cumulativeStatsRef.current.priceUsd > 0)) {
if (hasNewStats) {
setLatestStats({
priceUsd: cumulativeStatsRef.current.priceUsd,
processingTime: cumulativeStatsRef.current.processingTime,
@ -323,10 +327,9 @@ export function useWorkflowLifecycle() {
}
}, [convertLogToFrontendFormat]);
// Poll workflow data using unified chat data endpoint
// === POLLING FUNCTION ===
const pollWorkflowData = useCallback(async (id: string) => {
try {
// Determine afterTimestamp for incremental polling
const afterTimestamp = lastRenderedTimestampRef.current || undefined;
// Fetch workflow status
@ -334,213 +337,140 @@ export function useWorkflowLifecycle() {
if (workflowData) {
const status = workflowData.status || 'idle';
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
const round = workflowData.currentRound;
updateWorkflowStatus(status);
setCurrentRound(round);
}
if (round !== undefined) setCurrentRound(round);
// Fetch unified chat data
const chatData = await fetchChatData(request, id, afterTimestamp);
console.log('📊 Processed chat data:', {
messagesCount: chatData.messages?.length || 0,
logsCount: chatData.logs?.length || 0,
statsCount: chatData.stats?.length || 0,
afterTimestamp: afterTimestamp
});
// If we got empty results and we're using afterTimestamp, the backend might be filtering incorrectly
// Reset timestamp to null so next poll fetches all items (but only if we have existing data)
const hasNoNewData = (chatData.messages?.length || 0) === 0 &&
(chatData.logs?.length || 0) === 0 &&
(chatData.stats?.length || 0) === 0;
// Only reset if we're using afterTimestamp and got empty results
// This handles cases where backend filtering might miss items due to timestamp precision issues
if (hasNoNewData && afterTimestamp !== undefined && lastRenderedTimestampRef.current !== null) {
console.warn('⚠️ Got empty results with afterTimestamp, resetting timestamp for next poll');
// Don't reset immediately - let this poll complete, next poll will fetch all
lastRenderedTimestampRef.current = null;
}
// Process unified chat data
processUnifiedChatData(chatData);
// Determine if polling should continue
const currentStatus = statusRef.current;
// Stop polling immediately for failed or stopped workflows
// For completed workflows, allow grace period (handled by useEffect)
if (currentStatus === 'failed' || currentStatus === 'stopped') {
// === STATE MACHINE: Check if polling should stop based on status ===
if (status === 'stopped' || status === 'failed') {
console.log(`🛑 Workflow ${status} - stopping polling immediately`);
pollingControllerRef.current.stopPolling();
return;
}
// Continue polling for 'running' status
// For 'completed' status, continue if within grace period (handled by useEffect)
// Polling will be stopped by the useEffect when grace period expires or status changes to failed/stopped
} catch (error: any) {
// Handle rate limiting (429 errors)
if (error?.status === 429 || error?.response?.status === 429) {
throw error; // Let polling controller handle rate limit backoff
}
console.error('Error polling workflow data:', error);
// Don't throw for other errors - allow polling to continue with backoff
}
}, [request, updateWorkflowStatus, processUnifiedChatData]);
// Load initial workflow data (non-polling)
const _loadWorkflowData = useCallback(async (id: string) => {
try {
const workflowData = await fetchWorkflowApi(request, id).catch(() => null);
if (!workflowData) {
setMessages([]);
setLogs([]);
setDashboardLogs([]);
setUnifiedContentLogs([]);
setLatestStats(null);
// Reset stats tracking
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
return;
}
const messagesData = Array.isArray(workflowData.messages) ? workflowData.messages : [];
const logsData = Array.isArray(workflowData.logs) ? workflowData.logs : [];
const status = workflowData.status || 'idle';
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
// Fetch chat data
const chatData = await fetchChatData(request, instanceId, id, afterTimestamp);
updateWorkflowStatus(status);
setCurrentRound(round);
// Always fetch unified chat data to get all messages and logs
// Reset lastRenderedTimestamp to fetch all historical data
lastRenderedTimestampRef.current = null;
try {
const chatData = await fetchChatData(request, id, undefined);
console.log('📥 loadWorkflowData: Fetched unified chat data:', {
messagesCount: chatData.messages?.length || 0,
logsCount: chatData.logs?.length || 0
console.log('📊 Polled chat data:', {
messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0,
stats: chatData.stats?.length || 0,
afterTimestamp
});
// Process data (this will detect "last" message and stop polling if found)
processUnifiedChatData(chatData);
} catch (error) {
console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error);
// Fallback to workflowData if unified chat data fails
if (messagesData.length > 0) {
setMessages([...messagesData].sort(sortMessages));
console.error('❌ Polling error:', error);
}
}, [request, instanceId, updateWorkflowStatus, processUnifiedChatData]);
// Process logs and separate by operationId
const dashboardLogsList: WorkflowLog[] = [];
const unifiedContentLogsList: WorkflowLog[] = [];
logsData.forEach((log: any) => {
const frontendLog = convertLogToFrontendFormat(log);
if (frontendLog.operationId) {
dashboardLogsList.push(frontendLog);
} else {
unifiedContentLogsList.push(frontendLog);
}
});
setDashboardLogs(dashboardLogsList.sort(sortLogs));
setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs));
setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs));
}
} catch (error) {
console.error('Error loading workflow data:', error);
}
}, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]);
void _loadWorkflowData; // Intentionally unused, reserved for future use
// Set up polling when workflow is running
// === POLLING CONTROL EFFECT ===
useEffect(() => {
if (!workflowId) {
// Only clear state if not already cleared to avoid unnecessary updates
setMessages(prev => prev.length > 0 ? [] : prev);
setLogs(prev => prev.length > 0 ? [] : prev);
setDashboardLogs(prev => prev.length > 0 ? [] : prev);
setUnifiedContentLogs(prev => prev.length > 0 ? [] : prev);
setLatestStats(null);
// Reset stats tracking
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
setCurrentRound(prev => prev !== undefined ? undefined : prev);
if (statusChangedFromRunningAt !== null) {
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
}
lastRenderedTimestampRef.current = null;
pollingControllerRef.current.stopPolling();
return;
}
// Continue polling if:
// 1. Workflow is currently running, OR
// 2. Workflow just completed (within last 10 seconds) - grace period to catch final messages
// Stop polling for failed or stopped workflows immediately
// Use ref for statusChangedFromRunningAt to get latest value (state updates are async)
const changedAtRef = statusChangedFromRunningAtRef.current;
const shouldPoll = workflowStatus === 'running' ||
(workflowStatus === 'completed' && changedAtRef !== null && Date.now() - changedAtRef < 10000);
// Skip if we're actively starting a workflow - handleStartWorkflow manages polling
if (isStartingWorkflowRef.current) {
console.log('📍 Polling decision: Skipping - workflow start in progress');
return;
}
// === STATE MACHINE: Determine if polling should be active ===
// Use ref for immediate value (state may be stale)
const hasLastMessage = hasRenderedLastMessageRef.current;
const shouldPoll =
workflowStatus === 'running' ||
(workflowStatus === 'completed' && !hasLastMessage);
const shouldStopImmediately =
workflowStatus === 'stopped' ||
workflowStatus === 'failed' ||
hasLastMessage;
console.log('📍 Polling decision:', {
workflowStatus,
hasRenderedLastMessage: hasLastMessage,
shouldPoll,
shouldStopImmediately
});
if (shouldPoll) {
// Reset lastRenderedTimestamp for first poll (fetch all historical data)
if (lastRenderedTimestampRef.current === null) {
lastRenderedTimestampRef.current = null; // null means fetch all
}
// Start polling
pollingControllerRef.current.startPolling(workflowId, pollWorkflowData);
} else {
// Stop polling for failed, stopped, or completed (after grace period) workflows
} else if (shouldStopImmediately) {
pollingControllerRef.current.stopPolling();
// Clear the status change timestamp when we stop polling (only if not already null)
if (statusChangedFromRunningAt !== null) {
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
}
}
return () => {
pollingControllerRef.current.stopPolling();
};
}, [workflowStatus, workflowId, pollWorkflowData]);
}, [workflowStatus, workflowId, hasRenderedLastMessage, pollWorkflowData]);
// === START WORKFLOW (Send Button) ===
const handleStartWorkflow = useCallback(async (
workflowData: StartWorkflowRequest,
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
) => {
try {
const result = await startWorkflow(workflowData, options);
// Set flag to prevent useEffect from interfering during start
isStartingWorkflowRef.current = true;
const result = await startWorkflow(instanceId, workflowData, options);
if (result.success && result.data) {
const workflow = result.data as Workflow;
// === STATE MACHINE: New round starts ===
console.log('🚀 Starting workflow:', workflow.id);
// Reset state for new round - MUST update refs BEFORE state
hasRenderedLastMessageRef.current = false;
// Set afterTimestamp to NOW - only poll for new data
lastRenderedTimestampRef.current = Date.now();
// Start polling immediately (before state updates trigger useEffect)
pollingControllerRef.current.startPolling(workflow.id, pollWorkflowData);
// Now update state (will trigger re-renders)
setWorkflowId(workflow.id);
setHasRenderedLastMessage(false);
updateWorkflowStatus(workflow.status || 'running');
// Reset lastRenderedTimestamp for new workflow
lastRenderedTimestampRef.current = null;
// Clear the starting flag after a short delay to allow React to settle
setTimeout(() => {
isStartingWorkflowRef.current = false;
}, 100);
return { success: true, data: result.data };
} else {
isStartingWorkflowRef.current = false;
return { success: false, error: result.error || 'Failed to start workflow' };
}
} catch (error: any) {
isStartingWorkflowRef.current = false;
return { success: false, error: error.message || 'Failed to start workflow' };
}
}, [startWorkflow, updateWorkflowStatus]);
}, [instanceId, startWorkflow, updateWorkflowStatus, pollWorkflowData]);
// === STOP WORKFLOW ===
const handleStopWorkflow = useCallback(async () => {
if (!workflowId) {
return { success: false, error: 'No workflow to stop' };
}
try {
const result = await stopWorkflow(workflowId);
const result = await stopWorkflow(instanceId, workflowId);
if (result.success) {
updateWorkflowStatus('stopped');
pollingControllerRef.current.stopPolling();
return { success: true };
} else {
return { success: false, error: result.error || 'Failed to stop workflow' };
@ -548,32 +478,45 @@ export function useWorkflowLifecycle() {
} catch (error: any) {
return { success: false, error: error.message || 'Failed to stop workflow' };
}
}, [workflowId, stopWorkflow, updateWorkflowStatus]);
}, [instanceId, workflowId, stopWorkflow, updateWorkflowStatus]);
// === RESET WORKFLOW ===
const resetWorkflow = useCallback(() => {
console.log('🔄 Resetting workflow state');
setWorkflowId(null);
prevStatusRef.current = 'idle';
statusRef.current = 'idle';
updateWorkflowStatus('idle');
setCurrentRound(undefined);
setMessages([]);
setLogs([]);
setDashboardLogs([]);
setUnifiedContentLogs([]);
setLatestStats(null);
// Reset stats tracking
// Reset refs
lastRenderedTimestampRef.current = null;
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
lastRenderedTimestampRef.current = null;
hasRenderedLastMessageRef.current = false;
setHasRenderedLastMessage(false);
pollingControllerRef.current.stopPolling();
}, [updateWorkflowStatus]);
// === SELECT/LOAD WORKFLOW ===
const selectWorkflow = useCallback(async (workflowIdToSelect: string) => {
try {
console.log('📥 Loading workflow:', workflowIdToSelect);
// Reset state
setWorkflowId(workflowIdToSelect);
// Reset lastRenderedTimestamp and stats for new workflow selection
lastRenderedTimestampRef.current = null;
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
hasRenderedLastMessageRef.current = false;
setHasRenderedLastMessage(false);
// Fetch workflow data
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect).catch(() => null);
if (!workflowData) {
@ -586,55 +529,63 @@ export function useWorkflowLifecycle() {
return;
}
const messagesData = Array.isArray(workflowData.messages) ? workflowData.messages : [];
const logsData = Array.isArray(workflowData.logs) ? workflowData.logs : [];
const status = workflowData.status || 'idle';
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
const round = workflowData.currentRound;
updateWorkflowStatus(status);
setCurrentRound(round);
if (round !== undefined) setCurrentRound(round);
// Always fetch unified chat data to get all messages and logs (regardless of status)
// This ensures completed workflows also show their logs
// Fetch all chat data (no afterTimestamp = get everything)
try {
const chatData = await fetchChatData(request, workflowIdToSelect, undefined);
console.log('📥 selectWorkflow: Fetched unified chat data:', {
messagesCount: chatData.messages?.length || 0,
logsCount: chatData.logs?.length || 0,
status
const chatData = await fetchChatData(request, instanceId, workflowIdToSelect, undefined);
console.log('📥 Loaded chat data:', {
messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0,
stats: chatData.stats?.length || 0
});
// === STATE MACHINE: Check if last message has status="last" ===
const allMessages = chatData.messages || [];
const sortedMessages = [...allMessages].sort((a, b) => {
const aTime = a.publishedAt || a.timestamp || 0;
const bTime = b.publishedAt || b.timestamp || 0;
return bTime - aTime; // Sort descending (newest first)
});
const lastMessage = sortedMessages[0];
const lastMessageStatus = lastMessage ? (lastMessage as any).status : null;
console.log('📍 Last message status:', lastMessageStatus);
if (lastMessageStatus === 'last') {
// Round is complete - don't start polling
hasRenderedLastMessageRef.current = true;
setHasRenderedLastMessage(true);
console.log('✅ Workflow round complete - no polling needed');
} else if (status === 'running') {
// Workflow is running - polling will start via useEffect
console.log('🔄 Workflow is running - polling will start');
}
// Process the data
processUnifiedChatData(chatData);
} catch (error) {
console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error);
// Fallback to workflowData if unified chat data fails
if (messagesData.length > 0) {
setMessages([...messagesData].sort(sortMessages));
console.warn('⚠️ Failed to fetch chat data:', error);
updateWorkflowStatus('idle');
}
// Process logs and separate by operationId
const dashboardLogsList: WorkflowLog[] = [];
const unifiedContentLogsList: WorkflowLog[] = [];
logsData.forEach((log: any) => {
const frontendLog = convertLogToFrontendFormat(log);
if (frontendLog.operationId) {
dashboardLogsList.push(frontendLog);
} else {
unifiedContentLogsList.push(frontendLog);
}
});
setDashboardLogs(dashboardLogsList.sort(sortLogs));
setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs));
setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs));
}
// If workflow is running, polling will start automatically via useEffect
} catch (error) {
console.error('Error selecting workflow:', error);
console.error('❌ Error selecting workflow:', error);
}
}, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]);
}, [request, instanceId, updateWorkflowStatus, processUnifiedChatData]);
// === EXPOSE STATUS SETTER FOR OPTIMISTIC UPDATES ===
const setWorkflowStatusOptimistic = useCallback((status: string) => {
updateWorkflowStatus(status);
}, [updateWorkflowStatus]);
// === COMPUTED VALUES ===
const isRunning = workflowStatus === 'running';
const isStopping = workflowId ? stoppingWorkflows.has(workflowId) : false;
@ -650,6 +601,7 @@ export function useWorkflowLifecycle() {
dashboardLogs,
unifiedContentLogs,
latestStats,
hasRenderedLastMessage,
startWorkflow: handleStartWorkflow,
stopWorkflow: handleStopWorkflow,
resetWorkflow,

View file

@ -104,7 +104,7 @@ export function useAutomations() {
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'AutomationDefinition');
const perms = await checkPermission('DATA', 'data.automation.AutomationDefinition');
setPermissions(perms);
return perms;
} catch (error: any) {
@ -507,7 +507,7 @@ export function useAutomationTemplates() {
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'AutomationTemplate');
const perms = await checkPermission('DATA', 'data.automation.AutomationTemplate');
setPermissions(perms);
return perms;
} catch (e: any) {

289
src/hooks/useBilling.ts Normal file
View file

@ -0,0 +1,289 @@
/**
* useBilling Hook
*
* Hook für die Verwaltung von Billing-Daten.
* Bietet Zugriff auf Guthaben, Transaktionen, Statistiken und Admin-Funktionen.
*/
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import {
fetchBalances,
fetchBalanceForMandate,
fetchTransactions,
fetchStatistics,
fetchAllowedProviders,
fetchSettingsAdmin,
updateSettingsAdmin,
addCreditAdmin,
fetchAccountsAdmin,
fetchTransactionsAdmin,
fetchUsersForMandateAdmin,
type BillingBalance,
type BillingTransaction,
type BillingSettings,
type BillingSettingsUpdate,
type UsageReport,
type AccountSummary,
type CreditAddRequest,
type MandateUserSummary,
} from '../api/billingApi';
// Re-export types
export type {
BillingBalance,
BillingTransaction,
BillingSettings,
BillingSettingsUpdate,
UsageReport,
AccountSummary,
CreditAddRequest,
MandateUserSummary,
};
export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi';
/**
* Hook for user billing operations
*/
export function useBilling() {
const [balances, setBalances] = useState<BillingBalance[]>([]);
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
const [statistics, setStatistics] = useState<UsageReport | null>(null);
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
const { request, isLoading: loading, error } = useApiRequest();
// Fetch all balances for the user
const loadBalances = useCallback(async () => {
try {
const data = await fetchBalances(request);
setBalances(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading balances:', err);
setBalances([]);
return [];
}
}, [request]);
// Fetch balance for a specific mandate
const loadBalanceForMandate = useCallback(async (mandateId: string) => {
try {
return await fetchBalanceForMandate(request, mandateId);
} catch (err) {
console.error('Error loading balance for mandate:', err);
return null;
}
}, [request]);
// Fetch transactions
const loadTransactions = useCallback(async (limit: number = 50, offset: number = 0) => {
try {
const data = await fetchTransactions(request, limit, offset);
setTransactions(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading transactions:', err);
setTransactions([]);
return [];
}
}, [request]);
// Fetch statistics
const loadStatistics = useCallback(async (
period: 'day' | 'month' | 'year',
year: number,
month?: number
) => {
try {
const data = await fetchStatistics(request, period, year, month);
setStatistics(data);
return data;
} catch (err) {
console.error('Error loading statistics:', err);
setStatistics(null);
return null;
}
}, [request]);
// Fetch allowed providers
const loadAllowedProviders = useCallback(async () => {
try {
const data = await fetchAllowedProviders(request);
setAllowedProviders(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading allowed providers:', err);
setAllowedProviders([]);
return [];
}
}, [request]);
// Initial load
useEffect(() => {
loadBalances();
loadAllowedProviders();
}, []);
return {
balances,
transactions,
statistics,
allowedProviders,
loading,
error,
loadBalances,
loadBalanceForMandate,
loadTransactions,
loadStatistics,
loadAllowedProviders,
refetch: loadBalances,
};
}
/**
* Hook for admin billing operations
*/
export function useBillingAdmin(mandateId?: string) {
const [settings, setSettings] = useState<BillingSettings | null>(null);
const [accounts, setAccounts] = useState<AccountSummary[]>([]);
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
const [users, setUsers] = useState<MandateUserSummary[]>([]);
const { request, isLoading: loading, error } = useApiRequest();
// Fetch settings for a mandate
const loadSettings = useCallback(async (targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
const data = await fetchSettingsAdmin(request, mId);
setSettings(data);
return data;
} catch (err) {
console.error('Error loading billing settings:', err);
setSettings(null);
return null;
}
}, [request, mandateId]);
// Update settings
const saveSettings = useCallback(async (
settingsUpdate: BillingSettingsUpdate,
targetMandateId?: string
) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
setSettings(data);
return data;
} catch (err) {
console.error('Error saving billing settings:', err);
throw err;
}
}, [request, mandateId]);
// Add credit
const addCredit = useCallback(async (
creditRequest: CreditAddRequest,
targetMandateId?: string
) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
const result = await addCreditAdmin(request, mId, creditRequest);
// Reload accounts after adding credit
await loadAccounts(mId);
return result;
} catch (err) {
console.error('Error adding credit:', err);
throw err;
}
}, [request, mandateId]);
// Fetch accounts for a mandate
const loadAccounts = useCallback(async (targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
if (!mId) return [];
try {
const data = await fetchAccountsAdmin(request, mId);
setAccounts(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading accounts:', err);
setAccounts([]);
return [];
}
}, [request, mandateId]);
// Fetch transactions for a mandate
const loadTransactions = useCallback(async (targetMandateId?: string, limit: number = 100) => {
const mId = targetMandateId || mandateId;
if (!mId) return [];
try {
const data = await fetchTransactionsAdmin(request, mId, limit);
setTransactions(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading transactions:', err);
setTransactions([]);
return [];
}
}, [request, mandateId]);
// Fetch users for a mandate
const loadUsers = useCallback(async (targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
if (!mId) return [];
try {
const data = await fetchUsersForMandateAdmin(request, mId);
setUsers(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading users:', err);
setUsers([]);
return [];
}
}, [request, mandateId]);
// Load data when mandateId changes
useEffect(() => {
if (mandateId) {
loadSettings();
loadAccounts();
loadTransactions();
loadUsers();
}
}, [mandateId]);
return {
settings,
accounts,
transactions,
users,
loading,
error,
loadSettings,
saveSettings,
addCredit,
loadAccounts,
loadTransactions,
loadUsers,
refetch: () => {
if (mandateId) {
loadSettings();
loadAccounts();
loadTransactions();
loadUsers();
}
},
};
}
export default useBilling;

View file

@ -256,27 +256,13 @@ export function useChatbot(): ChatbotHookReturn {
return prev;
}
// For user messages, check if we already have a temporary one with same content
// Only replace if it's the temporary message we just created (by ID match)
if (message.role === 'user' && message.message === inputMessageContent) {
// Check if we have the exact temporary message we created
const hasTempMessage = prev.some(m => m.id === tempUserMessageId);
if (hasTempMessage) {
// Replace the temporary message with the real one from backend
// Backend sends the "first" message with the transformed/normalized user prompt
// Replace the temporary optimistic message with it
if (message.status === 'first') {
return prev.map(m =>
m.id === tempUserMessageId ? message : m
);
}
// If no temp message found, check if this is a duplicate of an existing real message
const isDuplicate = prev.some(m =>
m.role === 'user' &&
m.message === inputMessageContent &&
!m.id.startsWith('temp-')
);
if (isDuplicate) {
return prev; // Don't add duplicate
}
}
// For other messages, check for duplicates by role and content (more lenient check)
const isDuplicate = prev.some(m => {

View file

@ -51,6 +51,7 @@ export interface InvitationCreate {
email?: string;
roleIds: string[];
featureInstanceId?: string;
frontendUrl?: string;
expiresInHours?: number;
maxUses?: number;
}
@ -117,6 +118,7 @@ export function useInvitations() {
try {
const params = new URLSearchParams();
params.append('frontendUrl', window.location.origin);
if (fetchOptions?.includeUsed) params.append('includeUsed', 'true');
if (fetchOptions?.includeExpired) params.append('includeExpired', 'true');
if (Object.keys(paginationParams).length > 0) {
@ -160,7 +162,11 @@ export function useInvitations() {
setLoading(true);
setError(null);
try {
const response = await api.post('/api/invitations/', data, {
const payload = {
...data,
frontendUrl: data.frontendUrl || window.location.origin,
};
const response = await api.post('/api/invitations/', payload, {
headers: { 'X-Mandate-Id': mandateId }
});
return { success: true, data: response.data };

View file

@ -524,15 +524,12 @@ export function usePromptOperations() {
}
};
const handlePromptUpdate = async (promptId: string, updateData: { name: string; content: string }, _originalData?: any) => {
const handlePromptUpdate = async (promptId: string, updateData: Record<string, any>, _originalData?: any) => {
setUpdateError(null);
try {
// mandateId wird nicht mehr vom Client gesendet
const requestBody = {
name: updateData.name,
content: updateData.content
};
// Pass all provided fields (supports partial inline updates like isSystem toggle)
const { id, mandateId, _createdBy, _createdAt, _modifiedAt, _permissions, ...requestBody } = updateData;
const updatedPrompt = await updatePromptApi(request, promptId, requestBody);
@ -555,8 +552,8 @@ export function usePromptOperations() {
};
// Generic inline update handler for FormGeneratorTable
const handleInlineUpdate = async (promptId: string, changes: Partial<{ name: string; content: string }>) => {
const result = await handlePromptUpdate(promptId, changes as { name: string; content: string });
const handleInlineUpdate = async (promptId: string, changes: Record<string, any>) => {
const result = await handlePromptUpdate(promptId, changes);
if (!result.success) {
throw new Error(result.error || 'Failed to update');
}

View file

@ -60,8 +60,10 @@ export interface Role {
export interface Mandate {
id: string;
name: string | { [key: string]: string };
label?: string;
code?: string;
language?: string;
isSystem?: boolean;
}
/**
@ -226,7 +228,10 @@ export function useUserMandates() {
}, []);
/**
* Fetch all available roles (global and mandate-specific)
* Fetch available roles for a mandate (mandate-instance roles only).
* Each mandate has its own instances of system roles (admin, user, viewer)
* copied from templates during mandate creation. Only these mandate-bound
* roles should be offered for user assignment - NOT global templates.
*/
const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
try {
@ -238,13 +243,15 @@ export function useUserMandates() {
roles = response.data;
}
// Filter to global roles and roles for this mandate
// Only mandate-instance roles (mandateId matches, no featureInstanceId)
// Global templates (mandateId=null) are NOT assignable to users
if (mandateId) {
return roles.filter(r =>
!r.mandateId || r.mandateId === mandateId
!r.featureInstanceId && r.mandateId === mandateId
);
}
return roles;
// Without mandateId, no roles available (roles are always mandate-specific)
return [];
} catch (err: any) {
console.error('Error fetching roles:', err);
return [];

View file

@ -432,6 +432,7 @@ export function useWorkflowOperations() {
};
const startWorkflow = async (
instanceId: string,
workflowData: StartWorkflowRequest,
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
) => {
@ -439,7 +440,7 @@ export function useWorkflowOperations() {
setStartingWorkflow(true);
try {
const response = await startWorkflowApi(request, workflowData, options);
const response = await startWorkflowApi(request, instanceId, workflowData, options);
return { success: true, data: response };
} catch (error: any) {
const errorMessage = error.message || 'Failed to start workflow';
@ -450,12 +451,12 @@ export function useWorkflowOperations() {
}
};
const stopWorkflow = async (workflowId: string) => {
const stopWorkflow = async (instanceId: string, workflowId: string) => {
setStopError(null);
setStoppingWorkflows(prev => new Set(prev).add(workflowId));
try {
await stopWorkflowApi(request, workflowId);
await stopWorkflowApi(request, instanceId, workflowId);
return { success: true };
} catch (error: any) {
const errorMessage = error.message || 'Failed to stop workflow';

View file

@ -138,8 +138,12 @@
/* Feature Content */
.featureContent {
flex: 1;
overflow: auto;
/* Let child components handle their own scrolling for sticky headers */
overflow: hidden;
padding: 1.5rem;
/* Maintain flex chain for child components */
display: flex;
flex-direction: column;
}
/* Dark Theme */

View file

@ -81,7 +81,8 @@
/* Content */
.content {
flex: 1;
overflow: auto;
/* Let child components handle their own scrolling for sticky headers */
overflow: hidden;
background: var(--bg-primary, #ffffff);
}

View file

@ -3,55 +3,44 @@
*
* System-Übersicht für den User.
* Zeigt alle verfügbaren Feature-Instanzen als Karten an.
* Daten kommen vom Backend via GET /api/navigation.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { useMandates, useFeatureStore } from '../stores/featureStore';
import { getLabel, FEATURE_REGISTRY } from '../types/mandate';
import type { FeatureInstance } from '../types/mandate';
import { FaBriefcase, FaRobot, FaPlay, FaArrowRight } from 'react-icons/fa';
import useNavigation from '../hooks/useNavigation';
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
import { getPageIcon } from '../config/pageRegistry';
import { FaArrowRight } from 'react-icons/fa';
import styles from './Dashboard.module.css';
// =============================================================================
// FEATURE ICONS
// =============================================================================
const FEATURE_ICONS: Record<string, React.ReactNode> = {
trustee: <FaBriefcase size={24} />,
chatbot: <FaRobot size={24} />,
chatworkflow: <FaPlay size={24} />,
};
// =============================================================================
// INSTANCE CARD
// =============================================================================
interface InstanceCardProps {
instance: FeatureInstance;
featureLabel: string;
instance: NavFeatureInstance;
feature: MandateFeature;
mandateLabel: string;
}
const InstanceCard: React.FC<InstanceCardProps> = ({ instance, featureLabel }) => {
const basePath = `/mandates/${instance.mandateId}/${instance.featureCode}/${instance.id}`;
const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature, mandateLabel }) => {
// Ersten verfügbaren View-Pfad vom Backend nehmen
const targetPath = instance.views.length > 0 ? instance.views[0].uiPath : undefined;
// Ersten verfügbaren View finden
const featureConfig = FEATURE_REGISTRY[instance.featureCode];
const firstView = featureConfig?.views?.[0];
const targetPath = firstView ? `${basePath}/${firstView.path}` : basePath;
if (!targetPath) return null;
return (
<Link to={targetPath} className={styles.instanceCard}>
<div className={styles.cardIcon}>
{FEATURE_ICONS[instance.featureCode] || <FaBriefcase size={24} />}
{getPageIcon(feature.uiComponent)}
</div>
<div className={styles.cardContent}>
<div className={styles.cardHeader}>
<span className={styles.featureLabel}>{featureLabel}</span>
<span className={styles.roleBadge}>{instance.userRoles?.join(', ') || '-'}</span>
<span className={styles.featureLabel}>{feature.uiLabel}</span>
</div>
<h3 className={styles.instanceLabel}>{instance.instanceLabel}</h3>
<p className={styles.mandateName}>{instance.mandateName}</p>
<h3 className={styles.instanceLabel}>{instance.uiLabel}</h3>
<p className={styles.mandateName}>{mandateLabel}</p>
</div>
<div className={styles.cardArrow}>
<FaArrowRight />
@ -78,59 +67,80 @@ const EmptyState: React.FC = () => (
// =============================================================================
export const DashboardPage: React.FC = () => {
const mandates = useMandates();
const { hasAnyInstance, getAllInstances } = useFeatureStore();
const { dynamicBlock, loading } = useNavigation();
// Alle Instanzen sammeln für Übersicht
const allInstances = getAllInstances();
// Alle Mandate und deren Features/Instanzen aus der Navigation
const mandates: NavigationMandate[] = dynamicBlock?.mandates || [];
// Gruppiere nach Feature
const instancesByFeature = allInstances.reduce((acc, instance) => {
const featureCode = instance.featureCode;
if (!acc[featureCode]) {
acc[featureCode] = [];
// Gesamtzahl Instanzen und Mandate berechnen
let totalInstances = 0;
const totalMandates = mandates.length;
mandates.forEach(m => m.features.forEach(f => {
totalInstances += f.instances.length;
}));
if (loading) {
return (
<div className={styles.dashboard}>
<header className={styles.header}>
<h1>Übersicht</h1>
<p className={styles.subtitle}>Lade...</p>
</header>
</div>
);
}
acc[featureCode].push(instance);
return acc;
}, {} as Record<string, FeatureInstance[]>);
if (!hasAnyInstance()) {
if (totalInstances === 0) {
return <EmptyState />;
}
// Gruppiere Instanzen nach Feature (über alle Mandate)
const featureGroups: { feature: MandateFeature; instances: { instance: NavFeatureInstance; mandateLabel: string }[] }[] = [];
const featureMap = new Map<string, typeof featureGroups[0]>();
for (const mandate of mandates) {
for (const feature of mandate.features) {
const key = feature.uiComponent;
let group = featureMap.get(key);
if (!group) {
group = { feature, instances: [] };
featureMap.set(key, group);
featureGroups.push(group);
}
for (const instance of feature.instances) {
group.instances.push({ instance, mandateLabel: mandate.uiLabel });
}
}
}
return (
<div className={styles.dashboard}>
<header className={styles.header}>
<h1>Übersicht</h1>
<p className={styles.subtitle}>
Du hast Zugriff auf {allInstances.length} Feature-Instanz{allInstances.length !== 1 ? 'en' : ''}
in {mandates.length} Mandant{mandates.length !== 1 ? 'en' : ''}.
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
</p>
</header>
<main className={styles.content}>
{Object.entries(instancesByFeature).map(([featureCode, instances]) => {
const featureConfig = FEATURE_REGISTRY[featureCode];
const featureLabel = featureConfig ? getLabel(featureConfig.label) : featureCode;
return (
<section key={featureCode} className={styles.featureSection}>
{featureGroups.map(({ feature, instances }) => (
<section key={feature.uiComponent} className={styles.featureSection}>
<h2 className={styles.sectionTitle}>
{FEATURE_ICONS[featureCode]}
<span>{featureLabel}</span>
{getPageIcon(feature.uiComponent)}
<span>{feature.uiLabel}</span>
</h2>
<div className={styles.instanceGrid}>
{instances.map(instance => (
{instances.map(({ instance, mandateLabel }) => (
<InstanceCard
key={instance.id}
instance={instance}
featureLabel={featureLabel}
feature={feature}
mandateLabel={mandateLabel}
/>
))}
</div>
</section>
);
})}
))}
</main>
</div>
);

View file

@ -8,7 +8,7 @@
import React from 'react';
import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
import { getLabel, FEATURE_REGISTRY } from '../types/mandate';
import useNavigation from '../hooks/useNavigation';
// Trustee Views
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
@ -25,6 +25,12 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi
// RealEstate Views
import { RealEstatePekView, RealEstateProjectsView, RealEstateParcelsView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
// Chat Playground Views (reusing existing workflow pages)
import { PlaygroundPage, WorkflowsPage } from './workflows';
// Automation Views (reusing existing workflow pages)
import { AutomationsPage, AutomationTemplatesPage } from './workflows';
import styles from './FeatureView.module.css';
// =============================================================================
@ -103,6 +109,15 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
parcels: RealEstateParcelsView,
'instance-roles': RealEstateInstanceRolesPlaceholder,
},
chatplayground: {
playground: PlaygroundPage,
workflows: WorkflowsPage,
},
automation: {
definitions: AutomationsPage,
templates: AutomationTemplatesPage,
logs: () => <PlaceholderView title="Execution Logs" description="Automatisierungs-Ausführungsprotokolle" />,
},
};
// =============================================================================
@ -114,31 +129,13 @@ interface FeatureViewPageProps {
}
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
const { instance, featureCode, isValid } = useCurrentInstance();
const { instance, featureCode, mandateId, isValid } = useCurrentInstance();
const { dynamicBlock } = useNavigation();
// Berechtigungs-Check
const viewCode = `${featureCode}-${view}`;
const canView = useCanViewFeatureView(viewCode);
// DEBUG: Log permission check for chatbot
if (featureCode === 'chatbot') {
console.log('🔍 [DEBUG] FeatureView Permission Check:', {
featureCode,
view,
viewCode,
instanceId: instance?.id,
instanceLabel: instance?.instanceLabel,
isValid,
canView,
permissions: instance?.permissions,
views: instance?.permissions?.views,
viewKeys: instance?.permissions?.views ? Object.keys(instance.permissions.views) : [],
hasLegacyView: instance?.permissions?.views?.[viewCode],
hasFullObjectKey: instance?.permissions?.views?.[`ui.feature.${featureCode}.${view}`],
hasWildcard: instance?.permissions?.views?.['_all'],
});
}
// Nicht valider Kontext
if (!isValid || !featureCode || !instance) {
return <NotFound />;
@ -160,10 +157,17 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return <NotFound />;
}
// View-Info aus Registry
const featureConfig = FEATURE_REGISTRY[featureCode];
const viewConfig = featureConfig?.views?.find(v => v.code === view);
const viewLabel = viewConfig ? getLabel(viewConfig.label) : view;
// View-Label aus Backend-Navigation ermitteln
let viewLabel = view;
if (dynamicBlock) {
const navMandate = dynamicBlock.mandates.find(m => m.id === mandateId);
const navFeature = navMandate?.features.find(f => f.uiComponent.includes(featureCode));
const navInstance = navFeature?.instances.find(i => i.id === instance.id);
const navView = navInstance?.views.find(v => v.uiComponent.includes(view));
if (navView) {
viewLabel = navView.uiLabel;
}
}
return (
<div className={styles.featureView}>

View file

@ -44,6 +44,7 @@ export const InvitePage: React.FC = () => {
const [accepting, setAccepting] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userExists, setUserExists] = useState<boolean | null>(null);
// Validate token on mount
useEffect(() => {
@ -56,7 +57,6 @@ export const InvitePage: React.FC = () => {
const result = await validateInvitation(token);
setValidation(result);
setValidating(false);
// If invitation is valid but user is not authenticated,
// store the token for later use after login/registration
@ -64,7 +64,24 @@ export const InvitePage: React.FC = () => {
// (e.g., when user opens password reset email in a new tab)
if (result.valid && !isAuthenticated) {
localStorage.setItem(PENDING_INVITATION_KEY, token);
// Check if the target username already has an account
if (result.targetUsername) {
try {
const resp = await fetch(`/api/local/available?username=${encodeURIComponent(result.targetUsername)}`);
if (resp.ok) {
const data = await resp.json();
// available=true means username is free -> user does NOT exist
setUserExists(!data.available);
}
} catch {
// On error, default to showing both options
setUserExists(null);
}
}
}
setValidating(false);
};
validate();
@ -222,7 +239,7 @@ export const InvitePage: React.FC = () => {
);
}
// Not authenticated - show login/register options (NO inline registration form)
// Not authenticated - show appropriate options based on whether user account exists
return (
<div className={styles.container}>
<div className={styles.card}>
@ -254,9 +271,11 @@ export const InvitePage: React.FC = () => {
<div className={styles.authPrompt}>
<p>
{validation.targetUsername
{userExists === true
? `Bitte melden Sie sich als "${validation.targetUsername}" an, um die Einladung anzunehmen.`
: 'Bitte melden Sie sich an, um die Einladung anzunehmen.'}
: userExists === false
? 'Bitte erstellen Sie ein Konto, um die Einladung anzunehmen.'
: 'Bitte melden Sie sich an oder erstellen Sie ein Konto, um die Einladung anzunehmen.'}
</p>
</div>
@ -267,29 +286,48 @@ export const InvitePage: React.FC = () => {
)}
<div className={styles.authActions}>
{userExists === true ? (
<button
className={styles.primaryButton}
onClick={handleLoginRedirect}
>
<FaSignInAlt /> Anmelden
</button>
) : userExists === false ? (
<button
className={styles.primaryButton}
onClick={handleRegisterRedirect}
>
<FaUserPlus /> Konto erstellen
</button>
) : (
<>
<button
className={styles.primaryButton}
onClick={handleLoginRedirect}
>
<FaSignInAlt /> Anmelden
</button>
<div className={styles.divider}>
<span>oder</span>
</div>
<button
className={styles.secondaryButton}
onClick={handleRegisterRedirect}
>
<FaUserPlus /> Neues Konto erstellen
</button>
</>
)}
</div>
<div className={styles.authInfo}>
<p>
Sie können sich mit Ihrem bestehenden Konto anmelden oder ein neues erstellen.
Die Einladung wird automatisch nach der Anmeldung akzeptiert.
{userExists === true
? 'Melden Sie sich mit Ihrem bestehenden Konto an. Die Einladung wird automatisch nach der Anmeldung akzeptiert.'
: userExists === false
? 'Erstellen Sie ein neues Konto. Die Einladung wird automatisch nach der Registrierung akzeptiert.'
: 'Die Einladung wird automatisch nach der Anmeldung akzeptiert.'}
</p>
</div>
</div>

View file

@ -24,6 +24,7 @@ import { FeatureInstanceWizard } from './FeatureInstanceWizard';
import { InstanceHierarchyView } from './InstanceHierarchyView';
function getMandateName(mandate: Mandate): string {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}

View file

@ -6,7 +6,12 @@
.adminPage {
padding: 1.5rem;
min-height: 100%;
/* Fill parent height and enable flex layout for sticky table headers */
height: 100%;
max-height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pageHeader {

View file

@ -310,6 +310,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
// Get mandate name
const getMandateName = (mandate: Mandate) => {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
@ -439,6 +440,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<FormGeneratorTable
data={instances}
columns={columns}
apiEndpoint="/api/features/instances"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -86,7 +86,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
allOptions.push({
mandateId: mandate.id,
instanceId: inst.id,
mandateName: typeof mandate.name === 'string' ? mandate.name : (mandate.name?.de || mandate.name?.en || Object.values(mandate.name || {})[0] || mandate.id),
mandateName: mandate.label || (typeof mandate.name === 'string' ? mandate.name : (mandate.name?.de || mandate.name?.en || Object.values(mandate.name || {})[0] || mandate.id)),
instanceLabel: inst.label || inst.id,
featureCode: inst.featureCode,
combinedKey: `${mandate.id}:${inst.id}`,
@ -528,6 +528,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
<FormGeneratorTable
data={instanceUsers}
columns={columns}
apiEndpoint={selectedInstanceId ? `/api/features/instances/${selectedInstanceId}/users` : undefined}
loading={usersLoading}
pagination={true}
pageSize={25}

View file

@ -353,6 +353,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
<FormGeneratorTable
data={roles}
columns={columns}
apiEndpoint="/api/features/templates/roles"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -226,6 +226,7 @@ export const AdminInvitationsPage: React.FC = () => {
// Get mandate name
const getMandateName = (mandate: Mandate) => {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
@ -349,6 +350,7 @@ export const AdminInvitationsPage: React.FC = () => {
<FormGeneratorTable
data={invitations}
columns={columns}
apiEndpoint="/api/invitations/"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -10,12 +10,15 @@
* - Mandate-specific roles (mandateId=xyz) - editable permissions
*
* Each role can be expanded to show/edit its AccessRules via AccessRulesEditor.
*
* Includes a "Cleanup Duplicates" tool to find and remove duplicate AccessRules.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useMandateRoles, type Role } from '../../hooks/useMandateRoles';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { AccessRulesEditor } from '../../components/AccessRules';
import api from '../../api';
import {
FaUserShield,
FaShieldAlt,
@ -24,10 +27,35 @@ import {
FaChevronRight,
FaGlobe,
FaBuilding,
FaFilter
FaFilter,
FaBroom,
FaTimes,
FaExclamationTriangle,
FaCheckCircle
} from 'react-icons/fa';
import styles from './Admin.module.css';
// Types for cleanup result
interface DuplicateGroup {
roleId: string;
context: string;
item: string;
totalCount: number;
keepId: string;
deleteCount: number;
deleteIds: string[];
}
interface CleanupResult {
dryRun: boolean;
totalRules: number;
uniqueSignatures: number;
duplicateGroups: number;
duplicateRulesToDelete: number;
deletedCount: number;
details: DuplicateGroup[];
}
export const AdminMandateRolePermissionsPage: React.FC = () => {
const {
roles,
@ -41,9 +69,16 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
// State
const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all');
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
// Cleanup state
const [showCleanupModal, setShowCleanupModal] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [cleanupResult, setCleanupResult] = useState<CleanupResult | null>(null);
const [cleanupError, setCleanupError] = useState<string | null>(null);
const [cleanupPhase, setCleanupPhase] = useState<'idle' | 'preview' | 'done'>('idle');
// Load mandates on mount
useEffect(() => {
const loadMandates = async () => {
@ -82,19 +117,24 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
setExpandedRoleId(prev => prev === roleId ? null : roleId);
};
// Check if a role is a template (not bound to a specific mandate)
const _isTemplateRole = (role: Role): boolean => {
return !!role.isSystemRole || !role.mandateId;
};
// Get scope badge
const getScopeBadge = (role: Role) => {
if (role.isSystemRole) {
return (
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
<FaUserShield style={{ marginRight: 4 }} /> System
<FaUserShield style={{ marginRight: 4 }} /> System-Template
</span>
);
}
if (!role.mandateId) {
return (
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
<FaGlobe style={{ marginRight: 4 }} /> Global
<FaGlobe style={{ marginRight: 4 }} /> Template
</span>
);
}
@ -105,11 +145,54 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
);
};
// --- Cleanup functions ---
const _openCleanupModal = useCallback(async () => {
setShowCleanupModal(true);
setCleanupError(null);
setCleanupResult(null);
setCleanupPhase('idle');
setCleanupLoading(true);
try {
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=true');
setCleanupResult(response.data);
setCleanupPhase('preview');
} catch (err: any) {
setCleanupError(err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Duplikate');
} finally {
setCleanupLoading(false);
}
}, []);
const _executeCleanup = useCallback(async () => {
setCleanupLoading(true);
setCleanupError(null);
try {
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=false');
setCleanupResult(response.data);
setCleanupPhase('done');
// Refresh roles after cleanup
if (selectedMandateId) {
fetchRoles(selectedMandateId, { scopeFilter });
}
} catch (err: any) {
setCleanupError(err?.response?.data?.detail || err?.message || 'Fehler beim Bereinigen');
} finally {
setCleanupLoading(false);
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
const _closeCleanupModal = useCallback(() => {
setShowCleanupModal(false);
setCleanupResult(null);
setCleanupError(null);
setCleanupPhase('idle');
}, []);
// Filter options for scope
const scopeOptions = useMemo(() => [
{ value: 'all', label: 'Alle Rollen' },
{ value: 'mandate', label: 'Nur Mandanten-Rollen' },
{ value: 'global', label: 'Nur globale Rollen' },
{ value: 'mandate', label: 'Mandanten-Rollen' },
{ value: 'all', label: 'Alle (inkl. Templates)' },
{ value: 'global', label: 'Nur Templates' },
], []);
if (error) {
@ -140,6 +223,14 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={_openCleanupModal}
disabled={loading}
title="Doppelte Regeln finden und bereinigen"
>
<FaBroom /> Duplikate bereinigen
</button>
<button
className={styles.secondaryButton}
onClick={handleRefresh}
@ -188,7 +279,8 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
<span>
Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.
Alle Rollen-Berechtigungen sind bearbeitbar (System-Rollen-Namen sind geschützt).
<strong> Template-Rollen</strong> sind schreibgeschützt - Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus.
<strong> Mandanten-Rollen</strong> sind direkt bearbeitbar.
</span>
</div>
@ -207,9 +299,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<p>Keine Rollen gefunden</p>
<p className={styles.emptyHint}>
{scopeFilter === 'mandate'
? 'Es gibt noch keine mandantenspezifischen Rollen.'
? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.'
: scopeFilter === 'global'
? 'Es gibt noch keine globalen Rollen.'
? 'Es gibt noch keine Rollen-Templates.'
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
</p>
</div>
@ -242,11 +334,20 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
{/* Expanded Content - AccessRulesEditor */}
{expandedRoleId === role.id && (
<div className={styles.roleContent}>
{_isTemplateRole(role) && (
<div className={styles.infoBox} style={{ marginBottom: '0.75rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaUserShield style={{ marginRight: '0.5rem', color: 'var(--warning-color, #d69e2e)' }} />
<span>
Dies ist eine <strong>Template-Rolle</strong>. Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus.
Bestehende Mandanten-Instanzen werden nicht aktualisiert.
</span>
</div>
)}
<AccessRulesEditor
roleId={role.id}
roleName={role.roleLabel}
isTemplate={false}
readOnly={false} // All AccessRules are editable (access controlled via RBAC)
isTemplate={_isTemplateRole(role)}
readOnly={false}
apiBasePath="/api/rbac"
mandateId={selectedMandateId}
/>
@ -256,6 +357,139 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
))}
</div>
)}
{/* Cleanup Duplicates Modal */}
{showCleanupModal && (
<div className={styles.modalOverlay} onClick={_closeCleanupModal}>
<div className={styles.modal} style={{ maxWidth: '750px' }} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>
<FaBroom style={{ marginRight: '0.5rem' }} />
Doppelte Regeln bereinigen
</h3>
<button className={styles.modalClose} onClick={_closeCleanupModal}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
{/* Loading */}
{cleanupLoading && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{cleanupPhase === 'idle' ? 'Analysiere Duplikate...' : 'Bereinige Duplikate...'}</span>
</div>
)}
{/* Error */}
{cleanupError && (
<div style={{ padding: '1rem', background: '#fed7d7', borderRadius: '6px', color: '#c53030', marginBottom: '1rem' }}>
<FaExclamationTriangle style={{ marginRight: '0.5rem' }} />
{cleanupError}
</div>
)}
{/* Results */}
{cleanupResult && !cleanupLoading && (
<>
{/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1.25rem' }}>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.totalRules}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Regeln total</div>
</div>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.uniqueSignatures}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Eindeutige Regeln</div>
</div>
<div style={{ padding: '0.75rem', background: cleanupResult.duplicateGroups > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateGroups > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: cleanupResult.duplicateGroups > 0 ? '#c53030' : '#2f855a' }}>{cleanupResult.duplicateGroups}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Duplikat-Gruppen</div>
</div>
<div style={{ padding: '0.75rem', background: cleanupResult.duplicateRulesToDelete > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateRulesToDelete > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: cleanupResult.duplicateRulesToDelete > 0 ? '#c53030' : '#2f855a' }}>
{cleanupPhase === 'done' ? cleanupResult.deletedCount : cleanupResult.duplicateRulesToDelete}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
{cleanupPhase === 'done' ? 'Geloescht' : 'Zu loeschen'}
</div>
</div>
</div>
{/* Status Message */}
{cleanupPhase === 'done' && (
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span><strong>{cleanupResult.deletedCount}</strong> doppelte Regeln wurden erfolgreich entfernt.</span>
</div>
)}
{cleanupPhase === 'preview' && cleanupResult.duplicateGroups === 0 && (
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span>Keine Duplikate gefunden. Alles sauber!</span>
</div>
)}
{/* Details Table */}
{cleanupResult.details.length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
<h4 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-secondary)' }}>
Duplikat-Details {cleanupResult.details.length < cleanupResult.duplicateGroups && `(${cleanupResult.details.length} von ${cleanupResult.duplicateGroups})`}
</h4>
<div style={{ maxHeight: '300px', overflowY: 'auto', border: '1px solid var(--border-color)', borderRadius: '6px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', position: 'sticky', top: 0 }}>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Kontext</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Item</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Total</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)', color: '#c53030' }}>Duplikate</th>
</tr>
</thead>
<tbody>
{cleanupResult.details.map((group, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{group.context}
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{group.item}
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center' }}>{group.totalCount}</td>
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center', color: '#c53030', fontWeight: 600 }}>{group.deleteCount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
<div className={styles.modalFooter}>
<button className={styles.secondaryButton} onClick={_closeCleanupModal}>
{cleanupPhase === 'done' ? 'Schliessen' : 'Abbrechen'}
</button>
{cleanupPhase === 'preview' && cleanupResult && cleanupResult.duplicateRulesToDelete > 0 && (
<button
className={styles.dangerButton}
onClick={_executeCleanup}
disabled={cleanupLoading}
>
<FaBroom /> {cleanupResult.duplicateRulesToDelete} Duplikate loeschen
</button>
)}
</div>
</div>
</div>
)}
</div>
);
};

View file

@ -46,7 +46,7 @@ export const AdminMandateRolesPage: React.FC = () => {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all');
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
// Store current filter state for refetch
@ -126,14 +126,14 @@ export const AdminMandateRolesPage: React.FC = () => {
if (value === 'system') {
return (
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
<FaUserShield style={{ marginRight: 4 }} /> System
<FaUserShield style={{ marginRight: 4 }} /> System-Template
</span>
);
}
if (value === 'global') {
return (
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
<FaGlobe style={{ marginRight: 4 }} /> Global
<FaGlobe style={{ marginRight: 4 }} /> Template
</span>
);
}
@ -164,7 +164,7 @@ export const AdminMandateRolesPage: React.FC = () => {
default: 'mandate',
options: [
{ value: 'mandate', label: 'Nur dieser Mandant' },
{ value: 'global', label: 'Global (alle Mandanten)' },
{ value: 'global', label: 'Template (wird bei neuen Mandanten kopiert)' },
]
});
}
@ -285,6 +285,7 @@ export const AdminMandateRolesPage: React.FC = () => {
// Get mandate name
const getMandateName = (mandate: Mandate) => {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
@ -359,9 +360,9 @@ export const AdminMandateRolesPage: React.FC = () => {
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
style={{ minWidth: 150 }}
>
<option value="all">Alle Rollen</option>
<option value="mandate">Nur Mandanten-Rollen</option>
<option value="global">Nur globale Rollen</option>
<option value="mandate">Mandanten-Rollen</option>
<option value="all">Alle (inkl. Templates)</option>
<option value="global">Nur Templates</option>
</select>
</div>
@ -389,9 +390,9 @@ export const AdminMandateRolesPage: React.FC = () => {
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<strong>System-Rollen</strong> (admin, user, viewer) können nicht bearbeitet oder gelöscht werden.
<strong> Globale Rollen</strong> gelten für alle Mandanten.
<strong> Mandanten-Rollen</strong> gelten nur für den ausgewählten Mandanten.
<strong>System-Templates</strong> (admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert.
Templates selbst können nicht gelöscht werden.
<strong> Mandanten-Rollen</strong> gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.
</span>
</div>
)}
@ -416,9 +417,9 @@ export const AdminMandateRolesPage: React.FC = () => {
<h3 className={styles.emptyTitle}>Keine Rollen</h3>
<p className={styles.emptyDescription}>
{scopeFilter === 'mandate'
? 'Es gibt noch keine mandantenspezifischen Rollen.'
? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.'
: scopeFilter === 'global'
? 'Es gibt noch keine globalen Rollen.'
? 'Es gibt noch keine Rollen-Templates.'
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
</p>
<button
@ -433,6 +434,7 @@ export const AdminMandateRolesPage: React.FC = () => {
<FormGeneratorTable
data={roles}
columns={columns}
apiEndpoint="/api/rbac/roles"
loading={loading}
pagination={true}
pageSize={25}
@ -522,7 +524,7 @@ export const AdminMandateRolesPage: React.FC = () => {
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
Geltungsbereich: <strong>{editingRole.mandateId ? 'Mandant-spezifisch' : 'Global'}</strong>
Geltungsbereich: <strong>{editingRole.mandateId ? 'Mandanten-Instanz' : 'Template (global)'}</strong>
{' '}(kann nicht geändert werden)
</span>
</div>

View file

@ -9,7 +9,7 @@ import { useNavigate } from 'react-router-dom';
import { useAdminMandates, type Mandate } from '../../hooks/useMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding, FaUsers } from 'react-icons/fa';
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
import styles from './Admin.module.css';
export const AdminMandatesPage: React.FC = () => {
@ -42,6 +42,11 @@ export const AdminMandatesPage: React.FC = () => {
})) as AttributeDefinition[];
}, [attributes]);
// Create form attributes - exclude isSystem (only set by system, not user)
const createFormAttributes: AttributeDefinition[] = useMemo(() => {
return formAttributes.filter(attr => attr.name !== 'isSystem');
}, [formAttributes]);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingMandate, setEditingMandate] = useState<Mandate | null>(null);
@ -76,7 +81,11 @@ export const AdminMandatesPage: React.FC = () => {
};
// Handle delete (confirmation handled by DeleteActionButton)
// System mandates (isSystem=true) are protected from deletion
const handleDeleteMandate = async (mandate: Mandate) => {
if (mandate.isSystem) {
return; // Safety guard - should not be reachable due to disabled button
}
await handleDelete(mandate.id);
};
@ -153,6 +162,7 @@ export const AdminMandatesPage: React.FC = () => {
<FormGeneratorTable
data={mandates}
columns={columns}
apiEndpoint="/api/mandates/"
loading={loading}
pagination={true}
pageSize={25}
@ -169,6 +179,9 @@ export const AdminMandatesPage: React.FC = () => {
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
disabled: (row: Mandate) => row.isSystem
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
: false
}] : []),
]}
onDelete={handleDeleteMandate}
@ -199,14 +212,14 @@ export const AdminMandatesPage: React.FC = () => {
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
{createFormAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
attributes={createFormAttributes}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
@ -233,6 +246,14 @@ export const AdminMandatesPage: React.FC = () => {
</button>
</div>
<div className={styles.modalContent}>
{editingMandate.isSystem && (
<div className={styles.infoBox} style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
<span>
Dies ist ein <strong>System-Mandant</strong>. Er kann nicht gelöscht werden und der Name sollte nicht geändert werden.
</span>
</div>
)}
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />

View file

@ -188,7 +188,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
{overview.mandates.length === 0 ? (
<p className={styles.emptyHint}>Keine Mandate-Zuordnungen vorhanden.</p>
) : (
<div className={styles.rolesList}>
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{overview.mandates.map(mandate => (
<div key={mandate.id} className={styles.roleCard}>
<div
@ -250,7 +250,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
{overview.roles.length === 0 ? (
<p className={styles.emptyHint}>Keine Rollen zugewiesen.</p>
) : (
<div className={styles.rolesList}>
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{overview.roles.map(role => (
<div key={role.id} className={styles.roleCard}>
<div
@ -590,7 +590,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
) : overview ? (
<>
{/* User Info */}
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<div className={styles.infoBox} style={{ marginBottom: '1rem', flexShrink: 0 }}>
<strong>{overview.user.fullName || overview.user.username}</strong>
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<span>{overview.user.email}</span>
@ -610,7 +610,8 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
gap: '0.5rem',
marginBottom: '1rem',
borderBottom: '1px solid var(--border-color)',
paddingBottom: '0.5rem'
paddingBottom: '0.5rem',
flexShrink: 0
}}>
<button
className={activeTab === 'overview' ? styles.primaryButton : styles.secondaryButton}

View file

@ -248,6 +248,7 @@ export const AdminUserMandatesPage: React.FC = () => {
// Get mandate name
const getMandateName = (mandate: Mandate) => {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
@ -352,6 +353,7 @@ export const AdminUserMandatesPage: React.FC = () => {
<FormGeneratorTable
data={users}
columns={columns}
apiEndpoint={selectedMandateId ? `/api/mandates/${selectedMandateId}/users` : undefined}
loading={loading}
pagination={true}
pageSize={25}

View file

@ -203,6 +203,7 @@ export const AdminUsersPage: React.FC = () => {
<FormGeneratorTable
data={users}
columns={columns}
apiEndpoint="/api/users/"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -43,9 +43,14 @@ export const ConnectionsPage: React.FC = () => {
refetch();
}, []);
// Generate columns from attributes
// Generate columns from attributes - hide internal/redundant fields
const columns = useMemo(() => {
return (attributes || []).map(attr => ({
const hiddenColumns = ['id', 'externalId', 'tokenStatus', 'tokenExpiresAt', 'grantedScopes'];
return (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => {
const col: any = {
key: attr.name,
label: attr.label || attr.name,
type: attr.type as any,
@ -55,7 +60,19 @@ export const ConnectionsPage: React.FC = () => {
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
}));
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
};
// Resolve userId to username via FK
if (attr.name === 'userId') {
col.fkSource = '/api/users/';
col.fkDisplayField = 'username';
col.label = 'User';
}
return col;
});
}, [attributes]);
// Check permissions
@ -258,6 +275,7 @@ export const ConnectionsPage: React.FC = () => {
<FormGeneratorTable
data={connections}
columns={columns}
apiEndpoint="/api/connections/"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -60,9 +60,13 @@ export const FilesPage: React.FC = () => {
refetch();
}, []);
// Generate columns from attributes
// Generate columns from attributes - hide internal fields
const columns = useMemo(() => {
return (attributes || []).map(attr => ({
const hiddenColumns = ['id', 'mandateId', 'featureInstanceId', 'fileHash'];
const cols = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({
key: attr.name,
label: attr.label || attr.name,
type: attr.type as any,
@ -73,6 +77,23 @@ export const FilesPage: React.FC = () => {
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
}));
// Add _createdBy column with FK resolution to show username
cols.push({
key: '_createdBy',
label: 'Created By',
type: 'text' as any,
sortable: true,
filterable: false,
searchable: false,
width: 150,
minWidth: 100,
maxWidth: 250,
fkSource: '/api/users/',
fkDisplayField: 'username',
});
return cols;
}, [attributes]);
// Check permissions
@ -255,6 +276,7 @@ export const FilesPage: React.FC = () => {
<FormGeneratorTable
data={files}
columns={columns}
apiEndpoint="/api/files/list"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -53,9 +53,9 @@ export const PromptsPage: React.FC = () => {
// Generate columns from attributes - exclude ID fields from display
const columns = useMemo(() => {
// Fields to hide in table view
const hiddenColumns = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete'];
const hiddenColumns = ['id', 'mandateId', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions'];
return (attributes || [])
const cols = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({
key: attr.name,
@ -67,7 +67,26 @@ export const PromptsPage: React.FC = () => {
width: attr.name === 'content' ? 300 : attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
}));
// Add _createdBy column with FK resolution to show username
cols.push({
key: '_createdBy',
label: 'Created By',
type: 'text' as any,
sortable: true,
filterable: false,
searchable: false,
width: 150,
minWidth: 100,
maxWidth: 250,
fkSource: '/api/users/',
fkDisplayField: 'username',
});
return cols;
}, [attributes]);
// Check permissions
@ -118,7 +137,7 @@ export const PromptsPage: React.FC = () => {
// Form attributes for create/edit modal
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete'];
const excludedFields = ['id', 'mandateId', 'isSystem', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions'];
return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
@ -189,6 +208,7 @@ export const PromptsPage: React.FC = () => {
<FormGeneratorTable
data={prompts}
columns={columns}
apiEndpoint="/api/prompts"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -0,0 +1,654 @@
/* Billing Pages Styles */
/* ============================================================================
PAGE LAYOUT
============================================================================ */
.billingDashboard {
padding: 1.5rem;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.pageHeader {
margin-bottom: 2rem;
}
.pageHeader h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0 0 0.25rem 0;
}
.subtitle {
font-size: 0.875rem;
color: var(--text-secondary, #888);
margin: 0;
}
/* ============================================================================
SECTIONS
============================================================================ */
.section {
margin-bottom: 2rem;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.sectionTitle {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0 0 1rem 0;
}
.sectionHeader .sectionTitle {
margin-bottom: 0;
}
/* ============================================================================
BALANCE CARDS
============================================================================ */
.balanceGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.balanceCard {
background: var(--surface-color, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 1.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.balanceCard:hover {
border-color: var(--primary-color, #f25843);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.balanceCard.warning {
border-color: #ffc107;
background: rgba(255, 193, 7, 0.1);
}
.balanceHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.mandateName {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0;
}
.billingModel {
font-size: 0.75rem;
color: var(--text-secondary, #888);
background: var(--bg-secondary, #2a2a2a);
padding: 2px 8px;
border-radius: 4px;
}
.balanceAmount {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #e0e0e0);
margin-bottom: 0.5rem;
}
.warningBadge {
display: inline-block;
font-size: 0.75rem;
color: #856404;
background: rgba(255, 193, 7, 0.3);
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
}
/* ============================================================================
KPI CARDS
============================================================================ */
.kpiGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.kpiCard {
background: var(--surface-color, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.kpiLabel {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #888);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.kpiValue {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #e0e0e0);
}
.kpiSubtitle {
font-size: 0.75rem;
color: var(--text-tertiary, #666);
}
/* ============================================================================
CHARTS GRID
============================================================================ */
.chartsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
}
/* ============================================================================
TIME SERIES CHART
============================================================================ */
.timeSeriesChart {
padding: 0.5rem 0;
}
.timeSeriesBars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 200px;
padding-bottom: 24px;
position: relative;
}
.timeSeriesBarWrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.timeSeriesBarOuter {
flex: 1;
width: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
}
.timeSeriesBar {
width: 80%;
max-width: 40px;
background: var(--primary-color, #f25843);
border-radius: 4px 4px 0 0;
min-height: 2px;
transition: height 0.3s ease;
cursor: pointer;
}
.timeSeriesBar:hover {
opacity: 0.8;
}
.timeSeriesLabel {
font-size: 0.6875rem;
color: var(--text-secondary, #888);
margin-top: 4px;
white-space: nowrap;
}
/* ============================================================================
SUMMARY TABLE
============================================================================ */
.summaryTable {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.summaryRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.625rem 0.75rem;
background: var(--bg-secondary, #2a2a2a);
border-radius: 6px;
font-size: 0.875rem;
}
.summaryRow span {
color: var(--text-secondary, #888);
}
.summaryRow strong {
color: var(--text-primary, #e0e0e0);
font-family: monospace;
}
/* ============================================================================
STATISTICS
============================================================================ */
.periodSelector {
display: flex;
gap: 0.5rem;
align-items: center;
}
.select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
background: var(--surface-color, #1e1e1e);
color: var(--text-primary, #e0e0e0);
font-size: 0.875rem;
cursor: pointer;
}
.select:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.statisticsChart {
background: var(--surface-color, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 1.5rem;
}
.totalCost {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
background: var(--bg-secondary, #2a2a2a);
border-radius: 8px;
margin-bottom: 1.5rem;
}
.totalLabel {
font-size: 0.875rem;
color: var(--text-secondary, #888);
margin-bottom: 0.25rem;
}
.totalAmount {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary, #e0e0e0);
}
.chartSection {
margin-bottom: 1.5rem;
}
.chartSection:last-child {
margin-bottom: 0;
}
.chartSection h4 {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #888);
margin: 0 0 1rem 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.barChart {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.barRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.barLabel {
width: 100px;
font-size: 0.875rem;
color: var(--text-primary, #e0e0e0);
text-transform: capitalize;
}
.barContainer {
flex: 1;
height: 24px;
background: var(--bg-secondary, #2a2a2a);
border-radius: 4px;
overflow: hidden;
}
.bar {
height: 100%;
background: var(--primary-color, #f25843);
border-radius: 4px;
transition: width 0.3s ease;
min-width: 4px;
}
.barValue {
width: 100px;
text-align: right;
font-size: 0.875rem;
color: var(--text-secondary, #888);
font-family: monospace;
}
.featureList {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.featureRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: var(--bg-secondary, #2a2a2a);
border-radius: 4px;
}
.featureLabel {
font-size: 0.875rem;
color: var(--text-primary, #e0e0e0);
text-transform: capitalize;
}
.featureValue {
font-size: 0.875rem;
color: var(--text-secondary, #888);
font-family: monospace;
}
/* ============================================================================
TRANSACTIONS
============================================================================ */
.transactionsTable {
width: 100%;
border-collapse: collapse;
}
.transactionsTable th,
.transactionsTable td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color, #333);
}
.transactionsTable th {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #888);
text-transform: uppercase;
letter-spacing: 0.5px;
background: var(--bg-secondary, #2a2a2a);
}
.transactionsTable td {
font-size: 0.875rem;
color: var(--text-primary, #e0e0e0);
}
.transactionType {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.transactionType.credit {
background: rgba(40, 167, 69, 0.1);
color: #28a745;
}
.transactionType.debit {
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
}
.transactionType.adjustment {
background: rgba(23, 162, 184, 0.1);
color: #17a2b8;
}
/* ============================================================================
ADMIN STYLES
============================================================================ */
.adminSection {
background: var(--surface-color, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.adminSection h3 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0 0 1rem 0;
}
.formRow {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.formGroup {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.formGroup label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #888);
}
.input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
background: var(--surface-color, #1e1e1e);
color: var(--text-primary, #e0e0e0);
font-size: 0.875rem;
}
.input:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.accountsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.accountCard {
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
padding: 1rem;
}
.accountCard h4 {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0 0 0.5rem 0;
}
.accountInfo {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
}
.accountInfo span {
color: var(--text-secondary, #888);
}
.accountInfo strong {
color: var(--text-primary, #e0e0e0);
}
/* ============================================================================
BUTTONS
============================================================================ */
.button {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.buttonPrimary {
background: var(--primary-color, #f25843);
color: white;
}
.buttonPrimary:hover {
background: var(--primary-dark, #d94d3a);
}
.buttonPrimary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.buttonSecondary {
background: var(--bg-secondary, #2a2a2a);
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #333);
}
.buttonSecondary:hover {
background: var(--surface-color, #1e1e1e);
}
/* ============================================================================
UTILITY CLASSES
============================================================================ */
.loadingPlaceholder {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-secondary, #888);
font-size: 0.875rem;
}
.noData {
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
color: var(--text-tertiary, #666);
font-size: 0.875rem;
font-style: italic;
}
.errorMessage {
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.successMessage {
background: rgba(40, 167, 69, 0.1);
color: #28a745;
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
margin-bottom: 1rem;
}
/* ============================================================================
RESPONSIVE
============================================================================ */
@media (max-width: 768px) {
.billingDashboard {
padding: 0.75rem;
}
.balanceGrid {
grid-template-columns: 1fr;
}
.sectionHeader {
flex-direction: column;
align-items: flex-start;
}
.periodSelector {
width: 100%;
flex-wrap: wrap;
}
.formRow {
flex-direction: column;
}
.barLabel,
.barValue {
width: 80px;
font-size: 0.75rem;
}
}

View file

@ -0,0 +1,446 @@
/**
* Billing Admin Page
*
* Admin-Seite für Billing-Verwaltung (SysAdmin only).
* - Settings verwalten
* - Guthaben aufladen
* - Konten übersicht
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
import { useAdminMandates } from '../../hooks/useMandates';
import styles from './Billing.module.css';
// ============================================================================
// MANDATE SELECTOR
// ============================================================================
interface MandateSelectorProps {
selectedMandateId: string | null;
onSelect: (mandateId: string) => void;
}
const MandateSelector: React.FC<MandateSelectorProps> = ({ selectedMandateId, onSelect }) => {
const { mandates, loading } = useAdminMandates();
return (
<div className={styles.formGroup}>
<label>Mandant auswählen</label>
<select
className={styles.select}
value={selectedMandateId || ''}
onChange={(e) => onSelect(e.target.value)}
disabled={loading}
>
<option value="">-- Mandant wählen --</option>
{mandates.map((mandate) => (
<option key={mandate.id} value={mandate.id}>
{mandate.name || mandate.id}
</option>
))}
</select>
</div>
);
};
// ============================================================================
// SETTINGS EDITOR
// ============================================================================
interface SettingsEditorProps {
settings: BillingSettings | null;
onSave: (settings: Partial<BillingSettings>) => Promise<void>;
loading: boolean;
}
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
const [formData, setFormData] = useState({
billingModel: settings?.billingModel || 'UNLIMITED',
defaultUserCredit: settings?.defaultUserCredit || 10,
warningThresholdPercent: settings?.warningThresholdPercent || 10,
blockOnZeroBalance: settings?.blockOnZeroBalance ?? true,
notifyOnWarning: settings?.notifyOnWarning ?? true,
});
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
if (settings) {
setFormData({
billingModel: settings.billingModel,
defaultUserCredit: settings.defaultUserCredit,
warningThresholdPercent: settings.warningThresholdPercent,
blockOnZeroBalance: settings.blockOnZeroBalance,
notifyOnWarning: settings.notifyOnWarning,
});
}
}, [settings]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setMessage(null);
try {
await onSave(formData);
setMessage({ type: 'success', text: 'Einstellungen gespeichert!' });
} catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Fehler beim Speichern' });
} finally {
setSaving(false);
}
};
return (
<div className={styles.adminSection}>
<h3>Billing-Einstellungen</h3>
{message && (
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit}>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Abrechnungsmodell</label>
<select
className={styles.select}
value={formData.billingModel}
onChange={(e) => setFormData(prev => ({ ...prev, billingModel: e.target.value as any }))}
>
<option value="UNLIMITED">Unlimited</option>
<option value="PREPAY_MANDATE">Prepaid (Mandant)</option>
<option value="PREPAY_USER">Prepaid (Benutzer)</option>
<option value="CREDIT_POSTPAY">Kredit (Postpay)</option>
</select>
</div>
<div className={styles.formGroup}>
<label>Standard-Guthaben (CHF)</label>
<input
type="number"
className={styles.input}
value={formData.defaultUserCredit}
onChange={(e) => setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))}
min="0"
step="0.01"
/>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Warnschwelle (%)</label>
<input
type="number"
className={styles.input}
value={formData.warningThresholdPercent}
onChange={(e) => setFormData(prev => ({ ...prev, warningThresholdPercent: Number(e.target.value) }))}
min="0"
max="100"
step="1"
/>
</div>
<div className={styles.formGroup}>
<label>&nbsp;</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.blockOnZeroBalance}
onChange={(e) => setFormData(prev => ({ ...prev, blockOnZeroBalance: e.target.checked }))}
/>
Bei Guthaben 0 blockieren
</label>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>&nbsp;</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.notifyOnWarning}
onChange={(e) => setFormData(prev => ({ ...prev, notifyOnWarning: e.target.checked }))}
/>
Bei Warnung benachrichtigen
</label>
</div>
</div>
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || loading}
>
{saving ? 'Speichern...' : 'Einstellungen speichern'}
</button>
</form>
</div>
);
};
// ============================================================================
// CREDIT ADDER
// ============================================================================
interface CreditAdderProps {
settings: BillingSettings | null;
accounts: AccountSummary[];
users: MandateUserSummary[];
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<void>;
}
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [amount, setAmount] = useState<number>(10);
const [description, setDescription] = useState<string>('Manuelles Aufladen');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
// Map accounts by userId for balance lookup
const accountsByUserId = accounts
.filter(acc => acc.accountType === 'USER')
.reduce((map, acc) => {
if (acc.userId) map[acc.userId] = acc;
return map;
}, {} as Record<string, AccountSummary>);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (amount <= 0) {
setMessage({ type: 'error', text: 'Betrag muss positiv sein' });
return;
}
setSaving(true);
setMessage(null);
try {
await onAddCredit(isPrepayUser ? selectedUserId : undefined, amount, description);
setMessage({ type: 'success', text: `${amount} CHF erfolgreich gutgeschrieben!` });
setAmount(10);
setDescription('Manuelles Aufladen');
} catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
} finally {
setSaving(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
return (
<div className={styles.adminSection}>
<h3>Guthaben aufladen</h3>
{message && (
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit}>
{isPrepayUser && (
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Benutzer</label>
<select
className={styles.select}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
required
>
<option value="">-- Benutzer wählen --</option>
{users.map((user) => {
const account = accountsByUserId[user.id];
const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)';
return (
<option key={user.id} value={user.id}>
{user.displayName || user.username || user.id}{balanceInfo}
</option>
);
})}
</select>
</div>
</div>
)}
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Betrag (CHF)</label>
<input
type="number"
className={styles.input}
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
min="0.01"
step="0.01"
required
/>
</div>
<div className={styles.formGroup}>
<label>Beschreibung</label>
<input
type="text"
className={styles.input}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Grund für Gutschrift"
/>
</div>
</div>
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || (isPrepayUser && !selectedUserId)}
>
{saving ? 'Aufladen...' : 'Guthaben aufladen'}
</button>
</form>
</div>
);
};
// ============================================================================
// ACCOUNTS OVERVIEW
// ============================================================================
interface AccountsOverviewProps {
accounts: AccountSummary[];
users: MandateUserSummary[];
loading: boolean;
}
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, loading }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
// Build a lookup map: userId -> display name
const _userNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const user of users) {
const displayName = user.displayName
|| [user.firstName, user.lastName].filter(Boolean).join(' ')
|| user.username
|| user.id;
map.set(user.id, displayName);
}
return map;
}, [users]);
if (loading) {
return <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
}
if (accounts.length === 0) {
return <div className={styles.noData}>Keine Konten vorhanden</div>;
}
return (
<div className={styles.adminSection}>
<h3>Konten</h3>
<div className={styles.accountsGrid}>
{accounts.map((account) => (
<div key={account.id} className={styles.accountCard}>
<h4>{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4>
<div className={styles.accountInfo}>
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
{account.creditLimit && <span>Limit: {formatCurrency(account.creditLimit)}</span>}
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
</div>
</div>
))}
</div>
</div>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingAdmin: React.FC = () => {
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const { settings, accounts, users, loading, loadSettings, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
const handleMandateSelect = (mandateId: string) => {
setSelectedMandateId(mandateId || null);
};
const handleSaveSettings = useCallback(async (settingsUpdate: Partial<BillingSettings>) => {
if (!selectedMandateId) return;
await saveSettings(settingsUpdate);
}, [selectedMandateId, saveSettings]);
const handleAddCredit = useCallback(async (userId: string | undefined, amount: number, description: string) => {
if (!selectedMandateId) return;
await addCredit({ userId, amount, description });
await loadAccounts();
}, [selectedMandateId, addCredit, loadAccounts]);
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Billing Administration</h1>
<p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p>
</header>
<section className={styles.section}>
<MandateSelector
selectedMandateId={selectedMandateId}
onSelect={handleMandateSelect}
/>
</section>
{selectedMandateId && (
<>
<SettingsEditor
settings={settings}
onSave={handleSaveSettings}
loading={loading}
/>
<CreditAdder
settings={settings}
accounts={accounts}
users={users}
onAddCredit={handleAddCredit}
/>
<AccountsOverview
accounts={accounts}
users={users}
loading={loading}
/>
</>
)}
{!selectedMandateId && (
<div className={styles.noData}>
Bitte wählen Sie einen Mandanten aus.
</div>
)}
</div>
);
};
export default BillingAdmin;

View file

@ -0,0 +1,272 @@
/**
* Billing Dashboard Page
*
* Zeigt Guthaben, Statistiken und Transaktionen für den Benutzer.
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling';
import { BillingNav } from './BillingNav';
import styles from './Billing.module.css';
// ============================================================================
// BALANCE CARD COMPONENT
// ============================================================================
interface BalanceCardProps {
balance: BillingBalance;
onClick?: () => void;
}
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const getBillingModelLabel = (model: string) => {
switch (model) {
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
case 'CREDIT_POSTPAY': return 'Kredit';
case 'UNLIMITED': return 'Unlimited';
default: return model;
}
};
return (
<div
className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}
onClick={onClick}
>
<div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
<span className={styles.billingModel}>{getBillingModelLabel(balance.billingModel)}</span>
</div>
<div className={styles.balanceAmount}>
{formatCurrency(balance.balance)}
</div>
{balance.isWarning && (
<div className={styles.warningBadge}>
Niedriges Guthaben
</div>
)}
</div>
);
};
// ============================================================================
// STATISTICS CHART COMPONENT
// ============================================================================
interface StatisticsChartProps {
statistics: UsageReport | null;
loading?: boolean;
}
const StatisticsChart: React.FC<StatisticsChartProps> = ({ statistics, loading }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
if (loading) {
return <div className={styles.loadingPlaceholder}>Lade Statistiken...</div>;
}
if (!statistics) {
return <div className={styles.noData}>Keine Statistiken verfügbar</div>;
}
// Calculate max cost for bar scaling
const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1);
return (
<div className={styles.statisticsChart}>
<div className={styles.totalCost}>
<span className={styles.totalLabel}>Gesamtkosten</span>
<span className={styles.totalAmount}>{formatCurrency(statistics.totalCost)}</span>
</div>
<div className={styles.chartSection}>
<h4>Kosten nach Anbieter</h4>
{Object.entries(statistics.costByProvider).length === 0 ? (
<div className={styles.noData}>Keine Daten</div>
) : (
<div className={styles.barChart}>
{Object.entries(statistics.costByProvider).map(([provider, cost]) => (
<div key={provider} className={styles.barRow}>
<span className={styles.barLabel}>{provider}</span>
<div className={styles.barContainer}>
<div
className={styles.bar}
style={{ width: `${(cost / maxProviderCost) * 100}%` }}
/>
</div>
<span className={styles.barValue}>{formatCurrency(cost)}</span>
</div>
))}
</div>
)}
</div>
<div className={styles.chartSection}>
<h4>Kosten nach Modell</h4>
{Object.entries(statistics.costByModel || {}).length === 0 ? (
<div className={styles.noData}>Keine Daten</div>
) : (
<div className={styles.barChart}>
{Object.entries(statistics.costByModel || {}).map(([model, cost]) => (
<div key={model} className={styles.barRow}>
<span className={styles.barLabel}>{model}</span>
<div className={styles.barContainer}>
<div
className={styles.bar}
style={{ width: `${(cost / maxProviderCost) * 100}%` }}
/>
</div>
<span className={styles.barValue}>{formatCurrency(cost)}</span>
</div>
))}
</div>
)}
</div>
<div className={styles.chartSection}>
<h4>Kosten nach Feature</h4>
{Object.entries(statistics.costByFeature).length === 0 ? (
<div className={styles.noData}>Keine Daten</div>
) : (
<div className={styles.featureList}>
{Object.entries(statistics.costByFeature).map(([feature, cost]) => (
<div key={feature} className={styles.featureRow}>
<span className={styles.featureLabel}>{feature}</span>
<span className={styles.featureValue}>{formatCurrency(cost)}</span>
</div>
))}
</div>
)}
</div>
</div>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingDashboard: React.FC = () => {
const {
balances,
statistics,
loading,
loadBalances,
loadStatistics
} = useBilling();
const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month');
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1);
// Load statistics when period changes
useEffect(() => {
if (selectedPeriod === 'month') {
loadStatistics('month', selectedYear);
} else {
loadStatistics('year', selectedYear);
}
}, [selectedPeriod, selectedYear, loadStatistics]);
// Available years (current and last 2 years)
const availableYears = useMemo(() => {
const current = new Date().getFullYear();
return [current, current - 1, current - 2];
}, []);
// Available months
const availableMonths = [
{ value: 1, label: 'Januar' },
{ value: 2, label: 'Februar' },
{ value: 3, label: 'März' },
{ value: 4, label: 'April' },
{ value: 5, label: 'Mai' },
{ value: 6, label: 'Juni' },
{ value: 7, label: 'Juli' },
{ value: 8, label: 'August' },
{ value: 9, label: 'September' },
{ value: 10, label: 'Oktober' },
{ value: 11, label: 'November' },
{ value: 12, label: 'Dezember' },
];
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Billing</h1>
<p className={styles.subtitle}>Übersicht über Guthaben und Nutzung</p>
</header>
<BillingNav />
{/* Balance Cards */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Guthaben</h2>
{loading ? (
<div className={styles.loadingPlaceholder}>Lade Guthaben...</div>
) : balances.length === 0 ? (
<div className={styles.noData}>Keine Abrechnungskonten vorhanden</div>
) : (
<div className={styles.balanceGrid}>
{balances.map((balance) => (
<BalanceCard key={balance.mandateId} balance={balance} />
))}
</div>
)}
</section>
{/* Statistics */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>Nutzungsstatistik</h2>
<div className={styles.periodSelector}>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value as 'month' | 'year')}
className={styles.select}
>
<option value="month">Monat</option>
<option value="year">Jahr</option>
</select>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(Number(e.target.value))}
className={styles.select}
>
{availableYears.map((year) => (
<option key={year} value={year}>{year}</option>
))}
</select>
{selectedPeriod === 'month' && (
<select
value={selectedMonth}
onChange={(e) => setSelectedMonth(Number(e.target.value))}
className={styles.select}
>
{availableMonths.map((month) => (
<option key={month.value} value={month.value}>{month.label}</option>
))}
</select>
)}
</div>
</div>
<StatisticsChart statistics={statistics} loading={loading} />
</section>
</div>
);
};
export default BillingDashboard;

View file

@ -0,0 +1,495 @@
/**
* BillingDataView
*
* Unified billing page with internal tabs:
* - Tab "Übersicht": Balance cards + Usage summary for the user
* - Tab "Statistik": Dashboard with time-series charts and breakdowns
* - Tab "Transaktionen": Transaction table with FormGeneratorTable
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
import api from '../../api';
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
import { UserTransaction } from '../../api/billingApi';
import styles from './Billing.module.css';
// ============================================================================
// HELPER: Currency formatter
// ============================================================================
const _formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
// ============================================================================
// TYPES
// ============================================================================
interface ViewStatistics {
totalCost: number;
transactionCount: number;
costByProvider: Record<string, number>;
costByModel: Record<string, number>;
costByFeature: Record<string, number>;
costByMandate: Record<string, number>;
timeSeries: Array<{ date: string; cost: number; count: number }>;
}
// ============================================================================
// BALANCE CARD COMPONENT
// ============================================================================
interface BalanceCardProps {
balance: BillingBalance;
}
const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
const _getBillingModelLabel = (model: string) => {
switch (model) {
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
case 'CREDIT_POSTPAY': return 'Kredit';
case 'UNLIMITED': return 'Unlimited';
default: return model;
}
};
return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
<div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
<span className={styles.billingModel}>{_getBillingModelLabel(balance.billingModel)}</span>
</div>
<div className={styles.balanceAmount}>
{_formatCurrency(balance.balance)}
</div>
{balance.isWarning && (
<div className={styles.warningBadge}>
Niedriges Guthaben
</div>
)}
</div>
);
};
// ============================================================================
// TAB NAVIGATION COMPONENT
// ============================================================================
type TabType = 'overview' | 'statistics' | 'transactions';
interface TabNavProps {
activeTab: TabType;
onTabChange: (tab: TabType) => void;
}
const TabNav: React.FC<TabNavProps> = ({ activeTab, onTabChange }) => {
const _navLinkStyle = (isActive: boolean) => ({
padding: '8px 16px',
textDecoration: 'none',
borderRadius: '4px',
backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent',
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
fontWeight: isActive ? 600 : 400,
cursor: 'pointer',
border: 'none',
fontSize: '14px',
});
return (
<nav style={{
display: 'flex',
gap: '8px',
marginBottom: '24px',
borderBottom: '1px solid var(--color-border, #333)',
paddingBottom: '8px'
}}>
<button onClick={() => onTabChange('overview')} style={_navLinkStyle(activeTab === 'overview')}>
Übersicht
</button>
<button onClick={() => onTabChange('statistics')} style={_navLinkStyle(activeTab === 'statistics')}>
Statistik
</button>
<button onClick={() => onTabChange('transactions')} style={_navLinkStyle(activeTab === 'transactions')}>
Transaktionen
</button>
</nav>
);
};
// ============================================================================
// HELPERS: Convert viewStats to ReportSection arrays
// ============================================================================
function _recordToChartData(record: Record<string, number>): ReportChartDataPoint[] {
return Object.entries(record)
.sort((a, b) => b[1] - a[1])
.map(([key, value]) => ({ key: key || '—', value }));
}
function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] {
const topProvider = Object.entries(viewStats.costByProvider).sort((a, b) => b[1] - a[1])[0];
const topModel = Object.entries(viewStats.costByModel || {}).sort((a, b) => b[1] - a[1])[0];
const topFeature = Object.entries(viewStats.costByFeature).sort((a, b) => b[1] - a[1])[0];
return [
{
type: 'kpiGrid',
items: [
{
label: 'Gesamtkosten',
value: _formatCurrency(viewStats.totalCost),
subtitle: `${viewStats.transactionCount} Transaktionen`
},
{
label: 'Anbieter',
value: Object.keys(viewStats.costByProvider).length,
subtitle: topProvider ? `Top: ${topProvider[0]}` : 'Keine Nutzung'
},
{
label: 'Modelle',
value: Object.keys(viewStats.costByModel || {}).length,
subtitle: topModel ? `Top: ${topModel[0]}` : 'Keine Nutzung'
},
{
label: 'Features',
value: Object.keys(viewStats.costByFeature).length,
subtitle: topFeature ? `Top: ${topFeature[0]}` : 'Keine Nutzung'
}
]
},
{
type: 'horizontalBar',
title: 'Kosten nach Anbieter',
data: _recordToChartData(viewStats.costByProvider),
formatValue: _formatCurrency,
span: 'half' as const
},
{
type: 'horizontalBar',
title: 'Kosten nach Modell',
data: _recordToChartData(viewStats.costByModel || {}),
formatValue: _formatCurrency,
span: 'half' as const
},
{
type: 'horizontalBar',
title: 'Kosten nach Feature',
data: _recordToChartData(viewStats.costByFeature),
formatValue: _formatCurrency,
span: 'half' as const
}
];
}
function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
// Convert timeSeries to barChart data
const timeSeriesData: ReportChartDataPoint[] = viewStats.timeSeries.map(ts => ({
key: ts.date,
value: ts.cost
}));
const avgCost = viewStats.transactionCount > 0
? viewStats.totalCost / viewStats.transactionCount
: 0;
return [
{
type: 'barChart',
title: 'Kostenentwicklung',
data: timeSeriesData,
formatValue: _formatCurrency,
span: 'full' as const
},
{
type: 'pieChart',
title: 'Verteilung nach Anbieter',
data: _recordToChartData(viewStats.costByProvider),
formatValue: _formatCurrency,
donut: true,
span: 'half' as const
},
{
type: 'pieChart',
title: 'Verteilung nach Modell',
data: _recordToChartData(viewStats.costByModel || {}),
formatValue: _formatCurrency,
donut: true,
span: 'half' as const
},
{
type: 'pieChart',
title: 'Verteilung nach Feature',
data: _recordToChartData(viewStats.costByFeature),
formatValue: _formatCurrency,
donut: true,
span: 'half' as const
},
{
type: 'horizontalBar',
title: 'Kosten nach Mandant',
data: _recordToChartData(viewStats.costByMandate),
formatValue: _formatCurrency,
span: 'half' as const
},
{
type: 'table',
title: 'Zusammenfassung',
span: 'half' as const,
columns: [
{ key: 'metric', label: 'Kennzahl' },
{ key: 'value', label: 'Wert', align: 'right' as const }
],
rows: [
{ metric: 'Gesamtkosten', value: _formatCurrency(viewStats.totalCost) },
{ metric: 'Transaktionen', value: String(viewStats.transactionCount) },
{ metric: 'Durchschnitt / Transaktion', value: _formatCurrency(avgCost) },
{ metric: 'Anbieter', value: String(Object.keys(viewStats.costByProvider).length) },
{ metric: 'Modelle', value: String(Object.keys(viewStats.costByModel || {}).length) },
{ metric: 'Features', value: String(Object.keys(viewStats.costByFeature).length) },
{ metric: 'Mandanten', value: String(Object.keys(viewStats.costByMandate).length) }
]
}
];
}
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Dashboard state (for Overview tab)
const {
balances,
loading: dashboardLoading,
} = useBilling();
// Statistics state (shared by Overview and Statistics tabs)
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
// Transactions state (for Transactions tab)
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
const [transactionsLoading, setTransactionsLoading] = useState(false);
const [transactionsError, setTransactionsError] = useState<string | null>(null);
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
// Load aggregated statistics from the view/statistics route
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
try {
setStatsLoading(true);
const params: any = { period, year };
if (period === 'day' && month) {
params.month = month;
}
const response = await api.get('/api/billing/view/statistics', { params });
setViewStats(response.data);
} catch (err: any) {
console.error('Failed to load statistics:', err);
setViewStats(null);
} finally {
setStatsLoading(false);
}
}, []);
// Handle filter changes from FormGeneratorReport (user changes period/year/month)
const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => {
const period = filterState.period || 'month';
const year = filterState.year || new Date().getFullYear();
const month = filterState.month;
_loadViewStatistics(period, year, month);
}, [_loadViewStatistics]);
// Initial data load: load statistics when overview or statistics tab becomes active
useEffect(() => {
if (activeTab === 'overview' || activeTab === 'statistics') {
_loadViewStatistics('month', new Date().getFullYear());
}
}, [activeTab, _loadViewStatistics]);
// Load transactions with pagination support
const _loadTransactions = useCallback(async (paginationParams?: any) => {
try {
setTransactionsLoading(true);
setTransactionsError(null);
const params: any = {};
// Only serialize if it's a plain pagination object (not a React event or other non-serializable object)
if (paginationParams && typeof paginationParams === 'object' && 'page' in paginationParams) {
const { page, pageSize, sortBy, sortDirection, search, filters } = paginationParams;
params.pagination = JSON.stringify({ page, pageSize, sortBy, sortDirection, search, filters });
}
const response = await api.get('/api/billing/view/users/transactions', { params });
const data = response.data;
// Handle PaginatedResponse format: { items: [...], pagination: {...} }
if (data && typeof data === 'object' && 'items' in data) {
setTransactions(Array.isArray(data.items) ? data.items : []);
if (data.pagination) {
setTransactionsPagination(data.pagination);
}
} else {
// Backward compatibility: plain array response
setTransactions(Array.isArray(data) ? data : []);
}
} catch (err: any) {
console.error('Failed to load transactions:', err);
setTransactionsError(err.response?.data?.detail || err.message || 'Fehler beim Laden der Transaktionen');
} finally {
setTransactionsLoading(false);
}
}, []);
// Load transactions when switching to transactions tab
useEffect(() => {
if (activeTab === 'transactions') {
_loadTransactions();
}
}, [activeTab, _loadTransactions]);
// hookData for FormGeneratorTable
const transactionsHookData = useMemo(() => ({
refetch: _loadTransactions,
pagination: transactionsPagination ? {
totalPages: transactionsPagination.totalPages,
totalItems: transactionsPagination.totalItems,
} : undefined,
}), [_loadTransactions, transactionsPagination]);
// Table column definitions
const columns: ColumnConfig[] = useMemo(() => [
{ key: 'createdAt', label: 'Datum', type: 'timestamp' as any, sortable: true, width: 160 },
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
{ key: 'userName', label: 'Benutzer', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
{ key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 },
{ key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 },
{ key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 },
{ key: 'aicoreModel', label: 'Modell', type: 'text' as any, sortable: true, filterable: true, width: 150 },
{ key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 },
{ key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 },
], []);
// Build report sections based on current data
const overviewSections = useMemo<ReportSection[]>(() => {
if (!viewStats) return [];
return _buildOverviewSections(viewStats);
}, [viewStats]);
const statisticsSections = useMemo<ReportSection[]>(() => {
if (!viewStats) return [];
return _buildStatisticsSections(viewStats);
}, [viewStats]);
// Period selector config (shared between overview and statistics)
const periodSelectorConfig = useMemo(() => ({
periods: ['month' as const, 'day' as const],
defaultPeriod: 'month' as const,
showYear: true,
showMonth: true,
defaultYear: new Date().getFullYear(),
defaultMonth: new Date().getMonth() + 1
}), []);
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Billing</h1>
<p className={styles.subtitle}>Guthaben, Statistiken und Transaktionen</p>
</header>
<TabNav activeTab={activeTab} onTabChange={setActiveTab} />
{/* ================================================================ */}
{/* Tab: Übersicht (My Overview) */}
{/* ================================================================ */}
{activeTab === 'overview' && (
<>
{/* Balance Cards */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Mein Guthaben</h2>
{dashboardLoading ? (
<div className={styles.loadingPlaceholder}>Lade Guthaben...</div>
) : balances.length === 0 ? (
<div className={styles.noData}>Keine Abrechnungskonten vorhanden</div>
) : (
<div className={styles.balanceGrid}>
{balances.map((balance) => (
<BalanceCard key={balance.mandateId} balance={balance} />
))}
</div>
)}
</section>
{/* Usage Statistics via FormGeneratorReport (no period selector - always full year) */}
<section className={styles.section}>
<FormGeneratorReport
title="Nutzungsübersicht"
loading={statsLoading}
sections={overviewSections}
noDataMessage="Keine Statistiken verfügbar"
currencyCode="CHF"
/>
</section>
</>
)}
{/* ================================================================ */}
{/* Tab: Statistik (Dashboard) */}
{/* ================================================================ */}
{activeTab === 'statistics' && (
<section className={styles.section}>
<FormGeneratorReport
title="Nutzungsstatistik"
subtitle="Detaillierte Analyse der AI-Nutzung"
periodSelector={periodSelectorConfig}
onFilterChange={_handleStatsFilterChange}
loading={statsLoading}
sections={statisticsSections}
noDataMessage="Keine Statistiken verfügbar"
currencyCode="CHF"
/>
</section>
)}
{/* ================================================================ */}
{/* Tab: Transaktionen */}
{/* ================================================================ */}
{activeTab === 'transactions' && (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '500px' }}>
{transactionsError && (
<div className={styles.errorMessage}>
{transactionsError}
</div>
)}
<FormGeneratorTable
data={transactions}
columns={columns}
apiEndpoint="/api/billing/balance"
loading={transactionsLoading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
emptyMessage="Keine Transaktionen vorhanden"
onRefresh={_loadTransactions}
hookData={transactionsHookData}
/>
</div>
)}
</div>
);
};
export default BillingDataView;

View file

@ -0,0 +1,282 @@
/**
* Billing Mandate View Page
*
* Shows mandate-level balances and transactions for SysAdmins.
* Includes filtering by mandate.
*/
import React, { useEffect, useState, useMemo } from 'react';
import { useApiRequest } from '../../hooks/useApi';
import {
fetchMandateViewBalances,
fetchMandateViewTransactions,
type MandateBalance,
type BillingTransaction
} from '../../api/billingApi';
import { BillingNav } from './BillingNav';
import styles from './Billing.module.css';
// ============================================================================
// MANDATE BALANCE TABLE
// ============================================================================
interface MandateBalanceTableProps {
balances: MandateBalance[];
selectedMandateId: string | null;
onSelectMandate: (mandateId: string | null) => void;
}
const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
balances,
selectedMandateId,
onSelectMandate
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const getBillingModelLabel = (model: string) => {
switch (model) {
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
case 'CREDIT_POSTPAY': return 'Kredit';
case 'UNLIMITED': return 'Unlimited';
default: return model;
}
};
return (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Mandant</th>
<th>Billing-Modell</th>
<th>Anzahl Benutzer</th>
<th>Standard-Guthaben</th>
<th style={{ textAlign: 'right' }}>Gesamtguthaben</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{balances.map((balance) => (
<tr
key={balance.mandateId}
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
>
<td>{balance.mandateName || balance.mandateId}</td>
<td>{getBillingModelLabel(balance.billingModel)}</td>
<td>{balance.userCount}</td>
<td>{formatCurrency(balance.defaultUserCredit)}</td>
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td>
<td>
<button
className={`${styles.button} ${styles.buttonSecondary}`}
onClick={() => onSelectMandate(
selectedMandateId === balance.mandateId ? null : balance.mandateId
)}
style={{ padding: '4px 8px', fontSize: '12px' }}
>
{selectedMandateId === balance.mandateId ? 'Alle' : 'Filter'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
// ============================================================================
// TRANSACTION TABLE
// ============================================================================
interface TransactionTableProps {
transactions: BillingTransaction[];
}
const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('de-CH', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const getTypeClass = (type: string) => {
switch (type) {
case 'CREDIT': return styles.credit;
case 'DEBIT': return styles.debit;
case 'ADJUSTMENT': return styles.adjustment;
default: return '';
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'CREDIT': return 'Gutschrift';
case 'DEBIT': return 'Belastung';
case 'ADJUSTMENT': return 'Korrektur';
default: return type;
}
};
return (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Datum</th>
<th>Mandant</th>
<th>Typ</th>
<th>Beschreibung</th>
<th>Anbieter</th>
<th>Modell</th>
<th>Feature</th>
<th style={{ textAlign: 'right' }}>Betrag</th>
</tr>
</thead>
<tbody>
{transactions.map((t) => (
<tr key={t.id}>
<td>{formatDate(t.createdAt)}</td>
<td>{t.mandateName || '-'}</td>
<td>
<span className={`${styles.transactionType} ${getTypeClass(t.transactionType)}`}>
{getTypeLabel(t.transactionType)}
</span>
</td>
<td>{t.description}</td>
<td>{t.aicoreProvider || '-'}</td>
<td>{t.aicoreModel || '-'}</td>
<td>{t.featureCode || '-'}</td>
<td style={{ textAlign: 'right' }}>
{t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingMandateView: React.FC = () => {
const { request, isLoading: loading } = useApiRequest();
const [balances, setBalances] = useState<MandateBalance[]>([]);
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const [limit, setLimit] = useState(100);
// Load data
useEffect(() => {
const loadData = async () => {
try {
const [balanceData, transactionData] = await Promise.all([
fetchMandateViewBalances(request),
fetchMandateViewTransactions(request, limit)
]);
setBalances(Array.isArray(balanceData) ? balanceData : []);
setTransactions(Array.isArray(transactionData) ? transactionData : []);
} catch (err) {
console.error('Error loading mandate view data:', err);
setBalances([]);
setTransactions([]);
}
};
loadData();
}, [request, limit]);
// Filter transactions by selected mandate
const filteredTransactions = useMemo(() => {
if (!selectedMandateId) return transactions;
return transactions.filter(t => t.mandateId === selectedMandateId);
}, [transactions, selectedMandateId]);
const handleLoadMore = () => {
setLimit(prev => prev + 100);
};
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Mandanten-Billing</h1>
<p className={styles.subtitle}>Guthaben und Transaktionen pro Mandant</p>
</header>
<BillingNav />
{/* Mandate Balances */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Mandanten-Guthaben</h2>
{loading && balances.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Daten...</div>
) : balances.length === 0 ? (
<div className={styles.noData}>Keine Mandanten mit Billing-Settings vorhanden</div>
) : (
<MandateBalanceTable
balances={balances}
selectedMandateId={selectedMandateId}
onSelectMandate={setSelectedMandateId}
/>
)}
</section>
{/* Transactions */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>
Transaktionen
{selectedMandateId && (
<span style={{ fontWeight: 'normal', fontSize: '14px', marginLeft: '8px' }}>
(gefiltert nach {balances.find(b => b.mandateId === selectedMandateId)?.mandateName || selectedMandateId})
</span>
)}
</h2>
</div>
{loading && transactions.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
) : filteredTransactions.length === 0 ? (
<div className={styles.noData}>Keine Transaktionen vorhanden</div>
) : (
<>
<TransactionTable transactions={filteredTransactions} />
{transactions.length >= limit && (
<div style={{ textAlign: 'center', marginTop: 'var(--spacing-md)' }}>
<button
className={`${styles.button} ${styles.buttonSecondary}`}
onClick={handleLoadMore}
disabled={loading}
>
{loading ? 'Laden...' : 'Mehr laden'}
</button>
</div>
)}
</>
)}
</section>
</div>
);
};
export default BillingMandateView;

View file

@ -0,0 +1,54 @@
/**
* Billing Navigation Component
*
* Provides navigation between billing views.
* Simplified: Übersicht (Dashboard) + Daten (FormGeneratorTable view)
*/
import React from 'react';
import { NavLink } from 'react-router-dom';
import styles from './Billing.module.css';
export const BillingNav: React.FC = () => {
const navLinkStyle = (isActive: boolean) => ({
padding: '8px 16px',
textDecoration: 'none',
borderRadius: '4px',
backgroundColor: isActive ? 'var(--color-primary)' : 'transparent',
color: isActive ? 'white' : 'var(--color-text)',
fontWeight: isActive ? 600 : 400,
});
return (
<nav className={styles.billingNav} style={{
display: 'flex',
gap: '8px',
marginBottom: '24px',
borderBottom: '1px solid var(--color-border)',
paddingBottom: '8px'
}}>
<NavLink
to="/billing"
end
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.navLinkActive : ''}`
}
style={({ isActive }) => navLinkStyle(isActive)}
>
Übersicht
</NavLink>
<NavLink
to="/billing/transactions"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.navLinkActive : ''}`
}
style={({ isActive }) => navLinkStyle(isActive)}
>
Transaktionen
</NavLink>
</nav>
);
};
export default BillingNav;

View file

@ -0,0 +1,149 @@
/**
* Billing Transactions Page
*
* Zeigt die Transaktionshistorie für den Benutzer.
*/
import React, { useEffect, useState } from 'react';
import { useBilling, type BillingTransaction } from '../../hooks/useBilling';
import { BillingNav } from './BillingNav';
import styles from './Billing.module.css';
// ============================================================================
// TRANSACTION ROW COMPONENT
// ============================================================================
interface TransactionRowProps {
transaction: BillingTransaction;
}
const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('de-CH', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const getTypeClass = (type: string) => {
switch (type) {
case 'CREDIT': return styles.credit;
case 'DEBIT': return styles.debit;
case 'ADJUSTMENT': return styles.adjustment;
default: return '';
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'CREDIT': return 'Gutschrift';
case 'DEBIT': return 'Belastung';
case 'ADJUSTMENT': return 'Korrektur';
default: return type;
}
};
return (
<tr>
<td>{formatDate(transaction.createdAt)}</td>
<td>{transaction.mandateName || '-'}</td>
<td>
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
{getTypeLabel(transaction.transactionType)}
</span>
</td>
<td>{transaction.description}</td>
<td>{transaction.aicoreProvider || '-'}</td>
<td>{transaction.aicoreModel || '-'}</td>
<td>{transaction.featureCode || '-'}</td>
<td style={{ textAlign: 'right' }}>
{transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)}
</td>
</tr>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingTransactions: React.FC = () => {
const { transactions, loading, loadTransactions } = useBilling();
const [limit, setLimit] = useState(50);
useEffect(() => {
loadTransactions(limit);
}, [limit, loadTransactions]);
const handleLoadMore = () => {
setLimit(prev => prev + 50);
};
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Transaktionen</h1>
<p className={styles.subtitle}>Übersicht aller Kontobewegungen</p>
</header>
<BillingNav />
<section className={styles.section}>
{loading && transactions.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
) : transactions.length === 0 ? (
<div className={styles.noData}>Keine Transaktionen vorhanden</div>
) : (
<>
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Datum</th>
<th>Mandant</th>
<th>Typ</th>
<th>Beschreibung</th>
<th>Anbieter</th>
<th>Modell</th>
<th>Feature</th>
<th style={{ textAlign: 'right' }}>Betrag</th>
</tr>
</thead>
<tbody>
{transactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))}
</tbody>
</table>
</div>
{transactions.length >= limit && (
<div style={{ textAlign: 'center', marginTop: 'var(--spacing-md)' }}>
<button
className={`${styles.button} ${styles.buttonSecondary}`}
onClick={handleLoadMore}
disabled={loading}
>
{loading ? 'Laden...' : 'Mehr laden'}
</button>
</div>
)}
</>
)}
</section>
</div>
);
};
export default BillingTransactions;

View file

@ -0,0 +1,378 @@
/**
* Billing User View Page
*
* Shows user-level balances and transactions.
* RBAC-based: Users see only their own data, Admins see all.
* Includes filtering by mandate and user.
*/
import React, { useEffect, useState, useMemo } from 'react';
import { useApiRequest } from '../../hooks/useApi';
import {
fetchUserViewBalances,
fetchUserViewTransactions,
type UserBalance,
type UserTransaction
} from '../../api/billingApi';
import { BillingNav } from './BillingNav';
import styles from './Billing.module.css';
// ============================================================================
// USER BALANCE TABLE
// ============================================================================
interface UserBalanceTableProps {
balances: UserBalance[];
selectedMandateId: string | null;
selectedUserId: string | null;
onSelectMandate: (mandateId: string | null) => void;
onSelectUser: (userId: string | null) => void;
}
const UserBalanceTable: React.FC<UserBalanceTableProps> = ({
balances,
selectedMandateId,
selectedUserId,
onSelectMandate,
onSelectUser
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
// Get unique mandates and users for filter dropdowns
const uniqueMandates = useMemo(() => {
const mandates = new Map<string, string>();
balances.forEach(b => {
if (b.mandateId && !mandates.has(b.mandateId)) {
mandates.set(b.mandateId, b.mandateName || b.mandateId);
}
});
return Array.from(mandates.entries());
}, [balances]);
const uniqueUsers = useMemo(() => {
const users = new Map<string, string>();
balances.forEach(b => {
if (b.userId && !users.has(b.userId)) {
users.set(b.userId, b.userName || b.userId);
}
});
return Array.from(users.entries());
}, [balances]);
// Apply filters
const filteredBalances = useMemo(() => {
let result = balances;
if (selectedMandateId) {
result = result.filter(b => b.mandateId === selectedMandateId);
}
if (selectedUserId) {
result = result.filter(b => b.userId === selectedUserId);
}
return result;
}, [balances, selectedMandateId, selectedUserId]);
return (
<>
{/* Filter Controls */}
<div className={styles.filterControls} style={{ display: 'flex', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', fontWeight: 500 }}>
Mandant:
</label>
<select
value={selectedMandateId || ''}
onChange={(e) => onSelectMandate(e.target.value || null)}
className={styles.select}
>
<option value="">Alle Mandanten</option>
{uniqueMandates.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', fontWeight: 500 }}>
Benutzer:
</label>
<select
value={selectedUserId || ''}
onChange={(e) => onSelectUser(e.target.value || null)}
className={styles.select}
>
<option value="">Alle Benutzer</option>
{uniqueUsers.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
</div>
</div>
{/* Table */}
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Mandant</th>
<th>Benutzer</th>
<th style={{ textAlign: 'right' }}>Guthaben</th>
<th style={{ textAlign: 'right' }}>Warnschwelle</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{filteredBalances.map((balance) => (
<tr
key={balance.accountId}
className={balance.isWarning ? styles.warningRow : ''}
>
<td>{balance.mandateName || balance.mandateId}</td>
<td>{balance.userName || balance.userId}</td>
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.balance)}</td>
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.warningThreshold)}</td>
<td>
{balance.isWarning ? (
<span className={styles.warningBadge} style={{ fontSize: '12px', padding: '2px 6px' }}>
Niedrig
</span>
) : balance.enabled ? (
<span style={{ color: 'var(--color-success)' }}>Aktiv</span>
) : (
<span style={{ color: 'var(--color-error)' }}>Deaktiviert</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
};
// ============================================================================
// USER TRANSACTION TABLE
// ============================================================================
interface UserTransactionTableProps {
transactions: UserTransaction[];
selectedMandateId: string | null;
selectedUserId: string | null;
}
const UserTransactionTable: React.FC<UserTransactionTableProps> = ({
transactions,
selectedMandateId,
selectedUserId
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('de-CH', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const getTypeClass = (type: string) => {
switch (type) {
case 'CREDIT': return styles.credit;
case 'DEBIT': return styles.debit;
case 'ADJUSTMENT': return styles.adjustment;
default: return '';
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'CREDIT': return 'Gutschrift';
case 'DEBIT': return 'Belastung';
case 'ADJUSTMENT': return 'Korrektur';
default: return type;
}
};
// Apply filters
const filteredTransactions = useMemo(() => {
let result = transactions;
if (selectedMandateId) {
result = result.filter(t => t.mandateId === selectedMandateId);
}
if (selectedUserId) {
result = result.filter(t => t.userId === selectedUserId);
}
return result;
}, [transactions, selectedMandateId, selectedUserId]);
return (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Datum</th>
<th>Mandant</th>
<th>Benutzer</th>
<th>Typ</th>
<th>Beschreibung</th>
<th>Anbieter</th>
<th>Modell</th>
<th>Feature</th>
<th style={{ textAlign: 'right' }}>Betrag</th>
</tr>
</thead>
<tbody>
{filteredTransactions.map((t) => (
<tr key={t.id}>
<td>{formatDate(t.createdAt)}</td>
<td>{t.mandateName || '-'}</td>
<td>{t.userName || '-'}</td>
<td>
<span className={`${styles.transactionType} ${getTypeClass(t.transactionType)}`}>
{getTypeLabel(t.transactionType)}
</span>
</td>
<td>{t.description}</td>
<td>{t.aicoreProvider || '-'}</td>
<td>{t.aicoreModel || '-'}</td>
<td>{t.featureCode || '-'}</td>
<td style={{ textAlign: 'right' }}>
{t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingUserView: React.FC = () => {
const { request, isLoading: loading } = useApiRequest();
const [balances, setBalances] = useState<UserBalance[]>([]);
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [limit, setLimit] = useState(100);
// Load data
useEffect(() => {
const loadData = async () => {
try {
const [balanceData, transactionData] = await Promise.all([
fetchUserViewBalances(request),
fetchUserViewTransactions(request, limit)
]);
setBalances(Array.isArray(balanceData) ? balanceData : []);
setTransactions(Array.isArray(transactionData) ? transactionData : []);
} catch (err) {
console.error('Error loading user view data:', err);
setBalances([]);
setTransactions([]);
}
};
loadData();
}, [request, limit]);
const handleLoadMore = () => {
setLimit(prev => prev + 100);
};
// Count filtered transactions for display
const filteredTransactionCount = useMemo(() => {
let result = transactions;
if (selectedMandateId) {
result = result.filter(t => t.mandateId === selectedMandateId);
}
if (selectedUserId) {
result = result.filter(t => t.userId === selectedUserId);
}
return result.length;
}, [transactions, selectedMandateId, selectedUserId]);
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Benutzer-Billing</h1>
<p className={styles.subtitle}>Guthaben und Transaktionen pro Benutzer</p>
</header>
<BillingNav />
{/* User Balances */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Benutzer-Guthaben</h2>
{loading && balances.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Daten...</div>
) : balances.length === 0 ? (
<div className={styles.noData}>Keine Benutzer-Konten vorhanden</div>
) : (
<UserBalanceTable
balances={balances}
selectedMandateId={selectedMandateId}
selectedUserId={selectedUserId}
onSelectMandate={setSelectedMandateId}
onSelectUser={setSelectedUserId}
/>
)}
</section>
{/* Transactions */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>
Transaktionen
{(selectedMandateId || selectedUserId) && (
<span style={{ fontWeight: 'normal', fontSize: '14px', marginLeft: '8px' }}>
({filteredTransactionCount} gefiltert)
</span>
)}
</h2>
</div>
{loading && transactions.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
) : transactions.length === 0 ? (
<div className={styles.noData}>Keine Transaktionen vorhanden</div>
) : (
<>
<UserTransactionTable
transactions={transactions}
selectedMandateId={selectedMandateId}
selectedUserId={selectedUserId}
/>
{transactions.length >= limit && (
<div style={{ textAlign: 'center', marginTop: 'var(--spacing-md)' }}>
<button
className={`${styles.button} ${styles.buttonSecondary}`}
onClick={handleLoadMore}
disabled={loading}
>
{loading ? 'Laden...' : 'Mehr laden'}
</button>
</div>
)}
</>
)}
</section>
</div>
);
};
export default BillingUserView;

View file

@ -0,0 +1,13 @@
/**
* Billing Pages Exports
*/
export { BillingDashboard } from './BillingDashboard';
export { BillingDataView } from './BillingDataView';
export { BillingAdmin } from './BillingAdmin';
export { BillingNav } from './BillingNav';
// Legacy exports (can be removed after migration)
export { BillingTransactions } from './BillingTransactions';
export { BillingMandateView } from './BillingMandateView';
export { BillingUserView } from './BillingUserView';

View file

@ -1,223 +0,0 @@
/* MigratePages.module.css - Styles for migrate-to-feature pages */
.page {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
height: calc(100vh - 4rem);
display: flex;
flex-direction: column;
}
.header {
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 1.75rem;
font-weight: 600;
color: var(--color-text-primary, #1a1a2e);
margin: 0 0 0.5rem 0;
}
.subtitle {
color: var(--color-text-secondary, #6b7280);
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.migrateTag {
display: inline-block;
padding: 0.25rem 0.5rem;
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
font-size: 0.65rem;
font-weight: 700;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
/* Placeholder for migrate pages */
.placeholder {
text-align: center;
padding: 3rem;
background: var(--color-surface, #ffffff);
border: 2px dashed var(--color-border, #e5e7eb);
border-radius: 12px;
max-width: 500px;
}
.placeholderIcon {
font-size: 4rem;
margin-bottom: 1rem;
}
.placeholder h2 {
margin: 0 0 1rem 0;
color: var(--color-text-primary, #1a1a2e);
}
.placeholder p {
color: var(--color-text-secondary, #6b7280);
margin: 0 0 0.5rem 0;
}
.hint {
font-size: 0.875rem;
color: var(--color-text-tertiary, #9ca3af);
margin-top: 1rem !important;
}
/* Chat container for ChatbotPage */
.chatContainer {
flex: 1;
display: flex;
flex-direction: column;
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
.messagesArea {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.emptyChat {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary, #6b7280);
}
.message {
max-width: 70%;
padding: 0.75rem 1rem;
border-radius: 12px;
}
.message.user {
align-self: flex-end;
background: var(--color-primary, #4f46e5);
color: white;
border-bottom-right-radius: 4px;
}
.message.assistant {
align-self: flex-start;
background: var(--color-surface-secondary, #f3f4f6);
color: var(--color-text-primary, #1a1a2e);
border-bottom-left-radius: 4px;
}
.message.system {
align-self: center;
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
font-size: 0.875rem;
}
.messageContent {
word-wrap: break-word;
}
.messageTime {
font-size: 0.7rem;
opacity: 0.7;
margin-top: 0.25rem;
}
/* Typing indicator */
.typing {
display: flex;
gap: 4px;
padding: 0.5rem 0;
}
.typing span {
width: 8px;
height: 8px;
background: var(--color-text-secondary, #6b7280);
border-radius: 50%;
animation: typing 1s infinite;
}
.typing span:nth-child(2) {
animation-delay: 0.2s;
}
.typing span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
}
/* Input area */
.inputArea {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface-secondary, #f9fafb);
}
.chatInput {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 24px;
font-size: 0.875rem;
background: var(--color-surface, #ffffff);
}
.chatInput:focus {
outline: none;
border-color: var(--color-primary, #4f46e5);
box-shadow: 0 0 0 3px var(--color-primary-light, rgba(79, 70, 229, 0.1));
}
.sendButton {
padding: 0.75rem 1.5rem;
background: var(--color-primary, #4f46e5);
color: white;
border: none;
border-radius: 24px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.sendButton:hover:not(:disabled) {
background: var(--color-primary-dark, #4338ca);
}
.sendButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View file

@ -1,39 +0,0 @@
/**
* PekPage
*
* PEK (Projekt-Entwicklungs-Koordination) page - temporary global page.
* TODO: Migrate to feature instance.
*/
import React from 'react';
import styles from './MigratePages.module.css';
export const PekPage: React.FC = () => {
return (
<div className={styles.page}>
<header className={styles.header}>
<h1>PEK</h1>
<p className={styles.subtitle}>
<span className={styles.migrateTag}>MIGRATE TO FEATURE</span>
Projekt-Entwicklungs-Koordination
</p>
</header>
<main className={styles.content}>
<div className={styles.placeholder}>
<div className={styles.placeholderIcon}>📊</div>
<h2>PEK-Modul</h2>
<p>
Dieses Modul wird zu einer Feature-Instanz migriert.
</p>
<p className={styles.hint}>
Nach der Migration wird PEK als Feature pro Mandant verfügbar sein,
mit instanz-spezifischen Daten und Berechtigungen.
</p>
</div>
</main>
</div>
);
};
export default PekPage;

View file

@ -1,39 +0,0 @@
/**
* SpeechPage
*
* Speech recognition and transcription page - temporary global page.
* TODO: Migrate to feature instance.
*/
import React from 'react';
import styles from './MigratePages.module.css';
export const SpeechPage: React.FC = () => {
return (
<div className={styles.page}>
<header className={styles.header}>
<h1>Speech</h1>
<p className={styles.subtitle}>
<span className={styles.migrateTag}>MIGRATE TO FEATURE</span>
Spracherkennung und Transkription
</p>
</header>
<main className={styles.content}>
<div className={styles.placeholder}>
<div className={styles.placeholderIcon}>🎤</div>
<h2>Speech-Modul</h2>
<p>
Dieses Modul wird zu einer Feature-Instanz migriert.
</p>
<p className={styles.hint}>
Nach der Migration wird Speech als Feature pro Mandant verfügbar sein,
mit instanz-spezifischen Transkriptionen und Einstellungen.
</p>
</div>
</main>
</div>
);
};
export default SpeechPage;

View file

@ -1,2 +0,0 @@
export { PekPage } from './PekPage';
export { SpeechPage } from './SpeechPage';

View file

@ -185,6 +185,7 @@ export const RealEstateParcelsView: React.FC = () => {
<FormGeneratorTable
data={parcels}
columns={columns}
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/parcels` : undefined}
loading={loading}
pagination={true}
pageSize={25}

View file

@ -169,6 +169,7 @@ export const RealEstateProjectsView: React.FC = () => {
<FormGeneratorTable
data={projects}
columns={columns}
apiEndpoint={instanceId ? `/api/realestate/${instanceId}/projects` : undefined}
loading={loading}
pagination={true}
pageSize={25}

View file

@ -228,6 +228,7 @@ export const TrusteeDocumentsView: React.FC = () => {
<FormGeneratorTable
data={documents}
columns={columns}
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/documents` : undefined}
loading={loading}
pagination={true}
pageSize={25}

View file

@ -201,6 +201,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
<FormGeneratorTable
data={links}
columns={columns}
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/position-documents` : undefined}
loading={loading}
pagination={true}
pageSize={25}

View file

@ -222,6 +222,7 @@ export const TrusteePositionsView: React.FC = () => {
<FormGeneratorTable
data={positions}
columns={columns}
apiEndpoint={instanceId ? `/api/trustee/${instanceId}/positions` : undefined}
loading={loading}
pagination={true}
pageSize={25}

View file

@ -170,6 +170,7 @@ export const AutomationTemplatesPage: React.FC = () => {
<FormGeneratorTable
data={templates as any[]}
columns={columns}
apiEndpoint="/api/automation-templates"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -125,11 +125,16 @@ export const AutomationsPage: React.FC = () => {
}
}, [executionModal.logs]);
// Generate columns from attributes - exclude ID fields from display
// Generate columns from attributes - exclude internal fields, add enriched display columns
const columns = useMemo(() => {
const hiddenColumns = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'template', 'executionLogs', 'placeholders'];
const hiddenColumns = [
'id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt',
'template', 'executionLogs', 'placeholders',
// Hide enriched fields from attribute list (added manually below)
'_createdByUserName', 'mandateName', 'featureInstanceName',
];
return (attributes || [])
const attrColumns = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({
key: attr.name,
@ -142,6 +147,15 @@ export const AutomationsPage: React.FC = () => {
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
}));
// Add enriched display columns (from backend enrichment)
const enrichedColumns = [
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
{ key: 'featureInstanceName', label: 'Feature-Instanz', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 160, minWidth: 100, maxWidth: 250 },
{ key: '_createdByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
];
return [...attrColumns, ...enrichedColumns];
}, [attributes]);
// Check permissions
@ -426,7 +440,7 @@ export const AutomationsPage: React.FC = () => {
try {
await request({
url: `/api/chat/playground/${executionModal.workflowId}/stop`,
url: `/api/workflows/${executionModal.workflowId}/stop`,
method: 'post',
});
@ -583,6 +597,7 @@ export const AutomationsPage: React.FC = () => {
<FormGeneratorTable
data={automations as any[]}
columns={columns}
apiEndpoint="/api/automations"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -20,11 +20,24 @@
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.headerLeft {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.headerTitleRow {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.pageTitle {
font-size: 1.5rem;
font-weight: 600;
@ -32,6 +45,24 @@
margin: 0;
}
.headerStats {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary);
background-color: var(--bg-secondary);
padding: 0.25rem 0.75rem;
border-radius: 12px;
}
.headerStatItem {
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.pageSubtitle {
font-size: 0.875rem;
color: var(--text-secondary);
@ -372,30 +403,6 @@
border-color: var(--primary-color, #f25843);
}
/* Statistics bar */
.statsBar {
display: flex;
gap: 1.5rem;
padding: 0.5rem 0;
font-size: 0.75rem;
color: var(--text-secondary);
}
.statItem {
display: flex;
align-items: center;
gap: 0.25rem;
}
.statLabel {
color: var(--text-tertiary);
}
.statValue {
font-weight: 500;
color: var(--text-secondary);
}
/* Pending files */
.pendingFiles {
display: flex;

View file

@ -11,9 +11,11 @@ import { useSearchParams } from 'react-router-dom';
import { useDashboardInputForm } from '../../hooks/usePlayground';
import { useResizablePanels } from '../../hooks/useResizablePanels';
import { usePrompts } from '../../hooks/usePrompts';
import { useCurrentInstance } from '../../hooks/useCurrentInstance';
import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useVoiceLanguage, VoiceLanguageSelect, Messages } from '../../components/UiComponents';
import { ProviderMultiSelect } from '../../components/ProviderSelector';
import type { Message } from '../../components/UiComponents/Messages/MessagesTypes';
import api from '../../api';
import styles from './PlaygroundPage.module.css';
@ -23,8 +25,12 @@ export const PlaygroundPage: React.FC = () => {
const [searchParams] = useSearchParams();
const urlWorkflowId = searchParams.get('workflowId');
// Get feature instance context
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
// Main hook for input form and data
const hookData = useDashboardInputForm();
const hookData = useDashboardInputForm(instanceId);
const {
inputValue,
onInputChange,
@ -52,6 +58,8 @@ export const PlaygroundPage: React.FC = () => {
deletingFiles,
previewingFiles,
downloadingFiles,
selectedProviders,
onProvidersChange,
} = hookData;
const { prompts, refetch: refetchPrompts } = usePrompts();
@ -536,9 +544,6 @@ export const PlaygroundPage: React.FC = () => {
);
};
// Debug: Log permission status
console.log('🔐 PlaygroundPage permission check:', { playgroundUIPermission });
// Permission check - also show while loading
if (playgroundUIPermission === false) {
return (
@ -577,8 +582,26 @@ export const PlaygroundPage: React.FC = () => {
{/* Page Header */}
<header className={styles.pageHeader}>
<div>
<div className={styles.headerLeft}>
<div className={styles.headerTitleRow}>
<h1 className={styles.pageTitle}>Chat Playground</h1>
{/* Stats display in header */}
<div className={styles.headerStats}>
<span className={styles.headerStatItem} title="Daten gesendet / empfangen">
{formatBytes(latestStats?.bytesSent || 0)} / {formatBytes(latestStats?.bytesReceived || 0)}
</span>
{(latestStats?.processingTime ?? 0) > 0 && (
<span className={styles.headerStatItem} title="Verarbeitungszeit">
{formatDuration(latestStats?.processingTime || 0)}
</span>
)}
{(latestStats?.priceUsd ?? 0) > 0 && (
<span className={styles.headerStatItem} title="Kosten">
💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)}
</span>
)}
</div>
</div>
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
</div>
<div className={styles.headerControls}>
@ -706,36 +729,6 @@ export const PlaygroundPage: React.FC = () => {
</div>
)}
{/* Stats bar */}
{latestStats && (latestStats.bytesSent || latestStats.bytesReceived || latestStats.processingTime || latestStats.priceUsd) && (
<div className={styles.statsBar}>
{(latestStats.bytesSent !== undefined || latestStats.bytesReceived !== undefined) && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Daten:</span>
<span className={styles.statValue}>
{formatBytes(latestStats.bytesSent || 0)} / {formatBytes(latestStats.bytesReceived || 0)}
</span>
</div>
)}
{latestStats.processingTime !== undefined && latestStats.processingTime > 0 && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Zeit:</span>
<span className={styles.statValue}>
{formatDuration(latestStats.processingTime)}
</span>
</div>
)}
{latestStats.priceUsd !== undefined && latestStats.priceUsd > 0 && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Kosten:</span>
<span className={styles.statValue}>
${latestStats.priceUsd.toFixed(4)}
</span>
</div>
)}
</div>
)}
{/* Input row */}
<div className={styles.inputRow}>
<div className={styles.inputWrapper}>
@ -777,6 +770,12 @@ export const PlaygroundPage: React.FC = () => {
>
<FaPlus />
</button>
<ProviderMultiSelect
selectedProviders={selectedProviders}
onChange={onProvidersChange}
showLabel={false}
excludeByDefault={['privatellm']}
/>
<VoiceLanguageSelect
value={voiceLanguage}
onChange={setVoiceLanguage}

View file

@ -82,9 +82,11 @@ export const WorkflowsPage: React.FC = () => {
}
};
// Handle continue workflow - navigate to playground
// Handle continue workflow - navigate to playground within same feature instance
// Uses relative navigation since WorkflowsPage is rendered under same instance route as playground
const handleContinueWorkflow = (workflow: Workflow) => {
navigate(`/workflows/playground?workflowId=${workflow.id}`);
// Navigate relatively to playground (sibling route under same instance)
navigate(`../playground?workflowId=${workflow.id}`);
};
// Handle edit submit
@ -171,6 +173,7 @@ export const WorkflowsPage: React.FC = () => {
<FormGeneratorTable
data={workflows}
columns={columns}
apiEndpoint="/api/workflows/"
loading={loading}
pagination={true}
pageSize={25}

View file

@ -116,7 +116,8 @@ export interface MandateFeature {
*/
export interface Mandate {
id: string; // mandateId
name: string; // Anzeige-Name
name: string; // Technischer Identifier
label?: string; // Anzeige-Label (fuer FK-Referenzen und UI)
code?: string; // Optionaler Code
features: MandateFeature[];
}
@ -240,6 +241,25 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
]
},
chatplayground: {
code: 'chatplayground',
label: { de: 'Chat Playground', en: 'Chat Playground' },
icon: 'message',
views: [
{ code: 'playground', label: { de: 'Playground', en: 'Playground' }, path: 'playground' },
{ code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
]
},
automation: {
code: 'automation',
label: { de: 'Automatisierung', en: 'Automation' },
icon: 'settings',
views: [
{ code: 'definitions', label: { de: 'Definitionen', en: 'Definitions' }, path: 'definitions' },
{ code: 'templates', label: { de: 'Vorlagen', en: 'Templates' }, path: 'templates' },
{ code: 'logs', label: { de: 'Protokolle', en: 'Logs' }, path: 'logs' },
]
},
};
// =============================================================================