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/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css new file mode 100644 index 0000000..b1d0f13 --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css @@ -0,0 +1,406 @@ +/* ============================================================================= + FormGeneratorReport - Generic Reporting Component + ============================================================================= */ + +/* --- Container --- */ + +.reportContainer { + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; +} + +.reportHeader { + margin-bottom: 0.5rem; +} + +.reportTitle { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin: 0 0 0.25rem 0; +} + +.reportSubtitle { + font-size: 0.875rem; + color: var(--text-secondary, #888); + margin: 0; +} + +/* --- Toolbar (Filters + Period) --- */ + +.toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.75rem 1rem; + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 10px; +} + +.toolbarGroup { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.toolbarLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; +} + +.toolbarSeparator { + width: 1px; + height: 24px; + background: var(--border-color, #333); + margin: 0 0.25rem; +} + +.select { + padding: 0.375rem 0.625rem; + border: 1px solid var(--border-color, #333); + border-radius: 6px; + background: var(--bg-secondary, #2a2a2a); + color: var(--text-primary, #e0e0e0); + font-size: 0.8125rem; + cursor: pointer; + min-width: 80px; +} + +.select:focus { + outline: none; + border-color: var(--primary-color, #f25843); +} + +.dateInput { + padding: 0.375rem 0.625rem; + border: 1px solid var(--border-color, #333); + border-radius: 6px; + background: var(--bg-secondary, #2a2a2a); + color: var(--text-primary, #e0e0e0); + font-size: 0.8125rem; + cursor: pointer; +} + +.dateInput:focus { + outline: none; + border-color: var(--primary-color, #f25843); +} + +.textInput { + padding: 0.375rem 0.625rem; + border: 1px solid var(--border-color, #333); + border-radius: 6px; + background: var(--bg-secondary, #2a2a2a); + color: var(--text-primary, #e0e0e0); + font-size: 0.8125rem; + min-width: 120px; +} + +.textInput:focus { + outline: none; + border-color: var(--primary-color, #f25843); +} + +/* --- Sections Grid --- */ + +.sectionsGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.sectionFull { + grid-column: 1 / -1; +} + +.sectionHalf { + grid-column: span 1; +} + +/* --- Section Card --- */ + +.sectionCard { + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + padding: 1.25rem; + display: flex; + flex-direction: column; +} + +.sectionTitle { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0 0 0.75rem 0; +} + +.sectionDescription { + font-size: 0.8125rem; + color: var(--text-tertiary, #666); + margin: -0.5rem 0 0.75rem 0; +} + +/* --- KPI Grid --- */ + +.kpiGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; +} + +.kpiCard { + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.kpiLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.kpiValue { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary, #e0e0e0); +} + +.kpiSubtitle { + font-size: 0.75rem; + color: var(--text-tertiary, #666); +} + +/* --- Charts (recharts wrappers) --- */ + +.chartWrapper { + width: 100%; + height: 280px; + min-height: 280px; + min-width: 0; +} + +.chartWrapperSmall { + width: 100%; + height: 220px; + min-height: 220px; + min-width: 0; +} + +/* --- Horizontal Bar Chart --- */ + +.horizontalBarList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.horizontalBarRow { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.horizontalBarLabel { + width: 120px; + font-size: 0.8125rem; + color: var(--text-primary, #e0e0e0); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex-shrink: 0; +} + +.horizontalBarTrack { + flex: 1; + height: 22px; + background: var(--bg-secondary, #2a2a2a); + border-radius: 4px; + overflow: hidden; +} + +.horizontalBarFill { + height: 100%; + background: var(--primary-color, #f25843); + border-radius: 4px; + transition: width 0.3s ease; + min-width: 4px; +} + +.horizontalBarValue { + width: 90px; + text-align: right; + font-size: 0.8125rem; + color: var(--text-secondary, #888); + font-family: monospace; + flex-shrink: 0; +} + +/* --- Table --- */ + +.reportTable { + width: 100%; + border-collapse: collapse; +} + +.reportTable th { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 0.625rem 0.75rem; + text-align: left; + border-bottom: 2px solid var(--border-color, #333); +} + +.reportTable td { + font-size: 0.8125rem; + color: var(--text-primary, #e0e0e0); + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-color, #333); +} + +.reportTable tr:last-child td { + border-bottom: none; +} + +.reportTable tr:hover td { + background: var(--bg-secondary, #2a2a2a); +} + +.alignRight { + text-align: right; +} + +.alignCenter { + text-align: center; +} + +.monoValue { + font-family: monospace; +} + +.showMoreRow { + text-align: center; + padding: 0.5rem; +} + +.showMoreButton { + background: none; + border: none; + color: var(--primary-color, #f25843); + font-size: 0.8125rem; + cursor: pointer; + padding: 0.25rem 0.5rem; +} + +.showMoreButton:hover { + text-decoration: underline; +} + +/* --- Loading / No Data --- */ + +.loadingContainer { + display: flex; + align-items: center; + justify-content: center; + padding: 3rem; + color: var(--text-secondary, #888); + font-size: 0.875rem; +} + +.noData { + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + color: var(--text-tertiary, #666); + font-size: 0.8125rem; + font-style: italic; +} + +/* --- Recharts Custom Tooltip --- */ + +.customTooltip { + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 8px; + padding: 0.75rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.tooltipLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin-bottom: 0.25rem; +} + +.tooltipValue { + font-size: 0.8125rem; + color: var(--text-secondary, #888); +} + +.tooltipValue span { + color: var(--text-primary, #e0e0e0); + font-weight: 600; +} + +/* --- Responsive --- */ + +@media (max-width: 768px) { + .sectionsGrid { + grid-template-columns: 1fr; + } + + .sectionHalf { + grid-column: span 1; + } + + .toolbar { + flex-direction: column; + align-items: flex-start; + } + + .toolbarSeparator { + width: 100%; + height: 1px; + margin: 0.25rem 0; + } + + .kpiGrid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } + + .horizontalBarLabel { + width: 80px; + font-size: 0.75rem; + } + + .horizontalBarValue { + width: 70px; + font-size: 0.75rem; + } + + .chartWrapper { + height: 220px; + } +} diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx new file mode 100644 index 0000000..2082edd --- /dev/null +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx @@ -0,0 +1,773 @@ +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + LineChart, Line, AreaChart, Area, + PieChart, Pie, Cell, Legend +} from 'recharts'; +import styles from './FormGeneratorReport.module.css'; +import type { + FormGeneratorReportProps, + ReportSection, + ReportSectionKpi, + ReportSectionBarChart, + ReportSectionHorizontalBar, + ReportSectionLineChart, + ReportSectionPieChart, + ReportSectionTable, + ReportSectionAreaChart, + ReportFilterState, + ReportPeriod, + ReportFilterConfig, + ReportTableColumn +} from './FormGeneratorReportTypes'; + +// ============================================================================= +// CHART COLORS +// ============================================================================= + +const CHART_COLORS = [ + 'var(--primary-color, #f25843)', + '#4e79a7', + '#59a14f', + '#f28e2b', + '#b07aa1', + '#76b7b2', + '#e15759', + '#edc948', + '#9c755f', + '#bab0ac' +]; + +const MONTH_LABELS: Record = { + '01': 'Jan', '02': 'Feb', '03': 'Mär', '04': 'Apr', + '05': 'Mai', '06': 'Jun', '07': 'Jul', '08': 'Aug', + '09': 'Sep', '10': 'Okt', '11': 'Nov', '12': 'Dez' +}; + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +function _defaultFormatCurrency(value: number, currencyCode: string): string { + return `${currencyCode} ${value.toFixed(2)}`; +} + +function _formatDateLabel(dateStr: string): string { + const parts = dateStr.split('-'); + if (parts.length === 3) { + return `${parseInt(parts[2], 10)}.`; + } + if (parts.length === 2) { + return MONTH_LABELS[parts[1]] || parts[1]; + } + return dateStr; +} + +// ============================================================================= +// CUSTOM TOOLTIP +// ============================================================================= + +interface CustomTooltipProps { + active?: boolean; + payload?: any[]; + label?: string; + formatValue?: (value: number) => string; +} + +const _CustomTooltip: React.FC = ({ active, payload, label, formatValue }) => { + if (!active || !payload?.length) return null; + + const displayLabel = label ? _formatDateLabel(String(label)) : ''; + + return ( +
+ {displayLabel &&
{displayLabel}
} + {payload.map((entry: any, i: number) => ( +
+ {entry.name}: {formatValue ? formatValue(entry.value) : entry.value} +
+ ))} +
+ ); +}; + +// ============================================================================= +// SECTION RENDERERS +// ============================================================================= + +// --- KPI Grid --- + +const _renderKpiGrid = (section: ReportSectionKpi): React.ReactNode => { + return ( +
+ {section.items.map((item, i) => ( +
+ {item.label} + {item.value} + {item.subtitle && {item.subtitle}} +
+ ))} +
+ ); +}; + +// --- Bar Chart (vertical) --- + +const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string): React.ReactNode => { + if (!section.data?.length) { + return
Keine Daten
; + } + + const chartData = section.data.map(d => ({ + name: _formatDateLabel(d.key), + value: d.value, + rawKey: d.key + })); + + const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode)); + + return ( +
+ + + + + formatter(v)} + width={70} + /> + } /> + + + +
+ ); +}; + +// --- Horizontal Bar Chart --- + +const _renderHorizontalBar = (section: ReportSectionHorizontalBar, currencyCode: string): React.ReactNode => { + if (!section.data?.length) { + return
Keine Daten
; + } + + const maxValue = Math.max(...section.data.map(d => d.value), 0.01); + const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode)); + + return ( +
+ {section.data.map((item, i) => ( +
+ {item.key} +
+
+
+ {formatter(item.value)} +
+ ))} +
+ ); +}; + +// --- Line Chart --- + +const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string): React.ReactNode => { + if (!section.data?.length) { + return
Keine Daten
; + } + + const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode)); + + return ( +
+ + + + + + } /> + {section.series.map((s, i) => ( + + ))} + {section.series.length > 1 && } + + +
+ ); +}; + +// --- Area Chart --- + +const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string): React.ReactNode => { + if (!section.data?.length) { + return
Keine Daten
; + } + + const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode)); + + return ( +
+ + + + + + } /> + {section.series.map((s, i) => ( + + ))} + {section.series.length > 1 && } + + +
+ ); +}; + +// --- Pie Chart --- + +const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string): React.ReactNode => { + if (!section.data?.length) { + return
Keine Daten
; + } + + const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode)); + const total = section.data.reduce((sum, d) => sum + d.value, 0); + + const chartData = section.data.map((d, i) => ({ + name: d.key, + value: d.value, + color: d.color || CHART_COLORS[i % CHART_COLORS.length] + })); + + const _renderLabel = ({ name, percent }: any) => { + if (percent < 0.05) return null; + return `${name} (${(percent * 100).toFixed(0)}%)`; + }; + + return ( +
+ + + + {chartData.map((entry, i) => ( + + ))} + + [formatter(value), name]} + /> + { + const item = chartData.find(d => d.name === value); + return item ? `${value} (${((item.value / total) * 100).toFixed(1)}%)` : value; + }} + /> + + +
+ ); +}; + +// --- Table (proper component because it uses useState) --- + +interface ReportTableSectionProps { + section: ReportSectionTable; + currencyCode: string; +} + +const _ReportTableSection: React.FC = ({ section, currencyCode }) => { + const [showAll, setShowAll] = useState(false); + + if (!section.rows?.length) { + return
Keine Daten
; + } + + const maxRows = section.maxRows || 0; + const displayRows = maxRows > 0 && !showAll + ? section.rows.slice(0, maxRows) + : section.rows; + const hasMore = maxRows > 0 && section.rows.length > maxRows; + + const _formatCellValue = (col: ReportTableColumn, value: any, row: Record): string => { + if (col.formatValue) return col.formatValue(value, row); + if (value == null) return '—'; + + switch (col.format) { + case 'currency': + return _defaultFormatCurrency(Number(value), currencyCode); + case 'number': + return Number(value).toLocaleString('de-CH'); + case 'percent': + return `${(Number(value) * 100).toFixed(1)}%`; + case 'date': + return new Date(value).toLocaleDateString('de-CH'); + default: + return String(value); + } + }; + + return ( + <> + + + + {section.columns.map(col => ( + + ))} + + + + {displayRows.map((row, rowIdx) => ( + + {section.columns.map(col => ( + + ))} + + ))} + +
+ {col.label} +
+ {_formatCellValue(col, row[col.key], row)} +
+ {hasMore && !showAll && ( +
+ +
+ )} + + ); +}; + +// ============================================================================= +// 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/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/pages/billing/Billing.module.css b/src/pages/billing/Billing.module.css index f09b398..f5b50da 100644 --- a/src/pages/billing/Billing.module.css +++ b/src/pages/billing/Billing.module.css @@ -8,6 +8,8 @@ padding: 1.5rem; min-height: 100%; width: 100%; + display: flex; + flex-direction: column; } .pageHeader { @@ -123,6 +125,139 @@ font-weight: 500; } +/* ============================================================================ + KPI CARDS + ============================================================================ */ + +.kpiGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.kpiCard { + background: var(--surface-color, #1e1e1e); + border: 1px solid var(--border-color, #333); + border-radius: 12px; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.kpiLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.kpiValue { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary, #e0e0e0); +} + +.kpiSubtitle { + font-size: 0.75rem; + color: var(--text-tertiary, #666); +} + +/* ============================================================================ + CHARTS GRID + ============================================================================ */ + +.chartsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 1.5rem; +} + +/* ============================================================================ + TIME SERIES CHART + ============================================================================ */ + +.timeSeriesChart { + padding: 0.5rem 0; +} + +.timeSeriesBars { + display: flex; + align-items: flex-end; + gap: 4px; + height: 200px; + padding-bottom: 24px; + position: relative; +} + +.timeSeriesBarWrapper { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; +} + +.timeSeriesBarOuter { + flex: 1; + width: 100%; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.timeSeriesBar { + width: 80%; + max-width: 40px; + background: var(--primary-color, #f25843); + border-radius: 4px 4px 0 0; + min-height: 2px; + transition: height 0.3s ease; + cursor: pointer; +} + +.timeSeriesBar:hover { + opacity: 0.8; +} + +.timeSeriesLabel { + font-size: 0.6875rem; + color: var(--text-secondary, #888); + margin-top: 4px; + white-space: nowrap; +} + +/* ============================================================================ + SUMMARY TABLE + ============================================================================ */ + +.summaryTable { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.summaryRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.625rem 0.75rem; + background: var(--bg-secondary, #2a2a2a); + border-radius: 6px; + font-size: 0.875rem; +} + +.summaryRow span { + color: var(--text-secondary, #888); +} + +.summaryRow strong { + color: var(--text-primary, #e0e0e0); + font-family: monospace; +} + /* ============================================================================ STATISTICS ============================================================================ */ diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index a8ceecc..1f51613 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -2,17 +2,44 @@ * BillingDataView * * Unified billing page with internal tabs: - * - Tab "Übersicht": Balance cards + Statistics (from BillingDashboard) + * - 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, type UsageReport } from '../../hooks/useBilling'; +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; + costByFeature: Record; + costByMandate: Record; + timeSeries: Array<{ date: string; cost: number; count: number }>; +} + // ============================================================================ // BALANCE CARD COMPONENT // ============================================================================ @@ -22,14 +49,7 @@ interface BalanceCardProps { } const BalanceCard: React.FC = ({ balance }) => { - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('de-CH', { - style: 'currency', - currency: 'CHF' - }).format(amount); - }; - - const getBillingModelLabel = (model: string) => { + const _getBillingModelLabel = (model: string) => { switch (model) { case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; case 'PREPAY_USER': return 'Prepaid (Benutzer)'; @@ -43,10 +63,10 @@ const BalanceCard: React.FC = ({ balance }) => {

