diff --git a/package-lock.json b/package-lock.json index 34db8a7..7792924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e2f4809..ca11c58 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/App.tsx b/src/App.tsx index 95e21b5..3b82754 100644 --- a/src/App.tsx +++ b/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() { } /> } /> - {/* ============================================== */} - {/* WORKFLOWS ROUTES (global) */} - {/* ============================================== */} - - } /> - } /> - } /> - } /> - - {/* ============================================== */} {/* BASISDATEN ROUTES (global) */} {/* ============================================== */} @@ -127,11 +114,12 @@ function App() { {/* ============================================== */} - {/* MIGRATE TO FEATURES (temporary) */} + {/* BILLING ROUTES */} {/* ============================================== */} - } /> - } /> - } /> + + } /> + } /> + {/* ============================================== */} {/* FEATURE-INSTANZ ROUTES */} @@ -159,6 +147,15 @@ function App() { } /> } /> + {/* Chat Playground Feature Views */} + } /> + } /> + + {/* Automation Feature Views */} + } /> + } /> + } /> + {/* Catch-all für unbekannte Sub-Pfade */} } /> @@ -179,6 +176,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/api/automationApi.ts b/src/api/automationApi.ts index b6f0628..38d560c 100644 --- a/src/api/automationApi.ts +++ b/src/api/automationApi.ts @@ -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; } diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts new file mode 100644 index 0000000..7c78496 --- /dev/null +++ b/src/api/billingApi.ts @@ -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; + costByModel: Record; + costByFeature: Record; +} + +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) => Promise; + +// ============================================================================ +// 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 { + 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 { + 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 { + 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 { + const params: Record = { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return await request({ + url: '/api/billing/view/users/transactions', + method: 'get', + params: { limit } + }); +} diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 2e813f5..a6191b4 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -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; + 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 { 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 { @@ -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 { await request({ - url: `/api/chat/playground/${workflowId}/stop`, + url: `/api/chatplayground/${instanceId}/${workflowId}/stop`, method: 'post' }); } diff --git a/src/components/AutomationEditor/AutomationEditor.tsx b/src/components/AutomationEditor/AutomationEditor.tsx index 677daea..255e404 100644 --- a/src/components/AutomationEditor/AutomationEditor.tsx +++ b/src/components/AutomationEditor/AutomationEditor.tsx @@ -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 = ({ const [label, setLabel] = useState(''); const [schedule, setSchedule] = useState('0 22 * * *'); const [active, setActive] = useState(false); + const [allowedProviders, setAllowedProviders] = useState([]); // Template multilingual fields const [labelMulti, setLabelMulti] = useState({ en: '', de: '' }); @@ -530,6 +532,7 @@ export const AutomationEditor: React.FC = ({ 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 = ({ schedule, active, template: templateJson, - placeholders + placeholders, + allowedProviders }; } @@ -700,7 +704,7 @@ export const AutomationEditor: React.FC = ({ } 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 = ({ Automatisierung ist aktiv und wird planmässig ausgeführt

+ + {/* Allowed AI Providers */} +
+ +

+ Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt. +

