diff --git a/package-lock.json b/package-lock.json index ed756cc..e1f4abf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2578a2f..14ae99a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(main)/analytics/EX02ProjectCashFlowReport/page.tsx b/src/app/(main)/analytics/ProjectCashFlowReport/page.tsx similarity index 62% rename from src/app/(main)/analytics/EX02ProjectCashFlowReport/page.tsx rename to src/app/(main)/analytics/ProjectCashFlowReport/page.tsx index d1d54d8..301ef12 100644 --- a/src/app/(main)/analytics/EX02ProjectCashFlowReport/page.tsx +++ b/src/app/(main)/analytics/ProjectCashFlowReport/page.tsx @@ -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 ( <> - }> - + }> + diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index 176c9a2..bd0c08a 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -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 ( - + ); }; diff --git a/src/app/(main)/invoice/new/page.tsx b/src/app/(main)/invoice/new/page.tsx index 10e741f..da4701b 100644 --- a/src/app/(main)/invoice/new/page.tsx +++ b/src/app/(main)/invoice/new/page.tsx @@ -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", diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index cfaa1a9..b93ed10 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -31,10 +31,10 @@ export default async function MainLayout({ padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, }} > - - - {children} - + + + {children} + ); diff --git a/src/app/(main)/projects/create/page.tsx b/src/app/(main)/projects/create/page.tsx index c652a70..1671262 100644 --- a/src/app/(main)/projects/create/page.tsx +++ b/src/app/(main)/projects/create/page.tsx @@ -43,7 +43,7 @@ const Projects: React.FC = async () => { <> {t("Create Project")} - + ); diff --git a/src/app/(main)/projects/edit/not-found.tsx b/src/app/(main)/projects/edit/not-found.tsx new file mode 100644 index 0000000..14e0e6d --- /dev/null +++ b/src/app/(main)/projects/edit/not-found.tsx @@ -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 ( + + {t("Not Found")} + {t("The project was not found!")} + + {t("Return to all projects")} + + + ); +} diff --git a/src/app/(main)/projects/edit/page.tsx b/src/app/(main)/projects/edit/page.tsx new file mode 100644 index 0000000..78e0ed1 --- /dev/null +++ b/src/app/(main)/projects/edit/page.tsx @@ -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 = 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 ( + <> + {t("Edit Project")} + + + + + ); +}; + +export default Projects; diff --git a/src/app/(main)/settings/changepassword/page.tsx b/src/app/(main)/settings/changepassword/page.tsx new file mode 100644 index 0000000..b6b9a41 --- /dev/null +++ b/src/app/(main)/settings/changepassword/page.tsx @@ -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 ( + <> + + + {t("Change Password")} + + + {/* + }> + + + */} + + }> + + + + + ); + }; + + export default ChangePasswordPage; \ No newline at end of file diff --git a/src/app/(main)/settings/department/edit/page.tsx b/src/app/(main)/settings/department/edit/page.tsx new file mode 100644 index 0000000..748003e --- /dev/null +++ b/src/app/(main)/settings/department/edit/page.tsx @@ -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 = async ({searchParams}) => { + const { t } = await getServerI18n("departments"); + + // Preload necessary dependencies + // Assume projectId is string here + const departmentId = searchParams["id"]; + + return ( + <> + {t("Create Department")} + + + + + ); +}; + +export default Department; diff --git a/src/app/(main)/settings/department/new/page.tsx b/src/app/(main)/settings/department/new/page.tsx index 1f94edc..2121dc6 100644 --- a/src/app/(main)/settings/department/new/page.tsx +++ b/src/app/(main)/settings/department/new/page.tsx @@ -16,7 +16,7 @@ const Department: React.FC = async () => { <> {t("Create Department")} - + ); diff --git a/src/app/(main)/settings/group/create/page.tsx b/src/app/(main)/settings/group/create/page.tsx new file mode 100644 index 0000000..9130236 --- /dev/null +++ b/src/app/(main)/settings/group/create/page.tsx @@ -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 ( + <> + {t("Create Group")} + + + + + ); +}; + +export default CreateStaff; diff --git a/src/app/(main)/settings/group/edit/page.tsx b/src/app/(main)/settings/group/edit/page.tsx new file mode 100644 index 0000000..9055358 --- /dev/null +++ b/src/app/(main)/settings/group/edit/page.tsx @@ -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 ( + <> + {/* {t("Edit User Group")} */} + + + + + ); +}; + +export default Group; \ No newline at end of file diff --git a/src/app/(main)/settings/group/page.tsx b/src/app/(main)/settings/group/page.tsx new file mode 100644 index 0000000..5322132 --- /dev/null +++ b/src/app/(main)/settings/group/page.tsx @@ -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 ( + <> + + + {t("User Group")} + + + + + }> + + + + + ); + }; + + export default UserGroup; \ No newline at end of file diff --git a/src/app/(main)/settings/holiday/page.tsx b/src/app/(main)/settings/holiday/page.tsx new file mode 100644 index 0000000..16a5ac2 --- /dev/null +++ b/src/app/(main)/settings/holiday/page.tsx @@ -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 ( + <> + + + {t("Company Holiday")} + + {/* */} + + }> + + + + ) +}; + +export default Company; diff --git a/src/app/(main)/settings/skill/edit/page.tsx b/src/app/(main)/settings/skill/edit/page.tsx new file mode 100644 index 0000000..a2d9863 --- /dev/null +++ b/src/app/(main)/settings/skill/edit/page.tsx @@ -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 ( + <> + {t("Edit Skill")} + + }> + + + + {/* */} + + ); +}; + +export default EditSkillPage; diff --git a/src/app/(main)/settings/staff/create/page.tsx b/src/app/(main)/settings/staff/create/page.tsx index bc40f6a..24f3589 100644 --- a/src/app/(main)/settings/staff/create/page.tsx +++ b/src/app/(main)/settings/staff/create/page.tsx @@ -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 ( <> {t("Create Staff")} - + ); }; -export default CreateStaff; +export default CreateStaffPage; diff --git a/src/app/(main)/settings/staff/user/page.tsx b/src/app/(main)/settings/staff/user/page.tsx new file mode 100644 index 0000000..11ef8f9 --- /dev/null +++ b/src/app/(main)/settings/staff/user/page.tsx @@ -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 ( + <> + {t("Edit User")} + + }> + + + + + ); +}; +export default User; diff --git a/src/app/(main)/settings/team/create/page.tsx b/src/app/(main)/settings/team/create/page.tsx index 721fda7..a47d81c 100644 --- a/src/app/(main)/settings/team/create/page.tsx +++ b/src/app/(main)/settings/team/create/page.tsx @@ -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 ( <> {t("Create Team")} diff --git a/src/app/(main)/settings/user/edit/page.tsx b/src/app/(main)/settings/user/edit/page.tsx new file mode 100644 index 0000000..659347b --- /dev/null +++ b/src/app/(main)/settings/user/edit/page.tsx @@ -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 ( + <> + + }> + + + + + ); +}; + +export default EditUserPage; diff --git a/src/app/(main)/settings/user/page.tsx b/src/app/(main)/settings/user/page.tsx index 95973ab..ef7635f 100644 --- a/src/app/(main)/settings/user/page.tsx +++ b/src/app/(main)/settings/user/page.tsx @@ -33,14 +33,14 @@ export const metadata: Metadata = { {t("User")} - + */} }> diff --git a/src/app/(main)/staffReimbursement/create/page.tsx b/src/app/(main)/staffReimbursement/create/page.tsx index f1effc4..7633135 100644 --- a/src/app/(main)/staffReimbursement/create/page.tsx +++ b/src/app/(main)/staffReimbursement/create/page.tsx @@ -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 () => { <> {t("Create Claim")} - + ); diff --git a/src/app/(main)/tasks/edit/not-found.tsx b/src/app/(main)/tasks/edit/not-found.tsx new file mode 100644 index 0000000..b99e85e --- /dev/null +++ b/src/app/(main)/tasks/edit/not-found.tsx @@ -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 ( + + {t("Not Found")} + {t("The task template was not found!")} + + {t("Return to all task templates")} + + + ); +} diff --git a/src/app/(main)/tasks/edit/page.tsx b/src/app/(main)/tasks/edit/page.tsx index 2b2c02c..8f20792 100644 --- a/src/app/(main)/tasks/edit/page.tsx +++ b/src/app/(main)/tasks/edit/page.tsx @@ -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 = 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 ( <> {t("Edit Task Template")} - + ); diff --git a/src/app/api/departments/actions.ts b/src/app/api/departments/actions.ts index c6bdfd2..48a3174 100644 --- a/src/app/api/departments/actions.ts +++ b/src/app/api/departments/actions.ts @@ -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 () => { diff --git a/src/app/api/departments/index.ts b/src/app/api/departments/index.ts index 9bb4354..580b302 100644 --- a/src/app/api/departments/index.ts +++ b/src/app/api/departments/index.ts @@ -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(`${BASE_API_URL}/departments`, { next: { tags: ["departments"] }, }); +}); + +export const fetchDepartmentDetails = cache(async (departmentId: string) => { + return serverFetchJson( + `${BASE_API_URL}/departments/departmentDetails/${departmentId}`, + { + next: { tags: [`departmentDetail${departmentId}`] }, + }, + ); }); \ No newline at end of file diff --git a/src/app/api/group/actions.ts b/src/app/api/group/actions.ts new file mode 100644 index 0000000..6800b29 --- /dev/null +++ b/src/app/api/group/actions.ts @@ -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(`${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" }, + }); +}; \ No newline at end of file diff --git a/src/app/api/group/index.ts b/src/app/api/group/index.ts new file mode 100644 index 0000000..9dcee9e --- /dev/null +++ b/src/app/api/group/index.ts @@ -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(`${BASE_API_URL}/group`, { + next: { tags: ["group"] }, + }); + }); diff --git a/src/app/api/holidays/actions.ts b/src/app/api/holidays/actions.ts new file mode 100644 index 0000000..8874418 --- /dev/null +++ b/src/app/api/holidays/actions.ts @@ -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 +}; + diff --git a/src/app/api/holidays/index.ts b/src/app/api/holidays/index.ts new file mode 100644 index 0000000..1bf21aa --- /dev/null +++ b/src/app/api/holidays/index.ts @@ -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(`${BASE_API_URL}/company-holidays`, { + next: { tags: ["company-holidays"] }, + }); +}); \ No newline at end of file diff --git a/src/app/api/invoices/actions.ts b/src/app/api/invoices/actions.ts index 7194c2c..dc45104 100644 --- a/src/app/api/invoices/actions.ts +++ b/src/app/api/invoices/actions.ts @@ -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(`${BASE_API_URL}/invoices/getInvoiceInfo/${id}`, { next: { tags: ["invoiceInfoById"] }, }); -}) \ No newline at end of file +}) + +export const importIssuedInovice = async (data: FormData) => { + // console.log("----------------",data) + const importIssuedInovice = await serverFetchJson( + `${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( + `${BASE_API_URL}/invoices/import/received`, + { + method: "POST", + body: data, + // headers: { "Content-Type": "multipart/form-data" }, + }, + ); + + return importReceivedInovice; +}; \ No newline at end of file diff --git a/src/app/api/invoices/index.ts b/src/app/api/invoices/index.ts index d20e0cf..8d68557 100644 --- a/src/app/api/invoices/index.ts +++ b/src/app/api/invoices/index.ts @@ -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(`${BASE_API_URL}/invoices`, { next: { tags: ["invoices"] }, }); +}); + +export const fetchIssuedInvoices = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/invoices/v2/allInvoices`, { + next: { tags: ["invoices"] }, + }); +}); + +export const fetchReceivedInvoices = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/invoices/v2/allInvoices/paid`, { + next: { tags: ["invoices"] }, + }); }); \ No newline at end of file diff --git a/src/app/api/positions/actions.ts b/src/app/api/positions/actions.ts index 2f69990..3caf1ab 100644 --- a/src/app/api/positions/actions.ts +++ b/src/app/api/positions/actions.ts @@ -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(`${BASE_API_URL}/positions/combo`, { diff --git a/src/app/api/projects/actions.ts b/src/app/api/projects/actions.ts index 232f863..c80dff0 100644 --- a/src/app/api/projects/actions.ts +++ b/src/app/api/projects/actions.ts @@ -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( + `${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; }; diff --git a/src/app/api/projects/index.ts b/src/app/api/projects/index.ts index 7bfb067..30bd385 100644 --- a/src/app/api/projects/index.ts +++ b/src/app/api/projects/index.ts @@ -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( `${BASE_API_URL}/projects/assignedProjects`, { - next: { tags: ["assignedProjects"] }, + next: { tags: [`assignedProjects__${username}`] }, + }, + ); +}); + +export const fetchProjectWithTasks = cache(async () => { + return serverFetchJson( + `${BASE_API_URL}/projects/allProjectWithTasks`, + { + next: { tags: ["allProjectWithTasks"] }, + }, + ); +}); + +export const fetchProjectDetails = cache(async (projectId: string) => { + return serverFetchJson( + `${BASE_API_URL}/projects/projectDetails/${projectId}`, + { + next: { tags: [`projectDetails_${projectId}`] }, }, ); }); diff --git a/src/app/api/reports/actions.ts b/src/app/api/reports/actions.ts index be74c6f..8f38af3 100644 --- a/src/app/api/reports/actions.ts +++ b/src/app/api/reports/actions.ts @@ -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( - `${BASE_API_URL}/reports/EX02-ProjectCashFlowReport`, + `${BASE_API_URL}/reports/ProjectCashFlowReport`, { method: "POST", body: JSON.stringify(data), diff --git a/src/app/api/reports/index.ts b/src/app/api/reports/index.ts index 6baa7aa..9ab8f36 100644 --- a/src/app/api/reports/index.ts +++ b/src/app/api/reports/index.ts @@ -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; } \ No newline at end of file diff --git a/src/app/api/salarys/actions.ts b/src/app/api/salarys/actions.ts index 7cd01d3..0bb1482 100644 --- a/src/app/api/salarys/actions.ts +++ b/src/app/api/salarys/actions.ts @@ -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(`${BASE_API_URL}/salarys/combo`, { next: { tags: ["salary"] }, }); - }); \ No newline at end of file + }); + +export const importSalarys = async (data: FormData) => { + console.log("----------------",data) + const importSalarys = await serverFetchString( + `${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( + `${BASE_API_URL}/salarys/export`, + { + method: "POST", + // body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); + + return reportBlob +}; \ No newline at end of file diff --git a/src/app/api/tasks/actions.ts b/src/app/api/tasks/actions.ts index 2c043be..3414bf6 100644 --- a/src/app/api/tasks/actions.ts +++ b/src/app/api/tasks/actions.ts @@ -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) => { diff --git a/src/app/api/tasks/index.ts b/src/app/api/tasks/index.ts index f701cca..855a900 100644 --- a/src/app/api/tasks/index.ts +++ b/src/app/api/tasks/index.ts @@ -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(`${BASE_API_URL}/tasks`); }); + +export const fetchTaskTemplateDetail = cache(async (id: string) => { + const taskTemplate = await serverFetchJson( + `${BASE_API_URL}/tasks/templatesDetails/${id}`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + }, + ); + + return taskTemplate; +}); diff --git a/src/app/api/team/index.ts b/src/app/api/team/index.ts index 3aa872d..d9d3b27 100644 --- a/src/app/api/team/index.ts +++ b/src/app/api/team/index.ts @@ -15,6 +15,7 @@ export interface TeamResult { staffName: string; posLabel: string; posCode: string; + teamLead: number; } diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts index 6b959ec..cd32d06 100644 --- a/src/app/api/timesheets/actions.ts +++ b/src/app/api/timesheets/actions.ts @@ -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( + `${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( + `${BASE_API_URL}/timesheets/saveLeave`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); + + revalidateTag(`leaves_${username}`); + + return savedRecords; +}; diff --git a/src/app/api/timesheets/index.ts b/src/app/api/timesheets/index.ts new file mode 100644 index 0000000..d9b1862 --- /dev/null +++ b/src/app/api/timesheets/index.ts @@ -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(`${BASE_API_URL}/timesheets`, { + next: { tags: [`timesheets_${username}`] }, + }); +}); + +export const fetchLeaves = cache(async (username: string) => { + return serverFetchJson( + `${BASE_API_URL}/timesheets/leaves`, + { + next: { tags: [`leaves_${username}`] }, + }, + ); +}); + +export const fetchLeaveTypes = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/timesheets/leaveTypes`, { + next: { tags: ["leaveTypes"] }, + }); +}); diff --git a/src/app/api/timesheets/utils.ts b/src/app/api/timesheets/utils.ts new file mode 100644 index 0000000..c305e26 --- /dev/null +++ b/src/app/api/timesheets/utils.ts @@ -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): 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): 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; diff --git a/src/app/api/user/actions.ts b/src/app/api/user/actions.ts index 5df734a..77b58b5 100644 --- a/src/app/api/user/actions.ts +++ b/src/app/api/user/actions.ts @@ -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" }, + }); }; \ No newline at end of file diff --git a/src/app/api/user/index.ts b/src/app/api/user/index.ts index 9a6065b..f34292f 100644 --- a/src/app/api/user/index.ts +++ b/src/app/api/user/index.ts @@ -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 = () => { diff --git a/src/app/utils/fetchUtil.ts b/src/app/utils/fetchUtil.ts index 5060991..c1f310b 100644 --- a/src/app/utils/fetchUtil.ts +++ b/src/app/utils/fetchUtil.ts @@ -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(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(...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(...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(...args: FetchParams) { const response = await serverFetch(...args); diff --git a/src/app/utils/formatUtil.ts b/src/app/utils/formatUtil.ts index 918b0ca..899c1cd 100644 --- a/src/app/utils/formatUtil.ts +++ b/src/app/utils/formatUtil.ts @@ -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", { diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index a38d2bd..57c190a 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -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 = ({ avatarImageSrc, profileName }) => { +const AppBar: React.FC = async ({ avatarImageSrc, profileName }) => { + const session = await getServerSession(authOptions) as any; + const abilities: string[] = session.abilities + console.log(abilities) return ( - + diff --git a/src/components/AppBar/NavigationToggle.tsx b/src/components/AppBar/NavigationToggle.tsx index 9f61753..f704267 100644 --- a/src/components/AppBar/NavigationToggle.tsx +++ b/src/components/AppBar/NavigationToggle.tsx @@ -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 = ({ abilities }) => { const [isOpened, setIsOpened] = React.useState(false); const openNavigation = () => { @@ -18,7 +28,7 @@ const NavigationToggle: React.FC = () => { return ( <> - + { keepMounted: true, }} > - + ; @@ -26,6 +27,7 @@ const Profile: React.FC = ({ avatarImageSrc, profileName }) => { }; const { t } = useTranslation("login"); + const router = useRouter(); return ( <> @@ -52,6 +54,7 @@ const Profile: React.FC = ({ avatarImageSrc, profileName }) => { {profileName} + {router.replace("/settings/changepassword")}}>{t("Change Password")} signOut()}>{t("Sign out")} diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 3d6123a..65809dc 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -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 = () => { diff --git a/src/components/ChangePassword/ChangePassword.tsx b/src/components/ChangePassword/ChangePassword.tsx new file mode 100644 index 0000000..33e19ff --- /dev/null +++ b/src/components/ChangePassword/ChangePassword.tsx @@ -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(); + const [serverError, setServerError] = useState(""); + const router = useRouter(); + // const [tabIndex, setTabIndex] = useState(0); + const { t } = useTranslation(); + + const onSubmit = useCallback>( + 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>( + (errors) => { + console.log(errors); + }, + [] + ); + + return ( + + + + + + + + + + ); +}; + +export default ChangePassword; diff --git a/src/components/ChangePassword/ChangePasswordForm.tsx b/src/components/ChangePassword/ChangePasswordForm.tsx new file mode 100644 index 0000000..19e2a29 --- /dev/null +++ b/src/components/ChangePassword/ChangePasswordForm.tsx @@ -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(); + + // const resetGroup = useCallback(() => { + // console.log(defaultValues); + // if (defaultValues !== undefined) { + // resetField("description"); + // } + // }, [defaultValues]); + + return ( + + + + + {t("Group Info")} + + + + + + {showPassword ? : } + + + ) + }} + {...register("password", { + required: true, + })} + error={Boolean(errors.password)} + helperText={ + Boolean(errors.password) && + (errors.password?.message + ? t(errors.password.message) + : t("Please input correct password")) + } + /> + + + + + + {showNewPassword ? : } + + + ) + }} + {...register("newPassword")} + error={Boolean(errors.newPassword)} + helperText={ + Boolean(errors.newPassword) && + (errors.newPassword?.message + ? t(errors.newPassword.message) + : t("Please input correct newPassword")) + } + /> + + + + + {showNewPassword ? : } + + + ) + }} + {...register("newPasswordCheck")} + error={Boolean(errors.newPassword)} + helperText={ + Boolean(errors.newPassword) && + (errors.newPassword?.message + ? t(errors.newPassword.message) + : t("Please input correct newPassword")) + } + /> + + + + + + ); +}; +export default ChagnePasswordForm; diff --git a/src/components/ChangePassword/ChangePasswordLoading.tsx b/src/components/ChangePassword/ChangePasswordLoading.tsx new file mode 100644 index 0000000..30f29fd --- /dev/null +++ b/src/components/ChangePassword/ChangePasswordLoading.tsx @@ -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 ( + <> + + + + + + + + + + + Change Password + + + + + + + + + + + ); +}; + +export default ChangePasswordLoading; diff --git a/src/components/ChangePassword/ChangePasswordWrapper.tsx b/src/components/ChangePassword/ChangePasswordWrapper.tsx new file mode 100644 index 0000000..30acb9d --- /dev/null +++ b/src/components/ChangePassword/ChangePasswordWrapper.tsx @@ -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 ; +}; + +ChangePasswordWrapper.Loading = ChangePasswordLoading; + +export default ChangePasswordWrapper; diff --git a/src/components/ChangePassword/index.ts b/src/components/ChangePassword/index.ts new file mode 100644 index 0000000..f7ec5db --- /dev/null +++ b/src/components/ChangePassword/index.ts @@ -0,0 +1 @@ +export { default } from "./ChangePasswordWrapper"; diff --git a/src/components/ClaimDetail/index.ts b/src/components/ClaimDetail/index.ts deleted file mode 100644 index 0fa3ab2..0000000 --- a/src/components/ClaimDetail/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./ClaimDetailWrapper"; diff --git a/src/components/ClaimDetail/ClaimFormInfo.tsx b/src/components/ClaimSave/ClaimFormInfo.tsx similarity index 100% rename from src/components/ClaimDetail/ClaimFormInfo.tsx rename to src/components/ClaimSave/ClaimFormInfo.tsx diff --git a/src/components/ClaimDetail/ClaimFormInputGrid.tsx b/src/components/ClaimSave/ClaimFormInputGrid.tsx similarity index 100% rename from src/components/ClaimDetail/ClaimFormInputGrid.tsx rename to src/components/ClaimSave/ClaimFormInputGrid.tsx diff --git a/src/components/ClaimDetail/ClaimDetail.tsx b/src/components/ClaimSave/ClaimSave.tsx similarity index 93% rename from src/components/ClaimDetail/ClaimDetail.tsx rename to src/components/ClaimSave/ClaimSave.tsx index 54a82a5..336d554 100644 --- a/src/components/ClaimDetail/ClaimDetail.tsx +++ b/src/components/ClaimSave/ClaimSave.tsx @@ -21,7 +21,7 @@ export interface Props { projectCombo: ProjectCombo[] } -const ClaimDetail: React.FC = ({ projectCombo }) => { +const ClaimSave: React.FC = ({ projectCombo }) => { const { t } = useTranslation("common"); const [serverError, setServerError] = useState(""); const router = useRouter(); @@ -74,15 +74,15 @@ const ClaimDetail: React.FC = ({ 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 = ({ projectCombo }) => { ); }; -export default ClaimDetail; +export default ClaimSave; diff --git a/src/components/ClaimDetail/ClaimDetailWrapper.tsx b/src/components/ClaimSave/ClaimSaveWrapper.tsx similarity index 82% rename from src/components/ClaimDetail/ClaimDetailWrapper.tsx rename to src/components/ClaimSave/ClaimSaveWrapper.tsx index 602f9e3..af6234f 100644 --- a/src/components/ClaimDetail/ClaimDetailWrapper.tsx +++ b/src/components/ClaimSave/ClaimSaveWrapper.tsx @@ -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 ( - + ); }; diff --git a/src/components/ClaimSave/index.ts b/src/components/ClaimSave/index.ts new file mode 100644 index 0000000..56eabaf --- /dev/null +++ b/src/components/ClaimSave/index.ts @@ -0,0 +1 @@ +export { default } from "./ClaimSaveWrapper"; diff --git a/src/components/CompanyHoliday/CompanyHoliday.tsx b/src/components/CompanyHoliday/CompanyHoliday.tsx new file mode 100644 index 0000000..cdb5ed3 --- /dev/null +++ b/src/components/CompanyHoliday/CompanyHoliday.tsx @@ -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 = ({ 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([]) + 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>( + 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>( + (errors) => { + console.log(errors) + }, + [], + ); + + + const formProps = useForm({ + defaultValues: { + id: null, + name: "" + }, + }); + + return ( + <> + + + + + {isEdit && } + + + } + editable={editable} + /> + + + ); +}; + +export default CompanyHoliday; diff --git a/src/components/CompanyHoliday/CompanyHolidayDialog.tsx b/src/components/CompanyHoliday/CompanyHolidayDialog.tsx new file mode 100644 index 0000000..1f91a1d --- /dev/null +++ b/src/components/CompanyHoliday/CompanyHolidayDialog.tsx @@ -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 = ({ open, onClose, title, actions, content, editable }) => { + const { + t, + i18n: { language }, + } = useTranslation(); + + const { + register, + formState: { errors }, + setValue, + } = useFormContext(); + + useEffect(() => { + setValue("date", content.date); + }, [content]) + + console.log(editable) + + return ( + + + {title} + + + + + + + + { + if (!date) return; + setValue("date", date.format(INPUT_DATE_FORMAT)); + }} + slotProps={{ + textField: { + helperText: 'MM/DD/YYYY', + }, + }} + /> + + + + + {actions} + + + ); +}; + +export default CompanyHolidayDialog; \ No newline at end of file diff --git a/src/components/CompanyHoliday/CompanyHolidayLoading.tsx b/src/components/CompanyHoliday/CompanyHolidayLoading.tsx new file mode 100644 index 0000000..5b8c02d --- /dev/null +++ b/src/components/CompanyHoliday/CompanyHolidayLoading.tsx @@ -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 ( + <> + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CompanyHolidayLoading; diff --git a/src/components/CompanyHoliday/CompanyHolidayWrapper.tsx b/src/components/CompanyHoliday/CompanyHolidayWrapper.tsx new file mode 100644 index 0000000..23afb2c --- /dev/null +++ b/src/components/CompanyHoliday/CompanyHolidayWrapper.tsx @@ -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 ; +}; + +CompanyHolidayWrapper.Loading = CompanyHolidayLoading; + +export default CompanyHolidayWrapper; diff --git a/src/components/CompanyHoliday/index.ts b/src/components/CompanyHoliday/index.ts new file mode 100644 index 0000000..3dddafc --- /dev/null +++ b/src/components/CompanyHoliday/index.ts @@ -0,0 +1 @@ +export { default } from "./CompanyHolidayWrapper"; diff --git a/src/components/CreateDepartment/CreateDepartment.tsx b/src/components/CreateDepartment/CreateDepartment.tsx index 42088fe..2d95d28 100644 --- a/src/components/CreateDepartment/CreateDepartment.tsx +++ b/src/components/CreateDepartment/CreateDepartment.tsx @@ -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 = ({ + 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({ defaultValues: { - departmentCode: "", - departmentName: "", - description: "", + id: department?.id, + code: department?.code, + name: department?.name, + description: department?.description, }, }); diff --git a/src/components/CreateDepartment/CreateDepartmentWrapper.tsx b/src/components/CreateDepartment/CreateDepartmentWrapper.tsx index cf32044..199ed1c 100644 --- a/src/components/CreateDepartment/CreateDepartmentWrapper.tsx +++ b/src/components/CreateDepartment/CreateDepartmentWrapper.tsx @@ -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 = async (props) => { + + const departmentInfo = props.isEdit + ? await fetchDepartmentDetails(props.departmentId!) + : undefined; return ( - ); }; diff --git a/src/components/CreateDepartment/DepartmentDetails.tsx b/src/components/CreateDepartment/DepartmentDetails.tsx index 4039587..8cd559c 100644 --- a/src/components/CreateDepartment/DepartmentDetails.tsx +++ b/src/components/CreateDepartment/DepartmentDetails.tsx @@ -39,20 +39,20 @@ const DepartmentDetails: React.FC = ({ diff --git a/src/components/CreateGroup/AuthorityAllocation.tsx b/src/components/CreateGroup/AuthorityAllocation.tsx new file mode 100644 index 0000000..bdd4ccb --- /dev/null +++ b/src/components/CreateGroup/AuthorityAllocation.tsx @@ -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 = ({ auth }) => { + const { t } = useTranslation(); + const { + setValue, + getValues, + formState: { defaultValues }, + reset, + resetField, + } = useFormContext(); + const initialAuths = auth.map((a) => ({ ...a })).sort((a, b) => a.id - b.id); + const [filteredAuths, setFilteredAuths] = useState(initialAuths); + const [selectedAuths, setSelectedAuths] = useState( + () => { + 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[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("Auth Name"), name: "name" }, + // { label: t("Current Position"), name: "currentPosition" }, + ], + [addAuth, t] + ); + + const allocatedAuthColumns = useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeAuth, + buttonIcon: , + }, + { 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 + >((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>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + return ( + <> + + + + + + {t("Authority")} + + + + + + + + + + ), + }} + /> + + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + ); +}; + +export default AuthorityAllocation; diff --git a/src/components/CreateGroup/CreateGroup.tsx b/src/components/CreateGroup/CreateGroup.tsx new file mode 100644 index 0000000..e931521 --- /dev/null +++ b/src/components/CreateGroup/CreateGroup.tsx @@ -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 = ({ auth, users }) => { + const formProps = useForm(); + const [serverError, setServerError] = useState(""); + const router = useRouter(); + const [tabIndex, setTabIndex] = useState(0); + const { t } = useTranslation(); + + const errors = formProps.formState.errors; + + const onSubmit = useCallback>( + 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>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + const hasErrorsInTab = ( + tabIndex: number, + errors: FieldErrors, + ) => { + switch (tabIndex) { + case 0: + return Object.keys(errors).length > 0; + default: + false; + } + }; + + return ( + <> + + + + + ) : undefined + } + iconPosition="end" + /> + + + + {serverError && ( + + {serverError} + + )} + {tabIndex === 0 && } + {tabIndex === 1 && } + {tabIndex === 2 && } + + + + + + + + + ); +}; + +export default CreateGroup; diff --git a/src/components/CreateGroup/CreateGroupLoading.tsx b/src/components/CreateGroup/CreateGroupLoading.tsx new file mode 100644 index 0000000..6a48c4e --- /dev/null +++ b/src/components/CreateGroup/CreateGroupLoading.tsx @@ -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 ( + <> + + + + + + + + + + + Create Group + + + + + + + + + + + ); +}; + +export default CreateGroupLoading; diff --git a/src/components/CreateGroup/CreateGroupWrapper.tsx b/src/components/CreateGroup/CreateGroupWrapper.tsx new file mode 100644 index 0000000..e4bd018 --- /dev/null +++ b/src/components/CreateGroup/CreateGroupWrapper.tsx @@ -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 ; +}; + +CreateGroupWrapper.Loading = CreateGroupLoading; + +export default CreateGroupWrapper; diff --git a/src/components/CreateGroup/GroupInfo.tsx b/src/components/CreateGroup/GroupInfo.tsx new file mode 100644 index 0000000..d9141bc --- /dev/null +++ b/src/components/CreateGroup/GroupInfo.tsx @@ -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(); + + + const resetGroup = useCallback(() => { + console.log(defaultValues); + if (defaultValues !== undefined) { + resetField("description"); + } + }, [defaultValues]); + + + return ( + + + + + {t("Group Info")} + + + + + + + + + + + + + ); +}; + +export default GroupInfo; diff --git a/src/components/CreateGroup/UserAllocation.tsx b/src/components/CreateGroup/UserAllocation.tsx new file mode 100644 index 0000000..ff13c52 --- /dev/null +++ b/src/components/CreateGroup/UserAllocation.tsx @@ -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 = ({ users }) => { + const { t } = useTranslation(); + const { + setValue, + getValues, + formState: { defaultValues }, + reset, + resetField, + } = useFormContext(); + 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( + () => { + 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[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addUser, + buttonIcon: , + }, + { label: t("User Name"), name: "username" }, + { label: t("name"), name: "name" }, + ], + [addUser, t] + ); + + const allocatedUserColumns = useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeUser, + buttonIcon: , + }, + { 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 + >((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>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + return ( + <> + + + + + + {t("User")} + + + + + + + + + + ), + }} + /> + + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + ); +}; + +export default UserAllocation; diff --git a/src/components/CreateGroup/index.ts b/src/components/CreateGroup/index.ts new file mode 100644 index 0000000..1034fc8 --- /dev/null +++ b/src/components/CreateGroup/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateGroupWrapper" \ No newline at end of file diff --git a/src/components/CreateInvoice/CreateInvoice.tsx b/src/components/CreateInvoice_forGen/CreateInvoice.tsx similarity index 100% rename from src/components/CreateInvoice/CreateInvoice.tsx rename to src/components/CreateInvoice_forGen/CreateInvoice.tsx diff --git a/src/components/CreateInvoice/CreateInvoiceWrapper.tsx b/src/components/CreateInvoice_forGen/CreateInvoiceWrapper.tsx similarity index 100% rename from src/components/CreateInvoice/CreateInvoiceWrapper.tsx rename to src/components/CreateInvoice_forGen/CreateInvoiceWrapper.tsx diff --git a/src/components/CreateInvoice/InvoiceDetails.tsx b/src/components/CreateInvoice_forGen/InvoiceDetails.tsx similarity index 100% rename from src/components/CreateInvoice/InvoiceDetails.tsx rename to src/components/CreateInvoice_forGen/InvoiceDetails.tsx diff --git a/src/components/CreateInvoice/ProjectDetails.tsx b/src/components/CreateInvoice_forGen/ProjectDetails.tsx similarity index 100% rename from src/components/CreateInvoice/ProjectDetails.tsx rename to src/components/CreateInvoice_forGen/ProjectDetails.tsx diff --git a/src/components/CreateInvoice/ProjectTotalFee.tsx b/src/components/CreateInvoice_forGen/ProjectTotalFee.tsx similarity index 100% rename from src/components/CreateInvoice/ProjectTotalFee.tsx rename to src/components/CreateInvoice_forGen/ProjectTotalFee.tsx diff --git a/src/components/CreateInvoice/index.ts b/src/components/CreateInvoice_forGen/index.ts similarity index 100% rename from src/components/CreateInvoice/index.ts rename to src/components/CreateInvoice_forGen/index.ts diff --git a/src/components/CreatePosition/PositionDetails.tsx b/src/components/CreatePosition/PositionDetails.tsx index 5e03acd..22542c5 100644 --- a/src/components/CreatePosition/PositionDetails.tsx +++ b/src/components/CreatePosition/PositionDetails.tsx @@ -39,20 +39,20 @@ const PositionDetails: React.FC = ({ diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index 3e1a3f0..c4aa9d5 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -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 = ({ + isEditMode, + defaultInputs, allTasks, projectCategories, taskTemplates, @@ -90,7 +115,19 @@ const CreateProject: React.FC = ({ 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>( @@ -101,11 +138,102 @@ const CreateProject: React.FC = ({ ); const onSubmit = useCallback>( - 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 = ({ const onSubmitError = useCallback>( (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 = ({ 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 = ({ 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 ( - - - - - ) : undefined - } - iconPosition="end" - /> - - - - - { - - } - { - - } - { - - } - {} - {serverError && ( - - {serverError} - - )} - - + )} + {formProps.getValues("projectActualStart") && + !formProps.getValues("projectActualEnd") && ( + + )} + {!( + formProps.getValues("projectActualStart") && + formProps.getValues("projectActualEnd") + ) && ( + + )} + + )} + - {t("Cancel")} - - + + ) : undefined + } + iconPosition="end" + /> + + + ) : undefined + } + iconPosition="end" + /> + ) + : undefined} + iconPosition="end" /> + + { + + } + { + + } + { + + } + {} + {serverError && ( + + {serverError} + + )} + + + + - - + + ); }; diff --git a/src/components/CreateProject/CreateProjectWrapper.tsx b/src/components/CreateProject/CreateProjectWrapper.tsx index 3ca2fae..6ce6242 100644 --- a/src/components/CreateProject/CreateProjectWrapper.tsx +++ b/src/components/CreateProject/CreateProjectWrapper.tsx @@ -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 = 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 ( = ({ allTasks, isActive }) => { const { t } = useTranslation(); - const { watch } = useFormContext(); + const { watch, setError, clearErrors } = useFormContext(); const currentTaskGroups = watch("taskGroups"); const taskGroups = useMemo( () => @@ -57,6 +57,35 @@ const Milestone: React.FC = ({ 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 ( <> diff --git a/src/components/CreateProject/MilestoneSection.tsx b/src/components/CreateProject/MilestoneSection.tsx index 9984875..a6d6ccc 100644 --- a/src/components/CreateProject/MilestoneSection.tsx +++ b/src/components/CreateProject/MilestoneSection.tsx @@ -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 = ({ 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 = ({ taskGroupId }) => { - { - if (!date) return; - const milestones = getValues("milestones"); - setValue("milestones", { - ...milestones, - [taskGroupId]: { - ...milestones[taskGroupId], - startDate: date.format(INPUT_DATE_FORMAT), - }, - }); - }} - /> + { + 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)), + }, + }} + /> @@ -272,6 +279,11 @@ const MilestoneSection: React.FC = ({ taskGroupId }) => { }, }); }} + slotProps={{ + textField: { + error: endDate === "Invalid Date" || new Date(startDate) > new Date(endDate) || (Boolean(formState.errors.milestones) && !Boolean(endDate)), + }, + }} /> diff --git a/src/components/CreateProject/ProjectTotalFee.tsx b/src/components/CreateProject/ProjectTotalFee.tsx index a8809b9..edc7a71 100644 --- a/src/components/CreateProject/ProjectTotalFee.tsx +++ b/src/components/CreateProject/ProjectTotalFee.tsx @@ -23,6 +23,7 @@ const ProjectTotalFee: React.FC = ({ 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 = ({ taskGroups }) => { {t("Project Total Fee")} {moneyFormatter.format(projectTotal)} - {projectTotal > expectedTotalFee && ( + {projectTotal !== expectedTotalFee && ( - {t("Project total fee is larger than the expected total fee!")} + {t("Project total fee should be same as the expected total fee!")} )} diff --git a/src/components/CreateProject/ResourceAllocation.tsx b/src/components/CreateProject/ResourceAllocation.tsx index c593580..b1a79b9 100644 --- a/src/components/CreateProject/ResourceAllocation.tsx +++ b/src/components/CreateProject/ResourceAllocation.tsx @@ -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 = ({ grades }) => { const { t } = useTranslation(); - const { watch, register, setValue } = useFormContext(); + const { watch, register, setValue, formState: { errors }, setError, clearErrors } = useFormContext(); 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 = ({ grades }) => { type="number" {...register("totalManhour", { valueAsNumber: true, + required: "totalManhour code required!", + min: 1, })} + error={Boolean(errors.totalManhour)} /> ({ @@ -110,15 +134,18 @@ const ResourceAllocationByGrade: React.FC = ({ grades }) => { 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} /> ))} - - {percentFormatter.format(totalPercentage)} + + {totalPercentage + "%"} + {/* {percentFormatter.format(totalPercentage)} */} @@ -126,7 +153,7 @@ const ResourceAllocationByGrade: React.FC = ({ grades }) => { {grades.map((column, idx) => ( {manhourFormatter.format( - manhourPercentageByGrade[column.id] * totalManhour, + manhourPercentageByGrade[column.id] / 100 * totalManhour, )} ))} @@ -144,7 +171,7 @@ const ResourceAllocationByGrade: React.FC = ({ grades }) => { const ResourceAllocationByStage: React.FC = ({ grades, allTasks }) => { const { t } = useTranslation(); - const { watch, setValue } = useFormContext(); + const { watch, setValue, clearErrors, setError } = useFormContext(); const currentTaskGroups = watch("taskGroups"); const taskGroups = useMemo( @@ -167,13 +194,22 @@ const ResourceAllocationByStage: React.FC = ({ 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 = ({ grades, allTasks }) => { 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} /> {manhourFormatter.format( - currentTaskGroups[tg.id].percentAllocation * totalManhour, + currentTaskGroups[tg.id].percentAllocation / 100 * totalManhour, )} {grades.map((column, idx) => { const stageHours = - currentTaskGroups[tg.id].percentAllocation * totalManhour; + currentTaskGroups[tg.id].percentAllocation / 100 * totalManhour; return ( {manhourFormatter.format( - manhourPercentageByGrade[column.id] * stageHours, + manhourPercentageByGrade[column.id] / 100 * stageHours, )} ); @@ -248,10 +288,14 @@ const ResourceAllocationByStage: React.FC = ({ grades, allTasks }) => { 0, )} - + 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 = ({ grades, allTasks }) => { {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 = ({ 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 ( diff --git a/src/components/CreateProject/TaskSetup.tsx b/src/components/CreateProject/TaskSetup.tsx index 443014e..6651971 100644 --- a/src/components/CreateProject/TaskSetup.tsx +++ b/src/components/CreateProject/TaskSetup.tsx @@ -52,7 +52,7 @@ const TaskSetup: React.FC = ({ (e: SelectChangeEvent) => { if (e.target.value === "All" || isNumber(e.target.value)) { setSelectedTaskTemplateId(e.target.value); - onReset(); + // onReset(); } }, [onReset], diff --git a/src/components/CreateStaff/CreateStaff.tsx b/src/components/CreateStaff/CreateStaff.tsx index 94e7f71..00fa507 100644 --- a/src/components/CreateStaff/CreateStaff.tsx +++ b/src/components/CreateStaff/CreateStaff.tsx @@ -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 = ({ Title }) => { - // const router = useRouter(); - const { t } = useTranslation(); - const [companyCombo, setCompanyCombo] = useState(); - const [teamCombo, setTeamCombo] = useState(); - const [departmentCombo, setDepartmentCombo] = useState(); - const [positionCombo, setPositionCombo] = useState(); - const [gradeCombo, setGradeCombo] = useState(); - const [skillCombo, setSkillCombo] = useState(); - const [salaryCombo, setSalaryCombo] = useState(); - // 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 = ({ Title, combos }) => { + const { t } = useTranslation(); const fieldLists: Field[][] = [ [ @@ -163,49 +69,49 @@ const CreateStaff: React.FC = ({ 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 = ({ Title }) => { id: "joinPositionId", label: t("Join Position"), type: "combo-Obj", - options: positionCombo || [], + options: combos.position || [], required: true, }, { diff --git a/src/components/CreateStaff/CreateStaffWrapper.tsx b/src/components/CreateStaff/CreateStaffWrapper.tsx index c54ad87..825fc35 100644 --- a/src/components/CreateStaff/CreateStaffWrapper.tsx +++ b/src/components/CreateStaff/CreateStaffWrapper.tsx @@ -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 ; + return ; }; CreateStaffWrapper.Loading = CreateStaffLoading; diff --git a/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx b/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx index f7d5912..79f30af 100644 --- a/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx +++ b/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx @@ -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 = ({ tasks }) => { + + +const CreateTaskTemplate: React.FC = ({ tasks, defaultInputs, grades }) => { const { t } = useTranslation(); - const searchParams = useSearchParams() const router = useRouter(); const handleCancel = () => { router.back(); @@ -47,57 +52,53 @@ const CreateTaskTemplate: React.FC = ({ tasks }) => { const [serverError, setServerError] = React.useState(""); - const { - register, - handleSubmit, - setValue, - watch, - resetField, - formState: { errors, isSubmitting }, - } = useForm({ 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() - const id = searchParams.get('id') - - const fetchCurrentTaskTemplate = async () => { - try { - const taskTemplate = await fetchTaskTemplate(parseInt(id!!)) + const formProps = useForm({ + 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( + (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 = 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 = ({ tasks }) => { return ( <> - { - (id === null || refTaskTemplate !== undefined) && + + + {/* Task List Setup */} {t("Task List Setup")} @@ -135,22 +137,22 @@ const CreateTaskTemplate: React.FC = ({ tasks }) => { @@ -158,16 +160,54 @@ const CreateTaskTemplate: React.FC = ({ 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")} /> + + {/* Resource Allocation */} + + + + + { serverError && ( @@ -183,12 +223,13 @@ const CreateTaskTemplate: React.FC = ({ tasks }) => { variant="contained" startIcon={} type="submit" - disabled={isSubmitting} + disabled={formProps.formState.isSubmitting} > {t("Confirm")} - } + + ); }; diff --git a/src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx b/src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx index 77888a2..b236986 100644 --- a/src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx +++ b/src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx @@ -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 ; +const CreateTaskTemplateWrapper: React.FC = async (props) => { + const [tasks, grades] = await Promise.all([ + fetchAllTasks(), + fetchGrades(), + ]); + + const taskTemplateInfo = props.taskTemplateId ? await fetchTaskTemplateDetail(props.taskTemplateId) : undefined + return ; }; export default CreateTaskTemplateWrapper; diff --git a/src/components/CreateTaskTemplate/ResourceAllocation.tsx b/src/components/CreateTaskTemplate/ResourceAllocation.tsx new file mode 100644 index 0000000..5e529f0 --- /dev/null +++ b/src/components/CreateTaskTemplate/ResourceAllocation.tsx @@ -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 = ({ grades }) => { + const { t } = useTranslation(); + const { watch, register, setValue, formState: { errors }, setError, clearErrors } = useFormContext(); + + 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 ( + + + {t("Manhour Allocation By Grade")} + + ({ + marginBlockStart: 2, + marginInline: -3, + borderBottom: `1px solid ${theme.palette.divider}`, + })} + > + + + + + + {t("Allocation Type")} + + {grades.map((column, idx) => ( + + {column.name} + + ))} + {t("Total")} + + + + + {t("Percentage")} + {grades.map((column, idx) => ( + val + "%"} + onChange={makeUpdatePercentage(column.id)} + convertValue={(inputValue) => Number(inputValue)} + cellSx={{ backgroundColor: "primary.lightest" }} + inputSx={{ width: "3rem" }} + error={manhourPercentageByGrade[column.id] < 0} + /> + ))} + + {totalPercentage + "%"} + + + +
+
+
+
+ ); +}; + +const ResourceAllocationByStage: React.FC = ({ grades, allTasks }) => { + const { t } = useTranslation(); + const { watch, setValue, clearErrors, setError } = useFormContext(); + + const currentTaskGroups = watch("taskGroups"); + const taskGroups = useMemo( + () => + uniqBy( + allTasks.reduce((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 ( + + + {t("Manhour Allocation By Stage By Grade")} + + ({ + marginBlockStart: 2, + marginInline: -3, + borderBottom: `1px solid ${theme.palette.divider}`, + })} + > + + + + + {t("Stage")} + {t("Task Count")} + + {t("Total Manhour")} + + + + + {taskGroups.map((tg, idx) => ( + + {tg.name} + + {currentTaskGroups[tg.id].taskIds.length} + + 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} + /> + + ))} + + {t("Total")} + + {Object.values(currentTaskGroups).reduce( + (acc, tg) => acc + tg.taskIds.length, + 0, + )} + + 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, + ), + )} + + + +
+
+
+
+ ); +}; + +const NoTaskState: React.FC = () => { + const { t } = useTranslation(); + return ( + <> + + {t("Task Breakdown")} + + + {t('Please add some tasks first!')} + + + ); +}; + +const ResourceAllocationWrapper: React.FC = (props) => { + const { getValues } = useFormContext(); + + if (Object.keys(getValues("taskGroups")).length === 0) { + return ; + } + + return ( + + + + + ); +}; + +export default ResourceAllocationWrapper; diff --git a/src/components/CreateTeam/TeamInfo.tsx b/src/components/CreateTeam/TeamInfo.tsx index 4e61f4b..cd8b90a 100644 --- a/src/components/CreateTeam/TeamInfo.tsx +++ b/src/components/CreateTeam/TeamInfo.tsx @@ -27,7 +27,7 @@ const TeamInfo: React.FC = ( setValue, } = useFormContext(); - const resetCustomer = useCallback(() => { + const resetTeam = useCallback(() => { console.log(defaultValues); if (defaultValues !== undefined) { resetField("description"); diff --git a/src/components/CustomerSave/CustomerSave.tsx b/src/components/CustomerSave/CustomerSave.tsx index fc2469e..acb4ecf 100644 --- a/src/components/CustomerSave/CustomerSave.tsx +++ b/src/components/CustomerSave/CustomerSave.tsx @@ -199,20 +199,20 @@ const CustomerSave: React.FC = ({ 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.")); diff --git a/src/components/DateHoursTable/DateHoursList.tsx b/src/components/DateHoursTable/DateHoursList.tsx new file mode 100644 index 0000000..75b991b --- /dev/null +++ b/src/components/DateHoursTable/DateHoursList.tsx @@ -0,0 +1,205 @@ +import { + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; +import { ArrowBack, Check } from "@mui/icons-material"; +import { + Box, + Button, + Card, + CardActionArea, + CardContent, + Stack, + Typography, +} from "@mui/material"; +import dayjs from "dayjs"; +import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + LEAVE_DAILY_MAX_HOURS, + TIMESHEET_DAILY_MAX_HOURS, +} from "@/app/api/timesheets/utils"; + +interface Props { + days: string[]; + leaveEntries: RecordLeaveInput; + timesheetEntries: RecordTimesheetInput; + EntryComponent: React.FunctionComponent< + EntryComponentProps & { date: string } + >; + entryComponentProps: EntryComponentProps; +} + +function DateHoursList({ + days, + leaveEntries, + timesheetEntries, + EntryComponent, + entryComponentProps, +}: Props) { + const { + t, + i18n: { language }, + } = useTranslation("home"); + + const [selectedDate, setSelectedDate] = useState(""); + const isDateSelected = selectedDate !== ""; + + const makeSelectDate = useCallback( + (date: string) => () => { + setSelectedDate(date); + }, + [], + ); + + const onDateDone = useCallback>( + (e) => { + setSelectedDate(""); + e.preventDefault(); + }, + [], + ); + + return ( + <> + {isDateSelected ? ( + + ) : ( + + {days.map((day, index) => { + const dayJsObj = dayjs(day); + const leaves = leaveEntries[day]; + const leaveHours = + leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; + + const timesheet = timesheetEntries[day]; + const timesheetHours = + timesheet?.reduce( + (acc, entry) => + acc + (entry.inputHours || 0) + (entry.otHours || 0), + 0, + ) || 0; + + const dailyTotal = leaveHours + timesheetHours; + + const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; + const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; + + return ( + + + + + {shortDateFormatter(language).format(dayJsObj.toDate())} + + + + + {t("Timesheet Hours")} + + + {manhourFormatter.format(timesheetHours)} + + + + + {t("Leave Hours")} + + + {manhourFormatter.format(leaveHours)} + + {leaveExceeded && ( + + {t("Leave hours cannot be more than {{hours}}", { + hours: LEAVE_DAILY_MAX_HOURS, + })} + + )} + + + + + {t("Daily Total Hours")} + + + {manhourFormatter.format(timesheetHours + leaveHours)} + + {dailyTotalExceeded && ( + + {t( + "The daily total hours cannot be more than {{hours}}", + { + hours: TIMESHEET_DAILY_MAX_HOURS, + }, + )} + + )} + + + + + + ); + })} + + )} + + {isDateSelected ? ( + + ) : ( + + )} + + + ); +} + +export default DateHoursList; diff --git a/src/components/DateHoursTable/DateHoursTable.tsx b/src/components/DateHoursTable/DateHoursTable.tsx new file mode 100644 index 0000000..cd897b1 --- /dev/null +++ b/src/components/DateHoursTable/DateHoursTable.tsx @@ -0,0 +1,196 @@ +import { + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; +import { Info, KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; +import { + Box, + Collapse, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, +} from "@mui/material"; +import dayjs from "dayjs"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + LEAVE_DAILY_MAX_HOURS, + TIMESHEET_DAILY_MAX_HOURS, +} from "@/app/api/timesheets/utils"; + +interface Props { + days: string[]; + leaveEntries: RecordLeaveInput; + timesheetEntries: RecordTimesheetInput; + EntryTableComponent: React.FunctionComponent< + EntryTableProps & { day: string } + >; + entryTableProps: EntryTableProps; +} + +function DateHoursTable({ + days, + EntryTableComponent, + entryTableProps, + leaveEntries, + timesheetEntries, +}: Props) { + const { t } = useTranslation("home"); + + return ( + + + + + + {t("Date")} + {t("Timesheet Hours")} + {t("Leave Hours")} + {t("Daily Total Hours")} + + + + {days.map((day, index) => { + return ( + + ); + })} + +
+
+ ); +} + +function DayRow({ + day, + leaveEntries, + timesheetEntries, + entryTableProps, + EntryTableComponent, +}: { + day: string; + leaveEntries: RecordLeaveInput; + timesheetEntries: RecordTimesheetInput; + EntryTableComponent: React.FunctionComponent< + EntryTableProps & { day: string } + >; + entryTableProps: EntryTableProps; +}) { + const { + t, + i18n: { language }, + } = useTranslation("home"); + const dayJsObj = dayjs(day); + const [open, setOpen] = useState(false); + + const leaves = leaveEntries[day]; + const leaveHours = + leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; + + const timesheet = timesheetEntries[day]; + const timesheetHours = + timesheet?.reduce( + (acc, entry) => acc + (entry.inputHours || 0) + (entry.otHours || 0), + 0, + ) || 0; + + const dailyTotal = leaveHours + timesheetHours; + + const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; + const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; + + return ( + <> + + + setOpen(!open)} + > + {open ? : } + + + + {shortDateFormatter(language).format(dayJsObj.toDate())} + + {/* Timesheet */} + {manhourFormatter.format(timesheetHours)} + {/* Leave total */} + + + {manhourFormatter.format(leaveHours)} + {leaveExceeded && ( + + + + )} + + + {/* Daily total */} + + + {manhourFormatter.format(dailyTotal)} + {dailyTotalExceeded && ( + + + + )} + + + + + + + {} + + + + + ); +} + +export default DateHoursTable; diff --git a/src/components/DateHoursTable/index.ts b/src/components/DateHoursTable/index.ts new file mode 100644 index 0000000..9acba4b --- /dev/null +++ b/src/components/DateHoursTable/index.ts @@ -0,0 +1 @@ +export { default } from "./DateHoursTable"; diff --git a/src/components/DepartmentSearch/DepartmentSearch.tsx b/src/components/DepartmentSearch/DepartmentSearch.tsx index 57cd3ab..bbbd0a7 100644 --- a/src/components/DepartmentSearch/DepartmentSearch.tsx +++ b/src/components/DepartmentSearch/DepartmentSearch.tsx @@ -5,8 +5,11 @@ import SearchBox, { Criterion } from "../SearchBox"; import { useTranslation } from "react-i18next"; import SearchResults, { Column } from "../SearchResults"; import EditNote from "@mui/icons-material/EditNote"; -import uniq from "lodash/uniq"; import { DepartmentResult } from "@/app/api/departments"; +import { useRouter } from "next/navigation"; +import DeleteIcon from '@mui/icons-material/Delete'; +import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; +import { deleteDepartment } from "@/app/api/departments/actions"; interface Props { departments: DepartmentResult[]; @@ -17,6 +20,7 @@ type SearchParamNames = keyof SearchQuery; const DepartmentSearch: React.FC = ({ departments }) => { const { t } = useTranslation("departments"); + const router = useRouter(); const [filteredDepartments, setFilteredDepartments] = useState(departments); @@ -33,8 +37,20 @@ const DepartmentSearch: React.FC = ({ departments }) => { setFilteredDepartments(departments); }, [departments]); - const onProjectClick = useCallback((project: DepartmentResult) => { - console.log(project); + const onProjectClick = useCallback((department: DepartmentResult) => { + console.log(department.id) + router.push(`/settings/department/edit?id=${department.id}`); + }, [router]); + + const onDeleteClick = useCallback((department: DepartmentResult) => { + + deleteDialog(async() => { + await deleteDepartment(department.id) + + successDialog("Delete Success", t) + + setFilteredDepartments((prev) => prev.filter((obj) => obj.id !== department.id)) + }, t) }, []); const columns = useMemo[]>( @@ -48,6 +64,13 @@ const DepartmentSearch: React.FC = ({ departments }) => { { name: "code", label: t("Department Code") }, { name: "name", label: t("Department Name") }, { name: "description", label: t("Department Description") }, + { + name: "id", + label: t("Delete"), + onClick: onDeleteClick, + buttonIcon: , + color: "error" + }, ], [t, onProjectClick], ); diff --git a/src/components/EditPosition/EditPosition.tsx b/src/components/EditPosition/EditPosition.tsx index 92cf871..602f489 100644 --- a/src/components/EditPosition/EditPosition.tsx +++ b/src/components/EditPosition/EditPosition.tsx @@ -45,16 +45,8 @@ const EditPosition: React.FC = ({ try{ if (positionId !== null && parseInt(positionId) > 0) { const postionDetails = await fetchPositionDetails(parseInt(positionId)) - const updatedArray: EditPositionInputs[] = postionDetails.map((obj) => { - return { - id: obj.id, - positionCode: obj.code, - positionName: obj.name, - description: obj.description - }; - }); - setPositionDetails(updatedArray[0]) + setPositionDetails(postionDetails[0]) } } catch (error){ console.log(error) diff --git a/src/components/EditPosition/PositionDetails.tsx b/src/components/EditPosition/PositionDetails.tsx index 086998e..91ac18d 100644 --- a/src/components/EditPosition/PositionDetails.tsx +++ b/src/components/EditPosition/PositionDetails.tsx @@ -46,20 +46,20 @@ const PositionDetails: React.FC = ({ diff --git a/src/components/EditSkill/EditSkill.tsx b/src/components/EditSkill/EditSkill.tsx new file mode 100644 index 0000000..a34b6e9 --- /dev/null +++ b/src/components/EditSkill/EditSkill.tsx @@ -0,0 +1,151 @@ +"use client"; +import { SkillResult } from "@/app/api/skill"; +import { + Button, + Card, + CardContent, + Grid, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; +import EditSkillForm from "./EditSkillForm"; +import { CreateSkillInputs, saveSkill } from "@/app/api/skill/actions"; +import AuthAllocation from "../EditUser/AuthAllocation"; + +interface Props { + skills: SkillResult[]; +} + +const EditSkill: React.FC = async ({ skills }) => { + const { t } = useTranslation(); + const formProps = useForm(); + const [serverError, setServerError] = useState(""); + const router = useRouter(); + const searchParams = useSearchParams(); + const id = parseInt(searchParams.get("id") || "0"); + const [tabIndex, setTabIndex] = useState(0); + const [filteredSkill, setFilteredSkill] = useState(() => + skills.filter((s) => s.id === id)[0] as SkillResult + ); + const errors = formProps.formState.errors; + + const onSubmit = useCallback>( + async (data) => { + try { + console.log(data); + const postData = { + ...data, + id: id + } + await saveSkill(postData) + router.replace(`/settings/skill`) + } catch (e) { + console.log(e); + setServerError(t("An error has occurred. Please try again later.")); + } + }, + [router] + ); + + const handleCancel = () => { + router.back(); + }; + + const handleTabChange = useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + useEffect(() => { + formProps.reset({ + name: filteredSkill.name, + code: filteredSkill.code, + description: filteredSkill.description + }); + }, [skills]); + + const hasErrorsInTab = ( + tabIndex: number, + errors: FieldErrors + ) => { + switch (tabIndex) { + case 0: + return Object.keys(errors).length > 0; + default: + false; + } + }; + + return ( + <> + {serverError && ( + + {serverError} + + )} + + + + + ) : undefined + } + iconPosition="end" + /> + {/* */} + + {tabIndex === 0 && } + + + + + + + + + ); +}; +export default EditSkill; diff --git a/src/components/EditSkill/EditSkillForm.tsx b/src/components/EditSkill/EditSkillForm.tsx new file mode 100644 index 0000000..120d2e5 --- /dev/null +++ b/src/components/EditSkill/EditSkillForm.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { CreateSkillInputs } from "@/app/api/skill/actions"; +import { + Box, + Button, + Card, + CardContent, + Grid, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { useSearchParams } from "next/navigation"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +interface Props { + // users: UserResult[] +} + +const EditSkillForm: React.FC = async ({}) => { + const { t } = useTranslation(); + const searchParams = useSearchParams(); + const idString = searchParams.get("id"); + const { + register, + setValue, + getValues, + formState: { errors, defaultValues }, + reset, + resetField, + } = useFormContext(); + // const formProps = useForm({}); + + return ( + <> + + + + + {t("Skill Info")} + + + + + + + + + + + + + + + + + ); +}; +export default EditSkillForm; diff --git a/src/components/EditSkill/EditSkillLoading.tsx b/src/components/EditSkill/EditSkillLoading.tsx new file mode 100644 index 0000000..74e08af --- /dev/null +++ b/src/components/EditSkill/EditSkillLoading.tsx @@ -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 EditSkillLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + Edit Skill + + + + + + + + + + + ); +}; + +export default EditSkillLoading; diff --git a/src/components/EditSkill/EditSkillWrapper.tsx b/src/components/EditSkill/EditSkillWrapper.tsx new file mode 100644 index 0000000..12d7a12 --- /dev/null +++ b/src/components/EditSkill/EditSkillWrapper.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import EditSkill from "./EditSkill"; +import EditSkillLoading from "./EditSkillLoading"; +import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; +import { useSearchParams } from "next/navigation"; +import { fetchSkill } from "@/app/api/skill"; + +interface SubComponents { + Loading: typeof EditSkillLoading; +} + +const EditSkillWrapper: React.FC & SubComponents = async () => { + const skills = await fetchSkill() + console.log(skills) + + return ; +}; + +EditSkillWrapper.Loading = EditSkillLoading; + +export default EditSkillWrapper; diff --git a/src/components/EditSkill/index.ts b/src/components/EditSkill/index.ts new file mode 100644 index 0000000..ba42dd8 --- /dev/null +++ b/src/components/EditSkill/index.ts @@ -0,0 +1 @@ +export { default } from "./EditSkillWrapper"; diff --git a/src/components/EditStaff/EditStaff.tsx b/src/components/EditStaff/EditStaff.tsx index 0d62e28..4c032cc 100644 --- a/src/components/EditStaff/EditStaff.tsx +++ b/src/components/EditStaff/EditStaff.tsx @@ -311,7 +311,7 @@ const EditStaff: React.FC = async () => { label: t(`Join Position`), type: "combo-Obj", options: positionCombo, - value: data[key].id ?? "", + value: data[key]?.id ?? "", required: true, } as Field; case "departDate": diff --git a/src/components/EditStaffForm/EditStaffForm.tsx b/src/components/EditStaffForm/EditStaffForm.tsx index f15be32..96f8b1d 100644 --- a/src/components/EditStaffForm/EditStaffForm.tsx +++ b/src/components/EditStaffForm/EditStaffForm.tsx @@ -83,7 +83,7 @@ const EditStaffForm: React.FC = ({ Title, fieldLists }) => { }; return ( <> - {serverError && ( + {serverError && ( {serverError} diff --git a/src/components/EditTeam/Allocation.tsx b/src/components/EditTeam/Allocation.tsx index 44c89b1..f1386fe 100644 --- a/src/components/EditTeam/Allocation.tsx +++ b/src/components/EditTeam/Allocation.tsx @@ -16,8 +16,8 @@ import { Staff4TransferList, fetchStaffCombo } from "@/app/api/staff/actions"; import { StaffResult, StaffTeamTable } from "@/app/api/staff"; import SearchResults, { Column } from "../SearchResults"; import { Clear, PersonAdd, PersonRemove, Search } from "@mui/icons-material"; -import { Card } from "reactstrap"; import { + Card, Box, CardContent, Grid, @@ -35,9 +35,10 @@ import StarsIcon from "@mui/icons-material/Stars"; export interface Props { allStaffs: StaffResult[]; + teamLead: number; } -const Allocation: React.FC = ({ allStaffs: staff }) => { +const Allocation: React.FC = ({ allStaffs: staff, teamLead }) => { const { t } = useTranslation(); const searchParams = useSearchParams(); const idString = searchParams.get("id"); @@ -49,12 +50,16 @@ const Allocation: React.FC = ({ allStaffs: staff }) => { resetField, } = useFormContext(); - // let firstFilter: StaffResult[] = [] - const initialStaffs = staff.map((s) => ({ ...s })); const [filteredStaff, setFilteredStaff] = useState(initialStaffs); - const [selectedStaff, setSelectedStaff] = useState( - filteredStaff.filter((s) => getValues("addStaffIds")?.includes(s.id)) + const [selectedStaff, setSelectedStaff] = useState(() => { + const rearrangedStaff = filteredStaff.sort((a, b) => { + if (a.id === teamLead) return -1; + if (b.id === teamLead) return 1; + return 0; + }); + return rearrangedStaff.filter((s) => getValues("addStaffIds")?.includes(s.id)) + } ); const [seletedTeamLead, setSeletedTeamLead] = useState(); const [deletedStaffIds, setDeletedStaffIds] = useState([]); @@ -84,8 +89,8 @@ const Allocation: React.FC = ({ allStaffs: staff }) => { }, getValues("addStaffIds") ); - console.log(rearrangedList); - console.log(selectedStaff); + // console.log(rearrangedList); + // console.log(selectedStaff); const rearrangedStaff = rearrangedList.map((id) => { return selectedStaff.find((staff) => staff.id === id); diff --git a/src/components/EditTeam/EditTeam.tsx b/src/components/EditTeam/EditTeam.tsx index 095559b..432bc0e 100644 --- a/src/components/EditTeam/EditTeam.tsx +++ b/src/components/EditTeam/EditTeam.tsx @@ -20,12 +20,15 @@ import { StaffResult } from "@/app/api/staff"; interface desc { id: number; + name: string; description: string; + teamLead: number; } interface Props { staff: StaffResult[]; desc: desc[]; + // teamLead: StaffResult[] } const EditTeam: React.FC = async ({ staff, desc }) => { @@ -37,6 +40,8 @@ const EditTeam: React.FC = async ({ staff, desc }) => { const [filteredItems, setFilteredItems] = useState(); const [allStaffs, setAllStaffs] = useState(); const [filteredDesc, setFilteredDesc] = useState(); + const [filteredName, setFilteredName] = useState(); + const [teamLead, setTeamLead] = useState(); const [tabIndex, setTabIndex] = useState(0); const router = useRouter(); // const [selectedStaff, setSelectedStaff] = useState( @@ -63,25 +68,47 @@ const EditTeam: React.FC = async ({ staff, desc }) => { ); useEffect(() => { let idList: number[] = [] + console.log(desc) if (idString) { const filteredTeam = staff.filter( - (item) => item.teamId === parseInt(idString) + (item) => { + console.log(item) + console.log(parseInt(idString)) + return (item.teamId === parseInt(idString))} ); + console.log(filteredTeam) const tempDesc = desc.filter( (item) => item.id === parseInt(idString) ) - + // const leader = teamLead.filter( + // (staff) => staff.teamId === parseInt(idString) + // ) + // console.log(leader) + console.log(tempDesc[0].teamLead) + setTeamLead(tempDesc[0].teamLead) if (filteredTeam.length > 0) { const filteredIds: number[] = filteredTeam.map((i) => ( i.id - )) + )) + + // const teamLead = tempDesc[0].teamLead + // const index = filteredIds.indexOf(teamLead); + + // if (index !== -1) { + // filteredIds.splice(index, 1); + // filteredIds.unshift(teamLead); + // } + idList = filteredIds + console.log(filteredIds) } - // console.log(filteredIds) + console.log(idList) setFilteredItems(filteredTeam); formProps.reset({description: tempDesc[0].description, addStaffIds: idList}) setFilteredDesc(tempDesc[0].description) + setFilteredName(tempDesc[0].name) } + console.log(staff) setAllStaffs(staff) @@ -139,7 +166,7 @@ const EditTeam: React.FC = async ({ staff, desc }) => { > - {t("Edit Team")} + {t("Edit Team")} - {filteredName} = async ({ staff, desc }) => { {tabIndex === 0 && } - {tabIndex === 1 && } + {tabIndex === 1 && } + + + + + + + ); +}; +export default EditUser; diff --git a/src/components/EditUser/EditUserLoading.tsx b/src/components/EditUser/EditUserLoading.tsx new file mode 100644 index 0000000..971c9e4 --- /dev/null +++ b/src/components/EditUser/EditUserLoading.tsx @@ -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 EditUserLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + EditUser + + + + + + + + + + + ); +}; + +export default EditUserLoading; diff --git a/src/components/EditUser/EditUserWrapper.tsx b/src/components/EditUser/EditUserWrapper.tsx new file mode 100644 index 0000000..10be6f2 --- /dev/null +++ b/src/components/EditUser/EditUserWrapper.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import EditUser from "./EditUser"; +import EditUserLoading from "./EditUserLoading"; +// import { fetchTeam, fetchTeamLeads } from "@/app/api/Team"; +import { useSearchParams } from "next/navigation"; +import { fetchTeam, fetchTeamDetail } from "@/app/api/team"; +import { fetchStaff } from "@/app/api/staff"; +import { fetchUser } from "@/app/api/user"; + +interface SubComponents { + Loading: typeof EditUserLoading; +} + +const EditUserWrapper: React.FC & SubComponents = async () => { + const users = await fetchUser() + console.log(users) + + return +}; + +EditUserWrapper.Loading = EditUserLoading; + +export default EditUserWrapper; diff --git a/src/components/EditUser/UserDetail.tsx b/src/components/EditUser/UserDetail.tsx new file mode 100644 index 0000000..a3cf3ee --- /dev/null +++ b/src/components/EditUser/UserDetail.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { UserResult } from "@/app/api/user"; +import { UserInputs } from "@/app/api/user/actions"; +import { + Card, + CardContent, + Grid, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +interface Props { + data: UserResult +} + + +const UserDetail: React.FC = ({ + data +}) => { + const { t } = useTranslation(); + const { + register, + formState: { errors }, + control, + } = useFormContext(); + + return ( + + + + {t("User Detail")} + + + + + + + + + + + + ); +}; + +export default UserDetail; diff --git a/src/components/EditUser/index.ts b/src/components/EditUser/index.ts new file mode 100644 index 0000000..c12dc8e --- /dev/null +++ b/src/components/EditUser/index.ts @@ -0,0 +1 @@ +export { default } from "./EditUserWrapper"; diff --git a/src/components/EditUserGroup/AuthorityAllocation.tsx b/src/components/EditUserGroup/AuthorityAllocation.tsx new file mode 100644 index 0000000..da502da --- /dev/null +++ b/src/components/EditUserGroup/AuthorityAllocation.tsx @@ -0,0 +1,210 @@ +"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 = ({ auth }) => { + const { t } = useTranslation(); + const { + setValue, + getValues, + formState: { defaultValues }, + reset, + resetField, + } = useFormContext(); + console.log(auth) + const initialAuths = auth.map((a) => ({ ...a })).sort((a, b) => a.id - b.id); + const [filteredAuths, setFilteredAuths] = useState(initialAuths); + const [selectedAuths, setSelectedAuths] = useState( + () => initialAuths.filter((s) => getValues("addAuthIds")?.includes(s.id))) + const [removeAuthIds, setRemoveAuthIds] = useState([]); + + // 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)); + setRemoveAuthIds((prevIds) => [...prevIds, auth.id]); +}, []); + + const clearAuth = useCallback(() => { + if (defaultValues !== undefined) { + resetField("addAuthIds"); + setSelectedAuths( + initialAuths.filter((auth) => defaultValues.addAuthIds?.includes(auth.id)) + ); + } + }, [defaultValues]); + + // Sync with form + useEffect(() => { + setValue( + "addAuthIds", + selectedAuths.map((a) => a.id) + ); + setValue( + "removeAuthIds", + removeAuthIds + ); + }, [selectedAuths, removeAuthIds, setValue]); + + const AuthPoolColumns = useMemo[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("Auth Name"), name: "name" }, + // { label: t("Current Position"), name: "currentPosition" }, + ], + [addAuth, t] + ); + + const allocatedAuthColumns = useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeAuth, + buttonIcon: , + }, + { 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 + >((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>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + return ( + <> + + + + + + {t("Authority")} + + + + + + + + + + ), + }} + /> + + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + ); +}; + +export default AuthorityAllocation; diff --git a/src/components/EditUserGroup/EditUserGroup.tsx b/src/components/EditUserGroup/EditUserGroup.tsx new file mode 100644 index 0000000..1eba351 --- /dev/null +++ b/src/components/EditUserGroup/EditUserGroup.tsx @@ -0,0 +1,165 @@ +"use client"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import SearchResults, { Column } from "../SearchResults"; +// import { TeamResult } from "@/app/api/team"; +import { useTranslation } from "react-i18next"; +import { Button, Stack, Tab, Tabs, TabsProps, Typography } from "@mui/material"; +import { CreateTeamInputs, saveTeam } from "@/app/api/team/actions"; +import { + FieldErrors, + FormProvider, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { Check, Close, Error } from "@mui/icons-material"; +import { StaffResult } from "@/app/api/staff"; +import { CreateGroupInputs, auth, fetchAuth, saveGroup } from "@/app/api/group/actions"; +import { UserGroupResult } from "@/app/api/group"; +import { UserResult } from "@/app/api/user"; +import GroupInfo from "./GroupInfo"; +import AuthorityAllocation from "./AuthorityAllocation"; +import UserAllocation from "./UserAllocation"; +interface Props { + groups: UserGroupResult[]; +// auths: auth[]; + users: UserResult[]; +} + +const EditUserGroup: React.FC = ({ groups, users }) => { + // console.log(users) + const { t } = useTranslation(); + const [serverError, setServerError] = useState(""); + const formProps = useForm(); + const searchParams = useSearchParams(); + const id = parseInt(searchParams.get("id") || "0"); + const router = useRouter(); + const [tabIndex, setTabIndex] = useState(0); + const [auths, setAuths] = useState(); + + const errors = formProps.formState.errors; + + const handleTabChange = useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + const hasErrorsInTab = ( + tabIndex: number, + errors: FieldErrors + ) => { + switch (tabIndex) { + case 0: + return Object.keys(errors).length > 0; + default: + false; + } + }; + + const onSubmit = useCallback>( + async (data) => { + try { + console.log(data); + const tempData = { + ...data, + removeUserIds: data.removeUserIds ?? [], + removeAuthIds: data.removeAuthIds ?? [], + id: id + } + console.log(tempData) + await saveGroup(tempData); + router.replace("/settings/group"); + } catch (e) { + console.log(e); + setServerError(t("An error has occurred. Please try again later.")); + } + }, + [router] + ); + useEffect(() => { + const thisGroup = groups.filter((item) => item.id === id)[0]; + const addUserIds = users.filter((item) => item.groupId === id).map((data) => data.id) + let addAuthIds: number[] = [] + fetchAuth("group", id).then((data) => { + setAuths(data.records) + addAuthIds = data.records.filter((data) => data.v === 1).map((data) => data.id).sort((a, b) => a - b); + formProps.reset({ + name: thisGroup.name, + description: thisGroup.description, + addAuthIds: addAuthIds, + addUserIds: addUserIds, + }); + }); + // console.log(auths) + }, [groups, users]); + + return ( + <> + + + + {t("Edit User Group")} + + + + + ) : undefined + } + iconPosition="end" + /> + + + + + {serverError && ( + + {serverError} + + )} + {tabIndex === 0 && } + {tabIndex === 1 && } + {tabIndex === 2 && } + + + + + + + + ); +}; + +export default EditUserGroup; diff --git a/src/components/EditUserGroup/EditUserGroupLoading.tsx b/src/components/EditUserGroup/EditUserGroupLoading.tsx new file mode 100644 index 0000000..9238474 --- /dev/null +++ b/src/components/EditUserGroup/EditUserGroupLoading.tsx @@ -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 EditUserGroupLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + EditUserGroup + + + + + + + + + + + ); +}; + +export default EditUserGroupLoading; diff --git a/src/components/EditUserGroup/EditUserGroupWrapper.tsx b/src/components/EditUserGroup/EditUserGroupWrapper.tsx new file mode 100644 index 0000000..abec89d --- /dev/null +++ b/src/components/EditUserGroup/EditUserGroupWrapper.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import EditUserGroup from "./EditUserGroup"; +import EditUserGroupLoading from "./EditUserGroupLoading"; +import { fetchGroup } from "@/app/api/group"; +import { fetchUser } from "@/app/api/user"; + +interface SubComponents { + Loading: typeof EditUserGroupLoading; +} + +const EditUserGroupWrapper: React.FC & SubComponents = async () => { + + const [ + groups, + users, + ] = await Promise.all([ + fetchGroup(), + fetchUser(), + ]); + + return ; +}; + +EditUserGroupWrapper.Loading = EditUserGroupLoading; + +export default EditUserGroupWrapper; diff --git a/src/components/EditUserGroup/GroupInfo.tsx b/src/components/EditUserGroup/GroupInfo.tsx new file mode 100644 index 0000000..d9141bc --- /dev/null +++ b/src/components/EditUserGroup/GroupInfo.tsx @@ -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(); + + + const resetGroup = useCallback(() => { + console.log(defaultValues); + if (defaultValues !== undefined) { + resetField("description"); + } + }, [defaultValues]); + + + return ( + + + + + {t("Group Info")} + + + + + + + + + + + + + ); +}; + +export default GroupInfo; diff --git a/src/components/EditUserGroup/UserAllocation.tsx b/src/components/EditUserGroup/UserAllocation.tsx new file mode 100644 index 0000000..14ed975 --- /dev/null +++ b/src/components/EditUserGroup/UserAllocation.tsx @@ -0,0 +1,216 @@ +"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 = ({ users }) => { + const { t } = useTranslation(); + const { + setValue, + getValues, + formState: { defaultValues }, + reset, + resetField, + } = useFormContext(); + const initialUsers = users.map((u) => ({ ...u })).sort((a, b) => a.id - b.id); + const [filteredUsers, setFilteredUsers] = useState(initialUsers); + const [selectedUsers, setSelectedUsers] = useState( + () => { + return filteredUsers.filter( + (s) => getValues("addUserIds")?.includes(s.id) + ); + } + ); + const [deletedUserIds, setDeletedUserIds] = useState([]); + + // 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)); + setDeletedUserIds((prevIds) => [...prevIds, 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) + ); + setValue( + "removeUserIds", + deletedUserIds + ); + }, [selectedUsers, deletedUserIds, setValue]); + + const UserPoolColumns = useMemo[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addUser, + buttonIcon: , + }, + { label: t("User Name"), name: "username" }, + { label: t("name"), name: "name" }, + ], + [addUser, t] + ); + + const allocatedUserColumns = useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeUser, + buttonIcon: , + }, + { 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 + >((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>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + return ( + <> + + + + + + {t("User")} + + + + + + + + + + ), + }} + /> + + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + ); +}; + +export default UserAllocation; diff --git a/src/components/EditUserGroup/index.ts b/src/components/EditUserGroup/index.ts new file mode 100644 index 0000000..b062020 --- /dev/null +++ b/src/components/EditUserGroup/index.ts @@ -0,0 +1 @@ +export { default } from "./EditUserGroupWrapper"; diff --git a/src/components/FullscreenModal/FullscreenModal.tsx b/src/components/FullscreenModal/FullscreenModal.tsx new file mode 100644 index 0000000..11aa36b --- /dev/null +++ b/src/components/FullscreenModal/FullscreenModal.tsx @@ -0,0 +1,46 @@ +import { Close } from "@mui/icons-material"; +import { + Box, + IconButton, + Modal, + ModalProps, + Paper, + Slide, +} from "@mui/material"; + +interface Props extends ModalProps { + closeModal: () => void; +} + +const FullscreenModal: React.FC = ({ + children, + closeModal, + ...props +}) => { + return ( + + + + + + + + + + {children} + + + + + ); +}; + +export default FullscreenModal; diff --git a/src/components/FullscreenModal/index.ts b/src/components/FullscreenModal/index.ts new file mode 100644 index 0000000..5cc4ad7 --- /dev/null +++ b/src/components/FullscreenModal/index.ts @@ -0,0 +1 @@ +export { default } from "./FullscreenModal"; diff --git a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportWrapper.tsx b/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportWrapper.tsx deleted file mode 100644 index 5bf1089..0000000 --- a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportWrapper.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; -import GenerateEX02ProjectCashFlowReportLoading from "./GenerateEX02ProjectCashFlowReportLoading"; -import { fetchProjects } from "@/app/api/projects"; -import GenerateEX02ProjectCashFlowReport from "./GenerateEX02ProjectCashFlowReport"; - -interface SubComponents { - Loading: typeof GenerateEX02ProjectCashFlowReportLoading; -} - -const GenerateEX02ProjectCashFlowReportWrapper: React.FC & SubComponents = async () => { - const projects = await fetchProjects(); - - return ; -}; - -GenerateEX02ProjectCashFlowReportWrapper.Loading = GenerateEX02ProjectCashFlowReportLoading; - -export default GenerateEX02ProjectCashFlowReportWrapper; \ No newline at end of file diff --git a/src/components/GenerateEX02ProjectCashFlowReport/index.ts b/src/components/GenerateEX02ProjectCashFlowReport/index.ts deleted file mode 100644 index b547e33..0000000 --- a/src/components/GenerateEX02ProjectCashFlowReport/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./GenerateEX02ProjectCashFlowReportWrapper"; \ No newline at end of file diff --git a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReport.tsx b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx similarity index 77% rename from src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReport.tsx rename to src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx index 7aec1c2..0b7d661 100644 --- a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReport.tsx +++ b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReport.tsx @@ -4,8 +4,8 @@ import React, { useMemo } from "react"; import SearchBox, { Criterion } from "../SearchBox"; import { useTranslation } from "react-i18next"; import { ProjectResult } from "@/app/api/projects"; -import { EX02ProjectCashFlowReportFilter } from "@/app/api/reports"; -import { fetchEX02ProjectCashFlowReport } from "@/app/api/reports/actions"; +import { ProjectCashFlowReportFilter } from "@/app/api/reports"; +import { fetchProjectCashFlowReport } from "@/app/api/reports/actions"; import { downloadFile } from "@/app/utils/commonUtil"; import { BASE_API_URL } from "@/config/api"; @@ -13,10 +13,10 @@ interface Props { projects: ProjectResult[]; } -type SearchQuery = Partial>; +type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; -const GenerateEX02ProjectCashFlowReport: React.FC = ({ projects }) => { +const GenerateProjectCashFlowReport: React.FC = ({ projects }) => { const { t } = useTranslation(); const projectCombo = projects.map(project => `${project.code} - ${project.name}`) @@ -35,7 +35,7 @@ const GenerateEX02ProjectCashFlowReport: React.FC = ({ projects }) => { if (query.project.length > 0 && query.project.toLocaleLowerCase() !== "all") { const projectIndex = projectCombo.findIndex(project => project === query.project) - const response = await fetchEX02ProjectCashFlowReport({ projectId: projects[projectIndex].id }) + const response = await fetchProjectCashFlowReport({ projectId: projects[projectIndex].id }) if (response) { downloadFile(new Uint8Array(response.blobValue), response.filename!!) } @@ -46,4 +46,4 @@ const GenerateEX02ProjectCashFlowReport: React.FC = ({ projects }) => { ); }; -export default GenerateEX02ProjectCashFlowReport; \ No newline at end of file +export default GenerateProjectCashFlowReport; \ No newline at end of file diff --git a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportLoading.tsx b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportLoading.tsx similarity index 90% rename from src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportLoading.tsx rename to src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportLoading.tsx index 1792221..98514e0 100644 --- a/src/components/GenerateEX02ProjectCashFlowReport/GenerateEX02ProjectCashFlowReportLoading.tsx +++ b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportLoading.tsx @@ -5,7 +5,7 @@ import Stack from "@mui/material/Stack"; import React from "react"; // Can make this nicer -export const GenerateEX02ProjectCashFlowReportLoading: React.FC = () => { +export const GenerateProjectCashFlowReportLoading: React.FC = () => { return ( <> @@ -35,4 +35,4 @@ export const GenerateEX02ProjectCashFlowReportLoading: React.FC = () => { ); }; -export default GenerateEX02ProjectCashFlowReportLoading; \ No newline at end of file +export default GenerateProjectCashFlowReportLoading; \ No newline at end of file diff --git a/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportWrapper.tsx b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportWrapper.tsx new file mode 100644 index 0000000..218f43e --- /dev/null +++ b/src/components/GenerateProjectCashFlowReport/GenerateProjectCashFlowReportWrapper.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import GenerateProjectCashFlowReportLoading from "./GenerateProjectCashFlowReportLoading"; +import { fetchProjects } from "@/app/api/projects"; +import GenerateProjectCashFlowReport from "./GenerateProjectCashFlowReport"; + +interface SubComponents { + Loading: typeof GenerateProjectCashFlowReportLoading; +} + +const GenerateProjectCashFlowReportWrapper: React.FC & SubComponents = async () => { + const projects = await fetchProjects(); + + return ; +}; + +GenerateProjectCashFlowReportWrapper.Loading = GenerateProjectCashFlowReportLoading; + +export default GenerateProjectCashFlowReportWrapper; \ No newline at end of file diff --git a/src/components/GenerateProjectCashFlowReport/index.ts b/src/components/GenerateProjectCashFlowReport/index.ts new file mode 100644 index 0000000..5adef20 --- /dev/null +++ b/src/components/GenerateProjectCashFlowReport/index.ts @@ -0,0 +1 @@ +export { default } from "./GenerateProjectCashFlowReportWrapper"; \ No newline at end of file diff --git a/src/components/InvoiceSearch/InvoiceSearch.tsx b/src/components/InvoiceSearch/InvoiceSearch.tsx index 52dbdd5..6d2a244 100644 --- a/src/components/InvoiceSearch/InvoiceSearch.tsx +++ b/src/components/InvoiceSearch/InvoiceSearch.tsx @@ -5,98 +5,334 @@ import SearchBox, { Criterion } from "../SearchBox"; import { useTranslation } from "react-i18next"; import SearchResults, { Column } from "../SearchResults"; import EditNote from "@mui/icons-material/EditNote"; -import { InvoiceResult } from "@/app/api/invoices"; -import { useRouter } from "next/navigation"; +import { convertLocaleStringToNumber } from "@/app/utils/formatUtil" +import { Button, ButtonGroup, Stack, Tab, Tabs, TabsProps } from "@mui/material"; +import FileDownloadIcon from '@mui/icons-material/FileDownload'; +import FileUploadIcon from '@mui/icons-material/FileUpload'; +import { dateInRange, downloadFile } from "@/app/utils/commonUtil"; +import { importIssuedInovice, importReceivedInovice } from "@/app/api/invoices/actions"; +import { errorDialogWithContent, successDialog } from "../Swal/CustomAlerts"; +import { issuedInvoiceList, issuedInvoiceResult, issuedInvoiceSearchForm, receivedInvoiceList, receivedInvoiceSearchForm } from "@/app/api/invoices"; interface Props { - invoices: InvoiceResult[]; + issuedInvoice: issuedInvoiceList[]; + receivedInvoice: receivedInvoiceList[]; } -type SearchQuery = Partial>; +type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; -const InvoiceSearch: React.FC = ({ invoices }) => { +type SearchQuery2 = Partial>; +type SearchParamNames2 = keyof SearchQuery2; + +const InvoiceSearch: React.FC = ({ issuedInvoice, receivedInvoice }) => { const { t } = useTranslation("invoices"); - const router = useRouter(); + const [tabIndex, setTabIndex] = useState(0); - const [filteredInvoices, setFilteredInvoices] = useState(invoices); + const [filteredIssuedInvoices, setFilteredIssuedInvoices] = useState(issuedInvoice); + const [filteredReceivedInvoices, setFilteredReceivedInvoices] = useState(receivedInvoice); const searchCriteria: Criterion[] = useMemo( () => [ - { label: t("Project code"), paramName: "projectCode", type: "text" }, - { label: t("Project name"), paramName: "projectName", type: "text" }, - // { label: t("Stage"), paramName: "stage", type: "text" }, - { - label: t("Coming payment milestone from"), - label2: t("Coming payment milestone to"), - paramName: "comingPaymentMileStone", - type: "dateRange" - }, - { - label: t("Payment date from"), - label2: t("Payment date to"), - paramName: "paymentMilestoneDate", - type: "dateRange" - }, - // { label: t("Resource utilization %"), paramName: "resourceUsage", type: "text" }, - // { label: t("Unbilled hours"), paramName: "unbilledHours", type: "text" }, - // { label: t("Reminder to issue invoice"), paramName: "reminder", type: "text" }, + { label: t("Invoice No"), paramName: "invoiceNo", type: "text" }, + { label: t("Project Code"), paramName: "projectCode", type: "text" }, + { label: t("Invoice Date"), label2: t("Invoice Date To"), paramName: "invoiceDate", type: "dateRange" }, + { label: t("Due Date"), label2: t("Due Date To"), paramName: "dueDate", type: "dateRange" }, + ], + [t, issuedInvoice], + ); + + const searchCriteria2: Criterion[] = useMemo( + () => [ + { label: t("Invoice No"), paramName: "invoiceNo", type: "text" }, + { label: t("Project Code"), paramName: "projectCode", type: "text" }, + { label: t("Recipt Date"), label2: t("Recipt Date To"), paramName: "receiptDate", type: "dateRange" }, ], - [t, invoices], + [t, issuedInvoice], ); const onReset = useCallback(() => { - setFilteredInvoices(invoices); - }, [invoices]); + setFilteredIssuedInvoices(issuedInvoice); + }, [issuedInvoice]); + + function concatListOfObject(obj: any[]): string { + return obj.map(obj => `Cannot find "${obj.paymentMilestone}" in ${obj.invoiceNo}`).join(", ") + } + + function concatListOfObject2(obj: any[]): string { + return obj.map(obj => `"${obj.projectCode}" does not match with ${obj.invoicesNo}`).join(", ") + } + + const handleImportClick = useCallback(async (event:any) => { + // console.log(event) + try { + + const file = event.target.files[0]; + + if (!file) { + console.log('No file selected'); + return; + } + + if (file.type !== 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') { + console.log('Invalid file format. Only XLSX files are allowed.'); + return; + } + + const formData = new FormData(); + formData.append('multipartFileList', file); + + const response = await importIssuedInovice(formData); + // response: status, message, projectList, emptyRowList, invoiceList + + console.log(response) + if (response.status) { + successDialog(t("Import Success"), t).then(() => { + window.location.reload() + }) + }else{ + if (response.emptyRowList.length >= 1){ + errorDialogWithContent(t("Import Fail"), + t(`Please fill the mandatory field at Row
${response.emptyRowList.join(", ")}`), t) + .then(() => { + window.location.reload() + }) + } + else if (response.projectList.length >= 1){ + errorDialogWithContent(t("Import Fail"), + t(`Please check the corresponding project code
${response.projectList.join(", ")}`), t) + .then(() => { + // window.location.reload() + }) + } + else if (response.invoiceList.length >= 1){ + errorDialogWithContent(t("Import Fail"), + t(`Please check the corresponding Invoice No. The invoice is imported.
`)+ `${response.invoiceList.join(", ")}`, t) + .then(() => { + window.location.reload() + }) + } + else if (response.duplicateItem.length >= 1){ + errorDialogWithContent(t("Import Fail"), + t(`Please check the corresponding Invoice No. The below invoice has duplicated number.
`)+ `${response.duplicateItem.join(", ")}`, t) + .then(() => { + window.location.reload() + }) + }else if (response.paymentMilestones.length >= 1){ + errorDialogWithContent(t("Import Fail"), + t(`The payment milestone does not match with records. Please check the corresponding Invoice No.
`)+ `${concatListOfObject(response.paymentMilestones)}`, t) + .then(() => { + window.location.reload() + }) + } + } + + } catch (err) { + console.log(err) + return false + } + }, []); + + const handleRecImportClick = useCallback(async (event:any) => { + try { + + const file = event.target.files[0]; + + if (!file) { + console.log('No file selected'); + return; + } - const onProjectClick = useCallback((project: InvoiceResult) => { - console.log(project); - router.push(`/invoice/new?id=${project.id}`) - }, [router, t]); + if (file.type !== 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') { + console.log('Invalid file format. Only XLSX files are allowed.'); + return; + } - const columns = useMemo[]>( + const formData = new FormData(); + formData.append('multipartFileList', file); + + const response = await importReceivedInovice(formData) + console.log(response) + + if (response.status) { + successDialog(t("Import Success"), t).then(() => { + window.location.reload() + }) + }else{ + if (response.emptyRowList.length >= 1){ + errorDialogWithContent(t("Import Fail"), + t(`Please fill the mandatory field at Row
${response.emptyRowList.join(", ")}`), t) + .then(() => { + window.location.reload() + }) + } + else if (response.projectList.length >= 1){ + errorDialogWithContent(t("Import Fail"), + t(`Please check the corresponding project code
${response.projectList.join(", ")}`), t) + .then(() => { + // window.location.reload() + }) + } + else if (response.invoiceList.length >= 1){ + errorDialogWithContent(t("Import Fail"), + t(`Please check the corresponding Invoice No. The invoice has not yet issued.
`)+ `${response.invoiceList.join(", ")}`, t) + .then(() => { + window.location.reload() + }) + } + else if (response.duplicateItem.length >= 1){ + errorDialogWithContent(t("Import Fail"), + t(`Please check the corresponding Invoice No. The below invoice has duplicated number.
`)+ `${response.duplicateItem.join(", ")}`, t) + .then(() => { + window.location.reload() + }) + }else if (response.paymentMilestones.length >= 1){ + errorDialogWithContent(t("Import Fail"), + t(`The payment milestone does not match with records. Please check the corresponding Invoice No.
`)+ `${concatListOfObject2(response.paymentMilestones)}`, t) + .then(() => { + window.location.reload() + }) + } + } + }catch(error){ + console.log(error) + } + + }, []); + + const columns = useMemo[]>( () => [ - { - name: "id", - label: t("Details"), - onClick: onProjectClick, - buttonIcon: , - }, - { name: "projectCode", label: t("Project code") }, - { name: "projectName", label: t("Project name") }, + { name: "invoiceNo", label: t("Invoice No") }, + { name: "projectCode", label: t("Project Code") }, { name: "stage", label: t("Stage") }, - { name: "comingPaymentMileStone", label: t("Coming payment milestone") }, - { name: "paymentMilestoneDate", label: t("Payment date") }, - { name: "resourceUsage", label: t("Resource utilization %") }, - { name: "unbilledHours", label: t("Unbilled hours") }, - { name: "reminder", label: t("Reminder to issue invoice") }, + { name: "paymentMilestone", label: t("Payment Milestone") }, + { name: "invoiceDate", label: t("Invocie Date") }, + { name: "dueDate", label: t("Due Date") }, + { name: "issuedAmount", label: t("Amount (HKD") }, ], - [t, onProjectClick], + [t], + ); + + const columns2 = useMemo[]>( + () => [ + { name: "invoiceNo", label: t("Invoice No") }, + { name: "projectCode", label: t("Project Code") }, + { name: "projectName", label: t("Project Name") }, + { name: "team", label: t("Team") }, + { name: "receiptDate", label: t("Receipt Date") }, + { name: "receivedAmount", label: t("Amount (HKD)") }, + ], + [t], + ); + + function isDateInRange(dateToCheck: string, startDate: string, endDate: string): boolean { + + if (!startDate || !endDate) { + return false; + } + + // console.log(dateToCheck, startDate, endDate) + const dateToCheckObj = new Date(dateToCheck); + const startDateObj = new Date(startDate); + const endDateObj = new Date(endDate); + // console.log(dateToCheckObj >= startDateObj && dateToCheckObj <= endDateObj) + return dateToCheckObj >= startDateObj && dateToCheckObj <= endDateObj; + } + + const handleTabChange = useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [], ); return ( <> - + {/* */} + + + {/* */} + + { + tabIndex == 0 && + { + console.log(query) + setFilteredIssuedInvoices( + issuedInvoice.filter( + (s) => + (isDateInRange(s.invoiceDate, query.invoiceDate ?? undefined, query.invoiceDateTo ?? undefined)) || + (isDateInRange(s.dueDate, query.dueDate ?? undefined, query.dueDateTo ?? undefined)) || + (s.invoiceNo === query.invoiceNo) || + (s.projectCode === query.projectCode) + ), + ); + }} + onReset={onReset} + /> + } + { + tabIndex == 1 && + { console.log(query) - setFilteredInvoices( - invoices.filter( - (d) => - d.projectCode.toLowerCase().includes(query.projectCode.toLowerCase()) && - d.projectName.toLowerCase().includes(query.projectName.toLowerCase()) && - {/*(query.client === "All" || p.client === query.client) && - (query.category === "All" || p.category === query.category) && - (query.team === "All" || p.team === query.team), **/} + setFilteredReceivedInvoices( + receivedInvoice.filter( + (s) => + (isDateInRange(s.receiptDate, query.receiptDate ?? undefined, query.receiptDateTo ?? undefined)) || + (s.invoiceNo === query.invoiceNo) || + (s.projectCode === query.projectCode) ), ); }} onReset={onReset} /> - - items={filteredInvoices} - columns={columns} - /> + } + + + + + { + tabIndex == 0 && + + items={filteredIssuedInvoices} + columns={columns} + /> + } + { + tabIndex == 1 && + + items={filteredReceivedInvoices} + columns={columns2} + /> + } + ); }; diff --git a/src/components/InvoiceSearch/InvoiceSearchLoading.tsx b/src/components/InvoiceSearch/InvoiceSearchLoading.tsx index 927c6c6..e2cc58d 100644 --- a/src/components/InvoiceSearch/InvoiceSearchLoading.tsx +++ b/src/components/InvoiceSearch/InvoiceSearchLoading.tsx @@ -5,7 +5,7 @@ import Stack from "@mui/material/Stack"; import React from "react"; // Can make this nicer -export const InvoiceSearchLoading: React.FC = () => { +export const SalarySearchLoading: React.FC = () => { return ( <> @@ -23,7 +23,7 @@ export const InvoiceSearchLoading: React.FC = () => { - Invoice + Salary @@ -37,4 +37,4 @@ export const InvoiceSearchLoading: React.FC = () => { ); }; -export default InvoiceSearchLoading; +export default SalarySearchLoading; diff --git a/src/components/InvoiceSearch/InvoiceSearchWrapper.tsx b/src/components/InvoiceSearch/InvoiceSearchWrapper.tsx index ba05baf..8680599 100644 --- a/src/components/InvoiceSearch/InvoiceSearchWrapper.tsx +++ b/src/components/InvoiceSearch/InvoiceSearchWrapper.tsx @@ -1,24 +1,55 @@ - +// import { fetchInvoiceCategories, fetchInvoices } from "@/app/api/companys"; import React from "react"; import InvoiceSearch from "./InvoiceSearch"; import InvoiceSearchLoading from "./InvoiceSearchLoading"; -import { fetchInvoices } from "@/app/api/invoices"; -import { timestampToDateString } from "@/app/utils/formatUtil"; +import { fetchIssuedInvoices, fetchReceivedInvoices, issuedInvoiceList, issuedInvoiceResult } from "@/app/api/invoices"; +import { INPUT_DATE_FORMAT, convertDateArrayToString, moneyFormatter } from "@/app/utils/formatUtil"; interface SubComponents { Loading: typeof InvoiceSearchLoading; } -const InvoiceSearchWrapper: React.FC & SubComponents = async () => { - const Invoices = await fetchInvoices(); - - const temp = Invoices.map((invoice) => ({ - ...invoice, - paymentMilestoneDate: timestampToDateString(invoice.paymentMilestoneDate) - })) +// function calculateHourlyRate(loweLimit: number, upperLimit: number, numOfWorkingDay: number, workingHour: number){ +// const hourlyRate = (loweLimit + upperLimit)/2/numOfWorkingDay/workingHour +// return hourlyRate.toLocaleString() +// } - return ; +const InvoiceSearchWrapper: React.FC & SubComponents = async () => { + const issuedInvoices = await fetchIssuedInvoices() + const receivedInvoices = await fetchReceivedInvoices() + + const convertedIssedInvoices = issuedInvoices.map((invoice)=>{ + return{ + id: invoice.id, + invoiceNo: invoice.invoiceNo, + projectCode: invoice.projectCode, + projectName: invoice.projectName, + stage: invoice.stage, + paymentMilestone: invoice.paymentMilestone, + invoiceDate: convertDateArrayToString(invoice.invoiceDate, INPUT_DATE_FORMAT, false)!!, + dueDate: convertDateArrayToString(invoice.dueDate, INPUT_DATE_FORMAT, false)!!, + issuedAmount: moneyFormatter.format(invoice.issuedAmount) + } + }) + + const convertedReceivedInvoices = receivedInvoices.map((invoice)=>{ + return{ + id: invoice.id, + invoiceNo: invoice.invoiceNo, + projectCode: invoice.projectCode, + projectName: invoice.projectName, + team: invoice.team, + receiptDate: convertDateArrayToString(invoice.receiptDate, INPUT_DATE_FORMAT, false)!!, + receivedAmount: moneyFormatter.format(invoice.receivedAmount) + } + }) + + return + }; InvoiceSearchWrapper.Loading = InvoiceSearchLoading; diff --git a/src/components/InvoiceSearch_forGen/InvoiceSearch.tsx b/src/components/InvoiceSearch_forGen/InvoiceSearch.tsx new file mode 100644 index 0000000..52dbdd5 --- /dev/null +++ b/src/components/InvoiceSearch_forGen/InvoiceSearch.tsx @@ -0,0 +1,104 @@ +"use client"; + +import React, { useCallback, useMemo, useState } from "react"; +import SearchBox, { Criterion } from "../SearchBox"; +import { useTranslation } from "react-i18next"; +import SearchResults, { Column } from "../SearchResults"; +import EditNote from "@mui/icons-material/EditNote"; +import { InvoiceResult } from "@/app/api/invoices"; +import { useRouter } from "next/navigation"; + +interface Props { + invoices: InvoiceResult[]; +} + +type SearchQuery = Partial>; +type SearchParamNames = keyof SearchQuery; + +const InvoiceSearch: React.FC = ({ invoices }) => { + const { t } = useTranslation("invoices"); + const router = useRouter(); + + const [filteredInvoices, setFilteredInvoices] = useState(invoices); + + const searchCriteria: Criterion[] = useMemo( + () => [ + { label: t("Project code"), paramName: "projectCode", type: "text" }, + { label: t("Project name"), paramName: "projectName", type: "text" }, + // { label: t("Stage"), paramName: "stage", type: "text" }, + { + label: t("Coming payment milestone from"), + label2: t("Coming payment milestone to"), + paramName: "comingPaymentMileStone", + type: "dateRange" + }, + { + label: t("Payment date from"), + label2: t("Payment date to"), + paramName: "paymentMilestoneDate", + type: "dateRange" + }, + // { label: t("Resource utilization %"), paramName: "resourceUsage", type: "text" }, + // { label: t("Unbilled hours"), paramName: "unbilledHours", type: "text" }, + // { label: t("Reminder to issue invoice"), paramName: "reminder", type: "text" }, + ], + [t, invoices], + ); + + const onReset = useCallback(() => { + setFilteredInvoices(invoices); + }, [invoices]); + + const onProjectClick = useCallback((project: InvoiceResult) => { + console.log(project); + router.push(`/invoice/new?id=${project.id}`) + }, [router, t]); + + const columns = useMemo[]>( + () => [ + { + name: "id", + label: t("Details"), + onClick: onProjectClick, + buttonIcon: , + }, + { name: "projectCode", label: t("Project code") }, + { name: "projectName", label: t("Project name") }, + { name: "stage", label: t("Stage") }, + { name: "comingPaymentMileStone", label: t("Coming payment milestone") }, + { name: "paymentMilestoneDate", label: t("Payment date") }, + { name: "resourceUsage", label: t("Resource utilization %") }, + { name: "unbilledHours", label: t("Unbilled hours") }, + { name: "reminder", label: t("Reminder to issue invoice") }, + ], + [t, onProjectClick], + ); + + return ( + <> + { + console.log(query) + setFilteredInvoices( + invoices.filter( + (d) => + d.projectCode.toLowerCase().includes(query.projectCode.toLowerCase()) && + d.projectName.toLowerCase().includes(query.projectName.toLowerCase()) && + {/*(query.client === "All" || p.client === query.client) && + (query.category === "All" || p.category === query.category) && + (query.team === "All" || p.team === query.team), **/} + ), + ); + }} + onReset={onReset} + /> + + items={filteredInvoices} + columns={columns} + /> + + ); +}; + +export default InvoiceSearch; diff --git a/src/components/InvoiceSearch_forGen/InvoiceSearchLoading.tsx b/src/components/InvoiceSearch_forGen/InvoiceSearchLoading.tsx new file mode 100644 index 0000000..927c6c6 --- /dev/null +++ b/src/components/InvoiceSearch_forGen/InvoiceSearchLoading.tsx @@ -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 InvoiceSearchLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + Invoice + + + + + + + + + + + ); +}; + +export default InvoiceSearchLoading; diff --git a/src/components/InvoiceSearch_forGen/InvoiceSearchWrapper.tsx b/src/components/InvoiceSearch_forGen/InvoiceSearchWrapper.tsx new file mode 100644 index 0000000..ba05baf --- /dev/null +++ b/src/components/InvoiceSearch_forGen/InvoiceSearchWrapper.tsx @@ -0,0 +1,26 @@ + +import React from "react"; +import InvoiceSearch from "./InvoiceSearch"; +import InvoiceSearchLoading from "./InvoiceSearchLoading"; +import { fetchInvoices } from "@/app/api/invoices"; +import { timestampToDateString } from "@/app/utils/formatUtil"; + + +interface SubComponents { + Loading: typeof InvoiceSearchLoading; +} + +const InvoiceSearchWrapper: React.FC & SubComponents = async () => { + const Invoices = await fetchInvoices(); + + const temp = Invoices.map((invoice) => ({ + ...invoice, + paymentMilestoneDate: timestampToDateString(invoice.paymentMilestoneDate) + })) + + return ; +}; + +InvoiceSearchWrapper.Loading = InvoiceSearchLoading; + +export default InvoiceSearchWrapper; diff --git a/src/components/InvoiceSearch_forGen/index.ts b/src/components/InvoiceSearch_forGen/index.ts new file mode 100644 index 0000000..14315af --- /dev/null +++ b/src/components/InvoiceSearch_forGen/index.ts @@ -0,0 +1 @@ +export { default } from "./InvoiceSearchWrapper"; diff --git a/src/components/LeaveModal/LeaveModal.tsx b/src/components/LeaveModal/LeaveModal.tsx new file mode 100644 index 0000000..a6a551c --- /dev/null +++ b/src/components/LeaveModal/LeaveModal.tsx @@ -0,0 +1,180 @@ +import React, { useCallback, useMemo } from "react"; +import { + Box, + Button, + Card, + CardActions, + CardContent, + Modal, + ModalProps, + SxProps, + Typography, + useMediaQuery, + useTheme, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { Check, Close } from "@mui/icons-material"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { + RecordLeaveInput, + RecordTimesheetInput, + saveLeave, +} from "@/app/api/timesheets/actions"; +import dayjs from "dayjs"; +import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; +import LeaveTable from "../LeaveTable"; +import { LeaveType } from "@/app/api/timesheets"; +import FullscreenModal from "../FullscreenModal"; +import MobileLeaveTable from "../LeaveTable/MobileLeaveTable"; + +interface Props { + isOpen: boolean; + onClose: () => void; + username: string; + defaultLeaveRecords?: RecordLeaveInput; + leaveTypes: LeaveType[]; + timesheetRecords: RecordTimesheetInput; +} + +const modalSx: SxProps = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: { xs: "calc(100% - 2rem)", sm: "90%" }, + maxHeight: "90%", + maxWidth: 1400, +}; + +const LeaveModal: React.FC = ({ + isOpen, + onClose, + username, + defaultLeaveRecords, + timesheetRecords, + leaveTypes, +}) => { + const { t } = useTranslation("home"); + + const defaultValues = useMemo(() => { + const today = dayjs(); + return Array(7) + .fill(undefined) + .reduce((acc, _, index) => { + const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); + return { + ...acc, + [date]: defaultLeaveRecords?.[date] ?? [], + }; + }, {}); + }, [defaultLeaveRecords]); + + const formProps = useForm({ defaultValues }); + + const onSubmit = useCallback>( + async (data) => { + const savedRecords = await saveLeave(data, username); + + const today = dayjs(); + const newFormValues = Array(7) + .fill(undefined) + .reduce((acc, _, index) => { + const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); + return { + ...acc, + [date]: savedRecords[date] ?? [], + }; + }, {}); + + formProps.reset(newFormValues); + onClose(); + }, + [formProps, onClose, username], + ); + + const onCancel = useCallback(() => { + formProps.reset(defaultValues); + onClose(); + }, [defaultValues, formProps, onClose]); + + const onModalClose = useCallback>( + (_, reason) => { + if (reason !== "backdropClick") { + onCancel(); + } + }, + [onCancel], + ); + + const theme = useTheme(); + const matches = useMediaQuery(theme.breakpoints.up("sm")); + + return ( + + {matches ? ( + // Desktop version + + + + + {t("Record Leave")} + + + + + + + + + + + + ) : ( + // Mobile version + + + + {t("Record Leave")} + + + + + )} + + ); +}; + +export default LeaveModal; diff --git a/src/components/LeaveModal/index.ts b/src/components/LeaveModal/index.ts new file mode 100644 index 0000000..cd099c7 --- /dev/null +++ b/src/components/LeaveModal/index.ts @@ -0,0 +1 @@ +export { default } from "./LeaveModal"; diff --git a/src/components/LeaveTable/LeaveEditModal.tsx b/src/components/LeaveTable/LeaveEditModal.tsx new file mode 100644 index 0000000..c176930 --- /dev/null +++ b/src/components/LeaveTable/LeaveEditModal.tsx @@ -0,0 +1,132 @@ +import { LeaveType } from "@/app/api/timesheets"; +import { LeaveEntry } from "@/app/api/timesheets/actions"; +import { Check, Delete } from "@mui/icons-material"; +import { + Box, + Button, + FormControl, + InputLabel, + MenuItem, + Modal, + ModalProps, + Paper, + Select, + SxProps, + TextField, +} from "@mui/material"; +import React, { useCallback, useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +export interface Props extends Omit { + onSave: (leaveEntry: LeaveEntry) => void; + onDelete?: () => void; + leaveTypes: LeaveType[]; + defaultValues?: Partial; +} + +const modalSx: SxProps = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: "90%", + maxHeight: "90%", + padding: 3, + display: "flex", + flexDirection: "column", + gap: 2, +}; +const LeaveEditModal: React.FC = ({ + onSave, + onDelete, + open, + onClose, + leaveTypes, + defaultValues, +}) => { + const { t } = useTranslation("home"); + const { register, control, reset, getValues, trigger, formState } = + useForm(); + + useEffect(() => { + reset(defaultValues ?? { leaveTypeId: leaveTypes[0].id, id: Date.now() }); + }, [defaultValues, leaveTypes, reset]); + + const saveHandler = useCallback(async () => { + const valid = await trigger(); + if (valid) { + onSave(getValues()); + } + }, [getValues, onSave, trigger]); + + const closeHandler = useCallback>( + (...args) => { + onClose?.(...args); + reset(); + }, + [onClose, reset], + ); + + return ( + + + + {t("Leave Type")} + ( + + )} + /> + + value > 0, + })} + error={Boolean(formState.errors.inputHours)} + /> + + + {onDelete && ( + + )} + + + + + ); +}; + +export default LeaveEditModal; diff --git a/src/components/LeaveTable/LeaveEntryTable.tsx b/src/components/LeaveTable/LeaveEntryTable.tsx new file mode 100644 index 0000000..9fb8172 --- /dev/null +++ b/src/components/LeaveTable/LeaveEntryTable.tsx @@ -0,0 +1,286 @@ +import { Add, Check, Close, Delete } from "@mui/icons-material"; +import { Box, Button, Typography } from "@mui/material"; +import { + FooterPropsOverrides, + GridActionsCellItem, + GridColDef, + GridEventListener, + GridRowId, + GridRowModel, + GridRowModes, + GridRowModesModel, + GridToolbarContainer, + useGridApiRef, +} from "@mui/x-data-grid"; +import { useTranslation } from "react-i18next"; +import StyledDataGrid from "../StyledDataGrid"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { RecordLeaveInput, LeaveEntry } from "@/app/api/timesheets/actions"; +import { manhourFormatter } from "@/app/utils/formatUtil"; +import dayjs from "dayjs"; +import isBetween from "dayjs/plugin/isBetween"; +import { LeaveType } from "@/app/api/timesheets"; +import { isValidLeaveEntry } from "@/app/api/timesheets/utils"; + +dayjs.extend(isBetween); + +interface Props { + day: string; + leaveTypes: LeaveType[]; +} + +type LeaveEntryRow = Partial< + LeaveEntry & { + _isNew: boolean; + _error: string; + } +>; + +const EntryInputTable: React.FC = ({ day, leaveTypes }) => { + const { t } = useTranslation("home"); + + const { getValues, setValue } = useFormContext(); + const currentEntries = getValues(day); + + const [entries, setEntries] = useState(currentEntries || []); + + const [rowModesModel, setRowModesModel] = useState({}); + + const apiRef = useGridApiRef(); + const addRow = useCallback(() => { + const id = Date.now(); + setEntries((e) => [...e, { id, _isNew: true }]); + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.Edit, fieldToFocus: "leaveTypeId" }, + })); + }, []); + + const validateRow = useCallback( + (id: GridRowId) => { + const row = apiRef.current.getRowWithUpdatedValues( + id, + "", + ) as LeaveEntryRow; + + const error = isValidLeaveEntry(row); + + apiRef.current.updateRows([{ id, _error: error }]); + return !error; + }, + [apiRef], + ); + + const handleCancel = useCallback( + (id: GridRowId) => () => { + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.View, ignoreModifications: true }, + })); + const editedRow = entries.find((entry) => entry.id === id); + if (editedRow?._isNew) { + setEntries((es) => es.filter((e) => e.id !== id)); + } + }, + [entries], + ); + + const handleDelete = useCallback( + (id: GridRowId) => () => { + setEntries((es) => es.filter((e) => e.id !== id)); + }, + [], + ); + + const handleSave = useCallback( + (id: GridRowId) => () => { + if (validateRow(id)) { + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.View }, + })); + } + }, + [validateRow], + ); + + const handleEditStop = useCallback>( + (params, event) => { + if (!validateRow(params.id)) { + event.defaultMuiPrevented = true; + } + }, + [validateRow], + ); + + const processRowUpdate = useCallback((newRow: GridRowModel) => { + const updatedRow = { ...newRow, _isNew: false }; + setEntries((es) => es.map((e) => (e.id === newRow.id ? updatedRow : e))); + return updatedRow; + }, []); + + const columns = useMemo( + () => [ + { + type: "actions", + field: "actions", + headerName: t("Actions"), + getActions: ({ id }) => { + if (rowModesModel[id]?.mode === GridRowModes.Edit) { + return [ + } + label={t("Save")} + onClick={handleSave(id)} + />, + } + label={t("Cancel")} + onClick={handleCancel(id)} + />, + ]; + } + + return [ + } + label={t("Remove")} + onClick={handleDelete(id)} + />, + ]; + }, + }, + { + field: "leaveTypeId", + headerName: t("Leave Type"), + width: 200, + editable: true, + type: "singleSelect", + valueOptions() { + return leaveTypes.map((p) => ({ value: p.id, label: p.name })); + }, + valueGetter({ value }) { + return value ?? ""; + }, + }, + { + field: "inputHours", + headerName: t("Leave Hours"), + width: 150, + editable: true, + type: "number", + valueFormatter(params) { + return manhourFormatter.format(params.value); + }, + }, + { + field: "remark", + headerName: t("Remark"), + sortable: false, + flex: 1, + editable: true, + }, + ], + [t, rowModesModel, handleDelete, handleSave, handleCancel, leaveTypes], + ); + + useEffect(() => { + setValue(day, [ + ...entries + .filter( + (e) => + !e._isNew && !e._error && e.inputHours && e.leaveTypeId && e.id, + ) + .map((e) => ({ + id: e.id!, + inputHours: e.inputHours!, + leaveTypeId: e.leaveTypeId!, + remark: e.remark, + })), + ]); + }, [getValues, entries, setValue, day]); + + const footer = ( + + + + ); + + return ( + { + let classname = ""; + if (params.row._error === params.field) { + classname = "hasError"; + } else if ( + params.field === "taskGroupId" && + params.row.isPlanned !== undefined && + !params.row.isPlanned + ) { + classname = "hasWarning"; + } + return classname; + }} + slots={{ + footer: FooterToolbar, + noRowsOverlay: NoRowsOverlay, + }} + slotProps={{ + footer: { child: footer }, + }} + /> + ); +}; + +const NoRowsOverlay: React.FC = () => { + const { t } = useTranslation("home"); + return ( + + {t("Add some leave entries!")} + + ); +}; + +const FooterToolbar: React.FC = ({ child }) => { + return {child}; +}; + +export default EntryInputTable; diff --git a/src/components/LeaveTable/LeaveTable.tsx b/src/components/LeaveTable/LeaveTable.tsx new file mode 100644 index 0000000..ce71d12 --- /dev/null +++ b/src/components/LeaveTable/LeaveTable.tsx @@ -0,0 +1,32 @@ +import { + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import React from "react"; +import { useFormContext } from "react-hook-form"; +import LeaveEntryTable from "./LeaveEntryTable"; +import { LeaveType } from "@/app/api/timesheets"; +import DateHoursTable from "../DateHoursTable"; + +interface Props { + leaveTypes: LeaveType[]; + timesheetRecords: RecordTimesheetInput; +} + +const LeaveTable: React.FC = ({ leaveTypes, timesheetRecords }) => { + const { watch } = useFormContext(); + const currentInput = watch(); + const days = Object.keys(currentInput); + + return ( + + ); +}; + +export default LeaveTable; diff --git a/src/components/LeaveTable/MobileLeaveEntry.tsx b/src/components/LeaveTable/MobileLeaveEntry.tsx new file mode 100644 index 0000000..90fd19a --- /dev/null +++ b/src/components/LeaveTable/MobileLeaveEntry.tsx @@ -0,0 +1,179 @@ +import { LeaveType } from "@/app/api/timesheets"; +import { LeaveEntry, RecordLeaveInput } from "@/app/api/timesheets/actions"; +import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; +import { Add, Edit } from "@mui/icons-material"; +import { + Box, + Button, + Card, + CardContent, + IconButton, + Typography, +} from "@mui/material"; +import dayjs from "dayjs"; +import React, { useCallback, useMemo, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import LeaveEditModal, { Props as LeaveEditModalProps } from "./LeaveEditModal"; + +interface Props { + date: string; + leaveTypes: LeaveType[]; +} + +const MobileLeaveEntry: React.FC = ({ date, leaveTypes }) => { + const { + t, + i18n: { language }, + } = useTranslation("home"); + const dayJsObj = dayjs(date); + + const leaveTypeMap = useMemo<{ [id: LeaveType["id"]]: LeaveType }>(() => { + return leaveTypes.reduce( + (acc, leaveType) => ({ ...acc, [leaveType.id]: leaveType }), + {}, + ); + }, [leaveTypes]); + + const { watch, setValue } = useFormContext(); + const currentEntries = watch(date); + + // Edit modal + const [editModalProps, setEditModalProps] = useState< + Partial + >({}); + const [editModalOpen, setEditModalOpen] = useState(false); + + const openEditModal = useCallback( + (defaultValues?: LeaveEntry) => () => { + setEditModalProps({ + defaultValues, + onDelete: defaultValues + ? () => { + setValue( + date, + currentEntries.filter((entry) => entry.id !== defaultValues.id), + ); + setEditModalOpen(false); + } + : undefined, + }); + setEditModalOpen(true); + }, + [currentEntries, date, setValue], + ); + + const closeEditModal = useCallback(() => { + setEditModalOpen(false); + }, []); + + const onSaveEntry = useCallback( + (entry: LeaveEntry) => { + const existingEntry = currentEntries.find((e) => e.id === entry.id); + if (existingEntry) { + setValue( + date, + currentEntries.map((e) => ({ + ...(e.id === existingEntry.id ? entry : e), + })), + ); + } else { + setValue(date, [...currentEntries, entry]); + } + setEditModalOpen(false); + }, + [currentEntries, date, setValue], + ); + + return ( + + + {shortDateFormatter(language).format(dayJsObj.toDate())} + + {currentEntries.length ? ( + currentEntries.map((entry, index) => { + return ( + + + + + + {leaveTypeMap[entry.leaveTypeId].name} + + + {manhourFormatter.format(entry.inputHours)} + + + + + + + {entry.remark && ( + + + {t("Remark")} + + {entry.remark} + + )} + + + ); + }) + ) : ( + + {t("Add some leave entries!")} + + )} + + + + + + ); +}; + +export default MobileLeaveEntry; diff --git a/src/components/LeaveTable/MobileLeaveTable.tsx b/src/components/LeaveTable/MobileLeaveTable.tsx new file mode 100644 index 0000000..0bafd30 --- /dev/null +++ b/src/components/LeaveTable/MobileLeaveTable.tsx @@ -0,0 +1,35 @@ +import { + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import React from "react"; +import { useFormContext } from "react-hook-form"; +import { LeaveType } from "@/app/api/timesheets"; +import MobileLeaveEntry from "./MobileLeaveEntry"; +import DateHoursList from "../DateHoursTable/DateHoursList"; + +interface Props { + leaveTypes: LeaveType[]; + timesheetRecords: RecordTimesheetInput; +} + +const MobileLeaveTable: React.FC = ({ + timesheetRecords, + leaveTypes, +}) => { + const { watch } = useFormContext(); + const currentInput = watch(); + const days = Object.keys(currentInput); + + return ( + + ); +}; + +export default MobileLeaveTable; diff --git a/src/components/LeaveTable/index.ts b/src/components/LeaveTable/index.ts new file mode 100644 index 0000000..9aeb679 --- /dev/null +++ b/src/components/LeaveTable/index.ts @@ -0,0 +1 @@ +export { default } from "./LeaveTable"; diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index cb171fa..36beaed 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -23,6 +23,7 @@ import Department from '@mui/icons-material/Diversity3'; import Position from '@mui/icons-material/Paragliding'; import Salary from '@mui/icons-material/AttachMoney'; import Team from '@mui/icons-material/Paragliding'; +import Holiday from '@mui/icons-material/CalendarMonth'; import { useTranslation } from "react-i18next"; import Typography from "@mui/material/Typography"; import { usePathname } from "next/navigation"; @@ -34,113 +35,122 @@ import BusinessIcon from '@mui/icons-material/Business'; import ViewWeekIcon from '@mui/icons-material/ViewWeek'; import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; - +import { GENERATE_REPORTS, MAINTAIN_MASTERDATA, MAINTAIN_USER, VIEW_MASTERDATA, VIEW_USER } from "@/middleware"; +import { SessionWithAbilities } from "../AppBar/NavigationToggle"; +import { authOptions } from "@/config/authConfig"; +import { getServerSession } from "next-auth"; interface NavigationItem { icon: React.ReactNode; label: string; path: string; + isHidden?: boolean; children?: NavigationItem[]; } -const navigationItems: NavigationItem[] = [ - { icon: , label: "User Workspace", path: "/home" }, - { - icon: , - label: "Dashboard", - path: "", - children: [ - { - icon: , - label: "Financial Summary", - path: "/dashboard/ProjectFinancialSummary", - }, - { - icon: , - label: "Company / Team Cash Flow", - path: "/dashboard/CompanyTeamCashFlow", - }, - { - icon: , - label: "Project Cash Flow", - path: "/dashboard/ProjectCashFlow", - }, - { - icon: , - label: "Project Status by Client", - path: "/dashboard/ProjectStatusByClient", - }, - { - icon: , - label: "Project Status by Team", - path: "/dashboard/ProjectStatusByTeam", - }, - { - icon: , - label: "Staff Utilization", - path: "/dashboard/StaffUtilization", - }, - { - icon: , - label: "Project Resource Summary", - path: "/dashboard/ProjectResourceSummary", - } - ], - }, - { - icon: , - label: "Staff Reimbursement", - path: "/staffReimbursement", - children: [ - { - icon: , - label: "Claim Approval", - path: "/staffReimbursement/ClaimApproval", - }, - { - icon: , - label: "Claim Summary", - path: "/staffReimbursement/ClaimSummary", - }, - ], - }, - { icon: , label: "Project Management", path: "/projects" }, - { icon: , label: "Task Template", path: "/tasks" }, - { icon: , label: "Invoice", path: "/invoice" }, - { icon: , label: "Analysis Report", path: "", - children: [ - {icon: , label:"Late Start Report", path: "/analytics/LateStartReport"}, - {icon: , label:"Delay Report", path: "/analytics/DelayReport"}, - {icon: , label:"Resource Overconsumption Report", path: "/analytics/ResourceOverconsumptionReport"}, - {icon: , label:"Cost and Expense Report", path: "/analytics/CostandExpenseReport"}, - {icon: , label:"Completion Report", path: "/analytics/ProjectCompletionReport"}, - {icon: , label:"Completion Report with Outstanding Un-billed Hours Report", path: "/analytics/ProjectCompletionReportWO"}, - {icon: , label:"Project Claims Report", path: "/analytics/ProjectClaimsReport"}, - {icon: , label:"Project P&L Report", path: "/analytics/ProjectPLReport"}, - {icon: , label:"Financial Status Report", path: "/analytics/FinancialStatusReport"}, - {icon: , label:"EX02 - Project Cash Flow Report", path: "/analytics/EX02ProjectCashFlowReport"}, - ], -}, - { - icon: , label: "Setting", path: "", - children: [ - { icon: , label: "Client", path: "/settings/customer" }, - { icon: , label: "Subsidiary", path: "/settings/subsidiary" }, - { icon: , label: "Staff", path: "/settings/staff" }, - { icon: , label: "Company", path: "/settings/company" }, - { icon: , label: "Skill", path: "/settings/skill" }, - { icon: , label: "Department", path: "/settings/department" }, - { icon: , label: "Position", path: "/settings/position" }, - { icon: , label: "Salary", path: "/settings/salary" }, - { icon: , label: "Team", path: "/settings/team" }, - { icon: , label: "User", path: "/settings/user" }, +interface Props { + abilities?: string[] +} + +const NavigationContent: React.FC = ({ abilities }) => { + const navigationItems: NavigationItem[] = [ + { icon: , label: "User Workspace", path: "/home" }, + { + icon: , + label: "Dashboard", + path: "", + children: [ + { + icon: , + label: "Financial Summary", + path: "/dashboard/ProjectFinancialSummary", + }, + { + icon: , + label: "Company / Team Cash Flow", + path: "/dashboard/CompanyTeamCashFlow", + }, + { + icon: , + label: "Project Cash Flow", + path: "/dashboard/ProjectCashFlow", + }, + { + icon: , + label: "Project Status by Client", + path: "/dashboard/ProjectStatusByClient", + }, + { + icon: , + label: "Project Status by Team", + path: "/dashboard/ProjectStatusByTeam", + }, + { + icon: , + label: "Staff Utilization", + path: "/dashboard/StaffUtilization", + }, + { + icon: , + label: "Project Resource Summary", + path: "/dashboard/ProjectResourceSummary", + } + ], + }, + { + icon: , + label: "Staff Reimbursement", + path: "/staffReimbursement", + children: [ + { + icon: , + label: "Claim Approval", + path: "/staffReimbursement/ClaimApproval", + }, + { + icon: , + label: "Claim Summary", + path: "/staffReimbursement/ClaimSummary", + }, + ], + }, + { icon: , label: "Project Management", path: "/projects" }, + { icon: , label: "Task Template", path: "/tasks" }, + { icon: , label: "Invoice", path: "/invoice" }, + { icon: , label: "Analysis Report", path: "", isHidden: ![GENERATE_REPORTS].some((ability) => abilities!!.includes(ability)), + children: [ + {icon: , label:"Late Start Report", path: "/analytics/LateStartReport"}, + {icon: , label:"Delay Report", path: "/analytics/DelayReport"}, + {icon: , label:"Resource Overconsumption Report", path: "/analytics/ResourceOverconsumptionReport"}, + {icon: , label:"Cost and Expense Report", path: "/analytics/CostandExpenseReport"}, + {icon: , label:"Completion Report", path: "/analytics/ProjectCompletionReport"}, + {icon: , label:"Completion Report with Outstanding Un-billed Hours Report", path: "/analytics/ProjectCompletionReportWO"}, + {icon: , label:"Project Claims Report", path: "/analytics/ProjectClaimsReport"}, + {icon: , label:"Project P&L Report", path: "/analytics/ProjectPLReport"}, + {icon: , label:"Financial Status Report", path: "/analytics/FinancialStatusReport"}, + {icon: , label:"Project Cash Flow Report", path: "/analytics/ProjectCashFlowReport"}, ], }, -]; + { + icon: , label: "Setting", path: "", isHidden: ![VIEW_MASTERDATA, MAINTAIN_MASTERDATA].some((ability) => abilities!!.includes(ability)), + children: [ + { icon: , label: "Client", path: "/settings/customer" }, + { icon: , label: "Subsidiary", path: "/settings/subsidiary" }, + { icon: , label: "Staff", path: "/settings/staff" }, + { icon: , label: "Company", path: "/settings/company" }, + { icon: , label: "Skill", path: "/settings/skill" }, + { icon: , label: "Department", path: "/settings/department" }, + { icon: , label: "Position", path: "/settings/position" }, + { icon: , label: "Salary", path: "/settings/salary" }, + { icon: , label: "Team", path: "/settings/team" }, + { icon: , label: "User", path: "/settings/user" }, + { icon: , label: "User Group", path: "/settings/group" }, + { icon: , label: "Holiday", path: "/settings/holiday" }, + ], + }, + ]; -const NavigationContent: React.FC = () => { const { t } = useTranslation("common"); const pathname = usePathname(); - const [openItems, setOpenItems] = React.useState([]); const toggleItem = (label: string) => { setOpenItems((prevOpenItems) => @@ -186,7 +196,7 @@ const NavigationContent: React.FC = () => {
- {navigationItems.map((item) => renderNavigationItem(item))} + {navigationItems.filter(item => item.isHidden !== true).map((item) => renderNavigationItem(item))} {/* {navigationItems.map(({ icon, label, path }, index) => { return ( = ({ positions }) => { setFilteredPositions(positions); }, [positions]); - const onPositionClick = useCallback((project: PositionResult) => { - console.log(project); - const id = project.id + const onPositionClick = useCallback((position: PositionResult) => { + console.log(position); + const id = position.id router.push(`/settings/position/edit?id=${id}`); }, []); + const onDeleteClick = useCallback((position: PositionResult) => { + + deleteDialog(async() => { + await deletePosition(position.id) + + successDialog("Delete Success", t) + + setFilteredPositions((prev) => prev.filter((obj) => obj.id !== position.id)) + }, t) +}, []); + const columns = useMemo[]>( () => [ { @@ -51,6 +65,13 @@ const PositionSearch: React.FC = ({ positions }) => { { name: "code", label: t("Position Code") }, { name: "name", label: t("Position Name") }, { name: "description", label: t("Position Description") }, + { + name: "id", + label: t("Delete"), + onClick: onDeleteClick, + buttonIcon: , + color: "error" + }, ], [t, onPositionClick], ); diff --git a/src/components/ProjectCashFlow/ProjectCashFlow.tsx b/src/components/ProjectCashFlow/ProjectCashFlow.tsx index ac17e2f..ef81aff 100644 --- a/src/components/ProjectCashFlow/ProjectCashFlow.tsx +++ b/src/components/ProjectCashFlow/ProjectCashFlow.tsx @@ -27,6 +27,9 @@ const ProjectCashFlow: React.FC = () => { const [cashFlowYear, setCashFlowYear]: any[] = React.useState( todayDate.getFullYear(), ); + const [anticipateCashFlowYear, setAnticipateCashFlowYear]: any[] = React.useState( + todayDate.getFullYear(), + ); const columns = [ { id: "projectCode", @@ -236,6 +239,113 @@ const ProjectCashFlow: React.FC = () => { ], }; + const anticipateOptions: ApexOptions = { + chart: { + height: 350, + type: "line", + }, + stroke: { + width: [0, 0, 2, 2], + }, + plotOptions: { + bar: { + horizontal: false, + distributed: false, + }, + }, + dataLabels: { + enabled: false, + }, + xaxis: { + categories: [ + "Q1", + "Q2", + "Q3", + "Q4", + "Q5", + "Q6", + "Q7", + "Q8", + "Q9", + "Q10", + "Q11", + "Q12", + ], + }, + yaxis: [ + { + title: { + text: "Anticipate Monthly Income and Expenditure(HKD)", + }, + min: 0, + max: 350000, + tickAmount: 5, + labels: { + formatter: function (val) { + return val.toLocaleString() + } + } + }, + { + show: false, + seriesName: "Monthly_Expenditure", + title: { + text: "Monthly Expenditure (HKD)", + }, + min: 0, + max: 350000, + tickAmount: 5, + }, + { + seriesName: "Cumulative_Income", + opposite: true, + title: { + text: "Cumulative Income and Expenditure(HKD)", + }, + min: 0, + max: 850000, + tickAmount: 5, + labels: { + formatter: function (val) { + return val.toLocaleString() + } + } + }, + { + show: false, + seriesName: "Cumulative_Expenditure", + opposite: true, + title: { + text: "Cumulative Expenditure (HKD)", + }, + min: 0, + max: 850000, + tickAmount: 5, + }, + ], + grid: { + borderColor: "#f1f1f1", + }, + annotations: {}, + series: [ + { + name: "Monthly_Income", + type: "column", + color: "#f1c48a", + data: [0, 110000, 0, 0, 185000, 0, 0, 189000, 0, 0, 300000, 0], + }, + { + name: "Monthly_Expenditure", + type: "column", + color: "#89d7f3", + data: [ + 60000, 60000, 60000, 60000, 60000, 60000, 60000, 60000, 60000, 60000, + 60000, 60000, + ], + } + ], + }; + const accountsReceivableOptions: ApexOptions = { colors: ["#20E647"], series: [80], @@ -624,6 +734,48 @@ const ProjectCashFlow: React.FC = () => { }} > + + +
+
+ + +
+
+ +
+
+ +
+ +
+
>; type SearchParamNames = keyof SearchQuery; const ProjectSearch: React.FC = ({ projects, projectCategories }) => { + const router = useRouter(); const { t } = useTranslation("projects"); const [filteredProjects, setFilteredProjects] = useState(projects); @@ -51,9 +53,12 @@ const ProjectSearch: React.FC = ({ projects, projectCategories }) => { setFilteredProjects(projects); }, [projects]); - const onProjectClick = useCallback((project: ProjectResult) => { - console.log(project); - }, []); + const onProjectClick = useCallback( + (project: ProjectResult) => { + router.push(`/projects/edit?id=${project.id}`); + }, + [router], + ); const columns = useMemo[]>( () => [ diff --git a/src/components/SalarySearch/SalarySearch.tsx b/src/components/SalarySearch/SalarySearch.tsx index e7469fd..5c3a49d 100644 --- a/src/components/SalarySearch/SalarySearch.tsx +++ b/src/components/SalarySearch/SalarySearch.tsx @@ -7,6 +7,12 @@ import SearchResults, { Column } from "../SearchResults"; import EditNote from "@mui/icons-material/EditNote"; import { SalaryResult } from "@/app/api/salarys"; import { convertLocaleStringToNumber } from "@/app/utils/formatUtil" +import { Button, ButtonGroup, Stack } from "@mui/material"; +import FileDownloadIcon from '@mui/icons-material/FileDownload'; +import FileUploadIcon from '@mui/icons-material/FileUpload'; +import { exportSalary, importSalarys } from "@/app/api/salarys/actions"; +import { downloadFile } from "@/app/utils/commonUtil"; +import { errorDialog, successDialog } from "../Swal/CustomAlerts"; interface Props { salarys: SalaryResult[]; @@ -32,18 +38,67 @@ const SalarySearch: React.FC = ({ salarys }) => { setFilteredSalarys(salarys); }, [salarys]); - const onSalaryClick = useCallback((project: SalaryResult) => { - console.log(project); + const onSalaryClick = useCallback((salary: SalaryResult) => { + console.log(salary); + }, []); + + const handleImportClick = useCallback(async (event:any) => { + // console.log(event) + try { + + const file = event.target.files[0]; + + if (!file) { + console.log('No file selected'); + return; + } + + if (file.type !== 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') { + console.log('Invalid file format. Only XLSX files are allowed.'); + return; + } + + const formData = new FormData(); + formData.append('multipartFileList', file); + + const response = await importSalarys(formData); + + if (response === "OK") { + successDialog(t("Import Success"), t).then(()=>{ + window.location.reload() + }) + }else{ + errorDialog(t("Import Fail")).then(()=>{ + window.location.reload() + }) + } + + } catch (err) { + console.log(err) + return false + } + }, []); + + const handleExportClick = useCallback(async (event:any) => { + // console.log(event); + try{ + const response = await exportSalary() + if (response) { + downloadFile(new Uint8Array(response.blobValue), response.filename!!) + } + }catch(error){ + console.log(error) + } }, []); const columns = useMemo[]>( () => [ - { - name: "id", - label: t("Details"), - onClick: onSalaryClick, - buttonIcon: , - }, + // { + // name: "id", + // label: t("Details"), + // onClick: onSalaryClick, + // buttonIcon: , + // }, { name: "salaryPoint", label: t("Salary Point") }, { name: "lowerLimit", label: t("Lower Limit") }, { name: "upperLimit", label: t("Upper Limit") }, @@ -54,15 +109,44 @@ const SalarySearch: React.FC = ({ salarys }) => { return ( <> + + + + + + { + // console.log(Number(query.salaryPoint)) setFilteredSalarys( salarys.filter( (s) => - ((convertLocaleStringToNumber(s.lowerLimit) <= Number(query.salary))&& - (convertLocaleStringToNumber(s.upperLimit) >= Number(query.salary)))|| - (s.salaryPoint === Number(query.salaryPoint)) + { + // console.log(s) + return ( + ((convertLocaleStringToNumber(s.lowerLimit) <= Number(query.salary))&& + (convertLocaleStringToNumber(s.upperLimit) >= Number(query.salary)))|| + (s.salaryPoint === Number(query.salaryPoint)) + ) + } + ), ); }} diff --git a/src/components/SalarySearch/SalarySearchWrapper.tsx b/src/components/SalarySearch/SalarySearchWrapper.tsx index 3c910f9..83fe593 100644 --- a/src/components/SalarySearch/SalarySearchWrapper.tsx +++ b/src/components/SalarySearch/SalarySearchWrapper.tsx @@ -8,21 +8,21 @@ interface SubComponents { Loading: typeof SalarySearchLoading; } -function calculateHourlyRate(loweLimit: number, upperLimit: number, numOfWorkingDay: number, workingHour: number){ - const hourlyRate = (loweLimit + upperLimit)/2/numOfWorkingDay/workingHour - return hourlyRate.toLocaleString() -} +// function calculateHourlyRate(loweLimit: number, upperLimit: number, numOfWorkingDay: number, workingHour: number){ +// const hourlyRate = (loweLimit + upperLimit)/2/numOfWorkingDay/workingHour +// return hourlyRate.toLocaleString() +// } const SalarySearchWrapper: React.FC & SubComponents = async () => { const Salarys = await fetchSalarys(); // const Salarys:any[] = [] const salarysWithHourlyRate = Salarys.map((salary) => { - const hourlyRate = calculateHourlyRate(Number(salary.lowerLimit), Number(salary.upperLimit),22, 8) + // const hourlyRate = calculateHourlyRate(Number(salary.lowerLimit), Number(salary.upperLimit),22, 8) return { ...salary, upperLimit: salary.upperLimit.toLocaleString(), lowerLimit: salary.lowerLimit.toLocaleString(), - hourlyRate: hourlyRate + hourlyRate: salary.hourlyRate.toLocaleString(), } }) // console.log(salarysWithHourlyRate) diff --git a/src/components/SearchResults/SearchResults.tsx b/src/components/SearchResults/SearchResults.tsx index d057792..d6fbcbb 100644 --- a/src/components/SearchResults/SearchResults.tsx +++ b/src/components/SearchResults/SearchResults.tsx @@ -26,6 +26,7 @@ interface BaseColumn { color?: IconButtonOwnProps["color"]; needTranslation?: boolean; type?: string; + isHidden?: boolean; } interface ColumnWithAction extends BaseColumn { @@ -78,7 +79,7 @@ function SearchResults({ - {columns.map((column, idx) => ( + {columns.filter(item => item.isHidden !== true).map((column, idx) => ( {column?.type === "money" ?
{column.label}
: column.label}
@@ -91,7 +92,7 @@ function SearchResults({ .map((item) => { return ( - {columns.map((column, idx) => { + {columns.filter(item => item.isHidden !== true).map((column, idx) => { const columnName = column.name; return ( diff --git a/src/components/SkillSearch/SkillSearch.tsx b/src/components/SkillSearch/SkillSearch.tsx index 01db336..c13ea19 100644 --- a/src/components/SkillSearch/SkillSearch.tsx +++ b/src/components/SkillSearch/SkillSearch.tsx @@ -18,16 +18,21 @@ type SearchParamNames = keyof SearchQuery; const SkillSearch: React.FC = ({ skill }) => { const { t } = useTranslation(); - const [filteredStaff, setFilteredStaff] = useState(skill); + const [filteredSkill, setFilteredSkill] = useState(skill); const router = useRouter(); const searchCriteria: Criterion[] = useMemo( () => [ { - label: t("Staff Name"), + label: t("Skill Name"), paramName: "name", type: "text", }, + { + label: t("Skill code"), + paramName: "code", + type: "text", + }, ], [t] ); @@ -36,7 +41,7 @@ const SkillSearch: React.FC = ({ skill }) => { (skill: SkillResult) => { console.log(skill); const id = skill.id; - // router.push(`/settings/skill/edit?id=${id}`); + router.push(`/settings/skill/edit?id=${id}`); }, [router, t] ); @@ -45,7 +50,7 @@ const SkillSearch: React.FC = ({ skill }) => { // deleteDialog(async () => { // await deleteStaff(skill.id); // successDialog("Delete Success", t); - // setFilteredStaff((prev) => prev.filter((obj) => obj.id !== skill.id)); + // setFilteredSkill((prev) => prev.filter((obj) => obj.id !== skill.id)); // }, t); }, []); @@ -76,19 +81,16 @@ const SkillSearch: React.FC = ({ skill }) => { { - // setFilteredStaff( - // skill.filter( - // (s) => - // s.skillId.toLowerCase().includes(query.skillId.toLowerCase()) && - // s.name.toLowerCase().includes(query.name.toLowerCase()) - // // (query.team === "All" || s.team === query.team) && - // // (query.category === "All" || s.category === query.category) && - // // (query.team === "All" || s.team === query.team), - // ) - // ); + setFilteredSkill( + skill.filter( + (s) => + s.name.toLowerCase().includes(query.name.toLowerCase()) && + s.code.toLowerCase().includes(query.code.toLowerCase()) + ) + ); }} /> - items={filteredStaff} columns={columns} /> + items={filteredSkill} columns={columns} /> ); }; diff --git a/src/components/StaffSearch/StaffSearch.tsx b/src/components/StaffSearch/StaffSearch.tsx index 4111d14..e19e915 100644 --- a/src/components/StaffSearch/StaffSearch.tsx +++ b/src/components/StaffSearch/StaffSearch.tsx @@ -9,15 +9,18 @@ import DeleteIcon from "@mui/icons-material/Delete"; import { deleteStaff } from "@/app/api/staff/actions"; import { useRouter } from "next/navigation"; import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; +import Person from '@mui/icons-material/Person'; +import { MAINTAIN_USER, VIEW_USER } from "@/middleware"; interface Props { staff: StaffResult[]; + abilities: string[] } type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; -const StaffSearch: React.FC = ({ staff }) => { +const StaffSearch: React.FC = ({ staff, abilities }) => { const { t } = useTranslation(); const [filteredStaff, setFilteredStaff] = useState(staff); const router = useRouter(); @@ -65,6 +68,14 @@ const StaffSearch: React.FC = ({ staff }) => { [router, t] ); + const onUserClick = useCallback( + (staff: StaffResult) => { + console.log(staff); + router.push(`/settings/staff/user?id=${staff.id}`); + }, + [router, t] + ); + const deleteClick = useCallback((staff: StaffResult) => { deleteDialog(async () => { await deleteStaff(staff.id); @@ -81,6 +92,13 @@ const StaffSearch: React.FC = ({ staff }) => { onClick: onStaffClick, buttonIcon: , }, + { + name: "id", + label: t("Actions"), + onClick: onUserClick, + buttonIcon: , + isHidden: ![MAINTAIN_USER, VIEW_USER].some((ability) => abilities.includes(ability)), + }, { name: "team", label: t("Team") }, { name: "name", label: t("Staff Name") }, { name: "staffId", label: t("Staff ID") }, diff --git a/src/components/StaffSearch/StaffSearchWrapper.tsx b/src/components/StaffSearch/StaffSearchWrapper.tsx index c581ca8..4f3b57d 100644 --- a/src/components/StaffSearch/StaffSearchWrapper.tsx +++ b/src/components/StaffSearch/StaffSearchWrapper.tsx @@ -2,24 +2,23 @@ import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; import React from "react"; import StaffSearch from "./StaffSearch"; import StaffSearchLoading from "./StaffSearchLoading"; -import { comboProp, fetchCompanyCombo } from "@/app/api/companys/actions"; -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 { preloadStaff } from "@/app/api/staff"; +import { Session, getServerSession } from "next-auth"; +import { authOptions } from "@/config/authConfig"; interface SubComponents { Loading: typeof StaffSearchLoading; } +interface SessionWithAbilities extends Session { + abilities?: string[] +} + const StaffSearchWrapper: React.FC & SubComponents = async () => { const staff = await fetchStaff(); - console.log(staff); + const session = await getServerSession(authOptions) as SessionWithAbilities; + const abilities: string[] = session.abilities!! - return ; + return ; }; StaffSearchWrapper.Loading = StaffSearchLoading; diff --git a/src/components/StaffUtilization/StaffUtilization.tsx b/src/components/StaffUtilization/StaffUtilization.tsx index 81dde4b..74dc3eb 100644 --- a/src/components/StaffUtilization/StaffUtilization.tsx +++ b/src/components/StaffUtilization/StaffUtilization.tsx @@ -124,6 +124,8 @@ const StaffUtilization: React.FC = () => { const [manHoursSpentPeriod, setManHoursSpentPeriod]: any[] = React.useState( firstDayOfWeekString + " to " + lastDayOfWeekString, ); + const [unsubmittedTimeSheetSelect, setUnsubmittedTimeSheetSelect]: any = + React.useState("Weekly"); const [teamTotalManhoursSpentSelect, setTeamTotalManhoursSpentSelect]: any = React.useState("Weekly"); const [staffGradeManhoursSpentSelect, setStaffGradeManhoursSpentSelect]: any = @@ -165,6 +167,10 @@ const StaffUtilization: React.FC = () => { individualStaffManhoursSpentPeriod, setIndividualStaffManhoursSpentPeriod, ]: any[] = React.useState(weekDates); + const [ + unsubmittedTimeSheetPeriod, + setUnsubmittedTimeSheetPeriod, + ]: any[] = React.useState(weekDates); const [ teamTotalManhoursSpentPlanData, setTeamTotalManhoursSpentPlanData, @@ -179,6 +185,8 @@ const StaffUtilization: React.FC = () => { React.useState(dayjs()); const [weeklyValueByIndividualStaff, setWeeklyValueByIndividualStaff] = React.useState(dayjs()); + const [weeklyUnsubmittedTimeSheet, setWeeklyUnsubmittedTimeSheet ] = + React.useState(dayjs()); const [staffGradeManhoursSpentValue, setStaffGradeManhoursSpentValue] = React.useState(dayjs()); const [totalManHoursMonthlyFromValue, setTotalManHoursMonthlyFromValue] = @@ -424,6 +432,56 @@ const StaffUtilization: React.FC = () => { ], }; + const unsubmittedTimeSheetOptions: ApexOptions = { + chart: { + height: 350, + type: "line", + }, + stroke: { + width: [1], + }, + plotOptions: { + bar: { + horizontal: true, + distributed: false, + }, + }, + dataLabels: { + enabled: true, + }, + xaxis: { + categories: [ + "001-Staff A", + "002-Staff B", + "005-Staff E", + "006-Staff F", + "007-Staff G", + ], + }, + yaxis: [ + { + title: { + text: "Staff", + }, + min: 0, + max: 12, + tickAmount: 5, + }, + ], + grid: { + borderColor: "#f1f1f1", + }, + annotations: {}, + series: [ + { + name: "Unsubmitted Time Sheet", + type: "bar", + color: "#00acb1", + data: [2, 2, 1, 5, 1], + }, + ], + }; + const teamTotalManhoursSpentOnClick = (r: any) => { setTeamTotalManhoursSpentSelect(r); if (r === "Weekly") { @@ -456,6 +514,10 @@ const StaffUtilization: React.FC = () => { // } }; + const unsubmittedTimeSheetOnClick = (r: any) => { + setUnsubmittedTimeSheetSelect(r); + }; + const selectWeeklyPeriod = (r: any) => { const selectDate = new Date(r); const firstDayOfWeek = new Date(); @@ -486,6 +548,21 @@ const StaffUtilization: React.FC = () => { setWeeklyValueByStaffGrade(dayjs(firstDayOfWeek)); }; + const selectWeeklyPeriodUnsubmittedTimeSheet = (r: any) => { + const selectDate = new Date(r); + const firstDayOfWeek = new Date(); + firstDayOfWeek.setDate(selectDate.getDate() - selectDate.getDay() + 0); + const weekDates: any[] = []; + for (let i = 0; i < 7; i++) { + const currentDate = new Date(firstDayOfWeek); + currentDate.setDate(firstDayOfWeek.getDate() + i); + const formattedDate = dayjs(currentDate).format("DD MMM (ddd)"); + weekDates.push(formattedDate); + } + setUnsubmittedTimeSheetPeriod(weekDates); + setWeeklyUnsubmittedTimeSheet(dayjs(firstDayOfWeek)); + }; + const selectWeeklyPeriodIndividualStaff = (r: any) => { const selectDate = new Date(r); const firstDayOfWeek = new Date(); @@ -589,6 +666,28 @@ const StaffUtilization: React.FC = () => { setTeamTotalManhoursByStaffGrade(weekDates); }; + const selectUnsubmittedTimeSheetMonthlyPeriodFrom = (r: any) => { + const monthDates: any[] = []; + const monthPlanData: any[] = []; + const monthActualData: any[] = []; + const selectFromDate = dayjs(r); + for ( + let date = selectFromDate.clone(); + date.isBefore(totalManHoursMonthlyToValue, "month"); + date = date.add(1, "month") + ) { + monthDates.push(date.format("MM-YYYY")); + monthPlanData.push(840); + monthActualData.push(Math.floor(Math.random() * (1200 - 840) + 840)); + } + monthDates.push(totalManHoursMonthlyToValue.format("MM-YYYY")); + monthPlanData.push(840); + monthActualData.push(Math.floor(Math.random() * (1200 - 840) + 840)); + // setTeamTotalManhoursSpentPlanData(monthPlanData) + // setTeamTotalManhoursSpentActualData(monthActualData) + setUnsubmittedTimeSheetPeriod(weekDates); + }; + const selectIndividualStaffMonthlyPeriodFrom = (r: any) => { const monthDates: any[] = []; const monthPlanData: any[] = []; @@ -611,6 +710,28 @@ const StaffUtilization: React.FC = () => { setIndividualStaffManhoursSpentPeriod(weekDates); }; + const selectUnsubmittedTimeSheetMonthlyPeriodTo = (r: any) => { + const monthDates: any[] = []; + const monthPlanData: any[] = []; + const monthActualData: any[] = []; + const selectToDate = dayjs(r); + for ( + let date = totalManHoursMonthlyFromValue.clone(); + date.isBefore(selectToDate, "month"); + date = date.add(1, "month") + ) { + monthDates.push(date.format("MM-YYYY")); + monthPlanData.push(840); + monthActualData.push(Math.floor(Math.random() * (1200 - 840) + 840)); + } + monthDates.push(selectToDate.format("MM-YYYY")); + monthPlanData.push(840); + monthActualData.push(Math.floor(Math.random() * (1200 - 840) + 840)); + // setTeamTotalManhoursSpentPlanData(monthPlanData) + // setTeamTotalManhoursSpentActualData(monthActualData) + setUnsubmittedTimeSheetPeriod(weekDates); + }; + const selectIndividualStaffMonthlyPeriodTo = (r: any) => { const monthDates: any[] = []; const monthPlanData: any[] = []; @@ -934,7 +1055,125 @@ const StaffUtilization: React.FC = () => { }} > - + + +
+
+ {unsubmittedTimeSheetSelect === "Weekly" && ( + <> + + + + )} + {unsubmittedTimeSheetSelect === "Monthly" && ( + <> + + + + )} +
+
+
+ +
+
+ + {t("Non-billable")} + {t("None")} + {assignedProjects.length > 0 && [ + + {t("Assigned Projects")} + , + ...assignedProjects.map((project) => ( + {`${project.code} - ${project.name}`} + )), + ]} + {nonAssignedProjects.length > 0 && [ + + {t("Non-assigned Projects")} + , + ...nonAssignedProjects.map((project) => ( + {`${project.code} - ${project.name}`} + )), + ]} + + ); +}; + +export default ProjectSelect; diff --git a/src/components/TimesheetTable/TaskGroupSelect.tsx b/src/components/TimesheetTable/TaskGroupSelect.tsx new file mode 100644 index 0000000..0173e81 --- /dev/null +++ b/src/components/TimesheetTable/TaskGroupSelect.tsx @@ -0,0 +1,69 @@ +import React, { useCallback } from "react"; +import { MenuItem, Select, SelectChangeEvent } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { TaskGroup } from "@/app/api/tasks"; + +interface Props { + taskGroupsByProject: { + [projectId: number]: { + value: TaskGroup["id"]; + label: string; + }[]; + }; + projectId: number | undefined; + value: number | undefined; + onTaskGroupSelect: (taskGroupId: number | string) => void; +} + +const TaskGroupSelect: React.FC = ({ + value, + projectId, + onTaskGroupSelect, + taskGroupsByProject, +}) => { + const { t } = useTranslation("home"); + + const taskGroups = projectId ? taskGroupsByProject[projectId] : []; + + const onChange = useCallback( + (event: SelectChangeEvent) => { + const newValue = event.target.value; + onTaskGroupSelect(newValue); + }, + [onTaskGroupSelect], + ); + + return ( + + ); +}; + +export default TaskGroupSelect; diff --git a/src/components/TimesheetTable/TaskSelect.tsx b/src/components/TimesheetTable/TaskSelect.tsx new file mode 100644 index 0000000..66b6d25 --- /dev/null +++ b/src/components/TimesheetTable/TaskSelect.tsx @@ -0,0 +1,72 @@ +import React, { useCallback } from "react"; +import { MenuItem, Select, SelectChangeEvent } from "@mui/material"; +import { GridRenderEditCellParams } from "@mui/x-data-grid"; +import { TimeEntryRow } from "./EntryInputTable"; +import { useTranslation } from "react-i18next"; +import { ProjectWithTasks } from "@/app/api/projects"; + +interface Props { + allProjects: ProjectWithTasks[]; + value: number | undefined; + projectId: number | undefined; + taskGroupId: number | undefined; + editCellProps: GridRenderEditCellParams; + onTaskSelect: (taskId: number | string) => void; +} + +const TaskSelect: React.FC = ({ + value, + allProjects, + projectId, + taskGroupId, + onTaskSelect, +}) => { + const { t } = useTranslation("home"); + + const project = allProjects.find((p) => p.id === projectId); + const tasks = project + ? project.tasks.filter((task) => task.taskGroup.id === taskGroupId) + : []; + + const onChange = useCallback( + (event: SelectChangeEvent) => { + const newValue = event.target.value; + onTaskSelect(newValue); + }, + [onTaskSelect], + ); + + return ( + + ); +}; + +export default TaskSelect; diff --git a/src/components/TimesheetTable/TimesheetTable.tsx b/src/components/TimesheetTable/TimesheetTable.tsx index 8a7e944..659c488 100644 --- a/src/components/TimesheetTable/TimesheetTable.tsx +++ b/src/components/TimesheetTable/TimesheetTable.tsx @@ -1,128 +1,36 @@ -import { RecordTimesheetInput, TimeEntry } from "@/app/api/timesheets/actions"; -import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; -import { KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; import { - Box, - Collapse, - IconButton, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, -} from "@mui/material"; -import dayjs from "dayjs"; -import React, { useState } from "react"; + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import React from "react"; import { useFormContext } from "react-hook-form"; -import { useTranslation } from "react-i18next"; import EntryInputTable from "./EntryInputTable"; -import { AssignedProject } from "@/app/api/projects"; +import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; +import DateHoursTable from "../DateHoursTable"; interface Props { + allProjects: ProjectWithTasks[]; assignedProjects: AssignedProject[]; + leaveRecords: RecordLeaveInput; } -const TimesheetTable: React.FC = ({ assignedProjects }) => { - const { t } = useTranslation("home"); - +const TimesheetTable: React.FC = ({ + allProjects, + assignedProjects, + leaveRecords, +}) => { const { watch } = useFormContext(); const currentInput = watch(); const days = Object.keys(currentInput); return ( - -
- - - - {t("Date")} - {t("Daily Total Hours")} - - - - {days.map((day, index) => { - const entries = currentInput[day]; - return ( - - ); - })} - -
- - ); -}; - -const DayRow: React.FC<{ - day: string; - entries: TimeEntry[]; - assignedProjects: AssignedProject[]; -}> = ({ day, entries, assignedProjects }) => { - const { - t, - i18n: { language }, - } = useTranslation("home"); - const dayJsObj = dayjs(day); - const [open, setOpen] = useState(false); - - const totalHours = entries.reduce((acc, entry) => acc + entry.inputHours, 0); - - return ( - <> - - - setOpen(!open)} - > - {open ? : } - - - - {shortDateFormatter(language).format(dayJsObj.toDate())} - - 20 ? "error.main" : undefined }}> - {manhourFormatter.format(totalHours)} - {totalHours > 20 && ( - - {t("(the daily total hours cannot be more than 20.)")} - - )} - - - - - - - - - - - - + ); }; diff --git a/src/components/UserGroupSearch/UserGroupSearch.tsx b/src/components/UserGroupSearch/UserGroupSearch.tsx new file mode 100644 index 0000000..fee25e4 --- /dev/null +++ b/src/components/UserGroupSearch/UserGroupSearch.tsx @@ -0,0 +1,95 @@ +"use client"; + +import SearchBox, { Criterion } from "../SearchBox"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import SearchResults, { Column } from "../SearchResults/index"; +import EditNote from "@mui/icons-material/EditNote"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { useRouter } from "next/navigation"; +import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; +import { UserGroupResult } from "@/app/api/group"; +import { deleteUser } from "@/app/api/user/actions"; +import { deleteGroup } from "@/app/api/group/actions"; + +interface Props { + users: UserGroupResult[]; +} +type SearchQuery = Partial>; +type SearchParamNames = keyof SearchQuery; + +const UserGroupSearch: React.FC = ({ users }) => { + const { t } = useTranslation(); + const [filteredUser, setFilteredUser] = useState(users); + const router = useRouter(); + + const searchCriteria: Criterion[] = useMemo( + () => [ + { + label: t("User Name"), + paramName: "name", + type: "text", + }, + ], + [t] + ); + + const onUserClick = useCallback( + (group: UserGroupResult) => { + console.log(group); + router.push(`/settings/group/edit?id=${group.id}`) + }, + [router, t] + ); + + const onDeleteClick = useCallback((group: UserGroupResult) => { + deleteDialog(async () => { + await deleteGroup(group.id); + + successDialog(t("Delete Success"), t); + + setFilteredUser((prev) => prev.filter((obj) => obj.id !== group.id)); + }, t); + }, []); + + const columns = useMemo[]>( + () => [ + { + name: "action", + label: t("Edit"), + onClick: onUserClick, + buttonIcon: , + }, + { name: "name", label: t("Group Name") }, + { name: "description", label: t("Description") }, + { + name: "action", + label: t("Delete"), + onClick: onDeleteClick, + buttonIcon: , + color: "error" + }, + ], + [t] + ); + + return ( + <> + { + // setFilteredUser( + // users.filter( + // (t) => + // t.name.toLowerCase().includes(query.name.toLowerCase()) && + // t.code.toLowerCase().includes(query.code.toLowerCase()) && + // t.description.toLowerCase().includes(query.description.toLowerCase()) + // ) + // ) + }} + /> + items={filteredUser} columns={columns} /> + + ); +}; +export default UserGroupSearch; diff --git a/src/components/UserGroupSearch/UserGroupSearchLoading.tsx b/src/components/UserGroupSearch/UserGroupSearchLoading.tsx new file mode 100644 index 0000000..5d8df0f --- /dev/null +++ b/src/components/UserGroupSearch/UserGroupSearchLoading.tsx @@ -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 UserGroupSearchLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default UserGroupSearchLoading; diff --git a/src/components/UserGroupSearch/UserGroupSearchWrapper.tsx b/src/components/UserGroupSearch/UserGroupSearchWrapper.tsx new file mode 100644 index 0000000..9f792ed --- /dev/null +++ b/src/components/UserGroupSearch/UserGroupSearchWrapper.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import UserGroupSearchLoading from "./UserGroupSearchLoading"; +import { UserGroupResult, fetchGroup } from "@/app/api/group"; +import UserGroupSearch from "./UserGroupSearch"; + +interface SubComponents { + Loading: typeof UserGroupSearchLoading; +} + +const UserGroupSearchWrapper: React.FC & SubComponents = async () => { +const group = await fetchGroup() + console.log(group.records); + + return ; +}; + +UserGroupSearchWrapper.Loading = UserGroupSearchLoading; + +export default UserGroupSearchWrapper; diff --git a/src/components/UserGroupSearch/index.ts b/src/components/UserGroupSearch/index.ts new file mode 100644 index 0000000..f2e5e63 --- /dev/null +++ b/src/components/UserGroupSearch/index.ts @@ -0,0 +1 @@ +export { default } from "./UserGroupSearchWrapper"; diff --git a/src/components/UserWorkspacePage/AssignedProjects.tsx b/src/components/UserWorkspacePage/AssignedProjects.tsx index ccd088c..6baa133 100644 --- a/src/components/UserWorkspacePage/AssignedProjects.tsx +++ b/src/components/UserWorkspacePage/AssignedProjects.tsx @@ -14,9 +14,11 @@ import { Clear, Search } from "@mui/icons-material"; import ProjectGrid from "./ProjectGrid"; import { Props as UserWorkspaceProps } from "./UserWorkspacePage"; -const AssignedProjects: React.FC = ({ - assignedProjects, -}) => { +interface Props { + assignedProjects: UserWorkspaceProps["assignedProjects"]; +} + +const AssignedProjects: React.FC = ({ assignedProjects }) => { const { t } = useTranslation("home"); // Projects diff --git a/src/components/UserWorkspacePage/UserWorkspacePage.tsx b/src/components/UserWorkspacePage/UserWorkspacePage.tsx index fc0b233..062e233 100644 --- a/src/components/UserWorkspacePage/UserWorkspacePage.tsx +++ b/src/components/UserWorkspacePage/UserWorkspacePage.tsx @@ -9,13 +9,31 @@ import { Typography } from "@mui/material"; import ButtonGroup from "@mui/material/ButtonGroup"; import AssignedProjects from "./AssignedProjects"; import TimesheetModal from "../TimesheetModal"; -import { AssignedProject } from "@/app/api/projects"; +import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; +import { + RecordLeaveInput, + RecordTimesheetInput, +} from "@/app/api/timesheets/actions"; +import LeaveModal from "../LeaveModal"; +import { LeaveType } from "@/app/api/timesheets"; export interface Props { + leaveTypes: LeaveType[]; + allProjects: ProjectWithTasks[]; assignedProjects: AssignedProject[]; + username: string; + defaultLeaveRecords: RecordLeaveInput; + defaultTimesheets: RecordTimesheetInput; } -const UserWorkspacePage: React.FC = ({ assignedProjects }) => { +const UserWorkspacePage: React.FC = ({ + leaveTypes, + allProjects, + assignedProjects, + username, + defaultLeaveRecords, + defaultTimesheets, +}) => { const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); const { t } = useTranslation("home"); @@ -53,43 +71,39 @@ const UserWorkspacePage: React.FC = ({ assignedProjects }) => { flexWrap="wrap" spacing={2} > - {Boolean(assignedProjects.length) && ( - - - - - )} + + + + + + {assignedProjects.length > 0 ? ( - <> - - - - + ) : ( - <> - - {t("You have no assigned projects!")} - - + + {t("You have no assigned projects!")} + )} ); diff --git a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx index c311488..783498e 100644 --- a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx +++ b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx @@ -1,9 +1,38 @@ -import { fetchAssignedProjects } from "@/app/api/projects"; +import { + fetchAssignedProjects, + fetchProjectWithTasks, +} from "@/app/api/projects"; import UserWorkspacePage from "./UserWorkspacePage"; +import { + fetchLeaveTypes, + fetchLeaves, + fetchTimesheets, +} from "@/app/api/timesheets"; -const UserWorkspaceWrapper: React.FC = async () => { - const assignedProjects = await fetchAssignedProjects(); - return ; +interface Props { + username: string; +} + +const UserWorkspaceWrapper: React.FC = async ({ username }) => { + const [assignedProjects, allProjects, timesheets, leaves, leaveTypes] = + await Promise.all([ + fetchAssignedProjects(username), + fetchProjectWithTasks(), + fetchTimesheets(username), + fetchLeaves(username), + fetchLeaveTypes(), + ]); + + return ( + + ); }; export default UserWorkspaceWrapper; diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts index e0c2860..2c2b9da 100644 --- a/src/config/authConfig.ts +++ b/src/config/authConfig.ts @@ -3,10 +3,15 @@ import CredentialsProvider from "next-auth/providers/credentials"; import { LOGIN_API_PATH } from "./api"; export interface SessionWithTokens extends Session { + abilities?: any[]; accessToken?: string; refreshToken?: string; } +export interface ability { + actionSubjectCombo: string; +} + export const authOptions: AuthOptions = { debug: process.env.NODE_ENV === "development", providers: [ @@ -48,10 +53,13 @@ export const authOptions: AuthOptions = { const sessionWithToken: SessionWithTokens = { ...session, // Add the data from the token to the session + abilities: (token.abilities as ability[]).map( + (item: ability) => item.actionSubjectCombo, + ) as string[], accessToken: token.accessToken as string | undefined, refreshToken: token.refreshToken as string | undefined, }; - + // console.log(sessionWithToken) return sessionWithToken; }, }, diff --git a/src/middleware.ts b/src/middleware.ts index 85204dd..a793a79 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,48 @@ import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; -import { authOptions } from "@/config/authConfig"; +import { ability, authOptions } from "@/config/authConfig"; import { NextFetchEvent, NextResponse } from "next/server"; +import { getToken } from "next-auth/jwt"; + +// abilities +export const [ + VIEW_USER, + MAINTAIN_USER, + MAINTAIN_TIMESHEET, + VIEW_TASK_TEMPLATE, + VIEW_GROUP, + VIEW_MASTERDATA, + MAINTAIN_MASTERDATA, + VIEW_DASHBOARD_SELF, + VIEW_DASHBOARD_ALL, + IMPORT_INVOICE, + MAINTAIN_GROUP, + GENERATE_REPORTS, + VIEW_STAFF_PROFILE, + IMPORT_RECEIPT, + MAINTAIN_TASK_TEMPLATE, + MAINTAIN_TIMESHEET_7DAYS, + VIEW_PROJECT, + MAINTAIN_PROJECT, +] = [ + 'VIEW_USER', + 'MAINTAIN_USER', + 'MAINTAIN_TIMESHEET', + 'VIEW_TASK_TEMPLATE', + 'VIEW_GROUP', + 'VIEW_MASTERDATA', + 'MAINTAIN_MASTERDATA', + 'VIEW_DASHBOARD_SELF', + 'VIEW_DASHBOARD_ALL', + 'IMPORT_INVOICE', + 'MAINTAIN_GROUP', + 'GENERATE_REPORTS', + 'VIEW_STAFF_PROFILE', + 'IMPORT_RECEIPT', + 'MAINTAIN_TASK_TEMPLATE', + 'MAINTAIN_TIMESHEET_7DAYS', + 'VIEW_PROJECT', + 'MAINTAIN_PROJECT' +] const PRIVATE_ROUTES = [ "/analytics", @@ -14,15 +56,12 @@ const PRIVATE_ROUTES = [ ]; const LANG_QUERY_PARAM = "lang"; -const authMiddleware = withAuth({ - pages: authOptions.pages, -}); - export default async function middleware( req: NextRequestWithAuth, event: NextFetchEvent, ) { const langPref = req.nextUrl.searchParams.get(LANG_QUERY_PARAM); + const token = await getToken({ req: req, secret: process.env.SECRET }); if (langPref) { // Redirect to same url without the lang query param + set cookies const newUrl = new URL(req.nextUrl); @@ -31,6 +70,39 @@ export default async function middleware( response.cookies.set("i18next", langPref); return response; } + + let abilities: string[] = [] + if (token) { + abilities = (token.abilities as ability[]).map((item: ability) => item.actionSubjectCombo); + } + + const authMiddleware = withAuth({ + pages: authOptions.pages, + callbacks: { + authorized: ({req, token}) => { + let isAuth = Boolean(token); + if (!Boolean(token)) { + return Boolean(token) + } + if (req.nextUrl.pathname.startsWith('/settings')) { + isAuth = [VIEW_MASTERDATA, MAINTAIN_MASTERDATA].some((ability) => abilities.includes(ability)); + } + if (req.nextUrl.pathname.startsWith('/settings/user')) { + isAuth = [MAINTAIN_USER, VIEW_USER].some((ability) => abilities.includes(ability)); + } + if (req.nextUrl.pathname.startsWith('/settings/staff/user')) { + isAuth = [MAINTAIN_USER, VIEW_USER].some((ability) => abilities.includes(ability)); + } + if (req.nextUrl.pathname.startsWith('/analytics')) { + isAuth = [GENERATE_REPORTS].some((ability) => abilities.includes(ability)); + } + if (req.nextUrl.pathname.startsWith('/settings/staff/edit')) { + isAuth = [VIEW_STAFF_PROFILE].some((ability) => abilities.includes(ability)); + } + return isAuth + } + } + }); // Matcher for using the auth middleware return PRIVATE_ROUTES.some((route) => req.nextUrl.pathname.startsWith(route))