fixed billing transactions mapping and added reporting

This commit is contained in:
ValueOn AG 2026-02-08 13:15:23 +01:00
parent 3d75880d13
commit fcb8500104
9 changed files with 2238 additions and 264 deletions

346
package-lock.json generated
View file

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

View file

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

View file

@ -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;
}
}

View file

@ -0,0 +1,773 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
LineChart, Line, AreaChart, Area,
PieChart, Pie, Cell, Legend
} from 'recharts';
import styles from './FormGeneratorReport.module.css';
import type {
FormGeneratorReportProps,
ReportSection,
ReportSectionKpi,
ReportSectionBarChart,
ReportSectionHorizontalBar,
ReportSectionLineChart,
ReportSectionPieChart,
ReportSectionTable,
ReportSectionAreaChart,
ReportFilterState,
ReportPeriod,
ReportFilterConfig,
ReportTableColumn
} from './FormGeneratorReportTypes';
// =============================================================================
// CHART COLORS
// =============================================================================
const CHART_COLORS = [
'var(--primary-color, #f25843)',
'#4e79a7',
'#59a14f',
'#f28e2b',
'#b07aa1',
'#76b7b2',
'#e15759',
'#edc948',
'#9c755f',
'#bab0ac'
];
const MONTH_LABELS: Record<string, string> = {
'01': 'Jan', '02': 'Feb', '03': 'Mär', '04': 'Apr',
'05': 'Mai', '06': 'Jun', '07': 'Jul', '08': 'Aug',
'09': 'Sep', '10': 'Okt', '11': 'Nov', '12': 'Dez'
};
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function _defaultFormatCurrency(value: number, currencyCode: string): string {
return `${currencyCode} ${value.toFixed(2)}`;
}
function _formatDateLabel(dateStr: string): string {
const parts = dateStr.split('-');
if (parts.length === 3) {
return `${parseInt(parts[2], 10)}.`;
}
if (parts.length === 2) {
return MONTH_LABELS[parts[1]] || parts[1];
}
return dateStr;
}
// =============================================================================
// CUSTOM TOOLTIP
// =============================================================================
interface CustomTooltipProps {
active?: boolean;
payload?: any[];
label?: string;
formatValue?: (value: number) => string;
}
const _CustomTooltip: React.FC<CustomTooltipProps> = ({ active, payload, label, formatValue }) => {
if (!active || !payload?.length) return null;
const displayLabel = label ? _formatDateLabel(String(label)) : '';
return (
<div className={styles.customTooltip}>
{displayLabel && <div className={styles.tooltipLabel}>{displayLabel}</div>}
{payload.map((entry: any, i: number) => (
<div key={i} className={styles.tooltipValue}>
{entry.name}: <span>{formatValue ? formatValue(entry.value) : entry.value}</span>
</div>
))}
</div>
);
};
// =============================================================================
// SECTION RENDERERS
// =============================================================================
// --- KPI Grid ---
const _renderKpiGrid = (section: ReportSectionKpi): React.ReactNode => {
return (
<div className={styles.kpiGrid}>
{section.items.map((item, i) => (
<div key={i} className={styles.kpiCard} style={item.color ? { borderLeftColor: item.color, borderLeftWidth: 3 } : undefined}>
<span className={styles.kpiLabel}>{item.label}</span>
<span className={styles.kpiValue}>{item.value}</span>
{item.subtitle && <span className={styles.kpiSubtitle}>{item.subtitle}</span>}
</div>
))}
</div>
);
};
// --- Bar Chart (vertical) ---
const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string): React.ReactNode => {
if (!section.data?.length) {
return <div className={styles.noData}>Keine Daten</div>;
}
const chartData = section.data.map(d => ({
name: _formatDateLabel(d.key),
value: d.value,
rawKey: d.key
}));
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
return (
<div className={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 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;

View file

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

View file

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

View file

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

View file

@ -8,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
============================================================================ */

View file

@ -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;
}
const response = await api.get('/api/billing/view/statistics', { params });
console.log('📊 View statistics response:', response.data);
setViewStats(response.data);
} catch (err: any) {
console.error('Failed to load statistics:', err);
setViewStats(null);
} finally {
setStatsLoading(false);
}
}, [selectedPeriod, selectedYear, loadStatistics]);
}, []);
// Load transactions
const loadTransactions = useCallback(async () => {
// Handle filter changes from FormGeneratorReport (user changes period/year/month)
const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => {
const period = filterState.period || 'month';
const year = filterState.year || new Date().getFullYear();
const month = filterState.month;
_loadViewStatistics(period, year, month);
}, [_loadViewStatistics]);
// Initial data load: load statistics when overview or statistics tab becomes active
useEffect(() => {
if (activeTab === 'overview' || activeTab === 'statistics') {
_loadViewStatistics('month', new Date().getFullYear());
}
}, [activeTab, _loadViewStatistics]);
// Load transactions with pagination support
const _loadTransactions = useCallback(async (paginationParams?: any) => {
try {
setTransactionsLoading(true);
setTransactionsError(null);
const response = await api.get('/api/billing/view/users/transactions', {
params: { limit: 500 }
});
setTransactions(response.data || []);
const params: any = {};
if (paginationParams) {
params.pagination = JSON.stringify(paginationParams);
}
const response = await api.get('/api/billing/view/users/transactions', { params });
const data = response.data;
// Handle PaginatedResponse format: { items: [...], pagination: {...} }
if (data && typeof data === 'object' && 'items' in data) {
setTransactions(Array.isArray(data.items) ? data.items : []);
if (data.pagination) {
setTransactionsPagination(data.pagination);
}
} else {
// Backward compatibility: plain array response
setTransactions(Array.isArray(data) ? data : []);
}
} catch (err: any) {
console.error('Failed to load transactions:', err);
setTransactionsError(err.response?.data?.detail || err.message || 'Fehler beim Laden der Transaktionen');
@ -231,119 +332,53 @@ export const BillingDataView: React.FC = () => {
// Load transactions when switching to transactions tab
useEffect(() => {
if (activeTab === 'transactions' && transactions.length === 0) {
loadTransactions();
if (activeTab === 'transactions') {
_loadTransactions();
}
}, [activeTab, transactions.length, loadTransactions]);
}, [activeTab, _loadTransactions]);
// Available years
const availableYears = useMemo(() => {
const current = new Date().getFullYear();
return [current, current - 1, current - 2];
}, []);
// Available months
const availableMonths = [
{ value: 1, label: 'Januar' },
{ value: 2, label: 'Februar' },
{ value: 3, label: 'März' },
{ value: 4, label: 'April' },
{ value: 5, label: 'Mai' },
{ value: 6, label: 'Juni' },
{ value: 7, label: 'Juli' },
{ value: 8, label: 'August' },
{ value: 9, label: 'September' },
{ value: 10, label: 'Oktober' },
{ value: 11, label: 'November' },
{ value: 12, label: 'Dezember' },
];
// Transform transactions for table display
const tableData = useMemo(() => {
return transactions.map((t, index) => ({
_uniqueId: `${t.id}-${t.mandateId}-${index}`,
id: t.id,
createdAt: t.createdAt,
mandateId: t.mandateId,
mandateName: t.mandateName || '-',
userId: t.userId,
userName: t.userName || '-',
transactionType: t.transactionType,
description: t.description || '-',
aicoreProvider: t.aicoreProvider || '-',
featureCode: t.featureCode || '-',
amount: t.transactionType === 'DEBIT' ? -t.amount : t.amount,
}));
}, [transactions]);
// hookData for FormGeneratorTable
const transactionsHookData = useMemo(() => ({
refetch: _loadTransactions,
pagination: transactionsPagination ? {
totalPages: transactionsPagination.totalPages,
totalItems: transactionsPagination.totalItems,
} : undefined,
}), [_loadTransactions, transactionsPagination]);
// Table column definitions
const columns: ColumnConfig[] = useMemo(() => [
{
key: 'createdAt',
label: 'Datum',
type: 'datetime',
sortable: true,
width: 160,
},
{
key: 'mandateName',
label: 'Mandant',
type: 'text',
sortable: true,
filterable: true,
searchable: true,
width: 150,
},
{
key: 'userName',
label: 'Benutzer',
type: 'text',
sortable: true,
filterable: true,
searchable: true,
width: 150,
},
{
key: 'transactionType',
label: 'Typ',
type: 'text',
sortable: true,
filterable: true,
filterOptions: ['CREDIT', 'DEBIT', 'ADJUSTMENT'],
width: 100,
},
{
key: 'description',
label: 'Beschreibung',
type: 'text',
searchable: true,
width: 250,
},
{
key: 'aicoreProvider',
label: 'Anbieter',
type: 'text',
sortable: true,
filterable: true,
width: 120,
},
{
key: 'featureCode',
label: 'Feature',
type: 'text',
sortable: true,
filterable: true,
width: 120,
},
{
key: 'amount',
label: 'Betrag (CHF)',
type: 'number',
sortable: true,
width: 120,
},
{ key: 'createdAt', label: 'Datum', type: 'timestamp' as any, sortable: true, width: 160 },
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
{ key: 'userName', label: 'Benutzer', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
{ key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 },
{ key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 },
{ key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 },
{ key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 },
{ key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 },
], []);
// Build report sections based on current data
const overviewSections = useMemo<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>
);