| @@ -14,6 +14,7 @@ | |||||
| "@faker-js/faker": "^8.4.1", | "@faker-js/faker": "^8.4.1", | ||||
| "@fontsource/inter": "^5.0.16", | "@fontsource/inter": "^5.0.16", | ||||
| "@fontsource/plus-jakarta-sans": "^5.0.18", | "@fontsource/plus-jakarta-sans": "^5.0.18", | ||||
| "@fullcalendar/react": "^6.1.11", | |||||
| "@mui/icons-material": "^5.15.0", | "@mui/icons-material": "^5.15.0", | ||||
| "@mui/material": "^5.15.0", | "@mui/material": "^5.15.0", | ||||
| "@mui/material-nextjs": "^5.15.0", | "@mui/material-nextjs": "^5.15.0", | ||||
| @@ -21,13 +22,14 @@ | |||||
| "@mui/x-date-pickers": "^6.18.7", | "@mui/x-date-pickers": "^6.18.7", | ||||
| "@unly/universal-language-detector": "^2.0.3", | "@unly/universal-language-detector": "^2.0.3", | ||||
| "apexcharts": "^3.45.2", | "apexcharts": "^3.45.2", | ||||
| "axios": "^1.6.8", | |||||
| "date-holidays": "^3.23.11", | |||||
| "dayjs": "^1.11.10", | "dayjs": "^1.11.10", | ||||
| "fullcalendar": "^6.1.11", | |||||
| "i18next": "^23.7.11", | "i18next": "^23.7.11", | ||||
| "i18next-resources-to-backend": "^1.2.0", | "i18next-resources-to-backend": "^1.2.0", | ||||
| "lodash": "^4.17.21", | "lodash": "^4.17.21", | ||||
| "next": "14.0.4", | "next": "14.0.4", | ||||
| "next-auth": "^4.24.5", | |||||
| "next-auth": "^4.24.7", | |||||
| "next-pwa": "^5.6.0", | "next-pwa": "^5.6.0", | ||||
| "react": "^18", | "react": "^18", | ||||
| "react-apexcharts": "^1.4.1", | "react-apexcharts": "^1.4.1", | ||||
| @@ -2081,6 +2083,79 @@ | |||||
| "tslib": "^2.4.0" | "tslib": "^2.4.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/@fullcalendar/core": { | |||||
| "version": "6.1.11", | |||||
| "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.11.tgz", | |||||
| "integrity": "sha512-TjG7c8sUz+Vkui2FyCNJ+xqyu0nq653Ibe99A66LoW95oBo6tVhhKIaG1Wh0GVKymYiqAQN/OEdYTuj4ay27kA==", | |||||
| "dependencies": { | |||||
| "preact": "~10.12.1" | |||||
| } | |||||
| }, | |||||
| "node_modules/@fullcalendar/core/node_modules/preact": { | |||||
| "version": "10.12.1", | |||||
| "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", | |||||
| "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", | |||||
| "funding": { | |||||
| "type": "opencollective", | |||||
| "url": "https://opencollective.com/preact" | |||||
| } | |||||
| }, | |||||
| "node_modules/@fullcalendar/daygrid": { | |||||
| "version": "6.1.11", | |||||
| "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.11.tgz", | |||||
| "integrity": "sha512-hF5jJB7cgUIxWD5MVjj8IU407HISyLu7BWXcEIuTytkfr8oolOXeCazqnnjmRbnFOncoJQVstTtq6SIhaT32Xg==", | |||||
| "peerDependencies": { | |||||
| "@fullcalendar/core": "~6.1.11" | |||||
| } | |||||
| }, | |||||
| "node_modules/@fullcalendar/interaction": { | |||||
| "version": "6.1.11", | |||||
| "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.11.tgz", | |||||
| "integrity": "sha512-ynOKjzuPwEAMgTQ6R/Z2zvzIIqG4p8/Qmnhi1q0vzPZZxSIYx3rlZuvpEK2WGBZZ1XEafDOP/LGfbWoNZe+qdg==", | |||||
| "peerDependencies": { | |||||
| "@fullcalendar/core": "~6.1.11" | |||||
| } | |||||
| }, | |||||
| "node_modules/@fullcalendar/list": { | |||||
| "version": "6.1.11", | |||||
| "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.11.tgz", | |||||
| "integrity": "sha512-9Qx8uvik9pXD12u50FiHwNzlHv4wkhfsr+r03ycahW7vEeIAKCsIZGTkUfFP+96I5wHihrfLazu1cFQG4MPiuw==", | |||||
| "peerDependencies": { | |||||
| "@fullcalendar/core": "~6.1.11" | |||||
| } | |||||
| }, | |||||
| "node_modules/@fullcalendar/multimonth": { | |||||
| "version": "6.1.11", | |||||
| "resolved": "https://registry.npmjs.org/@fullcalendar/multimonth/-/multimonth-6.1.11.tgz", | |||||
| "integrity": "sha512-7DbPC+AAlaKnquGVdw1Z85Q3nSZ4GZ1NcVIk4k7bLnqDlntwHPPsrDlSIzUWKcN0q5/u7jQHm4PU1m3LAl70Sg==", | |||||
| "dependencies": { | |||||
| "@fullcalendar/daygrid": "~6.1.11" | |||||
| }, | |||||
| "peerDependencies": { | |||||
| "@fullcalendar/core": "~6.1.11" | |||||
| } | |||||
| }, | |||||
| "node_modules/@fullcalendar/react": { | |||||
| "version": "6.1.11", | |||||
| "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.11.tgz", | |||||
| "integrity": "sha512-Og0Tv0OiglTFp+b++yRyEhAeWnAmKkMLQ3iS0eJE1KDEov6QqGkoO+dUG4x8zp2w55IJqzik/a9iHi0s3oQDbA==", | |||||
| "peerDependencies": { | |||||
| "@fullcalendar/core": "~6.1.11", | |||||
| "react": "^16.7.0 || ^17 || ^18", | |||||
| "react-dom": "^16.7.0 || ^17 || ^18" | |||||
| } | |||||
| }, | |||||
| "node_modules/@fullcalendar/timegrid": { | |||||
| "version": "6.1.11", | |||||
| "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.11.tgz", | |||||
| "integrity": "sha512-0seUHK/ferH89IeuCvV4Bib0zWjgK0nsptNdmAc9wDBxD/d9hm5Mdti0URJX6bDoRtsSfRDu5XsRcrzwoc+AUQ==", | |||||
| "dependencies": { | |||||
| "@fullcalendar/daygrid": "~6.1.11" | |||||
| }, | |||||
| "peerDependencies": { | |||||
| "@fullcalendar/core": "~6.1.11" | |||||
| } | |||||
| }, | |||||
| "node_modules/@humanwhocodes/config-array": { | "node_modules/@humanwhocodes/config-array": { | ||||
| "version": "0.11.14", | "version": "0.11.14", | ||||
| "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", | "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", | ||||
| @@ -3648,8 +3723,7 @@ | |||||
| "node_modules/argparse": { | "node_modules/argparse": { | ||||
| "version": "2.0.1", | "version": "2.0.1", | ||||
| "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", | "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", | ||||
| "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", | |||||
| "dev": true | |||||
| "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" | |||||
| }, | }, | ||||
| "node_modules/aria-query": { | "node_modules/aria-query": { | ||||
| "version": "5.3.0", | "version": "5.3.0", | ||||
| @@ -3855,6 +3929,14 @@ | |||||
| "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", | "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", | ||||
| "dev": true | "dev": true | ||||
| }, | }, | ||||
| "node_modules/astronomia": { | |||||
| "version": "4.1.1", | |||||
| "resolved": "https://registry.npmjs.org/astronomia/-/astronomia-4.1.1.tgz", | |||||
| "integrity": "sha512-TcJD9lUC5eAo0/Ji7rnQauX/yQbi0yZWM+JsNr77W3OA5fsrgvuFgubLMFwfw4VlZ29cu9dG/yfJbfvuTSftjg==", | |||||
| "engines": { | |||||
| "node": ">=12.0.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/async": { | "node_modules/async": { | ||||
| "version": "3.2.5", | "version": "3.2.5", | ||||
| "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", | "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", | ||||
| @@ -3869,11 +3951,6 @@ | |||||
| "has-symbols": "^1.0.3" | "has-symbols": "^1.0.3" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/asynckit": { | |||||
| "version": "0.4.0", | |||||
| "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", | |||||
| "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" | |||||
| }, | |||||
| "node_modules/at-least-node": { | "node_modules/at-least-node": { | ||||
| "version": "1.0.0", | "version": "1.0.0", | ||||
| "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", | "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", | ||||
| @@ -3942,16 +4019,6 @@ | |||||
| "node": ">=4" | "node": ">=4" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/axios": { | |||||
| "version": "1.6.8", | |||||
| "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", | |||||
| "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", | |||||
| "dependencies": { | |||||
| "follow-redirects": "^1.15.6", | |||||
| "form-data": "^4.0.0", | |||||
| "proxy-from-env": "^1.1.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/axobject-query": { | "node_modules/axobject-query": { | ||||
| "version": "3.2.1", | "version": "3.2.1", | ||||
| "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", | "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", | ||||
| @@ -4136,6 +4203,17 @@ | |||||
| "node": ">=10.16.0" | "node": ">=10.16.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/caldate": { | |||||
| "version": "2.0.5", | |||||
| "resolved": "https://registry.npmjs.org/caldate/-/caldate-2.0.5.tgz", | |||||
| "integrity": "sha512-JndhrUuDuE975KUhFqJaVR1OQkCHZqpOrJur/CFXEIEhWhBMjxO85cRSK8q4FW+B+yyPq6GYua2u4KvNzTcq0w==", | |||||
| "dependencies": { | |||||
| "moment-timezone": "^0.5.43" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=12.0.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/call-bind": { | "node_modules/call-bind": { | ||||
| "version": "1.0.7", | "version": "1.0.7", | ||||
| "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", | "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", | ||||
| @@ -4337,17 +4415,6 @@ | |||||
| "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", | ||||
| "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" | "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" | ||||
| }, | }, | ||||
| "node_modules/combined-stream": { | |||||
| "version": "1.0.8", | |||||
| "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", | |||||
| "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", | |||||
| "dependencies": { | |||||
| "delayed-stream": "~1.0.0" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">= 0.8" | |||||
| } | |||||
| }, | |||||
| "node_modules/commander": { | "node_modules/commander": { | ||||
| "version": "4.1.1", | "version": "4.1.1", | ||||
| "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", | "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", | ||||
| @@ -4489,6 +4556,68 @@ | |||||
| "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", | "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", | ||||
| "dev": true | "dev": true | ||||
| }, | }, | ||||
| "node_modules/date-bengali-revised": { | |||||
| "version": "2.0.2", | |||||
| "resolved": "https://registry.npmjs.org/date-bengali-revised/-/date-bengali-revised-2.0.2.tgz", | |||||
| "integrity": "sha512-q9iDru4+TSA9k4zfm0CFHJj6nBsxP7AYgWC/qodK/i7oOIlj5K2z5IcQDtESfs/Qwqt/xJYaP86tkazd/vRptg==", | |||||
| "engines": { | |||||
| "node": ">=12.0.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/date-chinese": { | |||||
| "version": "2.1.4", | |||||
| "resolved": "https://registry.npmjs.org/date-chinese/-/date-chinese-2.1.4.tgz", | |||||
| "integrity": "sha512-WY+6+Qw92ZGWFvGtStmNQHEYpNa87b8IAQ5T8VKt4wqrn24lBXyyBnWI5jAIyy7h/KVwJZ06bD8l/b7yss82Ww==", | |||||
| "dependencies": { | |||||
| "astronomia": "^4.1.0" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=12.0.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/date-easter": { | |||||
| "version": "1.0.3", | |||||
| "resolved": "https://registry.npmjs.org/date-easter/-/date-easter-1.0.3.tgz", | |||||
| "integrity": "sha512-aOViyIgpM4W0OWUiLqivznwTtuMlD/rdUWhc5IatYnplhPiWrLv75cnifaKYhmQwUBLAMWLNG4/9mlLIbXoGBQ==", | |||||
| "engines": { | |||||
| "node": ">=12.0.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/date-holidays": { | |||||
| "version": "3.23.12", | |||||
| "resolved": "https://registry.npmjs.org/date-holidays/-/date-holidays-3.23.12.tgz", | |||||
| "integrity": "sha512-DLyP0PPVgNydgaTAY7SBS26+5h3KO1Z8FRKiAROkz0hAGNBLGAM48SMabfVa2ACRHH7Qw3LXYvlJkt9oa9WePA==", | |||||
| "dependencies": { | |||||
| "date-holidays-parser": "^3.4.4", | |||||
| "js-yaml": "^4.1.0", | |||||
| "lodash": "^4.17.21", | |||||
| "prepin": "^1.0.3" | |||||
| }, | |||||
| "bin": { | |||||
| "holidays2json": "scripts/holidays2json.cjs" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=12.0.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/date-holidays-parser": { | |||||
| "version": "3.4.4", | |||||
| "resolved": "https://registry.npmjs.org/date-holidays-parser/-/date-holidays-parser-3.4.4.tgz", | |||||
| "integrity": "sha512-R5aO4oT8H51ZKdvApqHrqYEiNBrqT6tRj2PFXNcZfqMI4nxY7KKKly0ZsmquR5gY+x9ldKR8SAMdozzIInaoXg==", | |||||
| "dependencies": { | |||||
| "astronomia": "^4.1.1", | |||||
| "caldate": "^2.0.5", | |||||
| "date-bengali-revised": "^2.0.2", | |||||
| "date-chinese": "^2.1.4", | |||||
| "date-easter": "^1.0.2", | |||||
| "deepmerge": "^4.3.1", | |||||
| "jalaali-js": "^1.2.6", | |||||
| "moment-timezone": "^0.5.43" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=12.0.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/dayjs": { | "node_modules/dayjs": { | ||||
| "version": "1.11.10", | "version": "1.11.10", | ||||
| "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", | "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", | ||||
| @@ -4626,14 +4755,6 @@ | |||||
| "rimraf": "bin.js" | "rimraf": "bin.js" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/delayed-stream": { | |||||
| "version": "1.0.0", | |||||
| "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", | |||||
| "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", | |||||
| "engines": { | |||||
| "node": ">=0.4.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/dequal": { | "node_modules/dequal": { | ||||
| "version": "2.0.3", | "version": "2.0.3", | ||||
| "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", | "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", | ||||
| @@ -5706,25 +5827,6 @@ | |||||
| "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", | "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", | ||||
| "dev": true | "dev": true | ||||
| }, | }, | ||||
| "node_modules/follow-redirects": { | |||||
| "version": "1.15.6", | |||||
| "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", | |||||
| "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", | |||||
| "funding": [ | |||||
| { | |||||
| "type": "individual", | |||||
| "url": "https://github.com/sponsors/RubenVerborgh" | |||||
| } | |||||
| ], | |||||
| "engines": { | |||||
| "node": ">=4.0" | |||||
| }, | |||||
| "peerDependenciesMeta": { | |||||
| "debug": { | |||||
| "optional": true | |||||
| } | |||||
| } | |||||
| }, | |||||
| "node_modules/for-each": { | "node_modules/for-each": { | ||||
| "version": "0.3.3", | "version": "0.3.3", | ||||
| "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", | "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", | ||||
| @@ -5802,6 +5904,19 @@ | |||||
| "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/fullcalendar": { | |||||
| "version": "6.1.11", | |||||
| "resolved": "https://registry.npmjs.org/fullcalendar/-/fullcalendar-6.1.11.tgz", | |||||
| "integrity": "sha512-OOlx/+yFn9k5LnucRzcDmShONBecOVKNN6HHWe8jl7hGzQBmkxO+iD6eBokO6p24EY1PjATqhZkhJqHiCUgx3A==", | |||||
| "dependencies": { | |||||
| "@fullcalendar/core": "~6.1.11", | |||||
| "@fullcalendar/daygrid": "~6.1.11", | |||||
| "@fullcalendar/interaction": "~6.1.11", | |||||
| "@fullcalendar/list": "~6.1.11", | |||||
| "@fullcalendar/multimonth": "~6.1.11", | |||||
| "@fullcalendar/timegrid": "~6.1.11" | |||||
| } | |||||
| }, | |||||
| "node_modules/function-bind": { | "node_modules/function-bind": { | ||||
| "version": "1.1.2", | "version": "1.1.2", | ||||
| "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", | ||||
| @@ -6755,6 +6870,11 @@ | |||||
| "node": ">=8" | "node": ">=8" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/jalaali-js": { | |||||
| "version": "1.2.6", | |||||
| "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.6.tgz", | |||||
| "integrity": "sha512-io974va+Qyu+UfuVX3UIAgJlxLhAMx9Y8VMfh+IG00Js7hXQo1qNQuwSiSa0xxco0SVgx5HWNkaiCcV+aZ8WPw==" | |||||
| }, | |||||
| "node_modules/jest-worker": { | "node_modules/jest-worker": { | ||||
| "version": "27.5.1", | "version": "27.5.1", | ||||
| "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", | "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", | ||||
| @@ -6800,9 +6920,9 @@ | |||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/jose": { | "node_modules/jose": { | ||||
| "version": "4.15.4", | |||||
| "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", | |||||
| "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==", | |||||
| "version": "4.15.5", | |||||
| "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", | |||||
| "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", | |||||
| "funding": { | "funding": { | ||||
| "url": "https://github.com/sponsors/panva" | "url": "https://github.com/sponsors/panva" | ||||
| } | } | ||||
| @@ -6816,7 +6936,6 @@ | |||||
| "version": "4.1.0", | "version": "4.1.0", | ||||
| "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", | "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", | ||||
| "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", | "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", | ||||
| "dev": true, | |||||
| "dependencies": { | "dependencies": { | ||||
| "argparse": "^2.0.1" | "argparse": "^2.0.1" | ||||
| }, | }, | ||||
| @@ -7135,6 +7254,7 @@ | |||||
| "version": "1.52.0", | "version": "1.52.0", | ||||
| "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", | ||||
| "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", | ||||
| "peer": true, | |||||
| "engines": { | "engines": { | ||||
| "node": ">= 0.6" | "node": ">= 0.6" | ||||
| } | } | ||||
| @@ -7143,6 +7263,7 @@ | |||||
| "version": "2.1.35", | "version": "2.1.35", | ||||
| "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", | ||||
| "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", | ||||
| "peer": true, | |||||
| "dependencies": { | "dependencies": { | ||||
| "mime-db": "1.52.0" | "mime-db": "1.52.0" | ||||
| }, | }, | ||||
| @@ -7183,6 +7304,25 @@ | |||||
| "node": ">=16 || 14 >=14.17" | "node": ">=16 || 14 >=14.17" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/moment": { | |||||
| "version": "2.30.1", | |||||
| "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", | |||||
| "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", | |||||
| "engines": { | |||||
| "node": "*" | |||||
| } | |||||
| }, | |||||
| "node_modules/moment-timezone": { | |||||
| "version": "0.5.45", | |||||
| "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz", | |||||
| "integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==", | |||||
| "dependencies": { | |||||
| "moment": "^2.29.4" | |||||
| }, | |||||
| "engines": { | |||||
| "node": "*" | |||||
| } | |||||
| }, | |||||
| "node_modules/ms": { | "node_modules/ms": { | ||||
| "version": "2.1.2", | "version": "2.1.2", | ||||
| "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", | ||||
| @@ -7275,14 +7415,14 @@ | |||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/next-auth": { | "node_modules/next-auth": { | ||||
| "version": "4.24.6", | |||||
| "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.6.tgz", | |||||
| "integrity": "sha512-djQt3ZEaWEIxcsuh3HTW2uuzLfXMRjHH+ugAsichlQSbH4iA5MRcgMA2HvTNvsDTDLh44tyU72+/gWsxgTbAKg==", | |||||
| "version": "4.24.7", | |||||
| "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.7.tgz", | |||||
| "integrity": "sha512-iChjE8ov/1K/z98gdKbn2Jw+2vLgJtVV39X+rCP5SGnVQuco7QOr19FRNGMIrD8d3LYhHWV9j9sKLzq1aDWWQQ==", | |||||
| "dependencies": { | "dependencies": { | ||||
| "@babel/runtime": "^7.20.13", | "@babel/runtime": "^7.20.13", | ||||
| "@panva/hkdf": "^1.0.2", | "@panva/hkdf": "^1.0.2", | ||||
| "cookie": "^0.5.0", | "cookie": "^0.5.0", | ||||
| "jose": "^4.11.4", | |||||
| "jose": "^4.15.5", | |||||
| "oauth": "^0.9.15", | "oauth": "^0.9.15", | ||||
| "openid-client": "^5.4.0", | "openid-client": "^5.4.0", | ||||
| "preact": "^10.6.3", | "preact": "^10.6.3", | ||||
| @@ -7993,6 +8133,14 @@ | |||||
| "node": ">= 0.8.0" | "node": ">= 0.8.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/prepin": { | |||||
| "version": "1.0.3", | |||||
| "resolved": "https://registry.npmjs.org/prepin/-/prepin-1.0.3.tgz", | |||||
| "integrity": "sha512-0XL2hreherEEvUy0fiaGEfN/ioXFV+JpImqIzQjxk6iBg4jQ2ARKqvC4+BmRD8w/pnpD+lbxvh0Ub+z7yBEjvA==", | |||||
| "bin": { | |||||
| "prepin": "bin/prepin.js" | |||||
| } | |||||
| }, | |||||
| "node_modules/prettier": { | "node_modules/prettier": { | ||||
| "version": "3.1.1", | "version": "3.1.1", | ||||
| "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", | ||||
| @@ -8057,11 +8205,6 @@ | |||||
| "react-is": "^16.13.1" | "react-is": "^16.13.1" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/proxy-from-env": { | |||||
| "version": "1.1.0", | |||||
| "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", | |||||
| "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" | |||||
| }, | |||||
| "node_modules/punycode": { | "node_modules/punycode": { | ||||
| "version": "2.3.1", | "version": "2.3.1", | ||||
| "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", | "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", | ||||
| @@ -15,6 +15,7 @@ | |||||
| "@faker-js/faker": "^8.4.1", | "@faker-js/faker": "^8.4.1", | ||||
| "@fontsource/inter": "^5.0.16", | "@fontsource/inter": "^5.0.16", | ||||
| "@fontsource/plus-jakarta-sans": "^5.0.18", | "@fontsource/plus-jakarta-sans": "^5.0.18", | ||||
| "@fullcalendar/react": "^6.1.11", | |||||
| "@mui/icons-material": "^5.15.0", | "@mui/icons-material": "^5.15.0", | ||||
| "@mui/material": "^5.15.0", | "@mui/material": "^5.15.0", | ||||
| "@mui/material-nextjs": "^5.15.0", | "@mui/material-nextjs": "^5.15.0", | ||||
| @@ -22,12 +23,14 @@ | |||||
| "@mui/x-date-pickers": "^6.18.7", | "@mui/x-date-pickers": "^6.18.7", | ||||
| "@unly/universal-language-detector": "^2.0.3", | "@unly/universal-language-detector": "^2.0.3", | ||||
| "apexcharts": "^3.45.2", | "apexcharts": "^3.45.2", | ||||
| "date-holidays": "^3.23.11", | |||||
| "dayjs": "^1.11.10", | "dayjs": "^1.11.10", | ||||
| "fullcalendar": "^6.1.11", | |||||
| "i18next": "^23.7.11", | "i18next": "^23.7.11", | ||||
| "i18next-resources-to-backend": "^1.2.0", | "i18next-resources-to-backend": "^1.2.0", | ||||
| "lodash": "^4.17.21", | "lodash": "^4.17.21", | ||||
| "next": "14.0.4", | "next": "14.0.4", | ||||
| "next-auth": "^4.24.5", | |||||
| "next-auth": "^4.24.7", | |||||
| "next-pwa": "^5.6.0", | "next-pwa": "^5.6.0", | ||||
| "react": "^18", | "react": "^18", | ||||
| "react-apexcharts": "^1.4.1", | "react-apexcharts": "^1.4.1", | ||||
| @@ -2,10 +2,10 @@ import { Metadata } from "next"; | |||||
| import { Suspense } from "react"; | import { Suspense } from "react"; | ||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import { fetchProjects } from "@/app/api/projects"; | import { fetchProjects } from "@/app/api/projects"; | ||||
| import GenerateEX02ProjectCashFlowReport from "@/components/GenerateEX02ProjectCashFlowReport"; | |||||
| import GenerateProjectCashFlowReport from "@/components/GenerateProjectCashFlowReport"; | |||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| title: "EX02 - Project Cash Flow Report", | |||||
| title: "Project Cash Flow Report", | |||||
| }; | }; | ||||
| const ProjectCashFlowReport: React.FC = async () => { | const ProjectCashFlowReport: React.FC = async () => { | ||||
| @@ -14,8 +14,8 @@ const ProjectCashFlowReport: React.FC = async () => { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <I18nProvider namespaces={["report", "common"]}> | <I18nProvider namespaces={["report", "common"]}> | ||||
| <Suspense fallback={<GenerateEX02ProjectCashFlowReport.Loading />}> | |||||
| <GenerateEX02ProjectCashFlowReport /> | |||||
| <Suspense fallback={<GenerateProjectCashFlowReport.Loading />}> | |||||
| <GenerateProjectCashFlowReport /> | |||||
| </Suspense> | </Suspense> | ||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| @@ -1,15 +1,36 @@ | |||||
| import { Metadata } from "next"; | import { Metadata } from "next"; | ||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import UserWorkspacePage from "@/components/UserWorkspacePage"; | import UserWorkspacePage from "@/components/UserWorkspacePage"; | ||||
| import { | |||||
| fetchLeaveTypes, | |||||
| fetchLeaves, | |||||
| fetchTimesheets, | |||||
| } from "@/app/api/timesheets"; | |||||
| import { authOptions } from "@/config/authConfig"; | |||||
| import { getServerSession } from "next-auth"; | |||||
| import { | |||||
| fetchAssignedProjects, | |||||
| fetchProjectWithTasks, | |||||
| } from "@/app/api/projects"; | |||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| title: "User Workspace", | title: "User Workspace", | ||||
| }; | }; | ||||
| const Home: React.FC = async () => { | const Home: React.FC = async () => { | ||||
| const session = await getServerSession(authOptions); | |||||
| // Get name for caching | |||||
| const username = session!.user!.name!; | |||||
| fetchTimesheets(username); | |||||
| fetchAssignedProjects(username); | |||||
| fetchLeaves(username); | |||||
| fetchLeaveTypes(); | |||||
| fetchProjectWithTasks(); | |||||
| return ( | return ( | ||||
| <I18nProvider namespaces={["home"]}> | <I18nProvider namespaces={["home"]}> | ||||
| <UserWorkspacePage /> | |||||
| <UserWorkspacePage username={username} /> | |||||
| </I18nProvider> | </I18nProvider> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -5,7 +5,7 @@ import Button from "@mui/material/Button"; | |||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import Link from "next/link"; | import Link from "next/link"; | ||||
| import CreateInvoice from "@/components/CreateInvoice"; | |||||
| import CreateInvoice from "@/components/CreateInvoice_forGen"; | |||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| title: "Create Invoice", | title: "Create Invoice", | ||||
| @@ -31,10 +31,10 @@ export default async function MainLayout({ | |||||
| padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, | padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, | ||||
| }} | }} | ||||
| > | > | ||||
| <Stack spacing={2}> | |||||
| <Breadcrumb /> | |||||
| {children} | |||||
| </Stack> | |||||
| <Stack spacing={2}> | |||||
| <Breadcrumb /> | |||||
| {children} | |||||
| </Stack> | |||||
| </Box> | </Box> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -43,7 +43,7 @@ const Projects: React.FC = async () => { | |||||
| <> | <> | ||||
| <Typography variant="h4">{t("Create Project")}</Typography> | <Typography variant="h4">{t("Create Project")}</Typography> | ||||
| <I18nProvider namespaces={["projects"]}> | <I18nProvider namespaces={["projects"]}> | ||||
| <CreateProject /> | |||||
| <CreateProject isEditMode={false} /> | |||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -0,0 +1,17 @@ | |||||
| import { getServerI18n } from "@/i18n"; | |||||
| import { Stack, Typography, Link } from "@mui/material"; | |||||
| import NextLink from "next/link"; | |||||
| export default async function NotFound() { | |||||
| const { t } = await getServerI18n("projects", "common"); | |||||
| return ( | |||||
| <Stack spacing={2}> | |||||
| <Typography variant="h4">{t("Not Found")}</Typography> | |||||
| <Typography variant="body1">{t("The project was not found!")}</Typography> | |||||
| <Link href="/projects" component={NextLink} variant="body2"> | |||||
| {t("Return to all projects")} | |||||
| </Link> | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| @@ -0,0 +1,74 @@ | |||||
| import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | |||||
| import { fetchGrades } from "@/app/api/grades"; | |||||
| import { | |||||
| fetchProjectBuildingTypes, | |||||
| fetchProjectCategories, | |||||
| fetchProjectContractTypes, | |||||
| fetchProjectDetails, | |||||
| fetchProjectFundingTypes, | |||||
| fetchProjectLocationTypes, | |||||
| fetchProjectServiceTypes, | |||||
| fetchProjectWorkNatures, | |||||
| } from "@/app/api/projects"; | |||||
| import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; | |||||
| import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||||
| import { ServerFetchError } from "@/app/utils/fetchUtil"; | |||||
| import CreateProject from "@/components/CreateProject"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import { isArray } from "lodash"; | |||||
| import { Metadata } from "next"; | |||||
| import { notFound } from "next/navigation"; | |||||
| interface Props { | |||||
| searchParams: { [key: string]: string | string[] | undefined }; | |||||
| } | |||||
| export const metadata: Metadata = { | |||||
| title: "Edit Project", | |||||
| }; | |||||
| const Projects: React.FC<Props> = async ({ searchParams }) => { | |||||
| const { t } = await getServerI18n("projects"); | |||||
| // Assume projectId is string here | |||||
| const projectId = searchParams["id"]; | |||||
| if (!projectId || isArray(projectId)) { | |||||
| notFound(); | |||||
| } | |||||
| // Preload necessary dependencies | |||||
| fetchAllTasks(); | |||||
| fetchTaskTemplates(); | |||||
| fetchProjectCategories(); | |||||
| fetchProjectContractTypes(); | |||||
| fetchProjectFundingTypes(); | |||||
| fetchProjectLocationTypes(); | |||||
| fetchProjectServiceTypes(); | |||||
| fetchProjectBuildingTypes(); | |||||
| fetchProjectWorkNatures(); | |||||
| fetchAllCustomers(); | |||||
| fetchAllSubsidiaries(); | |||||
| fetchGrades(); | |||||
| preloadTeamLeads(); | |||||
| preloadStaff(); | |||||
| try { | |||||
| await fetchProjectDetails(projectId); | |||||
| } catch (e) { | |||||
| if (e instanceof ServerFetchError && e.response?.status === 404) { | |||||
| notFound(); | |||||
| } | |||||
| } | |||||
| return ( | |||||
| <> | |||||
| <Typography variant="h4">{t("Edit Project")}</Typography> | |||||
| <I18nProvider namespaces={["projects"]}> | |||||
| <CreateProject isEditMode projectId={projectId} /> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default Projects; | |||||
| @@ -0,0 +1,53 @@ | |||||
| import { preloadClaims } from "@/app/api/claims"; | |||||
| import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; | |||||
| import ChangePassword from "@/components/ChangePassword"; | |||||
| import StaffSearch from "@/components/StaffSearch"; | |||||
| import TeamSearch from "@/components/TeamSearch"; | |||||
| import UserGroupSearch from "@/components/UserGroupSearch"; | |||||
| import UserSearch from "@/components/UserSearch"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| import Add from "@mui/icons-material/Add"; | |||||
| import Button from "@mui/material/Button"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import { Metadata } from "next"; | |||||
| import Link from "next/link"; | |||||
| import { Suspense } from "react"; | |||||
| export const metadata: Metadata = { | |||||
| title: "Change Password", | |||||
| }; | |||||
| const ChangePasswordPage: React.FC = async () => { | |||||
| const { t } = await getServerI18n("User Group"); | |||||
| // preloadTeamLeads(); | |||||
| // preloadStaff(); | |||||
| return ( | |||||
| <> | |||||
| <Stack | |||||
| direction="row" | |||||
| justifyContent="space-between" | |||||
| flexWrap="wrap" | |||||
| rowGap={2} | |||||
| > | |||||
| <Typography variant="h4" marginInlineEnd={2}> | |||||
| {t("Change Password")} | |||||
| </Typography> | |||||
| </Stack> | |||||
| {/* <I18nProvider namespaces={["User Group", "common"]}> | |||||
| <Suspense fallback={<UserGroupSearch.Loading />}> | |||||
| <UserGroupSearch /> | |||||
| </Suspense> | |||||
| </I18nProvider> */} | |||||
| <I18nProvider namespaces={["User Group", "common"]}> | |||||
| <Suspense fallback={<ChangePassword.Loading />}> | |||||
| <ChangePassword /> | |||||
| </Suspense> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default ChangePasswordPage; | |||||
| @@ -0,0 +1,31 @@ | |||||
| import CreateDepartment from "@/components/CreateDepartment"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import { Metadata } from "next"; | |||||
| export const metadata: Metadata = { | |||||
| title: "Create Department", | |||||
| }; | |||||
| interface Props { | |||||
| searchParams: { [key: string]: string | undefined }; | |||||
| } | |||||
| const Department: React.FC<Props> = async ({searchParams}) => { | |||||
| const { t } = await getServerI18n("departments"); | |||||
| // Preload necessary dependencies | |||||
| // Assume projectId is string here | |||||
| const departmentId = searchParams["id"]; | |||||
| return ( | |||||
| <> | |||||
| <Typography variant="h4">{t("Create Department")}</Typography> | |||||
| <I18nProvider namespaces={["departments"]}> | |||||
| <CreateDepartment isEdit={true} departmentId={departmentId}/> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default Department; | |||||
| @@ -16,7 +16,7 @@ const Department: React.FC = async () => { | |||||
| <> | <> | ||||
| <Typography variant="h4">{t("Create Department")}</Typography> | <Typography variant="h4">{t("Create Department")}</Typography> | ||||
| <I18nProvider namespaces={["departments"]}> | <I18nProvider namespaces={["departments"]}> | ||||
| <CreateDepartment /> | |||||
| <CreateDepartment isEdit={false} /> | |||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -0,0 +1,22 @@ | |||||
| // 'use client'; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| import React, { useCallback, useState } from "react"; | |||||
| import { Typography } from "@mui/material"; | |||||
| import CreateGroup from "@/components/CreateGroup"; | |||||
| // const Title = ["title1", "title2"]; | |||||
| const CreateStaff: React.FC = async () => { | |||||
| const { t } = await getServerI18n("group"); | |||||
| return ( | |||||
| <> | |||||
| <Typography variant="h4">{t("Create Group")}</Typography> | |||||
| <I18nProvider namespaces={["group"]}> | |||||
| <CreateGroup /> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default CreateStaff; | |||||
| @@ -0,0 +1,26 @@ | |||||
| import EditPosition from "@/components/EditPosition"; | |||||
| import EditUserGroup from "@/components/EditUserGroup"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import { Metadata } from "next"; | |||||
| export const metadata: Metadata = { | |||||
| title: "Edit User Group", | |||||
| }; | |||||
| const Group: React.FC = async () => { | |||||
| const { t } = await getServerI18n("group"); | |||||
| // Preload necessary dependencies | |||||
| return ( | |||||
| <> | |||||
| {/* <Typography variant="h4">{t("Edit User Group")}</Typography> */} | |||||
| <I18nProvider namespaces={["group"]}> | |||||
| <EditUserGroup /> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default Group; | |||||
| @@ -0,0 +1,55 @@ | |||||
| import { preloadClaims } from "@/app/api/claims"; | |||||
| import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; | |||||
| import StaffSearch from "@/components/StaffSearch"; | |||||
| import TeamSearch from "@/components/TeamSearch"; | |||||
| import UserGroupSearch from "@/components/UserGroupSearch"; | |||||
| import UserSearch from "@/components/UserSearch"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| import Add from "@mui/icons-material/Add"; | |||||
| import Button from "@mui/material/Button"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import { Metadata } from "next"; | |||||
| import Link from "next/link"; | |||||
| import { Suspense } from "react"; | |||||
| export const metadata: Metadata = { | |||||
| title: "User Group", | |||||
| }; | |||||
| const UserGroup: React.FC = async () => { | |||||
| const { t } = await getServerI18n("User Group"); | |||||
| // preloadTeamLeads(); | |||||
| // preloadStaff(); | |||||
| return ( | |||||
| <> | |||||
| <Stack | |||||
| direction="row" | |||||
| justifyContent="space-between" | |||||
| flexWrap="wrap" | |||||
| rowGap={2} | |||||
| > | |||||
| <Typography variant="h4" marginInlineEnd={2}> | |||||
| {t("User Group")} | |||||
| </Typography> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<Add />} | |||||
| LinkComponent={Link} | |||||
| href="/settings/group/create" | |||||
| > | |||||
| {t("Create User Group")} | |||||
| </Button> | |||||
| </Stack> | |||||
| <I18nProvider namespaces={["User Group", "common"]}> | |||||
| <Suspense fallback={<UserGroupSearch.Loading />}> | |||||
| <UserGroupSearch /> | |||||
| </Suspense> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default UserGroup; | |||||
| @@ -0,0 +1,48 @@ | |||||
| import CompanyHoliday from "@/components/CompanyHoliday"; | |||||
| import { Metadata } from "next"; | |||||
| import { getServerI18n } from "@/i18n"; | |||||
| import Add from "@mui/icons-material/Add"; | |||||
| import Button from "@mui/material/Button"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import Link from "next/link"; | |||||
| import { Suspense } from "react"; | |||||
| import { fetchCompanys, preloadCompanys } from "@/app/api/companys"; | |||||
| export const metadata: Metadata = { | |||||
| title: "Holiday", | |||||
| }; | |||||
| const Company: React.FC = async () => { | |||||
| const { t } = await getServerI18n("holiday"); | |||||
| // Preload necessary dependencies | |||||
| return ( | |||||
| <> | |||||
| <Stack | |||||
| direction="row" | |||||
| justifyContent="space-between" | |||||
| flexWrap="wrap" | |||||
| rowGap={2} | |||||
| > | |||||
| <Typography variant="h4" marginInlineEnd={2}> | |||||
| {t("Company Holiday")} | |||||
| </Typography> | |||||
| {/* <Button | |||||
| variant="contained" | |||||
| startIcon={<Add />} | |||||
| LinkComponent={Link} | |||||
| href="/settings/holiday/create" | |||||
| > | |||||
| {t("Create Holiday")} | |||||
| </Button> */} | |||||
| </Stack> | |||||
| <Suspense fallback={<CompanyHoliday.Loading />}> | |||||
| <CompanyHoliday/> | |||||
| </Suspense> | |||||
| </> | |||||
| ) | |||||
| }; | |||||
| export default Company; | |||||
| @@ -0,0 +1,28 @@ | |||||
| import { Edit } from "@mui/icons-material"; | |||||
| import { useSearchParams } from "next/navigation"; | |||||
| // import EditStaff from "@/components/EditStaff"; | |||||
| import { Suspense } from "react"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| // import EditStaffWrapper from "@/components/EditStaff/EditStaffWrapper"; | |||||
| import { Metadata } from "next"; | |||||
| import EditSkill from "@/components/EditSkill"; | |||||
| import { Typography } from "@mui/material"; | |||||
| const EditSkillPage: React.FC = async () => { | |||||
| const { t } = await getServerI18n("staff"); | |||||
| return ( | |||||
| <> | |||||
| <Typography variant="h4">{t("Edit Skill")}</Typography> | |||||
| <I18nProvider namespaces={["team", "common"]}> | |||||
| <Suspense fallback={<EditSkill.Loading />}> | |||||
| <EditSkill /> | |||||
| </Suspense> | |||||
| </I18nProvider> | |||||
| {/* <EditStaff /> */} | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default EditSkillPage; | |||||
| @@ -22,7 +22,7 @@ import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions"; | |||||
| import { Error } from "@mui/icons-material"; | import { Error } from "@mui/icons-material"; | ||||
| import { ProjectCategory } from "@/app/api/projects"; | import { ProjectCategory } from "@/app/api/projects"; | ||||
| import { Grid, Typography } from "@mui/material"; | import { Grid, Typography } from "@mui/material"; | ||||
| import CreateStaffForm from "@/components/CreateStaff/CreateStaff"; | |||||
| import CreateStaff from "@/components/CreateStaff"; | |||||
| interface CreateCustomInputs { | interface CreateCustomInputs { | ||||
| projectCode: string; | projectCode: string; | ||||
| @@ -31,23 +31,17 @@ interface CreateCustomInputs { | |||||
| // const Title = ["title1", "title2"]; | // const Title = ["title1", "title2"]; | ||||
| const CreateStaff: React.FC = async () => { | |||||
| const CreateStaffPage: React.FC = async () => { | |||||
| const { t } = await getServerI18n("staff"); | const { t } = await getServerI18n("staff"); | ||||
| const title = ['', t('Additional Info')] | |||||
| // const regex = new RegExp("^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$") | |||||
| // console.log(regex) | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Typography variant="h4">{t("Create Staff")}</Typography> | <Typography variant="h4">{t("Create Staff")}</Typography> | ||||
| <I18nProvider namespaces={["staff"]}> | <I18nProvider namespaces={["staff"]}> | ||||
| <CreateStaffForm | |||||
| Title={title} | |||||
| /> | |||||
| <CreateStaff/> | |||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| ); | ); | ||||
| }; | }; | ||||
| export default CreateStaff; | |||||
| export default CreateStaffPage; | |||||
| @@ -0,0 +1,22 @@ | |||||
| import { Edit } from "@mui/icons-material"; | |||||
| import { Metadata } from "next"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| import EditUser from "@/components/EditUser"; | |||||
| import { Typography } from "@mui/material"; | |||||
| import { Suspense } from "react"; | |||||
| const User: React.FC = async () => { | |||||
| const { t } = await getServerI18n("user"); | |||||
| return ( | |||||
| <> | |||||
| <Typography variant="h4">{t("Edit User")}</Typography> | |||||
| <I18nProvider namespaces={["user", "common"]}> | |||||
| <Suspense fallback={<EditUser.Loading />}> | |||||
| <EditUser /> | |||||
| </Suspense> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default User; | |||||
| @@ -28,10 +28,6 @@ import CreateTeam from "@/components/CreateTeam"; | |||||
| const CreateTeamPage: React.FC = async () => { | const CreateTeamPage: React.FC = async () => { | ||||
| const { t } = await getServerI18n("team"); | const { t } = await getServerI18n("team"); | ||||
| const title = ['', t('Additional Info')] | |||||
| // const regex = new RegExp("^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$") | |||||
| // console.log(regex) | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Typography variant="h4">{t("Create Team")}</Typography> | <Typography variant="h4">{t("Create Team")}</Typography> | ||||
| @@ -0,0 +1,24 @@ | |||||
| import { Edit } from "@mui/icons-material"; | |||||
| import { useSearchParams } from "next/navigation"; | |||||
| // import EditStaff from "@/components/EditStaff"; | |||||
| import { Suspense } from "react"; | |||||
| import { I18nProvider } from "@/i18n"; | |||||
| // import EditStaffWrapper from "@/components/EditStaff/EditStaffWrapper"; | |||||
| import { Metadata } from "next"; | |||||
| import EditUser from "@/components/EditUser"; | |||||
| const EditUserPage: React.FC = () => { | |||||
| return ( | |||||
| <> | |||||
| <I18nProvider namespaces={["team", "common"]}> | |||||
| <Suspense fallback={<EditUser.Loading />}> | |||||
| <EditUser /> | |||||
| </Suspense> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default EditUserPage; | |||||
| @@ -33,14 +33,14 @@ export const metadata: Metadata = { | |||||
| <Typography variant="h4" marginInlineEnd={2}> | <Typography variant="h4" marginInlineEnd={2}> | ||||
| {t("User")} | {t("User")} | ||||
| </Typography> | </Typography> | ||||
| <Button | |||||
| {/* <Button | |||||
| variant="contained" | variant="contained" | ||||
| startIcon={<Add />} | startIcon={<Add />} | ||||
| LinkComponent={Link} | LinkComponent={Link} | ||||
| href="/settings/team/create" | href="/settings/team/create" | ||||
| > | > | ||||
| {t("Create User")} | {t("Create User")} | ||||
| </Button> | |||||
| </Button> */} | |||||
| </Stack> | </Stack> | ||||
| <I18nProvider namespaces={["User", "common"]}> | <I18nProvider namespaces={["User", "common"]}> | ||||
| <Suspense fallback={<UserSearch.Loading />}> | <Suspense fallback={<UserSearch.Loading />}> | ||||
| @@ -1,4 +1,4 @@ | |||||
| import ClaimDetail from "@/components/ClaimDetail"; | |||||
| import ClaimSave from "@/components/ClaimSave"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | import { I18nProvider, getServerI18n } from "@/i18n"; | ||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import { Metadata } from "next"; | import { Metadata } from "next"; | ||||
| @@ -14,7 +14,7 @@ const ClaimDetails: React.FC = async () => { | |||||
| <> | <> | ||||
| <Typography variant="h4">{t("Create Claim")}</Typography> | <Typography variant="h4">{t("Create Claim")}</Typography> | ||||
| <I18nProvider namespaces={["claim", "common"]}> | <I18nProvider namespaces={["claim", "common"]}> | ||||
| <ClaimDetail /> | |||||
| <ClaimSave /> | |||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -0,0 +1,17 @@ | |||||
| import { getServerI18n } from "@/i18n"; | |||||
| import { Stack, Typography, Link } from "@mui/material"; | |||||
| import NextLink from "next/link"; | |||||
| export default async function NotFound() { | |||||
| const { t } = await getServerI18n("tasks", "common"); | |||||
| return ( | |||||
| <Stack spacing={2}> | |||||
| <Typography variant="h4">{t("Not Found")}</Typography> | |||||
| <Typography variant="body1">{t("The task template was not found!")}</Typography> | |||||
| <Link href="/projects" component={NextLink} variant="body2"> | |||||
| {t("Return to all task templates")} | |||||
| </Link> | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| @@ -1,23 +1,44 @@ | |||||
| import { preloadAllTasks } from "@/app/api/tasks"; | |||||
| import { fetchTaskTemplateDetail, preloadAllTasks } from "@/app/api/tasks"; | |||||
| import CreateTaskTemplate from "@/components/CreateTaskTemplate"; | import CreateTaskTemplate from "@/components/CreateTaskTemplate"; | ||||
| import { getServerI18n } from "@/i18n"; | import { getServerI18n } from "@/i18n"; | ||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import { Metadata } from "next"; | import { Metadata } from "next"; | ||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import { ServerFetchError } from "@/app/utils/fetchUtil"; | |||||
| import { isArray } from "lodash"; | |||||
| import { notFound } from "next/navigation"; | |||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| title: "Edit Task Template", | title: "Edit Task Template", | ||||
| }; | }; | ||||
| const TaskTemplates: React.FC = async () => { | |||||
| interface Props { | |||||
| searchParams: { [key: string]: string | string[] | undefined }; | |||||
| } | |||||
| const TaskTemplates: React.FC<Props> = async ({ searchParams }) => { | |||||
| const { t } = await getServerI18n("tasks"); | const { t } = await getServerI18n("tasks"); | ||||
| const taskTemplateId = searchParams["id"]; | |||||
| if (!taskTemplateId || isArray(taskTemplateId)) { | |||||
| notFound(); | |||||
| } | |||||
| preloadAllTasks(); | preloadAllTasks(); | ||||
| try { | |||||
| await fetchTaskTemplateDetail(taskTemplateId); | |||||
| } catch (e) { | |||||
| if (e instanceof ServerFetchError && e.response?.status === 404) { | |||||
| notFound(); | |||||
| } | |||||
| } | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Typography variant="h4">{t("Edit Task Template")}</Typography> | <Typography variant="h4">{t("Edit Task Template")}</Typography> | ||||
| <I18nProvider namespaces={["tasks", "common"]}> | <I18nProvider namespaces={["tasks", "common"]}> | ||||
| <CreateTaskTemplate /> | |||||
| <CreateTaskTemplate taskTemplateId={taskTemplateId}/> | |||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -1,6 +1,6 @@ | |||||
| "use server" | "use server" | ||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| @@ -14,8 +14,9 @@ export interface combo { | |||||
| records: comboProp[]; | records: comboProp[]; | ||||
| } | } | ||||
| export interface CreateDepartmentInputs { | export interface CreateDepartmentInputs { | ||||
| departmentCode: string; | |||||
| departmentName: string; | |||||
| id: number; | |||||
| code: string; | |||||
| name: string; | |||||
| description: string; | description: string; | ||||
| } | } | ||||
| @@ -25,7 +26,19 @@ export const saveDepartment = async (data: CreateDepartmentInputs) => { | |||||
| body: JSON.stringify(data), | body: JSON.stringify(data), | ||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| }); | }); | ||||
| }; | |||||
| }; | |||||
| export const deleteDepartment = async (id: number) => { | |||||
| const department = await serverFetchWithNoContent( | |||||
| `${BASE_API_URL}/departments/${id}`, | |||||
| { | |||||
| method: "DELETE", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| return department | |||||
| }; | |||||
| export const fetchDepartmentCombo = cache(async () => { | export const fetchDepartmentCombo = cache(async () => { | ||||
| @@ -2,6 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import "server-only"; | import "server-only"; | ||||
| import { CreateDepartmentInputs } from "./actions"; | |||||
| export interface DepartmentResult { | export interface DepartmentResult { | ||||
| id: number; | id: number; | ||||
| @@ -18,4 +19,13 @@ export const fetchDepartments = cache(async () => { | |||||
| return serverFetchJson<DepartmentResult[]>(`${BASE_API_URL}/departments`, { | return serverFetchJson<DepartmentResult[]>(`${BASE_API_URL}/departments`, { | ||||
| next: { tags: ["departments"] }, | next: { tags: ["departments"] }, | ||||
| }); | }); | ||||
| }); | |||||
| export const fetchDepartmentDetails = cache(async (departmentId: string) => { | |||||
| return serverFetchJson<CreateDepartmentInputs>( | |||||
| `${BASE_API_URL}/departments/departmentDetails/${departmentId}`, | |||||
| { | |||||
| next: { tags: [`departmentDetail${departmentId}`] }, | |||||
| }, | |||||
| ); | |||||
| }); | }); | ||||
| @@ -0,0 +1,51 @@ | |||||
| "use server"; | |||||
| import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | |||||
| import { revalidateTag } from "next/cache"; | |||||
| import { cache } from "react"; | |||||
| export interface CreateGroupInputs { | |||||
| id?: number; | |||||
| name: string; | |||||
| description: string; | |||||
| addUserIds?: number[]; | |||||
| removeUserIds?: number[]; | |||||
| addAuthIds?: number[]; | |||||
| removeAuthIds?: number[]; | |||||
| } | |||||
| export interface auth { | |||||
| id: number; | |||||
| module?: any | null; | |||||
| authority: string; | |||||
| name: string; | |||||
| description: string | null; | |||||
| v: number; | |||||
| } | |||||
| export interface record { | |||||
| records: auth[]; | |||||
| } | |||||
| export const fetchAuth = cache(async (target: string, id?: number) => { | |||||
| return serverFetchJson<record>(`${BASE_API_URL}/group/auth/${target}/${id ?? 0}`, { | |||||
| next: { tags: ["auth"] }, | |||||
| }); | |||||
| }); | |||||
| export const saveGroup = async (data: CreateGroupInputs) => { | |||||
| return serverFetchJson(`${BASE_API_URL}/group/save`, { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }; | |||||
| export const deleteGroup = async (id: number) => { | |||||
| return serverFetchWithNoContent(`${BASE_API_URL}/group/${id}`, { | |||||
| method: "DELETE", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }; | |||||
| @@ -0,0 +1,21 @@ | |||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | |||||
| import { cache } from "react"; | |||||
| import "server-only"; | |||||
| export interface Records { | |||||
| records: UserGroupResult[] | |||||
| } | |||||
| export interface UserGroupResult { | |||||
| id: number; | |||||
| action: () => void; | |||||
| name: string; | |||||
| description: string; | |||||
| } | |||||
| export const fetchGroup = cache(async () => { | |||||
| return serverFetchJson<Records>(`${BASE_API_URL}/group`, { | |||||
| next: { tags: ["group"] }, | |||||
| }); | |||||
| }); | |||||
| @@ -0,0 +1,33 @@ | |||||
| "use server"; | |||||
| import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | |||||
| import { Dayjs } from "dayjs"; | |||||
| import { cache } from "react"; | |||||
| export interface CreateCompanyHolidayInputs { | |||||
| id: number; | |||||
| name: string; | |||||
| date: string; | |||||
| } | |||||
| export const saveCompanyHoliday = async (data: CreateCompanyHolidayInputs) => { | |||||
| return serverFetchJson(`${BASE_API_URL}/company-holidays/new`, { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }; | |||||
| export const deleteCompanyHoliday = async (id: number) => { | |||||
| const holiday = await serverFetchWithNoContent( | |||||
| `${BASE_API_URL}/company-holidays/${id}`, | |||||
| { | |||||
| method: "DELETE", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| return holiday | |||||
| }; | |||||
| @@ -0,0 +1,30 @@ | |||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | |||||
| import { cache } from "react"; | |||||
| import "server-only"; | |||||
| import EventInput from '@fullcalendar/react'; | |||||
| export interface HolidaysList extends EventInput { | |||||
| id: string; | |||||
| title: string; | |||||
| date: string; | |||||
| extendedProps: { | |||||
| calendar: string; | |||||
| }; | |||||
| } | |||||
| export interface HolidaysResult { | |||||
| id: string; | |||||
| name: string; | |||||
| date: number[]; | |||||
| } | |||||
| export const preloadCompanys = () => { | |||||
| fetchHolidays(); | |||||
| }; | |||||
| export const fetchHolidays = cache(async () => { | |||||
| return serverFetchJson<HolidaysResult[]>(`${BASE_API_URL}/company-holidays`, { | |||||
| next: { tags: ["company-holidays"] }, | |||||
| }); | |||||
| }); | |||||
| @@ -1,6 +1,6 @@ | |||||
| "use server" | "use server" | ||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { serverFetchJson, serverFetchString } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| @@ -64,4 +64,32 @@ export const fetchInvoiceInfoById = cache(async (id: number) => { | |||||
| return serverFetchJson<InvoiceInformation[]>(`${BASE_API_URL}/invoices/getInvoiceInfo/${id}`, { | return serverFetchJson<InvoiceInformation[]>(`${BASE_API_URL}/invoices/getInvoiceInfo/${id}`, { | ||||
| next: { tags: ["invoiceInfoById"] }, | next: { tags: ["invoiceInfoById"] }, | ||||
| }); | }); | ||||
| }) | |||||
| }) | |||||
| export const importIssuedInovice = async (data: FormData) => { | |||||
| // console.log("----------------",data) | |||||
| const importIssuedInovice = await serverFetchJson<any>( | |||||
| `${BASE_API_URL}/invoices/import/issued`, | |||||
| { | |||||
| method: "POST", | |||||
| body: data, | |||||
| // headers: { "Content-Type": "multipart/form-data" }, | |||||
| }, | |||||
| ); | |||||
| return importIssuedInovice; | |||||
| }; | |||||
| export const importReceivedInovice = async (data: FormData) => { | |||||
| // console.log("----------------",data) | |||||
| const importReceivedInovice = await serverFetchJson<any>( | |||||
| `${BASE_API_URL}/invoices/import/received`, | |||||
| { | |||||
| method: "POST", | |||||
| body: data, | |||||
| // headers: { "Content-Type": "multipart/form-data" }, | |||||
| }, | |||||
| ); | |||||
| return importReceivedInovice; | |||||
| }; | |||||
| @@ -15,6 +15,107 @@ export interface InvoiceResult { | |||||
| reminder: string; | reminder: string; | ||||
| } | } | ||||
| export interface issuedInvoiceResult { | |||||
| id: number; | |||||
| invoiceNo: string; | |||||
| projectCode: string; | |||||
| projectName: string; | |||||
| team: string; | |||||
| stage: string; | |||||
| paymentMilestone: string; | |||||
| paymentMilestoneDate: string; | |||||
| client: string; | |||||
| address: string; | |||||
| attention: string; | |||||
| invoiceDate: number[]; | |||||
| dueDate: number[]; | |||||
| issuedAmount: number; | |||||
| } | |||||
| export interface receivedInvoiceResult { | |||||
| id: number; | |||||
| invoiceNo: string; | |||||
| projectCode: string; | |||||
| projectName: string; | |||||
| team: string; | |||||
| receiptDate: number[]; | |||||
| receivedAmount: number; | |||||
| } | |||||
| export interface issuedInvoiceList { | |||||
| id: number; | |||||
| invoiceNo: string; | |||||
| projectCode: string; | |||||
| projectName: string; | |||||
| // team: string; | |||||
| stage: string; | |||||
| paymentMilestone: string; | |||||
| // paymentMilestoneDate: string; | |||||
| // client: string; | |||||
| // address: string; | |||||
| // attention: string; | |||||
| invoiceDate: string; | |||||
| dueDate: string; | |||||
| issuedAmount: string; | |||||
| } | |||||
| export interface receivedInvoiceList { | |||||
| id: number; | |||||
| invoiceNo: string; | |||||
| projectCode: string; | |||||
| projectName: string; | |||||
| team: string; | |||||
| // stage: string; | |||||
| // paymentMilestone: string; | |||||
| // paymentMilestoneDate: string; | |||||
| // client: string; | |||||
| // address: string; | |||||
| // attention: string; | |||||
| receiptDate: string; | |||||
| receivedAmount: string; | |||||
| } | |||||
| export interface issuedInvoiceSearchForm { | |||||
| id: number; | |||||
| invoiceNo: string; | |||||
| projectCode: string; | |||||
| projectName: string; | |||||
| // team: string; | |||||
| // stage: string; | |||||
| // paymentMilestone: string; | |||||
| // paymentMilestoneDate: string; | |||||
| // client: string; | |||||
| // address: string; | |||||
| // attention: string; | |||||
| invoiceDate: string; | |||||
| invoiceDateTo: string; | |||||
| dueDate: string; | |||||
| dueDateTo: string; | |||||
| // issuedAmount: string; | |||||
| } | |||||
| export interface receivedInvoiceSearchForm { | |||||
| id: number; | |||||
| invoiceNo: string; | |||||
| projectCode: string; | |||||
| projectName: string; | |||||
| // team: string; | |||||
| // stage: string; | |||||
| // paymentMilestone: string; | |||||
| // paymentMilestoneDate: string; | |||||
| // client: string; | |||||
| // address: string; | |||||
| // attention: string; | |||||
| receiptDate: string; | |||||
| receiptDateTo: string; | |||||
| // dueDate: string; | |||||
| // dueDateTo: string; | |||||
| // issuedAmount: string; | |||||
| } | |||||
| export interface InvoiceInformatio{ | export interface InvoiceInformatio{ | ||||
| id: number; | id: number; | ||||
| address: string; | address: string; | ||||
| @@ -32,4 +133,16 @@ export const fetchInvoices = cache(async () => { | |||||
| return serverFetchJson<InvoiceResult[]>(`${BASE_API_URL}/invoices`, { | return serverFetchJson<InvoiceResult[]>(`${BASE_API_URL}/invoices`, { | ||||
| next: { tags: ["invoices"] }, | next: { tags: ["invoices"] }, | ||||
| }); | }); | ||||
| }); | |||||
| export const fetchIssuedInvoices = cache(async () => { | |||||
| return serverFetchJson<issuedInvoiceResult[]>(`${BASE_API_URL}/invoices/v2/allInvoices`, { | |||||
| next: { tags: ["invoices"] }, | |||||
| }); | |||||
| }); | |||||
| export const fetchReceivedInvoices = cache(async () => { | |||||
| return serverFetchJson<receivedInvoiceResult[]>(`${BASE_API_URL}/invoices/v2/allInvoices/paid`, { | |||||
| next: { tags: ["invoices"] }, | |||||
| }); | |||||
| }); | }); | ||||
| @@ -1,6 +1,6 @@ | |||||
| "use server" | "use server" | ||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import { PositionResult } from "."; | import { PositionResult } from "."; | ||||
| @@ -17,13 +17,15 @@ export interface combo { | |||||
| export interface CreatePositionInputs { | export interface CreatePositionInputs { | ||||
| positionCode: string; | positionCode: string; | ||||
| positionName: string; | positionName: string; | ||||
| code: string; | |||||
| name: string; | |||||
| description: string; | description: string; | ||||
| } | } | ||||
| export interface EditPositionInputs { | export interface EditPositionInputs { | ||||
| id: number; | id: number; | ||||
| positionCode: string; | |||||
| positionName: string; | |||||
| code: string; | |||||
| name: string; | |||||
| description: string; | description: string; | ||||
| } | } | ||||
| @@ -35,13 +37,25 @@ export const savePosition = async (data: CreatePositionInputs) => { | |||||
| }); | }); | ||||
| }; | }; | ||||
| export const editPosition = async (data: EditPositionInputs) => { | |||||
| return serverFetchJson(`${BASE_API_URL}/positions/new`, { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }; | |||||
| export const editPosition = async (data: EditPositionInputs) => { | |||||
| return serverFetchJson(`${BASE_API_URL}/positions/new`, { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }; | |||||
| export const deletePosition = async (id: number) => { | |||||
| const position = await serverFetchWithNoContent( | |||||
| `${BASE_API_URL}/positions/${id}`, | |||||
| { | |||||
| method: "DELETE", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| return position | |||||
| }; | |||||
| export const fetchPositionCombo = cache(async () => { | export const fetchPositionCombo = cache(async () => { | ||||
| return serverFetchJson<combo>(`${BASE_API_URL}/positions/combo`, { | return serverFetchJson<combo>(`${BASE_API_URL}/positions/combo`, { | ||||
| @@ -1,17 +1,25 @@ | |||||
| "use server"; | "use server"; | ||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { | |||||
| serverFetchJson, | |||||
| serverFetchWithNoContent, | |||||
| } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { Task, TaskGroup } from "../tasks"; | import { Task, TaskGroup } from "../tasks"; | ||||
| import { Customer } from "../customer"; | import { Customer } from "../customer"; | ||||
| import { revalidatePath, revalidateTag } from "next/cache"; | |||||
| export interface CreateProjectInputs { | export interface CreateProjectInputs { | ||||
| // Project details | |||||
| // Project | |||||
| projectId: number | null; | |||||
| projectDeleted: boolean | null; | |||||
| projectCode: string; | projectCode: string; | ||||
| projectName: string; | projectName: string; | ||||
| projectCategoryId: number; | projectCategoryId: number; | ||||
| projectDescription: string; | projectDescription: string; | ||||
| projectLeadId: number; | projectLeadId: number; | ||||
| projectActualStart: string; | |||||
| projectActualEnd: string; | |||||
| // Project info | // Project info | ||||
| serviceTypeId: number; | serviceTypeId: number; | ||||
| @@ -61,10 +69,38 @@ export interface PaymentInputs { | |||||
| amount: number; | amount: number; | ||||
| } | } | ||||
| export interface CreateProjectResponse { | |||||
| id: number; | |||||
| name: string; | |||||
| code: string; | |||||
| category: string; | |||||
| team: string; | |||||
| client: string; | |||||
| } | |||||
| export const saveProject = async (data: CreateProjectInputs) => { | export const saveProject = async (data: CreateProjectInputs) => { | ||||
| return serverFetchJson(`${BASE_API_URL}/projects/new`, { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| const newProject = await serverFetchJson<CreateProjectResponse>( | |||||
| `${BASE_API_URL}/projects/new`, | |||||
| { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| revalidateTag("projects"); | |||||
| return newProject; | |||||
| }; | |||||
| export const deleteProject = async (id: number) => { | |||||
| const project = await serverFetchWithNoContent( | |||||
| `${BASE_API_URL}/projects/${id}`, | |||||
| { | |||||
| method: "DELETE", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| revalidateTag("projects"); | |||||
| revalidatePath("/(main)/home"); | |||||
| return project; | |||||
| }; | }; | ||||
| @@ -3,6 +3,7 @@ import { BASE_API_URL } from "@/config/api"; | |||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import "server-only"; | import "server-only"; | ||||
| import { Task, TaskGroup } from "../tasks"; | import { Task, TaskGroup } from "../tasks"; | ||||
| import { CreateProjectInputs } from "./actions"; | |||||
| export interface ProjectResult { | export interface ProjectResult { | ||||
| id: number; | id: number; | ||||
| @@ -48,17 +49,20 @@ export interface WorkNature { | |||||
| name: string; | name: string; | ||||
| } | } | ||||
| export interface AssignedProject { | |||||
| export interface ProjectWithTasks { | |||||
| id: number; | id: number; | ||||
| code: string; | code: string; | ||||
| name: string; | name: string; | ||||
| tasks: Task[]; | tasks: Task[]; | ||||
| milestones: { | milestones: { | ||||
| [taskGroupId: TaskGroup["id"]]: { | [taskGroupId: TaskGroup["id"]]: { | ||||
| startDate: string; | |||||
| endDate: string; | |||||
| startDate?: string; | |||||
| endDate?: string; | |||||
| }; | }; | ||||
| }; | }; | ||||
| } | |||||
| export interface AssignedProject extends ProjectWithTasks { | |||||
| // Manhour info | // Manhour info | ||||
| hoursSpent: number; | hoursSpent: number; | ||||
| hoursSpentOther: number; | hoursSpentOther: number; | ||||
| @@ -137,11 +141,29 @@ export const fetchProjectWorkNatures = cache(async () => { | |||||
| }); | }); | ||||
| }); | }); | ||||
| export const fetchAssignedProjects = cache(async () => { | |||||
| export const fetchAssignedProjects = cache(async (username: string) => { | |||||
| return serverFetchJson<AssignedProject[]>( | return serverFetchJson<AssignedProject[]>( | ||||
| `${BASE_API_URL}/projects/assignedProjects`, | `${BASE_API_URL}/projects/assignedProjects`, | ||||
| { | { | ||||
| next: { tags: ["assignedProjects"] }, | |||||
| next: { tags: [`assignedProjects__${username}`] }, | |||||
| }, | |||||
| ); | |||||
| }); | |||||
| export const fetchProjectWithTasks = cache(async () => { | |||||
| return serverFetchJson<ProjectWithTasks[]>( | |||||
| `${BASE_API_URL}/projects/allProjectWithTasks`, | |||||
| { | |||||
| next: { tags: ["allProjectWithTasks"] }, | |||||
| }, | |||||
| ); | |||||
| }); | |||||
| export const fetchProjectDetails = cache(async (projectId: string) => { | |||||
| return serverFetchJson<CreateProjectInputs>( | |||||
| `${BASE_API_URL}/projects/projectDetails/${projectId}`, | |||||
| { | |||||
| next: { tags: [`projectDetails_${projectId}`] }, | |||||
| }, | }, | ||||
| ); | ); | ||||
| }); | }); | ||||
| @@ -1,7 +1,7 @@ | |||||
| "use server"; | "use server"; | ||||
| import { serverFetchBlob, serverFetchJson } from "@/app/utils/fetchUtil"; | import { serverFetchBlob, serverFetchJson } from "@/app/utils/fetchUtil"; | ||||
| import { EX02ProjectCashFlowReportRequest } from "."; | |||||
| import { ProjectCashFlowReportRequest } from "."; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| export interface FileResponse { | export interface FileResponse { | ||||
| @@ -9,9 +9,9 @@ export interface FileResponse { | |||||
| blobValue: Uint8Array; | blobValue: Uint8Array; | ||||
| } | } | ||||
| export const fetchEX02ProjectCashFlowReport = async (data: EX02ProjectCashFlowReportRequest) => { | |||||
| export const fetchProjectCashFlowReport = async (data: ProjectCashFlowReportRequest) => { | |||||
| const reportBlob = await serverFetchBlob<FileResponse>( | const reportBlob = await serverFetchBlob<FileResponse>( | ||||
| `${BASE_API_URL}/reports/EX02-ProjectCashFlowReport`, | |||||
| `${BASE_API_URL}/reports/ProjectCashFlowReport`, | |||||
| { | { | ||||
| method: "POST", | method: "POST", | ||||
| body: JSON.stringify(data), | body: JSON.stringify(data), | ||||
| @@ -1,8 +1,8 @@ | |||||
| // EX02 - Project Cash Flow Report | |||||
| export interface EX02ProjectCashFlowReportFilter { | |||||
| // - Project Cash Flow Report | |||||
| export interface ProjectCashFlowReportFilter { | |||||
| project: string[]; | project: string[]; | ||||
| } | } | ||||
| export interface EX02ProjectCashFlowReportRequest { | |||||
| export interface ProjectCashFlowReportRequest { | |||||
| projectId: number; | projectId: number; | ||||
| } | } | ||||
| @@ -1,8 +1,9 @@ | |||||
| "use server" | "use server" | ||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { serverFetchBlob, serverFetchJson, serverFetchString } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import { FileResponse } from "../reports/actions"; | |||||
| export interface comboProp { | export interface comboProp { | ||||
| id: any; | id: any; | ||||
| @@ -17,4 +18,31 @@ export const fetchSalaryCombo = cache(async () => { | |||||
| return serverFetchJson<combo>(`${BASE_API_URL}/salarys/combo`, { | return serverFetchJson<combo>(`${BASE_API_URL}/salarys/combo`, { | ||||
| next: { tags: ["salary"] }, | next: { tags: ["salary"] }, | ||||
| }); | }); | ||||
| }); | |||||
| }); | |||||
| export const importSalarys = async (data: FormData) => { | |||||
| console.log("----------------",data) | |||||
| const importSalarys = await serverFetchString<String>( | |||||
| `${BASE_API_URL}/salarys/import`, | |||||
| { | |||||
| method: "POST", | |||||
| body: data, | |||||
| // headers: { "Content-Type": "multipart/form-data" }, | |||||
| }, | |||||
| ); | |||||
| return importSalarys; | |||||
| }; | |||||
| export const exportSalary = async () => { | |||||
| const reportBlob = await serverFetchBlob<FileResponse>( | |||||
| `${BASE_API_URL}/salarys/export`, | |||||
| { | |||||
| method: "POST", | |||||
| // body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| return reportBlob | |||||
| }; | |||||
| @@ -4,13 +4,26 @@ import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { TaskTemplate } from "."; | import { TaskTemplate } from "."; | ||||
| import { revalidateTag } from "next/cache"; | import { revalidateTag } from "next/cache"; | ||||
| import { ManhourAllocation } from "@/app/api/projects/actions"; | |||||
| import { Task, TaskGroup } from '@/app/api/tasks'; | |||||
| export interface NewTaskTemplateFormInputs { | export interface NewTaskTemplateFormInputs { | ||||
| // task template | |||||
| code: string; | code: string; | ||||
| name: string; | name: string; | ||||
| taskIds: number[]; | taskIds: number[]; | ||||
| id: number | null; | id: number | null; | ||||
| // resource allocation template | |||||
| manhourPercentageByGrade: ManhourAllocation; | |||||
| taskGroups: { | |||||
| [taskGroup: TaskGroup["id"]]: { | |||||
| taskIds: Task["id"][]; | |||||
| percentAllocation: number; | |||||
| }; | |||||
| }; | |||||
| } | } | ||||
| export const saveTaskTemplate = async (data: NewTaskTemplateFormInputs) => { | export const saveTaskTemplate = async (data: NewTaskTemplateFormInputs) => { | ||||
| @@ -2,6 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import "server-only"; | import "server-only"; | ||||
| import { NewTaskTemplateFormInputs } from "./actions"; | |||||
| export interface TaskGroup { | export interface TaskGroup { | ||||
| id: number; | id: number; | ||||
| @@ -39,3 +40,15 @@ export const preloadAllTasks = () => { | |||||
| export const fetchAllTasks = cache(async () => { | export const fetchAllTasks = cache(async () => { | ||||
| return serverFetchJson<Task[]>(`${BASE_API_URL}/tasks`); | return serverFetchJson<Task[]>(`${BASE_API_URL}/tasks`); | ||||
| }); | }); | ||||
| export const fetchTaskTemplateDetail = cache(async (id: string) => { | |||||
| const taskTemplate = await serverFetchJson<NewTaskTemplateFormInputs>( | |||||
| `${BASE_API_URL}/tasks/templatesDetails/${id}`, | |||||
| { | |||||
| method: "GET", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| return taskTemplate; | |||||
| }); | |||||
| @@ -15,6 +15,7 @@ export interface TeamResult { | |||||
| staffName: string; | staffName: string; | ||||
| posLabel: string; | posLabel: string; | ||||
| posCode: string; | posCode: string; | ||||
| teamLead: number; | |||||
| } | } | ||||
| @@ -1,15 +1,65 @@ | |||||
| "use server"; | "use server"; | ||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { ProjectResult } from "../projects"; | import { ProjectResult } from "../projects"; | ||||
| import { Task, TaskGroup } from "../tasks"; | import { Task, TaskGroup } from "../tasks"; | ||||
| import { BASE_API_URL } from "@/config/api"; | |||||
| import { revalidateTag } from "next/cache"; | |||||
| export interface TimeEntry { | export interface TimeEntry { | ||||
| projectId: ProjectResult["id"]; | |||||
| taskGroupId: TaskGroup["id"]; | |||||
| taskId: Task["id"]; | |||||
| inputHours: number; | |||||
| id: number; | |||||
| projectId?: ProjectResult["id"]; | |||||
| taskGroupId?: TaskGroup["id"]; | |||||
| taskId?: Task["id"]; | |||||
| inputHours?: number; | |||||
| otHours?: number; | |||||
| remark?: string; | |||||
| } | } | ||||
| export interface RecordTimesheetInput { | export interface RecordTimesheetInput { | ||||
| [date: string]: TimeEntry[]; | [date: string]: TimeEntry[]; | ||||
| } | } | ||||
| export interface LeaveEntry { | |||||
| id: number; | |||||
| inputHours: number; | |||||
| leaveTypeId: number; | |||||
| remark?: string; | |||||
| } | |||||
| export interface RecordLeaveInput { | |||||
| [date: string]: LeaveEntry[]; | |||||
| } | |||||
| export const saveTimesheet = async ( | |||||
| data: RecordTimesheetInput, | |||||
| username: string, | |||||
| ) => { | |||||
| const savedRecords = await serverFetchJson<RecordTimesheetInput>( | |||||
| `${BASE_API_URL}/timesheets/save`, | |||||
| { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| revalidateTag(`timesheets_${username}`); | |||||
| return savedRecords; | |||||
| }; | |||||
| export const saveLeave = async (data: RecordLeaveInput, username: string) => { | |||||
| const savedRecords = await serverFetchJson<RecordLeaveInput>( | |||||
| `${BASE_API_URL}/timesheets/saveLeave`, | |||||
| { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| revalidateTag(`leaves_${username}`); | |||||
| return savedRecords; | |||||
| }; | |||||
| @@ -0,0 +1,30 @@ | |||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | |||||
| import { cache } from "react"; | |||||
| import { RecordLeaveInput, RecordTimesheetInput } from "./actions"; | |||||
| export interface LeaveType { | |||||
| id: number; | |||||
| name: string; | |||||
| } | |||||
| export const fetchTimesheets = cache(async (username: string) => { | |||||
| return serverFetchJson<RecordTimesheetInput>(`${BASE_API_URL}/timesheets`, { | |||||
| next: { tags: [`timesheets_${username}`] }, | |||||
| }); | |||||
| }); | |||||
| export const fetchLeaves = cache(async (username: string) => { | |||||
| return serverFetchJson<RecordLeaveInput>( | |||||
| `${BASE_API_URL}/timesheets/leaves`, | |||||
| { | |||||
| next: { tags: [`leaves_${username}`] }, | |||||
| }, | |||||
| ); | |||||
| }); | |||||
| export const fetchLeaveTypes = cache(async () => { | |||||
| return serverFetchJson<LeaveType[]>(`${BASE_API_URL}/timesheets/leaveTypes`, { | |||||
| next: { tags: ["leaveTypes"] }, | |||||
| }); | |||||
| }); | |||||
| @@ -0,0 +1,49 @@ | |||||
| import { LeaveEntry, TimeEntry } from "./actions"; | |||||
| /** | |||||
| * @param entry - the time entry | |||||
| * @returns the field where there is an error, or an empty string if there is none | |||||
| */ | |||||
| export const isValidTimeEntry = (entry: Partial<TimeEntry>): string => { | |||||
| // Test for errors | |||||
| let error: keyof TimeEntry | "" = ""; | |||||
| // Either normal or other hours need to be inputted | |||||
| if (!entry.inputHours && !entry.otHours) { | |||||
| error = "inputHours"; | |||||
| } else if (entry.inputHours && entry.inputHours <= 0) { | |||||
| error = "inputHours"; | |||||
| } else if (entry.otHours && entry.otHours <= 0) { | |||||
| error = "otHours"; | |||||
| } | |||||
| // If there is a project id, there should also be taskGroupId, taskId, inputHours | |||||
| if (entry.projectId) { | |||||
| if (!entry.taskGroupId) { | |||||
| error = "taskGroupId"; | |||||
| } else if (!entry.taskId) { | |||||
| error = "taskId"; | |||||
| } | |||||
| } else { | |||||
| if (!entry.remark) { | |||||
| error = "remark"; | |||||
| } | |||||
| } | |||||
| return error; | |||||
| }; | |||||
| export const isValidLeaveEntry = (entry: Partial<LeaveEntry>): string => { | |||||
| // Test for errrors | |||||
| let error: keyof LeaveEntry | "" = ""; | |||||
| if (!entry.leaveTypeId) { | |||||
| error = "leaveTypeId"; | |||||
| } else if (!entry.inputHours || !(entry.inputHours >= 0)) { | |||||
| error = "inputHours"; | |||||
| } | |||||
| return error; | |||||
| }; | |||||
| export const LEAVE_DAILY_MAX_HOURS = 8; | |||||
| export const TIMESHEET_DAILY_MAX_HOURS = 20; | |||||
| @@ -7,9 +7,16 @@ import { UserDetail, UserResult } from "."; | |||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| export interface UserInputs { | export interface UserInputs { | ||||
| username: string; | |||||
| firstname: string; | |||||
| lastname: string; | |||||
| name: string; | |||||
| email?: string; | |||||
| addAuthIds?: number[]; | |||||
| removeAuthIds?: number[]; | |||||
| } | |||||
| export interface PasswordInputs { | |||||
| password: string; | |||||
| newPassword: string; | |||||
| newPasswordCheck: string; | |||||
| } | } | ||||
| @@ -19,9 +26,25 @@ export const fetchUserDetails = cache(async (id: number) => { | |||||
| }); | }); | ||||
| }); | }); | ||||
| export const editUser = async (id: number, data: UserInputs) => { | |||||
| return serverFetchWithNoContent(`${BASE_API_URL}/user/${id}`, { | |||||
| method: "PUT", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }; | |||||
| export const deleteUser = async (id: number) => { | export const deleteUser = async (id: number) => { | ||||
| return serverFetchWithNoContent(`${BASE_API_URL}/user/${id}`, { | return serverFetchWithNoContent(`${BASE_API_URL}/user/${id}`, { | ||||
| method: "DELETE", | method: "DELETE", | ||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| }); | }); | ||||
| }; | |||||
| export const changePassword = async (data: any) => { | |||||
| return serverFetchWithNoContent(`${BASE_API_URL}/user/change-password`, { | |||||
| method: "PATCH", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }; | }; | ||||
| @@ -3,7 +3,6 @@ import { BASE_API_URL } from "@/config/api"; | |||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import "server-only"; | import "server-only"; | ||||
| export interface UserResult { | export interface UserResult { | ||||
| action: any; | action: any; | ||||
| id: number; | id: number; | ||||
| @@ -19,6 +18,8 @@ export interface UserResult { | |||||
| phone1: string; | phone1: string; | ||||
| phone2: string; | phone2: string; | ||||
| remarks: string; | remarks: string; | ||||
| groupId: number; | |||||
| auths: any | |||||
| } | } | ||||
| // export interface DetailedUser extends UserResult { | // export interface DetailedUser extends UserResult { | ||||
| @@ -27,9 +28,10 @@ export interface UserResult { | |||||
| // } | // } | ||||
| export interface UserDetail { | export interface UserDetail { | ||||
| authIds: number[]; | |||||
| data: UserResult; | data: UserResult; | ||||
| authIds: number[]; | |||||
| groupIds: number[]; | groupIds: number[]; | ||||
| auths: any[] | |||||
| } | } | ||||
| export const preloadUser = () => { | export const preloadUser = () => { | ||||
| @@ -3,6 +3,16 @@ import { getServerSession } from "next-auth"; | |||||
| import { headers } from "next/headers"; | import { headers } from "next/headers"; | ||||
| import { redirect } from "next/navigation"; | import { redirect } from "next/navigation"; | ||||
| export class ServerFetchError extends Error { | |||||
| public readonly response: Response | undefined; | |||||
| constructor(message?: string, response?: Response) { | |||||
| super(message); | |||||
| this.response = response; | |||||
| Object.setPrototypeOf(this, ServerFetchError.prototype); | |||||
| } | |||||
| } | |||||
| export const serverFetch: typeof fetch = async (input, init) => { | export const serverFetch: typeof fetch = async (input, init) => { | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||
| const session = await getServerSession<any, SessionWithTokens>(authOptions); | const session = await getServerSession<any, SessionWithTokens>(authOptions); | ||||
| @@ -17,7 +27,7 @@ export const serverFetch: typeof fetch = async (input, init) => { | |||||
| ? { | ? { | ||||
| Authorization: `Bearer ${accessToken}`, | Authorization: `Bearer ${accessToken}`, | ||||
| Accept: | Accept: | ||||
| "application/json, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |||||
| "application/json, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, multipart/form-data", | |||||
| } | } | ||||
| : {}), | : {}), | ||||
| }, | }, | ||||
| @@ -37,7 +47,10 @@ export async function serverFetchJson<T>(...args: FetchParams) { | |||||
| signOutUser(); | signOutUser(); | ||||
| default: | default: | ||||
| console.error(await response.text()); | console.error(await response.text()); | ||||
| throw Error("Something went wrong fetching data in server."); | |||||
| throw new ServerFetchError( | |||||
| "Something went wrong fetching data in server.", | |||||
| response, | |||||
| ); | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -58,6 +71,25 @@ export async function serverFetchWithNoContent(...args: FetchParams) { | |||||
| } | } | ||||
| } | } | ||||
| export async function serverFetchString<T>(...args: FetchParams) { | |||||
| const response = await serverFetch(...args); | |||||
| if (response.ok) { | |||||
| return response.text() as T; | |||||
| } else { | |||||
| switch (response.status) { | |||||
| case 401: | |||||
| signOutUser(); | |||||
| default: | |||||
| console.error(await response.text()); | |||||
| throw new ServerFetchError( | |||||
| "Something went wrong fetching data in server.", | |||||
| response, | |||||
| ); | |||||
| } | |||||
| } | |||||
| } | |||||
| export async function serverFetchBlob<T>(...args: FetchParams) { | export async function serverFetchBlob<T>(...args: FetchParams) { | ||||
| const response = await serverFetch(...args); | const response = await serverFetch(...args); | ||||
| @@ -30,6 +30,12 @@ export const convertDateArrayToString = (dateArray: number[], format: string = O | |||||
| return dayjs(dateString).format(format) | return dayjs(dateString).format(format) | ||||
| } | } | ||||
| } | } | ||||
| if (dateArray.length === 3) { | |||||
| if (!needTime) { | |||||
| const dateString = `${dateArray[0]}-${dateArray[1]}-${dateArray[2]}` | |||||
| return dayjs(dateString).format(format) | |||||
| } | |||||
| } | |||||
| } | } | ||||
| const shortDateFormatter_en = new Intl.DateTimeFormat("en-HK", { | const shortDateFormatter_en = new Intl.DateTimeFormat("en-HK", { | ||||
| @@ -5,18 +5,23 @@ import Profile from "./Profile"; | |||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import NavigationToggle from "./NavigationToggle"; | import NavigationToggle from "./NavigationToggle"; | ||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import { authOptions } from "@/config/authConfig"; | |||||
| import { getServerSession } from "next-auth"; | |||||
| export interface AppBarProps { | export interface AppBarProps { | ||||
| avatarImageSrc?: string; | avatarImageSrc?: string; | ||||
| profileName: string; | profileName: string; | ||||
| } | } | ||||
| const AppBar: React.FC<AppBarProps> = ({ avatarImageSrc, profileName }) => { | |||||
| const AppBar: React.FC<AppBarProps> = async ({ avatarImageSrc, profileName }) => { | |||||
| const session = await getServerSession(authOptions) as any; | |||||
| const abilities: string[] = session.abilities | |||||
| console.log(abilities) | |||||
| return ( | return ( | ||||
| <I18nProvider namespaces={["common"]}> | <I18nProvider namespaces={["common"]}> | ||||
| <MUIAppBar position="sticky" color="default" elevation={4}> | <MUIAppBar position="sticky" color="default" elevation={4}> | ||||
| <Toolbar> | <Toolbar> | ||||
| <NavigationToggle /> | |||||
| <NavigationToggle abilities={abilities}/> | |||||
| <Box | <Box | ||||
| sx={{ flexGrow: 1, display: "flex", justifyContent: "flex-end" }} | sx={{ flexGrow: 1, display: "flex", justifyContent: "flex-end" }} | ||||
| > | > | ||||
| @@ -4,8 +4,18 @@ import MenuIcon from "@mui/icons-material/Menu"; | |||||
| import NavigationContent from "../NavigationContent"; | import NavigationContent from "../NavigationContent"; | ||||
| import React from "react"; | import React from "react"; | ||||
| import Drawer from "@mui/material/Drawer"; | import Drawer from "@mui/material/Drawer"; | ||||
| import { Session } from "inspector"; | |||||
| import { authOptions } from "@/config/authConfig"; | |||||
| import { getServerSession } from "next-auth"; | |||||
| export interface SessionWithAbilities extends Session { | |||||
| abilities?: string[] | |||||
| } | |||||
| const NavigationToggle: React.FC = () => { | |||||
| interface Props { | |||||
| abilities?: string[] | |||||
| } | |||||
| const NavigationToggle: React.FC<Props> = ({ abilities }) => { | |||||
| const [isOpened, setIsOpened] = React.useState(false); | const [isOpened, setIsOpened] = React.useState(false); | ||||
| const openNavigation = () => { | const openNavigation = () => { | ||||
| @@ -18,7 +28,7 @@ const NavigationToggle: React.FC = () => { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Drawer variant="permanent" sx={{ display: { xs: "none", xl: "block" } }}> | <Drawer variant="permanent" sx={{ display: { xs: "none", xl: "block" } }}> | ||||
| <NavigationContent /> | |||||
| <NavigationContent abilities={abilities}/> | |||||
| </Drawer> | </Drawer> | ||||
| <Drawer | <Drawer | ||||
| sx={{ display: { xl: "none" } }} | sx={{ display: { xl: "none" } }} | ||||
| @@ -28,7 +38,7 @@ const NavigationToggle: React.FC = () => { | |||||
| keepMounted: true, | keepMounted: true, | ||||
| }} | }} | ||||
| > | > | ||||
| <NavigationContent /> | |||||
| <NavigationContent abilities={abilities}/> | |||||
| </Drawer> | </Drawer> | ||||
| <IconButton | <IconButton | ||||
| sx={{ display: { xl: "none" } }} | sx={{ display: { xl: "none" } }} | ||||
| @@ -10,6 +10,7 @@ import Divider from "@mui/material/Divider"; | |||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { signOut } from "next-auth/react"; | import { signOut } from "next-auth/react"; | ||||
| import { useRouter } from "next/navigation"; | |||||
| type Props = Pick<AppBarProps, "avatarImageSrc" | "profileName">; | type Props = Pick<AppBarProps, "avatarImageSrc" | "profileName">; | ||||
| @@ -26,6 +27,7 @@ const Profile: React.FC<Props> = ({ avatarImageSrc, profileName }) => { | |||||
| }; | }; | ||||
| const { t } = useTranslation("login"); | const { t } = useTranslation("login"); | ||||
| const router = useRouter(); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -52,6 +54,7 @@ const Profile: React.FC<Props> = ({ avatarImageSrc, profileName }) => { | |||||
| {profileName} | {profileName} | ||||
| </Typography> | </Typography> | ||||
| <Divider /> | <Divider /> | ||||
| <MenuItem onClick={() => {router.replace("/settings/changepassword")}}>{t("Change Password")}</MenuItem> | |||||
| <MenuItem onClick={() => signOut()}>{t("Sign out")}</MenuItem> | <MenuItem onClick={() => signOut()}>{t("Sign out")}</MenuItem> | ||||
| </Menu> | </Menu> | ||||
| </> | </> | ||||
| @@ -12,6 +12,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/home": "User Workspace", | "/home": "User Workspace", | ||||
| "/projects": "Projects", | "/projects": "Projects", | ||||
| "/projects/create": "Create Project", | "/projects/create": "Create Project", | ||||
| "/projects/edit": "Edit Project", | |||||
| "/tasks": "Task Template", | "/tasks": "Task Template", | ||||
| "/tasks/create": "Create Task Template", | "/tasks/create": "Create Task Template", | ||||
| "/staffReimbursement": "Staff Reimbursement", | "/staffReimbursement": "Staff Reimbursement", | ||||
| @@ -28,7 +29,8 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/settings/position": "Position", | "/settings/position": "Position", | ||||
| "/settings/position/new": "Create Position", | "/settings/position/new": "Create Position", | ||||
| "/settings/salarys": "Salary", | "/settings/salarys": "Salary", | ||||
| "/analytics/EX02ProjectCashFlowReport": "EX02 - Project Cash Flow Report", | |||||
| "/analytics/ProjectCashFlowReport": "Project Cash Flow Report", | |||||
| "/settings/holiday": "Holiday", | |||||
| }; | }; | ||||
| const Breadcrumb = () => { | const Breadcrumb = () => { | ||||
| @@ -0,0 +1,107 @@ | |||||
| "use client"; | |||||
| import { PasswordInputs, changePassword } from "@/app/api/user/actions"; | |||||
| import { Grid } from "@mui/material"; | |||||
| import { useRouter } from "next/navigation"; | |||||
| import { useCallback, useState } from "react"; | |||||
| import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { Button, Stack, Tab, Tabs, TabsProps, Typography } from "@mui/material"; | |||||
| import { Check, Close, Error } from "@mui/icons-material"; | |||||
| import ChagnePasswordForm from "./ChangePasswordForm"; | |||||
| import { ServerFetchError } from "@/app/utils/fetchUtil"; | |||||
| // interface Props { | |||||
| // // auth?: auth[] | |||||
| // // users?: UserResult[] | |||||
| // } | |||||
| const ChangePassword: React.FC = () => { | |||||
| const formProps = useForm<PasswordInputs>(); | |||||
| const [serverError, setServerError] = useState(""); | |||||
| const router = useRouter(); | |||||
| // const [tabIndex, setTabIndex] = useState(0); | |||||
| const { t } = useTranslation(); | |||||
| const onSubmit = useCallback<SubmitHandler<PasswordInputs>>( | |||||
| async (data) => { | |||||
| try { | |||||
| let haveError = false; | |||||
| // Minimum eight characters, at least one uppercase letter, one lowercase letter, one number and one special character: | |||||
| let regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ | |||||
| if (data.newPassword.length < 8 || data.newPassword.length > 20) { | |||||
| haveError = true | |||||
| formProps.setError("newPassword", { message: "The password requires 8-20 characters", type: "required" }) | |||||
| } | |||||
| if (!regex.test(data.newPassword)) { | |||||
| haveError = true | |||||
| formProps.setError("newPassword", { message: "A combination of uppercase letters, lowercase letters, numbers, and symbols is required.", type: "required" }) | |||||
| } | |||||
| if (data.password == data.newPassword) { | |||||
| haveError = true | |||||
| formProps.setError("newPassword", { message: "The new password cannot be the same as the old password", type: "required" }) | |||||
| } | |||||
| if (data.newPassword != data.newPasswordCheck) { | |||||
| haveError = true | |||||
| formProps.setError("newPassword", { message: "The new password has to be the same as the new password", type: "required" }) | |||||
| formProps.setError("newPasswordCheck", { message: "The new password has to be the same as the new password", type: "required" }) | |||||
| } | |||||
| if (haveError) { | |||||
| return | |||||
| } | |||||
| const postData = { | |||||
| password: data.password, | |||||
| newPassword: data.newPassword | |||||
| } | |||||
| // await changePassword(postData) | |||||
| // router.replace("/home") | |||||
| } catch (e) { | |||||
| console.log(e) | |||||
| setServerError(t("An error has occurred. Please try again later.")); | |||||
| } | |||||
| }, | |||||
| [router] | |||||
| ); | |||||
| const handleCancel = () => { | |||||
| router.push(`/home`); | |||||
| }; | |||||
| const onSubmitError = useCallback<SubmitErrorHandler<PasswordInputs>>( | |||||
| (errors) => { | |||||
| console.log(errors); | |||||
| }, | |||||
| [] | |||||
| ); | |||||
| return ( | |||||
| <FormProvider {...formProps}> | |||||
| <Stack | |||||
| spacing={2} | |||||
| component="form" | |||||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||||
| > | |||||
| <ChagnePasswordForm /> | |||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Close />} | |||||
| onClick={handleCancel} | |||||
| > | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<Check />} | |||||
| type="submit" | |||||
| // disabled={Boolean(formProps.watch("isGridEditing"))} | |||||
| > | |||||
| {t("Confirm")} | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | |||||
| </FormProvider> | |||||
| ); | |||||
| }; | |||||
| export default ChangePassword; | |||||
| @@ -0,0 +1,144 @@ | |||||
| "use client"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import Box from "@mui/material/Box"; | |||||
| import Card from "@mui/material/Card"; | |||||
| import CardContent from "@mui/material/CardContent"; | |||||
| import Grid from "@mui/material/Grid"; | |||||
| import TextField from "@mui/material/TextField"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import { useFormContext } from "react-hook-form"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { useCallback, useState } from "react"; | |||||
| import { PasswordInputs } from "@/app/api/user/actions"; | |||||
| import { Visibility, VisibilityOff } from "@mui/icons-material"; | |||||
| import { IconButton, InputAdornment } from "@mui/material"; | |||||
| const ChagnePasswordForm: React.FC = () => { | |||||
| const { t } = useTranslation(); | |||||
| const [showNewPassword, setShowNewPassword] = useState(false); | |||||
| const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword); | |||||
| const handleMouseDownNewPassword = () => setShowNewPassword(!showNewPassword); | |||||
| const [showPassword, setShowPassword] = useState(false); | |||||
| const handleClickShowPassword = () => setShowPassword(!showPassword); | |||||
| const handleMouseDownPassword = () => setShowPassword(!showPassword); | |||||
| const { | |||||
| register, | |||||
| formState: { errors, defaultValues }, | |||||
| control, | |||||
| reset, | |||||
| resetField, | |||||
| setValue, | |||||
| } = useFormContext<PasswordInputs>(); | |||||
| // const resetGroup = useCallback(() => { | |||||
| // console.log(defaultValues); | |||||
| // if (defaultValues !== undefined) { | |||||
| // resetField("description"); | |||||
| // } | |||||
| // }, [defaultValues]); | |||||
| return ( | |||||
| <Card sx={{ display: "block" }}> | |||||
| <CardContent component={Stack} spacing={4}> | |||||
| <Box> | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
| {t("Group Info")} | |||||
| </Typography> | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| label={t("Input Old Password")} | |||||
| fullWidth | |||||
| type={showPassword ? "text" : "password"} | |||||
| InputProps={{ | |||||
| endAdornment: ( | |||||
| <InputAdornment position="end"> | |||||
| <IconButton | |||||
| aria-label="toggle password visibility" | |||||
| onClick={handleClickShowPassword} | |||||
| onMouseDown={handleMouseDownPassword} | |||||
| > | |||||
| {showPassword ? <Visibility /> : <VisibilityOff />} | |||||
| </IconButton> | |||||
| </InputAdornment> | |||||
| ) | |||||
| }} | |||||
| {...register("password", { | |||||
| required: true, | |||||
| })} | |||||
| error={Boolean(errors.password)} | |||||
| helperText={ | |||||
| Boolean(errors.password) && | |||||
| (errors.password?.message | |||||
| ? t(errors.password.message) | |||||
| : t("Please input correct password")) | |||||
| } | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={6} /> | |||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| label={t("Input New Password")} | |||||
| fullWidth | |||||
| type={showNewPassword ? "text" : "password"} | |||||
| InputProps={{ | |||||
| endAdornment: ( | |||||
| <InputAdornment position="end"> | |||||
| <IconButton | |||||
| aria-label="toggle password visibility" | |||||
| onClick={handleClickShowNewPassword} | |||||
| onMouseDown={handleMouseDownNewPassword} | |||||
| > | |||||
| {showNewPassword ? <Visibility /> : <VisibilityOff />} | |||||
| </IconButton> | |||||
| </InputAdornment> | |||||
| ) | |||||
| }} | |||||
| {...register("newPassword")} | |||||
| error={Boolean(errors.newPassword)} | |||||
| helperText={ | |||||
| Boolean(errors.newPassword) && | |||||
| (errors.newPassword?.message | |||||
| ? t(errors.newPassword.message) | |||||
| : t("Please input correct newPassword")) | |||||
| } | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| label={t("Input New Password Again")} | |||||
| fullWidth | |||||
| type={showNewPassword ? "text" : "password"} | |||||
| InputProps={{ | |||||
| endAdornment: ( | |||||
| <InputAdornment position="end"> | |||||
| <IconButton | |||||
| aria-label="toggle password visibility" | |||||
| onClick={handleClickShowNewPassword} | |||||
| onMouseDown={handleMouseDownNewPassword} | |||||
| > | |||||
| {showNewPassword ? <Visibility /> : <VisibilityOff />} | |||||
| </IconButton> | |||||
| </InputAdornment> | |||||
| ) | |||||
| }} | |||||
| {...register("newPasswordCheck")} | |||||
| error={Boolean(errors.newPassword)} | |||||
| helperText={ | |||||
| Boolean(errors.newPassword) && | |||||
| (errors.newPassword?.message | |||||
| ? t(errors.newPassword.message) | |||||
| : t("Please input correct newPassword")) | |||||
| } | |||||
| /> | |||||
| </Grid> | |||||
| </Grid> | |||||
| </Box> | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| }; | |||||
| export default ChagnePasswordForm; | |||||
| @@ -0,0 +1,40 @@ | |||||
| import Card from "@mui/material/Card"; | |||||
| import CardContent from "@mui/material/CardContent"; | |||||
| import Skeleton from "@mui/material/Skeleton"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import React from "react"; | |||||
| // Can make this nicer | |||||
| export const ChangePasswordLoading: React.FC = () => { | |||||
| return ( | |||||
| <> | |||||
| <Card> | |||||
| <CardContent> | |||||
| <Stack spacing={2}> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton | |||||
| variant="rounded" | |||||
| height={50} | |||||
| width={100} | |||||
| sx={{ alignSelf: "flex-end" }} | |||||
| /> | |||||
| </Stack> | |||||
| </CardContent> | |||||
| </Card> | |||||
| <Card>Change Password | |||||
| <CardContent> | |||||
| <Stack spacing={2}> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| </Stack> | |||||
| </CardContent> | |||||
| </Card> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default ChangePasswordLoading; | |||||
| @@ -0,0 +1,20 @@ | |||||
| import React from "react"; | |||||
| import ChangePasswordLoading from "./ChangePasswordLoading"; | |||||
| import ChangePassword from "./ChangePassword"; | |||||
| interface SubComponents { | |||||
| Loading: typeof ChangePasswordLoading; | |||||
| } | |||||
| const ChangePasswordWrapper: React.FC & SubComponents = async () => { | |||||
| // const records = await fetchAuth() | |||||
| // const users = await fetchUser() | |||||
| // console.log(users) | |||||
| // const auth = records.records as auth[] | |||||
| return <ChangePassword />; | |||||
| }; | |||||
| ChangePasswordWrapper.Loading = ChangePasswordLoading; | |||||
| export default ChangePasswordWrapper; | |||||
| @@ -0,0 +1 @@ | |||||
| export { default } from "./ChangePasswordWrapper"; | |||||
| @@ -1 +0,0 @@ | |||||
| export { default } from "./ClaimDetailWrapper"; | |||||
| @@ -21,7 +21,7 @@ export interface Props { | |||||
| projectCombo: ProjectCombo[] | projectCombo: ProjectCombo[] | ||||
| } | } | ||||
| const ClaimDetail: React.FC<Props> = ({ projectCombo }) => { | |||||
| const ClaimSave: React.FC<Props> = ({ projectCombo }) => { | |||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const [serverError, setServerError] = useState(""); | const [serverError, setServerError] = useState(""); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| @@ -74,15 +74,15 @@ const ClaimDetail: React.FC<Props> = ({ projectCombo }) => { | |||||
| const buttonName = (event?.nativeEvent as any).submitter.name | const buttonName = (event?.nativeEvent as any).submitter.name | ||||
| const formData = new FormData() | const formData = new FormData() | ||||
| formData.append("expenseType", data.expenseType) | formData.append("expenseType", data.expenseType) | ||||
| data.addClaimDetails.forEach((claimDetail) => { | |||||
| console.log(claimDetail) | |||||
| formData.append("addClaimDetailIds", JSON.stringify(claimDetail.id)) | |||||
| formData.append("addClaimDetailInvoiceDates", convertDateToString(claimDetail.invoiceDate, "YYYY-MM-DD")) | |||||
| formData.append("addClaimDetailProjectIds", JSON.stringify(claimDetail.project)) | |||||
| formData.append("addClaimDetailDescriptions", claimDetail.description) | |||||
| formData.append("addClaimDetailAmounts", JSON.stringify(claimDetail.amount)) | |||||
| formData.append("addClaimDetailNewSupportingDocuments", claimDetail.newSupportingDocument) | |||||
| formData.append("addClaimDetailOldSupportingDocumentIds", JSON.stringify(claimDetail?.oldSupportingDocument?.id ?? -1)) | |||||
| data.addClaimDetails.forEach((ClaimSave) => { | |||||
| console.log(ClaimSave) | |||||
| formData.append("addClaimDetailIds", JSON.stringify(ClaimSave.id)) | |||||
| formData.append("addClaimDetailInvoiceDates", convertDateToString(ClaimSave.invoiceDate, "YYYY-MM-DD")) | |||||
| formData.append("addClaimDetailProjectIds", JSON.stringify(ClaimSave.project)) | |||||
| formData.append("addClaimDetailDescriptions", ClaimSave.description) | |||||
| formData.append("addClaimDetailAmounts", JSON.stringify(ClaimSave.amount)) | |||||
| formData.append("addClaimDetailNewSupportingDocuments", ClaimSave.newSupportingDocument) | |||||
| formData.append("addClaimDetailOldSupportingDocumentIds", JSON.stringify(ClaimSave?.oldSupportingDocument?.id ?? -1)) | |||||
| }) | }) | ||||
| // for (let i = 0; i < data.addClaimDetails.length; i++) { | // for (let i = 0; i < data.addClaimDetails.length; i++) { | ||||
| // const updatedData = { | // const updatedData = { | ||||
| @@ -155,4 +155,4 @@ const ClaimDetail: React.FC<Props> = ({ projectCombo }) => { | |||||
| ); | ); | ||||
| }; | }; | ||||
| export default ClaimDetail; | |||||
| export default ClaimSave; | |||||
| @@ -1,6 +1,6 @@ | |||||
| import React from "react"; | import React from "react"; | ||||
| import ClaimDetail from "./ClaimDetail"; | |||||
| import ClaimSave from "./ClaimSave"; | |||||
| import { fetchProjectCombo } from "@/app/api/claims"; | import { fetchProjectCombo } from "@/app/api/claims"; | ||||
| // import TaskSetup from "./TaskSetup"; | // import TaskSetup from "./TaskSetup"; | ||||
| // import StaffAllocation from "./StaffAllocation"; | // import StaffAllocation from "./StaffAllocation"; | ||||
| @@ -13,7 +13,7 @@ const ClaimDetailWrapper: React.FC = async () => { | |||||
| ]); | ]); | ||||
| return ( | return ( | ||||
| <ClaimDetail projectCombo={projectCombo}/> | |||||
| <ClaimSave projectCombo={projectCombo}/> | |||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -0,0 +1 @@ | |||||
| export { default } from "./ClaimSaveWrapper"; | |||||
| @@ -0,0 +1,227 @@ | |||||
| "use client"; | |||||
| import { HolidaysList, HolidaysResult } from "@/app/api/holidays"; | |||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Grid, Stack } from '@mui/material/'; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import FullCalendar from '@fullcalendar/react' | |||||
| import dayGridPlugin from '@fullcalendar/daygrid' // a plugin! | |||||
| import interactionPlugin from "@fullcalendar/interaction" // needed for dayClick | |||||
| import listPlugin from '@fullcalendar/list'; | |||||
| import Holidays from "date-holidays"; | |||||
| import CompanyHolidayDialog from "./CompanyHolidayDialog"; | |||||
| import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm, useFormContext } from "react-hook-form"; | |||||
| import { EventBusy } from "@mui/icons-material"; | |||||
| import { deleteCompanyHoliday, saveCompanyHoliday } from "@/app/api/holidays/actions"; | |||||
| import { useRouter } from "next/navigation"; | |||||
| import { deleteDialog, submitDialog } from "../Swal/CustomAlerts"; | |||||
| interface Props { | |||||
| holidays: HolidaysList[]; | |||||
| } | |||||
| const CompanyHoliday: React.FC<Props> = ({ holidays }) => { | |||||
| const { t } = useTranslation("holidays"); | |||||
| const router = useRouter(); | |||||
| const formValues = useFormContext(); | |||||
| const [serverError, setServerError] = useState(""); | |||||
| const hd = new Holidays('HK') | |||||
| console.log(holidays) | |||||
| const [companyHolidays, setCompanyHolidays] = useState<HolidaysList[]>([]) | |||||
| const [dateContent, setDateContent] = useState<{ date: string }>({date: ''}) | |||||
| const [open, setOpen] = useState(false); | |||||
| const [isEdit, setIsEdit] = useState(false); | |||||
| const [editable, setEditable] = useState(true); | |||||
| const handleClose = () => { | |||||
| setOpen(false); | |||||
| setEditable(true) | |||||
| setIsEdit(false) | |||||
| formProps.setValue("name", "") | |||||
| formProps.setValue("id", null) | |||||
| }; | |||||
| const getPublicHolidaysList = () => { | |||||
| const currentYear = new Date().getFullYear() | |||||
| const currentYearHolidays = hd.getHolidays(currentYear) | |||||
| const nextYearHolidays = hd.getHolidays(currentYear + 1) | |||||
| const events_cyhd = currentYearHolidays.map(ele => { | |||||
| const tempDay = new Date(ele.date) | |||||
| const tempYear = tempDay.getFullYear() | |||||
| const tempMonth = tempDay.getMonth() + 1 < 10 ? `0${ tempDay.getMonth() + 1}` : tempDay.getMonth() + 1 | |||||
| const tempDate = tempDay.getDate() < 10 ? `0${tempDay.getDate()}` : tempDay.getDate() | |||||
| let tempName = "" | |||||
| switch (ele.name) { | |||||
| case "复活节": | |||||
| tempName = "復活節" | |||||
| break | |||||
| case "劳动节": | |||||
| tempName = "勞動節" | |||||
| break | |||||
| case "端午节": | |||||
| tempName = "端午節" | |||||
| break | |||||
| case "重阳节": | |||||
| tempName = "重陽節" | |||||
| break | |||||
| case "圣诞节后的第一个工作日": | |||||
| tempName = "聖誕節後的第一个工作日" | |||||
| break | |||||
| default: | |||||
| tempName = ele.name | |||||
| break | |||||
| } | |||||
| return {date: `${tempYear}-${tempMonth}-${tempDate}`, title: tempName, extendedProps: {calendar: 'holiday'}} | |||||
| }) | |||||
| const events_nyhd = nextYearHolidays.map(ele => { | |||||
| const tempDay = new Date(ele.date) | |||||
| const tempYear = tempDay.getFullYear() | |||||
| const tempMonth = tempDay.getMonth() + 1 < 10 ? `0${ tempDay.getMonth() + 1}` : tempDay.getMonth() + 1 | |||||
| const tempDate = tempDay.getDate() < 10 ? `0${tempDay.getDate()}` : tempDay.getDate() | |||||
| let tempName = "" | |||||
| switch (ele.name) { | |||||
| case "复活节": | |||||
| tempName = "復活節" | |||||
| break | |||||
| case "劳动节": | |||||
| tempName = "勞動節" | |||||
| break | |||||
| case "端午节": | |||||
| tempName = "端午節" | |||||
| break | |||||
| case "重阳节": | |||||
| tempName = "重陽節" | |||||
| break | |||||
| case "圣诞节后的第一个工作日": | |||||
| tempName = "聖誕節後的第一个工作日" | |||||
| break | |||||
| default: | |||||
| tempName = ele.name | |||||
| break | |||||
| } | |||||
| return {date: `${tempYear}-${tempMonth}-${tempDate}`, title: tempName, extendedProps: {calendar: 'holiday'}} | |||||
| }) | |||||
| setCompanyHolidays([...events_cyhd, ...events_nyhd, ...holidays] as HolidaysList[]) | |||||
| } | |||||
| useEffect(()=>{ | |||||
| getPublicHolidaysList() | |||||
| },[]) | |||||
| useEffect(()=>{ | |||||
| },[holidays]) | |||||
| const handleDateClick = (event:any) => { | |||||
| // console.log(event.dateStr) | |||||
| setDateContent({date: event.dateStr}) | |||||
| setOpen(true); | |||||
| } | |||||
| const handleEventClick = (event:any) => { | |||||
| // event.event.id: if id !== "", holiday is created by company | |||||
| console.log(event.event.id) | |||||
| if (event.event.id === null || event.event.id === ""){ | |||||
| setEditable(false) | |||||
| } | |||||
| formProps.setValue("name", event.event.title) | |||||
| formProps.setValue("id", event.event.id) | |||||
| setDateContent({date: event.event.startStr}) | |||||
| setOpen(true); | |||||
| setIsEdit(true); | |||||
| } | |||||
| const onSubmit = useCallback<SubmitHandler<any>>( | |||||
| async (data) => { | |||||
| try { | |||||
| // console.log(data); | |||||
| setServerError(""); | |||||
| submitDialog(async () => { | |||||
| await saveCompanyHoliday(data) | |||||
| window.location.reload() | |||||
| setOpen(false); | |||||
| setIsEdit(false); | |||||
| }, t) | |||||
| } catch (e) { | |||||
| console.log(e); | |||||
| setServerError(t("An error has occurred. Please try again later.")); | |||||
| } | |||||
| }, | |||||
| [t, router], | |||||
| ); | |||||
| const handleDelete = async (event:any) => { | |||||
| try { | |||||
| setServerError(""); | |||||
| deleteDialog(async () => { | |||||
| await deleteCompanyHoliday(parseInt(formProps.getValues("id"))) | |||||
| window.location.reload() | |||||
| setOpen(false); | |||||
| setIsEdit(false); | |||||
| }, t); | |||||
| } catch (e) { | |||||
| console.log(e); | |||||
| setServerError(t("An error has occurred. Please try again later.")); | |||||
| } | |||||
| } | |||||
| const onSubmitError = useCallback<SubmitErrorHandler<any>>( | |||||
| (errors) => { | |||||
| console.log(errors) | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| const formProps = useForm<any>({ | |||||
| defaultValues: { | |||||
| id: null, | |||||
| name: "" | |||||
| }, | |||||
| }); | |||||
| return ( | |||||
| <> | |||||
| <FormProvider {...formProps}> | |||||
| <FullCalendar | |||||
| plugins={[ dayGridPlugin, interactionPlugin, listPlugin ]} | |||||
| initialView="dayGridMonth" | |||||
| events={companyHolidays} | |||||
| eventColor='#ff0000' | |||||
| dateClick={handleDateClick} | |||||
| eventClick={handleEventClick} | |||||
| headerToolbar={{ | |||||
| start: "today prev next", | |||||
| end: "dayGridMonth listMonth" | |||||
| }} | |||||
| buttonText={{ | |||||
| month: t("Calender View"), | |||||
| list: t("List View"), | |||||
| today: t("Today") | |||||
| }} | |||||
| /> | |||||
| <CompanyHolidayDialog | |||||
| open={open} | |||||
| onClose={handleClose} | |||||
| title={!editable ? "Bank Holiday" : isEdit ? "Edit Holiday" : "Create Holiday"} | |||||
| content={dateContent} | |||||
| actions={ | |||||
| <Stack direction="row" justifyContent="flex-end" gap={1} component="form" onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}> | |||||
| <Button onClick={handleClose}>Close</Button> | |||||
| {isEdit && <Button disabled={!editable} onClick={handleDelete}>Delete</Button>} | |||||
| <Button disabled={!editable} type="submit">Submit</Button> | |||||
| </Stack> | |||||
| } | |||||
| editable={editable} | |||||
| /> | |||||
| </FormProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default CompanyHoliday; | |||||
| @@ -0,0 +1,87 @@ | |||||
| import React, { useState, useEffect } from 'react'; | |||||
| import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Grid, FormControl } from '@mui/material/'; | |||||
| import { useForm, useFormContext } from 'react-hook-form'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; | |||||
| import dayjs from 'dayjs'; | |||||
| import { INPUT_DATE_FORMAT } from '@/app/utils/formatUtil'; | |||||
| import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; | |||||
| interface CompanyHolidayDialogProps { | |||||
| open: boolean; | |||||
| onClose: () => void; | |||||
| title: string; | |||||
| actions: React.ReactNode; | |||||
| content: Content; | |||||
| editable: Boolean; | |||||
| } | |||||
| interface Content { | |||||
| date: string | |||||
| } | |||||
| const CompanyHolidayDialog: React.FC<CompanyHolidayDialogProps> = ({ open, onClose, title, actions, content, editable }) => { | |||||
| const { | |||||
| t, | |||||
| i18n: { language }, | |||||
| } = useTranslation(); | |||||
| const { | |||||
| register, | |||||
| formState: { errors }, | |||||
| setValue, | |||||
| } = useFormContext<any>(); | |||||
| useEffect(() => { | |||||
| setValue("date", content.date); | |||||
| }, [content]) | |||||
| console.log(editable) | |||||
| return ( | |||||
| <LocalizationProvider | |||||
| dateAdapter={AdapterDayjs} | |||||
| adapterLocale={`${language}-hk`} | |||||
| > | |||||
| <Dialog open={open} onClose={onClose}> | |||||
| <DialogTitle>{title}</DialogTitle> | |||||
| <DialogContent> | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||||
| <Grid item xs={12}> | |||||
| <TextField | |||||
| disabled={!editable} | |||||
| label={t("Description")} | |||||
| fullWidth | |||||
| {...register("name", { | |||||
| required: "Description required!", | |||||
| })} | |||||
| error={Boolean(errors.name)} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | |||||
| <FormControl fullWidth> | |||||
| <DatePicker | |||||
| disabled={!editable} | |||||
| label={t("Company Holiday")} | |||||
| value={dayjs(content.date)} | |||||
| onChange={(date) => { | |||||
| if (!date) return; | |||||
| setValue("date", date.format(INPUT_DATE_FORMAT)); | |||||
| }} | |||||
| slotProps={{ | |||||
| textField: { | |||||
| helperText: 'MM/DD/YYYY', | |||||
| }, | |||||
| }} | |||||
| /> | |||||
| </FormControl> | |||||
| </Grid> | |||||
| </Grid> | |||||
| </DialogContent> | |||||
| <DialogActions>{actions}</DialogActions> | |||||
| </Dialog> | |||||
| </LocalizationProvider> | |||||
| ); | |||||
| }; | |||||
| export default CompanyHolidayDialog; | |||||
| @@ -0,0 +1,40 @@ | |||||
| import Card from "@mui/material/Card"; | |||||
| import CardContent from "@mui/material/CardContent"; | |||||
| import Skeleton from "@mui/material/Skeleton"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import React from "react"; | |||||
| // Can make this nicer | |||||
| export const CompanyHolidayLoading: React.FC = () => { | |||||
| return ( | |||||
| <> | |||||
| <Card> | |||||
| <CardContent> | |||||
| <Stack spacing={2}> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton | |||||
| variant="rounded" | |||||
| height={50} | |||||
| width={100} | |||||
| sx={{ alignSelf: "flex-end" }} | |||||
| /> | |||||
| </Stack> | |||||
| </CardContent> | |||||
| </Card> | |||||
| <Card> | |||||
| <CardContent> | |||||
| <Stack spacing={2}> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| </Stack> | |||||
| </CardContent> | |||||
| </Card> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default CompanyHolidayLoading; | |||||
| @@ -0,0 +1,34 @@ | |||||
| // import { fetchCompanyCategories, fetchCompanys } from "@/app/api/companys"; | |||||
| import React, { useState, } from "react"; | |||||
| import CompanyHoliday from "./CompanyHoliday"; | |||||
| import CompanyHolidayLoading from "./CompanyHolidayLoading"; | |||||
| import { fetchCompanys } from "@/app/api/companys"; | |||||
| import Holidays from "date-holidays"; | |||||
| import { HolidaysResult, fetchHolidays, HolidaysList } from "@/app/api/holidays"; | |||||
| import { convertDateArrayToString } from "@/app/utils/formatUtil"; | |||||
| interface SubComponents { | |||||
| Loading: typeof CompanyHolidayLoading; | |||||
| } | |||||
| const CompanyHolidayWrapper: React.FC & SubComponents = async () => { | |||||
| // const Companys = await fetchCompanys(); | |||||
| const companyHolidays: HolidaysResult[] = await fetchHolidays() | |||||
| // console.log(companyHolidays) | |||||
| const convertedHolidays = companyHolidays.map((holiday) => { | |||||
| return { | |||||
| id: holiday.id.toString(), | |||||
| title: holiday.name, | |||||
| date: convertDateArrayToString(holiday.date, "YYYY-MM-DD", false) | |||||
| } | |||||
| }) | |||||
| return <CompanyHoliday holidays={convertedHolidays as HolidaysList[]} />; | |||||
| }; | |||||
| CompanyHolidayWrapper.Loading = CompanyHolidayLoading; | |||||
| export default CompanyHolidayWrapper; | |||||
| @@ -0,0 +1 @@ | |||||
| export { default } from "./CompanyHolidayWrapper"; | |||||
| @@ -22,18 +22,23 @@ import { Error } from "@mui/icons-material"; | |||||
| import { ProjectCategory } from "@/app/api/projects"; | import { ProjectCategory } from "@/app/api/projects"; | ||||
| import { Typography } from "@mui/material"; | import { Typography } from "@mui/material"; | ||||
| import DepartmentDetails from "./DepartmentDetails"; | import DepartmentDetails from "./DepartmentDetails"; | ||||
| import { DepartmentResult } from "@/app/api/departments"; | |||||
| interface Props { | |||||
| isEdit: Boolean; | |||||
| department?: CreateDepartmentInputs; | |||||
| } | |||||
| const CreateDepartment: React.FC = ({ | |||||
| // allTasks, | |||||
| // projectCategories, | |||||
| // taskTemplates, | |||||
| // teamLeads, | |||||
| const CreateDepartment: React.FC<Props> = ({ | |||||
| isEdit, | |||||
| department, | |||||
| }) => { | }) => { | ||||
| const [serverError, setServerError] = useState(""); | const [serverError, setServerError] = useState(""); | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| console.log(department) | |||||
| const handleCancel = () => { | const handleCancel = () => { | ||||
| router.back(); | router.back(); | ||||
| }; | }; | ||||
| @@ -62,9 +67,10 @@ const CreateDepartment: React.FC = ({ | |||||
| const formProps = useForm<CreateDepartmentInputs>({ | const formProps = useForm<CreateDepartmentInputs>({ | ||||
| defaultValues: { | defaultValues: { | ||||
| departmentCode: "", | |||||
| departmentName: "", | |||||
| description: "", | |||||
| id: department?.id, | |||||
| code: department?.code, | |||||
| name: department?.name, | |||||
| description: department?.description, | |||||
| }, | }, | ||||
| }); | }); | ||||
| @@ -1,18 +1,24 @@ | |||||
| import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | ||||
| import CreateDepartment from "./CreateDepartment"; | import CreateDepartment from "./CreateDepartment"; | ||||
| import { fetchTeamLeads } from "@/app/api/staff"; | import { fetchTeamLeads } from "@/app/api/staff"; | ||||
| import { DepartmentResult, fetchDepartmentDetails } from "@/app/api/departments"; | |||||
| const CreateDepartmentWrapper: React.FC = async () => { | |||||
| // const [tasks, taskTemplates, DepartmentCategories, teamLeads] = | |||||
| // await Promise.all([ | |||||
| // fetchAllTasks(), | |||||
| // fetchTaskTemplates(), | |||||
| // fetchDepartmentCategories(), | |||||
| // fetchTeamLeads(), | |||||
| // ]); | |||||
| type CreateDepartmentProps = { isEdit: false }; | |||||
| interface EditDepartmentProps { | |||||
| isEdit: true; | |||||
| departmentId?: string; | |||||
| } | |||||
| type Props = CreateDepartmentProps | EditDepartmentProps; | |||||
| const CreateDepartmentWrapper: React.FC<Props> = async (props) => { | |||||
| const departmentInfo = props.isEdit | |||||
| ? await fetchDepartmentDetails(props.departmentId!) | |||||
| : undefined; | |||||
| return ( | return ( | ||||
| <CreateDepartment | |||||
| <CreateDepartment isEdit department={departmentInfo} | |||||
| /> | /> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -39,20 +39,20 @@ const DepartmentDetails: React.FC = ({ | |||||
| <TextField | <TextField | ||||
| label={t("Department Code")} | label={t("Department Code")} | ||||
| fullWidth | fullWidth | ||||
| {...register("departmentCode", { | |||||
| {...register("code", { | |||||
| required: "Department code required!", | required: "Department code required!", | ||||
| })} | })} | ||||
| error={Boolean(errors.departmentCode)} | |||||
| error={Boolean(errors.code)} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={t("Department Name")} | label={t("Department Name")} | ||||
| fullWidth | fullWidth | ||||
| {...register("departmentName", { | |||||
| {...register("name", { | |||||
| required: "Department name required!", | required: "Department name required!", | ||||
| })} | })} | ||||
| error={Boolean(errors.departmentName)} | |||||
| error={Boolean(errors.name)} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| @@ -0,0 +1,208 @@ | |||||
| "use client"; | |||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { | |||||
| FieldErrors, | |||||
| FormProvider, | |||||
| SubmitErrorHandler, | |||||
| SubmitHandler, | |||||
| useForm, | |||||
| useFormContext, | |||||
| } from "react-hook-form"; | |||||
| import { | |||||
| Box, | |||||
| Card, | |||||
| CardContent, | |||||
| Grid, | |||||
| IconButton, | |||||
| InputAdornment, | |||||
| Stack, | |||||
| Tab, | |||||
| Tabs, | |||||
| TabsProps, | |||||
| TextField, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import { differenceBy } from "lodash"; | |||||
| import { CreateGroupInputs, auth } from "@/app/api/group/actions"; | |||||
| import SearchResults, { Column } from "../SearchResults"; | |||||
| import { Add, Clear, Remove, Search } from "@mui/icons-material"; | |||||
| export interface Props { | |||||
| auth: auth[]; | |||||
| } | |||||
| const AuthorityAllocation: React.FC<Props> = ({ auth }) => { | |||||
| const { t } = useTranslation(); | |||||
| const { | |||||
| setValue, | |||||
| getValues, | |||||
| formState: { defaultValues }, | |||||
| reset, | |||||
| resetField, | |||||
| } = useFormContext<CreateGroupInputs>(); | |||||
| const initialAuths = auth.map((a) => ({ ...a })).sort((a, b) => a.id - b.id); | |||||
| const [filteredAuths, setFilteredAuths] = useState(initialAuths); | |||||
| const [selectedAuths, setSelectedAuths] = useState<typeof filteredAuths>( | |||||
| () => { | |||||
| return filteredAuths.filter( | |||||
| (s) => getValues("addAuthIds")?.includes(s.id) | |||||
| ); | |||||
| } | |||||
| ); | |||||
| // Adding / Removing Auth | |||||
| const addAuth = useCallback((auth: auth) => { | |||||
| setSelectedAuths((a) => [...a, auth]); | |||||
| }, []); | |||||
| const removeAuth = useCallback((auth: auth) => { | |||||
| setSelectedAuths((a) => a.filter((a) => a.id !== auth.id)); | |||||
| }, []); | |||||
| const clearAuth = useCallback(() => { | |||||
| if (defaultValues !== undefined) { | |||||
| resetField("addAuthIds"); | |||||
| setSelectedAuths( | |||||
| initialAuths.filter((s) => defaultValues.addAuthIds?.includes(s.id)) | |||||
| ); | |||||
| } | |||||
| }, [defaultValues]); | |||||
| // Sync with form | |||||
| useEffect(() => { | |||||
| setValue( | |||||
| "addAuthIds", | |||||
| selectedAuths.map((a) => a.id) | |||||
| ); | |||||
| }, [selectedAuths, setValue]); | |||||
| const AuthPoolColumns = useMemo<Column<auth>[]>( | |||||
| () => [ | |||||
| { | |||||
| label: t("Add"), | |||||
| name: "id", | |||||
| onClick: addAuth, | |||||
| buttonIcon: <Add />, | |||||
| }, | |||||
| { label: t("authority"), name: "authority" }, | |||||
| { label: t("Auth Name"), name: "name" }, | |||||
| // { label: t("Current Position"), name: "currentPosition" }, | |||||
| ], | |||||
| [addAuth, t] | |||||
| ); | |||||
| const allocatedAuthColumns = useMemo<Column<auth>[]>( | |||||
| () => [ | |||||
| { | |||||
| label: t("Remove"), | |||||
| name: "id", | |||||
| onClick: removeAuth, | |||||
| buttonIcon: <Remove color="warning"/>, | |||||
| }, | |||||
| { label: t("authority"), name: "authority" }, | |||||
| { label: t("Auth Name"), name: "name" }, | |||||
| ], | |||||
| [removeAuth, selectedAuths, t] | |||||
| ); | |||||
| const [query, setQuery] = React.useState(""); | |||||
| const onQueryInputChange = React.useCallback< | |||||
| React.ChangeEventHandler<HTMLInputElement> | |||||
| >((e) => { | |||||
| setQuery(e.target.value); | |||||
| }, []); | |||||
| const clearQueryInput = React.useCallback(() => { | |||||
| setQuery(""); | |||||
| }, []); | |||||
| React.useEffect(() => { | |||||
| // setFilteredStaff( | |||||
| // initialStaffs.filter((s) => { | |||||
| // const q = query.toLowerCase(); | |||||
| // // s.staffId.toLowerCase().includes(q) | |||||
| // // const q = query.toLowerCase(); | |||||
| // // return s.name.toLowerCase().includes(q); | |||||
| // // s.code.toString().includes(q) || | |||||
| // // (s.brNo != null && s.brNo.toLowerCase().includes(q)) | |||||
| // }) | |||||
| // ); | |||||
| }, [auth, query]); | |||||
| const resetAuth = React.useCallback(() => { | |||||
| clearQueryInput(); | |||||
| clearAuth(); | |||||
| }, [clearQueryInput, clearAuth]); | |||||
| const formProps = useForm({}); | |||||
| // Tab related | |||||
| const [tabIndex, setTabIndex] = React.useState(0); | |||||
| const handleTabChange = React.useCallback<NonNullable<TabsProps["onChange"]>>( | |||||
| (_e, newValue) => { | |||||
| setTabIndex(newValue); | |||||
| }, | |||||
| [] | |||||
| ); | |||||
| return ( | |||||
| <> | |||||
| <FormProvider {...formProps}> | |||||
| <Card sx={{ display: "block" }}> | |||||
| <CardContent | |||||
| sx={{ display: "flex", flexDirection: "column", gap: 1 }} | |||||
| > | |||||
| <Stack gap={2}> | |||||
| <Typography variant="overline" display="block"> | |||||
| {t("Authority")} | |||||
| </Typography> | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||||
| <Grid item xs={6} display="flex" alignItems="center"> | |||||
| <Search sx={{ marginInlineEnd: 1 }} /> | |||||
| <TextField | |||||
| variant="standard" | |||||
| fullWidth | |||||
| onChange={onQueryInputChange} | |||||
| value={query} | |||||
| placeholder={t("Search by staff ID, name or position.")} | |||||
| InputProps={{ | |||||
| endAdornment: query && ( | |||||
| <InputAdornment position="end"> | |||||
| <IconButton onClick={clearQueryInput}> | |||||
| <Clear /> | |||||
| </IconButton> | |||||
| </InputAdornment> | |||||
| ), | |||||
| }} | |||||
| /> | |||||
| </Grid> | |||||
| </Grid> | |||||
| <Tabs value={tabIndex} onChange={handleTabChange}> | |||||
| <Tab label={t("Authority Pool")} /> | |||||
| <Tab | |||||
| label={`${t("Allocated Authority")} (${selectedAuths.length})`} | |||||
| /> | |||||
| </Tabs> | |||||
| <Box sx={{ marginInline: -3 }}> | |||||
| {tabIndex === 0 && ( | |||||
| <SearchResults | |||||
| noWrapper | |||||
| items={differenceBy(filteredAuths, selectedAuths, "id")} | |||||
| columns={AuthPoolColumns} | |||||
| /> | |||||
| )} | |||||
| {tabIndex === 1 && ( | |||||
| <SearchResults | |||||
| noWrapper | |||||
| items={selectedAuths} | |||||
| columns={allocatedAuthColumns} | |||||
| /> | |||||
| )} | |||||
| </Box> | |||||
| </Stack> | |||||
| </CardContent> | |||||
| </Card> | |||||
| </FormProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default AuthorityAllocation; | |||||
| @@ -0,0 +1,130 @@ | |||||
| "use client"; | |||||
| import { CreateGroupInputs, auth, saveGroup } from "@/app/api/group/actions"; | |||||
| import { useRouter } from "next/navigation"; | |||||
| import { useCallback, useState } from "react"; | |||||
| import { FieldErrors, FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { Button, Stack, Tab, Tabs, TabsProps, Typography } from "@mui/material"; | |||||
| import { Check, Close, Error } from "@mui/icons-material"; | |||||
| import GroupInfo from "./GroupInfo"; | |||||
| import AuthorityAllocation from "./AuthorityAllocation"; | |||||
| import UserAllocation from "./UserAllocation"; | |||||
| import { UserResult } from "@/app/api/user"; | |||||
| interface Props { | |||||
| auth?: auth[] | |||||
| users?: UserResult[] | |||||
| } | |||||
| const CreateGroup: React.FC<Props> = ({ auth, users }) => { | |||||
| const formProps = useForm<CreateGroupInputs>(); | |||||
| const [serverError, setServerError] = useState(""); | |||||
| const router = useRouter(); | |||||
| const [tabIndex, setTabIndex] = useState(0); | |||||
| const { t } = useTranslation(); | |||||
| const errors = formProps.formState.errors; | |||||
| const onSubmit = useCallback<SubmitHandler<CreateGroupInputs>>( | |||||
| async (data) => { | |||||
| try { | |||||
| console.log(data); | |||||
| const postData = { | |||||
| ...data, | |||||
| removeUserIds: [], | |||||
| removeAuthIds: [], | |||||
| } | |||||
| console.log(postData) | |||||
| await saveGroup(postData) | |||||
| router.replace("/settings/group") | |||||
| } catch (e) { | |||||
| console.log(e); | |||||
| setServerError(t("An error has occurred. Please try again later.")); | |||||
| } | |||||
| }, | |||||
| [router] | |||||
| ); | |||||
| const handleCancel = () => { | |||||
| router.back(); | |||||
| }; | |||||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||||
| (_e, newValue) => { | |||||
| setTabIndex(newValue); | |||||
| }, | |||||
| [] | |||||
| ); | |||||
| const hasErrorsInTab = ( | |||||
| tabIndex: number, | |||||
| errors: FieldErrors<CreateGroupInputs>, | |||||
| ) => { | |||||
| switch (tabIndex) { | |||||
| case 0: | |||||
| return Object.keys(errors).length > 0; | |||||
| default: | |||||
| false; | |||||
| } | |||||
| }; | |||||
| return ( | |||||
| <> | |||||
| <FormProvider {...formProps}> | |||||
| <Stack | |||||
| spacing={2} | |||||
| component="form" | |||||
| onSubmit={formProps.handleSubmit(onSubmit)} | |||||
| > | |||||
| <Tabs | |||||
| value={tabIndex} | |||||
| onChange={handleTabChange} | |||||
| variant="scrollable" | |||||
| > | |||||
| <Tab | |||||
| label={t("Group Info")} | |||||
| icon={ | |||||
| hasErrorsInTab(0, errors) ? ( | |||||
| <Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||||
| ) : undefined | |||||
| } | |||||
| iconPosition="end" | |||||
| /> | |||||
| <Tab label={t("Authority Allocation")} iconPosition="end" /> | |||||
| <Tab label={t("User Allocation")} iconPosition="end" /> | |||||
| </Tabs> | |||||
| {serverError && ( | |||||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||||
| {serverError} | |||||
| </Typography> | |||||
| )} | |||||
| {tabIndex === 0 && <GroupInfo/>} | |||||
| {tabIndex === 1 && <AuthorityAllocation auth={auth!!}/>} | |||||
| {tabIndex === 2 && <UserAllocation users={users!!}/>} | |||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Close />} | |||||
| onClick={handleCancel} | |||||
| > | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<Check />} | |||||
| type="submit" | |||||
| // disabled={Boolean(formProps.watch("isGridEditing"))} | |||||
| > | |||||
| {t("Confirm")} | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | |||||
| </FormProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default CreateGroup; | |||||
| @@ -0,0 +1,40 @@ | |||||
| import Card from "@mui/material/Card"; | |||||
| import CardContent from "@mui/material/CardContent"; | |||||
| import Skeleton from "@mui/material/Skeleton"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import React from "react"; | |||||
| // Can make this nicer | |||||
| export const CreateGroupLoading: React.FC = () => { | |||||
| return ( | |||||
| <> | |||||
| <Card> | |||||
| <CardContent> | |||||
| <Stack spacing={2}> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton variant="rounded" height={60} /> | |||||
| <Skeleton | |||||
| variant="rounded" | |||||
| height={50} | |||||
| width={100} | |||||
| sx={{ alignSelf: "flex-end" }} | |||||
| /> | |||||
| </Stack> | |||||
| </CardContent> | |||||
| </Card> | |||||
| <Card>Create Group | |||||
| <CardContent> | |||||
| <Stack spacing={2}> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| <Skeleton variant="rounded" height={40} /> | |||||
| </Stack> | |||||
| </CardContent> | |||||
| </Card> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default CreateGroupLoading; | |||||
| @@ -0,0 +1,24 @@ | |||||
| import React from "react"; | |||||
| import CreateGroupLoading from "./CreateGroupLoading"; | |||||
| import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; | |||||
| import { useSearchParams } from "next/navigation"; | |||||
| import CreateGroup from "./CreateGroup"; | |||||
| import { auth, fetchAuth } from "@/app/api/group/actions"; | |||||
| import { fetchUser } from "@/app/api/user"; | |||||
| interface SubComponents { | |||||
| Loading: typeof CreateGroupLoading; | |||||
| } | |||||
| const CreateGroupWrapper: React.FC & SubComponents = async () => { | |||||
| const records = await fetchAuth() | |||||
| const users = await fetchUser() | |||||
| console.log(users) | |||||
| const auth = records.records as auth[] | |||||
| return <CreateGroup auth={auth} users={users}/>; | |||||
| }; | |||||
| CreateGroupWrapper.Loading = CreateGroupLoading; | |||||
| export default CreateGroupWrapper; | |||||
| @@ -0,0 +1,81 @@ | |||||
| "use client"; | |||||
| import Stack from "@mui/material/Stack"; | |||||
| import Box from "@mui/material/Box"; | |||||
| import Card from "@mui/material/Card"; | |||||
| import CardContent from "@mui/material/CardContent"; | |||||
| import Grid from "@mui/material/Grid"; | |||||
| import TextField from "@mui/material/TextField"; | |||||
| import Typography from "@mui/material/Typography"; | |||||
| import { CreateGroupInputs } from "@/app/api/group/actions"; | |||||
| import { useFormContext } from "react-hook-form"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { useCallback } from "react"; | |||||
| const GroupInfo: React.FC = () => { | |||||
| const { t } = useTranslation(); | |||||
| const { | |||||
| register, | |||||
| formState: { errors, defaultValues }, | |||||
| control, | |||||
| reset, | |||||
| resetField, | |||||
| setValue, | |||||
| } = useFormContext<CreateGroupInputs>(); | |||||
| const resetGroup = useCallback(() => { | |||||
| console.log(defaultValues); | |||||
| if (defaultValues !== undefined) { | |||||
| resetField("description"); | |||||
| } | |||||
| }, [defaultValues]); | |||||
| return ( | |||||
| <Card sx={{ display: "block" }}> | |||||
| <CardContent component={Stack} spacing={4}> | |||||
| <Box> | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
| {t("Group Info")} | |||||
| </Typography> | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| label={t("Group Name")} | |||||
| fullWidth | |||||
| {...register("name", { | |||||
| required: true, | |||||
| })} | |||||
| error={Boolean(errors.name)} | |||||
| helperText={ | |||||
| Boolean(errors.name) && | |||||
| (errors.name?.message | |||||
| ? t(errors.name.message) | |||||
| : t("Please input correct name")) | |||||
| } | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | |||||
| <TextField | |||||
| label={t("Group Description")} | |||||
| fullWidth | |||||
| multiline | |||||
| rows={4} | |||||
| {...register("description")} | |||||
| error={Boolean(errors.description)} | |||||
| helperText={ | |||||
| Boolean(errors.description) && | |||||
| (errors.description?.message | |||||
| ? t(errors.description.message) | |||||
| : t("Please input correct description")) | |||||
| } | |||||
| /> | |||||
| </Grid> | |||||
| </Grid> | |||||
| </Box> | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| }; | |||||
| export default GroupInfo; | |||||
| @@ -0,0 +1,209 @@ | |||||
| "use client"; | |||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { | |||||
| FieldErrors, | |||||
| FormProvider, | |||||
| SubmitErrorHandler, | |||||
| SubmitHandler, | |||||
| useForm, | |||||
| useFormContext, | |||||
| } from "react-hook-form"; | |||||
| import { | |||||
| Box, | |||||
| Card, | |||||
| CardContent, | |||||
| Grid, | |||||
| IconButton, | |||||
| InputAdornment, | |||||
| Stack, | |||||
| Tab, | |||||
| Tabs, | |||||
| TabsProps, | |||||
| TextField, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import { differenceBy } from "lodash"; | |||||
| import { CreateGroupInputs, auth } from "@/app/api/group/actions"; | |||||
| import SearchResults, { Column } from "../SearchResults"; | |||||
| import { Add, Clear, Remove, Search } from "@mui/icons-material"; | |||||
| import { UserResult } from "@/app/api/user"; | |||||
| export interface Props { | |||||
| users: UserResult[]; | |||||
| } | |||||
| const UserAllocation: React.FC<Props> = ({ users }) => { | |||||
| const { t } = useTranslation(); | |||||
| const { | |||||
| setValue, | |||||
| getValues, | |||||
| formState: { defaultValues }, | |||||
| reset, | |||||
| resetField, | |||||
| } = useFormContext<CreateGroupInputs>(); | |||||
| const initialUsers = users.map((u) => ({ ...u })).sort((a, b) => a.id - b.id).filter((u) => u.groupId !== null); | |||||
| const [filteredUsers, setFilteredUsers] = useState(initialUsers); | |||||
| const [selectedUsers, setSelectedUsers] = useState<typeof filteredUsers>( | |||||
| () => { | |||||
| return filteredUsers.filter( | |||||
| (s) => getValues("addUserIds")?.includes(s.id) | |||||
| ); | |||||
| } | |||||
| ); | |||||
| // Adding / Removing Auth | |||||
| const addUser = useCallback((users: UserResult) => { | |||||
| setSelectedUsers((a) => [...a, users]); | |||||
| }, []); | |||||
| const removeUser = useCallback((users: UserResult) => { | |||||
| setSelectedUsers((a) => a.filter((a) => a.id !== users.id)); | |||||
| }, []); | |||||
| const clearUser = useCallback(() => { | |||||
| if (defaultValues !== undefined) { | |||||
| resetField("addUserIds"); | |||||
| setSelectedUsers( | |||||
| initialUsers.filter((s) => defaultValues.addUserIds?.includes(s.id)) | |||||
| ); | |||||
| } | |||||
| }, [defaultValues]); | |||||
| // Sync with form | |||||
| useEffect(() => { | |||||
| setValue( | |||||
| "addUserIds", | |||||
| selectedUsers.map((u) => u.id) | |||||
| ); | |||||
| }, [selectedUsers, setValue]); | |||||
| const UserPoolColumns = useMemo<Column<UserResult>[]>( | |||||
| () => [ | |||||
| { | |||||
| label: t("Add"), | |||||
| name: "id", | |||||
| onClick: addUser, | |||||
| buttonIcon: <Add />, | |||||
| }, | |||||
| { label: t("User Name"), name: "username" }, | |||||
| { label: t("name"), name: "name" }, | |||||
| ], | |||||
| [addUser, t] | |||||
| ); | |||||
| const allocatedUserColumns = useMemo<Column<UserResult>[]>( | |||||
| () => [ | |||||
| { | |||||
| label: t("Remove"), | |||||
| name: "id", | |||||
| onClick: removeUser, | |||||
| buttonIcon: <Remove color="warning" />, | |||||
| }, | |||||
| { label: t("User Name"), name: "username" }, | |||||
| { label: t("name"), name: "name" }, | |||||
| ], | |||||
| [removeUser, selectedUsers, t] | |||||
| ); | |||||
| const [query, setQuery] = React.useState(""); | |||||
| const onQueryInputChange = React.useCallback< | |||||
| React.ChangeEventHandler<HTMLInputElement> | |||||
| >((e) => { | |||||
| setQuery(e.target.value); | |||||
| }, []); | |||||
| const clearQueryInput = React.useCallback(() => { | |||||
| setQuery(""); | |||||
| }, []); | |||||
| React.useEffect(() => { | |||||
| // setFilteredStaff( | |||||
| // initialStaffs.filter((s) => { | |||||
| // const q = query.toLowerCase(); | |||||
| // // s.staffId.toLowerCase().includes(q) | |||||
| // // const q = query.toLowerCase(); | |||||
| // // return s.name.toLowerCase().includes(q); | |||||
| // // s.code.toString().includes(q) || | |||||
| // // (s.brNo != null && s.brNo.toLowerCase().includes(q)) | |||||
| // }) | |||||
| // ); | |||||
| }, [users, query]); | |||||
| const resetUser = React.useCallback(() => { | |||||
| clearQueryInput(); | |||||
| clearUser(); | |||||
| }, [clearQueryInput, clearUser]); | |||||
| const formProps = useForm({}); | |||||
| // Tab related | |||||
| const [tabIndex, setTabIndex] = React.useState(0); | |||||
| const handleTabChange = React.useCallback<NonNullable<TabsProps["onChange"]>>( | |||||
| (_e, newValue) => { | |||||
| setTabIndex(newValue); | |||||
| }, | |||||
| [] | |||||
| ); | |||||
| return ( | |||||
| <> | |||||
| <FormProvider {...formProps}> | |||||
| <Card sx={{ display: "block" }}> | |||||
| <CardContent | |||||
| sx={{ display: "flex", flexDirection: "column", gap: 1 }} | |||||
| > | |||||
| <Stack gap={2}> | |||||
| <Typography variant="overline" display="block"> | |||||
| {t("User")} | |||||
| </Typography> | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||||
| <Grid item xs={6} display="flex" alignItems="center"> | |||||
| <Search sx={{ marginInlineEnd: 1 }} /> | |||||
| <TextField | |||||
| variant="standard" | |||||
| fullWidth | |||||
| onChange={onQueryInputChange} | |||||
| value={query} | |||||
| placeholder={t("Search by staff ID, name or position.")} | |||||
| InputProps={{ | |||||
| endAdornment: query && ( | |||||
| <InputAdornment position="end"> | |||||
| <IconButton onClick={clearQueryInput}> | |||||
| <Clear /> | |||||
| </IconButton> | |||||
| </InputAdornment> | |||||
| ), | |||||
| }} | |||||
| /> | |||||
| </Grid> | |||||
| </Grid> | |||||
| <Tabs value={tabIndex} onChange={handleTabChange}> | |||||
| <Tab label={t("User Pool")} /> | |||||
| <Tab | |||||
| label={`${t("Allocated Users")} (${selectedUsers.length})`} | |||||
| /> | |||||
| </Tabs> | |||||
| <Box sx={{ marginInline: -3 }}> | |||||
| {tabIndex === 0 && ( | |||||
| <SearchResults | |||||
| noWrapper | |||||
| items={differenceBy(filteredUsers, selectedUsers, "id")} | |||||
| columns={UserPoolColumns} | |||||
| /> | |||||
| )} | |||||
| {tabIndex === 1 && ( | |||||
| <SearchResults | |||||
| noWrapper | |||||
| items={selectedUsers} | |||||
| columns={allocatedUserColumns} | |||||
| /> | |||||
| )} | |||||
| </Box> | |||||
| </Stack> | |||||
| </CardContent> | |||||
| </Card> | |||||
| </FormProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default UserAllocation; | |||||
| @@ -0,0 +1 @@ | |||||
| export { default } from "./CreateGroupWrapper" | |||||
| @@ -39,20 +39,20 @@ const PositionDetails: React.FC = ({ | |||||
| <TextField | <TextField | ||||
| label={t("Position Code")} | label={t("Position Code")} | ||||
| fullWidth | fullWidth | ||||
| {...register("positionCode", { | |||||
| {...register("code", { | |||||
| required: "Position code required!", | required: "Position code required!", | ||||
| })} | })} | ||||
| error={Boolean(errors.positionCode)} | |||||
| error={Boolean(errors.code)} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={t("Position Name")} | label={t("Position Name")} | ||||
| fullWidth | fullWidth | ||||
| {...register("positionName", { | |||||
| {...register("name", { | |||||
| required: "Position name required!", | required: "Position name required!", | ||||
| })} | })} | ||||
| error={Boolean(errors.positionName)} | |||||
| error={Boolean(errors.name)} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import DoneIcon from "@mui/icons-material/Done"; | |||||
| import Check from "@mui/icons-material/Check"; | import Check from "@mui/icons-material/Check"; | ||||
| import Close from "@mui/icons-material/Close"; | import Close from "@mui/icons-material/Close"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| @@ -21,8 +22,12 @@ import { | |||||
| SubmitHandler, | SubmitHandler, | ||||
| useForm, | useForm, | ||||
| } from "react-hook-form"; | } from "react-hook-form"; | ||||
| import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions"; | |||||
| import { Error } from "@mui/icons-material"; | |||||
| import { | |||||
| CreateProjectInputs, | |||||
| deleteProject, | |||||
| saveProject, | |||||
| } from "@/app/api/projects/actions"; | |||||
| import { Delete, Error, PlayArrow } from "@mui/icons-material"; | |||||
| import { | import { | ||||
| BuildingType, | BuildingType, | ||||
| ContractType, | ContractType, | ||||
| @@ -36,8 +41,18 @@ import { StaffResult } from "@/app/api/staff"; | |||||
| import { Typography } from "@mui/material"; | import { Typography } from "@mui/material"; | ||||
| import { Grade } from "@/app/api/grades"; | import { Grade } from "@/app/api/grades"; | ||||
| import { Customer, Subsidiary } from "@/app/api/customer"; | import { Customer, Subsidiary } from "@/app/api/customer"; | ||||
| import { isEmpty } from "lodash"; | |||||
| import { | |||||
| deleteDialog, | |||||
| errorDialog, | |||||
| submitDialog, | |||||
| successDialog, | |||||
| } from "../Swal/CustomAlerts"; | |||||
| import dayjs from "dayjs"; | |||||
| export interface Props { | export interface Props { | ||||
| isEditMode: boolean; | |||||
| defaultInputs?: CreateProjectInputs; | |||||
| allTasks: Task[]; | allTasks: Task[]; | ||||
| projectCategories: ProjectCategory[]; | projectCategories: ProjectCategory[]; | ||||
| taskTemplates: TaskTemplate[]; | taskTemplates: TaskTemplate[]; | ||||
| @@ -63,12 +78,22 @@ const hasErrorsInTab = ( | |||||
| return ( | return ( | ||||
| errors.projectName || errors.projectCode || errors.projectDescription | errors.projectName || errors.projectCode || errors.projectDescription | ||||
| ); | ); | ||||
| case 2: | |||||
| return ( | |||||
| errors.totalManhour || errors.manhourPercentageByGrade || errors.taskGroups | |||||
| ); | |||||
| case 3: | |||||
| return ( | |||||
| errors.milestones | |||||
| ) | |||||
| default: | default: | ||||
| false; | false; | ||||
| } | } | ||||
| }; | }; | ||||
| const CreateProject: React.FC<Props> = ({ | const CreateProject: React.FC<Props> = ({ | ||||
| isEditMode, | |||||
| defaultInputs, | |||||
| allTasks, | allTasks, | ||||
| projectCategories, | projectCategories, | ||||
| taskTemplates, | taskTemplates, | ||||
| @@ -90,7 +115,19 @@ const CreateProject: React.FC<Props> = ({ | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const handleCancel = () => { | const handleCancel = () => { | ||||
| router.back(); | |||||
| router.replace("/projects"); | |||||
| }; | |||||
| const handleDelete = () => { | |||||
| deleteDialog(async () => { | |||||
| await deleteProject(formProps.getValues("projectId")!); | |||||
| const clickSuccessDialog = await successDialog("Delete Success", t); | |||||
| if (clickSuccessDialog) { | |||||
| router.replace("/projects"); | |||||
| } | |||||
| }, t); | |||||
| }; | }; | ||||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | ||||
| @@ -101,11 +138,102 @@ const CreateProject: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| const onSubmit = useCallback<SubmitHandler<CreateProjectInputs>>( | const onSubmit = useCallback<SubmitHandler<CreateProjectInputs>>( | ||||
| async (data) => { | |||||
| async (data, event) => { | |||||
| try { | try { | ||||
| console.log(data); | |||||
| // detect errors | |||||
| let hasErrors = false | |||||
| // Tab - Staff Allocation and Resource | |||||
| if (data.totalManhour === null || data.totalManhour <= 0) { | |||||
| formProps.setError("totalManhour", { message: "totalManhour value is not valid", type: "required" }) | |||||
| setTabIndex(2) | |||||
| hasErrors = true | |||||
| } | |||||
| const manhourPercentageByGradeKeys = Object.keys(data.manhourPercentageByGrade) | |||||
| if (manhourPercentageByGradeKeys.filter(k => data.manhourPercentageByGrade[k as any] < 0).length > 0 || | |||||
| manhourPercentageByGradeKeys.reduce((acc, value) => acc + data.manhourPercentageByGrade[value as any], 0) !== 100) { | |||||
| formProps.setError("manhourPercentageByGrade", { message: "manhourPercentageByGrade value is not valid", type: "invalid" }) | |||||
| setTabIndex(2) | |||||
| hasErrors = true | |||||
| } | |||||
| const taskGroupKeys = Object.keys(data.taskGroups) | |||||
| if (taskGroupKeys.filter(k => data.taskGroups[k as any].percentAllocation < 0).length > 0 || | |||||
| taskGroupKeys.reduce((acc, value) => acc + data.taskGroups[value as any].percentAllocation, 0) !== 100) { | |||||
| formProps.setError("taskGroups", { message: "Task Groups value is not invalid", type: "invalid" }) | |||||
| setTabIndex(2) | |||||
| hasErrors = true | |||||
| } | |||||
| // Tab - Milestone | |||||
| let projectTotal = 0 | |||||
| const milestonesKeys = Object.keys(data.milestones) | |||||
| milestonesKeys.filter(key => Object.keys(data.taskGroups).includes(key)).forEach(key => { | |||||
| const { startDate, endDate, payments } = data.milestones[parseFloat(key)] | |||||
| if (!Boolean(startDate) || startDate === "Invalid Date" || !Boolean(endDate) || endDate === "Invalid Date" || new Date(startDate) > new Date(endDate)) { | |||||
| formProps.setError("milestones", {message: "milestones is not valid", type: "invalid"}) | |||||
| setTabIndex(3) | |||||
| hasErrors = true | |||||
| } | |||||
| projectTotal += payments.reduce((acc, payment) => acc + payment.amount, 0) | |||||
| }) | |||||
| if (projectTotal !== data.expectedProjectFee) { | |||||
| formProps.setError("milestones", {message: "milestones is not valid", type: "invalid"}) | |||||
| setTabIndex(3) | |||||
| hasErrors = true | |||||
| } | |||||
| if (hasErrors) return false | |||||
| // save project | |||||
| setServerError(""); | setServerError(""); | ||||
| await saveProject(data); | |||||
| router.replace("/projects"); | |||||
| let title = t("Do you want to submit?"); | |||||
| let confirmButtonText = t("Submit"); | |||||
| let successTitle = t("Submit Success"); | |||||
| let errorTitle = t("Submit Fail"); | |||||
| const buttonName = (event?.nativeEvent as any).submitter.name; | |||||
| if (buttonName === "start") { | |||||
| title = t("Do you want to start?"); | |||||
| confirmButtonText = t("Start"); | |||||
| successTitle = t("Start Success"); | |||||
| errorTitle = t("Start Fail"); | |||||
| } else if (buttonName === "complete") { | |||||
| title = t("Do you want to complete?"); | |||||
| confirmButtonText = t("Complete"); | |||||
| successTitle = t("Complete Success"); | |||||
| errorTitle = t("Complete Fail"); | |||||
| } | |||||
| submitDialog( | |||||
| async () => { | |||||
| if (buttonName === "start") { | |||||
| data.projectActualStart = dayjs().format("YYYY-MM-DD"); | |||||
| } else if (buttonName === "complete") { | |||||
| data.projectActualEnd = dayjs().format("YYYY-MM-DD"); | |||||
| } | |||||
| const response = await saveProject(data); | |||||
| if (response.id > 0) { | |||||
| successDialog(successTitle, t).then(() => { | |||||
| router.replace("/projects"); | |||||
| }); | |||||
| } else { | |||||
| errorDialog(errorTitle, t).then(() => { | |||||
| return false; | |||||
| }); | |||||
| } | |||||
| }, | |||||
| t, | |||||
| { title: title, confirmButtonText: confirmButtonText }, | |||||
| ); | |||||
| } catch (e) { | } catch (e) { | ||||
| setServerError(t("An error has occurred. Please try again later.")); | setServerError(t("An error has occurred. Please try again later.")); | ||||
| } | } | ||||
| @@ -115,6 +243,7 @@ const CreateProject: React.FC<Props> = ({ | |||||
| const onSubmitError = useCallback<SubmitErrorHandler<CreateProjectInputs>>( | const onSubmitError = useCallback<SubmitErrorHandler<CreateProjectInputs>>( | ||||
| (errors) => { | (errors) => { | ||||
| console.log(errors) | |||||
| // Set the tab so that the focus will go there | // Set the tab so that the focus will go there | ||||
| if ( | if ( | ||||
| errors.projectName || | errors.projectName || | ||||
| @@ -122,6 +251,10 @@ const CreateProject: React.FC<Props> = ({ | |||||
| errors.projectCode | errors.projectCode | ||||
| ) { | ) { | ||||
| setTabIndex(0); | setTabIndex(0); | ||||
| } else if (errors.totalManhour || errors.manhourPercentageByGrade || errors.taskGroups) { | |||||
| setTabIndex(2) | |||||
| } else if (errors.milestones) { | |||||
| setTabIndex(3) | |||||
| } | } | ||||
| }, | }, | ||||
| [], | [], | ||||
| @@ -133,85 +266,163 @@ const CreateProject: React.FC<Props> = ({ | |||||
| allocatedStaffIds: [], | allocatedStaffIds: [], | ||||
| milestones: {}, | milestones: {}, | ||||
| totalManhour: 0, | totalManhour: 0, | ||||
| manhourPercentageByGrade: grades.reduce((acc, grade) => { | |||||
| return { ...acc, [grade.id]: 1 / grades.length }; | |||||
| }, {}), | |||||
| ...defaultInputs, | |||||
| // manhourPercentageByGrade should have a sensible default | |||||
| manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade) | |||||
| ? grades.reduce((acc, grade) => { | |||||
| return { ...acc, [grade.id]: 100 / grades.length }; | |||||
| }, {}) | |||||
| : defaultInputs?.manhourPercentageByGrade, | |||||
| }, | }, | ||||
| }); | }); | ||||
| const errors = formProps.formState.errors; | const errors = formProps.formState.errors; | ||||
| return ( | return ( | ||||
| <FormProvider {...formProps}> | |||||
| <Stack | |||||
| spacing={2} | |||||
| component="form" | |||||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||||
| > | |||||
| <Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | |||||
| <Tab | |||||
| label={t("Project and Client Details")} | |||||
| icon={ | |||||
| hasErrorsInTab(0, errors) ? ( | |||||
| <Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||||
| ) : undefined | |||||
| } | |||||
| iconPosition="end" | |||||
| /> | |||||
| <Tab label={t("Project Task Setup")} iconPosition="end" /> | |||||
| <Tab label={t("Staff Allocation and Resource")} iconPosition="end" /> | |||||
| <Tab label={t("Milestone")} iconPosition="end" /> | |||||
| </Tabs> | |||||
| { | |||||
| <ProjectClientDetails | |||||
| buildingTypes={buildingTypes} | |||||
| workNatures={workNatures} | |||||
| contractTypes={contractTypes} | |||||
| fundingTypes={fundingTypes} | |||||
| locationTypes={locationTypes} | |||||
| serviceTypes={serviceTypes} | |||||
| allCustomers={allCustomers} | |||||
| allSubsidiaries={allSubsidiaries} | |||||
| projectCategories={projectCategories} | |||||
| teamLeads={teamLeads} | |||||
| isActive={tabIndex === 0} | |||||
| /> | |||||
| } | |||||
| { | |||||
| <TaskSetup | |||||
| allTasks={allTasks} | |||||
| taskTemplates={taskTemplates} | |||||
| isActive={tabIndex === 1} | |||||
| /> | |||||
| } | |||||
| { | |||||
| <StaffAllocation | |||||
| isActive={tabIndex === 2} | |||||
| allTasks={allTasks} | |||||
| grades={grades} | |||||
| allStaffs={allStaffs} | |||||
| /> | |||||
| } | |||||
| {<Milestone allTasks={allTasks} isActive={tabIndex === 3} />} | |||||
| {serverError && ( | |||||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||||
| {serverError} | |||||
| </Typography> | |||||
| )} | |||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Close />} | |||||
| onClick={handleCancel} | |||||
| <> | |||||
| <FormProvider {...formProps}> | |||||
| <Stack | |||||
| spacing={2} | |||||
| component="form" | |||||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||||
| > | |||||
| {isEditMode && !(formProps.getValues("projectDeleted") === true) && ( | |||||
| <Stack direction="row" gap={1}> | |||||
| {!formProps.getValues("projectActualStart") && ( | |||||
| <Button | |||||
| name="start" | |||||
| type="submit" | |||||
| variant="contained" | |||||
| startIcon={<PlayArrow />} | |||||
| color="success" | |||||
| > | |||||
| {t("Start Project")} | |||||
| </Button> | |||||
| )} | |||||
| {formProps.getValues("projectActualStart") && | |||||
| !formProps.getValues("projectActualEnd") && ( | |||||
| <Button | |||||
| name="complete" | |||||
| type="submit" | |||||
| variant="contained" | |||||
| startIcon={<DoneIcon />} | |||||
| color="info" | |||||
| > | |||||
| {t("Complete Project")} | |||||
| </Button> | |||||
| )} | |||||
| {!( | |||||
| formProps.getValues("projectActualStart") && | |||||
| formProps.getValues("projectActualEnd") | |||||
| ) && ( | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Delete />} | |||||
| color="error" | |||||
| onClick={handleDelete} | |||||
| > | |||||
| {t("Delete Project")} | |||||
| </Button> | |||||
| )} | |||||
| </Stack> | |||||
| )} | |||||
| <Tabs | |||||
| value={tabIndex} | |||||
| onChange={handleTabChange} | |||||
| variant="scrollable" | |||||
| > | > | ||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button variant="contained" startIcon={<Check />} type="submit"> | |||||
| {t("Confirm")} | |||||
| </Button> | |||||
| <Tab | |||||
| label={t("Project and Client Details")} | |||||
| sx={{ marginInlineEnd: !hasErrorsInTab(1, errors) && (hasErrorsInTab(2, errors) || hasErrorsInTab(3, errors)) ? 1 : undefined }} | |||||
| icon={ | |||||
| hasErrorsInTab(0, errors) ? ( | |||||
| <Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||||
| ) : undefined | |||||
| } | |||||
| iconPosition="end" | |||||
| /> | |||||
| <Tab | |||||
| label={t("Project Task Setup")} | |||||
| sx={{ marginInlineEnd: hasErrorsInTab(2, errors) || hasErrorsInTab(3, errors) ? 1 : undefined }} | |||||
| iconPosition="end" /> | |||||
| <Tab | |||||
| label={t("Staff Allocation and Resource")} | |||||
| sx={{ marginInlineEnd: !hasErrorsInTab(2, errors) && hasErrorsInTab(3, errors) ? 1 : undefined }} | |||||
| icon={ | |||||
| hasErrorsInTab(2, errors) ? ( | |||||
| <Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||||
| ) : undefined | |||||
| } | |||||
| iconPosition="end" | |||||
| /> | |||||
| <Tab label={t("Milestone")} | |||||
| icon={ | |||||
| hasErrorsInTab(3, errors) ? ( | |||||
| <Error sx={{ marginInlineEnd: 1 }} color="error" />) | |||||
| : undefined} | |||||
| iconPosition="end" /> | |||||
| </Tabs> | |||||
| { | |||||
| <ProjectClientDetails | |||||
| buildingTypes={buildingTypes} | |||||
| workNatures={workNatures} | |||||
| contractTypes={contractTypes} | |||||
| fundingTypes={fundingTypes} | |||||
| locationTypes={locationTypes} | |||||
| serviceTypes={serviceTypes} | |||||
| allCustomers={allCustomers} | |||||
| allSubsidiaries={allSubsidiaries} | |||||
| projectCategories={projectCategories} | |||||
| teamLeads={teamLeads} | |||||
| isActive={tabIndex === 0} | |||||
| /> | |||||
| } | |||||
| { | |||||
| <TaskSetup | |||||
| allTasks={allTasks} | |||||
| taskTemplates={taskTemplates} | |||||
| isActive={tabIndex === 1} | |||||
| /> | |||||
| } | |||||
| { | |||||
| <StaffAllocation | |||||
| isActive={tabIndex === 2} | |||||
| allTasks={allTasks} | |||||
| grades={grades} | |||||
| allStaffs={allStaffs} | |||||
| /> | |||||
| } | |||||
| {<Milestone allTasks={allTasks} isActive={tabIndex === 3} />} | |||||
| {serverError && ( | |||||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||||
| {serverError} | |||||
| </Typography> | |||||
| )} | |||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Close />} | |||||
| onClick={handleCancel} | |||||
| > | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<Check />} | |||||
| type="submit" | |||||
| disabled={ | |||||
| formProps.getValues("projectDeleted") === true || | |||||
| (!!formProps.getValues("projectActualStart") && | |||||
| !!formProps.getValues("projectActualEnd")) | |||||
| } | |||||
| > | |||||
| {isEditMode ? t("Save") : t("Confirm")} | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | </Stack> | ||||
| </Stack> | |||||
| </FormProvider> | |||||
| </FormProvider> | |||||
| </> | |||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -4,6 +4,7 @@ import { | |||||
| fetchProjectBuildingTypes, | fetchProjectBuildingTypes, | ||||
| fetchProjectCategories, | fetchProjectCategories, | ||||
| fetchProjectContractTypes, | fetchProjectContractTypes, | ||||
| fetchProjectDetails, | |||||
| fetchProjectFundingTypes, | fetchProjectFundingTypes, | ||||
| fetchProjectLocationTypes, | fetchProjectLocationTypes, | ||||
| fetchProjectServiceTypes, | fetchProjectServiceTypes, | ||||
| @@ -13,7 +14,15 @@ import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; | |||||
| import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | ||||
| import { fetchGrades } from "@/app/api/grades"; | import { fetchGrades } from "@/app/api/grades"; | ||||
| const CreateProjectWrapper: React.FC = async () => { | |||||
| type CreateProjectProps = { isEditMode: false }; | |||||
| interface EditProjectProps { | |||||
| isEditMode: true; | |||||
| projectId?: string; | |||||
| } | |||||
| type Props = CreateProjectProps | EditProjectProps; | |||||
| const CreateProjectWrapper: React.FC<Props> = async (props) => { | |||||
| const [ | const [ | ||||
| tasks, | tasks, | ||||
| taskTemplates, | taskTemplates, | ||||
| @@ -46,8 +55,14 @@ const CreateProjectWrapper: React.FC = async () => { | |||||
| fetchGrades(), | fetchGrades(), | ||||
| ]); | ]); | ||||
| const projectInfo = props.isEditMode | |||||
| ? await fetchProjectDetails(props.projectId!) | |||||
| : undefined; | |||||
| return ( | return ( | ||||
| <CreateProject | <CreateProject | ||||
| isEditMode={props.isEditMode} | |||||
| defaultInputs={projectInfo} | |||||
| allTasks={tasks} | allTasks={tasks} | ||||
| projectCategories={projectCategories} | projectCategories={projectCategories} | ||||
| taskTemplates={taskTemplates} | taskTemplates={taskTemplates} | ||||
| @@ -4,7 +4,7 @@ import Card from "@mui/material/Card"; | |||||
| import CardContent from "@mui/material/CardContent"; | import CardContent from "@mui/material/CardContent"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import React, { useCallback, useMemo, useState } from "react"; | |||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import CardActions from "@mui/material/CardActions"; | import CardActions from "@mui/material/CardActions"; | ||||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | import RestartAlt from "@mui/icons-material/RestartAlt"; | ||||
| import { | import { | ||||
| @@ -29,7 +29,7 @@ export interface Props { | |||||
| const Milestone: React.FC<Props> = ({ allTasks, isActive }) => { | const Milestone: React.FC<Props> = ({ allTasks, isActive }) => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { watch } = useFormContext<CreateProjectInputs>(); | |||||
| const { watch, setError, clearErrors } = useFormContext<CreateProjectInputs>(); | |||||
| const currentTaskGroups = watch("taskGroups"); | const currentTaskGroups = watch("taskGroups"); | ||||
| const taskGroups = useMemo( | const taskGroups = useMemo( | ||||
| () => | () => | ||||
| @@ -57,6 +57,35 @@ const Milestone: React.FC<Props> = ({ allTasks, isActive }) => { | |||||
| [], | [], | ||||
| ); | ); | ||||
| // handle error checking | |||||
| const milestones = watch("milestones") | |||||
| const expectedTotalFee = watch("expectedProjectFee"); | |||||
| useEffect(() => { | |||||
| const milestonesKeys = Object.keys(milestones) | |||||
| let hasError = false | |||||
| let projectTotal = 0 | |||||
| milestonesKeys.filter(key => taskGroups.map(taskGroup => taskGroup.id).includes(parseInt(key))).forEach(key => { | |||||
| const { startDate, endDate, payments } = milestones[parseFloat(key)] | |||||
| if (new Date(startDate) > new Date(endDate) || !Boolean(startDate) || !Boolean(endDate)) { | |||||
| hasError = true | |||||
| } | |||||
| projectTotal += payments.reduce((acc, payment) => acc + payment.amount, 0) | |||||
| }) | |||||
| if (projectTotal !== expectedTotalFee) { | |||||
| hasError = true | |||||
| } | |||||
| // console.log(Object.keys(milestones).reduce((acc, key) => acc + milestones[parseFloat(key)].payments.reduce((acc2, value) => acc2 + value.amount, 0), 0)) | |||||
| if (hasError) { | |||||
| setError("milestones", {message: "milestones is not valid", type: "invalid"}) | |||||
| } else { | |||||
| clearErrors("milestones") | |||||
| } | |||||
| }, [milestones]) | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Card sx={{ display: isActive ? "block" : "none" }}> | <Card sx={{ display: isActive ? "block" : "none" }}> | ||||
| @@ -26,7 +26,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import "dayjs/locale/zh-hk"; | import "dayjs/locale/zh-hk"; | ||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | import React, { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { useFormContext } from "react-hook-form"; | |||||
| import { Controller, useFormContext } from "react-hook-form"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import StyledDataGrid from "../StyledDataGrid"; | import StyledDataGrid from "../StyledDataGrid"; | ||||
| import { INPUT_DATE_FORMAT, moneyFormatter } from "@/app/utils/formatUtil"; | import { INPUT_DATE_FORMAT, moneyFormatter } from "@/app/utils/formatUtil"; | ||||
| @@ -57,13 +57,15 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
| const apiRef = useGridApiRef(); | const apiRef = useGridApiRef(); | ||||
| const addRow = useCallback(() => { | const addRow = useCallback(() => { | ||||
| const id = Date.now(); | |||||
| // const id = Date.now(); | |||||
| const minId = Math.min(...payments.map((payment) => payment.id!!)); | |||||
| const id = minId >= 0 ? -1 : minId - 1 | |||||
| setPayments((p) => [...p, { id, _isNew: true }]); | setPayments((p) => [...p, { id, _isNew: true }]); | ||||
| setRowModesModel((model) => ({ | setRowModesModel((model) => ({ | ||||
| ...model, | ...model, | ||||
| [id]: { mode: GridRowModes.Edit, fieldToFocus: "description" }, | [id]: { mode: GridRowModes.Edit, fieldToFocus: "description" }, | ||||
| })); | })); | ||||
| }, []); | |||||
| }, [payments]); | |||||
| const validateRow = useCallback( | const validateRow = useCallback( | ||||
| (id: GridRowId) => { | (id: GridRowId) => { | ||||
| @@ -239,21 +241,26 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | ||||
| <Grid item xs> | <Grid item xs> | ||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| <DatePicker | |||||
| label={t("Stage Start Date")} | |||||
| value={startDate ? dayjs(startDate) : null} | |||||
| onChange={(date) => { | |||||
| if (!date) return; | |||||
| const milestones = getValues("milestones"); | |||||
| setValue("milestones", { | |||||
| ...milestones, | |||||
| [taskGroupId]: { | |||||
| ...milestones[taskGroupId], | |||||
| startDate: date.format(INPUT_DATE_FORMAT), | |||||
| }, | |||||
| }); | |||||
| }} | |||||
| /> | |||||
| <DatePicker | |||||
| label={t("Stage Start Date")} | |||||
| value={startDate ? dayjs(startDate) : null} | |||||
| onChange={(date) => { | |||||
| if (!date) return; | |||||
| const milestones = getValues("milestones"); | |||||
| setValue("milestones", { | |||||
| ...milestones, | |||||
| [taskGroupId]: { | |||||
| ...milestones[taskGroupId], | |||||
| startDate: date.format(INPUT_DATE_FORMAT), | |||||
| }, | |||||
| }); | |||||
| }} | |||||
| slotProps={{ | |||||
| textField: { | |||||
| error: startDate === "Invalid Date" || new Date(startDate) > new Date(endDate) || (Boolean(formState.errors.milestones) && !Boolean(startDate)), | |||||
| }, | |||||
| }} | |||||
| /> | |||||
| </FormControl> | </FormControl> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs> | <Grid item xs> | ||||
| @@ -272,6 +279,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
| }, | }, | ||||
| }); | }); | ||||
| }} | }} | ||||
| slotProps={{ | |||||
| textField: { | |||||
| error: endDate === "Invalid Date" || new Date(startDate) > new Date(endDate) || (Boolean(formState.errors.milestones) && !Boolean(endDate)), | |||||
| }, | |||||
| }} | |||||
| /> | /> | ||||
| </FormControl> | </FormControl> | ||||
| </Grid> | </Grid> | ||||
| @@ -23,6 +23,7 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||||
| {taskGroups.map((group, index) => { | {taskGroups.map((group, index) => { | ||||
| const payments = milestones[group.id]?.payments || []; | const payments = milestones[group.id]?.payments || []; | ||||
| const paymentTotal = payments.reduce((acc, p) => acc + p.amount, 0); | const paymentTotal = payments.reduce((acc, p) => acc + p.amount, 0); | ||||
| projectTotal += paymentTotal; | projectTotal += paymentTotal; | ||||
| return ( | return ( | ||||
| @@ -41,9 +42,9 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||||
| <Typography variant="h6">{t("Project Total Fee")}</Typography> | <Typography variant="h6">{t("Project Total Fee")}</Typography> | ||||
| <Typography>{moneyFormatter.format(projectTotal)}</Typography> | <Typography>{moneyFormatter.format(projectTotal)}</Typography> | ||||
| </Stack> | </Stack> | ||||
| {projectTotal > expectedTotalFee && ( | |||||
| {projectTotal !== expectedTotalFee && ( | |||||
| <Typography variant="caption" color="warning.main" alignSelf="flex-end"> | <Typography variant="caption" color="warning.main" alignSelf="flex-end"> | ||||
| {t("Project total fee is larger than the expected total fee!")} | |||||
| {t("Project total fee should be same as the expected total fee!")} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| @@ -45,24 +45,45 @@ const leftRightBorderCellSx: SxProps = { | |||||
| borderColor: "divider", | borderColor: "divider", | ||||
| }; | }; | ||||
| const errorCellSx: SxProps = { | |||||
| outline: "1px solid", | |||||
| outlineColor: "error.main", | |||||
| // borderLeft: "1px solid", | |||||
| // borderRight: "1px solid", | |||||
| // borderTop: "1px solid", | |||||
| // borderBottom: "1px solid", | |||||
| // borderColor: 'error.main' | |||||
| } | |||||
| const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { watch, register, setValue } = useFormContext<CreateProjectInputs>(); | |||||
| const { watch, register, setValue, formState: { errors }, setError, clearErrors } = useFormContext<CreateProjectInputs>(); | |||||
| const manhourPercentageByGrade = watch("manhourPercentageByGrade"); | const manhourPercentageByGrade = watch("manhourPercentageByGrade"); | ||||
| const totalManhour = watch("totalManhour"); | const totalManhour = watch("totalManhour"); | ||||
| const totalPercentage = Object.values(manhourPercentageByGrade).reduce( | |||||
| const totalPercentage = Math.round(Object.values(manhourPercentageByGrade).reduce( | |||||
| (acc, percent) => acc + percent, | (acc, percent) => acc + percent, | ||||
| 0, | 0, | ||||
| ); | |||||
| ) * 100) / 100; | |||||
| const makeUpdatePercentage = useCallback( | const makeUpdatePercentage = useCallback( | ||||
| (gradeId: Grade["id"]) => (percentage?: number) => { | (gradeId: Grade["id"]) => (percentage?: number) => { | ||||
| if (percentage !== undefined) { | if (percentage !== undefined) { | ||||
| setValue("manhourPercentageByGrade", { | |||||
| const updatedManhourPercentageByGrade = { | |||||
| ...manhourPercentageByGrade, | ...manhourPercentageByGrade, | ||||
| [gradeId]: percentage, | [gradeId]: percentage, | ||||
| }); | |||||
| } | |||||
| setValue("manhourPercentageByGrade", updatedManhourPercentageByGrade); | |||||
| const keys = Object.keys(updatedManhourPercentageByGrade) | |||||
| if (keys.filter(k => updatedManhourPercentageByGrade[k as any] < 0).length > 0 || | |||||
| keys.reduce((acc, value) => acc + updatedManhourPercentageByGrade[value as any], 0) !== 100) { | |||||
| setError("manhourPercentageByGrade", {message: "manhourPercentageByGrade value is not valid", type: "invalid"}) | |||||
| } else { | |||||
| clearErrors("manhourPercentageByGrade") | |||||
| } | |||||
| } | } | ||||
| }, | }, | ||||
| [manhourPercentageByGrade, setValue], | [manhourPercentageByGrade, setValue], | ||||
| @@ -79,7 +100,10 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||||
| type="number" | type="number" | ||||
| {...register("totalManhour", { | {...register("totalManhour", { | ||||
| valueAsNumber: true, | valueAsNumber: true, | ||||
| required: "totalManhour code required!", | |||||
| min: 1, | |||||
| })} | })} | ||||
| error={Boolean(errors.totalManhour)} | |||||
| /> | /> | ||||
| <Box | <Box | ||||
| sx={(theme) => ({ | sx={(theme) => ({ | ||||
| @@ -110,15 +134,18 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||||
| <TableCellEdit | <TableCellEdit | ||||
| key={`${column.id}${idx}`} | key={`${column.id}${idx}`} | ||||
| value={manhourPercentageByGrade[column.id]} | value={manhourPercentageByGrade[column.id]} | ||||
| renderValue={(val) => percentFormatter.format(val)} | |||||
| renderValue={(val) => val + "%"} | |||||
| // renderValue={(val) => percentFormatter.format(val)} | |||||
| onChange={makeUpdatePercentage(column.id)} | onChange={makeUpdatePercentage(column.id)} | ||||
| convertValue={(inputValue) => Number(inputValue)} | convertValue={(inputValue) => Number(inputValue)} | ||||
| cellSx={{ backgroundColor: "primary.lightest" }} | cellSx={{ backgroundColor: "primary.lightest" }} | ||||
| inputSx={{ width: "3rem" }} | inputSx={{ width: "3rem" }} | ||||
| error={manhourPercentageByGrade[column.id] < 0} | |||||
| /> | /> | ||||
| ))} | ))} | ||||
| <TableCell sx={leftBorderCellSx}> | |||||
| {percentFormatter.format(totalPercentage)} | |||||
| <TableCell sx={{ ...(totalPercentage === 100 && leftBorderCellSx), ...(totalPercentage !== 100 && {...errorCellSx, borderRight: "1px solid", borderColor: "error.main"})}}> | |||||
| {totalPercentage + "%"} | |||||
| {/* {percentFormatter.format(totalPercentage)} */} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| <TableRow> | <TableRow> | ||||
| @@ -126,7 +153,7 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||||
| {grades.map((column, idx) => ( | {grades.map((column, idx) => ( | ||||
| <TableCell key={`${column.id}${idx}`}> | <TableCell key={`${column.id}${idx}`}> | ||||
| {manhourFormatter.format( | {manhourFormatter.format( | ||||
| manhourPercentageByGrade[column.id] * totalManhour, | |||||
| manhourPercentageByGrade[column.id] / 100 * totalManhour, | |||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| ))} | ))} | ||||
| @@ -144,7 +171,7 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||||
| const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { watch, setValue } = useFormContext<CreateProjectInputs>(); | |||||
| const { watch, setValue, clearErrors, setError } = useFormContext<CreateProjectInputs>(); | |||||
| const currentTaskGroups = watch("taskGroups"); | const currentTaskGroups = watch("taskGroups"); | ||||
| const taskGroups = useMemo( | const taskGroups = useMemo( | ||||
| @@ -167,13 +194,22 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||||
| const makeUpdatePercentage = useCallback( | const makeUpdatePercentage = useCallback( | ||||
| (taskGroupId: TaskGroup["id"]) => (percentage?: number) => { | (taskGroupId: TaskGroup["id"]) => (percentage?: number) => { | ||||
| if (percentage !== undefined) { | if (percentage !== undefined) { | ||||
| setValue("taskGroups", { | |||||
| const updatedTaskGroups = { | |||||
| ...currentTaskGroups, | ...currentTaskGroups, | ||||
| [taskGroupId]: { | [taskGroupId]: { | ||||
| ...currentTaskGroups[taskGroupId], | ...currentTaskGroups[taskGroupId], | ||||
| percentAllocation: percentage, | percentAllocation: percentage, | ||||
| }, | }, | ||||
| }); | |||||
| } | |||||
| setValue("taskGroups", updatedTaskGroups); | |||||
| const keys = Object.keys(updatedTaskGroups) | |||||
| if (keys.filter(k => updatedTaskGroups[k as any].percentAllocation < 0).length > 0 || | |||||
| keys.reduce((acc, value) => acc + updatedTaskGroups[value as any].percentAllocation, 0) !== 100) { | |||||
| setError("taskGroups", {message: "Task Groups value is not invalid", type: "invalid"}) | |||||
| } else { | |||||
| clearErrors("taskGroups") | |||||
| } | |||||
| } | } | ||||
| }, | }, | ||||
| [currentTaskGroups, setValue], | [currentTaskGroups, setValue], | ||||
| @@ -216,24 +252,28 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||||
| </TableCell> | </TableCell> | ||||
| <TableCellEdit | <TableCellEdit | ||||
| value={currentTaskGroups[tg.id].percentAllocation} | value={currentTaskGroups[tg.id].percentAllocation} | ||||
| renderValue={(val) => percentFormatter.format(val)} | |||||
| // renderValue={(val) => percentFormatter.format(val)} | |||||
| renderValue={(val) => val + "%"} | |||||
| onChange={makeUpdatePercentage(tg.id)} | onChange={makeUpdatePercentage(tg.id)} | ||||
| convertValue={(inputValue) => Number(inputValue)} | convertValue={(inputValue) => Number(inputValue)} | ||||
| cellSx={{ backgroundColor: "primary.lightest" }} | |||||
| cellSx={{ | |||||
| backgroundColor: "primary.lightest", | |||||
| }} | |||||
| inputSx={{ width: "3rem" }} | inputSx={{ width: "3rem" }} | ||||
| error={currentTaskGroups[tg.id].percentAllocation < 0} | |||||
| /> | /> | ||||
| <TableCell sx={rightBorderCellSx}> | <TableCell sx={rightBorderCellSx}> | ||||
| {manhourFormatter.format( | {manhourFormatter.format( | ||||
| currentTaskGroups[tg.id].percentAllocation * totalManhour, | |||||
| currentTaskGroups[tg.id].percentAllocation / 100 * totalManhour, | |||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| {grades.map((column, idx) => { | {grades.map((column, idx) => { | ||||
| const stageHours = | const stageHours = | ||||
| currentTaskGroups[tg.id].percentAllocation * totalManhour; | |||||
| currentTaskGroups[tg.id].percentAllocation / 100 * totalManhour; | |||||
| return ( | return ( | ||||
| <TableCell key={`${column.id}${idx}`}> | <TableCell key={`${column.id}${idx}`}> | ||||
| {manhourFormatter.format( | {manhourFormatter.format( | ||||
| manhourPercentageByGrade[column.id] * stageHours, | |||||
| manhourPercentageByGrade[column.id] / 100 * stageHours, | |||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| ); | ); | ||||
| @@ -248,10 +288,14 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||||
| 0, | 0, | ||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={leftBorderCellSx}> | |||||
| <TableCell sx={{ | |||||
| ...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) === 100 && leftBorderCellSx), | |||||
| ...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) !== 100 && errorCellSx) | |||||
| }} | |||||
| > | |||||
| {percentFormatter.format( | {percentFormatter.format( | ||||
| Object.values(currentTaskGroups).reduce( | Object.values(currentTaskGroups).reduce( | ||||
| (acc, tg) => acc + tg.percentAllocation, | |||||
| (acc, tg) => acc + tg.percentAllocation / 100, | |||||
| 0, | 0, | ||||
| ), | ), | ||||
| )} | )} | ||||
| @@ -259,7 +303,7 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||||
| <TableCell sx={rightBorderCellSx}> | <TableCell sx={rightBorderCellSx}> | ||||
| {manhourFormatter.format( | {manhourFormatter.format( | ||||
| Object.values(currentTaskGroups).reduce( | Object.values(currentTaskGroups).reduce( | ||||
| (acc, tg) => acc + tg.percentAllocation * totalManhour, | |||||
| (acc, tg) => acc + tg.percentAllocation / 100 * totalManhour, | |||||
| 0, | 0, | ||||
| ), | ), | ||||
| )} | )} | ||||
| @@ -268,9 +312,9 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||||
| const hours = Object.values(currentTaskGroups).reduce( | const hours = Object.values(currentTaskGroups).reduce( | ||||
| (acc, tg) => | (acc, tg) => | ||||
| acc + | acc + | ||||
| tg.percentAllocation * | |||||
| totalManhour * | |||||
| manhourPercentageByGrade[column.id], | |||||
| tg.percentAllocation / 100 * | |||||
| totalManhour * | |||||
| manhourPercentageByGrade[column.id] / 100 , | |||||
| 0, | 0, | ||||
| ); | ); | ||||
| return ( | return ( | ||||
| @@ -52,7 +52,7 @@ const TaskSetup: React.FC<Props> = ({ | |||||
| (e: SelectChangeEvent<number | "All">) => { | (e: SelectChangeEvent<number | "All">) => { | ||||
| if (e.target.value === "All" || isNumber(e.target.value)) { | if (e.target.value === "All" || isNumber(e.target.value)) { | ||||
| setSelectedTaskTemplateId(e.target.value); | setSelectedTaskTemplateId(e.target.value); | ||||
| onReset(); | |||||
| // onReset(); | |||||
| } | } | ||||
| }, | }, | ||||
| [onReset], | [onReset], | ||||
| @@ -22,7 +22,6 @@ import { fetchSkillCombo } from "@/app/api/skill/actions"; | |||||
| import { fetchSalaryCombo } from "@/app/api/salarys/actions"; | import { fetchSalaryCombo } from "@/app/api/salarys/actions"; | ||||
| interface Field { | interface Field { | ||||
| // subtitle: string; | |||||
| id: string; | id: string; | ||||
| label: string; | label: string; | ||||
| type: string; | type: string; | ||||
| @@ -33,12 +32,6 @@ interface Field { | |||||
| options?: any[]; | options?: any[]; | ||||
| readOnly?: boolean; | readOnly?: boolean; | ||||
| } | } | ||||
| interface formProps { | |||||
| Title?: string[]; | |||||
| // fieldLists: Field[][]; | |||||
| } | |||||
| export interface comboItem { | export interface comboItem { | ||||
| company: comboProp[]; | company: comboProp[]; | ||||
| team: comboProp[]; | team: comboProp[]; | ||||
| @@ -49,101 +42,14 @@ export interface comboItem { | |||||
| salary: comboProp[]; | salary: comboProp[]; | ||||
| } | } | ||||
| const CreateStaff: React.FC<formProps> = ({ Title }) => { | |||||
| // const router = useRouter(); | |||||
| const { t } = useTranslation(); | |||||
| const [companyCombo, setCompanyCombo] = useState<comboProp[]>(); | |||||
| const [teamCombo, setTeamCombo] = useState<comboProp[]>(); | |||||
| const [departmentCombo, setDepartmentCombo] = useState<comboProp[]>(); | |||||
| const [positionCombo, setPositionCombo] = useState<comboProp[]>(); | |||||
| const [gradeCombo, setGradeCombo] = useState<comboProp[]>(); | |||||
| const [skillCombo, setSkillCombo] = useState<comboProp[]>(); | |||||
| const [salaryCombo, setSalaryCombo] = useState<comboProp[]>(); | |||||
| // const [serverError, setServerError] = useState(""); | |||||
| let comboItem: comboItem = { | |||||
| company: [], | |||||
| team: [], | |||||
| department: [], | |||||
| position: [], | |||||
| grade: [], | |||||
| skill: [], | |||||
| salary: [], | |||||
| }; | |||||
| const fetchCompany = async () => { | |||||
| await fetchCompanyCombo().then((data) => { | |||||
| if (data) setCompanyCombo(data.records); | |||||
| }); | |||||
| } | |||||
| const fetchTeam = async () => { | |||||
| await fetchTeamCombo().then((data) => { | |||||
| if (data) setTeamCombo(data.records); | |||||
| }); | |||||
| } | |||||
| const fetchDepartment = async () => { | |||||
| await fetchDepartmentCombo().then((data) => { | |||||
| if (data) setDepartmentCombo(data.records); | |||||
| }); | |||||
| } | |||||
| const fetchPosition = async () => { | |||||
| await fetchPositionCombo().then((data) => { | |||||
| if (data) setPositionCombo(data.records); | |||||
| }); | |||||
| } | |||||
| const fetchGrade = async () => { | |||||
| await fetchGradeCombo().then((data) => { | |||||
| if (data) setGradeCombo(data.records); | |||||
| }); | |||||
| } | |||||
| const fetchSkill = async () => { | |||||
| await fetchSkillCombo().then((data) => { | |||||
| if (data) setSkillCombo(data.records); | |||||
| }); | |||||
| } | |||||
| const fetchSalary = async () => { | |||||
| await fetchSalaryCombo().then((data) => { | |||||
| if (data) setSalaryCombo(data.records); | |||||
| }); | |||||
| } | |||||
| useEffect(() => { | |||||
| fetchCompany() | |||||
| fetchTeam() | |||||
| fetchDepartment() | |||||
| fetchPosition() | |||||
| fetchGrade() | |||||
| fetchSkill() | |||||
| fetchSalary() | |||||
| }, []); | |||||
| useEffect(() => { | |||||
| if(!companyCombo) | |||||
| fetchCompany() | |||||
| if(!teamCombo) | |||||
| fetchTeam() | |||||
| if(!departmentCombo) | |||||
| fetchDepartment() | |||||
| if(!positionCombo) | |||||
| fetchPosition() | |||||
| if(!gradeCombo) | |||||
| fetchGrade() | |||||
| if(!skillCombo) | |||||
| fetchSkill() | |||||
| if(!salaryCombo) | |||||
| fetchSalary() | |||||
| interface formProps { | |||||
| Title?: string[]; | |||||
| combos: comboItem; | |||||
| } | |||||
| }, [companyCombo, teamCombo, departmentCombo, positionCombo, gradeCombo, skillCombo, salaryCombo]); | |||||
| // useEffect(() => { | |||||
| // console.log(companyCombo) | |||||
| // }, [companyCombo]); | |||||
| const CreateStaff: React.FC<formProps> = ({ Title, combos }) => { | |||||
| const { t } = useTranslation(); | |||||
| const fieldLists: Field[][] = [ | const fieldLists: Field[][] = [ | ||||
| [ | [ | ||||
| @@ -163,49 +69,49 @@ const CreateStaff: React.FC<formProps> = ({ Title }) => { | |||||
| id: "companyId", | id: "companyId", | ||||
| label: t("Company"), | label: t("Company"), | ||||
| type: "combo-Obj", | type: "combo-Obj", | ||||
| options: companyCombo || [], | |||||
| options: combos.company || [], | |||||
| required: true, | required: true, | ||||
| }, | }, | ||||
| { | { | ||||
| id: "teamId", | id: "teamId", | ||||
| label: t("Team"), | label: t("Team"), | ||||
| type: "combo-Obj", | type: "combo-Obj", | ||||
| options: teamCombo || [], | |||||
| options: combos.team || [], | |||||
| required: false, | required: false, | ||||
| }, | }, | ||||
| { | { | ||||
| id: "departmentId", | id: "departmentId", | ||||
| label: t("Department"), | label: t("Department"), | ||||
| type: "combo-Obj", | type: "combo-Obj", | ||||
| options: departmentCombo || [], | |||||
| options: combos.department || [], | |||||
| required: true, | required: true, | ||||
| }, | }, | ||||
| { | { | ||||
| id: "gradeId", | id: "gradeId", | ||||
| label: t("Grade"), | label: t("Grade"), | ||||
| type: "combo-Obj", | type: "combo-Obj", | ||||
| options: gradeCombo || [], | |||||
| options: combos.grade || [], | |||||
| required: false, | required: false, | ||||
| }, | }, | ||||
| { | { | ||||
| id: "skillSetId", | id: "skillSetId", | ||||
| label: t("Skillset"), | label: t("Skillset"), | ||||
| type: "multiSelect-Obj", | type: "multiSelect-Obj", | ||||
| options: skillCombo || [], | |||||
| options: combos.skill || [], | |||||
| required: false, | required: false, | ||||
| }, | }, | ||||
| { | { | ||||
| id: "currentPositionId", | id: "currentPositionId", | ||||
| label: t("Current Position"), | label: t("Current Position"), | ||||
| type: "combo-Obj", | type: "combo-Obj", | ||||
| options: positionCombo || [], | |||||
| options: combos.position || [], | |||||
| required: true, | required: true, | ||||
| }, | }, | ||||
| { | { | ||||
| id: "salaryId", | id: "salaryId", | ||||
| label: t("Salary Point"), | label: t("Salary Point"), | ||||
| type: "combo-Obj", | type: "combo-Obj", | ||||
| options: salaryCombo || [], | |||||
| options: combos.salary || [], | |||||
| required: true, | required: true, | ||||
| }, | }, | ||||
| // { | // { | ||||
| @@ -279,7 +185,7 @@ const CreateStaff: React.FC<formProps> = ({ Title }) => { | |||||
| id: "joinPositionId", | id: "joinPositionId", | ||||
| label: t("Join Position"), | label: t("Join Position"), | ||||
| type: "combo-Obj", | type: "combo-Obj", | ||||
| options: positionCombo || [], | |||||
| options: combos.position || [], | |||||
| required: true, | required: true, | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -1,17 +1,48 @@ | |||||
| import React from "react"; | import React from "react"; | ||||
| import CreateStaff from "./CreateStaff"; | |||||
| import CreateStaff, { comboItem } from "./CreateStaff"; | |||||
| import CreateStaffLoading from "./CreateStaffLoading"; | import CreateStaffLoading from "./CreateStaffLoading"; | ||||
| import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; | import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; | ||||
| import { useSearchParams } from "next/navigation"; | import { useSearchParams } from "next/navigation"; | ||||
| import { fetchTeamCombo } from "@/app/api/team/actions"; | |||||
| import { fetchDepartmentCombo } from "@/app/api/departments/actions"; | |||||
| import { fetchPositionCombo } from "@/app/api/positions/actions"; | |||||
| import { fetchGradeCombo } from "@/app/api/grades/actions"; | |||||
| import { fetchSkillCombo } from "@/app/api/skill/actions"; | |||||
| import { fetchSalaryCombo } from "@/app/api/salarys/actions"; | |||||
| import { fetchCompanyCombo } from "@/app/api/companys/actions"; | |||||
| interface SubComponents { | interface SubComponents { | ||||
| Loading: typeof CreateStaffLoading; | Loading: typeof CreateStaffLoading; | ||||
| } | } | ||||
| const CreateStaffWrapper: React.FC & SubComponents = async () => { | const CreateStaffWrapper: React.FC & SubComponents = async () => { | ||||
| const [ | |||||
| CompanyCombo, | |||||
| TeamCombo, | |||||
| DepartmentCombo, | |||||
| PositionCombo, | |||||
| GradeCombo, | |||||
| SkillCombo, | |||||
| SalaryCombo, | |||||
| ] = await Promise.all([ | |||||
| fetchCompanyCombo(), | |||||
| fetchTeamCombo(), | |||||
| fetchDepartmentCombo(), | |||||
| fetchPositionCombo(), | |||||
| fetchGradeCombo(), | |||||
| fetchSkillCombo(), | |||||
| fetchSalaryCombo(), | |||||
| ]); | |||||
| const combos: comboItem = { | |||||
| company: CompanyCombo.records, | |||||
| team: TeamCombo.records, | |||||
| department: DepartmentCombo.records, | |||||
| position: PositionCombo.records, | |||||
| grade: GradeCombo.records, | |||||
| skill: SkillCombo.records, | |||||
| salary: SalaryCombo.records, | |||||
| } | |||||
| return <CreateStaff/>; | |||||
| return <CreateStaff combos={combos}/>; | |||||
| }; | }; | ||||
| CreateStaffWrapper.Loading = CreateStaffLoading; | CreateStaffWrapper.Loading = CreateStaffLoading; | ||||
| @@ -10,26 +10,31 @@ import TransferList from "../TransferList"; | |||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import Check from "@mui/icons-material/Check"; | import Check from "@mui/icons-material/Check"; | ||||
| import Close from "@mui/icons-material/Close"; | import Close from "@mui/icons-material/Close"; | ||||
| import { useRouter, useSearchParams } from "next/navigation"; | |||||
| import { useRouter } from "next/navigation"; | |||||
| import React from "react"; | import React from "react"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import { Task } from "@/app/api/tasks"; | |||||
| import { Task, TaskTemplate } from "@/app/api/tasks"; | |||||
| import { | import { | ||||
| NewTaskTemplateFormInputs, | NewTaskTemplateFormInputs, | ||||
| fetchTaskTemplate, | |||||
| saveTaskTemplate, | saveTaskTemplate, | ||||
| } from "@/app/api/tasks/actions"; | } from "@/app/api/tasks/actions"; | ||||
| import { SubmitHandler, useFieldArray, useForm } from "react-hook-form"; | |||||
| import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||||
| import { errorDialog, submitDialog, successDialog } from "../Swal/CustomAlerts"; | import { errorDialog, submitDialog, successDialog } from "../Swal/CustomAlerts"; | ||||
| import { Grade } from "@/app/api/grades"; | |||||
| import { intersectionWith, isEmpty } from "lodash"; | |||||
| import ResourceAllocationWrapper from "./ResourceAllocation"; | |||||
| interface Props { | interface Props { | ||||
| tasks: Task[]; | tasks: Task[]; | ||||
| defaultInputs?: NewTaskTemplateFormInputs; | |||||
| grades: Grade[] | |||||
| } | } | ||||
| const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||||
| const CreateTaskTemplate: React.FC<Props> = ({ tasks, defaultInputs, grades }) => { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const searchParams = useSearchParams() | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const handleCancel = () => { | const handleCancel = () => { | ||||
| router.back(); | router.back(); | ||||
| @@ -47,57 +52,53 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||||
| const [serverError, setServerError] = React.useState(""); | const [serverError, setServerError] = React.useState(""); | ||||
| const { | |||||
| register, | |||||
| handleSubmit, | |||||
| setValue, | |||||
| watch, | |||||
| resetField, | |||||
| formState: { errors, isSubmitting }, | |||||
| } = useForm<NewTaskTemplateFormInputs>({ defaultValues: { taskIds: [] } }); | |||||
| const currentTaskIds = watch("taskIds"); | |||||
| const selectedItems = React.useMemo(() => { | |||||
| return items.filter((item) => currentTaskIds.includes(item.id)); | |||||
| }, [currentTaskIds, items]); | |||||
| const [refTaskTemplate, setRefTaskTemplate] = React.useState<NewTaskTemplateFormInputs>() | |||||
| const id = searchParams.get('id') | |||||
| const fetchCurrentTaskTemplate = async () => { | |||||
| try { | |||||
| const taskTemplate = await fetchTaskTemplate(parseInt(id!!)) | |||||
| const formProps = useForm<NewTaskTemplateFormInputs>({ | |||||
| defaultValues: { | |||||
| taskGroups: {}, | |||||
| ...defaultInputs, | |||||
| const defaultValues = { | |||||
| id: parseInt(id!!), | |||||
| code: taskTemplate.code ?? null, | |||||
| name: taskTemplate.name ?? null, | |||||
| taskIds: taskTemplate.tasks.map(task => task.id) ?? [], | |||||
| } | |||||
| setRefTaskTemplate(defaultValues) | |||||
| } catch (e) { | |||||
| console.log(e) | |||||
| } | |||||
| } | |||||
| React.useLayoutEffect(() => { | |||||
| if (id !== null && parseInt(id) > 0) fetchCurrentTaskTemplate() | |||||
| }, [id]) | |||||
| React.useEffect(() => { | |||||
| if (refTaskTemplate) { | |||||
| setValue("taskIds", refTaskTemplate.taskIds) | |||||
| resetField("code", { defaultValue: refTaskTemplate.code }) | |||||
| resetField("name", { defaultValue: refTaskTemplate.name }) | |||||
| setValue("id", refTaskTemplate.id) | |||||
| manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade) | |||||
| ? grades.reduce((acc, grade) => { | |||||
| return { ...acc, [grade.id]: 100 / grades.length }; | |||||
| }, {}) | |||||
| : defaultInputs?.manhourPercentageByGrade, | |||||
| } | } | ||||
| }, [refTaskTemplate]) | |||||
| }); | |||||
| const currentTaskGroups = formProps.watch("taskGroups"); | |||||
| const currentTaskIds = Object.values(currentTaskGroups).reduce<Task["id"][]>( | |||||
| (acc, group) => { | |||||
| return [...acc, ...group.taskIds]; | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| const selectedItems = React.useMemo(() => { | |||||
| return intersectionWith( | |||||
| tasks, | |||||
| currentTaskIds, | |||||
| (task, taskId) => task.id === taskId, | |||||
| ).map((t) => ({ id: t.id, label: t.name, group: t.taskGroup })); | |||||
| }, [currentTaskIds, tasks]); | |||||
| const onSubmit: SubmitHandler<NewTaskTemplateFormInputs> = React.useCallback( | const onSubmit: SubmitHandler<NewTaskTemplateFormInputs> = React.useCallback( | ||||
| async (data) => { | async (data) => { | ||||
| try { | try { | ||||
| console.log(data) | |||||
| setServerError(""); | setServerError(""); | ||||
| let hasErrors = false | |||||
| // check the manhour allocation by stage by grade -> total = 100? | |||||
| const taskGroupKeys = Object.keys(data.taskGroups) | |||||
| if (taskGroupKeys.filter(k => data.taskGroups[k as any].percentAllocation < 0).length > 0 || | |||||
| taskGroupKeys.reduce((acc, value) => acc + data.taskGroups[value as any].percentAllocation, 0) !== 100) { | |||||
| formProps.setError("taskGroups", { message: "Task Groups value is not invalid", type: "invalid" }) | |||||
| hasErrors = true | |||||
| } | |||||
| if (hasErrors) return false | |||||
| submitDialog(async () => { | submitDialog(async () => { | ||||
| const response = await saveTaskTemplate(data); | const response = await saveTaskTemplate(data); | ||||
| @@ -120,8 +121,9 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| { | |||||
| (id === null || refTaskTemplate !== undefined) && <Stack component="form" onSubmit={handleSubmit(onSubmit)} gap={2}> | |||||
| <FormProvider {...formProps}> | |||||
| <Stack component="form" onSubmit={formProps.handleSubmit(onSubmit)} gap={2}> | |||||
| {/* Task List Setup */} | |||||
| <Card> | <Card> | ||||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | ||||
| <Typography variant="overline">{t("Task List Setup")}</Typography> | <Typography variant="overline">{t("Task List Setup")}</Typography> | ||||
| @@ -135,22 +137,22 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||||
| <TextField | <TextField | ||||
| label={t("Task Template Code")} | label={t("Task Template Code")} | ||||
| fullWidth | fullWidth | ||||
| {...register("code", { | |||||
| {...formProps.register("code", { | |||||
| required: t("Task template code is required"), | required: t("Task template code is required"), | ||||
| })} | })} | ||||
| error={Boolean(errors.code?.message)} | |||||
| helperText={errors.code?.message} | |||||
| error={Boolean(formProps.formState.errors.code?.message)} | |||||
| helperText={formProps.formState.errors.code?.message} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={t("Task Template Name")} | label={t("Task Template Name")} | ||||
| fullWidth | fullWidth | ||||
| {...register("name", { | |||||
| {...formProps.register("name", { | |||||
| required: t("Task template name is required"), | required: t("Task template name is required"), | ||||
| })} | })} | ||||
| error={Boolean(errors.name?.message)} | |||||
| helperText={errors.name?.message} | |||||
| error={Boolean(formProps.formState.errors.name?.message)} | |||||
| helperText={formProps.formState.errors.name?.message} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| </Grid> | </Grid> | ||||
| @@ -158,16 +160,54 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||||
| allItems={items} | allItems={items} | ||||
| selectedItems={selectedItems} | selectedItems={selectedItems} | ||||
| onChange={(selectedTasks) => { | onChange={(selectedTasks) => { | ||||
| setValue( | |||||
| "taskIds", | |||||
| selectedTasks.map((item) => item.id), | |||||
| ); | |||||
| // formProps.setValue( | |||||
| // "taskIds", | |||||
| // selectedTasks.map((item) => item.id), | |||||
| // ); | |||||
| const newTaskGroups = selectedTasks.reduce< | |||||
| NewTaskTemplateFormInputs["taskGroups"] | |||||
| >((acc, item) => { | |||||
| if (!item.group) { | |||||
| // TODO: this should not happen (all tasks are part of a group) | |||||
| return acc; | |||||
| } | |||||
| if (!acc[item.group.id]) { | |||||
| return { | |||||
| ...acc, | |||||
| [item.group.id]: { | |||||
| taskIds: [item.id], | |||||
| percentAllocation: | |||||
| currentTaskGroups[item.group.id]?.percentAllocation || 0, | |||||
| }, | |||||
| }; | |||||
| } | |||||
| return { | |||||
| ...acc, | |||||
| [item.group.id]: { | |||||
| ...acc[item.group.id], | |||||
| taskIds: [...acc[item.group.id].taskIds, item.id], | |||||
| }, | |||||
| }; | |||||
| }, {}); | |||||
| formProps.setValue("taskGroups", newTaskGroups); | |||||
| }} | }} | ||||
| allItemsLabel={t("Task Pool")} | allItemsLabel={t("Task Pool")} | ||||
| selectedItemsLabel={t("Task List Template")} | selectedItemsLabel={t("Task List Template")} | ||||
| /> | /> | ||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||
| {/* Resource Allocation */} | |||||
| <Card> | |||||
| <CardContent> | |||||
| <ResourceAllocationWrapper | |||||
| allTasks={tasks} | |||||
| grades={grades} | |||||
| /> | |||||
| </CardContent> | |||||
| </Card> | |||||
| { | { | ||||
| serverError && ( | serverError && ( | ||||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | <Typography variant="body2" color="error" alignSelf="flex-end"> | ||||
| @@ -183,12 +223,13 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||||
| variant="contained" | variant="contained" | ||||
| startIcon={<Check />} | startIcon={<Check />} | ||||
| type="submit" | type="submit" | ||||
| disabled={isSubmitting} | |||||
| disabled={formProps.formState.isSubmitting} | |||||
| > | > | ||||
| {t("Confirm")} | {t("Confirm")} | ||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Stack >} | |||||
| </Stack > | |||||
| </FormProvider> | |||||
| </> | </> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -1,11 +1,20 @@ | |||||
| import React from "react"; | import React from "react"; | ||||
| import CreateTaskTemplate from "./CreateTaskTemplate"; | import CreateTaskTemplate from "./CreateTaskTemplate"; | ||||
| import { fetchAllTasks } from "@/app/api/tasks"; | |||||
| import { fetchAllTasks, fetchTaskTemplateDetail } from "@/app/api/tasks"; | |||||
| import { fetchGrades } from "@/app/api/grades"; | |||||
| const CreateTaskTemplateWrapper: React.FC = async () => { | |||||
| const tasks = await fetchAllTasks(); | |||||
| interface Props { | |||||
| taskTemplateId?: string; | |||||
| } | |||||
| return <CreateTaskTemplate tasks={tasks} />; | |||||
| const CreateTaskTemplateWrapper: React.FC<Props> = async (props) => { | |||||
| const [tasks, grades] = await Promise.all([ | |||||
| fetchAllTasks(), | |||||
| fetchGrades(), | |||||
| ]); | |||||
| const taskTemplateInfo = props.taskTemplateId ? await fetchTaskTemplateDetail(props.taskTemplateId) : undefined | |||||
| return <CreateTaskTemplate tasks={tasks} grades={grades} defaultInputs={taskTemplateInfo}/>; | |||||
| }; | }; | ||||
| export default CreateTaskTemplateWrapper; | export default CreateTaskTemplateWrapper; | ||||
| @@ -0,0 +1,287 @@ | |||||
| import { Task, TaskGroup } from "@/app/api/tasks"; | |||||
| import { | |||||
| Box, | |||||
| Typography, | |||||
| TextField, | |||||
| Alert, | |||||
| TableContainer, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Stack, | |||||
| SxProps, | |||||
| } from "@mui/material"; | |||||
| import React, { useCallback, useMemo } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import uniqBy from "lodash/uniqBy"; | |||||
| import { Grade } from "@/app/api/grades"; | |||||
| import { percentFormatter } from "@/app/utils/formatUtil"; | |||||
| import TableCellEdit from "../TableCellEdit"; | |||||
| import { useFormContext } from "react-hook-form"; | |||||
| import { NewTaskTemplateFormInputs } from "@/app/api/tasks/actions"; | |||||
| interface Props { | |||||
| allTasks: Task[]; | |||||
| grades: Grade[]; | |||||
| } | |||||
| const leftBorderCellSx: SxProps = { | |||||
| borderLeft: "1px solid", | |||||
| borderColor: "divider", | |||||
| }; | |||||
| const rightBorderCellSx: SxProps = { | |||||
| borderRight: "1px solid", | |||||
| borderColor: "divider", | |||||
| }; | |||||
| const leftRightBorderCellSx: SxProps = { | |||||
| borderLeft: "1px solid", | |||||
| borderRight: "1px solid", | |||||
| borderColor: "divider", | |||||
| }; | |||||
| const errorCellSx: SxProps = { | |||||
| outline: "1px solid", | |||||
| outlineColor: "error.main", | |||||
| } | |||||
| const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||||
| const { t } = useTranslation(); | |||||
| const { watch, register, setValue, formState: { errors }, setError, clearErrors } = useFormContext<NewTaskTemplateFormInputs>(); | |||||
| const manhourPercentageByGrade = watch("manhourPercentageByGrade"); | |||||
| const totalPercentage = Math.round(Object.values(manhourPercentageByGrade).reduce( | |||||
| (acc, percent) => acc + percent, | |||||
| 0, | |||||
| ) * 100) / 100; | |||||
| const makeUpdatePercentage = useCallback( | |||||
| (gradeId: Grade["id"]) => (percentage?: number) => { | |||||
| if (percentage !== undefined) { | |||||
| const updatedManhourPercentageByGrade = { | |||||
| ...manhourPercentageByGrade, | |||||
| [gradeId]: percentage, | |||||
| } | |||||
| setValue("manhourPercentageByGrade", updatedManhourPercentageByGrade); | |||||
| const keys = Object.keys(updatedManhourPercentageByGrade) | |||||
| if (keys.filter(k => updatedManhourPercentageByGrade[k as any] < 0).length > 0 || | |||||
| keys.reduce((acc, value) => acc + updatedManhourPercentageByGrade[value as any], 0) !== 100) { | |||||
| setError("manhourPercentageByGrade", { message: "manhourPercentageByGrade value is not valid", type: "invalid" }) | |||||
| } else { | |||||
| clearErrors("manhourPercentageByGrade") | |||||
| } | |||||
| } | |||||
| }, | |||||
| [manhourPercentageByGrade, setValue], | |||||
| ); | |||||
| return ( | |||||
| <Box> | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
| {t("Manhour Allocation By Grade")} | |||||
| </Typography> | |||||
| <Box | |||||
| sx={(theme) => ({ | |||||
| marginBlockStart: 2, | |||||
| marginInline: -3, | |||||
| borderBottom: `1px solid ${theme.palette.divider}`, | |||||
| })} | |||||
| > | |||||
| <TableContainer sx={{ maxHeight: 440 }}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell sx={rightBorderCellSx}> | |||||
| {t("Allocation Type")} | |||||
| </TableCell> | |||||
| {grades.map((column, idx) => ( | |||||
| <TableCell key={`${column.id}${idx}`}> | |||||
| {column.name} | |||||
| </TableCell> | |||||
| ))} | |||||
| <TableCell sx={leftBorderCellSx}>{t("Total")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| <TableRow> | |||||
| <TableCell sx={rightBorderCellSx}>{t("Percentage")}</TableCell> | |||||
| {grades.map((column, idx) => ( | |||||
| <TableCellEdit | |||||
| key={`${column.id}${idx}`} | |||||
| value={manhourPercentageByGrade[column.id]} | |||||
| renderValue={(val) => val + "%"} | |||||
| onChange={makeUpdatePercentage(column.id)} | |||||
| convertValue={(inputValue) => Number(inputValue)} | |||||
| cellSx={{ backgroundColor: "primary.lightest" }} | |||||
| inputSx={{ width: "3rem" }} | |||||
| error={manhourPercentageByGrade[column.id] < 0} | |||||
| /> | |||||
| ))} | |||||
| <TableCell sx={{ ...(totalPercentage === 100 && leftBorderCellSx), ...(totalPercentage !== 100 && { ...errorCellSx, borderRight: "1px solid", borderColor: "error.main" }) }}> | |||||
| {totalPercentage + "%"} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </Box> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||||
| const { t } = useTranslation(); | |||||
| const { watch, setValue, clearErrors, setError } = useFormContext<NewTaskTemplateFormInputs>(); | |||||
| const currentTaskGroups = watch("taskGroups"); | |||||
| const taskGroups = useMemo( | |||||
| () => | |||||
| uniqBy( | |||||
| allTasks.reduce<TaskGroup[]>((acc, task) => { | |||||
| if (currentTaskGroups[task.taskGroup.id]) { | |||||
| return [...acc, task.taskGroup]; | |||||
| } | |||||
| return acc; | |||||
| }, []), | |||||
| "id", | |||||
| ), | |||||
| [allTasks, currentTaskGroups], | |||||
| ); | |||||
| const manhourPercentageByGrade = watch("manhourPercentageByGrade"); | |||||
| const makeUpdatePercentage = useCallback( | |||||
| (taskGroupId: TaskGroup["id"]) => (percentage?: number) => { | |||||
| console.log(percentage) | |||||
| if (percentage !== undefined) { | |||||
| const updatedTaskGroups = { | |||||
| ...currentTaskGroups, | |||||
| [taskGroupId]: { | |||||
| ...currentTaskGroups[taskGroupId], | |||||
| percentAllocation: percentage, | |||||
| }, | |||||
| } | |||||
| console.log(updatedTaskGroups) | |||||
| setValue("taskGroups", updatedTaskGroups); | |||||
| const keys = Object.keys(updatedTaskGroups) | |||||
| if (keys.filter(k => updatedTaskGroups[k as any].percentAllocation < 0).length > 0 || | |||||
| keys.reduce((acc, value) => acc + updatedTaskGroups[value as any].percentAllocation, 0) !== 100) { | |||||
| setError("taskGroups", { message: "Task Groups value is not invalid", type: "invalid" }) | |||||
| } else { | |||||
| clearErrors("taskGroups") | |||||
| } | |||||
| } | |||||
| }, | |||||
| [currentTaskGroups, setValue], | |||||
| ); | |||||
| return ( | |||||
| <Box> | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
| {t("Manhour Allocation By Stage By Grade")} | |||||
| </Typography> | |||||
| <Box | |||||
| sx={(theme) => ({ | |||||
| marginBlockStart: 2, | |||||
| marginInline: -3, | |||||
| borderBottom: `1px solid ${theme.palette.divider}`, | |||||
| })} | |||||
| > | |||||
| <TableContainer sx={{ maxHeight: 440 }}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Stage")}</TableCell> | |||||
| <TableCell sx={leftBorderCellSx}>{t("Task Count")}</TableCell> | |||||
| <TableCell colSpan={2} sx={leftRightBorderCellSx}> | |||||
| {t("Total Manhour")} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {taskGroups.map((tg, idx) => ( | |||||
| <TableRow key={`${tg.id}${idx}`}> | |||||
| <TableCell>{tg.name}</TableCell> | |||||
| <TableCell sx={leftBorderCellSx}> | |||||
| {currentTaskGroups[tg.id].taskIds.length} | |||||
| </TableCell> | |||||
| <TableCellEdit | |||||
| value={currentTaskGroups[tg.id].percentAllocation} | |||||
| // renderValue={(val) => percentFormatter.format(val)} | |||||
| renderValue={(val) => val + "%"} | |||||
| onChange={makeUpdatePercentage(tg.id)} | |||||
| convertValue={(inputValue) => Number(inputValue)} | |||||
| cellSx={{ | |||||
| backgroundColor: "primary.lightest", | |||||
| ...(currentTaskGroups[tg.id].percentAllocation < 0 && { ...errorCellSx, borderBottom: "0px", borderRight: "1px solid", borderColor: "error.main"}) | |||||
| }} | |||||
| inputSx={{ width: "3rem" }} | |||||
| error={currentTaskGroups[tg.id].percentAllocation < 0} | |||||
| /> | |||||
| </TableRow> | |||||
| ))} | |||||
| <TableRow> | |||||
| <TableCell>{t("Total")}</TableCell> | |||||
| <TableCell sx={leftBorderCellSx}> | |||||
| {Object.values(currentTaskGroups).reduce( | |||||
| (acc, tg) => acc + tg.taskIds.length, | |||||
| 0, | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell sx={{ | |||||
| ...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) === 100 && leftBorderCellSx), | |||||
| ...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) !== 100 && { ...errorCellSx, borderRight: "1px solid", borderColor: "error.main"}) | |||||
| }} | |||||
| > | |||||
| {percentFormatter.format( | |||||
| Object.values(currentTaskGroups).reduce( | |||||
| (acc, tg) => acc + tg.percentAllocation / 100, | |||||
| 0, | |||||
| ), | |||||
| )} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </Box> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| const NoTaskState: React.FC = () => { | |||||
| const { t } = useTranslation(); | |||||
| return ( | |||||
| <> | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
| {t("Task Breakdown")} | |||||
| </Typography> | |||||
| <Alert severity="warning"> | |||||
| {t('Please add some tasks first!')} | |||||
| </Alert> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| const ResourceAllocationWrapper: React.FC<Props> = (props) => { | |||||
| const { getValues } = useFormContext<NewTaskTemplateFormInputs>(); | |||||
| if (Object.keys(getValues("taskGroups")).length === 0) { | |||||
| return <NoTaskState />; | |||||
| } | |||||
| return ( | |||||
| <Stack spacing={4}> | |||||
| <ResourceAllocationByGrade {...props} /> | |||||
| <ResourceAllocationByStage {...props} /> | |||||
| </Stack> | |||||
| ); | |||||
| }; | |||||
| export default ResourceAllocationWrapper; | |||||
| @@ -27,7 +27,7 @@ const TeamInfo: React.FC = ( | |||||
| setValue, | setValue, | ||||
| } = useFormContext<CreateTeamInputs>(); | } = useFormContext<CreateTeamInputs>(); | ||||
| const resetCustomer = useCallback(() => { | |||||
| const resetTeam = useCallback(() => { | |||||
| console.log(defaultValues); | console.log(defaultValues); | ||||
| if (defaultValues !== undefined) { | if (defaultValues !== undefined) { | ||||
| resetField("description"); | resetField("description"); | ||||
| @@ -199,20 +199,20 @@ const CustomerSave: React.FC<Props> = ({ | |||||
| setServerError(""); | setServerError(""); | ||||
| submitDialog(async () => { | submitDialog(async () => { | ||||
| const response = await saveCustomer(data); | |||||
| if (response.message === "Success") { | |||||
| successDialog(t("Submit Success"), t).then(() => { | |||||
| router.replace("/settings/customer"); | |||||
| }) | |||||
| } else { | |||||
| errorDialog(t("Submit Fail"), t).then(() => { | |||||
| formProps.setError("code", { message: response.message, type: "custom" }) | |||||
| setTabIndex(0) | |||||
| return false | |||||
| }) | |||||
| } | |||||
| }, t) | |||||
| const response = await saveCustomer(data); | |||||
| if (response.message === "Success") { | |||||
| successDialog(t("Submit Success"), t).then(() => { | |||||
| router.replace("/settings/customer"); | |||||
| }) | |||||
| } else { | |||||
| errorDialog(t("Submit Fail"), t).then(() => { | |||||
| formProps.setError("code", { message: response.message, type: "custom" }) | |||||
| setTabIndex(0) | |||||
| return false | |||||
| }) | |||||
| } | |||||
| }, t) | |||||
| } catch (e) { | } catch (e) { | ||||
| console.log(e) | console.log(e) | ||||
| setServerError(t("An error has occurred. Please try again later.")); | setServerError(t("An error has occurred. Please try again later.")); | ||||