reporting dashboard

This commit is contained in:
2ManyProjects 2025-04-28 20:59:04 -05:00
parent 58244d6afa
commit 3d4d876283
4 changed files with 1085 additions and 2 deletions

View file

@ -24,7 +24,8 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "^9.0.2", "react-redux": "^9.0.2",
"react-router-dom": "^6.20.1" "react-router-dom": "^6.20.1",
"recharts": "^2.10.3"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.37", "@types/react": "^18.2.37",
@ -1951,6 +1952,60 @@
"@babel/types": "^7.20.7" "@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": { "node_modules/@types/estree": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "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", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" "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": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -3176,12 +3346,25 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true "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": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "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": ">= 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": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "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" "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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -4756,6 +4952,20 @@
"react-dom": ">=16.8" "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": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@ -4784,6 +4994,41 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/redux": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
@ -5360,6 +5605,11 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "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" "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": { "node_modules/vite": {
"version": "5.4.18", "version": "5.4.18",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.18.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.18.tgz",

View file

@ -26,7 +26,8 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "^9.0.2", "react-redux": "^9.0.2",
"react-router-dom": "^6.20.1" "react-router-dom": "^6.20.1",
"recharts": "^2.10.3"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.37", "@types/react": "^18.2.37",

View file

@ -28,6 +28,7 @@ const AdminCategoriesPage = lazy(() => import('@pages/Admin/CategoriesPage'));
const AdminCustomersPage = lazy(() => import('@pages/Admin/CustomersPage')); const AdminCustomersPage = lazy(() => import('@pages/Admin/CustomersPage'));
const AdminOrdersPage = lazy(() => import('@pages/Admin/OrdersPage')); const AdminOrdersPage = lazy(() => import('@pages/Admin/OrdersPage'));
const AdminSettingsPage = lazy(() => import('@pages/Admin/SettingsPage')); const AdminSettingsPage = lazy(() => import('@pages/Admin/SettingsPage'));
const AdminReportsPage = lazy(() => import('@pages/Admin/ReportsPage'));
const UserOrdersPage = lazy(() => import('@pages/UserOrdersPage')); const UserOrdersPage = lazy(() => import('@pages/UserOrdersPage'));
const NotFoundPage = lazy(() => import('@pages/NotFoundPage')); const NotFoundPage = lazy(() => import('@pages/NotFoundPage'));
@ -100,6 +101,7 @@ function App() {
<Route path="customers" element={<AdminCustomersPage />} /> <Route path="customers" element={<AdminCustomersPage />} />
<Route path="settings" element={<AdminSettingsPage />} /> <Route path="settings" element={<AdminSettingsPage />} />
<Route path="orders" element={<AdminOrdersPage />} /> <Route path="orders" element={<AdminOrdersPage />} />
<Route path="reports" element={<AdminReportsPage />} />
</Route> </Route>
{/* Catch-all route for 404s */} {/* Catch-all route for 404s */}

View 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;