fixed billing transactions mapping and added reporting
This commit is contained in:
parent
3d75880d13
commit
fcb8500104
9 changed files with 2238 additions and 264 deletions
346
package-lock.json
generated
346
package-lock.json
generated
|
|
@ -31,6 +31,7 @@
|
|||
"react-leaflet": "^5.0.0",
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-router-dom": "^7.7.1",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"xstate": "^5.20.1"
|
||||
},
|
||||
|
|
@ -1091,6 +1092,40 @@
|
|||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
||||
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
|
|
@ -1392,6 +1427,16 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
@ -1437,6 +1482,60 @@
|
|||
"@babel/types": "^7.20.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
|
|
@ -1549,6 +1648,11 @@
|
|||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz",
|
||||
|
|
@ -2274,6 +2378,14 @@
|
|||
"node": ">= 10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -2451,6 +2563,116 @@
|
|||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
|
|
@ -2468,6 +2690,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
||||
|
|
@ -2746,6 +2973,11 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
|
|
@ -3018,6 +3250,11 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
|
|
@ -3682,6 +3919,15 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -3721,6 +3967,14 @@
|
|||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
|
@ -5813,6 +6067,28 @@
|
|||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
|
@ -5870,6 +6146,45 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/relateurl": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
|
||||
|
|
@ -5946,6 +6261,11 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
|
|
@ -6386,6 +6706,11 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -6734,6 +7059,27 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.19",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"react-leaflet": "^5.0.0",
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-router-dom": "^7.7.1",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"xstate": "^5.20.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,773 @@
|
|||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
LineChart, Line, AreaChart, Area,
|
||||
PieChart, Pie, Cell, Legend
|
||||
} from 'recharts';
|
||||
import styles from './FormGeneratorReport.module.css';
|
||||
import type {
|
||||
FormGeneratorReportProps,
|
||||
ReportSection,
|
||||
ReportSectionKpi,
|
||||
ReportSectionBarChart,
|
||||
ReportSectionHorizontalBar,
|
||||
ReportSectionLineChart,
|
||||
ReportSectionPieChart,
|
||||
ReportSectionTable,
|
||||
ReportSectionAreaChart,
|
||||
ReportFilterState,
|
||||
ReportPeriod,
|
||||
ReportFilterConfig,
|
||||
ReportTableColumn
|
||||
} from './FormGeneratorReportTypes';
|
||||
|
||||
// =============================================================================
|
||||
// CHART COLORS
|
||||
// =============================================================================
|
||||
|
||||
const CHART_COLORS = [
|
||||
'var(--primary-color, #f25843)',
|
||||
'#4e79a7',
|
||||
'#59a14f',
|
||||
'#f28e2b',
|
||||
'#b07aa1',
|
||||
'#76b7b2',
|
||||
'#e15759',
|
||||
'#edc948',
|
||||
'#9c755f',
|
||||
'#bab0ac'
|
||||
];
|
||||
|
||||
const MONTH_LABELS: Record<string, string> = {
|
||||
'01': 'Jan', '02': 'Feb', '03': 'Mär', '04': 'Apr',
|
||||
'05': 'Mai', '06': 'Jun', '07': 'Jul', '08': 'Aug',
|
||||
'09': 'Sep', '10': 'Okt', '11': 'Nov', '12': 'Dez'
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function _defaultFormatCurrency(value: number, currencyCode: string): string {
|
||||
return `${currencyCode} ${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function _formatDateLabel(dateStr: string): string {
|
||||
const parts = dateStr.split('-');
|
||||
if (parts.length === 3) {
|
||||
return `${parseInt(parts[2], 10)}.`;
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return MONTH_LABELS[parts[1]] || parts[1];
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CUSTOM TOOLTIP
|
||||
// =============================================================================
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: any[];
|
||||
label?: string;
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
const _CustomTooltip: React.FC<CustomTooltipProps> = ({ active, payload, label, formatValue }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const displayLabel = label ? _formatDateLabel(String(label)) : '';
|
||||
|
||||
return (
|
||||
<div className={styles.customTooltip}>
|
||||
{displayLabel && <div className={styles.tooltipLabel}>{displayLabel}</div>}
|
||||
{payload.map((entry: any, i: number) => (
|
||||
<div key={i} className={styles.tooltipValue}>
|
||||
{entry.name}: <span>{formatValue ? formatValue(entry.value) : entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SECTION RENDERERS
|
||||
// =============================================================================
|
||||
|
||||
// --- KPI Grid ---
|
||||
|
||||
const _renderKpiGrid = (section: ReportSectionKpi): React.ReactNode => {
|
||||
return (
|
||||
<div className={styles.kpiGrid}>
|
||||
{section.items.map((item, i) => (
|
||||
<div key={i} className={styles.kpiCard} style={item.color ? { borderLeftColor: item.color, borderLeftWidth: 3 } : undefined}>
|
||||
<span className={styles.kpiLabel}>{item.label}</span>
|
||||
<span className={styles.kpiValue}>{item.value}</span>
|
||||
{item.subtitle && <span className={styles.kpiSubtitle}>{item.subtitle}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Bar Chart (vertical) ---
|
||||
|
||||
const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string): React.ReactNode => {
|
||||
if (!section.data?.length) {
|
||||
return <div className={styles.noData}>Keine Daten</div>;
|
||||
}
|
||||
|
||||
const chartData = section.data.map(d => ({
|
||||
name: _formatDateLabel(d.key),
|
||||
value: d.value,
|
||||
rawKey: d.key
|
||||
}));
|
||||
|
||||
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
|
||||
|
||||
return (
|
||||
<div className={styles.chartWrapper}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
|
||||
axisLine={{ stroke: 'var(--border-color, #333)' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
|
||||
axisLine={{ stroke: 'var(--border-color, #333)' }}
|
||||
tickFormatter={(v) => formatter(v)}
|
||||
width={70}
|
||||
/>
|
||||
<Tooltip content={<_CustomTooltip formatValue={formatter} />} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={section.color || CHART_COLORS[0]}
|
||||
radius={[4, 4, 0, 0]}
|
||||
name="Wert"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Horizontal Bar Chart ---
|
||||
|
||||
const _renderHorizontalBar = (section: ReportSectionHorizontalBar, currencyCode: string): React.ReactNode => {
|
||||
if (!section.data?.length) {
|
||||
return <div className={styles.noData}>Keine Daten</div>;
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...section.data.map(d => d.value), 0.01);
|
||||
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
|
||||
|
||||
return (
|
||||
<div className={styles.horizontalBarList}>
|
||||
{section.data.map((item, i) => (
|
||||
<div key={item.key} className={styles.horizontalBarRow}>
|
||||
<span className={styles.horizontalBarLabel} title={item.key}>{item.key}</span>
|
||||
<div className={styles.horizontalBarTrack}>
|
||||
<div
|
||||
className={styles.horizontalBarFill}
|
||||
style={{
|
||||
width: `${(item.value / maxValue) * 100}%`,
|
||||
background: item.color || CHART_COLORS[i % CHART_COLORS.length]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.horizontalBarValue}>{formatter(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Line Chart ---
|
||||
|
||||
const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string): React.ReactNode => {
|
||||
if (!section.data?.length) {
|
||||
return <div className={styles.noData}>Keine Daten</div>;
|
||||
}
|
||||
|
||||
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
|
||||
|
||||
return (
|
||||
<div className={styles.chartWrapper}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={section.data} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
|
||||
axisLine={{ stroke: 'var(--border-color, #333)' }}
|
||||
tickFormatter={_formatDateLabel}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
|
||||
axisLine={{ stroke: 'var(--border-color, #333)' }}
|
||||
tickFormatter={formatter}
|
||||
width={70}
|
||||
/>
|
||||
<Tooltip content={<_CustomTooltip formatValue={formatter} />} />
|
||||
{section.series.map((s, i) => (
|
||||
<Line
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.label}
|
||||
stroke={s.color || CHART_COLORS[i % CHART_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
))}
|
||||
{section.series.length > 1 && <Legend />}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Area Chart ---
|
||||
|
||||
const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string): React.ReactNode => {
|
||||
if (!section.data?.length) {
|
||||
return <div className={styles.noData}>Keine Daten</div>;
|
||||
}
|
||||
|
||||
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
|
||||
|
||||
return (
|
||||
<div className={styles.chartWrapper}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={section.data} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
|
||||
axisLine={{ stroke: 'var(--border-color, #333)' }}
|
||||
tickFormatter={_formatDateLabel}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
|
||||
axisLine={{ stroke: 'var(--border-color, #333)' }}
|
||||
tickFormatter={formatter}
|
||||
width={70}
|
||||
/>
|
||||
<Tooltip content={<_CustomTooltip formatValue={formatter} />} />
|
||||
{section.series.map((s, i) => (
|
||||
<Area
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.label}
|
||||
stroke={s.color || CHART_COLORS[i % CHART_COLORS.length]}
|
||||
fill={s.color || CHART_COLORS[i % CHART_COLORS.length]}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
{section.series.length > 1 && <Legend />}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Pie Chart ---
|
||||
|
||||
const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string): React.ReactNode => {
|
||||
if (!section.data?.length) {
|
||||
return <div className={styles.noData}>Keine Daten</div>;
|
||||
}
|
||||
|
||||
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
|
||||
const total = section.data.reduce((sum, d) => sum + d.value, 0);
|
||||
|
||||
const chartData = section.data.map((d, i) => ({
|
||||
name: d.key,
|
||||
value: d.value,
|
||||
color: d.color || CHART_COLORS[i % CHART_COLORS.length]
|
||||
}));
|
||||
|
||||
const _renderLabel = ({ name, percent }: any) => {
|
||||
if (percent < 0.05) return null;
|
||||
return `${name} (${(percent * 100).toFixed(0)}%)`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chartWrapperSmall}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={section.donut ? '55%' : 0}
|
||||
outerRadius="80%"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={_renderLabel}
|
||||
labelLine={false}
|
||||
>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [formatter(value), name]}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value: string) => {
|
||||
const item = chartData.find(d => d.name === value);
|
||||
return item ? `${value} (${((item.value / total) * 100).toFixed(1)}%)` : value;
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Table (proper component because it uses useState) ---
|
||||
|
||||
interface ReportTableSectionProps {
|
||||
section: ReportSectionTable;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
const _ReportTableSection: React.FC<ReportTableSectionProps> = ({ section, currencyCode }) => {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
if (!section.rows?.length) {
|
||||
return <div className={styles.noData}>Keine Daten</div>;
|
||||
}
|
||||
|
||||
const maxRows = section.maxRows || 0;
|
||||
const displayRows = maxRows > 0 && !showAll
|
||||
? section.rows.slice(0, maxRows)
|
||||
: section.rows;
|
||||
const hasMore = maxRows > 0 && section.rows.length > maxRows;
|
||||
|
||||
const _formatCellValue = (col: ReportTableColumn, value: any, row: Record<string, any>): string => {
|
||||
if (col.formatValue) return col.formatValue(value, row);
|
||||
if (value == null) return '—';
|
||||
|
||||
switch (col.format) {
|
||||
case 'currency':
|
||||
return _defaultFormatCurrency(Number(value), currencyCode);
|
||||
case 'number':
|
||||
return Number(value).toLocaleString('de-CH');
|
||||
case 'percent':
|
||||
return `${(Number(value) * 100).toFixed(1)}%`;
|
||||
case 'date':
|
||||
return new Date(value).toLocaleDateString('de-CH');
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<table className={styles.reportTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
{section.columns.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={col.align === 'right' ? styles.alignRight : col.align === 'center' ? styles.alignCenter : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayRows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx}>
|
||||
{section.columns.map(col => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={[
|
||||
col.align === 'right' ? styles.alignRight : col.align === 'center' ? styles.alignCenter : '',
|
||||
col.format === 'currency' || col.format === 'number' ? styles.monoValue : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{_formatCellValue(col, row[col.key], row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{hasMore && !showAll && (
|
||||
<div className={styles.showMoreRow}>
|
||||
<button className={styles.showMoreButton} onClick={() => setShowAll(true)}>
|
||||
Alle {section.rows.length} Einträge anzeigen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SECTION WRAPPER
|
||||
// =============================================================================
|
||||
|
||||
interface SectionWrapperProps {
|
||||
section: ReportSection;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
const _SectionWrapper: React.FC<SectionWrapperProps> = ({ section, currencyCode }) => {
|
||||
const spanClass = section.type === 'kpiGrid' || section.span === 'full'
|
||||
? styles.sectionFull
|
||||
: section.span === 'half'
|
||||
? styles.sectionHalf
|
||||
: styles.sectionFull;
|
||||
|
||||
// KPI grid renders without card wrapper
|
||||
if (section.type === 'kpiGrid') {
|
||||
return (
|
||||
<div className={spanClass}>
|
||||
{section.title && <h3 className={styles.sectionTitle}>{section.title}</h3>}
|
||||
{_renderKpiGrid(section)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const _renderContent = (): React.ReactNode => {
|
||||
switch (section.type) {
|
||||
case 'barChart':
|
||||
return _renderBarChart(section, currencyCode);
|
||||
case 'horizontalBar':
|
||||
return _renderHorizontalBar(section, currencyCode);
|
||||
case 'lineChart':
|
||||
return _renderLineChart(section, currencyCode);
|
||||
case 'areaChart':
|
||||
return _renderAreaChart(section, currencyCode);
|
||||
case 'pieChart':
|
||||
return _renderPieChart(section, currencyCode);
|
||||
case 'table':
|
||||
return <_ReportTableSection section={section} currencyCode={currencyCode} />;
|
||||
default:
|
||||
return <div className={styles.noData}>Unbekannter Sektionstyp</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${spanClass} ${styles.sectionCard}`}>
|
||||
{section.title && <h3 className={styles.sectionTitle}>{section.title}</h3>}
|
||||
{section.description && <p className={styles.sectionDescription}>{section.description}</p>}
|
||||
{_renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// TOOLBAR (FILTERS + PERIOD SELECTOR)
|
||||
// =============================================================================
|
||||
|
||||
interface ToolbarProps {
|
||||
periodSelector?: FormGeneratorReportProps['periodSelector'];
|
||||
dateRangeSelector?: FormGeneratorReportProps['dateRangeSelector'];
|
||||
filters?: ReportFilterConfig[];
|
||||
filterState: ReportFilterState;
|
||||
onFilterStateChange: (state: ReportFilterState) => void;
|
||||
}
|
||||
|
||||
const _Toolbar: React.FC<ToolbarProps> = ({
|
||||
periodSelector, dateRangeSelector, filters, filterState, onFilterStateChange
|
||||
}) => {
|
||||
const hasPeriod = !!periodSelector;
|
||||
const hasDateRange = dateRangeSelector?.enabled;
|
||||
const hasFilters = filters && filters.length > 0;
|
||||
|
||||
if (!hasPeriod && !hasDateRange && !hasFilters) return null;
|
||||
|
||||
const _handlePeriodChange = (period: ReportPeriod) => {
|
||||
onFilterStateChange({ ...filterState, period });
|
||||
};
|
||||
|
||||
const _handleYearChange = (year: number) => {
|
||||
onFilterStateChange({ ...filterState, year });
|
||||
};
|
||||
|
||||
const _handleMonthChange = (month: number) => {
|
||||
onFilterStateChange({ ...filterState, month });
|
||||
};
|
||||
|
||||
const _handleFilterChange = (key: string, value: string | string[]) => {
|
||||
onFilterStateChange({
|
||||
...filterState,
|
||||
filters: { ...filterState.filters, [key]: value }
|
||||
});
|
||||
};
|
||||
|
||||
const _handleDateRangeChange = (field: 'from' | 'to', dateStr: string) => {
|
||||
const dateRange = filterState.dateRange || { from: new Date(), to: new Date() };
|
||||
onFilterStateChange({
|
||||
...filterState,
|
||||
dateRange: { ...dateRange, [field]: new Date(dateStr) }
|
||||
});
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i);
|
||||
|
||||
const monthOptions = [
|
||||
{ value: 1, label: 'Januar' }, { value: 2, label: 'Februar' },
|
||||
{ value: 3, label: 'März' }, { value: 4, label: 'April' },
|
||||
{ value: 5, label: 'Mai' }, { value: 6, label: 'Juni' },
|
||||
{ value: 7, label: 'Juli' }, { value: 8, label: 'August' },
|
||||
{ value: 9, label: 'September' }, { value: 10, label: 'Oktober' },
|
||||
{ value: 11, label: 'November' }, { value: 12, label: 'Dezember' }
|
||||
];
|
||||
|
||||
const _renderPeriodLabel = (p: ReportPeriod): string => {
|
||||
const labels: Record<ReportPeriod, string> = {
|
||||
day: 'Tagesansicht',
|
||||
week: 'Wochenansicht',
|
||||
month: 'Monatsansicht',
|
||||
quarter: 'Quartalsansicht',
|
||||
year: 'Jahresansicht'
|
||||
};
|
||||
return labels[p] || p;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
{/* Period Selector */}
|
||||
{hasPeriod && (
|
||||
<div className={styles.toolbarGroup}>
|
||||
<span className={styles.toolbarLabel}>Zeitraum</span>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={filterState.period || periodSelector!.defaultPeriod}
|
||||
onChange={(e) => _handlePeriodChange(e.target.value as ReportPeriod)}
|
||||
>
|
||||
{periodSelector!.periods.map(p => (
|
||||
<option key={p} value={p}>{_renderPeriodLabel(p)}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{periodSelector!.showYear !== false && (
|
||||
<select
|
||||
className={styles.select}
|
||||
value={filterState.year || currentYear}
|
||||
onChange={(e) => _handleYearChange(Number(e.target.value))}
|
||||
>
|
||||
{yearOptions.map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{periodSelector!.showMonth !== false && filterState.period === 'day' && (
|
||||
<select
|
||||
className={styles.select}
|
||||
value={filterState.month || new Date().getMonth() + 1}
|
||||
onChange={(e) => _handleMonthChange(Number(e.target.value))}
|
||||
>
|
||||
{monthOptions.map(m => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
{hasPeriod && (hasDateRange || hasFilters) && (
|
||||
<div className={styles.toolbarSeparator} />
|
||||
)}
|
||||
|
||||
{/* Date Range */}
|
||||
{hasDateRange && (
|
||||
<div className={styles.toolbarGroup}>
|
||||
<span className={styles.toolbarLabel}>Von</span>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={filterState.dateRange?.from?.toISOString().split('T')[0] || ''}
|
||||
onChange={(e) => _handleDateRangeChange('from', e.target.value)}
|
||||
/>
|
||||
<span className={styles.toolbarLabel}>Bis</span>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={filterState.dateRange?.to?.toISOString().split('T')[0] || ''}
|
||||
onChange={(e) => _handleDateRangeChange('to', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
{hasDateRange && hasFilters && (
|
||||
<div className={styles.toolbarSeparator} />
|
||||
)}
|
||||
|
||||
{/* Custom Filters */}
|
||||
{hasFilters && filters!.map(filter => (
|
||||
<div key={filter.key} className={styles.toolbarGroup}>
|
||||
<span className={styles.toolbarLabel}>{filter.label}</span>
|
||||
{filter.type === 'text' ? (
|
||||
<input
|
||||
type="text"
|
||||
className={styles.textInput}
|
||||
placeholder={filter.placeholder || ''}
|
||||
value={(filterState.filters[filter.key] as string) || ''}
|
||||
onChange={(e) => _handleFilterChange(filter.key, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
className={styles.select}
|
||||
value={(filterState.filters[filter.key] as string) || ''}
|
||||
onChange={(e) => _handleFilterChange(filter.key, e.target.value)}
|
||||
>
|
||||
<option value="">{filter.placeholder || 'Alle'}</option>
|
||||
{filter.options?.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export const FormGeneratorReport: React.FC<FormGeneratorReportProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
sections,
|
||||
loading = false,
|
||||
noDataMessage = 'Keine Daten verfügbar',
|
||||
periodSelector,
|
||||
dateRangeSelector,
|
||||
filters,
|
||||
onFilterChange,
|
||||
currencyCode = 'CHF',
|
||||
className
|
||||
}) => {
|
||||
// Build initial filter state
|
||||
const initialFilterState = useMemo((): ReportFilterState => {
|
||||
const state: ReportFilterState = { filters: {} };
|
||||
|
||||
if (periodSelector) {
|
||||
state.period = periodSelector.defaultPeriod;
|
||||
state.year = periodSelector.defaultYear || new Date().getFullYear();
|
||||
state.month = periodSelector.defaultMonth || new Date().getMonth() + 1;
|
||||
}
|
||||
|
||||
if (dateRangeSelector?.enabled) {
|
||||
state.dateRange = {
|
||||
from: dateRangeSelector.defaultFrom || new Date(new Date().getFullYear(), 0, 1),
|
||||
to: dateRangeSelector.defaultTo || new Date()
|
||||
};
|
||||
}
|
||||
|
||||
if (filters) {
|
||||
for (const f of filters) {
|
||||
if (f.defaultValue !== undefined) {
|
||||
state.filters[f.key] = f.defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}, []); // intentionally empty - only compute once
|
||||
|
||||
const [filterState, setFilterState] = useState<ReportFilterState>(initialFilterState);
|
||||
|
||||
// Notify parent when filters change
|
||||
const _handleFilterStateChange = useCallback((newState: ReportFilterState) => {
|
||||
setFilterState(newState);
|
||||
onFilterChange?.(newState);
|
||||
}, [onFilterChange]);
|
||||
|
||||
// Initial load: notify parent of default filter state
|
||||
useEffect(() => {
|
||||
onFilterChange?.(initialFilterState);
|
||||
}, []); // intentionally once on mount
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`${styles.reportContainer} ${className || ''}`}>
|
||||
{title && (
|
||||
<div className={styles.reportHeader}>
|
||||
<h2 className={styles.reportTitle}>{title}</h2>
|
||||
{subtitle && <p className={styles.reportSubtitle}>{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
<_Toolbar
|
||||
periodSelector={periodSelector}
|
||||
dateRangeSelector={dateRangeSelector}
|
||||
filters={filters}
|
||||
filterState={filterState}
|
||||
onFilterStateChange={_handleFilterStateChange}
|
||||
/>
|
||||
<div className={styles.loadingContainer}>Lade Daten...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No sections
|
||||
if (!sections || sections.length === 0) {
|
||||
return (
|
||||
<div className={`${styles.reportContainer} ${className || ''}`}>
|
||||
{title && (
|
||||
<div className={styles.reportHeader}>
|
||||
<h2 className={styles.reportTitle}>{title}</h2>
|
||||
{subtitle && <p className={styles.reportSubtitle}>{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
<_Toolbar
|
||||
periodSelector={periodSelector}
|
||||
dateRangeSelector={dateRangeSelector}
|
||||
filters={filters}
|
||||
filterState={filterState}
|
||||
onFilterStateChange={_handleFilterStateChange}
|
||||
/>
|
||||
<div className={styles.noData}>{noDataMessage}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.reportContainer} ${className || ''}`}>
|
||||
{title && (
|
||||
<div className={styles.reportHeader}>
|
||||
<h2 className={styles.reportTitle}>{title}</h2>
|
||||
{subtitle && <p className={styles.reportSubtitle}>{subtitle}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<_Toolbar
|
||||
periodSelector={periodSelector}
|
||||
dateRangeSelector={dateRangeSelector}
|
||||
filters={filters}
|
||||
filterState={filterState}
|
||||
onFilterStateChange={_handleFilterStateChange}
|
||||
/>
|
||||
|
||||
<div className={styles.sectionsGrid}>
|
||||
{sections.map((section, i) => (
|
||||
<_SectionWrapper key={i} section={section} currencyCode={currencyCode} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormGeneratorReport;
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
// =============================================================================
|
||||
// FormGeneratorReport - Types
|
||||
// Generic reporting component with charts, KPIs, tables, and filters
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// FILTER TYPES
|
||||
// =============================================================================
|
||||
|
||||
/** Period granularity for time-based reports */
|
||||
export type ReportPeriod = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||
|
||||
/** Date range with from/to */
|
||||
export interface ReportDateRange {
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
/** Filter option for select/multiselect filters */
|
||||
export interface ReportFilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** A single filter definition */
|
||||
export interface ReportFilterConfig {
|
||||
/** Unique key for this filter */
|
||||
key: string;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** Filter type */
|
||||
type: 'select' | 'multiselect' | 'text';
|
||||
/** Available options (for select/multiselect) */
|
||||
options?: ReportFilterOption[];
|
||||
/** Default value */
|
||||
defaultValue?: string | string[];
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Period selector configuration */
|
||||
export interface ReportPeriodSelectorConfig {
|
||||
/** Available periods */
|
||||
periods: ReportPeriod[];
|
||||
/** Default period */
|
||||
defaultPeriod: ReportPeriod;
|
||||
/** Whether to show year selector */
|
||||
showYear?: boolean;
|
||||
/** Whether to show month selector (when period is 'day') */
|
||||
showMonth?: boolean;
|
||||
/** Default year */
|
||||
defaultYear?: number;
|
||||
/** Default month (1-12) */
|
||||
defaultMonth?: number;
|
||||
}
|
||||
|
||||
/** Date range selector configuration */
|
||||
export interface ReportDateRangeSelectorConfig {
|
||||
/** Whether the date range selector is enabled */
|
||||
enabled: boolean;
|
||||
/** Default from date */
|
||||
defaultFrom?: Date;
|
||||
/** Default to date */
|
||||
defaultTo?: Date;
|
||||
}
|
||||
|
||||
/** Combined filter state passed to the data callback */
|
||||
export interface ReportFilterState {
|
||||
/** Selected period */
|
||||
period?: ReportPeriod;
|
||||
/** Selected year */
|
||||
year?: number;
|
||||
/** Selected month (1-12) */
|
||||
month?: number;
|
||||
/** Date range */
|
||||
dateRange?: ReportDateRange;
|
||||
/** Custom filter values: key -> value(s) */
|
||||
filters: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SECTION TYPES
|
||||
// =============================================================================
|
||||
|
||||
/** KPI item for kpiGrid section */
|
||||
export interface ReportKpiItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
/** Optional color (CSS variable or hex) */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Column definition for table sections */
|
||||
export interface ReportTableColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
/** How to format the value */
|
||||
format?: 'text' | 'number' | 'currency' | 'percent' | 'date';
|
||||
/** Text alignment */
|
||||
align?: 'left' | 'center' | 'right';
|
||||
/** Custom formatter function */
|
||||
formatValue?: (value: any, row: Record<string, any>) => string;
|
||||
}
|
||||
|
||||
/** Data point for chart sections */
|
||||
export interface ReportChartDataPoint {
|
||||
/** Key/label for the data point (x-axis or category) */
|
||||
key: string;
|
||||
/** Numeric value */
|
||||
value: number;
|
||||
/** Optional secondary value */
|
||||
value2?: number;
|
||||
/** Optional color override */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Time series data point */
|
||||
export interface ReportTimeSeriesPoint {
|
||||
/** Date string (ISO format: "2026-02-08" or "2026-02") */
|
||||
date: string;
|
||||
/** Numeric values, keyed by series name */
|
||||
[seriesKey: string]: string | number;
|
||||
}
|
||||
|
||||
/** Series definition for multi-series charts */
|
||||
export interface ReportChartSeries {
|
||||
key: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SECTION DEFINITIONS
|
||||
// =============================================================================
|
||||
|
||||
interface ReportSectionBase {
|
||||
/** Optional section title */
|
||||
title?: string;
|
||||
/** Optional description text */
|
||||
description?: string;
|
||||
/** Grid span: 'full' takes full width, 'half' takes 50% */
|
||||
span?: 'full' | 'half';
|
||||
}
|
||||
|
||||
/** KPI grid: display metric cards */
|
||||
export interface ReportSectionKpi extends ReportSectionBase {
|
||||
type: 'kpiGrid';
|
||||
items: ReportKpiItem[];
|
||||
}
|
||||
|
||||
/** Vertical bar chart */
|
||||
export interface ReportSectionBarChart extends ReportSectionBase {
|
||||
type: 'barChart';
|
||||
data: ReportChartDataPoint[];
|
||||
/** Value format for tooltips/labels */
|
||||
formatValue?: (value: number) => string;
|
||||
/** Bar color */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Horizontal bar chart (for comparisons/rankings) */
|
||||
export interface ReportSectionHorizontalBar extends ReportSectionBase {
|
||||
type: 'horizontalBar';
|
||||
data: ReportChartDataPoint[];
|
||||
/** Value format for tooltips/labels */
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
/** Line chart (trends over time) */
|
||||
export interface ReportSectionLineChart extends ReportSectionBase {
|
||||
type: 'lineChart';
|
||||
data: ReportTimeSeriesPoint[];
|
||||
series: ReportChartSeries[];
|
||||
/** Value format for tooltips/labels */
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
/** Pie/donut chart (distribution) */
|
||||
export interface ReportSectionPieChart extends ReportSectionBase {
|
||||
type: 'pieChart';
|
||||
data: ReportChartDataPoint[];
|
||||
/** Show as donut (hollow center) */
|
||||
donut?: boolean;
|
||||
/** Value format for tooltips/labels */
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
/** Simple data table */
|
||||
export interface ReportSectionTable extends ReportSectionBase {
|
||||
type: 'table';
|
||||
columns: ReportTableColumn[];
|
||||
rows: Record<string, any>[];
|
||||
/** Maximum rows to display (default: all) */
|
||||
maxRows?: number;
|
||||
}
|
||||
|
||||
/** Area chart (filled line chart) */
|
||||
export interface ReportSectionAreaChart extends ReportSectionBase {
|
||||
type: 'areaChart';
|
||||
data: ReportTimeSeriesPoint[];
|
||||
series: ReportChartSeries[];
|
||||
/** Value format for tooltips/labels */
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
/** Union of all section types */
|
||||
export type ReportSection =
|
||||
| ReportSectionKpi
|
||||
| ReportSectionBarChart
|
||||
| ReportSectionHorizontalBar
|
||||
| ReportSectionLineChart
|
||||
| ReportSectionPieChart
|
||||
| ReportSectionTable
|
||||
| ReportSectionAreaChart;
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT PROPS
|
||||
// =============================================================================
|
||||
|
||||
export interface FormGeneratorReportProps {
|
||||
/** Report title (optional) */
|
||||
title?: string;
|
||||
/** Report subtitle/description (optional) */
|
||||
subtitle?: string;
|
||||
|
||||
/** Report sections to render */
|
||||
sections: ReportSection[];
|
||||
|
||||
/** Loading state */
|
||||
loading?: boolean;
|
||||
|
||||
/** No data message */
|
||||
noDataMessage?: string;
|
||||
|
||||
// --- Filter Configuration ---
|
||||
|
||||
/** Period selector config */
|
||||
periodSelector?: ReportPeriodSelectorConfig;
|
||||
|
||||
/** Date range selector config */
|
||||
dateRangeSelector?: ReportDateRangeSelectorConfig;
|
||||
|
||||
/** Custom filter definitions */
|
||||
filters?: ReportFilterConfig[];
|
||||
|
||||
/** Called when any filter changes. Parent should reload data and update sections. */
|
||||
onFilterChange?: (filterState: ReportFilterState) => void;
|
||||
|
||||
/** Currency code for formatting (default: 'CHF') */
|
||||
currencyCode?: string;
|
||||
|
||||
/** Custom CSS class */
|
||||
className?: string;
|
||||
}
|
||||
24
src/components/FormGenerator/FormGeneratorReport/index.ts
Normal file
24
src/components/FormGenerator/FormGeneratorReport/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export { FormGeneratorReport, default } from './FormGeneratorReport';
|
||||
export type {
|
||||
FormGeneratorReportProps,
|
||||
ReportSection,
|
||||
ReportSectionKpi,
|
||||
ReportSectionBarChart,
|
||||
ReportSectionHorizontalBar,
|
||||
ReportSectionLineChart,
|
||||
ReportSectionPieChart,
|
||||
ReportSectionTable,
|
||||
ReportSectionAreaChart,
|
||||
ReportFilterState,
|
||||
ReportFilterConfig,
|
||||
ReportFilterOption,
|
||||
ReportPeriod,
|
||||
ReportPeriodSelectorConfig,
|
||||
ReportDateRangeSelectorConfig,
|
||||
ReportDateRange,
|
||||
ReportKpiItem,
|
||||
ReportTableColumn,
|
||||
ReportChartDataPoint,
|
||||
ReportTimeSeriesPoint,
|
||||
ReportChartSeries
|
||||
} from './FormGeneratorReportTypes';
|
||||
|
|
@ -3,6 +3,7 @@ export * from './FormGeneratorTable';
|
|||
export * from './FormGeneratorList';
|
||||
export * from './FormGeneratorForm';
|
||||
export * from './FormGeneratorControls';
|
||||
export * from './FormGeneratorReport';
|
||||
|
||||
// Alias FormGeneratorTable as FormGenerator for backward compatibility
|
||||
export { FormGeneratorTable as FormGenerator, FormGeneratorTableComponent as FormGeneratorComponent } from './FormGeneratorTable';
|
||||
|
|
|
|||
|
|
@ -8,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
|
||||
============================================================================ */
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
costByFeature: Record<string, number>;
|
||||
costByMandate: Record<string, number>;
|
||||
timeSeries: Array<{ date: string; cost: number; count: number }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BALANCE CARD COMPONENT
|
||||
// ============================================================================
|
||||
|
|
@ -22,14 +49,7 @@ interface BalanceCardProps {
|
|||
}
|
||||
|
||||
const BalanceCard: React.FC<BalanceCardProps> = ({ 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<BalanceCardProps> = ({ balance }) => {
|
|||
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
||||
<div className={styles.balanceHeader}>
|
||||
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
||||
<span className={styles.billingModel}>{getBillingModelLabel(balance.billingModel)}</span>
|
||||
<span className={styles.billingModel}>{_getBillingModelLabel(balance.billingModel)}</span>
|
||||
</div>
|
||||
<div className={styles.balanceAmount}>
|
||||
{formatCurrency(balance.balance)}
|
||||
{_formatCurrency(balance.balance)}
|
||||
</div>
|
||||
{balance.isWarning && (
|
||||
<div className={styles.warningBadge}>
|
||||
|
|
@ -57,86 +77,11 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
|
|||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// STATISTICS CHART COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
interface StatisticsChartProps {
|
||||
statistics: UsageReport | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const StatisticsChart: React.FC<StatisticsChartProps> = ({ statistics, loading }) => {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: 'CHF'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loadingPlaceholder}>Lade Statistiken...</div>;
|
||||
}
|
||||
|
||||
if (!statistics) {
|
||||
return <div className={styles.noData}>Keine Statistiken verfügbar</div>;
|
||||
}
|
||||
|
||||
const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1);
|
||||
|
||||
return (
|
||||
<div className={styles.statisticsChart}>
|
||||
<div className={styles.totalCost}>
|
||||
<span className={styles.totalLabel}>Gesamtkosten</span>
|
||||
<span className={styles.totalAmount}>{formatCurrency(statistics.totalCost)}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartSection}>
|
||||
<h4>Kosten nach Anbieter</h4>
|
||||
{Object.entries(statistics.costByProvider).length === 0 ? (
|
||||
<div className={styles.noData}>Keine Daten</div>
|
||||
) : (
|
||||
<div className={styles.barChart}>
|
||||
{Object.entries(statistics.costByProvider).map(([provider, cost]) => (
|
||||
<div key={provider} className={styles.barRow}>
|
||||
<span className={styles.barLabel}>{provider}</span>
|
||||
<div className={styles.barContainer}>
|
||||
<div
|
||||
className={styles.bar}
|
||||
style={{ width: `${(cost / maxProviderCost) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.barValue}>{formatCurrency(cost)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.chartSection}>
|
||||
<h4>Kosten nach Feature</h4>
|
||||
{Object.entries(statistics.costByFeature).length === 0 ? (
|
||||
<div className={styles.noData}>Keine Daten</div>
|
||||
) : (
|
||||
<div className={styles.featureList}>
|
||||
{Object.entries(statistics.costByFeature).map(([feature, cost]) => (
|
||||
<div key={feature} className={styles.featureRow}>
|
||||
<span className={styles.featureLabel}>{feature}</span>
|
||||
<span className={styles.featureValue}>{formatCurrency(cost)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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<TabNavProps> = ({ 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<TabNavProps> = ({ activeTab, onTabChange }) => {
|
|||
borderBottom: '1px solid var(--color-border, #333)',
|
||||
paddingBottom: '8px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => onTabChange('overview')}
|
||||
style={navLinkStyle(activeTab === 'overview')}
|
||||
>
|
||||
<button onClick={() => onTabChange('overview')} style={_navLinkStyle(activeTab === 'overview')}>
|
||||
Übersicht
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onTabChange('transactions')}
|
||||
style={navLinkStyle(activeTab === 'transactions')}
|
||||
>
|
||||
<button onClick={() => onTabChange('statistics')} style={_navLinkStyle(activeTab === 'statistics')}>
|
||||
Statistik
|
||||
</button>
|
||||
<button onClick={() => onTabChange('transactions')} style={_navLinkStyle(activeTab === 'transactions')}>
|
||||
Transaktionen
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS: Convert viewStats to ReportSection arrays
|
||||
// ============================================================================
|
||||
|
||||
function _recordToChartData(record: Record<string, number>): ReportChartDataPoint[] {
|
||||
return Object.entries(record)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([key, value]) => ({ key: key || '—', value }));
|
||||
}
|
||||
|
||||
function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] {
|
||||
const topProvider = Object.entries(viewStats.costByProvider).sort((a, b) => b[1] - a[1])[0];
|
||||
const 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<ViewStatistics | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
// Transactions state (for Transactions tab)
|
||||
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
|
||||
const [transactionsLoading, setTransactionsLoading] = useState(false);
|
||||
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
||||
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
||||
|
||||
// Load 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;
|
||||
}
|
||||
}, [selectedPeriod, selectedYear, loadStatistics]);
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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<ReportSection[]>(() => {
|
||||
if (!viewStats) return [];
|
||||
return _buildOverviewSections(viewStats);
|
||||
}, [viewStats]);
|
||||
|
||||
const statisticsSections = useMemo<ReportSection[]>(() => {
|
||||
if (!viewStats) return [];
|
||||
return _buildStatisticsSections(viewStats);
|
||||
}, [viewStats]);
|
||||
|
||||
// Period selector config (shared between overview and statistics)
|
||||
const periodSelectorConfig = useMemo(() => ({
|
||||
periods: ['month' as const, 'day' as const],
|
||||
defaultPeriod: 'month' as const,
|
||||
showYear: true,
|
||||
showMonth: true,
|
||||
defaultYear: new Date().getFullYear(),
|
||||
defaultMonth: new Date().getMonth() + 1
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<div className={styles.billingDashboard}>
|
||||
<header className={styles.pageHeader}>
|
||||
|
|
@ -353,12 +388,14 @@ export const BillingDataView: React.FC = () => {
|
|||
|
||||
<TabNav activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{/* Overview Tab */}
|
||||
{/* ================================================================ */}
|
||||
{/* Tab: Übersicht (My Overview) */}
|
||||
{/* ================================================================ */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Balance Cards */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Guthaben</h2>
|
||||
<h2 className={styles.sectionTitle}>Mein Guthaben</h2>
|
||||
{dashboardLoading ? (
|
||||
<div className={styles.loadingPlaceholder}>Lade Guthaben...</div>
|
||||
) : balances.length === 0 ? (
|
||||
|
|
@ -372,49 +409,44 @@ export const BillingDataView: React.FC = () => {
|
|||
)}
|
||||
</section>
|
||||
|
||||
{/* Statistics */}
|
||||
{/* Usage Statistics via FormGeneratorReport */}
|
||||
<section className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Nutzungsstatistik</h2>
|
||||
<div className={styles.periodSelector}>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value as 'month' | 'year')}
|
||||
className={styles.select}
|
||||
>
|
||||
<option value="month">Monat</option>
|
||||
<option value="year">Jahr</option>
|
||||
</select>
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => setSelectedYear(Number(e.target.value))}
|
||||
className={styles.select}
|
||||
>
|
||||
{availableYears.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedPeriod === 'month' && (
|
||||
<select
|
||||
value={selectedMonth}
|
||||
onChange={(e) => setSelectedMonth(Number(e.target.value))}
|
||||
className={styles.select}
|
||||
>
|
||||
{availableMonths.map((month) => (
|
||||
<option key={month.value} value={month.value}>{month.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatisticsChart statistics={statistics} loading={dashboardLoading} />
|
||||
<FormGeneratorReport
|
||||
title="Nutzungsübersicht"
|
||||
periodSelector={periodSelectorConfig}
|
||||
onFilterChange={_handleStatsFilterChange}
|
||||
loading={statsLoading}
|
||||
sections={overviewSections}
|
||||
noDataMessage="Keine Statistiken verfügbar"
|
||||
currencyCode="CHF"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Transactions Tab */}
|
||||
{/* ================================================================ */}
|
||||
{/* Tab: Statistik (Dashboard) */}
|
||||
{/* ================================================================ */}
|
||||
{activeTab === 'statistics' && (
|
||||
<section className={styles.section}>
|
||||
<FormGeneratorReport
|
||||
title="Nutzungsstatistik"
|
||||
subtitle="Detaillierte Analyse der AI-Nutzung"
|
||||
periodSelector={periodSelectorConfig}
|
||||
onFilterChange={_handleStatsFilterChange}
|
||||
loading={statsLoading}
|
||||
sections={statisticsSections}
|
||||
noDataMessage="Keine Statistiken verfügbar"
|
||||
currencyCode="CHF"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ================================================================ */}
|
||||
{/* Tab: Transaktionen */}
|
||||
{/* ================================================================ */}
|
||||
{activeTab === 'transactions' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '500px' }}>
|
||||
{transactionsError && (
|
||||
<div className={styles.errorMessage}>
|
||||
{transactionsError}
|
||||
|
|
@ -422,7 +454,7 @@ export const BillingDataView: React.FC = () => {
|
|||
)}
|
||||
|
||||
<FormGeneratorTable
|
||||
data={tableData}
|
||||
data={transactions}
|
||||
columns={columns}
|
||||
loading={transactionsLoading}
|
||||
pagination={true}
|
||||
|
|
@ -430,11 +462,12 @@ 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}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue