Merge pull request #6 from valueonag/feat/cost-control
Feat/cost control
This commit is contained in:
commit
af9e827efc
79 changed files with 7481 additions and 1273 deletions
346
package-lock.json
generated
346
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
36
src/App.tsx
36
src/App.tsx
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
380
src/api/billingApi.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
24
src/components/FormGenerator/FormGeneratorReport/index.ts
Normal file
24
src/components/FormGenerator/FormGeneratorReport/index.ts
Normal 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';
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,33 +1546,45 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
onPageSizeChange={handlePageSizeChange}
|
||||
supportsBackendPagination={supportsBackendPagination}
|
||||
hookData={hookData}
|
||||
onCsvExport={apiEndpoint ? handleCsvExport : undefined}
|
||||
csvExporting={csvExporting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={`${styles.tableContainer} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}
|
||||
>
|
||||
{/* Loading overlay - shown while loading */}
|
||||
{loading && (
|
||||
<div className={styles.loadingOverlay}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
<p>{t('common.loading', 'Loading...')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 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>
|
||||
|
||||
{/* Empty state - only shown when not loading AND no data */}
|
||||
{!loading && displayData.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<p className={styles.emptyMessage}>{emptyMessage || t('formgen.empty', 'No data available')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<table ref={tableRef} className={styles.table}>
|
||||
{/* Table Container - vertical scroll only */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={styles.tableContainer}
|
||||
>
|
||||
{/* Loading overlay - shown while loading */}
|
||||
{loading && (
|
||||
<div className={styles.loadingOverlay}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
<p>{t('common.loading', 'Loading...')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state - only shown when not loading AND no data */}
|
||||
{!loading && displayData.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<p className={styles.emptyMessage}>{emptyMessage || t('formgen.empty', 'No data available')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<table ref={tableRef} className={styles.table}>
|
||||
<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)}
|
||||
|
|
@ -1704,7 +1898,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,40 +172,49 @@ 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' });
|
||||
}
|
||||
items.push(staticBlockToTreeItem(block));
|
||||
if (block.id === 'admin') {
|
||||
adminItems = [...block.items];
|
||||
} else if (block.items.length > 0) {
|
||||
meineSichtItems.push(...block.items);
|
||||
}
|
||||
} else if (block.type === 'dynamic') {
|
||||
// Dynamic block: features/mandates
|
||||
// Add separator before dynamic block
|
||||
items.push({ type: 'separator' });
|
||||
|
||||
const mandateNodes = dynamicBlockToTreeNodes(block);
|
||||
if (mandateNodes.length > 0) {
|
||||
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();
|
||||
|
||||
// "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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "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;
|
||||
}, [blocks]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
225
src/components/ProviderSelector/ProviderSelector.module.css
Normal file
225
src/components/ProviderSelector/ProviderSelector.module.css
Normal 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;
|
||||
}
|
||||
302
src/components/ProviderSelector/ProviderSelector.tsx
Normal file
302
src/components/ProviderSelector/ProviderSelector.tsx
Normal 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;
|
||||
10
src/components/ProviderSelector/index.ts
Normal file
10
src/components/ProviderSelector/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Provider Selector Component Exports
|
||||
*/
|
||||
|
||||
export {
|
||||
ProviderSelect,
|
||||
ProviderMultiSelect,
|
||||
ProviderBadges
|
||||
} from './ProviderSelector';
|
||||
export { default } from './ProviderSelector';
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
});
|
||||
// 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 (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
|
||||
);
|
||||
|
||||
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) {
|
||||
setOptimisticMessage(null);
|
||||
}
|
||||
} else {
|
||||
if (messageTexts.has(optimisticText)) {
|
||||
setOptimisticMessage(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasFirstMessage) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// === 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// === STATE MACHINE: Handle "last" message ===
|
||||
if (foundLastMessage && !hasRenderedLastMessageRef.current) {
|
||||
console.log('🛑 "last" message detected - stopping polling');
|
||||
hasRenderedLastMessageRef.current = true;
|
||||
setHasRenderedLastMessage(true);
|
||||
pollingControllerRef.current.stopPolling();
|
||||
}
|
||||
|
||||
// === UPDATE MESSAGES STATE ===
|
||||
setMessages(prevMessages => {
|
||||
const newMessages: WorkflowMessage[] = [...prevMessages];
|
||||
let hasChanges = false;
|
||||
let messagesAdded = 0;
|
||||
let messagesUpdated = 0;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'message') {
|
||||
const message = item.item as WorkflowMessage;
|
||||
if (!message || !message.id) return;
|
||||
|
||||
if (!message || !message.id) {
|
||||
console.warn('⚠️ Invalid message in timeline:', message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if message already exists
|
||||
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);
|
||||
|
||||
// === 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch unified chat data
|
||||
const chatData = await fetchChatData(request, id, afterTimestamp);
|
||||
// Fetch chat data
|
||||
const chatData = await fetchChatData(request, instanceId, id, afterTimestamp);
|
||||
|
||||
console.log('📊 Processed chat data:', {
|
||||
messagesCount: chatData.messages?.length || 0,
|
||||
logsCount: chatData.logs?.length || 0,
|
||||
statsCount: chatData.stats?.length || 0,
|
||||
afterTimestamp: afterTimestamp
|
||||
console.log('📊 Polled chat data:', {
|
||||
messages: chatData.messages?.length || 0,
|
||||
logs: chatData.logs?.length || 0,
|
||||
stats: chatData.stats?.length || 0,
|
||||
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
|
||||
// Process data (this will detect "last" message and stop polling if found)
|
||||
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') {
|
||||
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;
|
||||
|
||||
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
|
||||
});
|
||||
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));
|
||||
}
|
||||
|
||||
// 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);
|
||||
console.error('❌ Polling error:', error);
|
||||
}
|
||||
}, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]);
|
||||
void _loadWorkflowData; // Intentionally unused, reserved for future use
|
||||
}, [request, instanceId, updateWorkflowStatus, processUnifiedChatData]);
|
||||
|
||||
// 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,58 +529,66 @@ 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
|
||||
});
|
||||
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));
|
||||
|
||||
// === 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 logs and separate by operationId
|
||||
const dashboardLogsList: WorkflowLog[] = [];
|
||||
const unifiedContentLogsList: WorkflowLog[] = [];
|
||||
// Process the data
|
||||
processUnifiedChatData(chatData);
|
||||
|
||||
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.warn('⚠️ Failed to fetch chat data:', error);
|
||||
updateWorkflowStatus('idle');
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
workflowStatus,
|
||||
|
|
@ -650,6 +601,7 @@ export function useWorkflowLifecycle() {
|
|||
dashboardLogs,
|
||||
unifiedContentLogs,
|
||||
latestStats,
|
||||
hasRenderedLastMessage,
|
||||
startWorkflow: handleStartWorkflow,
|
||||
stopWorkflow: handleStopWorkflow,
|
||||
resetWorkflow,
|
||||
|
|
|
|||
|
|
@ -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
289
src/hooks/useBilling.ts
Normal 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;
|
||||
|
|
@ -256,26 +256,12 @@ 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
|
||||
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-')
|
||||
// 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 (isDuplicate) {
|
||||
return prev; // Don't add duplicate
|
||||
}
|
||||
}
|
||||
|
||||
// For other messages, check for duplicates by role and content (more lenient check)
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
// Ersten verfügbaren View finden
|
||||
const featureConfig = FEATURE_REGISTRY[instance.featureCode];
|
||||
const firstView = featureConfig?.views?.[0];
|
||||
const targetPath = firstView ? `${basePath}/${firstView.path}` : basePath;
|
||||
|
||||
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;
|
||||
|
||||
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();
|
||||
|
||||
// Alle Instanzen sammeln für Übersicht
|
||||
const allInstances = getAllInstances();
|
||||
|
||||
// Gruppiere nach Feature
|
||||
const instancesByFeature = allInstances.reduce((acc, instance) => {
|
||||
const featureCode = instance.featureCode;
|
||||
if (!acc[featureCode]) {
|
||||
acc[featureCode] = [];
|
||||
}
|
||||
acc[featureCode].push(instance);
|
||||
return acc;
|
||||
}, {} as Record<string, FeatureInstance[]>);
|
||||
|
||||
if (!hasAnyInstance()) {
|
||||
const { dynamicBlock, loading } = useNavigation();
|
||||
|
||||
// Alle Mandate und deren Features/Instanzen aus der Navigation
|
||||
const mandates: NavigationMandate[] = dynamicBlock?.mandates || [];
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{FEATURE_ICONS[featureCode]}
|
||||
<span>{featureLabel}</span>
|
||||
</h2>
|
||||
<div className={styles.instanceGrid}>
|
||||
{instances.map(instance => (
|
||||
<InstanceCard
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
featureLabel={featureLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{featureGroups.map(({ feature, instances }) => (
|
||||
<section key={feature.uiComponent} className={styles.featureSection}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{getPageIcon(feature.uiComponent)}
|
||||
<span>{feature.uiLabel}</span>
|
||||
</h2>
|
||||
<div className={styles.instanceGrid}>
|
||||
{instances.map(({ instance, mandateLabel }) => (
|
||||
<InstanceCard
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
feature={feature}
|
||||
mandateLabel={mandateLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
<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>
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ export const AdminUsersPage: React.FC = () => {
|
|||
<FormGeneratorTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
apiEndpoint="/api/users/"
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
|
|
|
|||
|
|
@ -43,19 +43,36 @@ 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 => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
}));
|
||||
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,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
fkSource: (attr as any).fkSource,
|
||||
fkDisplayField: (attr as any).fkDisplayField,
|
||||
};
|
||||
|
||||
// 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}
|
||||
|
|
|
|||
|
|
@ -60,19 +60,40 @@ export const FilesPage: React.FC = () => {
|
|||
refetch();
|
||||
}, []);
|
||||
|
||||
// Generate columns from attributes
|
||||
// Generate columns from attributes - hide internal fields
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
}));
|
||||
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,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
654
src/pages/billing/Billing.module.css
Normal file
654
src/pages/billing/Billing.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
446
src/pages/billing/BillingAdmin.tsx
Normal file
446
src/pages/billing/BillingAdmin.tsx
Normal 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> </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> </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;
|
||||
272
src/pages/billing/BillingDashboard.tsx
Normal file
272
src/pages/billing/BillingDashboard.tsx
Normal 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;
|
||||
495
src/pages/billing/BillingDataView.tsx
Normal file
495
src/pages/billing/BillingDataView.tsx
Normal 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;
|
||||
282
src/pages/billing/BillingMandateView.tsx
Normal file
282
src/pages/billing/BillingMandateView.tsx
Normal 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;
|
||||
54
src/pages/billing/BillingNav.tsx
Normal file
54
src/pages/billing/BillingNav.tsx
Normal 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;
|
||||
149
src/pages/billing/BillingTransactions.tsx
Normal file
149
src/pages/billing/BillingTransactions.tsx
Normal 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;
|
||||
378
src/pages/billing/BillingUserView.tsx
Normal file
378
src/pages/billing/BillingUserView.tsx
Normal 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;
|
||||
13
src/pages/billing/index.ts
Normal file
13
src/pages/billing/index.ts
Normal 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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { PekPage } from './PekPage';
|
||||
export { SpeechPage } from './SpeechPage';
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<h1 className={styles.pageTitle}>Chat Playground</h1>
|
||||
<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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue