| @@ -14,6 +14,7 @@ | |||
| "@faker-js/faker": "^8.4.1", | |||
| "@fontsource/inter": "^5.0.16", | |||
| "@fontsource/plus-jakarta-sans": "^5.0.18", | |||
| "@fullcalendar/react": "^6.1.11", | |||
| "@mui/icons-material": "^5.15.0", | |||
| "@mui/material": "^5.15.0", | |||
| "@mui/material-nextjs": "^5.15.0", | |||
| @@ -21,13 +22,14 @@ | |||
| "@mui/x-date-pickers": "^6.18.7", | |||
| "@unly/universal-language-detector": "^2.0.3", | |||
| "apexcharts": "^3.45.2", | |||
| "axios": "^1.6.8", | |||
| "date-holidays": "^3.23.11", | |||
| "dayjs": "^1.11.10", | |||
| "fullcalendar": "^6.1.11", | |||
| "i18next": "^23.7.11", | |||
| "i18next-resources-to-backend": "^1.2.0", | |||
| "lodash": "^4.17.21", | |||
| "next": "14.0.4", | |||
| "next-auth": "^4.24.5", | |||
| "next-auth": "^4.24.7", | |||
| "next-pwa": "^5.6.0", | |||
| "react": "^18", | |||
| "react-apexcharts": "^1.4.1", | |||
| @@ -2081,6 +2083,79 @@ | |||
| "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": { | |||
| "version": "0.11.14", | |||
| "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", | |||
| @@ -3648,8 +3723,7 @@ | |||
| "node_modules/argparse": { | |||
| "version": "2.0.1", | |||
| "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": { | |||
| "version": "5.3.0", | |||
| @@ -3855,6 +3929,14 @@ | |||
| "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", | |||
| "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": { | |||
| "version": "3.2.5", | |||
| "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", | |||
| @@ -3869,11 +3951,6 @@ | |||
| "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": { | |||
| "version": "1.0.0", | |||
| "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", | |||
| @@ -3942,16 +4019,6 @@ | |||
| "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": { | |||
| "version": "3.2.1", | |||
| "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", | |||
| @@ -4136,6 +4203,17 @@ | |||
| "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": { | |||
| "version": "1.0.7", | |||
| "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", | |||
| "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": { | |||
| "version": "4.1.1", | |||
| "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", | |||
| @@ -4489,6 +4556,68 @@ | |||
| "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", | |||
| "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": { | |||
| "version": "1.11.10", | |||
| "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", | |||
| @@ -4626,14 +4755,6 @@ | |||
| "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": { | |||
| "version": "2.0.3", | |||
| "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", | |||
| @@ -5706,25 +5827,6 @@ | |||
| "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", | |||
| "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": { | |||
| "version": "0.3.3", | |||
| "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_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": { | |||
| "version": "1.1.2", | |||
| "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", | |||
| @@ -6755,6 +6870,11 @@ | |||
| "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": { | |||
| "version": "27.5.1", | |||
| "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", | |||
| @@ -6800,9 +6920,9 @@ | |||
| } | |||
| }, | |||
| "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": { | |||
| "url": "https://github.com/sponsors/panva" | |||
| } | |||
| @@ -6816,7 +6936,6 @@ | |||
| "version": "4.1.0", | |||
| "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", | |||
| "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", | |||
| "dev": true, | |||
| "dependencies": { | |||
| "argparse": "^2.0.1" | |||
| }, | |||
| @@ -7135,6 +7254,7 @@ | |||
| "version": "1.52.0", | |||
| "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", | |||
| "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", | |||
| "peer": true, | |||
| "engines": { | |||
| "node": ">= 0.6" | |||
| } | |||
| @@ -7143,6 +7263,7 @@ | |||
| "version": "2.1.35", | |||
| "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", | |||
| "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", | |||
| "peer": true, | |||
| "dependencies": { | |||
| "mime-db": "1.52.0" | |||
| }, | |||
| @@ -7183,6 +7304,25 @@ | |||
| "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": { | |||
| "version": "2.1.2", | |||
| "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", | |||
| @@ -7275,14 +7415,14 @@ | |||
| } | |||
| }, | |||
| "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": { | |||
| "@babel/runtime": "^7.20.13", | |||
| "@panva/hkdf": "^1.0.2", | |||
| "cookie": "^0.5.0", | |||
| "jose": "^4.11.4", | |||
| "jose": "^4.15.5", | |||
| "oauth": "^0.9.15", | |||
| "openid-client": "^5.4.0", | |||
| "preact": "^10.6.3", | |||
| @@ -7993,6 +8133,14 @@ | |||
| "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": { | |||
| "version": "3.1.1", | |||
| "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", | |||
| @@ -8057,11 +8205,6 @@ | |||
| "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": { | |||
| "version": "2.3.1", | |||
| "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", | |||
| @@ -15,6 +15,7 @@ | |||
| "@faker-js/faker": "^8.4.1", | |||
| "@fontsource/inter": "^5.0.16", | |||
| "@fontsource/plus-jakarta-sans": "^5.0.18", | |||
| "@fullcalendar/react": "^6.1.11", | |||
| "@mui/icons-material": "^5.15.0", | |||
| "@mui/material": "^5.15.0", | |||
| "@mui/material-nextjs": "^5.15.0", | |||
| @@ -22,12 +23,14 @@ | |||
| "@mui/x-date-pickers": "^6.18.7", | |||
| "@unly/universal-language-detector": "^2.0.3", | |||
| "apexcharts": "^3.45.2", | |||
| "date-holidays": "^3.23.11", | |||
| "dayjs": "^1.11.10", | |||
| "fullcalendar": "^6.1.11", | |||
| "i18next": "^23.7.11", | |||
| "i18next-resources-to-backend": "^1.2.0", | |||
| "lodash": "^4.17.21", | |||
| "next": "14.0.4", | |||
| "next-auth": "^4.24.5", | |||
| "next-auth": "^4.24.7", | |||
| "next-pwa": "^5.6.0", | |||
| "react": "^18", | |||
| "react-apexcharts": "^1.4.1", | |||
| @@ -2,10 +2,10 @@ import { Metadata } from "next"; | |||
| import { Suspense } from "react"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| import { fetchProjects } from "@/app/api/projects"; | |||
| import GenerateEX02ProjectCashFlowReport from "@/components/GenerateEX02ProjectCashFlowReport"; | |||
| import GenerateProjectCashFlowReport from "@/components/GenerateProjectCashFlowReport"; | |||
| export const metadata: Metadata = { | |||
| title: "EX02 - Project Cash Flow Report", | |||
| title: "Project Cash Flow Report", | |||
| }; | |||
| const ProjectCashFlowReport: React.FC = async () => { | |||
| @@ -14,8 +14,8 @@ const ProjectCashFlowReport: React.FC = async () => { | |||
| return ( | |||
| <> | |||
| <I18nProvider namespaces={["report", "common"]}> | |||
| <Suspense fallback={<GenerateEX02ProjectCashFlowReport.Loading />}> | |||
| <GenerateEX02ProjectCashFlowReport /> | |||
| <Suspense fallback={<GenerateProjectCashFlowReport.Loading />}> | |||
| <GenerateProjectCashFlowReport /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| @@ -1,15 +1,36 @@ | |||
| import { Metadata } from "next"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| 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 = { | |||
| title: "User Workspace", | |||
| }; | |||
| 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 ( | |||
| <I18nProvider namespaces={["home"]}> | |||
| <UserWorkspacePage /> | |||
| <UserWorkspacePage username={username} /> | |||
| </I18nProvider> | |||
| ); | |||
| }; | |||
| @@ -5,7 +5,7 @@ import Button from "@mui/material/Button"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import Link from "next/link"; | |||
| import CreateInvoice from "@/components/CreateInvoice"; | |||
| import CreateInvoice from "@/components/CreateInvoice_forGen"; | |||
| export const metadata: Metadata = { | |||
| title: "Create Invoice", | |||
| @@ -31,10 +31,10 @@ export default async function MainLayout({ | |||
| padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, | |||
| }} | |||
| > | |||
| <Stack spacing={2}> | |||
| <Breadcrumb /> | |||
| {children} | |||
| </Stack> | |||
| <Stack spacing={2}> | |||
| <Breadcrumb /> | |||
| {children} | |||
| </Stack> | |||
| </Box> | |||
| </> | |||
| ); | |||
| @@ -43,7 +43,7 @@ const Projects: React.FC = async () => { | |||
| <> | |||
| <Typography variant="h4">{t("Create Project")}</Typography> | |||
| <I18nProvider namespaces={["projects"]}> | |||
| <CreateProject /> | |||
| <CreateProject isEditMode={false} /> | |||
| </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> | |||
| <I18nProvider namespaces={["departments"]}> | |||
| <CreateDepartment /> | |||
| <CreateDepartment isEdit={false} /> | |||
| </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 { ProjectCategory } from "@/app/api/projects"; | |||
| import { Grid, Typography } from "@mui/material"; | |||
| import CreateStaffForm from "@/components/CreateStaff/CreateStaff"; | |||
| import CreateStaff from "@/components/CreateStaff"; | |||
| interface CreateCustomInputs { | |||
| projectCode: string; | |||
| @@ -31,23 +31,17 @@ interface CreateCustomInputs { | |||
| // const Title = ["title1", "title2"]; | |||
| const CreateStaff: React.FC = async () => { | |||
| const CreateStaffPage: React.FC = async () => { | |||
| 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 ( | |||
| <> | |||
| <Typography variant="h4">{t("Create Staff")}</Typography> | |||
| <I18nProvider namespaces={["staff"]}> | |||
| <CreateStaffForm | |||
| Title={title} | |||
| /> | |||
| <CreateStaff/> | |||
| </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 { 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 ( | |||
| <> | |||
| <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}> | |||
| {t("User")} | |||
| </Typography> | |||
| <Button | |||
| {/* <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| LinkComponent={Link} | |||
| href="/settings/team/create" | |||
| > | |||
| {t("Create User")} | |||
| </Button> | |||
| </Button> */} | |||
| </Stack> | |||
| <I18nProvider namespaces={["User", "common"]}> | |||
| <Suspense fallback={<UserSearch.Loading />}> | |||
| @@ -1,4 +1,4 @@ | |||
| import ClaimDetail from "@/components/ClaimDetail"; | |||
| import ClaimSave from "@/components/ClaimSave"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Metadata } from "next"; | |||
| @@ -14,7 +14,7 @@ const ClaimDetails: React.FC = async () => { | |||
| <> | |||
| <Typography variant="h4">{t("Create Claim")}</Typography> | |||
| <I18nProvider namespaces={["claim", "common"]}> | |||
| <ClaimDetail /> | |||
| <ClaimSave /> | |||
| </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 { getServerI18n } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Metadata } from "next"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| import { ServerFetchError } from "@/app/utils/fetchUtil"; | |||
| import { isArray } from "lodash"; | |||
| import { notFound } from "next/navigation"; | |||
| export const metadata: Metadata = { | |||
| 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 taskTemplateId = searchParams["id"]; | |||
| if (!taskTemplateId || isArray(taskTemplateId)) { | |||
| notFound(); | |||
| } | |||
| preloadAllTasks(); | |||
| try { | |||
| await fetchTaskTemplateDetail(taskTemplateId); | |||
| } catch (e) { | |||
| if (e instanceof ServerFetchError && e.response?.status === 404) { | |||
| notFound(); | |||
| } | |||
| } | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Edit Task Template")}</Typography> | |||
| <I18nProvider namespaces={["tasks", "common"]}> | |||
| <CreateTaskTemplate /> | |||
| <CreateTaskTemplate taskTemplateId={taskTemplateId}/> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| @@ -1,6 +1,6 @@ | |||
| "use server" | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { cache } from "react"; | |||
| @@ -14,8 +14,9 @@ export interface combo { | |||
| records: comboProp[]; | |||
| } | |||
| export interface CreateDepartmentInputs { | |||
| departmentCode: string; | |||
| departmentName: string; | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description: string; | |||
| } | |||
| @@ -25,7 +26,19 @@ export const saveDepartment = async (data: CreateDepartmentInputs) => { | |||
| body: JSON.stringify(data), | |||
| 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 () => { | |||
| @@ -2,6 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { cache } from "react"; | |||
| import "server-only"; | |||
| import { CreateDepartmentInputs } from "./actions"; | |||
| export interface DepartmentResult { | |||
| id: number; | |||
| @@ -18,4 +19,13 @@ export const fetchDepartments = cache(async () => { | |||
| return serverFetchJson<DepartmentResult[]>(`${BASE_API_URL}/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" | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { serverFetchJson, serverFetchString } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { cache } from "react"; | |||
| @@ -64,4 +64,32 @@ export const fetchInvoiceInfoById = cache(async (id: number) => { | |||
| return serverFetchJson<InvoiceInformation[]>(`${BASE_API_URL}/invoices/getInvoiceInfo/${id}`, { | |||
| 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; | |||
| } | |||
| 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{ | |||
| id: number; | |||
| address: string; | |||
| @@ -32,4 +133,16 @@ export const fetchInvoices = cache(async () => { | |||
| return serverFetchJson<InvoiceResult[]>(`${BASE_API_URL}/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" | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { cache } from "react"; | |||
| import { PositionResult } from "."; | |||
| @@ -17,13 +17,15 @@ export interface combo { | |||
| export interface CreatePositionInputs { | |||
| positionCode: string; | |||
| positionName: string; | |||
| code: string; | |||
| name: string; | |||
| description: string; | |||
| } | |||
| export interface EditPositionInputs { | |||
| id: number; | |||
| positionCode: string; | |||
| positionName: string; | |||
| code: string; | |||
| name: 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 () => { | |||
| return serverFetchJson<combo>(`${BASE_API_URL}/positions/combo`, { | |||
| @@ -1,17 +1,25 @@ | |||
| "use server"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { | |||
| serverFetchJson, | |||
| serverFetchWithNoContent, | |||
| } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { Task, TaskGroup } from "../tasks"; | |||
| import { Customer } from "../customer"; | |||
| import { revalidatePath, revalidateTag } from "next/cache"; | |||
| export interface CreateProjectInputs { | |||
| // Project details | |||
| // Project | |||
| projectId: number | null; | |||
| projectDeleted: boolean | null; | |||
| projectCode: string; | |||
| projectName: string; | |||
| projectCategoryId: number; | |||
| projectDescription: string; | |||
| projectLeadId: number; | |||
| projectActualStart: string; | |||
| projectActualEnd: string; | |||
| // Project info | |||
| serviceTypeId: number; | |||
| @@ -61,10 +69,38 @@ export interface PaymentInputs { | |||
| amount: number; | |||
| } | |||
| export interface CreateProjectResponse { | |||
| id: number; | |||
| name: string; | |||
| code: string; | |||
| category: string; | |||
| team: string; | |||
| client: string; | |||
| } | |||
| 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 "server-only"; | |||
| import { Task, TaskGroup } from "../tasks"; | |||
| import { CreateProjectInputs } from "./actions"; | |||
| export interface ProjectResult { | |||
| id: number; | |||
| @@ -48,17 +49,20 @@ export interface WorkNature { | |||
| name: string; | |||
| } | |||
| export interface AssignedProject { | |||
| export interface ProjectWithTasks { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| tasks: Task[]; | |||
| milestones: { | |||
| [taskGroupId: TaskGroup["id"]]: { | |||
| startDate: string; | |||
| endDate: string; | |||
| startDate?: string; | |||
| endDate?: string; | |||
| }; | |||
| }; | |||
| } | |||
| export interface AssignedProject extends ProjectWithTasks { | |||
| // Manhour info | |||
| hoursSpent: 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[]>( | |||
| `${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"; | |||
| import { serverFetchBlob, serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { EX02ProjectCashFlowReportRequest } from "."; | |||
| import { ProjectCashFlowReportRequest } from "."; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| export interface FileResponse { | |||
| @@ -9,9 +9,9 @@ export interface FileResponse { | |||
| blobValue: Uint8Array; | |||
| } | |||
| export const fetchEX02ProjectCashFlowReport = async (data: EX02ProjectCashFlowReportRequest) => { | |||
| export const fetchProjectCashFlowReport = async (data: ProjectCashFlowReportRequest) => { | |||
| const reportBlob = await serverFetchBlob<FileResponse>( | |||
| `${BASE_API_URL}/reports/EX02-ProjectCashFlowReport`, | |||
| `${BASE_API_URL}/reports/ProjectCashFlowReport`, | |||
| { | |||
| method: "POST", | |||
| 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[]; | |||
| } | |||
| export interface EX02ProjectCashFlowReportRequest { | |||
| export interface ProjectCashFlowReportRequest { | |||
| projectId: number; | |||
| } | |||
| @@ -1,8 +1,9 @@ | |||
| "use server" | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { serverFetchBlob, serverFetchJson, serverFetchString } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { cache } from "react"; | |||
| import { FileResponse } from "../reports/actions"; | |||
| export interface comboProp { | |||
| id: any; | |||
| @@ -17,4 +18,31 @@ export const fetchSalaryCombo = cache(async () => { | |||
| return serverFetchJson<combo>(`${BASE_API_URL}/salarys/combo`, { | |||
| 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 { TaskTemplate } from "."; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { ManhourAllocation } from "@/app/api/projects/actions"; | |||
| import { Task, TaskGroup } from '@/app/api/tasks'; | |||
| export interface NewTaskTemplateFormInputs { | |||
| // task template | |||
| code: string; | |||
| name: string; | |||
| taskIds: number[]; | |||
| id: number | null; | |||
| // resource allocation template | |||
| manhourPercentageByGrade: ManhourAllocation; | |||
| taskGroups: { | |||
| [taskGroup: TaskGroup["id"]]: { | |||
| taskIds: Task["id"][]; | |||
| percentAllocation: number; | |||
| }; | |||
| }; | |||
| } | |||
| export const saveTaskTemplate = async (data: NewTaskTemplateFormInputs) => { | |||
| @@ -2,6 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { cache } from "react"; | |||
| import "server-only"; | |||
| import { NewTaskTemplateFormInputs } from "./actions"; | |||
| export interface TaskGroup { | |||
| id: number; | |||
| @@ -39,3 +40,15 @@ export const preloadAllTasks = () => { | |||
| export const fetchAllTasks = cache(async () => { | |||
| 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; | |||
| posLabel: string; | |||
| posCode: string; | |||
| teamLead: number; | |||
| } | |||
| @@ -1,15 +1,65 @@ | |||
| "use server"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { ProjectResult } from "../projects"; | |||
| import { Task, TaskGroup } from "../tasks"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| 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 { | |||
| [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"; | |||
| 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) => { | |||
| return serverFetchWithNoContent(`${BASE_API_URL}/user/${id}`, { | |||
| method: "DELETE", | |||
| 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 "server-only"; | |||
| export interface UserResult { | |||
| action: any; | |||
| id: number; | |||
| @@ -19,6 +18,8 @@ export interface UserResult { | |||
| phone1: string; | |||
| phone2: string; | |||
| remarks: string; | |||
| groupId: number; | |||
| auths: any | |||
| } | |||
| // export interface DetailedUser extends UserResult { | |||
| @@ -27,9 +28,10 @@ export interface UserResult { | |||
| // } | |||
| export interface UserDetail { | |||
| authIds: number[]; | |||
| data: UserResult; | |||
| authIds: number[]; | |||
| groupIds: number[]; | |||
| auths: any[] | |||
| } | |||
| export const preloadUser = () => { | |||
| @@ -3,6 +3,16 @@ import { getServerSession } from "next-auth"; | |||
| import { headers } from "next/headers"; | |||
| 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) => { | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| const session = await getServerSession<any, SessionWithTokens>(authOptions); | |||
| @@ -17,7 +27,7 @@ export const serverFetch: typeof fetch = async (input, init) => { | |||
| ? { | |||
| Authorization: `Bearer ${accessToken}`, | |||
| 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(); | |||
| default: | |||
| 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) { | |||
| const response = await serverFetch(...args); | |||
| @@ -30,6 +30,12 @@ export const convertDateArrayToString = (dateArray: number[], format: string = O | |||
| 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", { | |||
| @@ -5,18 +5,23 @@ import Profile from "./Profile"; | |||
| import Box from "@mui/material/Box"; | |||
| import NavigationToggle from "./NavigationToggle"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| import { authOptions } from "@/config/authConfig"; | |||
| import { getServerSession } from "next-auth"; | |||
| export interface AppBarProps { | |||
| avatarImageSrc?: 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 ( | |||
| <I18nProvider namespaces={["common"]}> | |||
| <MUIAppBar position="sticky" color="default" elevation={4}> | |||
| <Toolbar> | |||
| <NavigationToggle /> | |||
| <NavigationToggle abilities={abilities}/> | |||
| <Box | |||
| sx={{ flexGrow: 1, display: "flex", justifyContent: "flex-end" }} | |||
| > | |||
| @@ -4,8 +4,18 @@ import MenuIcon from "@mui/icons-material/Menu"; | |||
| import NavigationContent from "../NavigationContent"; | |||
| import React from "react"; | |||
| 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 openNavigation = () => { | |||
| @@ -18,7 +28,7 @@ const NavigationToggle: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Drawer variant="permanent" sx={{ display: { xs: "none", xl: "block" } }}> | |||
| <NavigationContent /> | |||
| <NavigationContent abilities={abilities}/> | |||
| </Drawer> | |||
| <Drawer | |||
| sx={{ display: { xl: "none" } }} | |||
| @@ -28,7 +38,7 @@ const NavigationToggle: React.FC = () => { | |||
| keepMounted: true, | |||
| }} | |||
| > | |||
| <NavigationContent /> | |||
| <NavigationContent abilities={abilities}/> | |||
| </Drawer> | |||
| <IconButton | |||
| sx={{ display: { xl: "none" } }} | |||
| @@ -10,6 +10,7 @@ import Divider from "@mui/material/Divider"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { signOut } from "next-auth/react"; | |||
| import { useRouter } from "next/navigation"; | |||
| type Props = Pick<AppBarProps, "avatarImageSrc" | "profileName">; | |||
| @@ -26,6 +27,7 @@ const Profile: React.FC<Props> = ({ avatarImageSrc, profileName }) => { | |||
| }; | |||
| const { t } = useTranslation("login"); | |||
| const router = useRouter(); | |||
| return ( | |||
| <> | |||
| @@ -52,6 +54,7 @@ const Profile: React.FC<Props> = ({ avatarImageSrc, profileName }) => { | |||
| {profileName} | |||
| </Typography> | |||
| <Divider /> | |||
| <MenuItem onClick={() => {router.replace("/settings/changepassword")}}>{t("Change Password")}</MenuItem> | |||
| <MenuItem onClick={() => signOut()}>{t("Sign out")}</MenuItem> | |||
| </Menu> | |||
| </> | |||
| @@ -12,6 +12,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||
| "/home": "User Workspace", | |||
| "/projects": "Projects", | |||
| "/projects/create": "Create Project", | |||
| "/projects/edit": "Edit Project", | |||
| "/tasks": "Task Template", | |||
| "/tasks/create": "Create Task Template", | |||
| "/staffReimbursement": "Staff Reimbursement", | |||
| @@ -28,7 +29,8 @@ const pathToLabelMap: { [path: string]: string } = { | |||
| "/settings/position": "Position", | |||
| "/settings/position/new": "Create Position", | |||
| "/settings/salarys": "Salary", | |||
| "/analytics/EX02ProjectCashFlowReport": "EX02 - Project Cash Flow Report", | |||
| "/analytics/ProjectCashFlowReport": "Project Cash Flow Report", | |||
| "/settings/holiday": "Holiday", | |||
| }; | |||
| 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[] | |||
| } | |||
| const ClaimDetail: React.FC<Props> = ({ projectCombo }) => { | |||
| const ClaimSave: React.FC<Props> = ({ projectCombo }) => { | |||
| const { t } = useTranslation("common"); | |||
| const [serverError, setServerError] = useState(""); | |||
| const router = useRouter(); | |||
| @@ -74,15 +74,15 @@ const ClaimDetail: React.FC<Props> = ({ projectCombo }) => { | |||
| const buttonName = (event?.nativeEvent as any).submitter.name | |||
| const formData = new FormData() | |||
| 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++) { | |||
| // 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 ClaimDetail from "./ClaimDetail"; | |||
| import ClaimSave from "./ClaimSave"; | |||
| import { fetchProjectCombo } from "@/app/api/claims"; | |||
| // import TaskSetup from "./TaskSetup"; | |||
| // import StaffAllocation from "./StaffAllocation"; | |||
| @@ -13,7 +13,7 @@ const ClaimDetailWrapper: React.FC = async () => { | |||
| ]); | |||
| 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 { Typography } from "@mui/material"; | |||
| 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 { t } = useTranslation(); | |||
| const router = useRouter(); | |||
| console.log(department) | |||
| const handleCancel = () => { | |||
| router.back(); | |||
| }; | |||
| @@ -62,9 +67,10 @@ const CreateDepartment: React.FC = ({ | |||
| const formProps = useForm<CreateDepartmentInputs>({ | |||
| 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 CreateDepartment from "./CreateDepartment"; | |||
| 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 ( | |||
| <CreateDepartment | |||
| <CreateDepartment isEdit department={departmentInfo} | |||
| /> | |||
| ); | |||
| }; | |||
| @@ -39,20 +39,20 @@ const DepartmentDetails: React.FC = ({ | |||
| <TextField | |||
| label={t("Department Code")} | |||
| fullWidth | |||
| {...register("departmentCode", { | |||
| {...register("code", { | |||
| required: "Department code required!", | |||
| })} | |||
| error={Boolean(errors.departmentCode)} | |||
| error={Boolean(errors.code)} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Department Name")} | |||
| fullWidth | |||
| {...register("departmentName", { | |||
| {...register("name", { | |||
| required: "Department name required!", | |||
| })} | |||
| error={Boolean(errors.departmentName)} | |||
| error={Boolean(errors.name)} | |||
| /> | |||
| </Grid> | |||
| <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 | |||
| label={t("Position Code")} | |||
| fullWidth | |||
| {...register("positionCode", { | |||
| {...register("code", { | |||
| required: "Position code required!", | |||
| })} | |||
| error={Boolean(errors.positionCode)} | |||
| error={Boolean(errors.code)} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Position Name")} | |||
| fullWidth | |||
| {...register("positionName", { | |||
| {...register("name", { | |||
| required: "Position name required!", | |||
| })} | |||
| error={Boolean(errors.positionName)} | |||
| error={Boolean(errors.name)} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| @@ -1,5 +1,6 @@ | |||
| "use client"; | |||
| import DoneIcon from "@mui/icons-material/Done"; | |||
| import Check from "@mui/icons-material/Check"; | |||
| import Close from "@mui/icons-material/Close"; | |||
| import Button from "@mui/material/Button"; | |||
| @@ -21,8 +22,12 @@ import { | |||
| SubmitHandler, | |||
| useForm, | |||
| } 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 { | |||
| BuildingType, | |||
| ContractType, | |||
| @@ -36,8 +41,18 @@ import { StaffResult } from "@/app/api/staff"; | |||
| import { Typography } from "@mui/material"; | |||
| import { Grade } from "@/app/api/grades"; | |||
| 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 { | |||
| isEditMode: boolean; | |||
| defaultInputs?: CreateProjectInputs; | |||
| allTasks: Task[]; | |||
| projectCategories: ProjectCategory[]; | |||
| taskTemplates: TaskTemplate[]; | |||
| @@ -63,12 +78,22 @@ const hasErrorsInTab = ( | |||
| return ( | |||
| errors.projectName || errors.projectCode || errors.projectDescription | |||
| ); | |||
| case 2: | |||
| return ( | |||
| errors.totalManhour || errors.manhourPercentageByGrade || errors.taskGroups | |||
| ); | |||
| case 3: | |||
| return ( | |||
| errors.milestones | |||
| ) | |||
| default: | |||
| false; | |||
| } | |||
| }; | |||
| const CreateProject: React.FC<Props> = ({ | |||
| isEditMode, | |||
| defaultInputs, | |||
| allTasks, | |||
| projectCategories, | |||
| taskTemplates, | |||
| @@ -90,7 +115,19 @@ const CreateProject: React.FC<Props> = ({ | |||
| const router = useRouter(); | |||
| 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"]>>( | |||
| @@ -101,11 +138,102 @@ const CreateProject: React.FC<Props> = ({ | |||
| ); | |||
| const onSubmit = useCallback<SubmitHandler<CreateProjectInputs>>( | |||
| async (data) => { | |||
| async (data, event) => { | |||
| 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(""); | |||
| 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) { | |||
| setServerError(t("An error has occurred. Please try again later.")); | |||
| } | |||
| @@ -115,6 +243,7 @@ const CreateProject: React.FC<Props> = ({ | |||
| const onSubmitError = useCallback<SubmitErrorHandler<CreateProjectInputs>>( | |||
| (errors) => { | |||
| console.log(errors) | |||
| // Set the tab so that the focus will go there | |||
| if ( | |||
| errors.projectName || | |||
| @@ -122,6 +251,10 @@ const CreateProject: React.FC<Props> = ({ | |||
| errors.projectCode | |||
| ) { | |||
| 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: [], | |||
| milestones: {}, | |||
| 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; | |||
| 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> | |||
| </FormProvider> | |||
| </FormProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| @@ -4,6 +4,7 @@ import { | |||
| fetchProjectBuildingTypes, | |||
| fetchProjectCategories, | |||
| fetchProjectContractTypes, | |||
| fetchProjectDetails, | |||
| fetchProjectFundingTypes, | |||
| fetchProjectLocationTypes, | |||
| fetchProjectServiceTypes, | |||
| @@ -13,7 +14,15 @@ import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; | |||
| import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | |||
| 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 [ | |||
| tasks, | |||
| taskTemplates, | |||
| @@ -46,8 +55,14 @@ const CreateProjectWrapper: React.FC = async () => { | |||
| fetchGrades(), | |||
| ]); | |||
| const projectInfo = props.isEditMode | |||
| ? await fetchProjectDetails(props.projectId!) | |||
| : undefined; | |||
| return ( | |||
| <CreateProject | |||
| isEditMode={props.isEditMode} | |||
| defaultInputs={projectInfo} | |||
| allTasks={tasks} | |||
| projectCategories={projectCategories} | |||
| taskTemplates={taskTemplates} | |||
| @@ -4,7 +4,7 @@ import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import { useTranslation } from "react-i18next"; | |||
| 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 RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import { | |||
| @@ -29,7 +29,7 @@ export interface Props { | |||
| const Milestone: React.FC<Props> = ({ allTasks, isActive }) => { | |||
| const { t } = useTranslation(); | |||
| const { watch } = useFormContext<CreateProjectInputs>(); | |||
| const { watch, setError, clearErrors } = useFormContext<CreateProjectInputs>(); | |||
| const currentTaskGroups = watch("taskGroups"); | |||
| 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 ( | |||
| <> | |||
| <Card sx={{ display: isActive ? "block" : "none" }}> | |||
| @@ -26,7 +26,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import dayjs from "dayjs"; | |||
| import "dayjs/locale/zh-hk"; | |||
| 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 StyledDataGrid from "../StyledDataGrid"; | |||
| import { INPUT_DATE_FORMAT, moneyFormatter } from "@/app/utils/formatUtil"; | |||
| @@ -57,13 +57,15 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
| const apiRef = useGridApiRef(); | |||
| 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 }]); | |||
| setRowModesModel((model) => ({ | |||
| ...model, | |||
| [id]: { mode: GridRowModes.Edit, fieldToFocus: "description" }, | |||
| })); | |||
| }, []); | |||
| }, [payments]); | |||
| const validateRow = useCallback( | |||
| (id: GridRowId) => { | |||
| @@ -239,21 +241,26 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
| <Grid item xs> | |||
| <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> | |||
| </Grid> | |||
| <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> | |||
| </Grid> | |||
| @@ -23,6 +23,7 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||
| {taskGroups.map((group, index) => { | |||
| const payments = milestones[group.id]?.payments || []; | |||
| const paymentTotal = payments.reduce((acc, p) => acc + p.amount, 0); | |||
| projectTotal += paymentTotal; | |||
| return ( | |||
| @@ -41,9 +42,9 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||
| <Typography variant="h6">{t("Project Total Fee")}</Typography> | |||
| <Typography>{moneyFormatter.format(projectTotal)}</Typography> | |||
| </Stack> | |||
| {projectTotal > expectedTotalFee && ( | |||
| {projectTotal !== expectedTotalFee && ( | |||
| <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> | |||
| )} | |||
| </Stack> | |||
| @@ -45,24 +45,45 @@ const leftRightBorderCellSx: SxProps = { | |||
| 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 { t } = useTranslation(); | |||
| const { watch, register, setValue } = useFormContext<CreateProjectInputs>(); | |||
| const { watch, register, setValue, formState: { errors }, setError, clearErrors } = useFormContext<CreateProjectInputs>(); | |||
| const manhourPercentageByGrade = watch("manhourPercentageByGrade"); | |||
| const totalManhour = watch("totalManhour"); | |||
| const totalPercentage = Object.values(manhourPercentageByGrade).reduce( | |||
| 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) { | |||
| setValue("manhourPercentageByGrade", { | |||
| 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], | |||
| @@ -79,7 +100,10 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| type="number" | |||
| {...register("totalManhour", { | |||
| valueAsNumber: true, | |||
| required: "totalManhour code required!", | |||
| min: 1, | |||
| })} | |||
| error={Boolean(errors.totalManhour)} | |||
| /> | |||
| <Box | |||
| sx={(theme) => ({ | |||
| @@ -110,15 +134,18 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| <TableCellEdit | |||
| key={`${column.id}${idx}`} | |||
| value={manhourPercentageByGrade[column.id]} | |||
| renderValue={(val) => percentFormatter.format(val)} | |||
| renderValue={(val) => val + "%"} | |||
| // renderValue={(val) => percentFormatter.format(val)} | |||
| onChange={makeUpdatePercentage(column.id)} | |||
| convertValue={(inputValue) => Number(inputValue)} | |||
| cellSx={{ backgroundColor: "primary.lightest" }} | |||
| 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> | |||
| </TableRow> | |||
| <TableRow> | |||
| @@ -126,7 +153,7 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| {grades.map((column, idx) => ( | |||
| <TableCell key={`${column.id}${idx}`}> | |||
| {manhourFormatter.format( | |||
| manhourPercentageByGrade[column.id] * totalManhour, | |||
| manhourPercentageByGrade[column.id] / 100 * totalManhour, | |||
| )} | |||
| </TableCell> | |||
| ))} | |||
| @@ -144,7 +171,7 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| const { t } = useTranslation(); | |||
| const { watch, setValue } = useFormContext<CreateProjectInputs>(); | |||
| const { watch, setValue, clearErrors, setError } = useFormContext<CreateProjectInputs>(); | |||
| const currentTaskGroups = watch("taskGroups"); | |||
| const taskGroups = useMemo( | |||
| @@ -167,13 +194,22 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| const makeUpdatePercentage = useCallback( | |||
| (taskGroupId: TaskGroup["id"]) => (percentage?: number) => { | |||
| if (percentage !== undefined) { | |||
| setValue("taskGroups", { | |||
| const updatedTaskGroups = { | |||
| ...currentTaskGroups, | |||
| [taskGroupId]: { | |||
| ...currentTaskGroups[taskGroupId], | |||
| 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], | |||
| @@ -216,24 +252,28 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| </TableCell> | |||
| <TableCellEdit | |||
| value={currentTaskGroups[tg.id].percentAllocation} | |||
| renderValue={(val) => percentFormatter.format(val)} | |||
| // renderValue={(val) => percentFormatter.format(val)} | |||
| renderValue={(val) => val + "%"} | |||
| onChange={makeUpdatePercentage(tg.id)} | |||
| convertValue={(inputValue) => Number(inputValue)} | |||
| cellSx={{ backgroundColor: "primary.lightest" }} | |||
| cellSx={{ | |||
| backgroundColor: "primary.lightest", | |||
| }} | |||
| inputSx={{ width: "3rem" }} | |||
| error={currentTaskGroups[tg.id].percentAllocation < 0} | |||
| /> | |||
| <TableCell sx={rightBorderCellSx}> | |||
| {manhourFormatter.format( | |||
| currentTaskGroups[tg.id].percentAllocation * totalManhour, | |||
| currentTaskGroups[tg.id].percentAllocation / 100 * totalManhour, | |||
| )} | |||
| </TableCell> | |||
| {grades.map((column, idx) => { | |||
| const stageHours = | |||
| currentTaskGroups[tg.id].percentAllocation * totalManhour; | |||
| currentTaskGroups[tg.id].percentAllocation / 100 * totalManhour; | |||
| return ( | |||
| <TableCell key={`${column.id}${idx}`}> | |||
| {manhourFormatter.format( | |||
| manhourPercentageByGrade[column.id] * stageHours, | |||
| manhourPercentageByGrade[column.id] / 100 * stageHours, | |||
| )} | |||
| </TableCell> | |||
| ); | |||
| @@ -248,10 +288,14 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| 0, | |||
| )} | |||
| </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( | |||
| Object.values(currentTaskGroups).reduce( | |||
| (acc, tg) => acc + tg.percentAllocation, | |||
| (acc, tg) => acc + tg.percentAllocation / 100, | |||
| 0, | |||
| ), | |||
| )} | |||
| @@ -259,7 +303,7 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| <TableCell sx={rightBorderCellSx}> | |||
| {manhourFormatter.format( | |||
| Object.values(currentTaskGroups).reduce( | |||
| (acc, tg) => acc + tg.percentAllocation * totalManhour, | |||
| (acc, tg) => acc + tg.percentAllocation / 100 * totalManhour, | |||
| 0, | |||
| ), | |||
| )} | |||
| @@ -268,9 +312,9 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| const hours = Object.values(currentTaskGroups).reduce( | |||
| (acc, tg) => | |||
| acc + | |||
| tg.percentAllocation * | |||
| totalManhour * | |||
| manhourPercentageByGrade[column.id], | |||
| tg.percentAllocation / 100 * | |||
| totalManhour * | |||
| manhourPercentageByGrade[column.id] / 100 , | |||
| 0, | |||
| ); | |||
| return ( | |||
| @@ -52,7 +52,7 @@ const TaskSetup: React.FC<Props> = ({ | |||
| (e: SelectChangeEvent<number | "All">) => { | |||
| if (e.target.value === "All" || isNumber(e.target.value)) { | |||
| setSelectedTaskTemplateId(e.target.value); | |||
| onReset(); | |||
| // onReset(); | |||
| } | |||
| }, | |||
| [onReset], | |||
| @@ -22,7 +22,6 @@ import { fetchSkillCombo } from "@/app/api/skill/actions"; | |||
| import { fetchSalaryCombo } from "@/app/api/salarys/actions"; | |||
| interface Field { | |||
| // subtitle: string; | |||
| id: string; | |||
| label: string; | |||
| type: string; | |||
| @@ -33,12 +32,6 @@ interface Field { | |||
| options?: any[]; | |||
| readOnly?: boolean; | |||
| } | |||
| interface formProps { | |||
| Title?: string[]; | |||
| // fieldLists: Field[][]; | |||
| } | |||
| export interface comboItem { | |||
| company: comboProp[]; | |||
| team: comboProp[]; | |||
| @@ -49,101 +42,14 @@ export interface comboItem { | |||
| 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[][] = [ | |||
| [ | |||
| @@ -163,49 +69,49 @@ const CreateStaff: React.FC<formProps> = ({ Title }) => { | |||
| id: "companyId", | |||
| label: t("Company"), | |||
| type: "combo-Obj", | |||
| options: companyCombo || [], | |||
| options: combos.company || [], | |||
| required: true, | |||
| }, | |||
| { | |||
| id: "teamId", | |||
| label: t("Team"), | |||
| type: "combo-Obj", | |||
| options: teamCombo || [], | |||
| options: combos.team || [], | |||
| required: false, | |||
| }, | |||
| { | |||
| id: "departmentId", | |||
| label: t("Department"), | |||
| type: "combo-Obj", | |||
| options: departmentCombo || [], | |||
| options: combos.department || [], | |||
| required: true, | |||
| }, | |||
| { | |||
| id: "gradeId", | |||
| label: t("Grade"), | |||
| type: "combo-Obj", | |||
| options: gradeCombo || [], | |||
| options: combos.grade || [], | |||
| required: false, | |||
| }, | |||
| { | |||
| id: "skillSetId", | |||
| label: t("Skillset"), | |||
| type: "multiSelect-Obj", | |||
| options: skillCombo || [], | |||
| options: combos.skill || [], | |||
| required: false, | |||
| }, | |||
| { | |||
| id: "currentPositionId", | |||
| label: t("Current Position"), | |||
| type: "combo-Obj", | |||
| options: positionCombo || [], | |||
| options: combos.position || [], | |||
| required: true, | |||
| }, | |||
| { | |||
| id: "salaryId", | |||
| label: t("Salary Point"), | |||
| type: "combo-Obj", | |||
| options: salaryCombo || [], | |||
| options: combos.salary || [], | |||
| required: true, | |||
| }, | |||
| // { | |||
| @@ -279,7 +185,7 @@ const CreateStaff: React.FC<formProps> = ({ Title }) => { | |||
| id: "joinPositionId", | |||
| label: t("Join Position"), | |||
| type: "combo-Obj", | |||
| options: positionCombo || [], | |||
| options: combos.position || [], | |||
| required: true, | |||
| }, | |||
| { | |||
| @@ -1,17 +1,48 @@ | |||
| import React from "react"; | |||
| import CreateStaff from "./CreateStaff"; | |||
| import CreateStaff, { comboItem } from "./CreateStaff"; | |||
| import CreateStaffLoading from "./CreateStaffLoading"; | |||
| import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; | |||
| 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 { | |||
| Loading: typeof CreateStaffLoading; | |||
| } | |||
| 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; | |||
| @@ -10,26 +10,31 @@ import TransferList from "../TransferList"; | |||
| import Button from "@mui/material/Button"; | |||
| import Check from "@mui/icons-material/Check"; | |||
| import Close from "@mui/icons-material/Close"; | |||
| import { useRouter, useSearchParams } from "next/navigation"; | |||
| import { useRouter } from "next/navigation"; | |||
| import React from "react"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import { Task } from "@/app/api/tasks"; | |||
| import { Task, TaskTemplate } from "@/app/api/tasks"; | |||
| import { | |||
| NewTaskTemplateFormInputs, | |||
| fetchTaskTemplate, | |||
| saveTaskTemplate, | |||
| } 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 { Grade } from "@/app/api/grades"; | |||
| import { intersectionWith, isEmpty } from "lodash"; | |||
| import ResourceAllocationWrapper from "./ResourceAllocation"; | |||
| interface Props { | |||
| tasks: Task[]; | |||
| defaultInputs?: NewTaskTemplateFormInputs; | |||
| grades: Grade[] | |||
| } | |||
| const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||
| const CreateTaskTemplate: React.FC<Props> = ({ tasks, defaultInputs, grades }) => { | |||
| const { t } = useTranslation(); | |||
| const searchParams = useSearchParams() | |||
| const router = useRouter(); | |||
| const handleCancel = () => { | |||
| router.back(); | |||
| @@ -47,57 +52,53 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||
| 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( | |||
| async (data) => { | |||
| try { | |||
| console.log(data) | |||
| 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 () => { | |||
| const response = await saveTaskTemplate(data); | |||
| @@ -120,8 +121,9 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||
| 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> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Typography variant="overline">{t("Task List Setup")}</Typography> | |||
| @@ -135,22 +137,22 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||
| <TextField | |||
| label={t("Task Template Code")} | |||
| fullWidth | |||
| {...register("code", { | |||
| {...formProps.register("code", { | |||
| 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 item xs={6}> | |||
| <TextField | |||
| label={t("Task Template Name")} | |||
| fullWidth | |||
| {...register("name", { | |||
| {...formProps.register("name", { | |||
| 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> | |||
| @@ -158,16 +160,54 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||
| allItems={items} | |||
| selectedItems={selectedItems} | |||
| 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")} | |||
| selectedItemsLabel={t("Task List Template")} | |||
| /> | |||
| </CardContent> | |||
| </Card> | |||
| {/* Resource Allocation */} | |||
| <Card> | |||
| <CardContent> | |||
| <ResourceAllocationWrapper | |||
| allTasks={tasks} | |||
| grades={grades} | |||
| /> | |||
| </CardContent> | |||
| </Card> | |||
| { | |||
| serverError && ( | |||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||
| @@ -183,12 +223,13 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => { | |||
| variant="contained" | |||
| startIcon={<Check />} | |||
| type="submit" | |||
| disabled={isSubmitting} | |||
| disabled={formProps.formState.isSubmitting} | |||
| > | |||
| {t("Confirm")} | |||
| </Button> | |||
| </Stack> | |||
| </Stack >} | |||
| </Stack > | |||
| </FormProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| @@ -1,11 +1,20 @@ | |||
| import React from "react"; | |||
| 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; | |||
| @@ -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, | |||
| } = useFormContext<CreateTeamInputs>(); | |||
| const resetCustomer = useCallback(() => { | |||
| const resetTeam = useCallback(() => { | |||
| console.log(defaultValues); | |||
| if (defaultValues !== undefined) { | |||
| resetField("description"); | |||
| @@ -199,20 +199,20 @@ const CustomerSave: React.FC<Props> = ({ | |||
| setServerError(""); | |||
| 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) { | |||
| console.log(e) | |||
| setServerError(t("An error has occurred. Please try again later.")); | |||