diff --git a/src/frontend/package.json b/src/frontend/package.json index 5e143edeec..5ecf2abe2d 100755 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -70,6 +70,7 @@ "eslint-plugin-prettier": "^5.0.0", "flatgeobuf": "^3.27.1", "geojson-validation": "^1.0.2", + "html2canvas": "^1.4.1", "install": "^0.13.0", "lucide-react": "^0.276.0", "mini-css-extract-plugin": "^2.7.5", @@ -82,6 +83,7 @@ "react-redux": "^8.0.5", "react-router-dom": "^6.6.0", "react-spinners": "^0.13.8", + "recharts": "^2.10.3", "redux": "^4.2.0", "redux-persist": "^6.0.0", "tailwind-merge": "^1.14.0", diff --git a/src/frontend/pnpm-lock.yaml b/src/frontend/pnpm-lock.yaml index f7db10e250..05f6f52132 100644 --- a/src/frontend/pnpm-lock.yaml +++ b/src/frontend/pnpm-lock.yaml @@ -86,6 +86,9 @@ dependencies: geojson-validation: specifier: ^1.0.2 version: 1.0.2 + html2canvas: + specifier: ^1.4.1 + version: 1.4.1 install: specifier: ^0.13.0 version: 0.13.0 @@ -122,6 +125,9 @@ dependencies: react-spinners: specifier: ^0.13.8 version: 0.13.8(react-dom@17.0.2)(react@17.0.2) + recharts: + specifier: ^2.10.3 + version: 2.10.3(prop-types@15.8.1)(react-dom@17.0.2)(react@17.0.2) redux: specifier: ^4.2.0 version: 4.2.1 @@ -3067,6 +3073,48 @@ packages: resolution: {integrity: sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==} dev: true + /@types/d3-array@3.2.1: + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + dev: false + + /@types/d3-color@3.1.3: + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + dev: false + + /@types/d3-ease@3.0.2: + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + dev: false + + /@types/d3-interpolate@3.0.4: + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + dependencies: + '@types/d3-color': 3.1.3 + dev: false + + /@types/d3-path@3.0.2: + resolution: {integrity: sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA==} + dev: false + + /@types/d3-scale@4.0.8: + resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==} + dependencies: + '@types/d3-time': 3.0.3 + dev: false + + /@types/d3-shape@3.1.6: + resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==} + dependencies: + '@types/d3-path': 3.0.2 + dev: false + + /@types/d3-time@3.0.3: + resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==} + dev: false + + /@types/d3-timer@3.0.2: + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + dev: false + /@types/eslint-scope@3.7.5: resolution: {integrity: sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA==} dependencies: @@ -3801,6 +3849,11 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + dev: false + /big-integer@1.6.51: resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} engines: {node: '>=0.6'} @@ -4041,6 +4094,12 @@ packages: engines: {node: '>=8'} dev: true + /css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + dependencies: + utrie: 1.0.2 + dev: false + /css-loader@6.8.1(webpack@5.88.2): resolution: {integrity: sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==} engines: {node: '>= 12.13.0'} @@ -4092,6 +4151,77 @@ packages: /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} + /d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + dev: false + + /d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + dev: false + + /d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + dependencies: + d3-path: 3.1.0 + dev: false + + /d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + dependencies: + d3-time: 3.1.0 + dev: false + + /d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false + /damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: true @@ -4136,6 +4266,10 @@ packages: dependencies: ms: 2.1.2 + /decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + dev: false + /decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} dev: true @@ -4265,6 +4399,12 @@ packages: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dev: true + /dom-helpers@3.4.0: + resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==} + dependencies: + '@babel/runtime': 7.23.1 + dev: false + /dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} dependencies: @@ -4794,6 +4934,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + /eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + dev: false + /events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -4845,6 +4989,11 @@ packages: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} dev: false + /fast-equals@5.0.1: + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} + engines: {node: '>=6.0.0'} + dev: false + /fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -5172,6 +5321,14 @@ packages: whatwg-encoding: 2.0.0 dev: true + /html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + dev: false + /http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -5277,6 +5434,11 @@ packages: side-channel: 1.0.4 dev: true + /internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + dev: false + /invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} dependencies: @@ -5875,7 +6037,6 @@ packages: /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: true /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} @@ -6551,6 +6712,10 @@ packages: react-dom: 17.0.2(react@17.0.2) dev: false + /react-lifecycles-compat@3.0.4: + resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} + dev: false + /react-loading-skeleton@3.3.1(react@17.0.2): resolution: {integrity: sha512-NilqqwMh2v9omN7LteiDloEVpFyMIa0VGqF+ukqp0ncVlYu1sKYbYGX9JEl+GtOT9TKsh04zCHAbavnQ2USldA==} peerDependencies: @@ -6656,6 +6821,20 @@ packages: react: 17.0.2 dev: false + /react-smooth@2.0.5(prop-types@15.8.1)(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA==} + peerDependencies: + prop-types: ^15.6.0 + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + fast-equals: 5.0.1 + prop-types: 15.8.1 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + react-transition-group: 2.9.0(react-dom@17.0.2)(react@17.0.2) + dev: false + /react-spinners@0.13.8(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==} peerDependencies: @@ -6683,6 +6862,20 @@ packages: tslib: 2.6.2 dev: false + /react-transition-group@2.9.0(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==} + peerDependencies: + react: '>=15.0.0' + react-dom: '>=15.0.0' + dependencies: + dom-helpers: 3.4.0 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + react-lifecycles-compat: 3.0.4 + dev: false + /react-transition-group@4.4.5(react-dom@17.0.2)(react@17.0.2): resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: @@ -6715,6 +6908,33 @@ packages: dependencies: picomatch: 2.3.1 + /recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + dependencies: + decimal.js-light: 2.5.1 + dev: false + + /recharts@2.10.3(prop-types@15.8.1)(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-G4J96fKTZdfFQd6aQnZjo2nVNdXhp+uuLb00+cBTGLo85pChvm1+E67K3wBOHDE/77spcYb2Cy9gYWVqiZvQCg==} + engines: {node: '>=14'} + peerDependencies: + prop-types: ^15.6.0 + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + clsx: 2.0.0 + eventemitter3: 4.0.7 + lodash: 4.17.21 + prop-types: 15.8.1 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + react-is: 16.13.1 + react-smooth: 2.0.5(prop-types@15.8.1)(react-dom@17.0.2)(react@17.0.2) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.1 + victory-vendor: 36.7.0 + dev: false + /redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -7332,6 +7552,12 @@ packages: commander: 2.20.3 source-map-support: 0.5.21 + /text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + dependencies: + utrie: 1.0.2 + dev: false + /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -7360,6 +7586,10 @@ packages: webpack: 5.88.2 dev: true + /tiny-invariant@1.3.1: + resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + dev: false + /tinybench@2.5.1: resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} dev: true @@ -7633,6 +7863,31 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + /utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + dependencies: + base64-arraybuffer: 1.0.2 + dev: false + + /victory-vendor@36.7.0: + resolution: {integrity: sha512-nqYuTkLSdTTeACyXcCLbL7rl0y6jpzLPtTNGOtSnajdR+xxMxBdjMxDjfNJNlhR+ZU8vbXz+QejntcbY7h9/ZA==} + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.8 + '@types/d3-shape': 3.1.6 + '@types/d3-time': 3.0.3 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + dev: false + /vite-node@0.34.6(@types/node@20.8.3)(sass@1.69.0): resolution: {integrity: sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==} engines: {node: '>=v14.18.0'} diff --git a/src/frontend/src/api/SubmissionService.ts b/src/frontend/src/api/SubmissionService.ts index 70d4e2a308..c6d6859b2b 100644 --- a/src/frontend/src/api/SubmissionService.ts +++ b/src/frontend/src/api/SubmissionService.ts @@ -1,6 +1,7 @@ import CoreModules from '@/shared/CoreModules'; import { ProjectActions } from '@/store/slices/ProjectSlice'; // import { HomeProjectCardModel } from '@/models/home/homeModel'; +import { SubmissionActions } from '@/store/slices/SubmissionSlice'; export const ProjectSubmissionService: Function = (url: string) => { return async (dispatch) => { @@ -38,3 +39,95 @@ export const ProjectBuildingGeojsonService: Function = (url: string) => { await fetchProjectBuildingGeojson(url); }; }; + +export const ProjectSubmissionInfographicsService: Function = (url: string) => { + return async (dispatch) => { + const fetchProjectSubmission = async (url: string) => { + try { + dispatch(SubmissionActions.SetSubmissionInfographicsLoading(true)); + const fetchSubmissionData = await CoreModules.axios.get(url); + const resp: any = fetchSubmissionData.data; + dispatch(SubmissionActions.SetSubmissionInfographicsLoading(false)); + dispatch(SubmissionActions.SetSubmissionInfographics(resp)); + } catch (error) {} + }; + + await fetchProjectSubmission(url); + }; +}; + +export const ValidatedVsMappedInfographicsService: Function = (url: string) => { + return async (dispatch) => { + const fetchProjectSubmission = async (url: string) => { + try { + dispatch(SubmissionActions.SetValidatedVsMappedLoading(true)); + const validatedVsMappedData = await CoreModules.axios.get(url); + const resp: any = validatedVsMappedData.data; + dispatch(SubmissionActions.SetValidatedVsMappedInfographics(resp)); + dispatch(SubmissionActions.SetValidatedVsMappedLoading(false)); + } catch (error) { + dispatch(SubmissionActions.SetValidatedVsMappedLoading(false)); + } + }; + + await fetchProjectSubmission(url); + }; +}; + +export const ProjectContributorsService: Function = (url: string) => { + return async (dispatch) => { + const fetchProjectContributor = async (url: string) => { + try { + dispatch(SubmissionActions.SetSubmissionContributorsLoading(true)); + const fetchContributorsData = await CoreModules.axios.get(url); + const resp: any = fetchContributorsData.data; + dispatch(SubmissionActions.SetSubmissionContributors(resp)); + dispatch(SubmissionActions.SetSubmissionContributorsLoading(false)); + } catch (error) { + dispatch(SubmissionActions.SetSubmissionContributorsLoading(false)); + } + }; + + await fetchProjectContributor(url); + }; +}; + +export const SubmissionFormFieldsService: Function = (url: string) => { + return async (dispatch) => { + const fetchFormFields = async (url: string) => { + try { + dispatch(SubmissionActions.SetSubmissionFormFieldsLoading(true)); + const response = await CoreModules.axios.get(url); + const formFields: any = response.data; + dispatch(SubmissionActions.SetSubmissionFormFields(formFields)); + dispatch(SubmissionActions.SetSubmissionFormFieldsLoading(false)); + dispatch(SubmissionActions.SetSubmissionTableRefreshing(false)); + } catch (error) { + dispatch(SubmissionActions.SetSubmissionFormFieldsLoading(false)); + dispatch(SubmissionActions.SetSubmissionTableRefreshing(false)); + } + }; + + await fetchFormFields(url); + }; +}; + +export const SubmissionTableService: Function = (url: string) => { + return async (dispatch) => { + const fetchSubmissionTable = async (url: string) => { + try { + dispatch(SubmissionActions.SetSubmissionTableLoading(true)); + const response = await CoreModules.axios.get(url); + const submissionTableData: any = response.data; + dispatch(SubmissionActions.SetSubmissionTable(submissionTableData)); + dispatch(SubmissionActions.SetSubmissionTableLoading(false)); + dispatch(SubmissionActions.SetSubmissionTableRefreshing(false)); + } catch (error) { + dispatch(SubmissionActions.SetSubmissionTableLoading(false)); + dispatch(SubmissionActions.SetSubmissionTableRefreshing(false)); + } + }; + + await fetchSubmissionTable(url); + }; +}; diff --git a/src/frontend/src/components/ProjectSubmissions/InfographicsCard.tsx b/src/frontend/src/components/ProjectSubmissions/InfographicsCard.tsx new file mode 100644 index 0000000000..8f8db48a73 --- /dev/null +++ b/src/frontend/src/components/ProjectSubmissions/InfographicsCard.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import handleDownload from '@/utilfunctions/downloadChart'; +import AssetModules from '@/shared/AssetModules.js'; + +type InfographicsCardType = { + header: string; + subHeader?: React.ReactElement; + body: React.ReactElement; + cardRef?: React.MutableRefObject | undefined; +}; + +const InfographicsCard = ({ header, subHeader, body, cardRef }: InfographicsCardType) => ( +
+
+
{header}
+
handleDownload(cardRef, header)} + className="group fmtm-rounded-full fmtm-p-1 hover:fmtm-bg-gray-200 fmtm-cursor-pointer fmtm-duration-150 fmtm-h-9 fmtm-w-9 fmtm-flex fmtm-items-center fmtm-justify-center" + > + +
+
+ {subHeader && subHeader} +
{body}
+
+); + +export default InfographicsCard; diff --git a/src/frontend/src/components/ProjectSubmissions/ProjectInfo.tsx b/src/frontend/src/components/ProjectSubmissions/ProjectInfo.tsx new file mode 100644 index 0000000000..57d08592ad --- /dev/null +++ b/src/frontend/src/components/ProjectSubmissions/ProjectInfo.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import CoreModules from '@/shared/CoreModules'; + +const ProjectInfo = () => { + const projectInfo = CoreModules.useAppSelector((state) => state.project.projectInfo); + const projectDashboardDetail = CoreModules.useAppSelector((state) => state.project.projectDashboardDetail); + const projectDashboardLoading = CoreModules.useAppSelector((state) => state.project.projectDashboardLoading); + + const dataCard = [ + { title: 'Tasks', count: projectDashboardDetail?.total_tasks }, + { title: 'Contributors', count: projectDashboardDetail?.total_contributors }, + { title: 'Submissions', count: projectDashboardDetail?.total_submission }, + ]; + + const ProjectDataCard = ({ data }) => ( +
+ {projectDashboardLoading ? ( + + ) : ( +

+ {data.count} +

+ )} + {projectDashboardLoading ? ( + + ) : ( +

+ {data.title} +

+ )} +
+ ); + return ( +
+
+

+ Projects + > + Dashboard +

+
+
+
+ {false ? ( +
+ + +
+ ) : ( +

{projectInfo?.title}

+ )} +
+

+ Created On:{' '} + {projectDashboardLoading ? ( + + ) : ( + {projectDashboardDetail?.created} + )} +

+

+ Last active:{' '} + {projectDashboardLoading ? ( + + ) : ( + {projectDashboardDetail?.last_active ? projectDashboardDetail?.last_active : '-'} + )} +

+
+
+
+
+ {dataCard.map((data, i) => ( + + ))} +
+
+
+
+ ); +}; + +export default ProjectInfo; diff --git a/src/frontend/src/components/ProjectSubmissions/ProjectSubmissionsSkeletonLoader.tsx b/src/frontend/src/components/ProjectSubmissions/ProjectSubmissionsSkeletonLoader.tsx new file mode 100644 index 0000000000..b143545f1b --- /dev/null +++ b/src/frontend/src/components/ProjectSubmissions/ProjectSubmissionsSkeletonLoader.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import CoreModules from '@/shared/CoreModules'; + +export const TaskCardSkeletonLoader = () => { + return ( +
+
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ ); +}; + +export const SubmissionsTableSkeletonLoader = () => { + return ( +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ + {Array.from({ length: 15 }).map(() => ( + + ))} +
+ ))} +
+ ); +}; diff --git a/src/frontend/src/components/ProjectSubmissions/SubmissionsInfographics.tsx b/src/frontend/src/components/ProjectSubmissions/SubmissionsInfographics.tsx new file mode 100644 index 0000000000..2f6de225f2 --- /dev/null +++ b/src/frontend/src/components/ProjectSubmissions/SubmissionsInfographics.tsx @@ -0,0 +1,310 @@ +import React, { useEffect, useRef, useState } from 'react'; +import TaskSubmissions from '@/components/ProjectSubmissions/TaskSubmissions'; +import CustomBarChart from '@/components/common/BarChart'; +import CustomPieChart from '@/components/common/PieChart'; +import Table, { TableHeader } from '@/components/common/CustomTable'; +import CustomLineChart from '@/components/common/LineChart'; +import CoreModules from '@/shared/CoreModules'; +import InfographicsCard from '@/components/ProjectSubmissions/InfographicsCard'; +import { + ProjectContributorsService, + ProjectSubmissionInfographicsService, + ValidatedVsMappedInfographicsService, +} from '@/api/SubmissionService'; +import environment from '@/environment'; + +const lineKeyData = [ + { + name: '11/25', + Actual: 4000, + Planned: 2400, + amt: 2400, + }, + { + name: '11/26', + Actual: 3000, + Planned: 1398, + amt: 2210, + }, + { + name: '11/27', + Actual: 2000, + Planned: 9800, + amt: 2290, + }, + { + name: '11/28', + Actual: 2780, + Planned: 3908, + amt: 2000, + }, + { + name: '11/29', + Actual: 1890, + Planned: 4800, + amt: 2181, + }, + { + name: '11/30', + Actual: 2390, + Planned: 3800, + amt: 2500, + }, + { + name: '12/01', + Actual: 3490, + Planned: 4300, + amt: 2100, + }, + { + name: '12/02', + Actual: 2780, + Planned: 3908, + amt: 2000, + }, + { + name: '12/03', + Actual: 1890, + Planned: 4800, + amt: 2181, + }, + { + name: '12/04', + Actual: 2390, + Planned: 3800, + amt: 2500, + }, + { + name: '12/05', + Actual: 3490, + Planned: 4300, + amt: 2100, + }, +]; + +const SubmissionsInfographics = () => { + const formSubmissionRef = useRef(null); + const projectProgressRef = useRef(null); + const totalContributorsRef = useRef(null); + const plannedVsActualRef = useRef(null); + const dispatch = CoreModules.useAppDispatch(); + + const params = CoreModules.useParams(); + const encodedId = params.projectId; + const decodedId = environment.decode(encodedId); + + const submissionInfographicsData = CoreModules.useAppSelector((state) => state.submission.submissionInfographics); + const submissionInfographicsLoading = CoreModules.useAppSelector( + (state) => state.submission.submissionInfographicsLoading, + ); + const submissionContributorsData = CoreModules.useAppSelector((state) => state.submission.submissionContributors); + const submissionContributorsLoading = CoreModules.useAppSelector( + (state) => state.submission.submissionContributorsLoading, + ); + const [submissionProjection, setSubmissionProjection] = useState<10 | 30>(10); + const validatedVsMappedInfographics = CoreModules.useAppSelector( + (state) => state.submission.validatedVsMappedInfographics, + ); + const validatedVsMappedLoading = CoreModules.useAppSelector((state) => state.submission.validatedVsMappedLoading); + + useEffect(() => { + dispatch( + ProjectSubmissionInfographicsService( + `${import.meta.env.VITE_API_URL}/submission/submission_page/${decodedId}?days=${submissionProjection}`, + ), + ); + }, [submissionProjection]); + + useEffect(() => { + dispatch( + ValidatedVsMappedInfographicsService(`${import.meta.env.VITE_API_URL}/tasks/activity/?project_id=${decodedId}`), + ); + }, []); + + useEffect(() => { + dispatch(ProjectContributorsService(`${import.meta.env.VITE_API_URL}/projects/contributors/${decodedId}`)); + }, []); + + const FormSubmissionSubHeader = () => ( +
+
setSubmissionProjection(10)} + > +

Last 10 days

+
+
setSubmissionProjection(30)} + > +

