Add dashboard, API routes, Olga route, and supporting files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b9f2b8175a
commit
b95e4874f1
11 changed files with 2058 additions and 2 deletions
|
|
@ -11,7 +11,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
|
||||||
313
pnpm-lock.yaml
313
pnpm-lock.yaml
|
|
@ -17,6 +17,9 @@ importers:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
|
recharts:
|
||||||
|
specifier: ^3.8.1
|
||||||
|
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
|
|
@ -429,9 +432,26 @@ packages:
|
||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.12.0':
|
||||||
|
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
|
||||||
|
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
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0':
|
||||||
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0':
|
||||||
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
|
|
@ -530,6 +550,33 @@ packages:
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2':
|
||||||
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3':
|
||||||
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2':
|
||||||
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1':
|
||||||
|
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.8':
|
||||||
|
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4':
|
||||||
|
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2':
|
||||||
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
|
@ -550,6 +597,9 @@ packages:
|
||||||
'@types/react@19.2.14':
|
'@types/react@19.2.14':
|
||||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6':
|
||||||
|
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.59.1':
|
'@typescript-eslint/eslint-plugin@8.59.1':
|
||||||
resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==}
|
resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
@ -841,6 +891,10 @@ packages:
|
||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
|
clsx@2.1.1:
|
||||||
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
|
|
@ -861,6 +915,50 @@ packages:
|
||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-color@3.1.0:
|
||||||
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-ease@3.0.1:
|
||||||
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-format@3.1.2:
|
||||||
|
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-path@3.1.0:
|
||||||
|
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
damerau-levenshtein@1.0.8:
|
damerau-levenshtein@1.0.8:
|
||||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||||
|
|
||||||
|
|
@ -893,6 +991,9 @@ packages:
|
||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1:
|
||||||
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
|
|
||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
|
|
@ -958,6 +1059,9 @@ packages:
|
||||||
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-toolkit@1.46.1:
|
||||||
|
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
|
||||||
|
|
||||||
escalade@3.2.0:
|
escalade@3.2.0:
|
||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -1086,6 +1190,9 @@ packages:
|
||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
eventemitter3@5.0.4:
|
||||||
|
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
|
|
@ -1235,6 +1342,12 @@ packages:
|
||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
immer@10.2.0:
|
||||||
|
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||||
|
|
||||||
|
immer@11.1.8:
|
||||||
|
resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -1247,6 +1360,10 @@ packages:
|
||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
internmap@2.0.3:
|
||||||
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-array-buffer@3.0.5:
|
is-array-buffer@3.0.5:
|
||||||
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -1677,10 +1794,38 @@ packages:
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
|
react-redux@9.3.0:
|
||||||
|
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^18.2.25 || ^19
|
||||||
|
react: ^18.0 || ^19
|
||||||
|
redux: ^5.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react@19.2.4:
|
react@19.2.4:
|
||||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
recharts@3.8.1:
|
||||||
|
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
|
||||||
|
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
|
||||||
|
|
||||||
|
redux-thunk@3.1.0:
|
||||||
|
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||||
|
peerDependencies:
|
||||||
|
redux: ^5.0.0
|
||||||
|
|
||||||
|
redux@5.0.1:
|
||||||
|
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -1689,6 +1834,9 @@ packages:
|
||||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
reselect@5.1.1:
|
||||||
|
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||||
|
|
||||||
resolve-from@4.0.0:
|
resolve-from@4.0.0:
|
||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
@ -1842,6 +1990,9 @@ packages:
|
||||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3:
|
||||||
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
tinyglobby@0.2.16:
|
tinyglobby@0.2.16:
|
||||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
@ -1913,6 +2064,14 @@ packages:
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0:
|
||||||
|
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
which-boxed-primitive@1.1.1:
|
which-boxed-primitive@1.1.1:
|
||||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -2303,8 +2462,24 @@ snapshots:
|
||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
'@standard-schema/utils': 0.3.0
|
||||||
|
immer: 11.1.8
|
||||||
|
redux: 5.0.1
|
||||||
|
redux-thunk: 3.1.0(redux@5.0.1)
|
||||||
|
reselect: 5.1.1
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
@ -2383,6 +2558,30 @@ snapshots:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-color': 3.1.3
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1': {}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.8':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-path': 3.1.1
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4': {}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
@ -2401,6 +2600,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
|
|
@ -2706,6 +2907,8 @@ snapshots:
|
||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|
@ -2724,6 +2927,44 @@ snapshots:
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
dependencies:
|
||||||
|
internmap: 2.0.3
|
||||||
|
|
||||||
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
|
d3-format@3.1.2: {}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-path@3.1.0: {}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-format: 3.1.2
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
dependencies:
|
||||||
|
d3-path: 3.1.0
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-time: 3.1.0
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
damerau-levenshtein@1.0.8: {}
|
damerau-levenshtein@1.0.8: {}
|
||||||
|
|
||||||
data-view-buffer@1.0.2:
|
data-view-buffer@1.0.2:
|
||||||
|
|
@ -2752,6 +2993,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1: {}
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
define-data-property@1.1.4:
|
define-data-property@1.1.4:
|
||||||
|
|
@ -2888,6 +3131,8 @@ snapshots:
|
||||||
is-date-object: 1.1.0
|
is-date-object: 1.1.0
|
||||||
is-symbol: 1.1.1
|
is-symbol: 1.1.1
|
||||||
|
|
||||||
|
es-toolkit@1.46.1: {}
|
||||||
|
|
||||||
escalade@3.2.0: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
@ -3097,6 +3342,8 @@ snapshots:
|
||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-glob@3.3.1:
|
fast-glob@3.3.1:
|
||||||
|
|
@ -3241,6 +3488,10 @@ snapshots:
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
|
immer@10.2.0: {}
|
||||||
|
|
||||||
|
immer@11.1.8: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
|
|
@ -3254,6 +3505,8 @@ snapshots:
|
||||||
hasown: 2.0.3
|
hasown: 2.0.3
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
|
|
||||||
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
is-array-buffer@3.0.5:
|
is-array-buffer@3.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
|
|
@ -3664,8 +3917,43 @@ snapshots:
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
|
react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/use-sync-external-store': 0.0.6
|
||||||
|
react: 19.2.4
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
react@19.2.4: {}
|
react@19.2.4: {}
|
||||||
|
|
||||||
|
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
|
||||||
|
clsx: 2.1.1
|
||||||
|
decimal.js-light: 2.5.1
|
||||||
|
es-toolkit: 1.46.1
|
||||||
|
eventemitter3: 5.0.4
|
||||||
|
immer: 10.2.0
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
react-is: 16.13.1
|
||||||
|
react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
|
||||||
|
reselect: 5.1.1
|
||||||
|
tiny-invariant: 1.3.3
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||||
|
victory-vendor: 37.3.6
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- redux
|
||||||
|
|
||||||
|
redux-thunk@3.1.0(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
|
redux@5.0.1: {}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
|
|
@ -3686,6 +3974,8 @@ snapshots:
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
set-function-name: 2.0.2
|
set-function-name: 2.0.2
|
||||||
|
|
||||||
|
reselect@5.1.1: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
@ -3898,6 +4188,8 @@ snapshots:
|
||||||
|
|
||||||
tapable@2.3.3: {}
|
tapable@2.3.3: {}
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinyglobby@0.2.16:
|
tinyglobby@0.2.16:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
|
|
@ -4013,6 +4305,27 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-array': 3.2.2
|
||||||
|
'@types/d3-ease': 3.0.2
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-scale': 4.0.9
|
||||||
|
'@types/d3-shape': 3.1.8
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
'@types/d3-timer': 3.0.2
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-scale: 4.0.2
|
||||||
|
d3-shape: 3.2.0
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
which-boxed-primitive@1.1.1:
|
which-boxed-primitive@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-bigint: 1.1.0
|
is-bigint: 1.1.0
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
ignoredBuiltDependencies:
|
allowBuilds:
|
||||||
|
sharp: set this to true or false
|
||||||
|
unrs-resolver: set this to true or false
|
||||||
|
onlyBuiltDependencies:
|
||||||
- sharp
|
- sharp
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
|
|
|
||||||
BIN
public/olga.jpg
Executable file
BIN
public/olga.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
5
public/pm-kritisch.json
Normal file
5
public/pm-kritisch.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"stand": "2026-05-18T22:00:00Z",
|
||||||
|
"kritisch": [],
|
||||||
|
"allesOk": true
|
||||||
|
}
|
||||||
17
src/app/api/notion-pm/route.ts
Normal file
17
src/app/api/notion-pm/route.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { fetchAllTodos } from '@/lib/notion-pm'
|
||||||
|
|
||||||
|
export const revalidate = 300
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const todos = await fetchAllTodos()
|
||||||
|
return NextResponse.json({
|
||||||
|
todos,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
count: todos.length,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: String(err) }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
877
src/app/dashboard/components/DashboardClient.tsx
Normal file
877
src/app/dashboard/components/DashboardClient.tsx
Normal file
|
|
@ -0,0 +1,877 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
ResponsiveContainer, Cell,
|
||||||
|
} from 'recharts'
|
||||||
|
import { Todo } from '@/lib/notion-pm'
|
||||||
|
import { KritischData } from '../page'
|
||||||
|
|
||||||
|
// ── MC CI ─────────────────────────────────────────────────────────────────────
|
||||||
|
const MC_BLUE = '#3B82F6'
|
||||||
|
const MC_PURPLE = '#8B5CF6'
|
||||||
|
|
||||||
|
const KAT_COLOR: Record<string, string> = {
|
||||||
|
'Kundenarbeit': '#3B82F6',
|
||||||
|
'Produkt & Launch': '#8B5CF6',
|
||||||
|
'Marketing & Content': '#EC4899',
|
||||||
|
'Vertrieb & Akquise': '#F97316',
|
||||||
|
'Technik & Tools': '#6B7280',
|
||||||
|
'Finanzen & Recht': '#EAB308',
|
||||||
|
'Organisation & Admin': '#A16207',
|
||||||
|
'Privat': '#22C55E',
|
||||||
|
'Diana': '#EF4444',
|
||||||
|
'Me Time': '#94A3B8',
|
||||||
|
}
|
||||||
|
function kc(k: string | null) { return k ? (KAT_COLOR[k] ?? MC_BLUE) : MC_BLUE }
|
||||||
|
|
||||||
|
// ── Capacity constants ────────────────────────────────────────────────────────
|
||||||
|
// Business: Mo–Fr 7h/Tag × 5 = 35h + WE Standard 5h = 40h/Woche
|
||||||
|
// Privat: Mo–Fr 1.5h/Tag × 5 = 7.5h + WE 7h (3–4h/Tag) = 14.5h/Woche
|
||||||
|
const BUSINESS_CAP = 40
|
||||||
|
const PRIVAT_CAP = 14.5
|
||||||
|
const REAL_CAP = BUSINESS_CAP + PRIVAT_CAP // 54.5h gesamt (für Machbarkeit)
|
||||||
|
|
||||||
|
const BUSINESS_KAT = new Set([
|
||||||
|
'Kundenarbeit', 'Produkt & Launch', 'Marketing & Content',
|
||||||
|
'Vertrieb & Akquise', 'Technik & Tools', 'Finanzen & Recht', 'Organisation & Admin',
|
||||||
|
])
|
||||||
|
const PRIVAT_KAT = new Set(['Privat', 'Diana', 'Me Time'])
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
const isErledigt = (t: Todo) => (t.status ?? '').toLowerCase().includes('erledigt')
|
||||||
|
const daysFromNow = (d: string) => {
|
||||||
|
const now = new Date(); now.setHours(0,0,0,0)
|
||||||
|
return Math.floor((new Date(d).getTime() - now.getTime()) / 86_400_000)
|
||||||
|
}
|
||||||
|
const round1 = (n: number) => Math.round(n * 10) / 10
|
||||||
|
|
||||||
|
// Wochenplan-Filter: Geplanter Tag muss in den nächsten 7 Tagen liegen.
|
||||||
|
// Kein Geplanter Tag aber Im Wochenplan = true → manuell gesetzt, zählt trotzdem.
|
||||||
|
const isThisWeek = (t: Todo) => {
|
||||||
|
if (!t.geplanterTag) return t.imWochenplan
|
||||||
|
const d = daysFromNow(t.geplanterTag)
|
||||||
|
return d >= 0 && d <= 6
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Analytics ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function kapazitaet(todos: Todo[]) {
|
||||||
|
const week = todos.filter(t => isThisWeek(t) && !isErledigt(t))
|
||||||
|
|
||||||
|
const businessH = round1(week.filter(t => BUSINESS_KAT.has(t.kategorie ?? '')).reduce((s,t) => s + (t.zeitH ?? 0), 0))
|
||||||
|
const privatH = round1(week.filter(t => PRIVAT_KAT.has(t.kategorie ?? '')).reduce((s,t) => s + (t.zeitH ?? 0), 0))
|
||||||
|
|
||||||
|
const businessPct = Math.round((businessH / BUSINESS_CAP) * 100)
|
||||||
|
const privatPct = Math.round((privatH / PRIVAT_CAP) * 100)
|
||||||
|
|
||||||
|
const bColor = businessPct <= 80 ? '#22C55E' : businessPct <= 100 ? '#EAB308' : '#EF4444'
|
||||||
|
const pColor = privatPct <= 80 ? '#22C55E' : privatPct <= 100 ? '#EAB308' : '#EF4444'
|
||||||
|
|
||||||
|
// legacy compat for other functions that use .geplant/.pct/.color/.label
|
||||||
|
const geplant = round1(businessH + privatH)
|
||||||
|
const pct = Math.round((geplant / REAL_CAP) * 100)
|
||||||
|
const color = pct <= 80 ? '#22C55E' : pct <= 100 ? '#EAB308' : '#EF4444'
|
||||||
|
const label = pct <= 80 ? 'Im Rahmen' : pct <= 100 ? 'Grenzwertig' : 'Überlastet'
|
||||||
|
|
||||||
|
return { businessH, privatH, businessPct, privatPct, bColor, pColor, geplant, pct, color, label }
|
||||||
|
}
|
||||||
|
|
||||||
|
function machbarkeit(todos: Todo[]) {
|
||||||
|
const week = todos
|
||||||
|
.filter(t => isThisWeek(t) && !isErledigt(t))
|
||||||
|
.sort((a,b) => {
|
||||||
|
// höchste Prio zuerst
|
||||||
|
const prio = (x: Todo) => x.prioritaet === 'Hoch' ? 0 : x.prioritaet === 'Mittel' ? 1 : 2
|
||||||
|
return prio(a) - prio(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
let budget = REAL_CAP
|
||||||
|
const machbar: Todo[] = []
|
||||||
|
const overflow: Todo[] = []
|
||||||
|
|
||||||
|
for (const t of week) {
|
||||||
|
const h = t.zeitH ?? 0.5
|
||||||
|
if (budget >= h) { machbar.push(t); budget -= h }
|
||||||
|
else overflow.push(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = week.length === 0 ? 100
|
||||||
|
: Math.round((machbar.length / week.length) * 100)
|
||||||
|
|
||||||
|
return { machbar, overflow, score, restH: round1(budget) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function energiebloecke(todos: Todo[]) {
|
||||||
|
const open = todos.filter(t => !isErledigt(t) && isThisWeek(t))
|
||||||
|
const fokus = open.filter(t => (t.zeitH ?? 0) >= 2 || t.prioritaet === 'Hoch')
|
||||||
|
const leicht = open.filter(t => !fokus.includes(t) && (t.zeitH ?? 1) < 1)
|
||||||
|
const mittel = open.filter(t => !fokus.includes(t) && !leicht.includes(t))
|
||||||
|
return { fokus, mittel, leicht }
|
||||||
|
}
|
||||||
|
|
||||||
|
function carryoverRate(todos: Todo[]) {
|
||||||
|
// Todos mit vergangener Deadline die NICHT erledigt sind
|
||||||
|
const withDeadline = todos.filter(t => t.deadline)
|
||||||
|
const overdue = withDeadline.filter(t => !isErledigt(t) && daysFromNow(t.deadline!) < 0)
|
||||||
|
const rate = withDeadline.length === 0 ? 0
|
||||||
|
: Math.round((overdue.length / withDeadline.length) * 100)
|
||||||
|
return { rate, overdueCount: overdue.length, total: withDeadline.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deadlineRisiko(todos: Todo[]) {
|
||||||
|
const projekte: Record<string, {riskCount: number; total: number}> = {}
|
||||||
|
for (const t of todos.filter(t => !isErledigt(t))) {
|
||||||
|
if (!projekte[t.projekt]) projekte[t.projekt] = { riskCount: 0, total: 0 }
|
||||||
|
projekte[t.projekt].total++
|
||||||
|
if (t.deadline) {
|
||||||
|
const d = daysFromNow(t.deadline)
|
||||||
|
if (d < 0 || d <= 7) projekte[t.projekt].riskCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.entries(projekte)
|
||||||
|
.map(([p, v]) => ({
|
||||||
|
projekt: p,
|
||||||
|
risikoPct: v.total === 0 ? 0 : Math.round((v.riskCount / v.total) * 100),
|
||||||
|
riskCount: v.riskCount,
|
||||||
|
}))
|
||||||
|
.filter(r => r.riskCount > 0)
|
||||||
|
.sort((a,b) => b.risikoPct - a.risikoPct)
|
||||||
|
.slice(0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AthenaEmpfehlung {
|
||||||
|
icon: string
|
||||||
|
text: string
|
||||||
|
color: string
|
||||||
|
items?: string[] // aufklappbare Liste — eine Aufgabe pro Zeile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortiert nach nächstem anstehendem Datum (geplanterTag → deadline → hinten)
|
||||||
|
function sortByNextDate(a: Todo, b: Todo): number {
|
||||||
|
const dateOf = (t: Todo) => t.geplanterTag ?? t.deadline ?? null
|
||||||
|
const da = dateOf(a), db = dateOf(b)
|
||||||
|
if (da && db) return new Date(da).getTime() - new Date(db).getTime()
|
||||||
|
if (da) return -1
|
||||||
|
if (db) return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function athenaEmpfehlungen(todos: Todo[]): AthenaEmpfehlung[] {
|
||||||
|
const tips: AthenaEmpfehlung[] = []
|
||||||
|
const kap = kapazitaet(todos)
|
||||||
|
const mb = machbarkeit(todos)
|
||||||
|
const cr = carryoverRate(todos)
|
||||||
|
|
||||||
|
// 1. Überlastung
|
||||||
|
if (kap.pct > 100) {
|
||||||
|
const overflow = mb.overflow
|
||||||
|
if (overflow.length > 0) {
|
||||||
|
tips.push({
|
||||||
|
icon: '↩',
|
||||||
|
text: `${overflow.length} Todo${overflow.length > 1 ? 's' : ''} auf nächste Woche verschieben`,
|
||||||
|
color: '#EF4444',
|
||||||
|
items: overflow.map(t => `${t.name.slice(0,50)} (${t.zeitH ?? '?'}h)`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Hohe Carry-over Rate
|
||||||
|
if (cr.rate > 30) {
|
||||||
|
tips.push({
|
||||||
|
icon: '⚡',
|
||||||
|
text: `Carry-over Rate ${cr.rate}% — ${cr.overdueCount} überfällige Todos priorisieren`,
|
||||||
|
color: '#EAB308',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Kundenarbeit unterrepräsentiert
|
||||||
|
const weekH = (kat: string) => todos
|
||||||
|
.filter(t => isThisWeek(t) && !isErledigt(t) && t.kategorie === kat)
|
||||||
|
.reduce((s,t) => s + (t.zeitH ?? 0), 0)
|
||||||
|
const kundenH = weekH('Kundenarbeit')
|
||||||
|
const totalWeekH = todos.filter(t => isThisWeek(t) && !isErledigt(t))
|
||||||
|
.reduce((s,t) => s + (t.zeitH ?? 0), 0)
|
||||||
|
if (totalWeekH > 0 && kundenH / totalWeekH < 0.25) {
|
||||||
|
tips.push({
|
||||||
|
icon: '👥',
|
||||||
|
text: `Kundenarbeit nur ${Math.round((kundenH/totalWeekH)*100)}% — prüfe ob Kunden-Todos fehlen`,
|
||||||
|
color: MC_BLUE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fokus-Todos ohne feste Zeit
|
||||||
|
const fokusOhneZeit = todos.filter(t =>
|
||||||
|
isThisWeek(t) && !isErledigt(t) && t.prioritaet === 'Hoch' && !t.zeitH
|
||||||
|
)
|
||||||
|
if (fokusOhneZeit.length > 0) {
|
||||||
|
tips.push({
|
||||||
|
icon: '🎯',
|
||||||
|
text: `${fokusOhneZeit.length} Hoch-Prio Todos ohne Zeitschätzung`,
|
||||||
|
color: MC_PURPLE,
|
||||||
|
items: fokusOhneZeit.map(t => t.name.slice(0, 52)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Me Time fehlt
|
||||||
|
const meTodos = todos.filter(t => !isErledigt(t) && t.kategorie === 'Me Time')
|
||||||
|
if (meTodos.length === 0) {
|
||||||
|
tips.push({
|
||||||
|
icon: '🧘',
|
||||||
|
text: `Me Time Zeitslot noch offen — bitte definieren`,
|
||||||
|
color: '#94A3B8',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Business-Puffer: nächstanstehende Todos vorschlagen
|
||||||
|
const businessRest = round1(BUSINESS_CAP - kap.businessH)
|
||||||
|
if (businessRest >= 1) {
|
||||||
|
const kandidaten = todos
|
||||||
|
.filter(t => !isErledigt(t) && !isThisWeek(t) && BUSINESS_KAT.has(t.kategorie ?? '') && (t.zeitH ?? 0) <= businessRest)
|
||||||
|
.sort(sortByNextDate)
|
||||||
|
.slice(0, 8)
|
||||||
|
if (kandidaten.length > 0) {
|
||||||
|
tips.push({
|
||||||
|
icon: '➕',
|
||||||
|
text: `Business: ${businessRest}h Puffer frei — nächstanstehende Vorschläge`,
|
||||||
|
color: '#22C55E',
|
||||||
|
items: kandidaten.map(t => {
|
||||||
|
const datum = t.geplanterTag ?? t.deadline
|
||||||
|
const datumStr = datum ? ` · ${datum.slice(5)}` : ''
|
||||||
|
return `${t.name.slice(0,50)} (${t.zeitH ?? '?'}h${datumStr})`
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Privat-Puffer: nächstanstehende Todos vorschlagen
|
||||||
|
const privatRest = round1(PRIVAT_CAP - kap.privatH)
|
||||||
|
if (privatRest >= 0.5) {
|
||||||
|
const kandidaten = todos
|
||||||
|
.filter(t => !isErledigt(t) && !isThisWeek(t) && PRIVAT_KAT.has(t.kategorie ?? '') && (t.zeitH ?? 0) <= privatRest)
|
||||||
|
.sort(sortByNextDate)
|
||||||
|
.slice(0, 6)
|
||||||
|
if (kandidaten.length > 0) {
|
||||||
|
tips.push({
|
||||||
|
icon: '🏠',
|
||||||
|
text: `Privat: ${privatRest}h Puffer frei — nächstanstehende Vorschläge`,
|
||||||
|
color: '#8B5CF6',
|
||||||
|
items: kandidaten.map(t => {
|
||||||
|
const datum = t.geplanterTag ?? t.deadline
|
||||||
|
const datumStr = datum ? ` · ${datum.slice(5)}` : ''
|
||||||
|
return `${t.name.slice(0,50)} (${t.zeitH ?? '?'}h${datumStr})`
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Alles gut
|
||||||
|
if (tips.length === 0) {
|
||||||
|
tips.push({ icon: '✓', text: `Alles im Rahmen — Kapazität ${kap.pct}%, Carry-over ${cr.rate}%. Weiter so!`, color: '#22C55E' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return tips
|
||||||
|
}
|
||||||
|
|
||||||
|
function kategorienData(todos: Todo[]) {
|
||||||
|
const map: Record<string, number> = {}
|
||||||
|
for (const t of todos) {
|
||||||
|
if (!t.kategorie || isErledigt(t)) continue
|
||||||
|
map[t.kategorie] = (map[t.kategorie] ?? 0) + (t.zeitH ?? 0)
|
||||||
|
}
|
||||||
|
return Object.entries(map)
|
||||||
|
.map(([name, stunden]) => ({ name, stunden: round1(stunden) }))
|
||||||
|
.sort((a,b) => b.stunden - a.stunden)
|
||||||
|
}
|
||||||
|
|
||||||
|
function top5(todos: Todo[]) {
|
||||||
|
return todos
|
||||||
|
.filter(t => !isErledigt(t) && t.deadline)
|
||||||
|
.sort((a,b) => new Date(a.deadline!).getTime() - new Date(b.deadline!).getTime())
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(t => ({ ...t, daysLeft: daysFromNow(t.deadline!) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function fruehwarnung(todos: Todo[]) {
|
||||||
|
const active = todos.filter(t => !isErledigt(t) && t.deadline)
|
||||||
|
return {
|
||||||
|
overdue: active.filter(t => daysFromNow(t.deadline!) < 0),
|
||||||
|
upcoming: active.filter(t => { const d = daysFromNow(t.deadline!); return d >= 0 && d <= 3 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeitdifferenz(todos: Todo[]) {
|
||||||
|
const map: Record<string, {geplant: number; tatsaechlich: number}> = {}
|
||||||
|
for (const t of todos) {
|
||||||
|
if (!map[t.projekt]) map[t.projekt] = { geplant: 0, tatsaechlich: 0 }
|
||||||
|
map[t.projekt].geplant += t.zeitH ?? 0
|
||||||
|
map[t.projekt].tatsaechlich += t.tatsaechlicheZeitH ?? 0
|
||||||
|
}
|
||||||
|
return Object.entries(map)
|
||||||
|
.map(([p, v]) => ({
|
||||||
|
projekt: p,
|
||||||
|
geplant: round1(v.geplant),
|
||||||
|
tatsaechlich: round1(v.tatsaechlich),
|
||||||
|
diff: round1(v.geplant - v.tatsaechlich),
|
||||||
|
}))
|
||||||
|
.filter(r => r.geplant > 0 || r.tatsaechlich > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function meilensteinKetten(todos: Todo[]) {
|
||||||
|
const map: Record<string, {projekt: string; todos: Todo[]; overdueCount: number}> = {}
|
||||||
|
for (const t of todos.filter(t => !isErledigt(t) && t.meilenstein)) {
|
||||||
|
const key = `${t.projekt} › ${t.meilenstein}`
|
||||||
|
if (!map[key]) map[key] = { projekt: t.projekt, todos: [], overdueCount: 0 }
|
||||||
|
map[key].todos.push(t)
|
||||||
|
if (t.deadline && daysFromNow(t.deadline) < 0) map[key].overdueCount++
|
||||||
|
}
|
||||||
|
return Object.entries(map)
|
||||||
|
.map(([label, v]) => ({ label, ...v }))
|
||||||
|
.sort((a,b) => b.overdueCount - a.overdueCount)
|
||||||
|
.slice(0, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI Primitives ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Card({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ background:'#1e293b', border:'1px solid #334155', borderRadius:'0.75rem', padding:'1.25rem' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Label({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p style={{ fontSize:'0.65rem', fontWeight:700, letterSpacing:'0.1em', textTransform:'uppercase', color:'#475569', marginBottom:'0.75rem', margin:'0 0 0.75rem' }}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ color, children }: { color: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span style={{ background: color+'22', color, fontSize:'0.65rem', fontWeight:700, padding:'0.1rem 0.4rem', borderRadius:'0.25rem', letterSpacing:'0.05em' }}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Kapazität + Puffer ───────────────────────────────────────────────
|
||||||
|
function KapazitaetsAmpel({ todos }: { todos: Todo[] }) {
|
||||||
|
const { businessH, privatH, businessPct, privatPct, bColor, pColor } = kapazitaet(todos)
|
||||||
|
|
||||||
|
function Bar({ label, geplant, cap, pct, color, hint }: {
|
||||||
|
label: string; geplant: number; cap: number; pct: number; color: string; hint: string
|
||||||
|
}) {
|
||||||
|
const barW = Math.min(pct, 100)
|
||||||
|
const statusLabel = pct <= 80 ? 'Im Rahmen' : pct <= 100 ? 'Grenzwertig' : 'Überlastet'
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom:'0.875rem' }}>
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', marginBottom:'0.2rem' }}>
|
||||||
|
<span style={{ fontSize:'0.68rem', fontWeight:700, color:'#475569', textTransform:'uppercase', letterSpacing:'0.08em' }}>{label}</span>
|
||||||
|
<span style={{ fontSize:'0.65rem', color:'#475569' }}>{hint}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', alignItems:'baseline', gap:'0.4rem', marginBottom:'0.3rem' }}>
|
||||||
|
<span style={{ fontSize:'1.75rem', fontWeight:800, color }}>{geplant}h</span>
|
||||||
|
<span style={{ color:'#475569', fontSize:'0.8rem' }}>/ {cap}h</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ background:'#0f172a', borderRadius:'9999px', height:'7px', marginBottom:'0.25rem', overflow:'hidden' }}>
|
||||||
|
<div style={{ width:`${barW}%`, height:'100%', background:color, borderRadius:'9999px', transition:'width 0.5s ease' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between' }}>
|
||||||
|
<span style={{ fontSize:'0.7rem', color, fontWeight:600 }}>{statusLabel} ({pct}%)</span>
|
||||||
|
<Badge color={color}>{pct <= 80 ? '🟢' : pct <= 100 ? '🟡' : '🔴'}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>Kapazität diese Woche</Label>
|
||||||
|
<Bar label="Business" geplant={businessH} cap={BUSINESS_CAP} pct={businessPct} color={bColor} hint="Mo–Fr 35h + WE 5h" />
|
||||||
|
<Bar label="Privat" geplant={privatH} cap={PRIVAT_CAP} pct={privatPct} color={pColor} hint="Mo–Fr 7.5h + WE 7h" />
|
||||||
|
<div style={{ background:'#0f172a', borderRadius:'0.5rem', padding:'0.4rem 0.75rem', fontSize:'0.68rem', color:'#475569', marginTop:'0.25rem' }}>
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr auto', gap:'0.2rem' }}>
|
||||||
|
<span>Business (35h Mo–Fr + 5h WE)</span><span style={{ color:'#3B82F6' }}>{BUSINESS_CAP}h</span>
|
||||||
|
<span>Privat (7.5h Mo–Fr + 7h WE)</span><span style={{ color:'#8B5CF6' }}>{PRIVAT_CAP}h</span>
|
||||||
|
<span>3 Wochenendtage/Monat frei</span><span style={{ color:'#64748b' }}>blockiert</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Machbarkeitsanalyse ──────────────────────────────────────────────
|
||||||
|
function Machbarkeit({ todos }: { todos: Todo[] }) {
|
||||||
|
const { machbar, overflow, score } = machbarkeit(todos)
|
||||||
|
const scoreColor = score >= 80 ? '#22C55E' : score >= 50 ? '#EAB308' : '#EF4444'
|
||||||
|
const scoreLabel = score >= 80 ? 'Machbar' : score >= 50 ? 'Knapp' : 'Nicht machbar'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>Machbarkeitsanalyse</Label>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:'1rem', marginBottom:'0.75rem' }}>
|
||||||
|
<div style={{ textAlign:'center' }}>
|
||||||
|
<div style={{ fontSize:'2rem', fontWeight:800, color:scoreColor }}>{score}%</div>
|
||||||
|
<div style={{ fontSize:'0.7rem', color:scoreColor, fontWeight:600 }}>{scoreLabel}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex:1 }}>
|
||||||
|
<div style={{ fontSize:'0.75rem', color:'#94a3b8', marginBottom:'0.25rem' }}>
|
||||||
|
<span style={{ color:'#22C55E', fontWeight:600 }}>{machbar.length} Todos</span> passen rein
|
||||||
|
{overflow.length > 0 && <span> · <span style={{ color:'#EF4444', fontWeight:600 }}>{overflow.length} fallen raus</span></span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ background:'#0f172a', borderRadius:'9999px', height:'6px', overflow:'hidden' }}>
|
||||||
|
<div style={{ width:`${score}%`, height:'100%', background:scoreColor }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overflow.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize:'0.65rem', color:'#EF4444', fontWeight:700, marginBottom:'0.375rem', textTransform:'uppercase', letterSpacing:'0.05em' }}>
|
||||||
|
Passt diese Woche nicht rein:
|
||||||
|
</p>
|
||||||
|
{overflow.slice(0, 4).map(t => (
|
||||||
|
<div key={t.id} style={{ display:'flex', justifyContent:'space-between', background:'#0f172a', borderRadius:'0.375rem', padding:'0.3rem 0.5rem', marginBottom:'0.25rem', fontSize:'0.75rem' }}>
|
||||||
|
<span style={{ color:'#f1f5f9' }}>{t.name.slice(0,42)}</span>
|
||||||
|
<span style={{ color:'#EF4444', fontWeight:600, marginLeft:'0.5rem', whiteSpace:'nowrap' }}>{t.zeitH ?? '?'}h</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{overflow.length > 4 && <p style={{ fontSize:'0.7rem', color:'#475569', marginTop:'0.25rem' }}>+{overflow.length-4} weitere…</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: ATHENA Empfehlungen ──────────────────────────────────────────────
|
||||||
|
function AthenaEmpfehlungen({ todos }: { todos: Todo[] }) {
|
||||||
|
const tips = athenaEmpfehlungen(todos)
|
||||||
|
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||||
|
const SHOW = 3
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>ATHENA Empfehlungen</Label>
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', gap:'0.5rem' }}>
|
||||||
|
{tips.map((tip, i) => {
|
||||||
|
const hasItems = tip.items && tip.items.length > 0
|
||||||
|
const isOpen = expanded[i] ?? false
|
||||||
|
const visible = hasItems ? (isOpen ? tip.items! : tip.items!.slice(0, SHOW)) : []
|
||||||
|
const hiddenCount = hasItems ? tip.items!.length - SHOW : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ borderLeft:`3px solid ${tip.color}`, background:'#0f172a', borderRadius:'0 0.375rem 0.375rem 0', padding:'0.5rem 0.625rem 0.5rem 0.75rem' }}>
|
||||||
|
<div style={{ display:'flex', gap:'0.5rem', alignItems:'flex-start' }}>
|
||||||
|
<span style={{ fontSize:'0.9rem', flexShrink:0 }}>{tip.icon}</span>
|
||||||
|
<span style={{ fontSize:'0.78rem', color:'#cbd5e1', lineHeight:1.4, flex:1 }}>{tip.text}</span>
|
||||||
|
</div>
|
||||||
|
{hasItems && (
|
||||||
|
<div style={{ marginTop:'0.375rem', paddingLeft:'1.5rem' }}>
|
||||||
|
{visible.map((item, j) => (
|
||||||
|
<div key={j} style={{ fontSize:'0.72rem', color:'#94a3b8', padding:'0.2rem 0', borderTop:'1px solid #1e293b', lineHeight:1.4 }}>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{hiddenCount > 0 && !isOpen && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(e => ({ ...e, [i]: true }))}
|
||||||
|
style={{ fontSize:'0.68rem', color:tip.color, background:'none', border:'none', cursor:'pointer', padding:'0.2rem 0', marginTop:'0.1rem' }}
|
||||||
|
>
|
||||||
|
+{hiddenCount} weitere anzeigen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isOpen && tip.items!.length > SHOW && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(e => ({ ...e, [i]: false }))}
|
||||||
|
style={{ fontSize:'0.68rem', color:'#475569', background:'none', border:'none', cursor:'pointer', padding:'0.2rem 0', marginTop:'0.1rem' }}
|
||||||
|
>
|
||||||
|
weniger anzeigen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Predictive Analytics ────────────────────────────────────────────
|
||||||
|
function PredictiveAnalytics({ todos }: { todos: Todo[] }) {
|
||||||
|
const cr = carryoverRate(todos)
|
||||||
|
const risks = deadlineRisiko(todos)
|
||||||
|
const trend = cr.rate === 0 ? { label:'Kein Rückstand', color:'#22C55E', icon:'↗' }
|
||||||
|
: cr.rate < 20 ? { label:'Stabil', color:'#22C55E', icon:'→' }
|
||||||
|
: cr.rate < 40 ? { label:'Leichter Rückstand', color:'#EAB308', icon:'↘' }
|
||||||
|
: { label:'Kritischer Rückstand', color:'#EF4444', icon:'↓' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>Predictive Analytics</Label>
|
||||||
|
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:'0.75rem', marginBottom:'0.75rem' }}>
|
||||||
|
{/* Carry-over Rate */}
|
||||||
|
<div style={{ background:'#0f172a', borderRadius:'0.5rem', padding:'0.625rem' }}>
|
||||||
|
<p style={{ fontSize:'0.65rem', color:'#475569', margin:'0 0 0.25rem', textTransform:'uppercase', letterSpacing:'0.05em' }}>Carry-over Rate</p>
|
||||||
|
<p style={{ fontSize:'1.5rem', fontWeight:800, color: cr.rate === 0 ? '#22C55E' : cr.rate < 30 ? '#EAB308' : '#EF4444', margin:0 }}>{cr.rate}%</p>
|
||||||
|
<p style={{ fontSize:'0.65rem', color:'#64748b', margin:'0.1rem 0 0' }}>{cr.overdueCount} / {cr.total} Todos überfällig</p>
|
||||||
|
</div>
|
||||||
|
{/* Trend */}
|
||||||
|
<div style={{ background:'#0f172a', borderRadius:'0.5rem', padding:'0.625rem' }}>
|
||||||
|
<p style={{ fontSize:'0.65rem', color:'#475569', margin:'0 0 0.25rem', textTransform:'uppercase', letterSpacing:'0.05em' }}>Trend</p>
|
||||||
|
<p style={{ fontSize:'1.5rem', fontWeight:800, color:trend.color, margin:0 }}>{trend.icon}</p>
|
||||||
|
<p style={{ fontSize:'0.65rem', color:trend.color, margin:'0.1rem 0 0', fontWeight:600 }}>{trend.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deadline-Risiko je Projekt */}
|
||||||
|
{risks.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize:'0.65rem', color:'#475569', textTransform:'uppercase', letterSpacing:'0.05em', margin:'0 0 0.375rem' }}>Deadline-Risiko je Projekt</p>
|
||||||
|
{risks.map(r => (
|
||||||
|
<div key={r.projekt} style={{ display:'flex', justifyContent:'space-between', alignItems:'center', background:'#0f172a', borderRadius:'0.375rem', padding:'0.3rem 0.5rem', marginBottom:'0.25rem' }}>
|
||||||
|
<span style={{ fontSize:'0.75rem', color:'#cbd5e1' }}>{r.projekt}</span>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:'0.375rem' }}>
|
||||||
|
<div style={{ background:'#334155', borderRadius:'9999px', width:'60px', height:'5px', overflow:'hidden' }}>
|
||||||
|
<div style={{ width:`${r.risikoPct}%`, height:'100%', background: r.risikoPct > 50 ? '#EF4444' : '#EAB308' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize:'0.7rem', color: r.risikoPct > 50 ? '#EF4444' : '#EAB308', fontWeight:700, width:'2.5rem', textAlign:'right' }}>{r.risikoPct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Energieblöcke ────────────────────────────────────────────────────
|
||||||
|
function Energiebloecke({ todos }: { todos: Todo[] }) {
|
||||||
|
const { fokus, mittel, leicht } = energiebloecke(todos)
|
||||||
|
|
||||||
|
function Block({ label, color, items, hint }: { label:string; color:string; items:Todo[]; hint:string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex:1 }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:'0.375rem', marginBottom:'0.375rem' }}>
|
||||||
|
<div style={{ width:'10px', height:'10px', borderRadius:'50%', background:color, flexShrink:0 }} />
|
||||||
|
<span style={{ fontSize:'0.7rem', fontWeight:700, color, textTransform:'uppercase', letterSpacing:'0.08em' }}>{label}</span>
|
||||||
|
<span style={{ fontSize:'0.65rem', color:'#475569' }}>({items.length})</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize:'0.65rem', color:'#475569', marginBottom:'0.375rem' }}>{hint}</div>
|
||||||
|
{items.slice(0,3).map(t => (
|
||||||
|
<div key={t.id} style={{ background:'#0f172a', borderRadius:'0.375rem', padding:'0.25rem 0.5rem', marginBottom:'0.2rem', display:'flex', justifyContent:'space-between', gap:'0.5rem' }}>
|
||||||
|
<span style={{ fontSize:'0.72rem', color:'#94a3b8' }}>{t.name.slice(0,32)}</span>
|
||||||
|
<span style={{ fontSize:'0.65rem', color:'#475569', whiteSpace:'nowrap' }}>{t.zeitH ? `${t.zeitH}h` : '?h'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{items.length > 3 && <p style={{ fontSize:'0.65rem', color:'#334155', margin:'0.2rem 0 0 0.5rem' }}>+{items.length-3} weitere</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>Energieblöcke (Wochenplan)</Label>
|
||||||
|
<div style={{ display:'flex', gap:'0.75rem' }}>
|
||||||
|
<Block label="Fokus" color="#EF4444" items={fokus} hint="≥2h · Hoch-Prio · morgens" />
|
||||||
|
<Block label="Mittel" color="#EAB308" items={mittel} hint="1–2h · Mittel-Prio" />
|
||||||
|
<Block label="Leicht" color="#22C55E" items={leicht} hint="<1h · flexibel" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Abhängigkeits-Tracker ────────────────────────────────────────────
|
||||||
|
function AbhaengigkeitsTracker({ todos }: { todos: Todo[] }) {
|
||||||
|
const ketten = meilensteinKetten(todos)
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>Abhängigkeits-Tracker (Meilenstein-Ketten)</Label>
|
||||||
|
{ketten.length === 0
|
||||||
|
? <p style={{ color:'#475569', fontSize:'0.875rem' }}>Keine offenen Meilensteine</p>
|
||||||
|
: ketten.map(k => (
|
||||||
|
<div key={k.label} style={{ marginBottom:'0.625rem' }}>
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:'0.25rem' }}>
|
||||||
|
<span style={{ fontSize:'0.75rem', color:'#94a3b8', fontWeight:600 }}>{k.label}</span>
|
||||||
|
{k.overdueCount > 0 && <Badge color="#EF4444">{k.overdueCount} überfällig</Badge>}
|
||||||
|
</div>
|
||||||
|
<div style={{ paddingLeft:'0.5rem', borderLeft:'2px solid #334155' }}>
|
||||||
|
{k.todos.slice(0,3).map(t => (
|
||||||
|
<div key={t.id} style={{ display:'flex', alignItems:'center', gap:'0.375rem', marginBottom:'0.2rem' }}>
|
||||||
|
<span style={{ width:'6px', height:'6px', borderRadius:'50%', background: t.deadline && daysFromNow(t.deadline) < 0 ? '#EF4444' : '#334155', flexShrink:0, display:'block' }} />
|
||||||
|
<span style={{ fontSize:'0.72rem', color:'#64748b' }}>{t.name.slice(0,44)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{k.todos.length > 3 && <p style={{ fontSize:'0.65rem', color:'#334155', margin:'0.1rem 0 0 0.875rem' }}>+{k.todos.length-3} weitere</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Kategorien-Chart ─────────────────────────────────────────────────
|
||||||
|
function KategorienChart({ todos }: { todos: Todo[] }) {
|
||||||
|
const data = kategorienData(todos)
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>Stunden je Kategorie (offen)</Label>
|
||||||
|
{data.length === 0
|
||||||
|
? <p style={{ color:'#475569', fontSize:'0.875rem' }}>Keine Daten</p>
|
||||||
|
: <ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={data} margin={{ top:4, right:8, left:-16, bottom:60 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#0f172a" />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize:9, fill:'#64748b' }} angle={-40} textAnchor="end" interval={0} />
|
||||||
|
<YAxis tick={{ fontSize:9, fill:'#64748b' }} unit="h" />
|
||||||
|
<Tooltip contentStyle={{ background:'#1e293b', border:'1px solid #334155', borderRadius:'0.5rem' }} labelStyle={{ color:'#f1f5f9', fontWeight:600 }} itemStyle={{ color:'#94a3b8' }} formatter={(v) => [`${v}h`, 'Stunden']} />
|
||||||
|
<Bar dataKey="stunden" radius={[4,4,0,0]}>
|
||||||
|
{data.map(e => <Cell key={e.name} fill={kc(e.name)} />)}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Top 5 Deadlines ──────────────────────────────────────────────────
|
||||||
|
function Top5({ todos }: { todos: Todo[] }) {
|
||||||
|
const items = top5(todos)
|
||||||
|
function dl(days: number) {
|
||||||
|
if (days < 0) return { text:`${Math.abs(days)}d überfällig`, color:'#EF4444' }
|
||||||
|
if (days === 0) return { text:'Heute', color:'#EAB308' }
|
||||||
|
if (days <= 3) return { text:`in ${days}d`, color:'#EAB308' }
|
||||||
|
return { text:`in ${days}d`, color:'#22C55E' }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>Top 5 — nächste Deadlines</Label>
|
||||||
|
{items.length === 0
|
||||||
|
? <p style={{ color:'#475569', fontSize:'0.875rem' }}>Keine offenen Todos mit Deadline</p>
|
||||||
|
: items.map((t,i) => {
|
||||||
|
const d = dl(t.daysLeft)
|
||||||
|
return (
|
||||||
|
<div key={t.id} style={{ display:'flex', gap:'0.625rem', background:'#0f172a', borderRadius:'0.5rem', padding:'0.5rem 0.625rem', marginBottom:'0.375rem' }}>
|
||||||
|
<span style={{ fontSize:'0.7rem', fontWeight:700, color:'#475569', width:'1rem', flexShrink:0 }}>{i+1}.</span>
|
||||||
|
<div style={{ flex:1, minWidth:0 }}>
|
||||||
|
<p style={{ color:'#f1f5f9', fontSize:'0.78rem', fontWeight:600, margin:0, lineHeight:1.3 }}>{t.name.slice(0,48)}</p>
|
||||||
|
<p style={{ color:'#475569', fontSize:'0.7rem', margin:'0.15rem 0 0' }}>{t.projekt}{t.meilenstein ? ` · ${t.meilenstein}` : ''}</p>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize:'0.7rem', fontWeight:700, color:d.color, flexShrink:0, whiteSpace:'nowrap' }}>{d.text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Frühwarnsystem ───────────────────────────────────────────────────
|
||||||
|
function Fruehwarnsystem({ todos }: { todos: Todo[] }) {
|
||||||
|
const { overdue, upcoming } = fruehwarnung(todos)
|
||||||
|
const kap = kapazitaet(todos)
|
||||||
|
const warnings = []
|
||||||
|
if (overdue.length > 0) warnings.push({ text:`${overdue.length} überfällige Todos`, color:'#EF4444' })
|
||||||
|
if (upcoming.length > 0) warnings.push({ text:`${upcoming.length} Todos fällig in ≤ 3 Tagen`, color:'#EAB308' })
|
||||||
|
if (kap.pct > 100) warnings.push({ text:`Überlastet: ${kap.pct}% der Kapazität`, color:'#EF4444' })
|
||||||
|
else if (kap.pct > 80) warnings.push({ text:`Grenzwertig: ${kap.pct}% der Kapazität`, color:'#EAB308' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>Frühwarnsystem</Label>
|
||||||
|
{warnings.length === 0
|
||||||
|
? <p style={{ color:'#22C55E', fontSize:'0.875rem' }}>✓ Alles im Rahmen</p>
|
||||||
|
: <div style={{ display:'flex', flexDirection:'column', gap:'0.375rem', marginBottom:'0.75rem' }}>
|
||||||
|
{warnings.map((w,i) => (
|
||||||
|
<div key={i} style={{ borderLeft:`3px solid ${w.color}`, paddingLeft:'0.625rem', background:'#0f172a', borderRadius:'0 0.375rem 0.375rem 0', padding:'0.375rem 0.5rem 0.375rem 0.625rem', fontSize:'0.78rem', color:w.color }}>
|
||||||
|
{w.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{overdue.slice(0,3).map(t => (
|
||||||
|
<p key={t.id} style={{ fontSize:'0.72rem', color:'#EF4444', margin:'0.1rem 0' }}>· {t.name.slice(0,46)} ({t.projekt})</p>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Diana ────────────────────────────────────────────────────────────
|
||||||
|
function DianaSection({ todos }: { todos: Todo[] }) {
|
||||||
|
const diana = todos.filter(t => t.kategorie === 'Diana' && !isErledigt(t))
|
||||||
|
if (diana.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>Diana</Label>
|
||||||
|
{diana.map(t => (
|
||||||
|
<div key={t.id} style={{ display:'flex', justifyContent:'space-between', background:'#0f172a', borderRadius:'0.375rem', padding:'0.3rem 0.5rem', marginBottom:'0.25rem', fontSize:'0.75rem' }}>
|
||||||
|
<span style={{ color:'#f1f5f9' }}>{t.name.slice(0,48)}</span>
|
||||||
|
{t.deadline && <span style={{ color: daysFromNow(t.deadline) < 0 ? '#EF4444' : '#94a3b8', fontSize:'0.7rem', whiteSpace:'nowrap', marginLeft:'0.5rem' }}>{t.deadline.slice(5)}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Me Time ──────────────────────────────────────────────────────────
|
||||||
|
function MeTime({ todos }: { todos: Todo[] }) {
|
||||||
|
const meTodos = todos.filter(t => t.kategorie === 'Me Time' && !isErledigt(t))
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>Me Time</Label>
|
||||||
|
<div style={{ background:'#EAB30822', border:'1px solid #EAB30866', borderRadius:'0.5rem', padding:'0.625rem 0.75rem', marginBottom:'0.5rem' }}>
|
||||||
|
<p style={{ fontSize:'0.85rem', fontWeight:700, color:'#EAB308', margin:'0 0 0.2rem' }}>⚠ Noch nicht definiert</p>
|
||||||
|
<p style={{ fontSize:'0.7rem', color:'#92700a', margin:0 }}>Zeitslot für Me Time ist noch offen — bitte planen</p>
|
||||||
|
</div>
|
||||||
|
{meTodos.length === 0
|
||||||
|
? <p style={{ fontSize:'0.72rem', color:'#475569' }}>Keine Me-Time-Todos diese Woche</p>
|
||||||
|
: <p style={{ fontSize:'0.72rem', color:'#22C55E' }}>{meTodos.length} Me-Time-Todos eingetragen</p>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Zeitdifferenz ────────────────────────────────────────────────────
|
||||||
|
function Zeitdifferenz({ todos }: { todos: Todo[] }) {
|
||||||
|
const rows = zeitdifferenz(todos).sort((a,b) => Math.abs(b.diff) - Math.abs(a.diff))
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Label>Zeitdifferenz je Projekt</Label>
|
||||||
|
{rows.map(r => {
|
||||||
|
const dc = r.diff >= 0 ? '#22C55E' : '#EF4444'
|
||||||
|
const ds = r.diff >= 0 ? '+' : ''
|
||||||
|
return (
|
||||||
|
<div key={r.projekt} style={{ display:'grid', gridTemplateColumns:'1fr auto auto auto', gap:'0.5rem', alignItems:'center', background:'#0f172a', borderRadius:'0.375rem', padding:'0.3rem 0.5rem', marginBottom:'0.2rem', fontSize:'0.72rem' }}>
|
||||||
|
<span style={{ color:'#cbd5e1', fontWeight:500 }}>{r.projekt}</span>
|
||||||
|
<span style={{ color:'#475569' }}>{r.geplant}h</span>
|
||||||
|
<span style={{ color:'#475569' }}>{r.tatsaechlich}h</span>
|
||||||
|
<span style={{ color:dc, fontWeight:700, textAlign:'right' }}>{ds}{r.diff}h</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr auto auto auto', gap:'0.5rem', padding:'0.2rem 0.5rem', fontSize:'0.62rem', color:'#334155' }}>
|
||||||
|
<span /><span>Geplant</span><span>Tatsächl.</span><span>Diff</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section: Kritische Stellen ────────────────────────────────────────────────
|
||||||
|
function KritischeStellen({ data }: { data: KritischData }) {
|
||||||
|
const uhrzeit = new Date(data.stand).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data.allesOk) {
|
||||||
|
return (
|
||||||
|
<div style={{ background:'#052e16', border:'1px solid #166534', borderRadius:'0.75rem', padding:'0.875rem 1.25rem', display:'flex', justifyContent:'space-between', alignItems:'center' }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:'0.625rem' }}>
|
||||||
|
<span style={{ fontSize:'1rem' }}>✓</span>
|
||||||
|
<span style={{ fontSize:'0.85rem', fontWeight:700, color:'#22C55E' }}>Alles im Rahmen — keine kritischen Stellen</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize:'0.65rem', color:'#166534' }}>Stand: {uhrzeit} Uhr</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const farbenMap: Record<string, string> = { rot: '#EF4444', gelb: '#EAB308' }
|
||||||
|
const typIcon: Record<string, string> = {
|
||||||
|
ueberfallig: '🔴', deadline: '⚠', meilenstein: '🎯', kapazitaet: '📊'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background:'#1a0a0a', border:'1px solid #7f1d1d', borderRadius:'0.75rem', padding:'0.875rem 1.25rem' }}>
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:'0.625rem' }}>
|
||||||
|
<span style={{ fontSize:'0.72rem', fontWeight:700, letterSpacing:'0.1em', textTransform:'uppercase', color:'#EF4444' }}>
|
||||||
|
Kritische Stellen ({data.kritisch.length})
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize:'0.65rem', color:'#7f1d1d' }}>Stand: {uhrzeit} Uhr</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', gap:'0.375rem' }}>
|
||||||
|
{data.kritisch.map((e, i) => {
|
||||||
|
const color = farbenMap[e.farbe] ?? '#EF4444'
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display:'flex', alignItems:'flex-start', gap:'0.625rem', background:'#0f172a', borderRadius:'0.375rem', padding:'0.4rem 0.625rem', borderLeft:`3px solid ${color}` }}>
|
||||||
|
<span style={{ fontSize:'0.8rem', flexShrink:0 }}>{typIcon[e.typ] ?? '⚠'}</span>
|
||||||
|
<div style={{ flex:1, minWidth:0 }}>
|
||||||
|
<span style={{ fontSize:'0.72rem', color:'#94a3b8', fontWeight:600, marginRight:'0.375rem' }}>{e.projekt}</span>
|
||||||
|
<span style={{ fontSize:'0.72rem', color:'#cbd5e1' }}>{e.text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function DashboardClient({ todos, fetchedAt, kritischData }: { todos: Todo[]; fetchedAt: string; kritischData: KritischData }) {
|
||||||
|
const totalOpen = todos.filter(t => !isErledigt(t)).length
|
||||||
|
const uhrzeit = new Date(fetchedAt).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight:'100vh', background:'#0f172a', fontFamily:'var(--font-geist-sans, system-ui, sans-serif)', color:'#f1f5f9' }}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ background:`linear-gradient(135deg, ${MC_BLUE} 0%, ${MC_PURPLE} 100%)`, padding:'1.25rem 2rem' }}>
|
||||||
|
<div style={{ maxWidth:'1400px', margin:'0 auto', display:'flex', justifyContent:'space-between', alignItems:'flex-end', flexWrap:'wrap', gap:'0.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize:'1.4rem', fontWeight:900, color:'#fff', margin:0, letterSpacing:'-0.02em' }}>ATHENA PM Dashboard</h1>
|
||||||
|
<p style={{ color:'rgba(255,255,255,0.7)', fontSize:'0.75rem', margin:'0.2rem 0 0' }}>Market Compass · Live Notion PM</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign:'right' }}>
|
||||||
|
<p style={{ color:'rgba(255,255,255,0.6)', fontSize:'0.7rem', margin:0 }}>Aktualisiert: {uhrzeit} Uhr</p>
|
||||||
|
<p style={{ color:'rgba(255,255,255,0.6)', fontSize:'0.7rem', margin:'0.1rem 0 0' }}>{totalOpen} offene Todos · 10 Projekte</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div style={{ maxWidth:'1400px', margin:'0 auto', padding:'1.5rem 2rem', display:'flex', flexDirection:'column', gap:'1rem' }}>
|
||||||
|
|
||||||
|
{/* Row 0: Kritische Stellen */}
|
||||||
|
<KritischeStellen data={kritischData} />
|
||||||
|
|
||||||
|
{/* Row 1: Kapazität + Machbarkeit + ATHENA Empfehlungen */}
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:'1rem' }}>
|
||||||
|
<KapazitaetsAmpel todos={todos} />
|
||||||
|
<Machbarkeit todos={todos} />
|
||||||
|
<AthenaEmpfehlungen todos={todos} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Predictive Analytics + Energieblöcke + Me Time */}
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:'1rem' }}>
|
||||||
|
<PredictiveAnalytics todos={todos} />
|
||||||
|
<Energiebloecke todos={todos} />
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', gap:'1rem' }}>
|
||||||
|
<MeTime todos={todos} />
|
||||||
|
<DianaSection todos={todos} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Kategorien-Chart + Top 5 */}
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'2fr 1fr', gap:'1rem' }}>
|
||||||
|
<KategorienChart todos={todos} />
|
||||||
|
<Top5 todos={todos} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Frühwarnsystem + Zeitdifferenz + Abhängigkeiten */}
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:'1rem' }}>
|
||||||
|
<Fruehwarnsystem todos={todos} />
|
||||||
|
<Zeitdifferenz todos={todos} />
|
||||||
|
<AbhaengigkeitsTracker todos={todos} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/app/dashboard/page.tsx
Normal file
42
src/app/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { fetchAllTodos } from '@/lib/notion-pm'
|
||||||
|
import DashboardClient from './components/DashboardClient'
|
||||||
|
|
||||||
|
export const revalidate = 300
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'ATHENA PM Dashboard | Market Compass',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KritischEintrag {
|
||||||
|
typ: 'deadline' | 'meilenstein' | 'kapazitaet' | 'ueberfallig'
|
||||||
|
projekt: string
|
||||||
|
text: string
|
||||||
|
farbe: 'rot' | 'gelb'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KritischData {
|
||||||
|
stand: string
|
||||||
|
kritisch: KritischEintrag[]
|
||||||
|
allesOk: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKritischData(): KritischData {
|
||||||
|
try {
|
||||||
|
const filePath = join(process.cwd(), 'public', 'pm-kritisch.json')
|
||||||
|
const raw = readFileSync(filePath, 'utf-8')
|
||||||
|
return JSON.parse(raw) as KritischData
|
||||||
|
} catch {
|
||||||
|
return { stand: new Date().toISOString(), kritisch: [], allesOk: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const [todos, kritischData] = await Promise.all([
|
||||||
|
fetchAllTodos(),
|
||||||
|
Promise.resolve(loadKritischData()),
|
||||||
|
])
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
return <DashboardClient todos={todos} fetchedAt={timestamp} kritischData={kritischData} />
|
||||||
|
}
|
||||||
50
src/app/olga/layout.tsx
Normal file
50
src/app/olga/layout.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import type { Metadata } from "next"
|
||||||
|
import { Cormorant_Garamond, DM_Sans } from "next/font/google"
|
||||||
|
|
||||||
|
const cormorant = Cormorant_Garamond({
|
||||||
|
variable: "--font-cormorant",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "400", "500", "600"],
|
||||||
|
style: ["normal", "italic"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const dmSans = DM_Sans({
|
||||||
|
variable: "--font-dm-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "400", "500", "600"],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Skin Signal Deep Scan — Olga Izieva",
|
||||||
|
description:
|
||||||
|
"In 90 Minuten weißt du endlich, was dein Körper dir die ganze Zeit sagen wollte. Skin Signal Deep Scan von Olga Izieva — systemische Körperanalyse für Frauen 35+.",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OlgaLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${cormorant.variable} ${dmSans.variable}`}
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-dm-sans, system-ui, sans-serif)",
|
||||||
|
background: "#F0EBE1",
|
||||||
|
minHeight: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
.olga-bio-grid {
|
||||||
|
grid-template-columns: 200px 1fr !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.olga-bio-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
640
src/app/olga/page.tsx
Normal file
640
src/app/olga/page.tsx
Normal file
|
|
@ -0,0 +1,640 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Image from "next/image"
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
dark: "#1C2420",
|
||||||
|
green: "#2D4A3E",
|
||||||
|
greenLight: "#3D5C4E",
|
||||||
|
cognac: "#A06040",
|
||||||
|
cognacLight: "#C4845A",
|
||||||
|
parchment: "#F0EBE1",
|
||||||
|
parchmentMid: "#E8E0D4",
|
||||||
|
parchmentDark: "#DDD4C4",
|
||||||
|
brown: "#1A1210",
|
||||||
|
textDark: "#2A2218",
|
||||||
|
textMuted: "#7A6E62",
|
||||||
|
white: "#FDFAF6",
|
||||||
|
sage: "#EBF0ED",
|
||||||
|
}
|
||||||
|
|
||||||
|
function StrataDecor({ color = C.cognac, opacity = 0.2 }: { color?: string; opacity?: number }) {
|
||||||
|
return (
|
||||||
|
<svg width="280" height="40" viewBox="0 0 280 40" fill="none" aria-hidden="true">
|
||||||
|
<path d="M0 10 Q70 5 140 12 Q210 19 280 8" stroke={color} strokeWidth="1.5" fill="none" opacity={opacity + 0.08} />
|
||||||
|
<path d="M0 20 Q70 15 140 22 Q210 29 280 18" stroke={color} strokeWidth="1" fill="none" opacity={opacity} />
|
||||||
|
<path d="M0 30 Q70 25 140 32 Q210 39 280 28" stroke={color} strokeWidth="0.75" fill="none" opacity={opacity - 0.08} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: React.CSSProperties = {
|
||||||
|
fontFamily: "var(--font-dm-sans, system-ui, sans-serif)",
|
||||||
|
color: C.textDark,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
const serif: React.CSSProperties = {
|
||||||
|
fontFamily: "var(--font-cormorant, Georgia, serif)",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OlgaDeepScanPage() {
|
||||||
|
const [formData, setFormData] = useState({ name: "", email: "", phone: "", message: "" })
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||||
|
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitted(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={body}>
|
||||||
|
|
||||||
|
{/* ── HEADER ─────────────────────────────────────────────── */}
|
||||||
|
<header style={{ background: C.dark, padding: "14px 32px" }}>
|
||||||
|
<div style={{ maxWidth: 760, margin: "0 auto" }}>
|
||||||
|
<p style={{
|
||||||
|
...serif,
|
||||||
|
color: C.parchment,
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
letterSpacing: "0.14em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
margin: 0,
|
||||||
|
opacity: 0.75,
|
||||||
|
}}>
|
||||||
|
Olga Izieva · Skin Signal Method™
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── HERO ───────────────────────────────────────────────── */}
|
||||||
|
<section style={{ background: C.green, padding: "88px 32px 80px" }}>
|
||||||
|
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||||
|
|
||||||
|
<span style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background: C.cognac,
|
||||||
|
color: C.white,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
padding: "5px 14px",
|
||||||
|
borderRadius: 2,
|
||||||
|
marginBottom: 28,
|
||||||
|
}}>
|
||||||
|
Nur 5 Plätze verfügbar
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 style={{
|
||||||
|
...serif,
|
||||||
|
color: C.white,
|
||||||
|
fontSize: "clamp(2rem, 5vw, 3rem)",
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
marginBottom: 24,
|
||||||
|
maxWidth: 600,
|
||||||
|
}}>
|
||||||
|
In 90 Minuten weißt du endlich, was dein Körper dir die ganze Zeit sagen wollte
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
color: C.parchment,
|
||||||
|
fontSize: "1.05rem",
|
||||||
|
lineHeight: 1.75,
|
||||||
|
marginBottom: 12,
|
||||||
|
maxWidth: 540,
|
||||||
|
opacity: 0.88,
|
||||||
|
}}>
|
||||||
|
Der Skin Signal Deep Scan — eine systemische Körperanalyse, die Haut, Hormone, Darm, Schlaf und Stress als ein zusammenhängendes Bild liest.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
color: C.parchment,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
marginBottom: 44,
|
||||||
|
opacity: 0.6,
|
||||||
|
}}>
|
||||||
|
Olga Izieva · 33 Jahre klinische Erfahrung · Heilpraktikerin · Medical Cosmetology
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#termin"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background: C.cognac,
|
||||||
|
color: C.white,
|
||||||
|
padding: "15px 38px",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: 3,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Meinen Platz sichern →
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p style={{ color: C.parchment, fontSize: "0.8rem", marginTop: 14, opacity: 0.55 }}>
|
||||||
|
Pilotpreis 197 € · persönlich bei Olga · 5 Plätze
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 48, opacity: 0.7 }}>
|
||||||
|
<StrataDecor color={C.parchment} opacity={0.12} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── PROBLEM ────────────────────────────────────────────── */}
|
||||||
|
<section style={{ background: C.white, padding: "80px 32px" }}>
|
||||||
|
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||||
|
|
||||||
|
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||||
|
Kommt dir das bekannt vor?
|
||||||
|
</p>
|
||||||
|
<h2 style={{
|
||||||
|
...serif,
|
||||||
|
color: C.dark,
|
||||||
|
fontSize: "clamp(1.7rem, 3.5vw, 2.4rem)",
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
marginBottom: 48,
|
||||||
|
maxWidth: 560,
|
||||||
|
}}>
|
||||||
|
Du tust alles richtig — und trotzdem stimmt irgendetwas nicht.
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 24 }}>
|
||||||
|
{[
|
||||||
|
["Haut die müde wirkt", "Trotz Pflege, Supplements und clean eating — sie strahlt einfach nicht mehr so wie früher."],
|
||||||
|
["Ärzte sagen: alles normal", "Die Blutwerte sind unauffällig. Aber du fühlst, dass etwas nicht stimmt — und niemand kann dir sagen was."],
|
||||||
|
["Du hast schon alles probiert", "Keto, Yoga, Schlaftracking, Vitamin D, Kollagen. Jedes Mal kurze Besserung. Dann wieder Ausgangszustand."],
|
||||||
|
["Niemand sieht das Gesamtbild", "Jeder schaut auf seinen Bereich. Haut oder Hormone oder Darm. Nie alles zusammen. Nie du."],
|
||||||
|
].map(([title, text], i) => (
|
||||||
|
<div key={i} style={{ padding: "24px 0", borderTop: `1.5px solid ${C.parchmentDark}` }}>
|
||||||
|
<div style={{
|
||||||
|
...serif,
|
||||||
|
color: C.cognac,
|
||||||
|
fontSize: "1.6rem",
|
||||||
|
fontWeight: 400,
|
||||||
|
marginBottom: 10,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}>
|
||||||
|
{String(i + 1).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
<p style={{ fontWeight: 600, color: C.textDark, fontSize: "0.95rem", marginBottom: 6 }}>{title}</p>
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "0.88rem", lineHeight: 1.65 }}>{text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── EPIPHANY ───────────────────────────────────────────── */}
|
||||||
|
<section style={{ background: C.sage, padding: "80px 32px" }}>
|
||||||
|
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||||
|
<StrataDecor color={C.green} opacity={0.16} />
|
||||||
|
<div style={{ height: 40 }} />
|
||||||
|
|
||||||
|
<blockquote style={{
|
||||||
|
...serif,
|
||||||
|
color: C.green,
|
||||||
|
fontSize: "clamp(1.6rem, 4vw, 2.6rem)",
|
||||||
|
fontStyle: "italic",
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
marginBottom: 40,
|
||||||
|
borderLeft: `3px solid ${C.cognac}`,
|
||||||
|
paddingLeft: 32,
|
||||||
|
maxWidth: 600,
|
||||||
|
}}>
|
||||||
|
„Die Haut ist keine Krankheit. Sie ist die Botschaft."
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "1rem", lineHeight: 1.8, marginBottom: 16, maxWidth: 560 }}>
|
||||||
|
Olga Izieva sieht seit 33 Jahren, was Ärzte nicht sehen wollen: Hautprobleme sind keine Oberflächenfrage. Sie zeigen, was im Körper wirklich passiert.
|
||||||
|
</p>
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "1rem", lineHeight: 1.8, maxWidth: 560 }}>
|
||||||
|
Als ihre eigene Haut durch Stress zusammenbrach und niemand helfen konnte, entwickelte sie die Skin Signal Method™. Ein systemischer Ansatz, der Haut als das liest, was sie ist: das ehrlichste Diagnoseinstrument, das wir haben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── VALUE STACK ────────────────────────────────────────── */}
|
||||||
|
<section style={{ background: C.white, padding: "80px 32px" }}>
|
||||||
|
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||||
|
|
||||||
|
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||||
|
Was du bekommst
|
||||||
|
</p>
|
||||||
|
<h2 style={{
|
||||||
|
...serif,
|
||||||
|
color: C.dark,
|
||||||
|
fontSize: "clamp(1.7rem, 3.5vw, 2.2rem)",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
Skin Signal Deep Scan
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "0.95rem", marginBottom: 48 }}>
|
||||||
|
90 Minuten. Vollständige systemische Analyse. Alles schriftlich.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 0, marginBottom: 40, borderTop: `1.5px solid ${C.parchmentDark}` }}>
|
||||||
|
{[
|
||||||
|
["Visuelle Körperanalyse", "Haut, Zunge, Nägel, Haare, Lippen — Olga liest alle äußeren Signale deines Körpers", "150 €"],
|
||||||
|
["Systemische Anamnese", "Hormone, Darm, Schlaf, Stress, Ernährung, Bewegung, Medikamente — beide vollständigen Fragebögen", "180 €"],
|
||||||
|
["Schriftliche System-Hypothese", "Olgas Einschätzung was systemisch hinter deinen Symptomen steckt — benannt, nicht vermutet", "220 €"],
|
||||||
|
["7-Tage-Sofortplan", "Drei bis fünf konkrete Schritte die heute noch beginnen können — schriftlich", "102 €"],
|
||||||
|
["Klare Einschätzung nächster Schritt", "Du weißt danach genau was als nächstes kommt — Olga schlägt die konkrete Richtung vor", "50 €"],
|
||||||
|
].map(([title, desc, value], i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 20,
|
||||||
|
padding: "20px 0",
|
||||||
|
borderBottom: `1px solid ${C.parchmentDark}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<p style={{ fontWeight: 600, color: C.textDark, fontSize: "0.93rem", marginBottom: 3 }}>{title}</p>
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "0.84rem", lineHeight: 1.55 }}>{desc}</p>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: C.cognac, fontWeight: 600, fontSize: "0.9rem", whiteSpace: "nowrap", paddingTop: 2 }}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preisblock */}
|
||||||
|
<div style={{
|
||||||
|
background: C.green,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "32px 36px",
|
||||||
|
marginBottom: 20,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 10, flexWrap: "wrap", gap: 8 }}>
|
||||||
|
<p style={{ color: C.parchment, fontSize: "0.88rem", opacity: 0.7 }}>Gesamtwert</p>
|
||||||
|
<p style={{ ...serif, color: C.parchment, fontSize: "1.4rem", textDecoration: "line-through", opacity: 0.4 }}>702 €</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
<p style={{ color: C.parchment, fontSize: "1rem", fontWeight: 600 }}>Pilotpreis — für 5 Bestandskundinnen</p>
|
||||||
|
<p style={{ ...serif, color: C.white, fontSize: "2.6rem", fontWeight: 600, lineHeight: 1 }}>197 €</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "0.83rem", lineHeight: 1.65, fontStyle: "italic", marginBottom: 40 }}>
|
||||||
|
Das ist der erste strukturierte Durchlauf der Skin Signal Method™. Du bekommst die vollständige Analyse. Im Gegenzug nimmt sich Olga die Zeit, die sie im regulären Praxiskontext nicht hätte.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<a
|
||||||
|
href="#termin"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background: C.cognac,
|
||||||
|
color: C.white,
|
||||||
|
padding: "15px 42px",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: "none",
|
||||||
|
borderRadius: 3,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Meinen Platz sichern →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── TRANSFORMATION ─────────────────────────────────────── */}
|
||||||
|
<section style={{ background: C.parchmentMid, padding: "80px 32px" }}>
|
||||||
|
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||||
|
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 24 }}>
|
||||||
|
Was danach anders ist
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ borderLeft: `3px solid ${C.green}`, paddingLeft: 32 }}>
|
||||||
|
<p style={{ ...serif, color: C.dark, fontSize: "clamp(1.2rem, 2.5vw, 1.5rem)", lineHeight: 1.7, marginBottom: 20, fontStyle: "italic" }}>
|
||||||
|
„Nach diesem Gespräch weißt du endlich, was dein Körper dir die ganze Zeit sagen wollte."
|
||||||
|
</p>
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "0.97rem", lineHeight: 1.8, marginBottom: 16 }}>
|
||||||
|
Die Haut die müde wirkt, die Energie die fehlt, das Gewicht das sich trotz allem nicht bewegt — du verstehst jetzt das Muster dahinter. Olga hat dich als Gesamtbild gelesen: Haut, Zunge, Nägel, Haare, Darm, Hormone, Schlaf, Stress — alles zusammen.
|
||||||
|
</p>
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "0.97rem", lineHeight: 1.8 }}>
|
||||||
|
Du verlässt das Gespräch nicht mit einem weiteren Ratschlag. Sondern mit einer klaren Einschätzung was systemisch hinter deinen Symptomen steckt — und einem 7-Tage-Plan der heute noch startet.{" "}
|
||||||
|
<strong style={{ color: C.dark }}>Dein Körper war nie dein Feind. Er hat nur auf Klarheit gewartet.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── ÜBER OLGA ──────────────────────────────────────────── */}
|
||||||
|
<section style={{ background: C.white, padding: "80px 32px" }}>
|
||||||
|
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||||
|
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 40 }}>
|
||||||
|
Über Olga Izieva
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "200px 1fr",
|
||||||
|
gap: 48,
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
className="olga-bio-grid"
|
||||||
|
>
|
||||||
|
{/* Foto */}
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<div style={{
|
||||||
|
position: "relative",
|
||||||
|
width: 200,
|
||||||
|
height: 240,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "0 8px 32px rgba(28,36,32,0.12)",
|
||||||
|
}}>
|
||||||
|
<Image
|
||||||
|
src="/olga.jpg"
|
||||||
|
alt="Olga Izieva"
|
||||||
|
fill
|
||||||
|
style={{ objectFit: "cover", objectPosition: "center top" }}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: -12,
|
||||||
|
right: -12,
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: C.parchment,
|
||||||
|
border: `3px solid ${C.cognac}`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
...serif,
|
||||||
|
color: C.cognac,
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: 4,
|
||||||
|
}}>
|
||||||
|
33 Jahre<br />Erfahrung
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
...serif,
|
||||||
|
color: C.dark,
|
||||||
|
fontSize: "clamp(1.6rem, 3vw, 2.2rem)",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 20,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}>
|
||||||
|
Die Frau, die deinen Körper liest wie niemand sonst
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "0.95rem", lineHeight: 1.8, marginBottom: 16 }}>
|
||||||
|
Olga Izieva hat 33 Jahre als Kosmetikerin gearbeitet — und dabei täglich gesehen, was Ärzte nicht sehen wollten: Die Haut ist kein kosmetisches Problem. Sie ist ein systemisches Signal.
|
||||||
|
</p>
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "0.95rem", lineHeight: 1.8, marginBottom: 28 }}>
|
||||||
|
Sie ist Heilpraktikerin, hat Zertifikate in Ernährungswissenschaft, Psychologie und Medical Cosmetology. Sie koordiniert Ärzte, Spezialisten und Coaches — der einzige Hub, der den visuellen Diagnosezugang der Kosmetik mit systemischer Medizin verbindet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{[
|
||||||
|
"33 Jahre klinische Praxis als Kosmetikerin",
|
||||||
|
"Ausgebildete Heilpraktikerin",
|
||||||
|
"Zertifikate in Ernährungswissenschaft & Psychologie",
|
||||||
|
"Medical Cosmetology",
|
||||||
|
"Skin Signal Method™ — aus der eigenen Praxis heraus entwickelt",
|
||||||
|
].map((point, i) => (
|
||||||
|
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
|
||||||
|
<span style={{ color: C.cognac, marginTop: 2, fontSize: "0.9rem" }}>—</span>
|
||||||
|
<p style={{ color: C.textDark, fontSize: "0.88rem", lineHeight: 1.5 }}>{point}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── GARANTIE ───────────────────────────────────────────── */}
|
||||||
|
<section style={{ background: C.parchment, padding: "80px 32px" }}>
|
||||||
|
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||||
|
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||||
|
Olgas Versprechen
|
||||||
|
</p>
|
||||||
|
<h2 style={{
|
||||||
|
...serif,
|
||||||
|
color: C.dark,
|
||||||
|
fontSize: "clamp(1.6rem, 3.5vw, 2.2rem)",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 48,
|
||||||
|
}}>
|
||||||
|
Ohne Wenn und Aber.
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 24 }}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: "Klarheits-Garantie",
|
||||||
|
text: "Wenn du nach unserem Gespräch nicht weißt, was dein nächster konkreter Schritt ist — wir machen es nochmal. Kostenlos.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "7-Tage-Garantie",
|
||||||
|
text: "Setz den 7-Tage-Sofortplan um. Wenn du nach einer Woche keine spürbare Veränderung merkst, bekommst du ein kostenloses 20-Minuten-Nachgespräch dazu.",
|
||||||
|
},
|
||||||
|
].map((g, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
background: C.white,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "28px 28px",
|
||||||
|
borderTop: `3px solid ${C.cognac}`,
|
||||||
|
}}>
|
||||||
|
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", marginBottom: 14 }}>
|
||||||
|
{g.label}
|
||||||
|
</p>
|
||||||
|
<p style={{ ...serif, color: C.dark, fontSize: "1.05rem", lineHeight: 1.65, fontStyle: "italic" }}>
|
||||||
|
„{g.text}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── ORDER FORM ─────────────────────────────────────────── */}
|
||||||
|
<section id="termin" style={{ background: C.sage, padding: "88px 32px 96px" }}>
|
||||||
|
<div style={{ maxWidth: 560, margin: "0 auto" }}>
|
||||||
|
<p style={{ color: C.cognac, fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", marginBottom: 16 }}>
|
||||||
|
Platz sichern
|
||||||
|
</p>
|
||||||
|
<h2 style={{
|
||||||
|
...serif,
|
||||||
|
color: C.dark,
|
||||||
|
fontSize: "clamp(1.7rem, 3.5vw, 2.4rem)",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}>
|
||||||
|
Dein Platz im Skin Signal Deep Scan
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "0.93rem", marginBottom: 6, lineHeight: 1.65 }}>
|
||||||
|
Füll das Formular aus — Olga meldet sich persönlich bei dir für die Terminvereinbarung.
|
||||||
|
</p>
|
||||||
|
<p style={{ color: C.cognac, fontSize: "0.85rem", fontWeight: 600, marginBottom: 44 }}>
|
||||||
|
Pilotpreis 197 € · 90 Minuten · 5 Plätze
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{submitted ? (
|
||||||
|
<div style={{
|
||||||
|
background: C.white,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "40px 36px",
|
||||||
|
textAlign: "center",
|
||||||
|
borderTop: `3px solid ${C.green}`,
|
||||||
|
}}>
|
||||||
|
<p style={{
|
||||||
|
...serif,
|
||||||
|
color: C.green,
|
||||||
|
fontSize: "1.9rem",
|
||||||
|
fontStyle: "italic",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}>
|
||||||
|
Anfrage eingegangen.
|
||||||
|
</p>
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "0.95rem", lineHeight: 1.7 }}>
|
||||||
|
Olga meldet sich innerhalb von 24 Stunden persönlich bei dir mit allen Details zum Termin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||||
|
{[
|
||||||
|
{ name: "name", label: "Dein Name", type: "text", placeholder: "Vorname Nachname", required: true },
|
||||||
|
{ name: "email", label: "E-Mail-Adresse", type: "email", placeholder: "deine@email.de", required: true },
|
||||||
|
{ name: "phone", label: "Telefonnummer (optional)", type: "tel", placeholder: "+49 ...", required: false },
|
||||||
|
].map((field) => (
|
||||||
|
<div key={field.name}>
|
||||||
|
<label style={{
|
||||||
|
display: "block",
|
||||||
|
color: C.textDark,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 7,
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
}}>
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={field.type}
|
||||||
|
name={field.name}
|
||||||
|
value={formData[field.name as keyof typeof formData]}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px 16px",
|
||||||
|
border: `1.5px solid ${C.parchmentDark}`,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: C.white,
|
||||||
|
color: C.textDark,
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: "block",
|
||||||
|
color: C.textDark,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 7,
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
}}>
|
||||||
|
Was beschäftigt dich gerade am meisten? (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
value={formData.message}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Beschreib kurz was dich beschäftigt — Olga bereitet sich damit auf das Gespräch vor."
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px 16px",
|
||||||
|
border: `1.5px solid ${C.parchmentDark}`,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: C.white,
|
||||||
|
color: C.textDark,
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
outline: "none",
|
||||||
|
resize: "vertical",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
fontFamily: "var(--font-dm-sans, system-ui, sans-serif)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
background: C.cognac,
|
||||||
|
color: C.white,
|
||||||
|
padding: "15px 36px",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: "pointer",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
marginTop: 4,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Anfrage absenden →
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p style={{ color: C.textMuted, fontSize: "0.79rem", lineHeight: 1.55 }}>
|
||||||
|
Kein automatischer Kauf. Olga meldet sich persönlich. Zahlung erfolgt nach Terminbestätigung.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── FOOTER ─────────────────────────────────────────────── */}
|
||||||
|
<footer style={{ background: C.dark, padding: "24px 32px" }}>
|
||||||
|
<div style={{ maxWidth: 720, margin: "0 auto", display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 10 }}>
|
||||||
|
<p style={{ ...serif, color: C.parchment, fontSize: "0.95rem", opacity: 0.65, letterSpacing: "0.06em" }}>
|
||||||
|
Olga Izieva · Skin Signal Method™
|
||||||
|
</p>
|
||||||
|
<p style={{ color: C.parchment, fontSize: "0.75rem", opacity: 0.35 }}>
|
||||||
|
Impressum · Datenschutz
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
src/lib/notion-pm.ts
Normal file
108
src/lib/notion-pm.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
const NOTION_VERSION = '2022-06-28'
|
||||||
|
|
||||||
|
function getHeaders() {
|
||||||
|
const token = process.env.PAI_NOTION_TOKEN
|
||||||
|
if (!token) throw new Error('PAI_NOTION_TOKEN not set')
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Notion-Version': NOTION_VERSION,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DBS: Record<string, string> = {
|
||||||
|
'GSO Gruppencoaching': '3641a317-e544-81f0-aa71-dc214949b532',
|
||||||
|
'OWV': '3641a317-e544-8105-84e8-d1bbcf6940a6',
|
||||||
|
'OSD': '3641a317-e544-8102-b677-ec4a77266d1c',
|
||||||
|
'Womenmatic': '3641a317-e544-8163-a10f-f2aa2669b2c6',
|
||||||
|
'Olga Izieva': '3641a317-e544-81ea-8f53-e21d20afda3f',
|
||||||
|
'Tecnoclean': '3641a317-e544-8113-a047-cb227d16d5ac',
|
||||||
|
'Dennis Alter': '3641a317-e544-810f-8a4c-e628e1d636fb',
|
||||||
|
'Interessenten': '3641a317-e544-8144-a67c-f39dab33b49c',
|
||||||
|
'Urlaub Kroatien': '3641a317-e544-819b-b28b-e6a49cf54dcb',
|
||||||
|
'Alltags ToDos': '3641a317-e544-812e-af33-e1912fe95416',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Todo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
projekt: string
|
||||||
|
status: string | null
|
||||||
|
prioritaet: string | null
|
||||||
|
deadline: string | null
|
||||||
|
startdatum: string | null
|
||||||
|
geplanterTag: string | null
|
||||||
|
meilenstein: string | null
|
||||||
|
zeitH: number | null
|
||||||
|
tatsaechlicheZeitH: number | null
|
||||||
|
zeitdifferenzH: number | null
|
||||||
|
kategorie: string | null
|
||||||
|
imWochenplan: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDbPages(dbId: string): Promise<any[]> {
|
||||||
|
const headers = getHeaders()
|
||||||
|
const pages: any[] = []
|
||||||
|
let cursor: string | undefined
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const body: Record<string, unknown> = { page_size: 100 }
|
||||||
|
if (cursor) body.start_cursor = cursor
|
||||||
|
|
||||||
|
const res = await fetch(`https://api.notion.com/v1/databases/${dbId}/query`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
next: { revalidate: 300 },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) break
|
||||||
|
const data = await res.json()
|
||||||
|
pages.push(...(data.results ?? []))
|
||||||
|
if (!data.has_more) break
|
||||||
|
cursor = data.next_cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTodo(page: any, projektName: string): Todo {
|
||||||
|
const p = page.properties ?? {}
|
||||||
|
|
||||||
|
const name = (p['Name']?.title ?? [])
|
||||||
|
.map((t: any) => t.plain_text as string)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const zeitH = p['Zeit (h)']?.number ?? null
|
||||||
|
const tatsaechlicheZeitH = p['Tatsächliche Zeit (h)']?.number ?? null
|
||||||
|
const zdRaw = p['Zeitdifferenz (h)']?.formula
|
||||||
|
const zeitdifferenzH =
|
||||||
|
zdRaw?.type === 'number' ? (zdRaw.number ?? null) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: page.id,
|
||||||
|
name,
|
||||||
|
projekt: projektName,
|
||||||
|
status: p['Status']?.status?.name ?? null,
|
||||||
|
prioritaet: p['Priorität']?.select?.name ?? null,
|
||||||
|
deadline: p['Deadline']?.date?.start ?? null,
|
||||||
|
startdatum: p['Startdatum']?.date?.start ?? null,
|
||||||
|
geplanterTag: p['Geplanter Tag']?.date?.start ?? null,
|
||||||
|
meilenstein: p['Meilenstein']?.select?.name ?? null,
|
||||||
|
zeitH,
|
||||||
|
tatsaechlicheZeitH,
|
||||||
|
zeitdifferenzH,
|
||||||
|
kategorie: p['Kategorie']?.select?.name ?? null,
|
||||||
|
imWochenplan: p['Im Wochenplan']?.checkbox ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllTodos(): Promise<Todo[]> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
Object.entries(DBS).map(async ([name, id]) => {
|
||||||
|
const pages = await fetchDbPages(id)
|
||||||
|
return pages.map((p) => parseTodo(p, name))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return results.flat()
|
||||||
|
}
|
||||||
Reference in a new issue