diff --git a/package-lock.json b/package-lock.json index 6ebfa41..db0f6ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "react-dom": "^19.2.0", "react-multi-date-picker": "^4.5.2", "react-router-dom": "^7.13.1", + "recharts": "^3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0" }, @@ -1196,6 +1197,42 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://package-mirror.liara.ir/repository/npm/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://package-mirror.liara.ir/repository/npm/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://package-mirror.liara.ir/repository/npm/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1528,6 +1565,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.19", "resolved": "https://package-mirror.liara.ir/repository/npm/@swc/helpers/-/helpers-0.5.19.tgz", @@ -1866,6 +1915,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://package-mirror.liara.ir/repository/npm/@types/estree/-/estree-1.0.8.tgz", @@ -1909,6 +2021,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://package-mirror.liara.ir/repository/npm/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.0", "resolved": "https://package-mirror.liara.ir/repository/npm/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", @@ -2570,6 +2688,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://package-mirror.liara.ir/repository/npm/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://package-mirror.liara.ir/repository/npm/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://package-mirror.liara.ir/repository/npm/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://package-mirror.liara.ir/repository/npm/debug/-/debug-4.4.3.tgz", @@ -2588,6 +2827,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://package-mirror.liara.ir/repository/npm/deep-is/-/deep-is-0.1.4.tgz", @@ -2692,6 +2937,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.46.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/es-toolkit/-/es-toolkit-1.46.0.tgz", + "integrity": "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://package-mirror.liara.ir/repository/npm/esbuild/-/esbuild-0.27.3.tgz", @@ -2940,6 +3195,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://package-mirror.liara.ir/repository/npm/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://package-mirror.liara.ir/repository/npm/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3269,6 +3530,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://package-mirror.liara.ir/repository/npm/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3296,6 +3567,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://package-mirror.liara.ir/repository/npm/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://package-mirror.liara.ir/repository/npm/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4007,6 +4287,13 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://package-mirror.liara.ir/repository/npm/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, "node_modules/react-multi-date-picker": { "version": "4.5.2", "resolved": "https://package-mirror.liara.ir/repository/npm/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz", @@ -4021,6 +4308,29 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://package-mirror.liara.ir/repository/npm/react-refresh/-/react-refresh-0.18.0.tgz", @@ -4069,6 +4379,57 @@ "react-dom": ">=18" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://package-mirror.liara.ir/repository/npm/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://package-mirror.liara.ir/repository/npm/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://package-mirror.liara.ir/repository/npm/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4248,6 +4609,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://package-mirror.liara.ir/repository/npm/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://package-mirror.liara.ir/repository/npm/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4391,6 +4758,28 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://package-mirror.liara.ir/repository/npm/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://package-mirror.liara.ir/repository/npm/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 6e239b1..0e031d4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react-dom": "^19.2.0", "react-multi-date-picker": "^4.5.2", "react-router-dom": "^7.13.1", + "recharts": "^3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0" }, diff --git a/src/App.tsx b/src/App.tsx index 8559fa8..0cba72b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { Projects } from "./pages/Projects" import ProjectCreate from "./pages/ProjectCreate" import ProjectEdit from "./pages/ProjectEdit" import Tags from "./pages/Tags" +import Reports from "./pages/Reports" import Timesheet from "./pages/Timesheet" const MainLayout = () => { @@ -63,6 +64,7 @@ const router = createBrowserRouter([ children: [ { path: "/profile", element: }, { path: "/timesheet", element: }, + { path: "/reports", element: }, { path: "/tags", element: }, { path: "/workspaces", element: }, { path: "/workspaces/create", element: }, diff --git a/src/api/notifications.ts b/src/api/notifications.ts index 2bb2868..6dc4705 100644 --- a/src/api/notifications.ts +++ b/src/api/notifications.ts @@ -106,3 +106,67 @@ export const buildNotificationStreamUrl = (token: string) => { const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "") return `${cleanBaseUrl}/api/notifications/stream/?token=${encodeURIComponent(token)}` } + +const REPORT_EXPORT_DOWNLOAD_PATTERN = /\/api\/reports\/exports\/[^/]+\/download\/?$/ + +const toApiEndpoint = (actionUrl: string) => { + const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "") + if (actionUrl.startsWith("http://") || actionUrl.startsWith("https://")) { + if (!actionUrl.startsWith(cleanBaseUrl)) { + return null + } + return actionUrl.slice(cleanBaseUrl.length) || "/" + } + return actionUrl.startsWith("/") ? actionUrl : `/${actionUrl}` +} + +const getFilenameFromDisposition = (contentDisposition: string | null) => { + if (!contentDisposition) return null + + const utfMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) + if (utfMatch?.[1]) { + return decodeURIComponent(utfMatch[1]) + } + + const plainMatch = contentDisposition.match(/filename="?([^"]+)"?/i) + return plainMatch?.[1] || null +} + +export const isReportExportDownloadUrl = (actionUrl?: string | null) => { + if (!actionUrl) return false + const endpoint = toApiEndpoint(actionUrl) + return !!endpoint && REPORT_EXPORT_DOWNLOAD_PATTERN.test(endpoint) +} + +export const downloadNotificationFile = async ( + actionUrl: string, + fallbackFilename?: string | null, +) => { + const endpoint = toApiEndpoint(actionUrl) + if (!endpoint) { + throw new Error("Unsupported download url") + } + + const response = await authFetch(endpoint, { + method: "GET", + }) + + if (!response.ok) { + throw new Error("Failed to download file") + } + + const blob = await response.blob() + const objectUrl = window.URL.createObjectURL(blob) + const filename = + getFilenameFromDisposition(response.headers.get("content-disposition")) || + fallbackFilename || + "download" + + const anchor = document.createElement("a") + anchor.href = objectUrl + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.URL.revokeObjectURL(objectUrl) +} diff --git a/src/api/reports.ts b/src/api/reports.ts new file mode 100644 index 0000000..a1c6309 --- /dev/null +++ b/src/api/reports.ts @@ -0,0 +1,184 @@ +import { authFetch } from "./client"; + +export type ReportPeriod = + | "this_week" + | "this_month" + | "this_year" + | "half_year_first" + | "half_year_second" + | "period"; + +export interface CurrencyTotal { + currency: string; + amount: string; +} + +export interface ReportSummary { + total_seconds: number; + billable_seconds: number; + non_billable_seconds: number; + total_duration: string; + billable_duration: string; + non_billable_duration: string; + income_totals: CurrencyTotal[]; +} + +export interface ReportScope { + workspace: { id: string; name: string }; + period: ReportPeriod; + from_date: string; + to_date: string; + user: { id: string; name: string; mobile: string } | null; + is_workspace_scope: boolean; + filters: { + client_id: string | null; + project_id: string | null; + tag_ids: string[]; + }; +} + +export interface ReportChartBucket { + bucket_key: string; + bucket_label: string; + total_seconds: number; + total_duration: string; +} + +export interface ChartReportResponse { + scope: ReportScope; + summary: ReportSummary; + buckets: ReportChartBucket[]; +} + +export interface DailyReportRow { + date: string; + billable_seconds: number; + non_billable_seconds: number; + total_seconds: number; + billable_duration: string; + non_billable_duration: string; + total_duration: string; + income_totals: CurrencyTotal[]; +} + +export interface BreakdownRow { + id: string; + name: string; + billable_seconds: number; + non_billable_seconds: number; + total_seconds: number; + billable_duration: string; + non_billable_duration: string; + total_duration: string; + income_totals: CurrencyTotal[]; +} + +export interface DayDetailEntry { + id: string; + description: string; + user: { id: string; name: string; mobile: string }; + project: { + id: string; + name: string; + client: { id: string; name: string } | null; + } | null; + tags: { id: string; name: string; color: string }[]; + start_time: string; + end_time: string | null; + duration_seconds: number; + duration: string; + is_billable: boolean; + hourly_rate: string | null; + currency: string; + income_totals: CurrencyTotal[]; +} + +export interface DayDetailsResponse { + scope: ReportScope; + day: string; + summary: ReportSummary; + entries: DayDetailEntry[]; +} + +export interface TableReportResponse { + scope: ReportScope; + summary: ReportSummary; + days: DailyReportRow[]; + clients: BreakdownRow[]; + projects: BreakdownRow[]; + tags: BreakdownRow[]; +} + +export interface ReportExportJob { + id: string; + workspace: string; + export_type: "excel" | "pdf"; + status: "pending" | "processing" | "completed" | "failed" | "expired"; + filters: Record; + file_name: string; + error_message: string; + expires_at: string | null; + completed_at: string | null; + created_at: string; +} + +export interface ReportFilters { + workspace: string; + period: ReportPeriod; + from_date?: string; + to_date?: string; + user?: string; + client?: string; + project?: string; + tags?: string[]; + language?: "en" | "fa"; +} + +const toQueryString = (filters: ReportFilters) => { + const query = new URLSearchParams(); + query.set("workspace", filters.workspace); + query.set("period", filters.period); + if (filters.from_date) query.set("from_date", filters.from_date); + if (filters.to_date) query.set("to_date", filters.to_date); + if (filters.user) query.set("user", filters.user); + if (filters.client) query.set("client", filters.client); + if (filters.project) query.set("project", filters.project); + if (filters.language) query.set("language", filters.language); + filters.tags?.forEach((tagId) => query.append("tags", tagId)); + return query.toString(); +}; + +export const getChartReport = async (filters: ReportFilters): Promise => { + const response = await authFetch(`/api/reports/chart/?${toQueryString(filters)}`); + if (!response.ok) throw new Error("Failed to load chart report"); + return response.json(); +}; + +export const getTableReport = async (filters: ReportFilters): Promise => { + const response = await authFetch(`/api/reports/table/?${toQueryString(filters)}`); + if (!response.ok) throw new Error("Failed to load table report"); + return response.json(); +}; + +export const getDayDetailsReport = async ( + filters: ReportFilters, + day: string, +): Promise => { + const query = `${toQueryString(filters)}&day=${encodeURIComponent(day)}`; + const response = await authFetch(`/api/reports/day-details/?${query}`); + if (!response.ok) throw new Error("Failed to load day details"); + return response.json(); +}; + +export const createReportExport = async ( + filters: ReportFilters, + exportType: "excel" | "pdf", + language: "en" | "fa", +): Promise => { + const response = await authFetch("/api/reports/exports/", { + method: "POST", + body: JSON.stringify({ ...filters, export_type: exportType, language }), + }); + if (!response.ok) throw new Error("Failed to queue report export"); + return response.json(); +}; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 88afb63..0e3e1b6 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { PanelRightClose, PanelRightOpen, Briefcase, + ChartColumn, Clock3, Tags, } from 'lucide-react'; @@ -35,6 +36,11 @@ export const Sidebar = ({ mobileOpen = false, onMobileClose }: SidebarProps) => icon: Clock3, label: t.sidebar?.timesheet || 'Timesheet' }, + { + path: '/reports', + icon: ChartColumn, + label: t.sidebar?.reports || 'Reports' + }, { path: '/tags', icon: Tags, diff --git a/src/components/reports/ReportsChartPanel.tsx b/src/components/reports/ReportsChartPanel.tsx new file mode 100644 index 0000000..ab4195a --- /dev/null +++ b/src/components/reports/ReportsChartPanel.tsx @@ -0,0 +1,271 @@ +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import type { ChartReportResponse, CurrencyTotal, ReportChartBucket } from "../../api/reports"; +import { useTranslation } from "../../hooks/useTranslation"; + +const toPersianDigits = (value: string) => + value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit); + +const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value); + +const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => { + if (!totals.length) return "-"; + return totals.map((item) => `${localizeDigits(item.amount, lang)} ${item.currency}`).join(" | "); +}; + +const formatSecondsTick = (value: number, lang: "en" | "fa") => { + const hours = value / 3600; + const rounded = hours >= 10 ? hours.toFixed(0) : hours.toFixed(1); + return localizeDigits(rounded, lang); +}; + +const parseIsoDate = (value: string) => { + const [year, month, day] = value.split("-").map(Number); + return new Date(year, (month || 1) - 1, day || 1); +}; + +const formatIsoDate = (value: Date) => { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +const monthKeyFromDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}`; + +const labelFormatters = { + dayShort: (value: Date, lang: "en" | "fa") => + new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { + weekday: "short", + }).format(value), + dayLong: (value: Date, lang: "en" | "fa") => + new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { + weekday: "long", + month: "short", + day: "numeric", + }).format(value), + monthShort: (value: Date, lang: "en" | "fa") => + new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { + month: "short", + }).format(value), + monthLong: (value: Date, lang: "en" | "fa") => + new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { + month: "long", + year: "numeric", + }).format(value), +}; + +const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => { + const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])); + const result: ReportChartBucket[] = []; + const cursor = parseIsoDate(fromDate); + const limit = parseIsoDate(toDate); + + while (cursor.getTime() <= limit.getTime()) { + const key = formatIsoDate(cursor); + const existingBucket = map.get(key); + result.push( + existingBucket ?? { + bucket_key: key, + bucket_label: labelFormatters.dayShort(cursor, lang), + total_seconds: 0, + total_duration: "00:00:00", + }, + ); + cursor.setDate(cursor.getDate() + 1); + } + + return result.map((bucket) => ({ + ...bucket, + bucket_label: labelFormatters.dayShort(parseIsoDate(bucket.bucket_key), lang), + })); +}; + +const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => { + const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])); + const result: ReportChartBucket[] = []; + const start = parseIsoDate(fromDate); + const end = parseIsoDate(toDate); + const cursor = new Date(start.getFullYear(), start.getMonth(), 1); + const limit = new Date(end.getFullYear(), end.getMonth(), 1); + + while (cursor.getTime() <= limit.getTime()) { + const key = monthKeyFromDate(cursor); + const existingBucket = map.get(key); + result.push( + existingBucket ?? { + bucket_key: key, + bucket_label: labelFormatters.monthShort(cursor, lang), + total_seconds: 0, + total_duration: "00:00:00", + }, + ); + cursor.setMonth(cursor.getMonth() + 1); + } + + return result.map((bucket) => { + const [year, month] = bucket.bucket_key.split("-").map(Number); + const date = new Date(year, (month || 1) - 1, 1); + return { + ...bucket, + bucket_label: labelFormatters.monthShort(date, lang), + }; + }); +}; + +const formatTooltipLabel = (payload: ReportChartBucket | undefined, lang: "en" | "fa", period: string) => { + if (!payload) return ""; + const useMonth = period === "this_year" || period === "half_year_first" || period === "half_year_second"; + if (useMonth) { + const [year, month] = payload.bucket_key.split("-").map(Number); + return labelFormatters.monthLong(new Date(year, (month || 1) - 1, 1), lang); + } + return labelFormatters.dayLong(parseIsoDate(payload.bucket_key), lang); +}; + +function ChartTooltip({ + active, + payload, + label, + lang, + totalSecondsLabel, +}: { + active?: boolean; + payload?: ReadonlyArray<{ value?: unknown; payload?: ReportChartBucket }>; + label: string; + lang: "en" | "fa"; + totalSecondsLabel: string; +}) { + if (!active || !payload?.length) return null; + + const point = payload[0]; + const seconds = Number(point.value || 0); + const hours = seconds / 3600; + + return ( +
+
{label}
+
+ {totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)} +
+
+ ); +} + +export function ReportsChartPanel({ + data, + labels, +}: { + data: ChartReportResponse | null; + labels: Record; +}) { + const { lang } = useTranslation(); + + if (!data) return null; + + const useMonthlyBuckets = + data.scope.period === "this_year" || + data.scope.period === "half_year_first" || + data.scope.period === "half_year_second"; + + const buckets = useMonthlyBuckets + ? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang) + : buildDailyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang); + + const chartMinWidth = Math.max(640, buckets.length * (useMonthlyBuckets ? 88 : 42)); + const interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0; + + return ( +
+
+
+
{labels.totalHours}
+
+ {localizeDigits(data.summary.total_duration, lang)} +
+
+
+
{labels.billableHours}
+
+ {localizeDigits(data.summary.billable_duration, lang)} +
+
+
+
{labels.nonBillableHours}
+
+ {localizeDigits(data.summary.non_billable_duration, lang)} +
+
+
+
{labels.totalIncome}
+
+ {formatMoneyTotals(data.summary.income_totals, lang)} +
+
+
+ +
+
+
{labels.chart}
+
+ {localizeDigits(`${buckets.length}`, lang)} +
+
+ +
+
+ + + + + formatSecondsTick(value, lang)} + tickLine={false} + axisLine={false} + width={44} + tick={{ fontSize: 11, fill: "#64748b" }} + /> + ( + + )} + /> + + {buckets.map((bucket) => ( + 0 ? "#0ea5e9" : "#cbd5e1"} + /> + ))} + + + +
+
+
+
+ ); +} diff --git a/src/components/reports/ReportsFilterBar.tsx b/src/components/reports/ReportsFilterBar.tsx new file mode 100644 index 0000000..2013072 --- /dev/null +++ b/src/components/reports/ReportsFilterBar.tsx @@ -0,0 +1,323 @@ +import { useEffect, useMemo, useState } from "react"; +import { Filter, Search, X } from "lucide-react"; + +import type { ReportPeriod } from "../../api/reports"; +import type { Project } from "../../api/projects"; +import type { Tag } from "../../api/tags"; +import type { WorkspaceMembership } from "../../api/workspaces"; +import JalaliDatePicker from "../ui/JalaliDatePicker"; +import { SearchableSelect } from "../ui/SearchableSelect"; +import { Select } from "../ui/Select"; + +export interface ReportsFilterDraft { + period: ReportPeriod; + from_date: string; + to_date: string; + user: string; + client: string; + project: string; + tags: string[]; +} + +function ReportTagMultiSelect({ + tags, + value, + onChange, + placeholder, + searchPlaceholder, +}: { + tags: Tag[]; + value: string[]; + onChange: (value: string[]) => void; + placeholder: string; + searchPlaceholder: string; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const filteredTags = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return tags; + return tags.filter((tag) => tag.name.toLowerCase().includes(needle)); + }, [query, tags]); + + const label = value.length + ? tags + .filter((tag) => value.includes(tag.id)) + .map((tag) => tag.name) + .join(" | ") + : placeholder; + + useEffect(() => { + if (!open) return; + const handleOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (!target.closest("[data-reports-tag-select='true']")) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleOutside); + return () => document.removeEventListener("mousedown", handleOutside); + }, [open]); + + return ( +
+ + {open ? ( +
+
+
+ + setQuery(event.target.value)} + placeholder={searchPlaceholder} + className="h-8 w-full rounded-xl border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-white" + /> +
+
+
+ {filteredTags.map((tag) => { + const selected = value.includes(tag.id); + return ( + + ); + })} +
+
+ ) : null} +
+ ); +} + +export function ReportsFilterBar({ + value, + onApply, + projects, + clients, + tags, + users, + canSelectUsers, + labels, +}: { + value: ReportsFilterDraft; + onApply: (draft: ReportsFilterDraft) => void; + projects: Project[]; + clients: { id: string; name: string }[]; + tags: Tag[]; + users: WorkspaceMembership[]; + canSelectUsers: boolean; + labels: Record; +}) { + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + useEffect(() => { + if (!canSelectUsers && draft.user) { + setDraft((current) => ({ ...current, user: "" })); + } + }, [canSelectUsers, draft.user]); + + const filteredProjects = draft.client + ? projects.filter((project) => project.client?.id === draft.client) + : projects; + + return ( +
+
+
+ +