Last 30 days

+
+
+ ); + + // Test data for project progress + const featCount = 500; + const current = 450; + const remaining = featCount - current; + + const pieData = [ + { names: 'Current Progress', value: current }, + { names: 'Remaining', value: remaining }, + ]; + + return ( +
+
+
+ } + body={ + submissionInfographicsLoading ? ( + + ) : submissionInfographicsData.length > 0 ? ( + + ) : ( +
+ No form submissions! +
+ ) + } + /> +
+
+ + ) : pieData.length > 0 ? ( + + ) : ( +
+ No data available! +
+ ) + } + /> +
+
+
+
+ + ) : validatedVsMappedInfographics.length > 0 ? ( + + ) : ( +
+ No tasks validated or mapped yet! +
+ ) + } + /> +
+
+ {}} + style={{ height: '100%' }} + isLoading={submissionContributorsLoading} + > + {index + 1}} + /> + + ( +
+ {row?.user} +
+ )} + /> + ( +
+ {row?.contributions} +
+ )} + /> + + } + /> +
+
+
+ + ) : lineKeyData.length > 0 ? ( + + ) : ( +
+ No data available! +
+ ) + } + /> +
+ +
+ +
+
+ ); +}; + +export default SubmissionsInfographics; diff --git a/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx b/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx new file mode 100644 index 0000000000..7aae1e50af --- /dev/null +++ b/src/frontend/src/components/ProjectSubmissions/SubmissionsTable.tsx @@ -0,0 +1,403 @@ +import React, { useEffect, useState } from 'react'; +import AssetModules from '@/shared/AssetModules.js'; +import { CustomSelect } from '@/components/common/Select.js'; +import windowDimention from '@/hooks/WindowDimension'; +import Table, { TableHeader } from '@/components/common/CustomTable'; +import { SubmissionFormFieldsService, SubmissionTableService } from '@/api/SubmissionService'; +import CoreModules from '@/shared/CoreModules.js'; +import environment from '@/environment'; +import { SubmissionsTableSkeletonLoader } from '@/components/ProjectSubmissions/ProjectSubmissionsSkeletonLoader.js'; +import { Loader2 } from 'lucide-react'; +import { SubmissionActions } from '@/store/slices/SubmissionSlice'; + +type filterType = { + taskId: number | null; + submittedBy: string | null; + reviewState: string | null; + submittedDate: Date | null; +}; + +const SubmissionsTable = () => { + const initialFilterState = { + taskId: null, + submittedBy: null, + reviewState: null, + submittedDate: null, + }; + const [showFilter, setShowFilter] = useState(true); + const [filter, setFilter] = useState(initialFilterState); + const { windowSize } = windowDimention(); + const dispatch = CoreModules.useAppDispatch(); + const params = CoreModules.useParams(); + const encodedId = params.projectId; + const decodedId = environment.decode(encodedId); + const submissionFormFields = CoreModules.useAppSelector((state) => state.submission.submissionFormFields); + const submissionTableData = CoreModules.useAppSelector((state) => state.submission.submissionTableData); + const submissionFormFieldsLoading = CoreModules.useAppSelector( + (state) => state.submission.submissionFormFieldsLoading, + ); + const submissionTableDataLoading = CoreModules.useAppSelector((state) => state.submission.submissionTableDataLoading); + const submissionTableRefreshing = CoreModules.useAppSelector((state) => state.submission.submissionTableRefreshing); + const taskInfo = CoreModules.useAppSelector((state) => state.task.taskInfo); + const [numberOfFilters, setNumberOfFilters] = useState(0); + const [paginationPage, setPaginationPage] = useState(1); + + useEffect(() => { + let count = 0; + const filters = Object.keys(filter); + filters?.map((fltr) => { + if (filter[fltr]) { + count = count + 1; + } + }); + setNumberOfFilters(count); + }, [filter]); + + const updatedSubmissionFormFields = submissionFormFields?.map((formField) => { + if (formField.type !== 'structure') { + return { + ...formField, + path: formField?.path.slice(1).replace(/\//g, '.'), + name: formField?.name.charAt(0).toUpperCase() + formField?.name.slice(1).replace(/_/g, ' '), + }; + } + return null; + }); + + useEffect(() => { + dispatch( + SubmissionFormFieldsService(`${import.meta.env.VITE_API_URL}/submission/submission_form_fields/${decodedId}`), + ); + }, []); + + useEffect(() => { + if (!filter.taskId) { + dispatch( + SubmissionTableService( + `${import.meta.env.VITE_API_URL}/submission/submission_table/${decodedId}?page=${paginationPage}`, + ), + ); + } else { + dispatch( + SubmissionTableService( + `${import.meta.env.VITE_API_URL}/submission/task_submissions/${decodedId}?task_id=${ + filter.taskId + }&page=${paginationPage}`, + ), + ); + } + }, [paginationPage]); + + useEffect(() => { + setPaginationPage(1); + if (!filter.taskId) { + dispatch( + SubmissionTableService(`${import.meta.env.VITE_API_URL}/submission/submission_table/${decodedId}?page=1`), + ); + } else { + dispatch( + SubmissionTableService( + `${import.meta.env.VITE_API_URL}/submission/task_submissions/${decodedId}?task_id=${filter.taskId}&page=1`, + ), + ); + } + }, [filter]); + + const refreshTable = () => { + dispatch( + SubmissionFormFieldsService(`${import.meta.env.VITE_API_URL}/submission/submission_form_fields/${decodedId}`), + ); + dispatch(SubmissionActions.SetSubmissionTableRefreshing(true)); + if (!filter.taskId) { + dispatch( + SubmissionTableService( + `${import.meta.env.VITE_API_URL}/submission/submission_table/${decodedId}?page=${paginationPage}`, + ), + ); + } else { + dispatch( + SubmissionTableService( + `${import.meta.env.VITE_API_URL}/submission/task_submissions/${decodedId}?task_id=${ + filter.taskId + }&page=${paginationPage}`, + ), + ); + } + }; + + const handleChangePage = ( + e: React.ChangeEvent | React.KeyboardEvent, + newPage: number, + ) => { + if (newPage + 1 > submissionTableData?.pagination?.pages || newPage + 1 < 1) { + setPaginationPage(paginationPage); + return; + } + setPaginationPage(newPage + 1); + }; + + const clearFilters = () => { + setFilter(initialFilterState); + }; + + const TableFilter = () => ( +
+
+
+
+
+ +
+

FILTER

+
+

{numberOfFilters}

+
+
+ +
+ {showFilter && ( +
+
+ value && setFilter((prev) => ({ ...prev, taskId: +value }))} + className="fmtm-text-grey-700 fmtm-text-sm !fmtm-mb-0" + /> +
+
+ {}} + errorMsg="" + className="fmtm-text-grey-700 fmtm-text-sm !fmtm-mb-0" + /> +
+
+ {}} + errorMsg="" + className="fmtm-text-grey-700 fmtm-text-sm !fmtm-mb-0" + /> +
+
+ {}} + errorMsg="" + className="fmtm-text-grey-700 fmtm-text-sm !fmtm-mb-0" + /> +
+
+ )} + +
setShowFilter(!showFilter)} + > + +
+
+
+ +
+
+ ); + + function getValueByPath(obj: any, path: string) { + let value = obj; + path?.split('.')?.map((item) => { + if (path === 'start' || path === 'end') { + value = `${value[item]?.split('T')[0]} ${value[item]?.split('T')[1]}`; + } else if (item === 'point') { + value = `${value[item].type} (${value[item].coordinates})`; + } else { + value = value[item]; + } + }); + return value ? value : '-'; + } + + return ( +
+ + {submissionTableDataLoading || submissionFormFieldsLoading ? ( + + ) : ( + {}} isLoading={false}> + {index + 1}} + /> + {updatedSubmissionFormFields?.map((field: any): React.ReactNode | null => { + if (field) { + return ( + ( +
+ {getValueByPath(row, field?.path)} +
+ )} + /> + ); + } + return null; + })} + ( +
+ {' '} + {' '} + {' '} + {' '} + +
+ )} + /> +
+ )} + {submissionTableData?.pagination && ( +
+ {}} + /> +

Jump to

+ { + if (e.currentTarget.value) { + handleChangePage(e, parseInt(e.currentTarget.value) - 1); + } + }} + disabled={submissionTableDataLoading || submissionFormFieldsLoading} + /> +
+ )} +
+ ); +}; + +export default SubmissionsTable; diff --git a/src/frontend/src/components/ProjectSubmissions/TaskSubmissions.tsx b/src/frontend/src/components/ProjectSubmissions/TaskSubmissions.tsx new file mode 100644 index 0000000000..214ad1cabb --- /dev/null +++ b/src/frontend/src/components/ProjectSubmissions/TaskSubmissions.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import TaskSubmissionsMap from '@/components/ProjectSubmissions/TaskSubmissionsMap'; +import InputTextField from '@/components/common/InputTextField'; +import Button from '@/components/common/Button'; +import AssetModules from '@/shared/AssetModules.js'; +import CoreModules from '@/shared/CoreModules.js'; +import { TaskCardSkeletonLoader } from '@/components/ProjectSubmissions/ProjectSubmissionsSkeletonLoader'; + +const TaskSubmissions = () => { + const dispatch = CoreModules.useAppDispatch(); + const taskInfo = CoreModules.useAppSelector((state) => state.task.taskInfo); + const taskLoading = CoreModules.useAppSelector((state) => state.task.taskLoading); + + const zoomToTask = (taskId) => { + dispatch(CoreModules.TaskActions.SetSelectedTask(+taskId)); + }; + + const TaskCard = ({ task }) => ( +
+
+
+

#{task?.task_id}

+
+

Expected Count

+

{task?.feature_count}

+
+
+

Submission Count

+

{task.submission_count}

+
+
+
+ +
+
+
+ ); + + return ( +
+
+ {}} value="" placeholder="Search by task id" /> +
+ {taskLoading ? ( +
+ {Array.from({ length: 10 }).map((i) => ( + + ))} +
+ ) : ( +
+ {taskInfo?.map((task) => )} +
+ )} +
+
+
+ +
+
+ ); +}; + +export default TaskSubmissions; diff --git a/src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMap.tsx b/src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMap.tsx new file mode 100644 index 0000000000..100aa7ef74 --- /dev/null +++ b/src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMap.tsx @@ -0,0 +1,296 @@ +import React, { useCallback, useState, useEffect } from 'react'; + +import CoreModules from '@/shared/CoreModules'; +import { MapContainer as MapComponent } from '@/components/MapComponent/OpenLayersComponent'; +import { useOLMap } from '@/components/MapComponent/OpenLayersComponent'; +import LayerSwitcherControl from '@/components/MapComponent/OpenLayersComponent/LayerSwitcher'; +import { VectorLayer } from '@/components/MapComponent/OpenLayersComponent/Layers'; +import { Vector as VectorSource } from 'ol/source'; +import GeoJSON from 'ol/format/GeoJSON'; +import { get } from 'ol/proj'; +import { ProjectBuildingGeojsonService } from '@/api/SubmissionService'; +import environment from '@/environment'; +import { getStyles } from '@/components/MapComponent/OpenLayersComponent/helpers/styleUtils'; +import { ProjectActions } from '@/store/slices/ProjectSlice'; +import { basicGeojsonTemplate } from '@/utilities/mapUtils'; +import TaskSubmissionsMapLegend from '@/components/ProjectSubmissions/TaskSubmissionsMapLegend'; +import Accordion from '@/components/common/Accordion'; + +export const defaultStyles = { + lineColor: '#000000', + lineOpacity: 70, + fillColor: '#1a2fa2', + fillOpacity: 50, + lineThickness: 1, + circleRadius: 5, + dashline: 0, + showLabel: false, + customLabelText: null, + labelField: '', + labelFont: 'Calibri', + labelFontSize: 14, + labelColor: '#000000', + labelOpacity: 100, + labelOutlineWidth: 3, + labelOutlineColor: '#ffffff', + labelOffsetX: 0, + labelOffsetY: 0, + labelText: 'normal', + labelMaxResolution: 400, + labelAlign: 'center', + labelBaseline: 'middle', + labelRotationDegree: 0, + labelFontWeight: 'normal', + labelPlacement: 'point', + labelMaxAngleDegree: 45.0, + labelOverflow: false, + labelLineHeight: 1, + visibleOnMap: true, + icon: {}, + showSublayer: false, + sublayerColumnName: '', + sublayer: {}, +}; + +export const municipalStyles = { + ...defaultStyles, + fillOpacity: 0, + lineColor: '#008099', + dashline: 5, + width: 10, +}; + +const colorCodes = { + // '#9edefa': { min: 0, max: 5 }, + '#A9D2F3': { min: 10, max: 50 }, + '#7CB2E8': { min: 50, max: 100 }, + '#4A90D9': { min: 100, max: 130 }, + '#0062AC': { min: 130, max: 160 }, +}; +function colorRange(data, noOfRange) { + if (data?.length === 0) return []; + const actualCodes = [{ min: 0, max: 0, color: '#605f5e' }]; + const maxVal = Math.max(...data?.map((d) => d.count)); + const maxValue = maxVal <= noOfRange ? 10 : maxVal; + // const minValue = Math.min(...data?.map((d) => d.count)) 0; + const minValue = 1; + // const firstValue = minValue; + const colorCodesKeys = Object.keys(colorCodes); + const interval = (maxValue - minValue) / noOfRange; + let currentValue = minValue; + colorCodesKeys.forEach((key, index) => { + const nextValue = currentValue + interval; + actualCodes.push({ + min: Math.round(currentValue), + max: Math.round(nextValue), + color: colorCodesKeys[index], + }); + currentValue = nextValue; + }); + return actualCodes; +} +const getChoroplethColor = (value, colorCodesOutput) => { + let toReturn = '#FF4538'; + colorCodesOutput?.map((obj) => { + if (obj.min <= value && obj.max >= value) { + toReturn = obj.color; + return toReturn; + } + return toReturn; + }); + return toReturn; +}; + +const TaskSubmissionsMap = () => { + const dispatch = CoreModules.useAppDispatch(); + const [taskBoundaries, setTaskBoundaries] = useState(null); + const [buildingGeojson, setBuildingGeojson] = useState(null); + const projectTaskBoundries = CoreModules.useAppSelector((state) => state.project.projectTaskBoundries); + + const taskInfo = CoreModules.useAppSelector((state) => state.task.taskInfo); + const federalWiseProjectCount = taskInfo?.map((task) => ({ + code: task.task_id, + count: task.submission_count, + })); + + const projectBuildingGeojson = CoreModules.useAppSelector((state) => state.project.projectBuildingGeojson); + const selectedTask = CoreModules.useAppSelector((state) => state.task.selectedTask); + const defaultTheme = CoreModules.useAppSelector((state) => state.theme.hotTheme); + const params = CoreModules.useParams(); + const encodedId = params.projectId; + const decodedId = environment.decode(encodedId); + const legendColorArray = colorRange(federalWiseProjectCount, '4'); + const { mapRef, map } = useOLMap({ + center: [0, 0], + zoom: 4, + maxZoom: 25, + }); + + useEffect(() => { + return () => { + dispatch(ProjectActions.SetProjectBuildingGeojson(null)); + }; + }, []); + + useEffect(() => { + if ( + !projectTaskBoundries || + projectTaskBoundries?.length < 1 || + projectTaskBoundries?.[0]?.taskBoundries?.length < 1 + ) { + return; + } + const taskGeojsonFeatureCollection = { + ...basicGeojsonTemplate, + features: [ + ...projectTaskBoundries?.[0]?.taskBoundries?.map((task) => ({ + ...task.outline_geojson, + id: task.outline_geojson.properties.uid, + })), + ], + }; + setTaskBoundaries(taskGeojsonFeatureCollection); + // const taskBuildingGeojsonFeatureCollection = { + // ...basicGeojsonTemplate, + // features: [ + // ...projectBuildingGeojson?.map((feature) => ({ + // ...feature.geometry, + // id: feature.id, + // })), + // ], + // }; + // setBuildingGeojson(taskBuildingGeojsonFeatureCollection); + }, [projectTaskBoundries]); + useEffect(() => { + if (!projectBuildingGeojson) return; + const taskBuildingGeojsonFeatureCollection = { + ...basicGeojsonTemplate, + features: [ + ...projectBuildingGeojson?.map((feature) => ({ + ...feature.geometry, + id: feature.id, + })), + ], + }; + setBuildingGeojson(taskBuildingGeojsonFeatureCollection); + }, [projectBuildingGeojson]); + + useEffect(() => { + if (!taskBoundaries) return; + const filteredSelectedTaskGeojson = { + ...basicGeojsonTemplate, + features: taskBoundaries?.features?.filter((task) => task.properties.uid === selectedTask), + }; + const vectorSource = new VectorSource({ + features: new GeoJSON().readFeatures(filteredSelectedTaskGeojson, { + featureProjection: get('EPSG:3857'), + }), + }); + var extent = vectorSource.getExtent(); + map.getView().fit(extent, { + // easing: elastic, + animate: true, + size: map?.getSize(), + // maxZoom: 15, + padding: [50, 50, 50, 50], + // duration: 900, + constrainResolution: true, + duration: 2000, + }); + + dispatch( + ProjectBuildingGeojsonService( + `${import.meta.env.VITE_API_URL}/projects/${decodedId}/features?task_id=${selectedTask}`, + ), + ); + }, [selectedTask]); + + const taskOnSelect = (properties, feature) => { + dispatch(CoreModules.TaskActions.SetSelectedTask(properties.uid)); + }; + + const setChoropleth = useCallback( + (style, feature, resolution) => { + const stylex = { ...style }; + stylex.fillOpacity = 80; + const getFederal = federalWiseProjectCount?.find((d) => d.code === feature.getProperties().uid); + const getFederalCount = getFederal?.count; + stylex.labelMaxResolution = 1000; + stylex.showLabel = true; + // stylex.labelField = 'district_code'; + // stylex.customLabelText = getFederalName; + const choroplethColor = getChoroplethColor(getFederalCount, legendColorArray); + stylex.fillColor = choroplethColor; + return getStyles({ + style: stylex, + feature, + resolution, + }); + }, + [federalWiseProjectCount], + ); + + map?.on('loadstart', function () { + map.getTargetElement().classList.add('spinner'); + }); + map?.on('loadend', function () { + map.getTargetElement().classList.remove('spinner'); + }); + return ( + + + + {taskBoundaries && ( + + setChoropleth({ ...municipalStyles, lineThickness: 3 }, feature, resolution) + } + geojson={taskBoundaries} + mapOnClick={taskOnSelect} + viewProperties={{ + size: map?.getSize(), + padding: [50, 50, 50, 50], + constrainResolution: true, + duration: 2000, + }} + zoomToLayer + zIndex={5} + /> + )} +
+ } + header={ +

+ No. of Submissions +

+ } + onToggle={() => {}} + className="fmtm-py-0 !fmtm-pb-0 fmtm-rounded-lg hover:fmtm-bg-gray-50" + collapsed={true} + /> +
+ {buildingGeojson && } +
+
+ ); +}; + +export default TaskSubmissionsMap; diff --git a/src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMapLegend.tsx b/src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMapLegend.tsx new file mode 100644 index 0000000000..95307b2d3d --- /dev/null +++ b/src/frontend/src/components/ProjectSubmissions/TaskSubmissionsMapLegend.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +const colorCodes = [ + { color: '#0062AC', min: 130, max: 160 }, + { color: '#4A90D9', min: 100, max: 130 }, + { color: '#7CB2E8', min: 50, max: 100 }, + { color: '#A9D2F3', min: 10, max: 50 }, + { color: '#FF4538', min: 0, max: 0 }, +]; + +const LegendListItem = ({ code }) => ( +
+
+
+ {code.min !== code.max ? ( +

+ {code.min} - {code.max} +

+ ) : ( +

{code.min}

+ )} +
+
+); + +const TaskSubmissionsMapLegend = () => { + return ( +
+
+ {colorCodes.map((code) => ( + + ))} +
+
+ ); +}; + +export default TaskSubmissionsMapLegend; diff --git a/src/frontend/src/components/common/BarChart.tsx b/src/frontend/src/components/common/BarChart.tsx new file mode 100644 index 0000000000..ee0afb474d --- /dev/null +++ b/src/frontend/src/components/common/BarChart.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; + +const CustomBarChart = ({ data, xLabel, yLabel, dataKey, nameKey }) => { + return ( + + + + + + + + + ); +}; + +export default CustomBarChart; diff --git a/src/frontend/src/components/common/CustomTable.tsx b/src/frontend/src/components/common/CustomTable.tsx new file mode 100644 index 0000000000..27c69e1b72 --- /dev/null +++ b/src/frontend/src/components/common/CustomTable.tsx @@ -0,0 +1,315 @@ +/* eslint-disable react/prop-types */ +/* eslint-disable react/no-unknown-property */ +/* eslint-disable react/self-closing-comp */ +/* eslint-disable react/no-unused-prop-types */ +/* eslint-disable react/no-array-index-key */ +/* eslint-disable react/no-unstable-nested-components */ +/* eslint-disable no-unused-vars */ +/* eslint-disable react/jsx-props-no-spreading */ +import React, { Component, Children } from 'react'; +import PropTypes from 'prop-types'; +import CoreModules from '../../shared/CoreModules'; + +type TableHeaderType = { + dataField: string; + dataFormat: (...args: any[]) => {}; + isSubHeader: boolean; + dataHeader: string; + rowcolSpan: any; + headerClassName: string; + containSubHeader: boolean; +}; + +export function TableHeader({ + dataField, + dataFormat, + isSubHeader, + dataHeader, + rowcolSpan, + headerClassName, + containSubHeader, +}: TableHeaderType) { + return ( +
+ ); +} +export default class Table extends Component { + getFields = () => { + const { + props: { data, children }, + } = this; + const numberOfChildren = Children.count(children); + if (numberOfChildren > 0) { + const isSubheaderCollection = Children.map(children, (child) => child?.props?.isSubHeader); + const containSubHeaderCollection = Children.map(children, (child) => child?.props?.containSubHeader); + const fields = Children.map(children, (child) => child?.props?.dataField); + const rowColSpanValue = Children.map(children, (child) => child?.props?.rowcolSpan); + const headClassName = Children.map(children, (child) => child?.props?.headerClassName); + const rowClassName = Children.map(children, (child) => child?.props?.rowClassName); + return { + fields, + rowColSpanValue, + headClassName, + isSubheaderCollection, + containSubHeaderCollection, + rowClassName, + }; + } + return data[0] ? Object.keys(data[0]) : []; + }; + + getHeaderData = (field) => { + const { + props: { children }, + } = this; + const numberOfChildren = Children.count(children); + if (numberOfChildren > 0) { + const tableChildren = Children.toArray(children); + const tableChildWithSameFieldAndDataHeader = tableChildren.find( + (child) => child.props.dataField === field && child.props.dataHeader !== null, + ); + + if (tableChildWithSameFieldAndDataHeader) { + return tableChildWithSameFieldAndDataHeader.props.dataHeader; + } + + return field; + } + + if (typeof field === 'object') return null; + + return field; + }; + + getBodyCellData = (row, field, index) => { + const { + props: { children }, + } = this; + const numberOfChildren = Children.count(children); + if (numberOfChildren > 0) { + const tableChildren = Children.toArray(children); + const tableChildWithSameFieldAndDataFormat = tableChildren.find( + (child) => child.props.dataField === field && child.props.dataFormat !== null, + ); + + if (tableChildWithSameFieldAndDataFormat) { + return tableChildWithSameFieldAndDataFormat.props.dataFormat(row, row[field], index); + } + + return row[field]; + } + + if (typeof row[field] === 'object') return null; + + return row[field]; + }; + + renderHeader = () => { + const { fields, rowColSpanValue, headClassName, isSubheaderCollection } = this.getFields(); + const { headerWidths, data } = this.props; + return fields.map((field, index) => { + const isSubHeader = isSubheaderCollection[index]; + const rowCol = rowColSpanValue[index].split('_'); + return ( + // !isSubHeader && ( + 0 && { + style: { + width: headerWidths[index], + minWidth: headerWidths[index], + maxWidth: headerWidths[index], + }, + })} + className="fmtm-px-5 fmtm-pt-4 fmtm-pb-4 fmtm-bg-black-100 fmtm-align-middle fmtm-text-body-sm fmtm-leading-5 fmtm-text-left fmtm-capitalize fmtm-font-bold fmtm-border-[1px] fmtm-border-[#B9B9B9] fmtm-text-sm fmtm-bg-[#F0F0F0] fmtm-max-w-[11rem]" + > + {this.getHeaderData(field)} + + ); + // ); + }); + }; + + renderSubHeader = () => { + const { fields, headClassName, isSubheaderCollection } = this.getFields(); + const { headerWidths, data } = this.props; + return fields.map((field, index) => { + const isSubHeader = isSubheaderCollection[index]; + return ( + isSubHeader && ( + 0 && { style: { width: headerWidths[index] } })} + className="fmtm-align-middle fmtm-text-body-sm fmtm-leading-4 fmtm-text-left fmtm-capitalize fmtm-font-bold fmtm-border-[1px] fmtm-border-[#B9B9B9]" + > + {this.getHeaderData(field)} + + ) + ); + }); + }; + + renderRow = () => { + const { + props: { data, uniqueKey, onRowClick, trClassName, flag, loading, isLoading }, + } = this; + const { fields, containSubHeaderCollection, rowClassName } = this.getFields(); + if (isLoading === false && fields.length > 0 && data.length === 0) { + return ( + + + No data available. + + + ); + } + if (isLoading) { + return Array.from({ length: 5 }).map((i) => ( + + {fields.map( + (field, ind) => + !containSubHeaderCollection[ind] && ( + + + + ), + )} + + )); + } else { + return data?.map((row, index) => ( + onRowClick(row), + style: { cursor: 'pointer' }, + })} + className={`${trClassName && trClassName(row)} ${ + flag.toLowerCase() === 'dashboard' ? '' : 'hover:fmtm-bg-active_bg' + } fmtm-cursor-pointer fmtm-ease-in fmtm-duration-100 fmtm-h-[50px] + fmtm-items-baseline fmtm-relative fmtm-bg-white`} + > + {fields.map( + (field, ind) => + !containSubHeaderCollection[ind] && ( + + {this.getBodyCellData(row, field, index)} + + ), + )} + + )); + } + }; + + render() { + const { showHeader, className, style, tableClassName, loadMoreRef, parentloadMoreRef, scrollElement } = this.props; + return ( +
+ + {showHeader && ( + + {this.renderHeader()} + {this.renderSubHeader()} + + )} + {this.renderRow()} +
+
+ {scrollElement} +
+ ); + } +} + +TableHeader.defaultProps = { + dataField: '', + dataFormat: null, + dataHeader: null, + rowcolSpan: '', + headerClassName: '', + isSubHeader: false, + containSubHeader: false, + rowClassName: '', +}; + +TableHeader.propTypes = { + dataField: PropTypes.string, + dataFormat: PropTypes.func, + dataHeader: PropTypes.func, + rowcolSpan: PropTypes.string, + headerClassName: PropTypes.string, + isSubHeader: PropTypes.bool, + containSubHeader: PropTypes.bool, + rowClassName: PropTypes.string, +}; + +Table.defaultProps = { + uniqueKey: '', + children: null, + showHeader: true, + onRowClick: null, + className: '', + tableClassName: '', + style: {}, + headerWidths: [], + loadMoreRef: null, + parentloadMoreRef: null, + rowColSpanNum: [], + rowcolSpanName: [], + headClassName: [], + trClassName: '', + scrollElement: () => {}, + flag: '', +}; + +Table.propTypes = { + uniqueKey: PropTypes.string, + data: PropTypes.array.isRequired, + children: PropTypes.node, + showHeader: PropTypes.bool, + onRowClick: PropTypes.func, + className: PropTypes.string, + tableClassName: PropTypes.string, + style: PropTypes.object, + headerWidths: PropTypes.array, + loadMoreRef: PropTypes.object, + parentloadMoreRef: PropTypes.object, + rowColSpanNum: PropTypes.array, + headClassName: PropTypes.array, + rowcolSpanName: PropTypes.array, + trClassName: PropTypes.string, + scrollElement: PropTypes.func, + flag: PropTypes.string, +}; diff --git a/src/frontend/src/components/common/LineChart.tsx b/src/frontend/src/components/common/LineChart.tsx new file mode 100644 index 0000000000..45f5920b82 --- /dev/null +++ b/src/frontend/src/components/common/LineChart.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; + +const CustomLineChart = ({ data, xAxisDataKey, lineOneKey, lineTwoKey, xLabel, yLabel }) => { + return ( + + + + {xLabel && ( + + )} + {yLabel && ( + + )} + + + + + + + ); +}; + +export default CustomLineChart; diff --git a/src/frontend/src/components/common/PieChart.tsx b/src/frontend/src/components/common/PieChart.tsx new file mode 100644 index 0000000000..7b54067e7e --- /dev/null +++ b/src/frontend/src/components/common/PieChart.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { PieChart, Pie, Sector, Cell, ResponsiveContainer, Tooltip } from 'recharts'; + +const COLORS = ['#F19C3C', '#D73F3F', '#FFB74D', '#EC407A']; + +const RADIAN = Math.PI / 180; +const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent, index }) => { + const radius = innerRadius + (outerRadius - innerRadius) * 0.2; + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + + return ( + cx ? 'start' : 'end'} dominantBaseline="central"> + {`${(percent * 100).toFixed(0)}%`} + + ); +}; + +const CustomPieChart = ({ data, dataKey, nameKey }) => { + return ( + + + + {data.map((entry, index) => ( + + ))} + + + + + ); +}; + +export default CustomPieChart; diff --git a/src/frontend/src/components/common/Select.tsx b/src/frontend/src/components/common/Select.tsx index 1e12d733b6..b795a6df8b 100644 --- a/src/frontend/src/components/common/Select.tsx +++ b/src/frontend/src/components/common/Select.tsx @@ -112,11 +112,11 @@ interface ICustomSelect { placeholder: string; data: any; dataKey: string; - value?: string; + value?: string | null; valueKey: string; label: string; onValueChange: (value: string | null | number) => void; - errorMsg: string; + errorMsg?: string; className: string; } @@ -133,8 +133,8 @@ export const CustomSelect = ({ className, }: ICustomSelect) => { return ( -
- {title &&

{title}

} +
+ {title &&

{title}

}