reporting dashboard
This commit is contained in:
parent
58244d6afa
commit
3d4d876283
4 changed files with 1085 additions and 2 deletions
273
frontend/package-lock.json
generated
273
frontend/package-lock.json
generated
|
|
@ -24,7 +24,8 @@
|
|||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.0.2",
|
||||
"react-router-dom": "^6.20.1"
|
||||
"react-router-dom": "^6.20.1",
|
||||
"recharts": "^2.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
|
|
@ -1951,6 +1952,60 @@
|
|||
"@babel/types": "^7.20.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
|
|
@ -2520,6 +2575,116 @@
|
|||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"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://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||
|
|
@ -2596,6 +2761,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
|
|
@ -3176,12 +3346,25 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
||||
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
|
|
@ -3680,6 +3863,14 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
|
|
@ -4197,6 +4388,11 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
|
@ -4756,6 +4952,20 @@
|
|||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
|
|
@ -4784,6 +4994,41 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.3",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.3.tgz",
|
||||
"integrity": "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
|
|
@ -5360,6 +5605,11 @@
|
|||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -5536,6 +5786,27 @@
|
|||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"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": "5.4.18",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.18.tgz",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@
|
|||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.0.2",
|
||||
"react-router-dom": "^6.20.1"
|
||||
"react-router-dom": "^6.20.1",
|
||||
"recharts": "^2.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const AdminCategoriesPage = lazy(() => import('@pages/Admin/CategoriesPage'));
|
|||
const AdminCustomersPage = lazy(() => import('@pages/Admin/CustomersPage'));
|
||||
const AdminOrdersPage = lazy(() => import('@pages/Admin/OrdersPage'));
|
||||
const AdminSettingsPage = lazy(() => import('@pages/Admin/SettingsPage'));
|
||||
const AdminReportsPage = lazy(() => import('@pages/Admin/ReportsPage'));
|
||||
const UserOrdersPage = lazy(() => import('@pages/UserOrdersPage'));
|
||||
const NotFoundPage = lazy(() => import('@pages/NotFoundPage'));
|
||||
|
||||
|
|
@ -100,6 +101,7 @@ function App() {
|
|||
<Route path="customers" element={<AdminCustomersPage />} />
|
||||
<Route path="settings" element={<AdminSettingsPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
<Route path="reports" element={<AdminReportsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch-all route for 404s */}
|
||||
|
|
|
|||
809
frontend/src/pages/Admin/ReportsPage.jsx
Normal file
809
frontend/src/pages/Admin/ReportsPage.jsx
Normal file
|
|
@ -0,0 +1,809 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Tabs,
|
||||
Tab,
|
||||
Divider,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import {
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfYear,
|
||||
endOfYear,
|
||||
addDays,
|
||||
addWeeks,
|
||||
addMonths,
|
||||
addYears,
|
||||
format,
|
||||
isWithinInterval,
|
||||
parseISO,
|
||||
subMonths,
|
||||
subWeeks,
|
||||
subYears,
|
||||
subDays,
|
||||
isValid,
|
||||
parse
|
||||
} from 'date-fns';
|
||||
import { useAdminOrders } from '../../hooks/adminHooks';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import apiClient from '../../services/api';
|
||||
|
||||
// COLORS for charts
|
||||
const COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff8042', '#0088FE', '#00C49F', '#FFBB28', '#FF8042'];
|
||||
|
||||
const ReportsPage = () => {
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// Time period filter states
|
||||
const [timelineType, setTimelineType] = useState('monthly');
|
||||
const [fromDate, setFromDate] = useState(subMonths(new Date(), 1));
|
||||
const [toDate, setToDate] = useState(new Date());
|
||||
|
||||
// State for date inputs
|
||||
const [fromDateString, setFromDateString] = useState(format(fromDate, 'yyyy-MM-dd'));
|
||||
const [toDateString, setToDateString] = useState(format(toDate, 'yyyy-MM-dd'));
|
||||
|
||||
// Fetch data
|
||||
const { data: orders, isLoading: ordersLoading } = useAdminOrders();
|
||||
const { data: products, isLoading: productsLoading } = useQuery({
|
||||
queryKey: ['admin-products'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/products');
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
|
||||
// Update date objects when string inputs change
|
||||
useEffect(() => {
|
||||
const parsedFromDate = parse(fromDateString, 'yyyy-MM-dd', new Date());
|
||||
if (isValid(parsedFromDate)) {
|
||||
setFromDate(parsedFromDate);
|
||||
}
|
||||
}, [fromDateString]);
|
||||
|
||||
useEffect(() => {
|
||||
const parsedToDate = parse(toDateString, 'yyyy-MM-dd', new Date());
|
||||
if (isValid(parsedToDate)) {
|
||||
setToDate(parsedToDate);
|
||||
}
|
||||
}, [toDateString]);
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
// Handle timeline type change
|
||||
const handleTimelineChange = (event) => {
|
||||
const newTimelineType = event.target.value;
|
||||
setTimelineType(newTimelineType);
|
||||
|
||||
// Adjust the date range based on the new timeline type
|
||||
const today = new Date();
|
||||
let newFromDate;
|
||||
|
||||
switch (newTimelineType) {
|
||||
case 'daily':
|
||||
newFromDate = subDays(today, 7); // Last 7 days
|
||||
break;
|
||||
case 'weekly':
|
||||
newFromDate = subWeeks(today, 4); // Last 4 weeks
|
||||
break;
|
||||
case 'monthly':
|
||||
newFromDate = subMonths(today, 1); // Last month
|
||||
break;
|
||||
case 'annually':
|
||||
newFromDate = subYears(today, 1); // Last year
|
||||
break;
|
||||
default:
|
||||
newFromDate = subMonths(today, 1);
|
||||
}
|
||||
|
||||
setFromDate(newFromDate);
|
||||
setToDate(today);
|
||||
setFromDateString(format(newFromDate, 'yyyy-MM-dd'));
|
||||
setToDateString(format(today, 'yyyy-MM-dd'));
|
||||
};
|
||||
|
||||
// Handle date input changes
|
||||
const handleFromDateChange = (e) => {
|
||||
setFromDateString(e.target.value);
|
||||
};
|
||||
|
||||
const handleToDateChange = (e) => {
|
||||
setToDateString(e.target.value);
|
||||
};
|
||||
|
||||
// Filter orders based on selected date range
|
||||
const filteredOrders = useMemo(() => {
|
||||
if (!orders) return [];
|
||||
|
||||
return orders.filter(order => {
|
||||
const orderDate = parseISO(order.created_at);
|
||||
return isWithinInterval(orderDate, {
|
||||
start: startOfDay(fromDate),
|
||||
end: endOfDay(toDate)
|
||||
});
|
||||
});
|
||||
}, [orders, fromDate, toDate]);
|
||||
|
||||
// Calculate total revenue for the selected period
|
||||
const totalRevenue = useMemo(() => {
|
||||
return filteredOrders.reduce((sum, order) => {
|
||||
if (order.payment_completed) {
|
||||
return sum + parseFloat(order.total_amount || 0);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
}, [filteredOrders]);
|
||||
|
||||
// Calculate average order value
|
||||
const averageOrderValue = useMemo(() => {
|
||||
const completedOrders = filteredOrders.filter(order => order.payment_completed);
|
||||
if (completedOrders.length === 0) return 0;
|
||||
|
||||
const total = completedOrders.reduce((sum, order) => sum + parseFloat(order.total_amount || 0), 0);
|
||||
return total / completedOrders.length;
|
||||
}, [filteredOrders]);
|
||||
|
||||
// Group orders by status
|
||||
const ordersByStatus = useMemo(() => {
|
||||
const statusCounts = {};
|
||||
|
||||
filteredOrders.forEach(order => {
|
||||
const status = order.status || 'unknown';
|
||||
statusCounts[status] = (statusCounts[status] || 0) + 1;
|
||||
});
|
||||
|
||||
return Object.entries(statusCounts).map(([status, count]) => ({
|
||||
name: status.charAt(0).toUpperCase() + status.slice(1),
|
||||
value: count
|
||||
}));
|
||||
}, [filteredOrders]);
|
||||
|
||||
// Group orders by location (state/province)
|
||||
const ordersByLocation = useMemo(() => {
|
||||
const locationCounts = {};
|
||||
|
||||
filteredOrders.forEach(order => {
|
||||
if (!order.shipping_address) return;
|
||||
|
||||
// Basic location extraction from shipping address
|
||||
let location = 'Unknown';
|
||||
|
||||
try {
|
||||
// Try to extract state/province from the address
|
||||
// This is a simple approach - might need to be refined based on your address format
|
||||
const addressLines = order.shipping_address.split('\n');
|
||||
const stateLine = addressLines.find(line => line.includes(','));
|
||||
|
||||
if (stateLine) {
|
||||
const statePart = stateLine.split(',')[1];
|
||||
if (statePart) {
|
||||
// Extract state code (usually 2 letters before zip code)
|
||||
const stateMatch = statePart.trim().match(/^([A-Z]{2})/);
|
||||
location = stateMatch ? stateMatch[1] : statePart.trim().split(' ')[0];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing location from address:', error);
|
||||
}
|
||||
|
||||
locationCounts[location] = (locationCounts[location] || 0) + 1;
|
||||
});
|
||||
|
||||
return Object.entries(locationCounts)
|
||||
.map(([location, count]) => ({
|
||||
name: location,
|
||||
value: count
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value) // Sort by count descending
|
||||
.slice(0, 8); // Top 8 locations
|
||||
}, [filteredOrders]);
|
||||
|
||||
// Prepare time series data for revenue chart
|
||||
const revenueTimeSeriesData = useMemo(() => {
|
||||
if (!orders) return [];
|
||||
|
||||
const dataMap = new Map();
|
||||
let dateFormat;
|
||||
let step;
|
||||
|
||||
// Determine format and grouping based on timeline type
|
||||
switch (timelineType) {
|
||||
case 'daily':
|
||||
dateFormat = 'MMM d';
|
||||
step = (date) => addDays(date, 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
dateFormat = "'Week' w, yyyy";
|
||||
step = (date) => addWeeks(date, 1);
|
||||
break;
|
||||
case 'monthly':
|
||||
dateFormat = 'MMM yyyy';
|
||||
step = (date) => addMonths(date, 1);
|
||||
break;
|
||||
case 'annually':
|
||||
dateFormat = 'yyyy';
|
||||
step = (date) => addYears(date, 1);
|
||||
break;
|
||||
default:
|
||||
dateFormat = 'MMM yyyy';
|
||||
step = (date) => addMonths(date, 1);
|
||||
}
|
||||
|
||||
// Initialize dataMap with all periods in the range
|
||||
let currentDate = new Date(fromDate);
|
||||
while (currentDate <= toDate) {
|
||||
const key = format(currentDate, dateFormat);
|
||||
dataMap.set(key, { name: key, revenue: 0, orders: 0 });
|
||||
currentDate = step(currentDate);
|
||||
}
|
||||
|
||||
// Add order data to the map
|
||||
filteredOrders.forEach(order => {
|
||||
const orderDate = parseISO(order.created_at);
|
||||
const key = format(orderDate, dateFormat);
|
||||
|
||||
if (dataMap.has(key)) {
|
||||
const entry = dataMap.get(key);
|
||||
if (order.payment_completed) {
|
||||
entry.revenue += parseFloat(order.total_amount || 0);
|
||||
}
|
||||
entry.orders += 1;
|
||||
dataMap.set(key, entry);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert map to array and sort by date
|
||||
return Array.from(dataMap.values());
|
||||
}, [filteredOrders, timelineType, fromDate, toDate]);
|
||||
|
||||
// Prepare product sales data
|
||||
const productSalesData = useMemo(() => {
|
||||
if (!orders || !products) return [];
|
||||
|
||||
const productSales = {};
|
||||
|
||||
// Initialize with all products
|
||||
products.forEach(product => {
|
||||
productSales[product.id] = {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
category: product.category_name,
|
||||
quantity: 0,
|
||||
revenue: 0
|
||||
};
|
||||
});
|
||||
|
||||
// Aggregate sales data from orders
|
||||
filteredOrders.forEach(order => {
|
||||
if (order.items && Array.isArray(order.items)) {
|
||||
order.items.forEach(item => {
|
||||
if (productSales[item.product_id]) {
|
||||
productSales[item.product_id].quantity += item.quantity;
|
||||
productSales[item.product_id].revenue += parseFloat(item.price_at_purchase) * item.quantity;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to array and sort by quantity
|
||||
return Object.values(productSales)
|
||||
.filter(product => product.quantity > 0)
|
||||
.sort((a, b) => b.quantity - a.quantity);
|
||||
}, [filteredOrders, products]);
|
||||
|
||||
// Top selling products for the chart
|
||||
const topSellingProducts = useMemo(() => {
|
||||
return productSalesData.slice(0, 5);
|
||||
}, [productSalesData]);
|
||||
|
||||
// Category sales distribution
|
||||
const categorySalesData = useMemo(() => {
|
||||
if (!productSalesData.length) return [];
|
||||
|
||||
const categoryMap = {};
|
||||
|
||||
productSalesData.forEach(product => {
|
||||
const category = product.category || 'Uncategorized';
|
||||
|
||||
if (!categoryMap[category]) {
|
||||
categoryMap[category] = {
|
||||
name: category,
|
||||
value: 0,
|
||||
revenue: 0
|
||||
};
|
||||
}
|
||||
|
||||
categoryMap[category].value += product.quantity;
|
||||
categoryMap[category].revenue += product.revenue;
|
||||
});
|
||||
|
||||
return Object.values(categoryMap).sort((a, b) => b.value - a.value);
|
||||
}, [productSalesData]);
|
||||
|
||||
// Loading state
|
||||
const isLoading = ordersLoading || productsLoading;
|
||||
|
||||
// Handle export reports
|
||||
const handleExportCSV = () => {
|
||||
// Generate CSV content
|
||||
let csvContent = 'data:text/csv;charset=utf-8,';
|
||||
|
||||
// Add headers
|
||||
csvContent += 'Period,Revenue,Orders\n';
|
||||
|
||||
// Add data rows
|
||||
revenueTimeSeriesData.forEach(item => {
|
||||
csvContent += `${item.name},${item.revenue.toFixed(2)},${item.orders}\n`;
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', encodedUri);
|
||||
link.setAttribute('download', `sales-report-${format(new Date(), 'yyyy-MM-dd')}.csv`);
|
||||
document.body.appendChild(link);
|
||||
|
||||
// Trigger download
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Reports & Analytics
|
||||
</Typography>
|
||||
|
||||
{/* Filters */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="timeline-label">Timeline</InputLabel>
|
||||
<Select
|
||||
labelId="timeline-label"
|
||||
value={timelineType}
|
||||
label="Timeline"
|
||||
onChange={handleTimelineChange}
|
||||
>
|
||||
<MenuItem value="daily">Daily</MenuItem>
|
||||
<MenuItem value="weekly">Weekly</MenuItem>
|
||||
<MenuItem value="monthly">Monthly</MenuItem>
|
||||
<MenuItem value="annually">Annually</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
label="From Date"
|
||||
type="date"
|
||||
value={fromDateString}
|
||||
onChange={handleFromDateChange}
|
||||
fullWidth
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
inputProps={{
|
||||
max: toDateString
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
label="To Date"
|
||||
type="date"
|
||||
value={toDateString}
|
||||
onChange={handleToDateChange}
|
||||
fullWidth
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
inputProps={{
|
||||
min: fromDateString,
|
||||
max: format(new Date(), 'yyyy-MM-dd')
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
onClick={handleExportCSV}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="textSecondary" gutterBottom>
|
||||
Total Revenue
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
${totalRevenue.toFixed(2)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="textSecondary" gutterBottom>
|
||||
Orders
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{filteredOrders.length}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="textSecondary" gutterBottom>
|
||||
Average Order Value
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
${averageOrderValue.toFixed(2)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="textSecondary" gutterBottom>
|
||||
Completed Orders
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{filteredOrders.filter(order => order.payment_completed).length}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Tabs for different reports */}
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab label="Sales Overview" />
|
||||
<Tab label="Product Performance" />
|
||||
<Tab label="Order Analysis" />
|
||||
<Tab label="Geographic Distribution" />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Sales Overview Tab */}
|
||||
{activeTab === 0 && (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Revenue & Orders Over Time
|
||||
</Typography>
|
||||
<Box sx={{ height: 400 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={revenueTimeSeriesData}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis yAxisId="left" />
|
||||
<YAxis yAxisId="right" orientation="right" />
|
||||
<Tooltip formatter={(value, name) => {
|
||||
return name === 'revenue' ? `$${value.toFixed(2)}` : value;
|
||||
}} />
|
||||
<Legend />
|
||||
<Line yAxisId="left" type="monotone" dataKey="revenue" name="Revenue ($)" stroke="#8884d8" activeDot={{ r: 8 }} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="orders" name="Orders" stroke="#82ca9d" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Order Status Distribution
|
||||
</Typography>
|
||||
<Box sx={{ height: 300 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={ordersByStatus}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{ordersByStatus.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value, name, props) => [`${value} orders`, props.payload.name]} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Category Sales Distribution
|
||||
</Typography>
|
||||
<Box sx={{ height: 300 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categorySalesData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{categorySalesData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value, name, props) => [`${value} items`, props.payload.name]} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Product Performance Tab */}
|
||||
{activeTab === 1 && (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Top Selling Products
|
||||
</Typography>
|
||||
<Box sx={{ height: 400 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={topSellingProducts}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value, name) => {
|
||||
return name === 'revenue' ? `$${value.toFixed(2)}` : value;
|
||||
}} />
|
||||
<Legend />
|
||||
<Bar dataKey="quantity" name="Units Sold" fill="#8884d8" />
|
||||
<Bar dataKey="revenue" name="Revenue ($)" fill="#82ca9d" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Product Sales Details
|
||||
</Typography>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '8px', borderBottom: '1px solid #ddd' }}>Product</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px', borderBottom: '1px solid #ddd' }}>Category</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px', borderBottom: '1px solid #ddd' }}>Units Sold</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px', borderBottom: '1px solid #ddd' }}>Revenue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{productSalesData.map((product) => (
|
||||
<tr key={product.id}>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #f0f0f0' }}>{product.name}</td>
|
||||
<td style={{ padding: '8px', borderBottom: '1px solid #f0f0f0' }}>{product.category}</td>
|
||||
<td style={{ textAlign: 'right', padding: '8px', borderBottom: '1px solid #f0f0f0' }}>{product.quantity}</td>
|
||||
<td style={{ textAlign: 'right', padding: '8px', borderBottom: '1px solid #f0f0f0' }}>${product.revenue.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Order Analysis Tab */}
|
||||
{activeTab === 2 && (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Orders Over Time
|
||||
</Typography>
|
||||
<Box sx={{ height: 400 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={revenueTimeSeriesData}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="orders" name="Number of Orders" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardHeader title="Order Completion Rate" />
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={filteredOrders.length > 0 ? (filteredOrders.filter(o => o.payment_completed).length / filteredOrders.length) * 100 : 0}
|
||||
size={100}
|
||||
thickness={5}
|
||||
sx={{ color: '#82ca9d' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="div">
|
||||
{filteredOrders.length > 0 ? Math.round((filteredOrders.filter(o => o.payment_completed).length / filteredOrders.length) * 100) : 0}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ mt: 2, textAlign: 'center' }}>
|
||||
{filteredOrders.filter(o => o.payment_completed).length} out of {filteredOrders.length} orders completed
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardHeader title="Average Order Value Trend" />
|
||||
<CardContent>
|
||||
<Box sx={{ height: 200 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={revenueTimeSeriesData.map(item => ({
|
||||
name: item.name,
|
||||
avgValue: item.orders > 0 ? item.revenue / item.orders : 0
|
||||
}))}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value) => `$${value.toFixed(2)}`} />
|
||||
<Line type="monotone" dataKey="avgValue" name="Avg. Order Value" stroke="#ff7300" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Geographic Distribution Tab */}
|
||||
{activeTab === 3 && (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Orders by Location
|
||||
</Typography>
|
||||
<Box sx={{ height: 400 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={ordersByLocation}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
layout="vertical"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" />
|
||||
<YAxis dataKey="name" type="category" width={100} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="value" name="Number of Orders" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Note: Location data is extracted from shipping addresses. The accuracy may vary based on address format.
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsPage;
|
||||
Loading…
Reference in a new issue