{balance.mandateName}

- {getBillingModelLabel(balance.billingModel)} + {_getBillingModelLabel(balance.billingModel)}
- {formatCurrency(balance.balance)} + {_formatCurrency(balance.balance)}
{balance.isWarning && (
@@ -57,86 +77,11 @@ const BalanceCard: React.FC = ({ balance }) => { ); }; -// ============================================================================ -// 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
; - } - - 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 Feature

- {Object.entries(statistics.costByFeature).length === 0 ? ( -
Keine Daten
- ) : ( -
- {Object.entries(statistics.costByFeature).map(([feature, cost]) => ( -
- {feature} - {formatCurrency(cost)} -
- ))} -
- )} -
-
- ); -}; - // ============================================================================ // TAB NAVIGATION COMPONENT // ============================================================================ -type TabType = 'overview' | 'transactions'; +type TabType = 'overview' | 'statistics' | 'transactions'; interface TabNavProps { activeTab: TabType; @@ -144,7 +89,7 @@ interface TabNavProps { } const TabNav: React.FC = ({ activeTab, onTabChange }) => { - const navLinkStyle = (isActive: boolean) => ({ + const _navLinkStyle = (isActive: boolean) => ({ padding: '8px 16px', textDecoration: 'none', borderRadius: '4px', @@ -164,22 +109,138 @@ const TabNav: React.FC = ({ activeTab, onTabChange }) => { borderBottom: '1px solid var(--color-border, #333)', paddingBottom: '8px' }}> - - + ); }; +// ============================================================================ +// 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 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: 'Features', + value: Object.keys(viewStats.costByFeature).length, + subtitle: topFeature ? `Top: ${topFeature[0]}` : 'Keine Nutzung' + }, + { + label: 'Mandanten', + value: Object.keys(viewStats.costByMandate).length, + subtitle: 'aktiv genutzt' + } + ] + }, + { + type: 'horizontalBar', + title: 'Kosten nach Anbieter', + data: _recordToChartData(viewStats.costByProvider), + 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 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: 'Features', value: String(Object.keys(viewStats.costByFeature).length) }, + { metric: 'Mandanten', value: String(Object.keys(viewStats.costByMandate).length) } + ] + } + ]; +} + // ============================================================================ // MAIN COMPONENT // ============================================================================ @@ -190,37 +251,77 @@ export const BillingDataView: React.FC = () => { // Dashboard state (for Overview tab) const { balances, - statistics, loading: dashboardLoading, - loadStatistics } = useBilling(); - const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month'); - const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); - const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1); + + // 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 statistics when period changes - useEffect(() => { - if (selectedPeriod === 'month') { - loadStatistics('month', selectedYear); - } else { - loadStatistics('year', selectedYear); + // 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 }); + console.log('📊 View statistics response:', response.data); + setViewStats(response.data); + } catch (err: any) { + console.error('Failed to load statistics:', err); + setViewStats(null); + } finally { + setStatsLoading(false); } - }, [selectedPeriod, selectedYear, loadStatistics]); + }, []); - // Load transactions - const loadTransactions = useCallback(async () => { + // 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 response = await api.get('/api/billing/view/users/transactions', { - params: { limit: 500 } - }); - setTransactions(response.data || []); + + const params: any = {}; + if (paginationParams) { + params.pagination = JSON.stringify(paginationParams); + } + + 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'); @@ -231,119 +332,53 @@ export const BillingDataView: React.FC = () => { // Load transactions when switching to transactions tab useEffect(() => { - if (activeTab === 'transactions' && transactions.length === 0) { - loadTransactions(); + if (activeTab === 'transactions') { + _loadTransactions(); } - }, [activeTab, transactions.length, loadTransactions]); + }, [activeTab, _loadTransactions]); - // Available 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' }, - ]; - - // Transform transactions for table display - const tableData = useMemo(() => { - return transactions.map((t, index) => ({ - _uniqueId: `${t.id}-${t.mandateId}-${index}`, - id: t.id, - createdAt: t.createdAt, - mandateId: t.mandateId, - mandateName: t.mandateName || '-', - userId: t.userId, - userName: t.userName || '-', - transactionType: t.transactionType, - description: t.description || '-', - aicoreProvider: t.aicoreProvider || '-', - featureCode: t.featureCode || '-', - amount: t.transactionType === 'DEBIT' ? -t.amount : t.amount, - })); - }, [transactions]); + // 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: 'datetime', - sortable: true, - width: 160, - }, - { - key: 'mandateName', - label: 'Mandant', - type: 'text', - sortable: true, - filterable: true, - searchable: true, - width: 150, - }, - { - key: 'userName', - label: 'Benutzer', - type: 'text', - sortable: true, - filterable: true, - searchable: true, - width: 150, - }, - { - key: 'transactionType', - label: 'Typ', - type: 'text', - sortable: true, - filterable: true, - filterOptions: ['CREDIT', 'DEBIT', 'ADJUSTMENT'], - width: 100, - }, - { - key: 'description', - label: 'Beschreibung', - type: 'text', - searchable: true, - width: 250, - }, - { - key: 'aicoreProvider', - label: 'Anbieter', - type: 'text', - sortable: true, - filterable: true, - width: 120, - }, - { - key: 'featureCode', - label: 'Feature', - type: 'text', - sortable: true, - filterable: true, - width: 120, - }, - { - key: 'amount', - label: 'Betrag (CHF)', - type: 'number', - sortable: true, - width: 120, - }, + { 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: '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 (
@@ -353,12 +388,14 @@ export const BillingDataView: React.FC = () => { - {/* Overview Tab */} + {/* ================================================================ */} + {/* Tab: Übersicht (My Overview) */} + {/* ================================================================ */} {activeTab === 'overview' && ( <> {/* Balance Cards */}
-

Guthaben

+

Mein Guthaben

{dashboardLoading ? (
Lade Guthaben...
) : balances.length === 0 ? ( @@ -372,49 +409,44 @@ export const BillingDataView: React.FC = () => { )}
- {/* Statistics */} + {/* Usage Statistics via FormGeneratorReport */}
-
-

Nutzungsstatistik

-
- - - {selectedPeriod === 'month' && ( - - )} -
-
- +
)} - {/* Transactions Tab */} + {/* ================================================================ */} + {/* Tab: Statistik (Dashboard) */} + {/* ================================================================ */} + {activeTab === 'statistics' && ( +
+ +
+ )} + + {/* ================================================================ */} + {/* Tab: Transaktionen */} + {/* ================================================================ */} {activeTab === 'transactions' && ( - <> +
{transactionsError && (
{transactionsError} @@ -422,7 +454,7 @@ export const BillingDataView: React.FC = () => { )} { searchable={true} filterable={true} sortable={true} - idField="_uniqueId" + selectable={false} emptyMessage="Keine Transaktionen vorhanden" - onRefresh={loadTransactions} + onRefresh={_loadTransactions} + hookData={transactionsHookData} /> - +
)}
);