diff --git a/package.json b/package.json index 93a1010..4a427e0 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "pg": "^8.16.3", "react": "19.1.0", "react-dom": "19.1.0", + "recharts": "^3.8.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "textarea": "^0.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec7681c..17ad3af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + recharts: + specifier: ^3.8.0 + version: 3.8.1(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react-is@16.13.1)(react@19.1.0)(redux@5.0.1) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1838,6 +1841,17 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@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 + '@rollup/rollup-linux-x64-gnu@4.52.2': resolution: {integrity: sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==} cpu: [x64] @@ -1859,6 +1873,9 @@ packages: '@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==} @@ -1976,6 +1993,33 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@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/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2059,6 +2103,9 @@ packages: '@types/tedious@4.0.14': resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript-eslint/eslint-plugin@8.44.1': resolution: {integrity: sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2540,6 +2587,50 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + 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==} @@ -2580,6 +2671,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + 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==} @@ -2781,6 +2875,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==} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -3178,6 +3275,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'} @@ -3244,6 +3347,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'} @@ -3948,6 +4055,18 @@ 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-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3982,6 +4101,22 @@ packages: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} 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-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -4008,6 +4143,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + 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'} @@ -4274,6 +4412,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -4399,6 +4540,9 @@ packages: 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==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -6247,6 +6391,18 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.1.13)(react@19.1.0)(redux@5.0.1))(react@19.1.0)': + 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.1.0 + react-redux: 9.3.0(@types/react@19.1.13)(react@19.1.0)(redux@5.0.1) + '@rollup/rollup-linux-x64-gnu@4.52.2': optional: true @@ -6269,6 +6425,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -6384,6 +6542,30 @@ snapshots: dependencies: '@types/node': 20.19.17 + '@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/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -6484,6 +6666,8 @@ snapshots: dependencies: '@types/node': 20.19.17 + '@types/use-sync-external-store@0.0.6': {} + '@typescript-eslint/eslint-plugin@8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2))(eslint@9.36.0(jiti@2.6.0))(typescript@5.9.2)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -6987,6 +7171,44 @@ snapshots: csstype@3.1.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-uri-to-buffer@4.0.1: {} @@ -7019,6 +7241,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -7200,6 +7424,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.46.1: {} + esbuild-register@3.6.0(esbuild@0.25.10): dependencies: debug: 4.4.3 @@ -7748,6 +7974,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 @@ -7828,6 +8058,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -8470,6 +8702,15 @@ snapshots: react-is@16.13.1: {} + react-redux@9.3.0(@types/react@19.1.13)(react@19.1.0)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.1.0 + use-sync-external-store: 1.5.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.1.13)(react@19.1.0): dependencies: react: 19.1.0 @@ -8499,6 +8740,32 @@ snapshots: react@19.1.0: {} + recharts@3.8.1(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react-is@16.13.1)(react@19.1.0)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.1.13)(react@19.1.0)(redux@5.0.1))(react@19.1.0) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.46.1 + eventemitter3: 5.0.1 + immer: 10.2.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-is: 16.13.1 + react-redux: 9.3.0(@types/react@19.1.13)(react@19.1.0)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.5.0(react@19.1.0) + 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-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -8540,6 +8807,8 @@ snapshots: require-main-filename@2.0.0: {} + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -8853,6 +9122,8 @@ snapshots: through@2.3.8: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -9005,6 +9276,23 @@ snapshots: dependencies: react: 19.1.0 + 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 + web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {} diff --git a/src/app/dashboard/[rid]/page.tsx b/src/app/dashboard/[rid]/page.tsx index 24650f6..3f6f8a8 100644 --- a/src/app/dashboard/[rid]/page.tsx +++ b/src/app/dashboard/[rid]/page.tsx @@ -1,18 +1,51 @@ -import { findOne } from '@/lib/services/recruitmentSessions'; import { notFound } from 'next/navigation'; +import { getDashboardData } from '@/lib/services/dashboard'; +import { ActivityChart } from '@/components/dashboard/ActivityChart'; +import { StageSummary } from '@/components/dashboard/StageSummary'; +import { CourseSummary } from '@/components/dashboard/CourseSummary'; +import { AutomationsSection } from '@/components/dashboard/AutomationsSection'; +import { LatestApplicantsSection } from '@/components/dashboard/LatestApplicantsSection'; export default async function Dashboard({ params, }: PageProps<'/dashboard/[rid]'>) { const { rid } = await params; - const recruitmentSession = await findOne(rid); - if (!recruitmentSession) notFound(); + const data = await getDashboardData(rid); + if (!data.session) notFound(); + + const { + session, + activity, + stageCounts, + courseCounts, + latestApplicants, + automations, + } = data; return ( -
-

- Work in progress -

-
+
+
+

+ Recruitment {session.year} · Semester {session.semester} +

+

+ Overview of activity and pipeline for the current session. +

+
+ +
+

Applicant activity

+ +
+ +
+ + +
+ + + + +
); } diff --git a/src/components/dashboard/ActivityChart.tsx b/src/components/dashboard/ActivityChart.tsx new file mode 100644 index 0000000..ed8dcce --- /dev/null +++ b/src/components/dashboard/ActivityChart.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart'; +import type { ChartConfig } from '@/components/ui/chart'; +import type { ActivityPoint } from '@/lib/services/dashboard'; + +type ActivityChartProps = { + activity: ActivityPoint[]; +}; + +const chartConfig = { + count: { + label: 'Applicants', + color: 'var(--primary)', + }, +} satisfies ChartConfig; + +export function ActivityChart({ activity }: ActivityChartProps) { + if (activity.length === 0) { + return ( +
+

