@@ -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.")); | |||