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": {
|
||||
"next": "16.2.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
313
pnpm-lock.yaml
313
pnpm-lock.yaml
|
|
@ -17,6 +17,9 @@ importers:
|
|||
react-dom:
|
||||
specifier: 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:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4
|
||||
|
|
@ -429,9 +432,26 @@ packages:
|
|||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||
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':
|
||||
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':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
|
|
@ -530,6 +550,33 @@ packages:
|
|||
'@tybys/wasm-util@0.10.1':
|
||||
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':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
|
|
@ -550,6 +597,9 @@ packages:
|
|||
'@types/react@19.2.14':
|
||||
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':
|
||||
resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -841,6 +891,10 @@ packages:
|
|||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
|
@ -861,6 +915,50 @@ packages:
|
|||
csstype@3.2.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||
|
||||
|
|
@ -893,6 +991,9 @@ packages:
|
|||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
deep-is@0.1.4:
|
||||
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==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.46.1:
|
||||
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
|
||||
|
||||
escalade@3.2.0:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -1086,6 +1190,9 @@ packages:
|
|||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
|
|
@ -1235,6 +1342,12 @@ packages:
|
|||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -1247,6 +1360,10 @@ packages:
|
|||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -1677,10 +1794,38 @@ packages:
|
|||
react-is@16.13.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -1689,6 +1834,9 @@ packages:
|
|||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -1842,6 +1990,9 @@ packages:
|
|||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
|
@ -1913,6 +2064,14 @@ packages:
|
|||
uri-js@4.4.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -2303,8 +2462,24 @@ snapshots:
|
|||
|
||||
'@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': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -2383,6 +2558,30 @@ snapshots:
|
|||
tslib: 2.8.1
|
||||
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/json-schema@7.0.15': {}
|
||||
|
|
@ -2401,6 +2600,8 @@ snapshots:
|
|||
dependencies:
|
||||
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)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
|
|
@ -2706,6 +2907,8 @@ snapshots:
|
|||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
|
@ -2724,6 +2927,44 @@ snapshots:
|
|||
|
||||
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: {}
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
|
|
@ -2752,6 +2993,8 @@ snapshots:
|
|||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
define-data-property@1.1.4:
|
||||
|
|
@ -2888,6 +3131,8 @@ snapshots:
|
|||
is-date-object: 1.1.0
|
||||
is-symbol: 1.1.1
|
||||
|
||||
es-toolkit@1.46.1: {}
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
|
@ -3097,6 +3342,8 @@ snapshots:
|
|||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-glob@3.3.1:
|
||||
|
|
@ -3241,6 +3488,10 @@ snapshots:
|
|||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.8: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
|
|
@ -3254,6 +3505,8 @@ snapshots:
|
|||
hasown: 2.0.3
|
||||
side-channel: 1.1.0
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
dependencies:
|
||||
call-bind: 1.0.9
|
||||
|
|
@ -3664,8 +3917,43 @@ snapshots:
|
|||
|
||||
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: {}
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
call-bind: 1.0.9
|
||||
|
|
@ -3686,6 +3974,8 @@ snapshots:
|
|||
gopd: 1.2.0
|
||||
set-function-name: 2.0.2
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
|
@ -3898,6 +4188,8 @@ snapshots:
|
|||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
|
|
@ -4013,6 +4305,27 @@ snapshots:
|
|||
dependencies:
|
||||
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:
|
||||
dependencies:
|
||||
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
|
||||
- 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