+
)} diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css index d9ed884..2aa4da0 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css @@ -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; diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index 04b233f..719be78 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -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)')} )} + {onCsvExport && ( + + )} {onRefresh && ( + + )} + + ); +}; + +// ============================================================================= +// SECTION WRAPPER +// ============================================================================= + +interface SectionWrapperProps { + section: ReportSection; + currencyCode: string; +} + +const _SectionWrapper: React.FC = ({ 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 ( +
+ {section.title &&

{section.title}

} + {_renderKpiGrid(section)} +
+ ); + } + + 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
Unbekannter Sektionstyp
; + } + }; + + return ( +
+ {section.title &&

{section.title}

} + {section.description &&

{section.description}

} + {_renderContent()} +
+ ); +}; + +// ============================================================================= +// TOOLBAR (FILTERS + PERIOD SELECTOR) +// ============================================================================= + +interface ToolbarProps { + periodSelector?: FormGeneratorReportProps['periodSelector']; + dateRangeSelector?: FormGeneratorReportProps['dateRangeSelector']; + filters?: ReportFilterConfig[]; + filterState: ReportFilterState; + onFilterStateChange: (state: ReportFilterState) => void; +} + +const _Toolbar: React.FC = ({ + 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 = { + day: 'Tagesansicht', + week: 'Wochenansicht', + month: 'Monatsansicht', + quarter: 'Quartalsansicht', + year: 'Jahresansicht' + }; + return labels[p] || p; + }; + + return ( +
+ {/* Period Selector */} + {hasPeriod && ( +
+ Zeitraum + + + {periodSelector!.showYear !== false && ( + + )} + + {periodSelector!.showMonth !== false && filterState.period === 'day' && ( + + )} +
+ )} + + {/* Separator */} + {hasPeriod && (hasDateRange || hasFilters) && ( +
+ )} + + {/* Date Range */} + {hasDateRange && ( +
+ Von + _handleDateRangeChange('from', e.target.value)} + /> + Bis + _handleDateRangeChange('to', e.target.value)} + /> +
+ )} + + {/* Separator */} + {hasDateRange && hasFilters && ( +
+ )} + + {/* Custom Filters */} + {hasFilters && filters!.map(filter => ( +
+ {filter.label} + {filter.type === 'text' ? ( + _handleFilterChange(filter.key, e.target.value)} + /> + ) : ( + + )} +
+ ))} +
+ ); +}; + +// ============================================================================= +// MAIN COMPONENT +// ============================================================================= + +export const FormGeneratorReport: React.FC = ({ + 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(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 ( +
+ {title && ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ )} + <_Toolbar + periodSelector={periodSelector} + dateRangeSelector={dateRangeSelector} + filters={filters} + filterState={filterState} + onFilterStateChange={_handleFilterStateChange} + /> +
Lade Daten...
+
+ ); + } + + // No sections + if (!sections || sections.length === 0) { + return ( +
+ {title && ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ )} + <_Toolbar + periodSelector={periodSelector} + dateRangeSelector={dateRangeSelector} + filters={filters} + filterState={filterState} + onFilterStateChange={_handleFilterStateChange} + /> +
{noDataMessage}
+
+ ); + } + + return ( +
+ {title && ( +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ )} + + <_Toolbar + periodSelector={periodSelector} + dateRangeSelector={dateRangeSelector} + filters={filters} + filterState={filterState} + onFilterStateChange={_handleFilterStateChange} + /> + +
+ {sections.map((section, i) => ( + <_SectionWrapper key={i} section={section} currencyCode={currencyCode} /> + ))} +
+
+ ); +}; + +export default FormGeneratorReport; diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts new file mode 100644 index 0000000..3340569 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts @@ -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; +} + +// ============================================================================= +// 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; +} + +/** 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[]; + /** 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; +} diff --git a/src/components/FormGenerator/FormGeneratorReport/index.ts b/src/components/FormGenerator/FormGeneratorReport/index.ts new file mode 100644 index 0000000..b4002a7 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorReport/index.ts @@ -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'; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index fa439f6..7668a13 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -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; } } + diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 936231f..c5b3f7e 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -155,6 +155,8 @@ export interface FormGeneratorTableProps { 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>({ @@ -185,7 +187,8 @@ export function FormGeneratorTable>({ className = '', getRowDataAttributes, hookData, - emptyMessage + emptyMessage, + apiEndpoint }: FormGeneratorTableProps) { const { t } = useLanguage(); // Get current language from localStorage or default to 'en' @@ -338,13 +341,23 @@ export function FormGeneratorTable>({ const tableRef = useRef(null); const tableContainerRef = useRef(null); + // Refs for top scrollbar synchronization + const topScrollbarRef = useRef(null); + const topScrollbarInnerRef = useRef(null); + const isScrollingSyncRef = useRef(false); // Prevent scroll sync loops + // Track container width for actions column 20% threshold const [containerWidth, setContainerWidth] = useState(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>({ 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>({ }; }, []); + // 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>(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>({ onPageSizeChange={handlePageSizeChange} supportsBackendPagination={supportsBackendPagination} hookData={hookData} + onCsvExport={apiEndpoint ? handleCsvExport : undefined} + csvExporting={csvExporting} /> )} - {/* Table */} -
- {/* Loading overlay - shown while loading */} - {loading && ( -
-
-

{t('common.loading', 'Loading...')}

-
- )} + {/* Table Wrapper - contains top scrollbar and table container */} +
+ {/* Top horizontal scrollbar - syncs with table container */} +
+
+
- {/* Empty state - only shown when not loading AND no data */} - {!loading && displayData.length === 0 ? ( -
-

{emptyMessage || t('formgen.empty', 'No data available')}

-
- ) : ( - + {/* Table Container - vertical scroll only */} +
+ {/* Loading overlay - shown while loading */} + {loading && ( +
+
+

{t('common.loading', 'Loading...')}

+
+ )} + + {/* Empty state - only shown when not loading AND no data */} + {!loading && displayData.length === 0 ? ( +
+

{emptyMessage || t('formgen.empty', 'No data available')}

+
+ ) : ( +
{selectable && ( -
+ { @@ -1548,7 +1742,7 @@ export function FormGeneratorTable>({ )} > {selectable && ( - + >({ )}
- )} + )} +
); diff --git a/src/components/FormGenerator/index.ts b/src/components/FormGenerator/index.ts index f7f6249..24ccc3f 100644 --- a/src/components/FormGenerator/index.ts +++ b/src/components/FormGenerator/index.ts @@ -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'; diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index 85f5398..dc9ffb5 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -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]); diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css index 477fc06..01f0722 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css @@ -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; diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx index 4cb31ed..1d38b65 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx @@ -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 = ({ } }, [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 = ({ } }; - // 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 && ( - - {isExpanded ? : } - + )} {!isExpandable && hasChildren === false && ( - + )} {node.icon && {node.icon}} {node.label} @@ -228,7 +220,7 @@ const TreeNode: React.FC = ({ ); // 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 ? ( = ({ className={nodeClasses} onClick={handleClick} data-id={node.dataId} + data-depth={level} > {nodeContent} @@ -246,6 +239,7 @@ const TreeNode: React.FC = ({ onClick={handleClick} disabled={node.disabled} data-id={node.dataId} + data-depth={level} > {nodeContent} diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index b78b874..e19cd0e 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -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 && (
+ + + + {/* Dropdown Content */} + {isExpanded && ( +
+ {showLabel &&
{label}
} + +
+ +
+ + {loading ? ( +
Lade...
+ ) : ( +
+ {allowedProviders.map((provider) => ( + + ))} +
+ )} + + {isAllSelected && !loading && ( +
+ Alle Provider aktiv (kein Filter) +
+ )} +
+ )} +
+ ); +}; + +// ============================================================================ +// COMPACT PROVIDER BADGE LIST +// ============================================================================ + +interface ProviderBadgesProps { + providers: string[]; + className?: string; +} + +export const ProviderBadges: React.FC = ({ + providers, + className, +}) => { + if (providers.length === 0) { + return Alle Provider; + } + + return ( +
+ {providers.map((provider) => ( + + {PROVIDER_ICONS[provider] || '🔌'} {PROVIDER_LABELS[provider] || provider} + + ))} +
+ ); +}; + +// Default export +export default ProviderSelect; diff --git a/src/components/ProviderSelector/index.ts b/src/components/ProviderSelector/index.ts new file mode 100644 index 0000000..afe1b42 --- /dev/null +++ b/src/components/ProviderSelector/index.ts @@ -0,0 +1,10 @@ +/** + * Provider Selector Component Exports + */ + +export { + ProviderSelect, + ProviderMultiSelect, + ProviderBadges +} from './ProviderSelector'; +export { default } from './ProviderSelector'; diff --git a/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx b/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx index 169bd72..f62aef4 100644 --- a/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx +++ b/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx @@ -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 = ({ 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 ( -
+
+ {/* Error indicator for failed actions */} + {isError && ( +
+ + Aktion fehlgeschlagen +
+ )} + {/* Message content */} {message.message && (
diff --git a/src/components/UiComponents/Messages/Messages.module.css b/src/components/UiComponents/Messages/Messages.module.css index e5741d5..620e4ac 100644 --- a/src/components/UiComponents/Messages/Messages.module.css +++ b/src/components/UiComponents/Messages/Messages.module.css @@ -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; diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 62f8828..c71ba73 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -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 = { 'page.system.pek': , 'page.system.speech': , + // Billing pages + 'page.billing.dashboard': , + 'page.billing.transactions': , + // Admin pages 'page.admin.access': , 'page.admin.users': , @@ -57,8 +61,10 @@ export const PAGE_ICONS: Record = { 'page.admin.user-mandates': , 'page.admin.feature-roles': , 'page.admin.feature-instances': , + 'page.admin.featureInstances': , 'page.admin.feature-users': , 'page.admin.user-access-overview': , + 'page.admin.billing': , // Feature pages - Trustee 'page.feature.trustee.dashboard': , diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts index 096c85b..e39494a 100644 --- a/src/hooks/playground/useDashboardInputForm.ts +++ b/src/hooks/playground/useDashboardInputForm.ts @@ -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(''); const [pendingFiles, setPendingFiles] = useState([]); const [isFileAttachmentPopupOpen, setIsFileAttachmentPopupOpen] = useState(false); const [optimisticMessage, setOptimisticMessage] = useState(null); const [selectedPromptId, setSelectedPromptId] = useState(null); const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null); + const [selectedProviders, setSelectedProviders] = useState([]); // AI provider selection (multiselect) const { checkPermission, canView } = usePermissions(); const [playgroundUIPermission, setPlaygroundUIPermission] = useState(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(); - 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); } diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts index bc2dbc5..3fb301e 100644 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ b/src/hooks/playground/useWorkflowLifecycle.ts @@ -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(null); const [workflowStatus, setWorkflowStatus] = useState('idle'); const [currentRound, setCurrentRound] = useState(undefined); @@ -26,48 +73,38 @@ export function useWorkflowLifecycle() { const [logs, setLogs] = useState([]); const [dashboardLogs, setDashboardLogs] = useState([]); const [unifiedContentLogs, setUnifiedContentLogs] = useState([]); - const [statusChangedFromRunningAt, setStatusChangedFromRunningAt] = useState(null); const [latestStats, setLatestStats] = useState<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null); - const prevStatusRef = useRef('idle'); + + // === REFS FOR SYNC ACCESS === const statusRef = useRef('idle'); - const statusChangedFromRunningAtRef = useRef(null); const lastRenderedTimestampRef = useRef(null); - // Track processed stat IDs to avoid double-counting const processedStatIdsRef = useRef>(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(false); + const [hasRenderedLastMessage, setHasRenderedLastMessage] = useState(false); + + // Flag to prevent useEffect from stopping polling during active workflow start + const isStartingWorkflowRef = useRef(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, diff --git a/src/hooks/useAutomations.ts b/src/hooks/useAutomations.ts index 4893fea..1120885 100644 --- a/src/hooks/useAutomations.ts +++ b/src/hooks/useAutomations.ts @@ -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) { diff --git a/src/hooks/useBilling.ts b/src/hooks/useBilling.ts new file mode 100644 index 0000000..8b79f77 --- /dev/null +++ b/src/hooks/useBilling.ts @@ -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([]); + const [transactions, setTransactions] = useState([]); + const [statistics, setStatistics] = useState(null); + const [allowedProviders, setAllowedProviders] = useState([]); + 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(null); + const [accounts, setAccounts] = useState([]); + const [transactions, setTransactions] = useState([]); + const [users, setUsers] = useState([]); + 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; diff --git a/src/hooks/useChatbot.ts b/src/hooks/useChatbot.ts index c4db9f8..6fb79c0 100644 --- a/src/hooks/useChatbot.ts +++ b/src/hooks/useChatbot.ts @@ -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) diff --git a/src/hooks/useInvitations.ts b/src/hooks/useInvitations.ts index bd2514b..e8dbd79 100644 --- a/src/hooks/useInvitations.ts +++ b/src/hooks/useInvitations.ts @@ -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 }; diff --git a/src/hooks/usePrompts.ts b/src/hooks/usePrompts.ts index 6d0cabb..3818047 100644 --- a/src/hooks/usePrompts.ts +++ b/src/hooks/usePrompts.ts @@ -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, _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) => { + const result = await handlePromptUpdate(promptId, changes); if (!result.success) { throw new Error(result.error || 'Failed to update'); } diff --git a/src/hooks/useUserMandates.ts b/src/hooks/useUserMandates.ts index 61758dd..f52b6f3 100644 --- a/src/hooks/useUserMandates.ts +++ b/src/hooks/useUserMandates.ts @@ -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 => { 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 []; diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index 942d1e5..79c12cc 100644 --- a/src/hooks/useWorkflows.ts +++ b/src/hooks/useWorkflows.ts @@ -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'; diff --git a/src/layouts/FeatureLayout.module.css b/src/layouts/FeatureLayout.module.css index 5cce883..aeca795 100644 --- a/src/layouts/FeatureLayout.module.css +++ b/src/layouts/FeatureLayout.module.css @@ -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 */ diff --git a/src/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css index f245160..ac71c8b 100644 --- a/src/layouts/MainLayout.module.css +++ b/src/layouts/MainLayout.module.css @@ -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); } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index a94a979..686d86f 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -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 = { - trustee: , - chatbot: , - chatworkflow: , -}; - // ============================================================================= // INSTANCE CARD // ============================================================================= interface InstanceCardProps { - instance: FeatureInstance; - featureLabel: string; + instance: NavFeatureInstance; + feature: MandateFeature; + mandateLabel: string; } -const InstanceCard: React.FC = ({ 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 = ({ 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 (
- {FEATURE_ICONS[instance.featureCode] || } + {getPageIcon(feature.uiComponent)}
- {featureLabel} - {instance.userRoles?.join(', ') || '-'} + {feature.uiLabel}
-

{instance.instanceLabel}

-

{instance.mandateName}

+

{instance.uiLabel}

+

{mandateLabel}

@@ -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); - - 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 ( +
+
+

Übersicht

+

Lade...

+
+
+ ); + } + + if (totalInstances === 0) { return ; } - + + // Gruppiere Instanzen nach Feature (über alle Mandate) + const featureGroups: { feature: MandateFeature; instances: { instance: NavFeatureInstance; mandateLabel: string }[] }[] = []; + const featureMap = new Map(); + + 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 (

Übersicht

- 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' : ''}.

- +
- {Object.entries(instancesByFeature).map(([featureCode, instances]) => { - const featureConfig = FEATURE_REGISTRY[featureCode]; - const featureLabel = featureConfig ? getLabel(featureConfig.label) : featureCode; - - return ( -
-

- {FEATURE_ICONS[featureCode]} - {featureLabel} -

-
- {instances.map(instance => ( - - ))} -
-
- ); - })} + {featureGroups.map(({ feature, instances }) => ( +
+

+ {getPageIcon(feature.uiComponent)} + {feature.uiLabel} +

+
+ {instances.map(({ instance, mandateLabel }) => ( + + ))} +
+
+ ))}
); diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index d49700c..7314af7 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -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> = { parcels: RealEstateParcelsView, 'instance-roles': RealEstateInstanceRolesPlaceholder, }, + chatplayground: { + playground: PlaygroundPage, + workflows: WorkflowsPage, + }, + automation: { + definitions: AutomationsPage, + templates: AutomationTemplatesPage, + logs: () => , + }, }; // ============================================================================= @@ -114,31 +129,13 @@ interface FeatureViewPageProps { } export const FeatureViewPage: React.FC = ({ 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 ; @@ -160,10 +157,17 @@ export const FeatureViewPage: React.FC = ({ view }) => { return ; } - // 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 (
diff --git a/src/pages/InvitePage.tsx b/src/pages/InvitePage.tsx index 1f9257a..f5cd0ab 100644 --- a/src/pages/InvitePage.tsx +++ b/src/pages/InvitePage.tsx @@ -44,6 +44,7 @@ export const InvitePage: React.FC = () => { const [accepting, setAccepting] = useState(false); const [success, setSuccess] = useState(false); const [error, setError] = useState(null); + const [userExists, setUserExists] = useState(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 (
@@ -254,9 +271,11 @@ export const InvitePage: React.FC = () => {

- {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.'}

@@ -267,29 +286,48 @@ export const InvitePage: React.FC = () => { )}
- - -
- oder -
- - + {userExists === true ? ( + + ) : userExists === false ? ( + + ) : ( + <> + +
+ oder +
+ + + )}

- 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.'}

diff --git a/src/pages/admin/AccessManagementHub.tsx b/src/pages/admin/AccessManagementHub.tsx index 5a64905..d522ea7 100644 --- a/src/pages/admin/AccessManagementHub.tsx +++ b/src/pages/admin/AccessManagementHub.tsx @@ -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; } diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css index 50e63ef..6e99486 100644 --- a/src/pages/admin/Admin.module.css +++ b/src/pages/admin/Admin.module.css @@ -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 { diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index 682e171..fb0b92e 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -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 = () => { { 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 = () => { { { // 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 = () => { { const { roles, @@ -41,9 +69,16 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { // State const [mandates, setMandates] = useState([]); const [selectedMandateId, setSelectedMandateId] = useState(''); - const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all'); + const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate'); const [expandedRoleId, setExpandedRoleId] = useState(null); + // Cleanup state + const [showCleanupModal, setShowCleanupModal] = useState(false); + const [cleanupLoading, setCleanupLoading] = useState(false); + const [cleanupResult, setCleanupResult] = useState(null); + const [cleanupError, setCleanupError] = useState(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 ( - System + System-Template ); } if (!role.mandateId) { return ( - Global + Template ); } @@ -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 = () => {

+
@@ -207,9 +299,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {

Keine Rollen gefunden

{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.'}

@@ -242,11 +334,20 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { {/* Expanded Content - AccessRulesEditor */} {expandedRoleId === role.id && (
+ {_isTemplateRole(role) && ( +
+ + + Dies ist eine Template-Rolle. Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus. + Bestehende Mandanten-Instanzen werden nicht aktualisiert. + +
+ )} @@ -256,6 +357,139 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { ))}
)} + + {/* Cleanup Duplicates Modal */} + {showCleanupModal && ( +
+
e.stopPropagation()}> +
+

+ + Doppelte Regeln bereinigen +

+ +
+ +
+ {/* Loading */} + {cleanupLoading && ( +
+
+ {cleanupPhase === 'idle' ? 'Analysiere Duplikate...' : 'Bereinige Duplikate...'} +
+ )} + + {/* Error */} + {cleanupError && ( +
+ + {cleanupError} +
+ )} + + {/* Results */} + {cleanupResult && !cleanupLoading && ( + <> + {/* Summary Cards */} +
+
+
{cleanupResult.totalRules}
+
Regeln total
+
+
+
{cleanupResult.uniqueSignatures}
+
Eindeutige Regeln
+
+
0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateGroups > 0 ? '#fc8181' : '#9ae6b4'}` }}> +
0 ? '#c53030' : '#2f855a' }}>{cleanupResult.duplicateGroups}
+
Duplikat-Gruppen
+
+
0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateRulesToDelete > 0 ? '#fc8181' : '#9ae6b4'}` }}> +
0 ? '#c53030' : '#2f855a' }}> + {cleanupPhase === 'done' ? cleanupResult.deletedCount : cleanupResult.duplicateRulesToDelete} +
+
+ {cleanupPhase === 'done' ? 'Geloescht' : 'Zu loeschen'} +
+
+
+ + {/* Status Message */} + {cleanupPhase === 'done' && ( +
+ + {cleanupResult.deletedCount} doppelte Regeln wurden erfolgreich entfernt. +
+ )} + + {cleanupPhase === 'preview' && cleanupResult.duplicateGroups === 0 && ( +
+ + Keine Duplikate gefunden. Alles sauber! +
+ )} + + {/* Details Table */} + {cleanupResult.details.length > 0 && ( +
+

+ Duplikat-Details {cleanupResult.details.length < cleanupResult.duplicateGroups && `(${cleanupResult.details.length} von ${cleanupResult.duplicateGroups})`} +

+
+ + + + + + + + + + + {cleanupResult.details.map((group, idx) => ( + + + + + + + ))} + +
KontextItemTotalDuplikate
+ + {group.context} + + + + {group.item} + + {group.totalCount}{group.deleteCount}
+
+
+ )} + + )} +
+ +
+ + {cleanupPhase === 'preview' && cleanupResult && cleanupResult.duplicateRulesToDelete > 0 && ( + + )} +
+
+
+ )}
); }; diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx index a6fc149..19ddff4 100644 --- a/src/pages/admin/AdminMandateRolesPage.tsx +++ b/src/pages/admin/AdminMandateRolesPage.tsx @@ -46,7 +46,7 @@ export const AdminMandateRolesPage: React.FC = () => { const [showCreateModal, setShowCreateModal] = useState(false); const [editingRole, setEditingRole] = useState(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([]); // Store current filter state for refetch @@ -126,14 +126,14 @@ export const AdminMandateRolesPage: React.FC = () => { if (value === 'system') { return ( - System + System-Template ); } if (value === 'global') { return ( - Global + Template ); } @@ -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 }} > - - - + + +
@@ -389,9 +390,9 @@ export const AdminMandateRolesPage: React.FC = () => {
- System-Rollen (admin, user, viewer) können nicht bearbeitet oder gelöscht werden. - Globale Rollen gelten für alle Mandanten. - Mandanten-Rollen gelten nur für den ausgewählten Mandanten. + System-Templates (admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert. + Templates selbst können nicht gelöscht werden. + Mandanten-Rollen gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.
)} @@ -416,9 +417,9 @@ export const AdminMandateRolesPage: React.FC = () => {

Keine Rollen

{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.'}

- {formAttributes.length === 0 ? ( + {createFormAttributes.length === 0 ? (
Lade Formular...
) : ( setShowCreateModal(false)} @@ -233,6 +246,14 @@ export const AdminMandatesPage: React.FC = () => {
+ {editingMandate.isSystem && ( +
+ + + Dies ist ein System-Mandant. Er kann nicht gelöscht werden und der Name sollte nicht geändert werden. + +
+ )} {formAttributes.length === 0 ? (
diff --git a/src/pages/admin/AdminUserAccessOverviewPage.tsx b/src/pages/admin/AdminUserAccessOverviewPage.tsx index f509f6c..9416c86 100644 --- a/src/pages/admin/AdminUserAccessOverviewPage.tsx +++ b/src/pages/admin/AdminUserAccessOverviewPage.tsx @@ -188,7 +188,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => { {overview.mandates.length === 0 ? (

Keine Mandate-Zuordnungen vorhanden.

) : ( -
+
{overview.mandates.map(mandate => (
{ {overview.roles.length === 0 ? (

Keine Rollen zugewiesen.

) : ( -
+
{overview.roles.map(role => (
{ ) : overview ? ( <> {/* User Info */} -
+
{overview.user.fullName || overview.user.username} | {overview.user.email} @@ -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 }}> + +
+ ); +}; + +// ============================================================================ +// CREDIT ADDER +// ============================================================================ + +interface CreditAdderProps { + settings: BillingSettings | null; + accounts: AccountSummary[]; + users: MandateUserSummary[]; + onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise; +} + +const CreditAdder: React.FC = ({ settings, accounts, users, onAddCredit }) => { + const [selectedUserId, setSelectedUserId] = useState(''); + const [amount, setAmount] = useState(10); + const [description, setDescription] = useState('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); + + 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 ( +
+

Guthaben aufladen

+ + {message && ( +
+ {message.text} +
+ )} + +
+ {isPrepayUser && ( +
+
+ + +
+
+ )} + +
+
+ + setAmount(Number(e.target.value))} + min="0.01" + step="0.01" + required + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="Grund für Gutschrift" + /> +
+
+ + +
+
+ ); +}; + +// ============================================================================ +// ACCOUNTS OVERVIEW +// ============================================================================ + +interface AccountsOverviewProps { + accounts: AccountSummary[]; + users: MandateUserSummary[]; + loading: boolean; +} + +const AccountsOverview: React.FC = ({ 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(); + 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
Lade Konten...
; + } + + if (accounts.length === 0) { + return
Keine Konten vorhanden
; + } + + return ( +
+

Konten

+
+ {accounts.map((account) => ( +
+

{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}

+
+ {account.userId && User: {_userNameMap.get(account.userId) || account.userId}} + Guthaben: {formatCurrency(account.balance)} + {account.creditLimit && Limit: {formatCurrency(account.creditLimit)}} + Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'} +
+
+ ))} +
+
+ ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingAdmin: React.FC = () => { + const [selectedMandateId, setSelectedMandateId] = useState(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) => { + 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 ( +
+
+

Billing Administration

+

Verwaltung von Abrechnungseinstellungen und Guthaben

+
+ +
+ +
+ + {selectedMandateId && ( + <> + + + + + + + )} + + {!selectedMandateId && ( +
+ Bitte wählen Sie einen Mandanten aus. +
+ )} +
+ ); +}; + +export default BillingAdmin; diff --git a/src/pages/billing/BillingDashboard.tsx b/src/pages/billing/BillingDashboard.tsx new file mode 100644 index 0000000..8e73efb --- /dev/null +++ b/src/pages/billing/BillingDashboard.tsx @@ -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 = ({ 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 ( +
+
+

{balance.mandateName}

+ {getBillingModelLabel(balance.billingModel)} +
+
+ {formatCurrency(balance.balance)} +
+ {balance.isWarning && ( +
+ Niedriges Guthaben +
+ )} +
+ ); +}; + +// ============================================================================ +// STATISTICS CHART COMPONENT +// ============================================================================ + +interface StatisticsChartProps { + statistics: UsageReport | null; + loading?: boolean; +} + +const StatisticsChart: React.FC = ({ statistics, loading }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + if (loading) { + return
Lade Statistiken...
; + } + + if (!statistics) { + return
Keine Statistiken verfügbar
; + } + + // Calculate max cost for bar scaling + const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1); + + return ( +
+
+ Gesamtkosten + {formatCurrency(statistics.totalCost)} +
+ +
+

Kosten nach Anbieter

+ {Object.entries(statistics.costByProvider).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByProvider).map(([provider, cost]) => ( +
+ {provider} +
+
+
+ {formatCurrency(cost)} +
+ ))} +
+ )} +
+ +
+

Kosten nach Modell

+ {Object.entries(statistics.costByModel || {}).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByModel || {}).map(([model, cost]) => ( +
+ {model} +
+
+
+ {formatCurrency(cost)} +
+ ))} +
+ )} +
+ +
+

Kosten nach Feature

+ {Object.entries(statistics.costByFeature).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByFeature).map(([feature, cost]) => ( +
+ {feature} + {formatCurrency(cost)} +
+ ))} +
+ )} +
+
+ ); +}; + +// ============================================================================ +// 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 ( +
+
+

Billing

+

Übersicht über Guthaben und Nutzung

+
+ + + + {/* Balance Cards */} +
+

Guthaben

+ {loading ? ( +
Lade Guthaben...
+ ) : balances.length === 0 ? ( +
Keine Abrechnungskonten vorhanden
+ ) : ( +
+ {balances.map((balance) => ( + + ))} +
+ )} +
+ + {/* Statistics */} +
+
+

Nutzungsstatistik

+
+ + + {selectedPeriod === 'month' && ( + + )} +
+
+ +
+
+ ); +}; + +export default BillingDashboard; diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx new file mode 100644 index 0000000..3eda4c2 --- /dev/null +++ b/src/pages/billing/BillingDataView.tsx @@ -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; + costByModel: Record; + costByFeature: Record; + costByMandate: Record; + timeSeries: Array<{ date: string; cost: number; count: number }>; +} + +// ============================================================================ +// BALANCE CARD COMPONENT +// ============================================================================ + +interface BalanceCardProps { + balance: BillingBalance; +} + +const BalanceCard: React.FC = ({ 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 ( +
+
+

{balance.mandateName}

+ {_getBillingModelLabel(balance.billingModel)} +
+
+ {_formatCurrency(balance.balance)} +
+ {balance.isWarning && ( +
+ Niedriges Guthaben +
+ )} +
+ ); +}; + +// ============================================================================ +// TAB NAVIGATION COMPONENT +// ============================================================================ + +type TabType = 'overview' | 'statistics' | 'transactions'; + +interface TabNavProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; +} + +const TabNav: React.FC = ({ 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 ( + + ); +}; + +// ============================================================================ +// HELPERS: Convert viewStats to ReportSection arrays +// ============================================================================ + +function _recordToChartData(record: Record): 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('overview'); + + // Dashboard state (for Overview tab) + const { + balances, + loading: dashboardLoading, + } = useBilling(); + + // Statistics state (shared by Overview and Statistics tabs) + const [viewStats, setViewStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); + + // Transactions state (for Transactions tab) + const [transactions, setTransactions] = useState([]); + const [transactionsLoading, setTransactionsLoading] = useState(false); + const [transactionsError, setTransactionsError] = useState(null); + const [transactionsPagination, setTransactionsPagination] = useState(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(() => { + if (!viewStats) return []; + return _buildOverviewSections(viewStats); + }, [viewStats]); + + const statisticsSections = useMemo(() => { + 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 ( +
+
+

Billing

+

Guthaben, Statistiken und Transaktionen

+
+ + + + {/* ================================================================ */} + {/* Tab: Übersicht (My Overview) */} + {/* ================================================================ */} + {activeTab === 'overview' && ( + <> + {/* Balance Cards */} +
+

Mein Guthaben

+ {dashboardLoading ? ( +
Lade Guthaben...
+ ) : balances.length === 0 ? ( +
Keine Abrechnungskonten vorhanden
+ ) : ( +
+ {balances.map((balance) => ( + + ))} +
+ )} +
+ + {/* Usage Statistics via FormGeneratorReport (no period selector - always full year) */} +
+ +
+ + )} + + {/* ================================================================ */} + {/* Tab: Statistik (Dashboard) */} + {/* ================================================================ */} + {activeTab === 'statistics' && ( +
+ +
+ )} + + {/* ================================================================ */} + {/* Tab: Transaktionen */} + {/* ================================================================ */} + {activeTab === 'transactions' && ( +
+ {transactionsError && ( +
+ {transactionsError} +
+ )} + + +
+ )} +
+ ); +}; + +export default BillingDataView; diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx new file mode 100644 index 0000000..f6792c6 --- /dev/null +++ b/src/pages/billing/BillingMandateView.tsx @@ -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 = ({ + 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 ( +
+ + + + + + + + + + + + + {balances.map((balance) => ( + + + + + + + + + ))} + +
MandantBilling-ModellAnzahl BenutzerStandard-GuthabenGesamtguthabenAktion
{balance.mandateName || balance.mandateId}{getBillingModelLabel(balance.billingModel)}{balance.userCount}{formatCurrency(balance.defaultUserCredit)}{formatCurrency(balance.totalBalance)} + +
+
+ ); +}; + +// ============================================================================ +// TRANSACTION TABLE +// ============================================================================ + +interface TransactionTableProps { + transactions: BillingTransaction[]; +} + +const TransactionTable: React.FC = ({ 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 ( +
+ + + + + + + + + + + + + + + {transactions.map((t) => ( + + + + + + + + + + + ))} + +
DatumMandantTypBeschreibungAnbieterModellFeatureBetrag
{formatDate(t.createdAt)}{t.mandateName || '-'} + + {getTypeLabel(t.transactionType)} + + {t.description}{t.aicoreProvider || '-'}{t.aicoreModel || '-'}{t.featureCode || '-'} + {t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)} +
+
+ ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingMandateView: React.FC = () => { + const { request, isLoading: loading } = useApiRequest(); + const [balances, setBalances] = useState([]); + const [transactions, setTransactions] = useState([]); + const [selectedMandateId, setSelectedMandateId] = useState(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 ( +
+
+

Mandanten-Billing

+

Guthaben und Transaktionen pro Mandant

+
+ + + + {/* Mandate Balances */} +
+

Mandanten-Guthaben

+ {loading && balances.length === 0 ? ( +
Lade Daten...
+ ) : balances.length === 0 ? ( +
Keine Mandanten mit Billing-Settings vorhanden
+ ) : ( + + )} +
+ + {/* Transactions */} +
+
+

+ Transaktionen + {selectedMandateId && ( + + (gefiltert nach {balances.find(b => b.mandateId === selectedMandateId)?.mandateName || selectedMandateId}) + + )} +

+
+ {loading && transactions.length === 0 ? ( +
Lade Transaktionen...
+ ) : filteredTransactions.length === 0 ? ( +
Keine Transaktionen vorhanden
+ ) : ( + <> + + + {transactions.length >= limit && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default BillingMandateView; diff --git a/src/pages/billing/BillingNav.tsx b/src/pages/billing/BillingNav.tsx new file mode 100644 index 0000000..0b4c918 --- /dev/null +++ b/src/pages/billing/BillingNav.tsx @@ -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 ( + + ); +}; + +export default BillingNav; diff --git a/src/pages/billing/BillingTransactions.tsx b/src/pages/billing/BillingTransactions.tsx new file mode 100644 index 0000000..d64bde7 --- /dev/null +++ b/src/pages/billing/BillingTransactions.tsx @@ -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 = ({ 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 ( + + {formatDate(transaction.createdAt)} + {transaction.mandateName || '-'} + + + {getTypeLabel(transaction.transactionType)} + + + {transaction.description} + {transaction.aicoreProvider || '-'} + {transaction.aicoreModel || '-'} + {transaction.featureCode || '-'} + + {transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)} + + + ); +}; + +// ============================================================================ +// 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 ( +
+
+

Transaktionen

+

Übersicht aller Kontobewegungen

+
+ + + +
+ {loading && transactions.length === 0 ? ( +
Lade Transaktionen...
+ ) : transactions.length === 0 ? ( +
Keine Transaktionen vorhanden
+ ) : ( + <> +
+ + + + + + + + + + + + + + + {transactions.map((transaction) => ( + + ))} + +
DatumMandantTypBeschreibungAnbieterModellFeatureBetrag
+
+ + {transactions.length >= limit && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default BillingTransactions; diff --git a/src/pages/billing/BillingUserView.tsx b/src/pages/billing/BillingUserView.tsx new file mode 100644 index 0000000..4587acc --- /dev/null +++ b/src/pages/billing/BillingUserView.tsx @@ -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 = ({ + 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(); + 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(); + 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 */} +
+
+ + +
+
+ + +
+
+ + {/* Table */} +
+ + + + + + + + + + + + {filteredBalances.map((balance) => ( + + + + + + + + ))} + +
MandantBenutzerGuthabenWarnschwelleStatus
{balance.mandateName || balance.mandateId}{balance.userName || balance.userId}{formatCurrency(balance.balance)}{formatCurrency(balance.warningThreshold)} + {balance.isWarning ? ( + + Niedrig + + ) : balance.enabled ? ( + Aktiv + ) : ( + Deaktiviert + )} +
+
+ + ); +}; + +// ============================================================================ +// USER TRANSACTION TABLE +// ============================================================================ + +interface UserTransactionTableProps { + transactions: UserTransaction[]; + selectedMandateId: string | null; + selectedUserId: string | null; +} + +const UserTransactionTable: React.FC = ({ + 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 ( +
+ + + + + + + + + + + + + + + + {filteredTransactions.map((t) => ( + + + + + + + + + + + + ))} + +
DatumMandantBenutzerTypBeschreibungAnbieterModellFeatureBetrag
{formatDate(t.createdAt)}{t.mandateName || '-'}{t.userName || '-'} + + {getTypeLabel(t.transactionType)} + + {t.description}{t.aicoreProvider || '-'}{t.aicoreModel || '-'}{t.featureCode || '-'} + {t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)} +
+
+ ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingUserView: React.FC = () => { + const { request, isLoading: loading } = useApiRequest(); + const [balances, setBalances] = useState([]); + const [transactions, setTransactions] = useState([]); + const [selectedMandateId, setSelectedMandateId] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(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 ( +
+
+

Benutzer-Billing

+

Guthaben und Transaktionen pro Benutzer

+
+ + + + {/* User Balances */} +
+

Benutzer-Guthaben

+ {loading && balances.length === 0 ? ( +
Lade Daten...
+ ) : balances.length === 0 ? ( +
Keine Benutzer-Konten vorhanden
+ ) : ( + + )} +
+ + {/* Transactions */} +
+
+

+ Transaktionen + {(selectedMandateId || selectedUserId) && ( + + ({filteredTransactionCount} gefiltert) + + )} +

+
+ {loading && transactions.length === 0 ? ( +
Lade Transaktionen...
+ ) : transactions.length === 0 ? ( +
Keine Transaktionen vorhanden
+ ) : ( + <> + + + {transactions.length >= limit && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default BillingUserView; diff --git a/src/pages/billing/index.ts b/src/pages/billing/index.ts new file mode 100644 index 0000000..86d7563 --- /dev/null +++ b/src/pages/billing/index.ts @@ -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'; diff --git a/src/pages/migrate/MigratePages.module.css b/src/pages/migrate/MigratePages.module.css deleted file mode 100644 index 95b409f..0000000 --- a/src/pages/migrate/MigratePages.module.css +++ /dev/null @@ -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; -} diff --git a/src/pages/migrate/PekPage.tsx b/src/pages/migrate/PekPage.tsx deleted file mode 100644 index 797b8b8..0000000 --- a/src/pages/migrate/PekPage.tsx +++ /dev/null @@ -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 ( -
-
-

PEK

-

- MIGRATE TO FEATURE - Projekt-Entwicklungs-Koordination -

-
- -
-
-
📊
-

PEK-Modul

-

- Dieses Modul wird zu einer Feature-Instanz migriert. -

-

- Nach der Migration wird PEK als Feature pro Mandant verfügbar sein, - mit instanz-spezifischen Daten und Berechtigungen. -

-
-
-
- ); -}; - -export default PekPage; diff --git a/src/pages/migrate/SpeechPage.tsx b/src/pages/migrate/SpeechPage.tsx deleted file mode 100644 index 9bfda2c..0000000 --- a/src/pages/migrate/SpeechPage.tsx +++ /dev/null @@ -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 ( -
-
-

Speech

-

- MIGRATE TO FEATURE - Spracherkennung und Transkription -

-
- -
-
-
🎤
-

Speech-Modul

-

- Dieses Modul wird zu einer Feature-Instanz migriert. -

-

- Nach der Migration wird Speech als Feature pro Mandant verfügbar sein, - mit instanz-spezifischen Transkriptionen und Einstellungen. -

-
-
-
- ); -}; - -export default SpeechPage; diff --git a/src/pages/migrate/index.ts b/src/pages/migrate/index.ts deleted file mode 100644 index c7ed0d3..0000000 --- a/src/pages/migrate/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { PekPage } from './PekPage'; -export { SpeechPage } from './SpeechPage'; diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx index 9042cb7..065ad2d 100644 --- a/src/pages/views/realestate/RealEstateParcelsView.tsx +++ b/src/pages/views/realestate/RealEstateParcelsView.tsx @@ -185,6 +185,7 @@ export const RealEstateParcelsView: 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 = () => { { 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 */}
-
-

Chat Playground

+
+
+

Chat Playground

+ {/* Stats display in header */} +
+ + ↑ {formatBytes(latestStats?.bytesSent || 0)} / ↓ {formatBytes(latestStats?.bytesReceived || 0)} + + {(latestStats?.processingTime ?? 0) > 0 && ( + + ⏱️ {formatDuration(latestStats?.processingTime || 0)} + + )} + {(latestStats?.priceUsd ?? 0) > 0 && ( + + 💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)} + + )} +
+

Workflow-Ausführung und Chat-Interaktion

@@ -706,36 +729,6 @@ export const PlaygroundPage: React.FC = () => {
)} - {/* Stats bar */} - {latestStats && (latestStats.bytesSent || latestStats.bytesReceived || latestStats.processingTime || latestStats.priceUsd) && ( -
- {(latestStats.bytesSent !== undefined || latestStats.bytesReceived !== undefined) && ( -
- Daten: - - {formatBytes(latestStats.bytesSent || 0)} / {formatBytes(latestStats.bytesReceived || 0)} - -
- )} - {latestStats.processingTime !== undefined && latestStats.processingTime > 0 && ( -
- Zeit: - - {formatDuration(latestStats.processingTime)} - -
- )} - {latestStats.priceUsd !== undefined && latestStats.priceUsd > 0 && ( -
- Kosten: - - ${latestStats.priceUsd.toFixed(4)} - -
- )} -
- )} - {/* Input row */}
@@ -777,6 +770,12 @@ export const PlaygroundPage: 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 = () => { = { { 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' }, + ] + }, }; // =============================================================================