No activity data yet.

+
+ ); + } + + const formatted = activity.map((p) => ({ + date: p.date, + count: p.count, + label: new Intl.DateTimeFormat('it-IT', { + day: 'numeric', + month: 'short', + }).format(new Date(p.date)), + })); + + return ( + + + + + + } + /> + + + + ); +} diff --git a/src/components/dashboard/ApplicantCard.tsx b/src/components/dashboard/ApplicantCard.tsx new file mode 100644 index 0000000..5f745e5 --- /dev/null +++ b/src/components/dashboard/ApplicantCard.tsx @@ -0,0 +1,56 @@ +import { Avatar, AvatarFallback } from '@/components/ui'; +import { getStageColor, getStageLabel } from '@/lib/stages'; +import type { Applicant } from '@/db/types'; + +type ApplicantCardProps = { + applicant: Applicant; +}; + +function getInitials(name: string, surname: string): string { + return `${name[0] ?? ''}${surname[0] ?? ''}`.toUpperCase(); +} + +function formatDate(date: Date): string { + return new Intl.DateTimeFormat('it-IT', { + day: 'numeric', + month: 'short', + }).format(new Date(date)); +} + +export function ApplicantCard({ applicant }: ApplicantCardProps) { + return ( +
+
+ + + {getInitials(applicant.name, applicant.surname)} + + +
+

+ {applicant.name} {applicant.surname} +

+

+ {applicant.course} +

+
+
+
+
+ + + {getStageLabel(applicant.stage)} + +
+ {applicant.createdAt && ( + + {formatDate(applicant.createdAt)} + + )} +
+
+ ); +} diff --git a/src/components/dashboard/AutomationsSection.tsx b/src/components/dashboard/AutomationsSection.tsx new file mode 100644 index 0000000..bd2ae86 --- /dev/null +++ b/src/components/dashboard/AutomationsSection.tsx @@ -0,0 +1,57 @@ +import { CheckCircle2, Loader2, AlertCircle } from 'lucide-react'; +import type { AutomationRun } from '@/lib/services/dashboard'; + +type AutomationsSectionProps = { + automations: AutomationRun[]; +}; + +function formatRelativeTime(date: Date): string { + const diffMs = Date.now() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return 'just now'; + if (diffMin === 1) return '1 min ago'; + if (diffMin < 60) return `${diffMin} min ago`; + const diffH = Math.floor(diffMin / 60); + if (diffH === 1) return '1 hour ago'; + return `${diffH} hours ago`; +} + +function formatDuration(ms: number): string { + if (ms === 0) return '—'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +export function AutomationsSection({ automations }: AutomationsSectionProps) { + return ( +
+

Automations

+ {automations.length === 0 ? ( +

No automations found.

+ ) : ( + + )} +
+ ); +} diff --git a/src/components/dashboard/CourseSummary.tsx b/src/components/dashboard/CourseSummary.tsx new file mode 100644 index 0000000..638d698 --- /dev/null +++ b/src/components/dashboard/CourseSummary.tsx @@ -0,0 +1,50 @@ +import type { CourseCount } from '@/lib/services/dashboard'; + +type CourseSummaryProps = { + courseCounts: CourseCount[]; + limit?: number; +}; + +export function CourseSummary({ courseCounts, limit = 8 }: CourseSummaryProps) { + const total = courseCounts.reduce((sum, c) => sum + c.count, 0); + const visible = courseCounts.slice(0, limit); + const hidden = courseCounts.length - visible.length; + + return ( +
+

Applicants per course

+ {total === 0 ? ( +

No applicants yet.

+ ) : ( + <> + + {hidden > 0 && ( +

+ +{hidden} other{hidden !== 1 ? 's' : ''} +

+ )} + + )} +
+ ); +} diff --git a/src/components/dashboard/LatestApplicantsSection.tsx b/src/components/dashboard/LatestApplicantsSection.tsx new file mode 100644 index 0000000..f990919 --- /dev/null +++ b/src/components/dashboard/LatestApplicantsSection.tsx @@ -0,0 +1,27 @@ +import { ApplicantCard } from '@/components/dashboard/ApplicantCard'; +import type { Applicant } from '@/db/types'; + +type LatestApplicantsSectionProps = { + applicants: Applicant[]; +}; + +export function LatestApplicantsSection({ + applicants, +}: LatestApplicantsSectionProps) { + return ( +
+

Latest applicants

+ {applicants.length === 0 ? ( +

No applicants yet.

+ ) : ( +
+
+ {applicants.map((applicant) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/dashboard/StageSummary.tsx b/src/components/dashboard/StageSummary.tsx new file mode 100644 index 0000000..9c0ceea --- /dev/null +++ b/src/components/dashboard/StageSummary.tsx @@ -0,0 +1,48 @@ +import { getStageColor, getStageLabel } from '@/lib/stages'; +import type { StageCount } from '@/lib/services/dashboard'; + +type StageSummaryProps = { + stageCounts: StageCount[]; +}; + +export function StageSummary({ stageCounts }: StageSummaryProps) { + const total = stageCounts.reduce((sum, s) => sum + s.count, 0); + + return ( +
+

Candidates per stage

+ {total === 0 ? ( +

No candidates yet.

+ ) : ( + + )} +
+ ); +} diff --git a/src/components/dashboard/index.ts b/src/components/dashboard/index.ts index 0010826..c7007b8 100644 --- a/src/components/dashboard/index.ts +++ b/src/components/dashboard/index.ts @@ -2,3 +2,13 @@ export * from './DashboardLink'; export * from './InterviewCard'; export * from './candidate-actions'; + +export * from './ActivityChart'; + +export * from './StageSummary'; +export * from './CourseSummary'; + +export * from './AutomationsSection'; + +export * from './ApplicantCard'; +export * from './LatestApplicantsSection'; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..916769f --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 0000000..e85b303 --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,374 @@ +'use client'; + +import * as React from 'react'; +import * as RechartsPrimitive from 'recharts'; +import type { TooltipValueType } from 'recharts'; + +import { cn } from '@/lib/utils'; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: '', dark: '.dark' } as const; + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const; +type TooltipNameType = number | string; + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +>; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error('useChart must be used within a '); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >['children']; + initialDimension?: { + width: number; + height: number; + }; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`; + + return ( + +
+ + + {children} + +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ); + + if (!colorConfig.length) { + return null; + } + + return ( +