feat(reports): add reports page and export notification downloads
This commit is contained in:
389
package-lock.json
generated
389
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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: <Profile /> },
|
||||
{ path: "/timesheet", element: <Timesheet /> },
|
||||
{ path: "/reports", element: <Reports /> },
|
||||
{ path: "/tags", element: <Tags /> },
|
||||
{ path: "/workspaces", element: <Workspaces /> },
|
||||
{ path: "/workspaces/create", element: <CreateWorkspace /> },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
184
src/api/reports.ts
Normal file
184
src/api/reports.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<ChartReportResponse> => {
|
||||
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<TableReportResponse> => {
|
||||
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<DayDetailsResponse> => {
|
||||
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<ReportExportJob> => {
|
||||
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();
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
271
src/components/reports/ReportsChartPanel.tsx
Normal file
271
src/components/reports/ReportsChartPanel.tsx
Normal file
@@ -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 (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/95 px-3 py-2 shadow-xl shadow-slate-200/60 backdrop-blur dark:border-slate-700 dark:bg-slate-900/95 dark:shadow-black/30">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{label}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsChartPanel({
|
||||
data,
|
||||
labels,
|
||||
}: {
|
||||
data: ChartReportResponse | null;
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.totalHours}</div>
|
||||
<div className="mt-2 text-xl font-bold text-slate-900 dark:text-white sm:text-2xl">
|
||||
{localizeDigits(data.summary.total_duration, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.billableHours}</div>
|
||||
<div className="mt-2 text-xl font-bold text-slate-900 dark:text-white sm:text-2xl">
|
||||
{localizeDigits(data.summary.billable_duration, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.nonBillableHours}</div>
|
||||
<div className="mt-2 text-xl font-bold text-slate-900 dark:text-white sm:text-2xl">
|
||||
{localizeDigits(data.summary.non_billable_duration, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.totalIncome}</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-slate-900 dark:text-white sm:text-base">
|
||||
{formatMoneyTotals(data.summary.income_totals, lang)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{localizeDigits(`${buckets.length}`, lang)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="h-[300px] min-w-full sm:h-[360px]" style={{ minWidth: `${chartMinWidth}px` }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={buckets} barCategoryGap={useMonthlyBuckets ? "28%" : "18%"} margin={{ top: 8, right: 12, bottom: 8, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="4 6" stroke="currentColor" className="text-slate-200 dark:text-slate-800" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="bucket_label"
|
||||
interval={interval}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
height={40}
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value) => formatSecondsTick(value, lang)}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={44}
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(14,165,233,0.08)" }}
|
||||
content={({ active, payload }) => (
|
||||
<ChartTooltip
|
||||
active={active}
|
||||
payload={payload}
|
||||
label={formatTooltipLabel(payload?.[0]?.payload as ReportChartBucket | undefined, lang, data.scope.period)}
|
||||
lang={lang}
|
||||
totalSecondsLabel={labels.totalHours}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Bar dataKey="total_seconds" radius={[12, 12, 4, 4]} maxBarSize={useMonthlyBuckets ? 40 : 22}>
|
||||
{buckets.map((bucket) => (
|
||||
<Cell
|
||||
key={bucket.bucket_key}
|
||||
fill={bucket.total_seconds > 0 ? "#0ea5e9" : "#cbd5e1"}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
323
src/components/reports/ReportsFilterBar.tsx
Normal file
323
src/components/reports/ReportsFilterBar.tsx
Normal file
@@ -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 (
|
||||
<div className="relative" data-reports-tag-select="true">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="flex h-10 w-full items-center rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="absolute inset-x-0 top-full z-40 mt-2 rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="border-b border-slate-200 p-2 dark:border-slate-700">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto p-2">
|
||||
{filteredTags.map((tag) => {
|
||||
const selected = value.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => onChange(selected ? value.filter((id) => id !== tag.id) : [...value, tag.id])}
|
||||
className={`flex w-full items-center gap-2 rounded-xl px-2 py-2 text-sm ${
|
||||
selected
|
||||
? "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
|
||||
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color || "#94A3B8" }} />
|
||||
<span className="truncate">{tag.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="xl:col-span-1">
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.period}
|
||||
</label>
|
||||
<Select
|
||||
value={draft.period}
|
||||
onChange={(period) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
period: period as ReportPeriod,
|
||||
from_date: period === "period" ? current.from_date : "",
|
||||
to_date: period === "period" ? current.to_date : "",
|
||||
}))
|
||||
}
|
||||
options={[
|
||||
{ value: "this_week", label: labels.thisWeek },
|
||||
{ value: "this_month", label: labels.thisMonth },
|
||||
{ value: "this_year", label: labels.thisYear },
|
||||
{ value: "half_year_first", label: labels.firstHalf },
|
||||
{ value: "half_year_second", label: labels.secondHalf },
|
||||
{ value: "period", label: labels.customPeriod },
|
||||
]}
|
||||
className="w-full"
|
||||
buttonClassName="h-10 w-full rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{draft.period === "period" ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.fromDate}
|
||||
</label>
|
||||
<JalaliDatePicker
|
||||
value={draft.from_date}
|
||||
onChange={(nextValue) => setDraft((current) => ({ ...current, from_date: nextValue }))}
|
||||
placeholder="YYYY/MM/DD"
|
||||
inputClassName="h-10 rounded-2xl border border-slate-200 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.toDate}
|
||||
</label>
|
||||
<JalaliDatePicker
|
||||
value={draft.to_date}
|
||||
onChange={(nextValue) => setDraft((current) => ({ ...current, to_date: nextValue }))}
|
||||
placeholder="YYYY/MM/DD"
|
||||
inputClassName="h-10 rounded-2xl border border-slate-200 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{canSelectUsers ? (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.user}
|
||||
</label>
|
||||
<SearchableSelect
|
||||
value={draft.user}
|
||||
onChange={(user) => setDraft((current) => ({ ...current, user }))}
|
||||
options={[
|
||||
{ value: "", label: labels.allUsers },
|
||||
...users.map((membership) => ({
|
||||
value: membership.user.id,
|
||||
label:
|
||||
`${membership.user.first_name || ""} ${membership.user.last_name || ""}`.trim() ||
|
||||
membership.user.email ||
|
||||
membership.user.id,
|
||||
searchText: membership.user.mobile || "",
|
||||
})),
|
||||
]}
|
||||
placeholder={labels.allUsers}
|
||||
searchPlaceholder={labels.searchUsers}
|
||||
className="w-full"
|
||||
buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.client}
|
||||
</label>
|
||||
<SearchableSelect
|
||||
value={draft.client}
|
||||
onChange={(client) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
client,
|
||||
project:
|
||||
current.project && !projects.some((project) => project.id === current.project && project.client?.id === client)
|
||||
? ""
|
||||
: current.project,
|
||||
}))
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: labels.allClients },
|
||||
...clients.map((client) => ({ value: client.id, label: client.name })),
|
||||
]}
|
||||
placeholder={labels.allClients}
|
||||
searchPlaceholder={labels.searchClients}
|
||||
className="w-full"
|
||||
buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.project}
|
||||
</label>
|
||||
<SearchableSelect
|
||||
value={draft.project}
|
||||
onChange={(project) => setDraft((current) => ({ ...current, project }))}
|
||||
options={[
|
||||
{ value: "", label: labels.allProjects },
|
||||
...filteredProjects.map((project) => ({
|
||||
value: project.id,
|
||||
label: project.name,
|
||||
searchText: project.client?.name || "",
|
||||
})),
|
||||
]}
|
||||
placeholder={labels.allProjects}
|
||||
searchPlaceholder={labels.searchProjects}
|
||||
className="w-full"
|
||||
buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
|
||||
{labels.tags}
|
||||
</label>
|
||||
<ReportTagMultiSelect
|
||||
tags={tags}
|
||||
value={draft.tags}
|
||||
onChange={(nextTags) => setDraft((current) => ({ ...current, tags: nextTags }))}
|
||||
placeholder={labels.allTags}
|
||||
searchPlaceholder={labels.searchTags || "Search tags..."}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
period: "this_month",
|
||||
from_date: "",
|
||||
to_date: "",
|
||||
user: "",
|
||||
client: "",
|
||||
project: "",
|
||||
tags: [],
|
||||
})
|
||||
}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-slate-200 px-4 text-sm text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
{labels.clear}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply(draft)}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl bg-sky-600 px-4 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{labels.apply}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
src/components/reports/ReportsTablePanel.tsx
Normal file
272
src/components/reports/ReportsTablePanel.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Fragment } from "react";
|
||||
import { ChevronDown, ChevronUp, FileSpreadsheet, FileText } from "lucide-react";
|
||||
|
||||
import type { BreakdownRow, DayDetailsResponse, TableReportResponse } 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: { currency: string; amount: string }[], lang: "en" | "fa") => {
|
||||
if (!totals.length) return "-";
|
||||
return totals.map((item) => `${localizeDigits(item.amount, lang)} ${item.currency}`).join(" | ");
|
||||
};
|
||||
|
||||
const formatDisplayDate = (value: string, lang: "en" | "fa") => {
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
||||
dateStyle: "medium",
|
||||
}).format(parsed);
|
||||
};
|
||||
|
||||
const formatDisplayDateTime = (value: string, lang: "en" | "fa") => {
|
||||
const parsed = new Date(value);
|
||||
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(parsed);
|
||||
};
|
||||
|
||||
function BreakdownCards({
|
||||
title,
|
||||
rows,
|
||||
labels,
|
||||
lang,
|
||||
}: {
|
||||
title: string;
|
||||
rows: BreakdownRow[];
|
||||
labels: Record<string, string>;
|
||||
lang: "en" | "fa";
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<div className="mb-4 text-sm font-semibold text-slate-900 dark:text-white">{title}</div>
|
||||
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-800 dark:bg-slate-950/70">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{row.name}</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-600 dark:text-slate-300">
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.billableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(row.billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.nonBillableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(row.non_billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalIncome}</div>
|
||||
<div className="font-medium">{formatMoneyTotals(row.income_totals, lang)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto sm:block">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.name}</th>
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.billableHours}</th>
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.nonBillableHours}</th>
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80">
|
||||
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{row.name}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.non_billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(row.income_totals, lang)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsTablePanel({
|
||||
data,
|
||||
dayDetails,
|
||||
openDay,
|
||||
onToggleDay,
|
||||
onExport,
|
||||
labels,
|
||||
}: {
|
||||
data: TableReportResponse | null;
|
||||
dayDetails: DayDetailsResponse | null;
|
||||
openDay: string | null;
|
||||
onToggleDay: (day: string) => void;
|
||||
onExport: (type: "excel" | "pdf") => void;
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
const { lang } = useTranslation();
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExport("excel")}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 text-sm font-medium text-emerald-700 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
{labels.exportExcel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExport("pdf")}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-rose-200 bg-rose-50 px-4 text-sm font-medium text-rose-700 transition hover:bg-rose-100 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{labels.exportPdf}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<div className="mb-4 text-sm font-semibold text-slate-900 dark:text-white">{labels.details}</div>
|
||||
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{data.days.map((day) => {
|
||||
const isOpen = openDay === day.date;
|
||||
return (
|
||||
<div key={day.date} className="rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-800 dark:bg-slate-950/70">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{formatDisplayDate(day.date, lang)}</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{labels.totalHours}: {localizeDigits(day.total_duration, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleDay(day.date)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-600 dark:text-slate-300">
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.billableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(day.billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.nonBillableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(day.non_billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalIncome}</div>
|
||||
<div className="font-medium">{formatMoneyTotals(day.income_totals, lang)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && dayDetails?.day === day.date ? (
|
||||
<div className="mt-3 space-y-2 border-t border-slate-200 pt-3 dark:border-slate-800">
|
||||
{dayDetails.entries.map((entry) => (
|
||||
<div key={entry.id} className="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{entry.description || labels.noDescription}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{entry.project?.name || "-"} • {localizeDigits(entry.duration, lang)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">{formatDisplayDateTime(entry.start_time, lang)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="rounded-2xl border border-sky-200 bg-sky-50 p-3 dark:border-sky-500/20 dark:bg-sky-500/10">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.total}</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-700 dark:text-slate-300">
|
||||
<div>{labels.billableHours}: {localizeDigits(data.summary.billable_duration, lang)}</div>
|
||||
<div>{labels.nonBillableHours}: {localizeDigits(data.summary.non_billable_duration, lang)}</div>
|
||||
<div>{labels.totalIncome}: {formatMoneyTotals(data.summary.income_totals, lang)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto sm:block">
|
||||
<table className="min-w-[860px] w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="w-[18%] px-3 py-3 text-start font-medium">{labels.date}</th>
|
||||
<th className="w-[16%] px-3 py-3 text-start font-medium">{labels.billableHours}</th>
|
||||
<th className="w-[20%] px-3 py-3 text-start font-medium">{labels.nonBillableHours}</th>
|
||||
<th className="w-[36%] px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
|
||||
<th className="w-[10%] px-3 py-3 text-start font-medium">{labels.details}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.days.map((day) => {
|
||||
const isOpen = openDay === day.date;
|
||||
return (
|
||||
<Fragment key={day.date}>
|
||||
<tr className="border-b border-slate-100 dark:border-slate-800/80">
|
||||
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{formatDisplayDate(day.date, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(day.billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(day.non_billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(day.income_totals, lang)}</td>
|
||||
<td className="px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleDay(day.date)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && dayDetails?.day === day.date ? (
|
||||
<tr className="border-b border-slate-100 bg-slate-50/70 dark:border-slate-800/80 dark:bg-slate-950/70">
|
||||
<td colSpan={6} className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
{dayDetails.entries.map((entry) => (
|
||||
<div key={entry.id} className="rounded-2xl border border-slate-200 bg-white px-4 py-3 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">{entry.description || labels.noDescription}</span>
|
||||
{entry.project ? <span className="text-sky-600 dark:text-sky-300">{entry.project.name}</span> : null}
|
||||
<span className="text-slate-500 dark:text-slate-400">{localizeDigits(entry.duration, lang)}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{formatDisplayDateTime(entry.start_time, lang)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<tr className="bg-sky-50/80 font-semibold dark:bg-sky-500/10">
|
||||
<td className="px-3 py-3">{labels.total}</td>
|
||||
<td className="px-3 py-3">{localizeDigits(data.summary.billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3">{localizeDigits(data.summary.non_billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3">{formatMoneyTotals(data.summary.income_totals, lang)}</td>
|
||||
<td className="px-3 py-3" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BreakdownCards title={labels.clientsTable} rows={data.clients} labels={labels} lang={lang} />
|
||||
<BreakdownCards title={labels.projectsTable} rows={data.projects} labels={labels} lang={lang} />
|
||||
<BreakdownCards title={labels.tagsTable} rows={data.tags} labels={labels} lang={lang} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
buildNotificationStreamUrl,
|
||||
downloadNotificationFile,
|
||||
deleteNotification,
|
||||
getNotifications,
|
||||
isReportExportDownloadUrl,
|
||||
issueNotificationStreamToken,
|
||||
markAllNotificationsRead,
|
||||
markNotificationSeen,
|
||||
@@ -120,11 +122,22 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
|
||||
setNotifications((current) => current.filter((notification) => notification.id !== notificationId))
|
||||
}, [])
|
||||
|
||||
const openNotificationTarget = useCallback((notification: NotificationItem) => {
|
||||
const openNotificationTarget = useCallback(async (notification: NotificationItem) => {
|
||||
const downloadUrl =
|
||||
typeof notification.meta?.download_url === "string"
|
||||
? notification.meta.download_url
|
||||
: notification.action_url
|
||||
|
||||
if (downloadUrl && isReportExportDownloadUrl(downloadUrl)) {
|
||||
await downloadNotificationFile(downloadUrl, typeof notification.meta?.file_name === "string" ? notification.meta.file_name : null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!notification.action_url) {
|
||||
return
|
||||
}
|
||||
window.location.assign(notification.action_url)
|
||||
|
||||
window.open(notification.action_url, "_blank", "noopener,noreferrer")
|
||||
}, [])
|
||||
|
||||
const showIncomingToast = useCallback(
|
||||
@@ -145,7 +158,9 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
|
||||
action: notification.action_url
|
||||
? {
|
||||
label: t.notifications?.openAction || "Open",
|
||||
onClick: () => openNotificationTarget(notification),
|
||||
onClick: () => {
|
||||
void openNotificationTarget(notification)
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
@@ -254,8 +269,12 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const handleNotificationClick = useCallback(async (notification: NotificationItem) => {
|
||||
await markAsSeen(notification)
|
||||
openNotificationTarget(notification)
|
||||
}, [markAsSeen, openNotificationTarget])
|
||||
try {
|
||||
await openNotificationTarget(notification)
|
||||
} catch {
|
||||
toast.error(t.notifications?.openError || "Failed to open notification")
|
||||
}
|
||||
}, [markAsSeen, openNotificationTarget, t.notifications])
|
||||
|
||||
const connectToStream = useCallback(async () => {
|
||||
if (!getAccessToken()) {
|
||||
|
||||
@@ -246,6 +246,7 @@ export const en = {
|
||||
|
||||
sidebar: {
|
||||
timesheet: "Timesheet",
|
||||
reports: "Reports",
|
||||
workspaces: 'Workspaces',
|
||||
clients: 'Clients',
|
||||
projects: "Projects",
|
||||
@@ -414,6 +415,57 @@ export const en = {
|
||||
toFilterPrefix: "To",
|
||||
},
|
||||
|
||||
reports: {
|
||||
title: "Reports",
|
||||
description: (workspaceName: string) => `Review activity reports for ${workspaceName}`,
|
||||
selectWorkspace: "Please select a workspace first.",
|
||||
chartTab: "Chart",
|
||||
tableTab: "Table",
|
||||
period: "Period",
|
||||
periodThisWeek: "This week",
|
||||
periodThisMonth: "This month",
|
||||
periodThisYear: "This year",
|
||||
periodFirstHalf: "First half of year",
|
||||
periodSecondHalf: "Second half of year",
|
||||
periodCustom: "Custom period",
|
||||
fromDate: "From date",
|
||||
toDate: "To date",
|
||||
user: "User",
|
||||
allUsers: "All users",
|
||||
searchUsers: "Search users...",
|
||||
client: "Client",
|
||||
allClients: "All clients",
|
||||
searchClients: "Search clients...",
|
||||
project: "Project",
|
||||
allProjects: "All projects",
|
||||
searchProjects: "Search projects...",
|
||||
tags: "Tags",
|
||||
allTags: "All tags",
|
||||
searchTags: "Search tags...",
|
||||
name: "Name",
|
||||
clear: "Clear",
|
||||
apply: "Apply",
|
||||
totalHours: "Total hours",
|
||||
billableHours: "Billable hours",
|
||||
nonBillableHours: "Non-billable hours",
|
||||
totalIncome: "Total income",
|
||||
chartTitle: "Activity chart",
|
||||
totalSeconds: "Total seconds",
|
||||
exportExcel: "Export Excel",
|
||||
exportPdf: "Export PDF",
|
||||
date: "Date",
|
||||
details: "Details",
|
||||
total: "Total",
|
||||
clientsTable: "Clients",
|
||||
projectsTable: "Projects",
|
||||
tagsTable: "Tags",
|
||||
loadError: "Failed to load reports.",
|
||||
loadDayDetailsError: "Failed to load day details.",
|
||||
loadFiltersError: "Failed to load report filters.",
|
||||
exportQueued: "Export queued. You will receive a notification with the download link.",
|
||||
exportError: "Failed to queue report export.",
|
||||
},
|
||||
|
||||
notifications: {
|
||||
title: "Notifications",
|
||||
open: "Open notifications",
|
||||
@@ -426,6 +478,7 @@ export const en = {
|
||||
markAllError: "Failed to update notifications",
|
||||
deleteError: "Failed to delete notification",
|
||||
loadError: "Failed to load notifications",
|
||||
openError: "Failed to open notification",
|
||||
newTitle: "New notification",
|
||||
openAction: "Open",
|
||||
summary: (total: number, unread: number) => `${total} total, ${unread} unread`,
|
||||
|
||||
@@ -243,6 +243,7 @@ export const fa = {
|
||||
|
||||
sidebar: {
|
||||
timesheet: 'تایمشیت',
|
||||
reports: 'گزارشها',
|
||||
workspaces: 'ورکاسپیسها',
|
||||
clients: 'مشتریان',
|
||||
projects: "پروژهها",
|
||||
@@ -410,6 +411,56 @@ export const fa = {
|
||||
fromFilterPrefix: "از",
|
||||
toFilterPrefix: "تا",
|
||||
},
|
||||
reports: {
|
||||
title: "گزارشها",
|
||||
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
|
||||
selectWorkspace: "لطفاً ابتدا یک ورکاسپیس انتخاب کنید.",
|
||||
chartTab: "نمودار",
|
||||
tableTab: "جدول",
|
||||
period: "بازه",
|
||||
periodThisWeek: "این هفته",
|
||||
periodThisMonth: "این ماه",
|
||||
periodThisYear: "این سال",
|
||||
periodFirstHalf: "نیمه اول سال",
|
||||
periodSecondHalf: "نیمه دوم سال",
|
||||
periodCustom: "بازه دلخواه",
|
||||
fromDate: "از تاریخ",
|
||||
toDate: "تا تاریخ",
|
||||
user: "کاربر",
|
||||
allUsers: "همه کاربران",
|
||||
searchUsers: "جستوجوی کاربران...",
|
||||
client: "مشتری",
|
||||
allClients: "همه مشتریها",
|
||||
searchClients: "جستوجوی مشتریها...",
|
||||
project: "پروژه",
|
||||
allProjects: "همه پروژهها",
|
||||
searchProjects: "جستوجوی پروژهها...",
|
||||
tags: "تگها",
|
||||
allTags: "همه تگها",
|
||||
searchTags: "جستوجوی تگها...",
|
||||
name: "نام",
|
||||
clear: "پاک کردن",
|
||||
apply: "اعمال",
|
||||
totalHours: "مجموع ساعت",
|
||||
billableHours: "ساعات کاری",
|
||||
nonBillableHours: "ساعات غیر کاری",
|
||||
totalIncome: "مجموع درآمد",
|
||||
chartTitle: "نمودار فعالیت",
|
||||
totalSeconds: "مجموع ثانیه",
|
||||
exportExcel: "خروجی Excel",
|
||||
exportPdf: "خروجی PDF",
|
||||
date: "تاریخ",
|
||||
details: "جزئیات",
|
||||
total: "مجموع",
|
||||
clientsTable: "مشتریها",
|
||||
projectsTable: "پروژهها",
|
||||
tagsTable: "تگها",
|
||||
loadError: "دریافت گزارشها با خطا مواجه شد.",
|
||||
loadDayDetailsError: "دریافت جزئیات روز با خطا مواجه شد.",
|
||||
loadFiltersError: "دریافت فیلترهای گزارش با خطا مواجه شد.",
|
||||
exportQueued: "درخواست خروجی ثبت شد. پیوند دانلود از طریق اعلان ارسال میشود.",
|
||||
exportError: "ثبت درخواست خروجی با خطا مواجه شد.",
|
||||
},
|
||||
notifications: {
|
||||
title: "اعلانها",
|
||||
open: "باز کردن اعلانها",
|
||||
@@ -422,6 +473,7 @@ export const fa = {
|
||||
markAllError: "بهروزرسانی اعلانها با خطا مواجه شد.",
|
||||
deleteError: "حذف اعلان با خطا مواجه شد.",
|
||||
loadError: "دریافت اعلانها با خطا مواجه شد.",
|
||||
openError: "باز کردن اعلان با خطا مواجه شد.",
|
||||
newTitle: "اعلان جدید",
|
||||
openAction: "باز کردن",
|
||||
summary: (total: number, unread: number) => `${total} کل، ${unread} خواندهنشده`,
|
||||
|
||||
333
src/pages/Reports.tsx
Normal file
333
src/pages/Reports.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { BarChart3, Table2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getClients } from "../api/clients";
|
||||
import { getProjects, type Project } from "../api/projects";
|
||||
import {
|
||||
createReportExport,
|
||||
getChartReport,
|
||||
getDayDetailsReport,
|
||||
getTableReport,
|
||||
type ChartReportResponse,
|
||||
type DayDetailsResponse,
|
||||
type ReportFilters,
|
||||
type TableReportResponse,
|
||||
} from "../api/reports";
|
||||
import { getTags, type Tag } from "../api/tags";
|
||||
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../api/workspaces";
|
||||
import { ReportsChartPanel } from "../components/reports/ReportsChartPanel";
|
||||
import { ReportsFilterBar, type ReportsFilterDraft } from "../components/reports/ReportsFilterBar";
|
||||
import { ReportsTablePanel } from "../components/reports/ReportsTablePanel";
|
||||
import { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { canWorkspace, WORKSPACE_MEMBERS_VIEW } from "../lib/permissions";
|
||||
|
||||
type Tab = "chart" | "table";
|
||||
|
||||
const normalizeDigits = (value: string) =>
|
||||
value
|
||||
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
|
||||
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)));
|
||||
|
||||
const getPersianDateParts = (value: Date) => {
|
||||
const parts = new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
}).formatToParts(value);
|
||||
|
||||
const year = Number(normalizeDigits(parts.find((part) => part.type === "year")?.value || ""));
|
||||
const month = Number(normalizeDigits(parts.find((part) => part.type === "month")?.value || ""));
|
||||
const day = Number(normalizeDigits(parts.find((part) => part.type === "day")?.value || ""));
|
||||
|
||||
return { year, month, day };
|
||||
};
|
||||
|
||||
const getCurrentLanguageAwareMonthRange = (lang: "en" | "fa") => {
|
||||
const today = new Date();
|
||||
|
||||
if (lang !== "fa") {
|
||||
const start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
return {
|
||||
from_date: start.toISOString().slice(0, 10),
|
||||
to_date: end.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
const todayParts = getPersianDateParts(today);
|
||||
const start = new Date(today);
|
||||
while (true) {
|
||||
const parts = getPersianDateParts(start);
|
||||
if (parts.year === todayParts.year && parts.month === todayParts.month && parts.day === 1) {
|
||||
break;
|
||||
}
|
||||
start.setDate(start.getDate() - 1);
|
||||
}
|
||||
|
||||
const end = new Date(today);
|
||||
while (true) {
|
||||
const next = new Date(end);
|
||||
next.setDate(next.getDate() + 1);
|
||||
const parts = getPersianDateParts(next);
|
||||
if (parts.year !== todayParts.year || parts.month !== todayParts.month) {
|
||||
break;
|
||||
}
|
||||
end.setDate(end.getDate() + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
from_date: start.toISOString().slice(0, 10),
|
||||
to_date: end.toISOString().slice(0, 10),
|
||||
};
|
||||
};
|
||||
|
||||
export default function Reports() {
|
||||
const { t, lang } = useTranslation();
|
||||
const { activeWorkspace } = useWorkspace();
|
||||
const [tab, setTab] = useState<Tab>("chart");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [memberships, setMemberships] = useState<WorkspaceMembership[]>([]);
|
||||
const [chartData, setChartData] = useState<ChartReportResponse | null>(null);
|
||||
const [tableData, setTableData] = useState<TableReportResponse | null>(null);
|
||||
const [dayDetails, setDayDetails] = useState<DayDetailsResponse | null>(null);
|
||||
const [openDay, setOpenDay] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW);
|
||||
|
||||
const [filters, setFilters] = useState<ReportsFilterDraft>({
|
||||
period: "this_month",
|
||||
from_date: "",
|
||||
to_date: "",
|
||||
user: "",
|
||||
client: "",
|
||||
project: "",
|
||||
tags: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorkspace?.id) return;
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const [projectsResponse, clientsResponse, tagsResponse, membersResponse] = await Promise.all([
|
||||
getProjects(activeWorkspace.id, { limit: 300, offset: 0 }),
|
||||
getClients(activeWorkspace.id, "", "", 300, 0),
|
||||
getTags(activeWorkspace.id, { limit: 300, offset: 0 }),
|
||||
fetchWorkspaceMemberships({ workspace: activeWorkspace.id }),
|
||||
]);
|
||||
setProjects(projectsResponse.results || []);
|
||||
setClients((clientsResponse.results || []).map((client: { id: string; name: string }) => ({ id: client.id, name: client.name })));
|
||||
setTags(tagsResponse.results || []);
|
||||
setMemberships(membersResponse.results || []);
|
||||
} catch {
|
||||
toast.error(t.reports?.loadFiltersError || "Failed to load report filters.");
|
||||
}
|
||||
};
|
||||
void loadOptions();
|
||||
}, [activeWorkspace?.id, t.reports?.loadFiltersError]);
|
||||
|
||||
const buildApiFilters = (draft: ReportsFilterDraft): ReportFilters | null => {
|
||||
if (!activeWorkspace?.id) return null;
|
||||
|
||||
return {
|
||||
workspace: activeWorkspace.id,
|
||||
period: draft.period,
|
||||
from_date: draft.from_date || undefined,
|
||||
to_date: draft.to_date || undefined,
|
||||
user: canSelectUsers ? draft.user || undefined : undefined,
|
||||
client: draft.client || undefined,
|
||||
project: draft.project || undefined,
|
||||
tags: draft.tags,
|
||||
language: lang,
|
||||
};
|
||||
};
|
||||
|
||||
const apiFilters = useMemo<ReportFilters | null>(() => buildApiFilters(filters), [activeWorkspace?.id, canSelectUsers, filters, lang]);
|
||||
|
||||
const runReportLoad = async (nextFilters: ReportFilters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [nextChart, nextTable] = await Promise.all([
|
||||
getChartReport(nextFilters),
|
||||
getTableReport(nextFilters),
|
||||
]);
|
||||
setChartData(nextChart);
|
||||
setTableData(nextTable);
|
||||
setOpenDay(null);
|
||||
setDayDetails(null);
|
||||
} catch {
|
||||
toast.error(t.reports?.loadError || "Failed to load reports.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiFilters) return;
|
||||
void runReportLoad(apiFilters);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [apiFilters?.workspace]);
|
||||
|
||||
const handleToggleDay = async (day: string) => {
|
||||
if (!apiFilters) return;
|
||||
if (openDay === day) {
|
||||
setOpenDay(null);
|
||||
setDayDetails(null);
|
||||
return;
|
||||
}
|
||||
setOpenDay(day);
|
||||
try {
|
||||
const details = await getDayDetailsReport(apiFilters, day);
|
||||
setDayDetails(details);
|
||||
} catch {
|
||||
toast.error(t.reports?.loadDayDetailsError || "Failed to load day details.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (type: "excel" | "pdf") => {
|
||||
if (!apiFilters) return;
|
||||
try {
|
||||
await createReportExport(apiFilters, type, lang);
|
||||
toast.success(t.reports?.exportQueued || "Export queued. You will receive a notification with the download link.");
|
||||
} catch {
|
||||
toast.error(t.reports?.exportError || "Failed to queue report export.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeWorkspace) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
|
||||
{t.reports?.selectWorkspace || "Please select a workspace first."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-5 p-4 md:p-6">
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.reports?.title || "Reports"}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.reports?.description?.(activeWorkspace.name) || `Review activity reports for ${activeWorkspace.name}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-2 rounded-2xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-950 lg:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("chart")}
|
||||
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
|
||||
tab === "chart"
|
||||
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
|
||||
: "text-slate-500 dark:text-slate-400"
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
{t.reports?.chartTab || "Chart"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("table")}
|
||||
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
|
||||
tab === "table"
|
||||
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
|
||||
: "text-slate-500 dark:text-slate-400"
|
||||
}`}
|
||||
>
|
||||
<Table2 className="h-4 w-4" />
|
||||
{t.reports?.tableTab || "Table"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReportsFilterBar
|
||||
value={filters}
|
||||
onApply={(draft) => {
|
||||
setFilters(draft);
|
||||
const nextFilters = buildApiFilters(draft);
|
||||
if (!nextFilters) return;
|
||||
void runReportLoad(nextFilters);
|
||||
}}
|
||||
projects={projects}
|
||||
clients={clients}
|
||||
tags={tags}
|
||||
users={memberships}
|
||||
canSelectUsers={canSelectUsers}
|
||||
labels={{
|
||||
period: t.reports?.period || "Period",
|
||||
thisWeek: t.reports?.periodThisWeek || "This week",
|
||||
thisMonth: t.reports?.periodThisMonth || "This month",
|
||||
thisYear: t.reports?.periodThisYear || "This year",
|
||||
firstHalf: t.reports?.periodFirstHalf || "First half of year",
|
||||
secondHalf: t.reports?.periodSecondHalf || "Second half of year",
|
||||
customPeriod: t.reports?.periodCustom || "Custom period",
|
||||
fromDate: t.reports?.fromDate || "From date",
|
||||
toDate: t.reports?.toDate || "To date",
|
||||
user: t.reports?.user || "User",
|
||||
allUsers: t.reports?.allUsers || "All users",
|
||||
searchUsers: t.reports?.searchUsers || "Search users...",
|
||||
client: t.reports?.client || "Client",
|
||||
allClients: t.reports?.allClients || "All clients",
|
||||
searchClients: t.reports?.searchClients || "Search clients...",
|
||||
project: t.reports?.project || "Project",
|
||||
allProjects: t.reports?.allProjects || "All projects",
|
||||
searchProjects: t.reports?.searchProjects || "Search projects...",
|
||||
tags: t.reports?.tags || "Tags",
|
||||
allTags: t.reports?.allTags || "All tags",
|
||||
searchTags: t.reports?.searchTags || "Search tags...",
|
||||
clear: t.reports?.clear || "Clear",
|
||||
apply: t.reports?.apply || "Apply",
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
|
||||
{t.loading || "Loading..."}
|
||||
</div>
|
||||
) : tab === "chart" ? (
|
||||
<ReportsChartPanel
|
||||
data={chartData}
|
||||
labels={{
|
||||
totalHours: t.reports?.totalHours || "Total hours",
|
||||
billableHours: t.reports?.billableHours || "Billable hours",
|
||||
nonBillableHours: t.reports?.nonBillableHours || "Non-billable hours",
|
||||
totalIncome: t.reports?.totalIncome || "Total income",
|
||||
chart: t.reports?.chartTitle || "Activity chart",
|
||||
totalSeconds: t.reports?.totalSeconds || "Total seconds",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ReportsTablePanel
|
||||
data={tableData}
|
||||
dayDetails={dayDetails}
|
||||
openDay={openDay}
|
||||
onToggleDay={(day) => void handleToggleDay(day)}
|
||||
onExport={(type) => void handleExport(type)}
|
||||
labels={{
|
||||
exportExcel: t.reports?.exportExcel || "Export Excel",
|
||||
exportPdf: t.reports?.exportPdf || "Export PDF",
|
||||
date: t.reports?.date || "Date",
|
||||
billableHours: t.reports?.billableHours || "Billable hours",
|
||||
nonBillableHours: t.reports?.nonBillableHours || "Non-billable hours",
|
||||
totalHours: t.reports?.totalHours || "Total hours",
|
||||
totalIncome: t.reports?.totalIncome || "Total income",
|
||||
details: t.reports?.details || "Details",
|
||||
total: t.reports?.total || "Total",
|
||||
name: t.reports?.name || "Name",
|
||||
clientsTable: t.reports?.clientsTable || "Clients",
|
||||
projectsTable: t.reports?.projectsTable || "Projects",
|
||||
tagsTable: t.reports?.tagsTable || "Tags",
|
||||
noDescription: t.timesheet?.emptyDescription || "No description",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user