Bladeren bron

Merge branch 'main' of https://git.2fi-solutions.com/wayne.lee/tsms

tags/Baseline_30082024_FRONTEND_UAT
leoho2fi 1 jaar geleden
bovenliggende
commit
85b2d1fc0c
100 gewijzigde bestanden met toevoegingen van 3740 en 515 verwijderingen
  1. +213
    -70
      package-lock.json
  2. +4
    -1
      package.json
  3. +4
    -4
      src/app/(main)/analytics/ProjectCashFlowReport/page.tsx
  4. +22
    -1
      src/app/(main)/home/page.tsx
  5. +1
    -1
      src/app/(main)/invoice/new/page.tsx
  6. +4
    -4
      src/app/(main)/layout.tsx
  7. +1
    -1
      src/app/(main)/projects/create/page.tsx
  8. +17
    -0
      src/app/(main)/projects/edit/not-found.tsx
  9. +74
    -0
      src/app/(main)/projects/edit/page.tsx
  10. +53
    -0
      src/app/(main)/settings/changepassword/page.tsx
  11. +31
    -0
      src/app/(main)/settings/department/edit/page.tsx
  12. +1
    -1
      src/app/(main)/settings/department/new/page.tsx
  13. +22
    -0
      src/app/(main)/settings/group/create/page.tsx
  14. +26
    -0
      src/app/(main)/settings/group/edit/page.tsx
  15. +55
    -0
      src/app/(main)/settings/group/page.tsx
  16. +48
    -0
      src/app/(main)/settings/holiday/page.tsx
  17. +28
    -0
      src/app/(main)/settings/skill/edit/page.tsx
  18. +4
    -10
      src/app/(main)/settings/staff/create/page.tsx
  19. +22
    -0
      src/app/(main)/settings/staff/user/page.tsx
  20. +0
    -4
      src/app/(main)/settings/team/create/page.tsx
  21. +24
    -0
      src/app/(main)/settings/user/edit/page.tsx
  22. +2
    -2
      src/app/(main)/settings/user/page.tsx
  23. +2
    -2
      src/app/(main)/staffReimbursement/create/page.tsx
  24. +17
    -0
      src/app/(main)/tasks/edit/not-found.tsx
  25. +24
    -3
      src/app/(main)/tasks/edit/page.tsx
  26. +17
    -4
      src/app/api/departments/actions.ts
  27. +10
    -0
      src/app/api/departments/index.ts
  28. +51
    -0
      src/app/api/group/actions.ts
  29. +21
    -0
      src/app/api/group/index.ts
  30. +33
    -0
      src/app/api/holidays/actions.ts
  31. +30
    -0
      src/app/api/holidays/index.ts
  32. +30
    -2
      src/app/api/invoices/actions.ts
  33. +113
    -0
      src/app/api/invoices/index.ts
  34. +24
    -10
      src/app/api/positions/actions.ts
  35. +43
    -7
      src/app/api/projects/actions.ts
  36. +27
    -5
      src/app/api/projects/index.ts
  37. +3
    -3
      src/app/api/reports/actions.ts
  38. +3
    -3
      src/app/api/reports/index.ts
  39. +30
    -2
      src/app/api/salarys/actions.ts
  40. +13
    -0
      src/app/api/tasks/actions.ts
  41. +13
    -0
      src/app/api/tasks/index.ts
  42. +1
    -0
      src/app/api/team/index.ts
  43. +54
    -4
      src/app/api/timesheets/actions.ts
  44. +30
    -0
      src/app/api/timesheets/index.ts
  45. +49
    -0
      src/app/api/timesheets/utils.ts
  46. +26
    -3
      src/app/api/user/actions.ts
  47. +4
    -2
      src/app/api/user/index.ts
  48. +34
    -2
      src/app/utils/fetchUtil.ts
  49. +6
    -0
      src/app/utils/formatUtil.ts
  50. +7
    -2
      src/components/AppBar/AppBar.tsx
  51. +13
    -3
      src/components/AppBar/NavigationToggle.tsx
  52. +3
    -0
      src/components/AppBar/Profile.tsx
  53. +3
    -1
      src/components/Breadcrumb/Breadcrumb.tsx
  54. +107
    -0
      src/components/ChangePassword/ChangePassword.tsx
  55. +144
    -0
      src/components/ChangePassword/ChangePasswordForm.tsx
  56. +40
    -0
      src/components/ChangePassword/ChangePasswordLoading.tsx
  57. +20
    -0
      src/components/ChangePassword/ChangePasswordWrapper.tsx
  58. +1
    -0
      src/components/ChangePassword/index.ts
  59. +0
    -1
      src/components/ClaimDetail/index.ts
  60. +0
    -0
      src/components/ClaimSave/ClaimFormInfo.tsx
  61. +0
    -0
      src/components/ClaimSave/ClaimFormInputGrid.tsx
  62. +11
    -11
      src/components/ClaimSave/ClaimSave.tsx
  63. +2
    -2
      src/components/ClaimSave/ClaimSaveWrapper.tsx
  64. +1
    -0
      src/components/ClaimSave/index.ts
  65. +227
    -0
      src/components/CompanyHoliday/CompanyHoliday.tsx
  66. +87
    -0
      src/components/CompanyHoliday/CompanyHolidayDialog.tsx
  67. +40
    -0
      src/components/CompanyHoliday/CompanyHolidayLoading.tsx
  68. +34
    -0
      src/components/CompanyHoliday/CompanyHolidayWrapper.tsx
  69. +1
    -0
      src/components/CompanyHoliday/index.ts
  70. +14
    -8
      src/components/CreateDepartment/CreateDepartment.tsx
  71. +15
    -9
      src/components/CreateDepartment/CreateDepartmentWrapper.tsx
  72. +4
    -4
      src/components/CreateDepartment/DepartmentDetails.tsx
  73. +208
    -0
      src/components/CreateGroup/AuthorityAllocation.tsx
  74. +130
    -0
      src/components/CreateGroup/CreateGroup.tsx
  75. +40
    -0
      src/components/CreateGroup/CreateGroupLoading.tsx
  76. +24
    -0
      src/components/CreateGroup/CreateGroupWrapper.tsx
  77. +81
    -0
      src/components/CreateGroup/GroupInfo.tsx
  78. +209
    -0
      src/components/CreateGroup/UserAllocation.tsx
  79. +1
    -0
      src/components/CreateGroup/index.ts
  80. +0
    -0
      src/components/CreateInvoice_forGen/CreateInvoice.tsx
  81. +0
    -0
      src/components/CreateInvoice_forGen/CreateInvoiceWrapper.tsx
  82. +0
    -0
      src/components/CreateInvoice_forGen/InvoiceDetails.tsx
  83. +0
    -0
      src/components/CreateInvoice_forGen/ProjectDetails.tsx
  84. +0
    -0
      src/components/CreateInvoice_forGen/ProjectTotalFee.tsx
  85. +0
    -0
      src/components/CreateInvoice_forGen/index.ts
  86. +4
    -4
      src/components/CreatePosition/PositionDetails.tsx
  87. +288
    -77
      src/components/CreateProject/CreateProject.tsx
  88. +16
    -1
      src/components/CreateProject/CreateProjectWrapper.tsx
  89. +31
    -2
      src/components/CreateProject/Milestone.tsx
  90. +30
    -18
      src/components/CreateProject/MilestoneSection.tsx
  91. +3
    -2
      src/components/CreateProject/ProjectTotalFee.tsx
  92. +67
    -23
      src/components/CreateProject/ResourceAllocation.tsx
  93. +1
    -1
      src/components/CreateProject/TaskSetup.tsx
  94. +14
    -108
      src/components/CreateStaff/CreateStaff.tsx
  95. +35
    -4
      src/components/CreateStaff/CreateStaffWrapper.tsx
  96. +105
    -64
      src/components/CreateTaskTemplate/CreateTaskTemplate.tsx
  97. +13
    -4
      src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx
  98. +287
    -0
      src/components/CreateTaskTemplate/ResourceAllocation.tsx
  99. +1
    -1
      src/components/CreateTeam/TeamInfo.tsx
  100. +14
    -14
      src/components/CustomerSave/CustomerSave.tsx

+ 213
- 70
package-lock.json Bestand weergeven

@@ -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",


+ 4
- 1
package.json Bestand weergeven

@@ -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",


src/app/(main)/analytics/EX02ProjectCashFlowReport/page.tsx → src/app/(main)/analytics/ProjectCashFlowReport/page.tsx Bestand weergeven

@@ -2,10 +2,10 @@ import { Metadata } from "next";
import { Suspense } from "react";
import { I18nProvider } from "@/i18n";
import { fetchProjects } from "@/app/api/projects";
import GenerateEX02ProjectCashFlowReport from "@/components/GenerateEX02ProjectCashFlowReport";
import GenerateProjectCashFlowReport from "@/components/GenerateProjectCashFlowReport";

export const metadata: Metadata = {
title: "EX02 - Project Cash Flow Report",
title: "Project Cash Flow Report",
};

const ProjectCashFlowReport: React.FC = async () => {
@@ -14,8 +14,8 @@ const ProjectCashFlowReport: React.FC = async () => {
return (
<>
<I18nProvider namespaces={["report", "common"]}>
<Suspense fallback={<GenerateEX02ProjectCashFlowReport.Loading />}>
<GenerateEX02ProjectCashFlowReport />
<Suspense fallback={<GenerateProjectCashFlowReport.Loading />}>
<GenerateProjectCashFlowReport />
</Suspense>
</I18nProvider>
</>

+ 22
- 1
src/app/(main)/home/page.tsx Bestand weergeven

@@ -1,15 +1,36 @@
import { Metadata } from "next";
import { I18nProvider } from "@/i18n";
import UserWorkspacePage from "@/components/UserWorkspacePage";
import {
fetchLeaveTypes,
fetchLeaves,
fetchTimesheets,
} from "@/app/api/timesheets";
import { authOptions } from "@/config/authConfig";
import { getServerSession } from "next-auth";
import {
fetchAssignedProjects,
fetchProjectWithTasks,
} from "@/app/api/projects";

export const metadata: Metadata = {
title: "User Workspace",
};

const Home: React.FC = async () => {
const session = await getServerSession(authOptions);
// Get name for caching
const username = session!.user!.name!;

fetchTimesheets(username);
fetchAssignedProjects(username);
fetchLeaves(username);
fetchLeaveTypes();
fetchProjectWithTasks();

return (
<I18nProvider namespaces={["home"]}>
<UserWorkspacePage />
<UserWorkspacePage username={username} />
</I18nProvider>
);
};


+ 1
- 1
src/app/(main)/invoice/new/page.tsx Bestand weergeven

@@ -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",


+ 4
- 4
src/app/(main)/layout.tsx Bestand weergeven

@@ -31,10 +31,10 @@ export default async function MainLayout({
padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" },
}}
>
<Stack spacing={2}>
<Breadcrumb />
{children}
</Stack>
<Stack spacing={2}>
<Breadcrumb />
{children}
</Stack>
</Box>
</>
);


+ 1
- 1
src/app/(main)/projects/create/page.tsx Bestand weergeven

@@ -43,7 +43,7 @@ const Projects: React.FC = async () => {
<>
<Typography variant="h4">{t("Create Project")}</Typography>
<I18nProvider namespaces={["projects"]}>
<CreateProject />
<CreateProject isEditMode={false} />
</I18nProvider>
</>
);


+ 17
- 0
src/app/(main)/projects/edit/not-found.tsx Bestand weergeven

@@ -0,0 +1,17 @@
import { getServerI18n } from "@/i18n";
import { Stack, Typography, Link } from "@mui/material";
import NextLink from "next/link";

export default async function NotFound() {
const { t } = await getServerI18n("projects", "common");

return (
<Stack spacing={2}>
<Typography variant="h4">{t("Not Found")}</Typography>
<Typography variant="body1">{t("The project was not found!")}</Typography>
<Link href="/projects" component={NextLink} variant="body2">
{t("Return to all projects")}
</Link>
</Stack>
);
}

+ 74
- 0
src/app/(main)/projects/edit/page.tsx Bestand weergeven

@@ -0,0 +1,74 @@
import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer";
import { fetchGrades } from "@/app/api/grades";
import {
fetchProjectBuildingTypes,
fetchProjectCategories,
fetchProjectContractTypes,
fetchProjectDetails,
fetchProjectFundingTypes,
fetchProjectLocationTypes,
fetchProjectServiceTypes,
fetchProjectWorkNatures,
} from "@/app/api/projects";
import { preloadStaff, preloadTeamLeads } from "@/app/api/staff";
import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks";
import { ServerFetchError } from "@/app/utils/fetchUtil";
import CreateProject from "@/components/CreateProject";
import { I18nProvider, getServerI18n } from "@/i18n";
import Typography from "@mui/material/Typography";
import { isArray } from "lodash";
import { Metadata } from "next";
import { notFound } from "next/navigation";

interface Props {
searchParams: { [key: string]: string | string[] | undefined };
}

export const metadata: Metadata = {
title: "Edit Project",
};

const Projects: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("projects");
// Assume projectId is string here
const projectId = searchParams["id"];

if (!projectId || isArray(projectId)) {
notFound();
}

// Preload necessary dependencies
fetchAllTasks();
fetchTaskTemplates();
fetchProjectCategories();
fetchProjectContractTypes();
fetchProjectFundingTypes();
fetchProjectLocationTypes();
fetchProjectServiceTypes();
fetchProjectBuildingTypes();
fetchProjectWorkNatures();
fetchAllCustomers();
fetchAllSubsidiaries();
fetchGrades();
preloadTeamLeads();
preloadStaff();

try {
await fetchProjectDetails(projectId);
} catch (e) {
if (e instanceof ServerFetchError && e.response?.status === 404) {
notFound();
}
}

return (
<>
<Typography variant="h4">{t("Edit Project")}</Typography>
<I18nProvider namespaces={["projects"]}>
<CreateProject isEditMode projectId={projectId} />
</I18nProvider>
</>
);
};

export default Projects;

+ 53
- 0
src/app/(main)/settings/changepassword/page.tsx Bestand weergeven

@@ -0,0 +1,53 @@
import { preloadClaims } from "@/app/api/claims";
import { preloadStaff, preloadTeamLeads } from "@/app/api/staff";
import ChangePassword from "@/components/ChangePassword";
import StaffSearch from "@/components/StaffSearch";
import TeamSearch from "@/components/TeamSearch";
import UserGroupSearch from "@/components/UserGroupSearch";
import UserSearch from "@/components/UserSearch";
import { I18nProvider, getServerI18n } from "@/i18n";
import Add from "@mui/icons-material/Add";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react";


export const metadata: Metadata = {
title: "Change Password",
};


const ChangePasswordPage: React.FC = async () => {
const { t } = await getServerI18n("User Group");
// preloadTeamLeads();
// preloadStaff();
return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Change Password")}
</Typography>
</Stack>
{/* <I18nProvider namespaces={["User Group", "common"]}>
<Suspense fallback={<UserGroupSearch.Loading />}>
<UserGroupSearch />
</Suspense>
</I18nProvider> */}
<I18nProvider namespaces={["User Group", "common"]}>
<Suspense fallback={<ChangePassword.Loading />}>
<ChangePassword />
</Suspense>
</I18nProvider>
</>
);
};
export default ChangePasswordPage;

+ 31
- 0
src/app/(main)/settings/department/edit/page.tsx Bestand weergeven

@@ -0,0 +1,31 @@
import CreateDepartment from "@/components/CreateDepartment";
import { I18nProvider, getServerI18n } from "@/i18n";
import Typography from "@mui/material/Typography";
import { Metadata } from "next";

export const metadata: Metadata = {
title: "Create Department",
};

interface Props {
searchParams: { [key: string]: string | undefined };
}

const Department: React.FC<Props> = async ({searchParams}) => {
const { t } = await getServerI18n("departments");

// Preload necessary dependencies
// Assume projectId is string here
const departmentId = searchParams["id"];

return (
<>
<Typography variant="h4">{t("Create Department")}</Typography>
<I18nProvider namespaces={["departments"]}>
<CreateDepartment isEdit={true} departmentId={departmentId}/>
</I18nProvider>
</>
);
};

export default Department;

+ 1
- 1
src/app/(main)/settings/department/new/page.tsx Bestand weergeven

@@ -16,7 +16,7 @@ const Department: React.FC = async () => {
<>
<Typography variant="h4">{t("Create Department")}</Typography>
<I18nProvider namespaces={["departments"]}>
<CreateDepartment />
<CreateDepartment isEdit={false} />
</I18nProvider>
</>
);


+ 22
- 0
src/app/(main)/settings/group/create/page.tsx Bestand weergeven

@@ -0,0 +1,22 @@
// 'use client';
import { I18nProvider, getServerI18n } from "@/i18n";
import React, { useCallback, useState } from "react";
import { Typography } from "@mui/material";
import CreateGroup from "@/components/CreateGroup";

// const Title = ["title1", "title2"];

const CreateStaff: React.FC = async () => {
const { t } = await getServerI18n("group");

return (
<>
<Typography variant="h4">{t("Create Group")}</Typography>
<I18nProvider namespaces={["group"]}>
<CreateGroup />
</I18nProvider>
</>
);
};

export default CreateStaff;

+ 26
- 0
src/app/(main)/settings/group/edit/page.tsx Bestand weergeven

@@ -0,0 +1,26 @@
import EditPosition from "@/components/EditPosition";
import EditUserGroup from "@/components/EditUserGroup";
import { I18nProvider, getServerI18n } from "@/i18n";
import Typography from "@mui/material/Typography";
import { Metadata } from "next";

export const metadata: Metadata = {
title: "Edit User Group",
};

const Group: React.FC = async () => {
const { t } = await getServerI18n("group");

// Preload necessary dependencies

return (
<>
{/* <Typography variant="h4">{t("Edit User Group")}</Typography> */}
<I18nProvider namespaces={["group"]}>
<EditUserGroup />
</I18nProvider>
</>
);
};

export default Group;

+ 55
- 0
src/app/(main)/settings/group/page.tsx Bestand weergeven

@@ -0,0 +1,55 @@
import { preloadClaims } from "@/app/api/claims";
import { preloadStaff, preloadTeamLeads } from "@/app/api/staff";
import StaffSearch from "@/components/StaffSearch";
import TeamSearch from "@/components/TeamSearch";
import UserGroupSearch from "@/components/UserGroupSearch";
import UserSearch from "@/components/UserSearch";
import { I18nProvider, getServerI18n } from "@/i18n";
import Add from "@mui/icons-material/Add";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react";


export const metadata: Metadata = {
title: "User Group",
};


const UserGroup: React.FC = async () => {
const { t } = await getServerI18n("User Group");
// preloadTeamLeads();
// preloadStaff();
return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("User Group")}
</Typography>
<Button
variant="contained"
startIcon={<Add />}
LinkComponent={Link}
href="/settings/group/create"
>
{t("Create User Group")}
</Button>
</Stack>
<I18nProvider namespaces={["User Group", "common"]}>
<Suspense fallback={<UserGroupSearch.Loading />}>
<UserGroupSearch />
</Suspense>
</I18nProvider>
</>
);
};
export default UserGroup;

+ 48
- 0
src/app/(main)/settings/holiday/page.tsx Bestand weergeven

@@ -0,0 +1,48 @@
import CompanyHoliday from "@/components/CompanyHoliday";
import { Metadata } from "next";
import { getServerI18n } from "@/i18n";
import Add from "@mui/icons-material/Add";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Link from "next/link";
import { Suspense } from "react";
import { fetchCompanys, preloadCompanys } from "@/app/api/companys";

export const metadata: Metadata = {
title: "Holiday",
};

const Company: React.FC = async () => {
const { t } = await getServerI18n("holiday");
// Preload necessary dependencies

return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Company Holiday")}
</Typography>
{/* <Button
variant="contained"
startIcon={<Add />}
LinkComponent={Link}
href="/settings/holiday/create"
>
{t("Create Holiday")}
</Button> */}
</Stack>
<Suspense fallback={<CompanyHoliday.Loading />}>
<CompanyHoliday/>
</Suspense>
</>
)
};

export default Company;

+ 28
- 0
src/app/(main)/settings/skill/edit/page.tsx Bestand weergeven

@@ -0,0 +1,28 @@
import { Edit } from "@mui/icons-material";
import { useSearchParams } from "next/navigation";
// import EditStaff from "@/components/EditStaff";
import { Suspense } from "react";
import { I18nProvider, getServerI18n } from "@/i18n";
// import EditStaffWrapper from "@/components/EditStaff/EditStaffWrapper";
import { Metadata } from "next";
import EditSkill from "@/components/EditSkill";
import { Typography } from "@mui/material";


const EditSkillPage: React.FC = async () => {
const { t } = await getServerI18n("staff");

return (
<>
<Typography variant="h4">{t("Edit Skill")}</Typography>
<I18nProvider namespaces={["team", "common"]}>
<Suspense fallback={<EditSkill.Loading />}>
<EditSkill />
</Suspense>
</I18nProvider>
{/* <EditStaff /> */}
</>
);
};

export default EditSkillPage;

+ 4
- 10
src/app/(main)/settings/staff/create/page.tsx Bestand weergeven

@@ -22,7 +22,7 @@ import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions";
import { Error } from "@mui/icons-material";
import { ProjectCategory } from "@/app/api/projects";
import { Grid, Typography } from "@mui/material";
import CreateStaffForm from "@/components/CreateStaff/CreateStaff";
import CreateStaff from "@/components/CreateStaff";

interface CreateCustomInputs {
projectCode: string;
@@ -31,23 +31,17 @@ interface CreateCustomInputs {

// const Title = ["title1", "title2"];

const CreateStaff: React.FC = async () => {
const CreateStaffPage: React.FC = async () => {
const { t } = await getServerI18n("staff");

const title = ['', t('Additional Info')]
// const regex = new RegExp("^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$")
// console.log(regex)

return (
<>
<Typography variant="h4">{t("Create Staff")}</Typography>
<I18nProvider namespaces={["staff"]}>
<CreateStaffForm
Title={title}
/>
<CreateStaff/>
</I18nProvider>
</>
);
};

export default CreateStaff;
export default CreateStaffPage;

+ 22
- 0
src/app/(main)/settings/staff/user/page.tsx Bestand weergeven

@@ -0,0 +1,22 @@
import { Edit } from "@mui/icons-material";
import { Metadata } from "next";
import { I18nProvider, getServerI18n } from "@/i18n";
import EditUser from "@/components/EditUser";
import { Typography } from "@mui/material";
import { Suspense } from "react";

const User: React.FC = async () => {
const { t } = await getServerI18n("user");

return (
<>
<Typography variant="h4">{t("Edit User")}</Typography>
<I18nProvider namespaces={["user", "common"]}>
<Suspense fallback={<EditUser.Loading />}>
<EditUser />
</Suspense>
</I18nProvider>
</>
);
};
export default User;

+ 0
- 4
src/app/(main)/settings/team/create/page.tsx Bestand weergeven

@@ -28,10 +28,6 @@ import CreateTeam from "@/components/CreateTeam";
const CreateTeamPage: React.FC = async () => {
const { t } = await getServerI18n("team");

const title = ['', t('Additional Info')]
// const regex = new RegExp("^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$")
// console.log(regex)

return (
<>
<Typography variant="h4">{t("Create Team")}</Typography>


+ 24
- 0
src/app/(main)/settings/user/edit/page.tsx Bestand weergeven

@@ -0,0 +1,24 @@
import { Edit } from "@mui/icons-material";
import { useSearchParams } from "next/navigation";
// import EditStaff from "@/components/EditStaff";
import { Suspense } from "react";
import { I18nProvider } from "@/i18n";
// import EditStaffWrapper from "@/components/EditStaff/EditStaffWrapper";
import { Metadata } from "next";
import EditUser from "@/components/EditUser";


const EditUserPage: React.FC = () => {

return (
<>
<I18nProvider namespaces={["team", "common"]}>
<Suspense fallback={<EditUser.Loading />}>
<EditUser />
</Suspense>
</I18nProvider>
</>
);
};

export default EditUserPage;

+ 2
- 2
src/app/(main)/settings/user/page.tsx Bestand weergeven

@@ -33,14 +33,14 @@ export const metadata: Metadata = {
<Typography variant="h4" marginInlineEnd={2}>
{t("User")}
</Typography>
<Button
{/* <Button
variant="contained"
startIcon={<Add />}
LinkComponent={Link}
href="/settings/team/create"
>
{t("Create User")}
</Button>
</Button> */}
</Stack>
<I18nProvider namespaces={["User", "common"]}>
<Suspense fallback={<UserSearch.Loading />}>


+ 2
- 2
src/app/(main)/staffReimbursement/create/page.tsx Bestand weergeven

@@ -1,4 +1,4 @@
import ClaimDetail from "@/components/ClaimDetail";
import ClaimSave from "@/components/ClaimSave";
import { I18nProvider, getServerI18n } from "@/i18n";
import Typography from "@mui/material/Typography";
import { Metadata } from "next";
@@ -14,7 +14,7 @@ const ClaimDetails: React.FC = async () => {
<>
<Typography variant="h4">{t("Create Claim")}</Typography>
<I18nProvider namespaces={["claim", "common"]}>
<ClaimDetail />
<ClaimSave />
</I18nProvider>
</>
);


+ 17
- 0
src/app/(main)/tasks/edit/not-found.tsx Bestand weergeven

@@ -0,0 +1,17 @@
import { getServerI18n } from "@/i18n";
import { Stack, Typography, Link } from "@mui/material";
import NextLink from "next/link";

export default async function NotFound() {
const { t } = await getServerI18n("tasks", "common");

return (
<Stack spacing={2}>
<Typography variant="h4">{t("Not Found")}</Typography>
<Typography variant="body1">{t("The task template was not found!")}</Typography>
<Link href="/projects" component={NextLink} variant="body2">
{t("Return to all task templates")}
</Link>
</Stack>
);
}

+ 24
- 3
src/app/(main)/tasks/edit/page.tsx Bestand weergeven

@@ -1,23 +1,44 @@
import { preloadAllTasks } from "@/app/api/tasks";
import { fetchTaskTemplateDetail, preloadAllTasks } from "@/app/api/tasks";
import CreateTaskTemplate from "@/components/CreateTaskTemplate";
import { getServerI18n } from "@/i18n";
import Typography from "@mui/material/Typography";
import { Metadata } from "next";
import { I18nProvider } from "@/i18n";
import { ServerFetchError } from "@/app/utils/fetchUtil";
import { isArray } from "lodash";
import { notFound } from "next/navigation";

export const metadata: Metadata = {
title: "Edit Task Template",
};

const TaskTemplates: React.FC = async () => {
interface Props {
searchParams: { [key: string]: string | string[] | undefined };
}

const TaskTemplates: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("tasks");
const taskTemplateId = searchParams["id"];

if (!taskTemplateId || isArray(taskTemplateId)) {
notFound();
}

preloadAllTasks();

try {
await fetchTaskTemplateDetail(taskTemplateId);
} catch (e) {
if (e instanceof ServerFetchError && e.response?.status === 404) {
notFound();
}
}

return (
<>
<Typography variant="h4">{t("Edit Task Template")}</Typography>
<I18nProvider namespaces={["tasks", "common"]}>
<CreateTaskTemplate />
<CreateTaskTemplate taskTemplateId={taskTemplateId}/>
</I18nProvider>
</>
);


+ 17
- 4
src/app/api/departments/actions.ts Bestand weergeven

@@ -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 () => {


+ 10
- 0
src/app/api/departments/index.ts Bestand weergeven

@@ -2,6 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { cache } from "react";
import "server-only";
import { CreateDepartmentInputs } from "./actions";

export interface DepartmentResult {
id: number;
@@ -18,4 +19,13 @@ export const fetchDepartments = cache(async () => {
return serverFetchJson<DepartmentResult[]>(`${BASE_API_URL}/departments`, {
next: { tags: ["departments"] },
});
});

export const fetchDepartmentDetails = cache(async (departmentId: string) => {
return serverFetchJson<CreateDepartmentInputs>(
`${BASE_API_URL}/departments/departmentDetails/${departmentId}`,
{
next: { tags: [`departmentDetail${departmentId}`] },
},
);
});

+ 51
- 0
src/app/api/group/actions.ts Bestand weergeven

@@ -0,0 +1,51 @@
"use server";

import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { revalidateTag } from "next/cache";
import { cache } from "react";


export interface CreateGroupInputs {
id?: number;
name: string;
description: string;
addUserIds?: number[];
removeUserIds?: number[];
addAuthIds?: number[];
removeAuthIds?: number[];
}

export interface auth {
id: number;
module?: any | null;
authority: string;
name: string;
description: string | null;
v: number;
}

export interface record {
records: auth[];
}

export const fetchAuth = cache(async (target: string, id?: number) => {
return serverFetchJson<record>(`${BASE_API_URL}/group/auth/${target}/${id ?? 0}`, {
next: { tags: ["auth"] },
});
});
export const saveGroup = async (data: CreateGroupInputs) => {
return serverFetchJson(`${BASE_API_URL}/group/save`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};
export const deleteGroup = async (id: number) => {
return serverFetchWithNoContent(`${BASE_API_URL}/group/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
};

+ 21
- 0
src/app/api/group/index.ts Bestand weergeven

@@ -0,0 +1,21 @@
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { cache } from "react";
import "server-only";

export interface Records {
records: UserGroupResult[]
}

export interface UserGroupResult {
id: number;
action: () => void;
name: string;
description: string;
}

export const fetchGroup = cache(async () => {
return serverFetchJson<Records>(`${BASE_API_URL}/group`, {
next: { tags: ["group"] },
});
});

+ 33
- 0
src/app/api/holidays/actions.ts Bestand weergeven

@@ -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
};


+ 30
- 0
src/app/api/holidays/index.ts Bestand weergeven

@@ -0,0 +1,30 @@
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { cache } from "react";
import "server-only";
import EventInput from '@fullcalendar/react';

export interface HolidaysList extends EventInput {
id: string;
title: string;
date: string;
extendedProps: {
calendar: string;
};
}

export interface HolidaysResult {
id: string;
name: string;
date: number[];
}

export const preloadCompanys = () => {
fetchHolidays();
};

export const fetchHolidays = cache(async () => {
return serverFetchJson<HolidaysResult[]>(`${BASE_API_URL}/company-holidays`, {
next: { tags: ["company-holidays"] },
});
});

+ 30
- 2
src/app/api/invoices/actions.ts Bestand weergeven

@@ -1,6 +1,6 @@
"use server"

import { serverFetchJson } from "@/app/utils/fetchUtil";
import { serverFetchJson, serverFetchString } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { cache } from "react";

@@ -64,4 +64,32 @@ export const fetchInvoiceInfoById = cache(async (id: number) => {
return serverFetchJson<InvoiceInformation[]>(`${BASE_API_URL}/invoices/getInvoiceInfo/${id}`, {
next: { tags: ["invoiceInfoById"] },
});
})
})

export const importIssuedInovice = async (data: FormData) => {
// console.log("----------------",data)
const importIssuedInovice = await serverFetchJson<any>(
`${BASE_API_URL}/invoices/import/issued`,
{
method: "POST",
body: data,
// headers: { "Content-Type": "multipart/form-data" },
},
);

return importIssuedInovice;
};

export const importReceivedInovice = async (data: FormData) => {
// console.log("----------------",data)
const importReceivedInovice = await serverFetchJson<any>(
`${BASE_API_URL}/invoices/import/received`,
{
method: "POST",
body: data,
// headers: { "Content-Type": "multipart/form-data" },
},
);

return importReceivedInovice;
};

+ 113
- 0
src/app/api/invoices/index.ts Bestand weergeven

@@ -15,6 +15,107 @@ export interface InvoiceResult {
reminder: string;
}

export interface issuedInvoiceResult {
id: number;
invoiceNo: string;
projectCode: string;
projectName: string;
team: string;
stage: string;
paymentMilestone: string;
paymentMilestoneDate: string;
client: string;
address: string;
attention: string;
invoiceDate: number[];
dueDate: number[];
issuedAmount: number;
}

export interface receivedInvoiceResult {
id: number;
invoiceNo: string;
projectCode: string;
projectName: string;
team: string;
receiptDate: number[];
receivedAmount: number;
}



export interface issuedInvoiceList {
id: number;
invoiceNo: string;
projectCode: string;
projectName: string;
// team: string;
stage: string;
paymentMilestone: string;
// paymentMilestoneDate: string;
// client: string;
// address: string;
// attention: string;
invoiceDate: string;
dueDate: string;
issuedAmount: string;
}

export interface receivedInvoiceList {
id: number;
invoiceNo: string;
projectCode: string;
projectName: string;
team: string;
// stage: string;
// paymentMilestone: string;
// paymentMilestoneDate: string;
// client: string;
// address: string;
// attention: string;
receiptDate: string;
receivedAmount: string;
}

export interface issuedInvoiceSearchForm {
id: number;
invoiceNo: string;
projectCode: string;
projectName: string;
// team: string;
// stage: string;
// paymentMilestone: string;
// paymentMilestoneDate: string;
// client: string;
// address: string;
// attention: string;
invoiceDate: string;
invoiceDateTo: string;
dueDate: string;
dueDateTo: string;
// issuedAmount: string;
}

export interface receivedInvoiceSearchForm {
id: number;
invoiceNo: string;
projectCode: string;
projectName: string;
// team: string;
// stage: string;
// paymentMilestone: string;
// paymentMilestoneDate: string;
// client: string;
// address: string;
// attention: string;
receiptDate: string;
receiptDateTo: string;
// dueDate: string;
// dueDateTo: string;
// issuedAmount: string;
}


export interface InvoiceInformatio{
id: number;
address: string;
@@ -32,4 +133,16 @@ export const fetchInvoices = cache(async () => {
return serverFetchJson<InvoiceResult[]>(`${BASE_API_URL}/invoices`, {
next: { tags: ["invoices"] },
});
});

export const fetchIssuedInvoices = cache(async () => {
return serverFetchJson<issuedInvoiceResult[]>(`${BASE_API_URL}/invoices/v2/allInvoices`, {
next: { tags: ["invoices"] },
});
});

export const fetchReceivedInvoices = cache(async () => {
return serverFetchJson<receivedInvoiceResult[]>(`${BASE_API_URL}/invoices/v2/allInvoices/paid`, {
next: { tags: ["invoices"] },
});
});

+ 24
- 10
src/app/api/positions/actions.ts Bestand weergeven

@@ -1,6 +1,6 @@
"use server"

import { serverFetchJson } from "@/app/utils/fetchUtil";
import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { cache } from "react";
import { PositionResult } from ".";
@@ -17,13 +17,15 @@ export interface combo {
export interface CreatePositionInputs {
positionCode: string;
positionName: string;
code: string;
name: string;
description: string;
}

export interface EditPositionInputs {
id: number;
positionCode: string;
positionName: string;
code: string;
name: string;
description: string;
}

@@ -35,13 +37,25 @@ export const savePosition = async (data: CreatePositionInputs) => {
});
};

export const editPosition = async (data: EditPositionInputs) => {
return serverFetchJson(`${BASE_API_URL}/positions/new`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};
export const editPosition = async (data: EditPositionInputs) => {
return serverFetchJson(`${BASE_API_URL}/positions/new`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

export const deletePosition = async (id: number) => {
const position = await serverFetchWithNoContent(
`${BASE_API_URL}/positions/${id}`,
{
method: "DELETE",
headers: { "Content-Type": "application/json" },
},
);

return position
};

export const fetchPositionCombo = cache(async () => {
return serverFetchJson<combo>(`${BASE_API_URL}/positions/combo`, {


+ 43
- 7
src/app/api/projects/actions.ts Bestand weergeven

@@ -1,17 +1,25 @@
"use server";

import { serverFetchJson } from "@/app/utils/fetchUtil";
import {
serverFetchJson,
serverFetchWithNoContent,
} from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { Task, TaskGroup } from "../tasks";
import { Customer } from "../customer";
import { revalidatePath, revalidateTag } from "next/cache";

export interface CreateProjectInputs {
// Project details
// Project
projectId: number | null;
projectDeleted: boolean | null;
projectCode: string;
projectName: string;
projectCategoryId: number;
projectDescription: string;
projectLeadId: number;
projectActualStart: string;
projectActualEnd: string;

// Project info
serviceTypeId: number;
@@ -61,10 +69,38 @@ export interface PaymentInputs {
amount: number;
}

export interface CreateProjectResponse {
id: number;
name: string;
code: string;
category: string;
team: string;
client: string;
}
export const saveProject = async (data: CreateProjectInputs) => {
return serverFetchJson(`${BASE_API_URL}/projects/new`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
const newProject = await serverFetchJson<CreateProjectResponse>(
`${BASE_API_URL}/projects/new`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);

revalidateTag("projects");
return newProject;
};

export const deleteProject = async (id: number) => {
const project = await serverFetchWithNoContent(
`${BASE_API_URL}/projects/${id}`,
{
method: "DELETE",
headers: { "Content-Type": "application/json" },
},
);

revalidateTag("projects");
revalidatePath("/(main)/home");
return project;
};

+ 27
- 5
src/app/api/projects/index.ts Bestand weergeven

@@ -3,6 +3,7 @@ import { BASE_API_URL } from "@/config/api";
import { cache } from "react";
import "server-only";
import { Task, TaskGroup } from "../tasks";
import { CreateProjectInputs } from "./actions";

export interface ProjectResult {
id: number;
@@ -48,17 +49,20 @@ export interface WorkNature {
name: string;
}

export interface AssignedProject {
export interface ProjectWithTasks {
id: number;
code: string;
name: string;
tasks: Task[];
milestones: {
[taskGroupId: TaskGroup["id"]]: {
startDate: string;
endDate: string;
startDate?: string;
endDate?: string;
};
};
}

export interface AssignedProject extends ProjectWithTasks {
// Manhour info
hoursSpent: number;
hoursSpentOther: number;
@@ -137,11 +141,29 @@ export const fetchProjectWorkNatures = cache(async () => {
});
});

export const fetchAssignedProjects = cache(async () => {
export const fetchAssignedProjects = cache(async (username: string) => {
return serverFetchJson<AssignedProject[]>(
`${BASE_API_URL}/projects/assignedProjects`,
{
next: { tags: ["assignedProjects"] },
next: { tags: [`assignedProjects__${username}`] },
},
);
});

export const fetchProjectWithTasks = cache(async () => {
return serverFetchJson<ProjectWithTasks[]>(
`${BASE_API_URL}/projects/allProjectWithTasks`,
{
next: { tags: ["allProjectWithTasks"] },
},
);
});

export const fetchProjectDetails = cache(async (projectId: string) => {
return serverFetchJson<CreateProjectInputs>(
`${BASE_API_URL}/projects/projectDetails/${projectId}`,
{
next: { tags: [`projectDetails_${projectId}`] },
},
);
});

+ 3
- 3
src/app/api/reports/actions.ts Bestand weergeven

@@ -1,7 +1,7 @@
"use server";

import { serverFetchBlob, serverFetchJson } from "@/app/utils/fetchUtil";
import { EX02ProjectCashFlowReportRequest } from ".";
import { ProjectCashFlowReportRequest } from ".";
import { BASE_API_URL } from "@/config/api";

export interface FileResponse {
@@ -9,9 +9,9 @@ export interface FileResponse {
blobValue: Uint8Array;
}

export const fetchEX02ProjectCashFlowReport = async (data: EX02ProjectCashFlowReportRequest) => {
export const fetchProjectCashFlowReport = async (data: ProjectCashFlowReportRequest) => {
const reportBlob = await serverFetchBlob<FileResponse>(
`${BASE_API_URL}/reports/EX02-ProjectCashFlowReport`,
`${BASE_API_URL}/reports/ProjectCashFlowReport`,
{
method: "POST",
body: JSON.stringify(data),


+ 3
- 3
src/app/api/reports/index.ts Bestand weergeven

@@ -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;
}

+ 30
- 2
src/app/api/salarys/actions.ts Bestand weergeven

@@ -1,8 +1,9 @@
"use server"

import { serverFetchJson } from "@/app/utils/fetchUtil";
import { serverFetchBlob, serverFetchJson, serverFetchString } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { cache } from "react";
import { FileResponse } from "../reports/actions";

export interface comboProp {
id: any;
@@ -17,4 +18,31 @@ export const fetchSalaryCombo = cache(async () => {
return serverFetchJson<combo>(`${BASE_API_URL}/salarys/combo`, {
next: { tags: ["salary"] },
});
});
});

export const importSalarys = async (data: FormData) => {
console.log("----------------",data)
const importSalarys = await serverFetchString<String>(
`${BASE_API_URL}/salarys/import`,
{
method: "POST",
body: data,
// headers: { "Content-Type": "multipart/form-data" },
},
);

return importSalarys;
};

export const exportSalary = async () => {
const reportBlob = await serverFetchBlob<FileResponse>(
`${BASE_API_URL}/salarys/export`,
{
method: "POST",
// body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);

return reportBlob
};

+ 13
- 0
src/app/api/tasks/actions.ts Bestand weergeven

@@ -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) => {


+ 13
- 0
src/app/api/tasks/index.ts Bestand weergeven

@@ -2,6 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { cache } from "react";
import "server-only";
import { NewTaskTemplateFormInputs } from "./actions";

export interface TaskGroup {
id: number;
@@ -39,3 +40,15 @@ export const preloadAllTasks = () => {
export const fetchAllTasks = cache(async () => {
return serverFetchJson<Task[]>(`${BASE_API_URL}/tasks`);
});

export const fetchTaskTemplateDetail = cache(async (id: string) => {
const taskTemplate = await serverFetchJson<NewTaskTemplateFormInputs>(
`${BASE_API_URL}/tasks/templatesDetails/${id}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);

return taskTemplate;
});

+ 1
- 0
src/app/api/team/index.ts Bestand weergeven

@@ -15,6 +15,7 @@ export interface TeamResult {
staffName: string;
posLabel: string;
posCode: string;
teamLead: number;

}


+ 54
- 4
src/app/api/timesheets/actions.ts Bestand weergeven

@@ -1,15 +1,65 @@
"use server";

import { serverFetchJson } from "@/app/utils/fetchUtil";
import { ProjectResult } from "../projects";
import { Task, TaskGroup } from "../tasks";
import { BASE_API_URL } from "@/config/api";
import { revalidateTag } from "next/cache";

export interface TimeEntry {
projectId: ProjectResult["id"];
taskGroupId: TaskGroup["id"];
taskId: Task["id"];
inputHours: number;
id: number;
projectId?: ProjectResult["id"];
taskGroupId?: TaskGroup["id"];
taskId?: Task["id"];
inputHours?: number;
otHours?: number;
remark?: string;
}

export interface RecordTimesheetInput {
[date: string]: TimeEntry[];
}

export interface LeaveEntry {
id: number;
inputHours: number;
leaveTypeId: number;
remark?: string;
}

export interface RecordLeaveInput {
[date: string]: LeaveEntry[];
}

export const saveTimesheet = async (
data: RecordTimesheetInput,
username: string,
) => {
const savedRecords = await serverFetchJson<RecordTimesheetInput>(
`${BASE_API_URL}/timesheets/save`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);

revalidateTag(`timesheets_${username}`);

return savedRecords;
};

export const saveLeave = async (data: RecordLeaveInput, username: string) => {
const savedRecords = await serverFetchJson<RecordLeaveInput>(
`${BASE_API_URL}/timesheets/saveLeave`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);

revalidateTag(`leaves_${username}`);

return savedRecords;
};

+ 30
- 0
src/app/api/timesheets/index.ts Bestand weergeven

@@ -0,0 +1,30 @@
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { cache } from "react";
import { RecordLeaveInput, RecordTimesheetInput } from "./actions";

export interface LeaveType {
id: number;
name: string;
}

export const fetchTimesheets = cache(async (username: string) => {
return serverFetchJson<RecordTimesheetInput>(`${BASE_API_URL}/timesheets`, {
next: { tags: [`timesheets_${username}`] },
});
});

export const fetchLeaves = cache(async (username: string) => {
return serverFetchJson<RecordLeaveInput>(
`${BASE_API_URL}/timesheets/leaves`,
{
next: { tags: [`leaves_${username}`] },
},
);
});

export const fetchLeaveTypes = cache(async () => {
return serverFetchJson<LeaveType[]>(`${BASE_API_URL}/timesheets/leaveTypes`, {
next: { tags: ["leaveTypes"] },
});
});

+ 49
- 0
src/app/api/timesheets/utils.ts Bestand weergeven

@@ -0,0 +1,49 @@
import { LeaveEntry, TimeEntry } from "./actions";

/**
* @param entry - the time entry
* @returns the field where there is an error, or an empty string if there is none
*/
export const isValidTimeEntry = (entry: Partial<TimeEntry>): string => {
// Test for errors
let error: keyof TimeEntry | "" = "";

// Either normal or other hours need to be inputted
if (!entry.inputHours && !entry.otHours) {
error = "inputHours";
} else if (entry.inputHours && entry.inputHours <= 0) {
error = "inputHours";
} else if (entry.otHours && entry.otHours <= 0) {
error = "otHours";
}

// If there is a project id, there should also be taskGroupId, taskId, inputHours
if (entry.projectId) {
if (!entry.taskGroupId) {
error = "taskGroupId";
} else if (!entry.taskId) {
error = "taskId";
}
} else {
if (!entry.remark) {
error = "remark";
}
}

return error;
};

export const isValidLeaveEntry = (entry: Partial<LeaveEntry>): string => {
// Test for errrors
let error: keyof LeaveEntry | "" = "";
if (!entry.leaveTypeId) {
error = "leaveTypeId";
} else if (!entry.inputHours || !(entry.inputHours >= 0)) {
error = "inputHours";
}

return error;
};

export const LEAVE_DAILY_MAX_HOURS = 8;
export const TIMESHEET_DAILY_MAX_HOURS = 20;

+ 26
- 3
src/app/api/user/actions.ts Bestand weergeven

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

+ 4
- 2
src/app/api/user/index.ts Bestand weergeven

@@ -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 = () => {


+ 34
- 2
src/app/utils/fetchUtil.ts Bestand weergeven

@@ -3,6 +3,16 @@ import { getServerSession } from "next-auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export class ServerFetchError extends Error {
public readonly response: Response | undefined;
constructor(message?: string, response?: Response) {
super(message);
this.response = response;

Object.setPrototypeOf(this, ServerFetchError.prototype);
}
}

export const serverFetch: typeof fetch = async (input, init) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = await getServerSession<any, SessionWithTokens>(authOptions);
@@ -17,7 +27,7 @@ export const serverFetch: typeof fetch = async (input, init) => {
? {
Authorization: `Bearer ${accessToken}`,
Accept:
"application/json, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/json, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, multipart/form-data",
}
: {}),
},
@@ -37,7 +47,10 @@ export async function serverFetchJson<T>(...args: FetchParams) {
signOutUser();
default:
console.error(await response.text());
throw Error("Something went wrong fetching data in server.");
throw new ServerFetchError(
"Something went wrong fetching data in server.",
response,
);
}
}
}
@@ -58,6 +71,25 @@ export async function serverFetchWithNoContent(...args: FetchParams) {
}
}

export async function serverFetchString<T>(...args: FetchParams) {
const response = await serverFetch(...args);

if (response.ok) {
return response.text() as T;
} else {
switch (response.status) {
case 401:
signOutUser();
default:
console.error(await response.text());
throw new ServerFetchError(
"Something went wrong fetching data in server.",
response,
);
}
}
}

export async function serverFetchBlob<T>(...args: FetchParams) {
const response = await serverFetch(...args);



+ 6
- 0
src/app/utils/formatUtil.ts Bestand weergeven

@@ -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", {


+ 7
- 2
src/components/AppBar/AppBar.tsx Bestand weergeven

@@ -5,18 +5,23 @@ import Profile from "./Profile";
import Box from "@mui/material/Box";
import NavigationToggle from "./NavigationToggle";
import { I18nProvider } from "@/i18n";
import { authOptions } from "@/config/authConfig";
import { getServerSession } from "next-auth";

export interface AppBarProps {
avatarImageSrc?: string;
profileName: string;
}

const AppBar: React.FC<AppBarProps> = ({ avatarImageSrc, profileName }) => {
const AppBar: React.FC<AppBarProps> = async ({ avatarImageSrc, profileName }) => {
const session = await getServerSession(authOptions) as any;
const abilities: string[] = session.abilities
console.log(abilities)
return (
<I18nProvider namespaces={["common"]}>
<MUIAppBar position="sticky" color="default" elevation={4}>
<Toolbar>
<NavigationToggle />
<NavigationToggle abilities={abilities}/>
<Box
sx={{ flexGrow: 1, display: "flex", justifyContent: "flex-end" }}
>


+ 13
- 3
src/components/AppBar/NavigationToggle.tsx Bestand weergeven

@@ -4,8 +4,18 @@ import MenuIcon from "@mui/icons-material/Menu";
import NavigationContent from "../NavigationContent";
import React from "react";
import Drawer from "@mui/material/Drawer";
import { Session } from "inspector";
import { authOptions } from "@/config/authConfig";
import { getServerSession } from "next-auth";
export interface SessionWithAbilities extends Session {
abilities?: string[]
}

const NavigationToggle: React.FC = () => {
interface Props {
abilities?: string[]
}

const NavigationToggle: React.FC<Props> = ({ abilities }) => {
const [isOpened, setIsOpened] = React.useState(false);

const openNavigation = () => {
@@ -18,7 +28,7 @@ const NavigationToggle: React.FC = () => {
return (
<>
<Drawer variant="permanent" sx={{ display: { xs: "none", xl: "block" } }}>
<NavigationContent />
<NavigationContent abilities={abilities}/>
</Drawer>
<Drawer
sx={{ display: { xl: "none" } }}
@@ -28,7 +38,7 @@ const NavigationToggle: React.FC = () => {
keepMounted: true,
}}
>
<NavigationContent />
<NavigationContent abilities={abilities}/>
</Drawer>
<IconButton
sx={{ display: { xl: "none" } }}


+ 3
- 0
src/components/AppBar/Profile.tsx Bestand weergeven

@@ -10,6 +10,7 @@ import Divider from "@mui/material/Divider";
import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";

type Props = Pick<AppBarProps, "avatarImageSrc" | "profileName">;

@@ -26,6 +27,7 @@ const Profile: React.FC<Props> = ({ avatarImageSrc, profileName }) => {
};

const { t } = useTranslation("login");
const router = useRouter();

return (
<>
@@ -52,6 +54,7 @@ const Profile: React.FC<Props> = ({ avatarImageSrc, profileName }) => {
{profileName}
</Typography>
<Divider />
<MenuItem onClick={() => {router.replace("/settings/changepassword")}}>{t("Change Password")}</MenuItem>
<MenuItem onClick={() => signOut()}>{t("Sign out")}</MenuItem>
</Menu>
</>


+ 3
- 1
src/components/Breadcrumb/Breadcrumb.tsx Bestand weergeven

@@ -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 = () => {


+ 107
- 0
src/components/ChangePassword/ChangePassword.tsx Bestand weergeven

@@ -0,0 +1,107 @@
"use client";
import { PasswordInputs, changePassword } from "@/app/api/user/actions";
import { Grid } from "@mui/material";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Button, Stack, Tab, Tabs, TabsProps, Typography } from "@mui/material";
import { Check, Close, Error } from "@mui/icons-material";
import ChagnePasswordForm from "./ChangePasswordForm";
import { ServerFetchError } from "@/app/utils/fetchUtil";

// interface Props {
// // auth?: auth[]
// // users?: UserResult[]
// }

const ChangePassword: React.FC = () => {
const formProps = useForm<PasswordInputs>();
const [serverError, setServerError] = useState("");
const router = useRouter();
// const [tabIndex, setTabIndex] = useState(0);
const { t } = useTranslation();

const onSubmit = useCallback<SubmitHandler<PasswordInputs>>(
async (data) => {
try {
let haveError = false;
// Minimum eight characters, at least one uppercase letter, one lowercase letter, one number and one special character:
let regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/

if (data.newPassword.length < 8 || data.newPassword.length > 20) {
haveError = true
formProps.setError("newPassword", { message: "The password requires 8-20 characters", type: "required" })
}
if (!regex.test(data.newPassword)) {
haveError = true
formProps.setError("newPassword", { message: "A combination of uppercase letters, lowercase letters, numbers, and symbols is required.", type: "required" })
}
if (data.password == data.newPassword) {
haveError = true
formProps.setError("newPassword", { message: "The new password cannot be the same as the old password", type: "required" })
}
if (data.newPassword != data.newPasswordCheck) {
haveError = true
formProps.setError("newPassword", { message: "The new password has to be the same as the new password", type: "required" })
formProps.setError("newPasswordCheck", { message: "The new password has to be the same as the new password", type: "required" })
}
if (haveError) {
return
}
const postData = {
password: data.password,
newPassword: data.newPassword
}
// await changePassword(postData)
// router.replace("/home")
} catch (e) {
console.log(e)
setServerError(t("An error has occurred. Please try again later."));
}
},
[router]
);

const handleCancel = () => {
router.push(`/home`);
};

const onSubmitError = useCallback<SubmitErrorHandler<PasswordInputs>>(
(errors) => {
console.log(errors);
},
[]
);

return (
<FormProvider {...formProps}>
<Stack
spacing={2}
component="form"
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}
>
<ChagnePasswordForm />
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={handleCancel}
>
{t("Cancel")}
</Button>
<Button
variant="contained"
startIcon={<Check />}
type="submit"
// disabled={Boolean(formProps.watch("isGridEditing"))}
>
{t("Confirm")}
</Button>
</Stack>
</Stack>
</FormProvider>
);
};

export default ChangePassword;

+ 144
- 0
src/components/ChangePassword/ChangePasswordForm.tsx Bestand weergeven

@@ -0,0 +1,144 @@
"use client";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useCallback, useState } from "react";
import { PasswordInputs } from "@/app/api/user/actions";
import { Visibility, VisibilityOff } from "@mui/icons-material";
import { IconButton, InputAdornment } from "@mui/material";

const ChagnePasswordForm: React.FC = () => {
const { t } = useTranslation();
const [showNewPassword, setShowNewPassword] = useState(false);
const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword);
const handleMouseDownNewPassword = () => setShowNewPassword(!showNewPassword);

const [showPassword, setShowPassword] = useState(false);
const handleClickShowPassword = () => setShowPassword(!showPassword);
const handleMouseDownPassword = () => setShowPassword(!showPassword);

const {
register,
formState: { errors, defaultValues },
control,
reset,
resetField,
setValue,
} = useFormContext<PasswordInputs>();

// const resetGroup = useCallback(() => {
// console.log(defaultValues);
// if (defaultValues !== undefined) {
// resetField("description");
// }
// }, [defaultValues]);

return (
<Card sx={{ display: "block" }}>
<CardContent component={Stack} spacing={4}>
<Box>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Group Info")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6}>
<TextField
label={t("Input Old Password")}
fullWidth
type={showPassword ? "text" : "password"}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword}
>
{showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
)
}}
{...register("password", {
required: true,
})}
error={Boolean(errors.password)}
helperText={
Boolean(errors.password) &&
(errors.password?.message
? t(errors.password.message)
: t("Please input correct password"))
}
/>
</Grid>
<Grid item xs={6} />
<Grid item xs={6}>
<TextField
label={t("Input New Password")}
fullWidth
type={showNewPassword ? "text" : "password"}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowNewPassword}
onMouseDown={handleMouseDownNewPassword}
>
{showNewPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
)
}}
{...register("newPassword")}
error={Boolean(errors.newPassword)}
helperText={
Boolean(errors.newPassword) &&
(errors.newPassword?.message
? t(errors.newPassword.message)
: t("Please input correct newPassword"))
}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Input New Password Again")}
fullWidth
type={showNewPassword ? "text" : "password"}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowNewPassword}
onMouseDown={handleMouseDownNewPassword}
>
{showNewPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
)
}}
{...register("newPasswordCheck")}
error={Boolean(errors.newPassword)}
helperText={
Boolean(errors.newPassword) &&
(errors.newPassword?.message
? t(errors.newPassword.message)
: t("Please input correct newPassword"))
}
/>
</Grid>
</Grid>
</Box>
</CardContent>
</Card>
);
};
export default ChagnePasswordForm;

+ 40
- 0
src/components/ChangePassword/ChangePasswordLoading.tsx Bestand weergeven

@@ -0,0 +1,40 @@
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import React from "react";

// Can make this nicer
export const ChangePasswordLoading: React.FC = () => {
return (
<>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton
variant="rounded"
height={50}
width={100}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
</CardContent>
</Card>
<Card>Change Password
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
</Stack>
</CardContent>
</Card>
</>
);
};

export default ChangePasswordLoading;

+ 20
- 0
src/components/ChangePassword/ChangePasswordWrapper.tsx Bestand weergeven

@@ -0,0 +1,20 @@
import React from "react";
import ChangePasswordLoading from "./ChangePasswordLoading";
import ChangePassword from "./ChangePassword";

interface SubComponents {
Loading: typeof ChangePasswordLoading;
}

const ChangePasswordWrapper: React.FC & SubComponents = async () => {
// const records = await fetchAuth()
// const users = await fetchUser()
// console.log(users)
// const auth = records.records as auth[]

return <ChangePassword />;
};

ChangePasswordWrapper.Loading = ChangePasswordLoading;

export default ChangePasswordWrapper;

+ 1
- 0
src/components/ChangePassword/index.ts Bestand weergeven

@@ -0,0 +1 @@
export { default } from "./ChangePasswordWrapper";

+ 0
- 1
src/components/ClaimDetail/index.ts Bestand weergeven

@@ -1 +0,0 @@
export { default } from "./ClaimDetailWrapper";

src/components/ClaimDetail/ClaimFormInfo.tsx → src/components/ClaimSave/ClaimFormInfo.tsx Bestand weergeven


src/components/ClaimDetail/ClaimFormInputGrid.tsx → src/components/ClaimSave/ClaimFormInputGrid.tsx Bestand weergeven


src/components/ClaimDetail/ClaimDetail.tsx → src/components/ClaimSave/ClaimSave.tsx Bestand weergeven

@@ -21,7 +21,7 @@ export interface Props {
projectCombo: ProjectCombo[]
}

const ClaimDetail: React.FC<Props> = ({ projectCombo }) => {
const ClaimSave: React.FC<Props> = ({ projectCombo }) => {
const { t } = useTranslation("common");
const [serverError, setServerError] = useState("");
const router = useRouter();
@@ -74,15 +74,15 @@ const ClaimDetail: React.FC<Props> = ({ projectCombo }) => {
const buttonName = (event?.nativeEvent as any).submitter.name
const formData = new FormData()
formData.append("expenseType", data.expenseType)
data.addClaimDetails.forEach((claimDetail) => {
console.log(claimDetail)
formData.append("addClaimDetailIds", JSON.stringify(claimDetail.id))
formData.append("addClaimDetailInvoiceDates", convertDateToString(claimDetail.invoiceDate, "YYYY-MM-DD"))
formData.append("addClaimDetailProjectIds", JSON.stringify(claimDetail.project))
formData.append("addClaimDetailDescriptions", claimDetail.description)
formData.append("addClaimDetailAmounts", JSON.stringify(claimDetail.amount))
formData.append("addClaimDetailNewSupportingDocuments", claimDetail.newSupportingDocument)
formData.append("addClaimDetailOldSupportingDocumentIds", JSON.stringify(claimDetail?.oldSupportingDocument?.id ?? -1))
data.addClaimDetails.forEach((ClaimSave) => {
console.log(ClaimSave)
formData.append("addClaimDetailIds", JSON.stringify(ClaimSave.id))
formData.append("addClaimDetailInvoiceDates", convertDateToString(ClaimSave.invoiceDate, "YYYY-MM-DD"))
formData.append("addClaimDetailProjectIds", JSON.stringify(ClaimSave.project))
formData.append("addClaimDetailDescriptions", ClaimSave.description)
formData.append("addClaimDetailAmounts", JSON.stringify(ClaimSave.amount))
formData.append("addClaimDetailNewSupportingDocuments", ClaimSave.newSupportingDocument)
formData.append("addClaimDetailOldSupportingDocumentIds", JSON.stringify(ClaimSave?.oldSupportingDocument?.id ?? -1))
})
// for (let i = 0; i < data.addClaimDetails.length; i++) {
// const updatedData = {
@@ -155,4 +155,4 @@ const ClaimDetail: React.FC<Props> = ({ projectCombo }) => {
);
};

export default ClaimDetail;
export default ClaimSave;

src/components/ClaimDetail/ClaimDetailWrapper.tsx → src/components/ClaimSave/ClaimSaveWrapper.tsx Bestand weergeven

@@ -1,6 +1,6 @@

import React from "react";
import ClaimDetail from "./ClaimDetail";
import ClaimSave from "./ClaimSave";
import { fetchProjectCombo } from "@/app/api/claims";
// import TaskSetup from "./TaskSetup";
// import StaffAllocation from "./StaffAllocation";
@@ -13,7 +13,7 @@ const ClaimDetailWrapper: React.FC = async () => {
]);

return (
<ClaimDetail projectCombo={projectCombo}/>
<ClaimSave projectCombo={projectCombo}/>
);
};


+ 1
- 0
src/components/ClaimSave/index.ts Bestand weergeven

@@ -0,0 +1 @@
export { default } from "./ClaimSaveWrapper";

+ 227
- 0
src/components/CompanyHoliday/CompanyHoliday.tsx Bestand weergeven

@@ -0,0 +1,227 @@
"use client";

import { HolidaysList, HolidaysResult } from "@/app/api/holidays";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Grid, Stack } from '@mui/material/';
import { useTranslation } from "react-i18next";
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid' // a plugin!
import interactionPlugin from "@fullcalendar/interaction" // needed for dayClick
import listPlugin from '@fullcalendar/list';
import Holidays from "date-holidays";
import CompanyHolidayDialog from "./CompanyHolidayDialog";
import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm, useFormContext } from "react-hook-form";
import { EventBusy } from "@mui/icons-material";
import { deleteCompanyHoliday, saveCompanyHoliday } from "@/app/api/holidays/actions";
import { useRouter } from "next/navigation";
import { deleteDialog, submitDialog } from "../Swal/CustomAlerts";

interface Props {
holidays: HolidaysList[];
}

const CompanyHoliday: React.FC<Props> = ({ holidays }) => {
const { t } = useTranslation("holidays");
const router = useRouter();
const formValues = useFormContext();
const [serverError, setServerError] = useState("");

const hd = new Holidays('HK')
console.log(holidays)

const [companyHolidays, setCompanyHolidays] = useState<HolidaysList[]>([])
const [dateContent, setDateContent] = useState<{ date: string }>({date: ''})
const [open, setOpen] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [editable, setEditable] = useState(true);

const handleClose = () => {
setOpen(false);
setEditable(true)
setIsEdit(false)
formProps.setValue("name", "")
formProps.setValue("id", null)
};

const getPublicHolidaysList = () => {
const currentYear = new Date().getFullYear()
const currentYearHolidays = hd.getHolidays(currentYear)
const nextYearHolidays = hd.getHolidays(currentYear + 1)
const events_cyhd = currentYearHolidays.map(ele => {
const tempDay = new Date(ele.date)
const tempYear = tempDay.getFullYear()
const tempMonth = tempDay.getMonth() + 1 < 10 ? `0${ tempDay.getMonth() + 1}` : tempDay.getMonth() + 1
const tempDate = tempDay.getDate() < 10 ? `0${tempDay.getDate()}` : tempDay.getDate()
let tempName = ""
switch (ele.name) {
case "复活节":
tempName = "復活節"
break
case "劳动节":
tempName = "勞動節"
break
case "端午节":
tempName = "端午節"
break
case "重阳节":
tempName = "重陽節"
break
case "圣诞节后的第一个工作日":
tempName = "聖誕節後的第一个工作日"
break
default:
tempName = ele.name
break
}

return {date: `${tempYear}-${tempMonth}-${tempDate}`, title: tempName, extendedProps: {calendar: 'holiday'}}
})

const events_nyhd = nextYearHolidays.map(ele => {
const tempDay = new Date(ele.date)
const tempYear = tempDay.getFullYear()
const tempMonth = tempDay.getMonth() + 1 < 10 ? `0${ tempDay.getMonth() + 1}` : tempDay.getMonth() + 1
const tempDate = tempDay.getDate() < 10 ? `0${tempDay.getDate()}` : tempDay.getDate()
let tempName = ""
switch (ele.name) {
case "复活节":
tempName = "復活節"
break
case "劳动节":
tempName = "勞動節"
break
case "端午节":
tempName = "端午節"
break
case "重阳节":
tempName = "重陽節"
break
case "圣诞节后的第一个工作日":
tempName = "聖誕節後的第一个工作日"
break
default:
tempName = ele.name
break
}
return {date: `${tempYear}-${tempMonth}-${tempDate}`, title: tempName, extendedProps: {calendar: 'holiday'}}
})

setCompanyHolidays([...events_cyhd, ...events_nyhd, ...holidays] as HolidaysList[])
}

useEffect(()=>{
getPublicHolidaysList()
},[])

useEffect(()=>{

},[holidays])

const handleDateClick = (event:any) => {
// console.log(event.dateStr)
setDateContent({date: event.dateStr})
setOpen(true);
}
const handleEventClick = (event:any) => {
// event.event.id: if id !== "", holiday is created by company
console.log(event.event.id)
if (event.event.id === null || event.event.id === ""){
setEditable(false)
}
formProps.setValue("name", event.event.title)
formProps.setValue("id", event.event.id)
setDateContent({date: event.event.startStr})
setOpen(true);
setIsEdit(true);
}

const onSubmit = useCallback<SubmitHandler<any>>(
async (data) => {
try {
// console.log(data);
setServerError("");
submitDialog(async () => {
await saveCompanyHoliday(data)
window.location.reload()
setOpen(false);
setIsEdit(false);
}, t)
} catch (e) {
console.log(e);
setServerError(t("An error has occurred. Please try again later."));
}
},
[t, router],
);

const handleDelete = async (event:any) => {
try {
setServerError("");
deleteDialog(async () => {
await deleteCompanyHoliday(parseInt(formProps.getValues("id")))
window.location.reload()
setOpen(false);
setIsEdit(false);
}, t);
} catch (e) {
console.log(e);
setServerError(t("An error has occurred. Please try again later."));
}
}
const onSubmitError = useCallback<SubmitErrorHandler<any>>(
(errors) => {
console.log(errors)
},
[],
);

const formProps = useForm<any>({
defaultValues: {
id: null,
name: ""
},
});

return (
<>
<FormProvider {...formProps}>
<FullCalendar
plugins={[ dayGridPlugin, interactionPlugin, listPlugin ]}
initialView="dayGridMonth"
events={companyHolidays}
eventColor='#ff0000'
dateClick={handleDateClick}
eventClick={handleEventClick}
headerToolbar={{
start: "today prev next",
end: "dayGridMonth listMonth"
}}
buttonText={{
month: t("Calender View"),
list: t("List View"),
today: t("Today")
}}
/>
<CompanyHolidayDialog
open={open}
onClose={handleClose}
title={!editable ? "Bank Holiday" : isEdit ? "Edit Holiday" : "Create Holiday"}
content={dateContent}
actions={
<Stack direction="row" justifyContent="flex-end" gap={1} component="form" onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}>
<Button onClick={handleClose}>Close</Button>
{isEdit && <Button disabled={!editable} onClick={handleDelete}>Delete</Button>}
<Button disabled={!editable} type="submit">Submit</Button>
</Stack>
}
editable={editable}
/>
</FormProvider>
</>
);
};

export default CompanyHoliday;

+ 87
- 0
src/components/CompanyHoliday/CompanyHolidayDialog.tsx Bestand weergeven

@@ -0,0 +1,87 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Grid, FormControl } from '@mui/material/';
import { useForm, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers';
import dayjs from 'dayjs';
import { INPUT_DATE_FORMAT } from '@/app/utils/formatUtil';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';

interface CompanyHolidayDialogProps {
open: boolean;
onClose: () => void;
title: string;
actions: React.ReactNode;
content: Content;
editable: Boolean;
}

interface Content {
date: string
}

const CompanyHolidayDialog: React.FC<CompanyHolidayDialogProps> = ({ open, onClose, title, actions, content, editable }) => {
const {
t,
i18n: { language },
} = useTranslation();

const {
register,
formState: { errors },
setValue,
} = useFormContext<any>();

useEffect(() => {
setValue("date", content.date);
}, [content])

console.log(editable)

return (
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale={`${language}-hk`}
>
<Dialog open={open} onClose={onClose}>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={12}>
<TextField
disabled={!editable}
label={t("Description")}
fullWidth
{...register("name", {
required: "Description required!",
})}
error={Boolean(errors.name)}
/>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<DatePicker
disabled={!editable}
label={t("Company Holiday")}
value={dayjs(content.date)}
onChange={(date) => {
if (!date) return;
setValue("date", date.format(INPUT_DATE_FORMAT));
}}
slotProps={{
textField: {
helperText: 'MM/DD/YYYY',
},
}}
/>
</FormControl>
</Grid>
</Grid>
</DialogContent>
<DialogActions>{actions}</DialogActions>
</Dialog>
</LocalizationProvider>
);
};

export default CompanyHolidayDialog;

+ 40
- 0
src/components/CompanyHoliday/CompanyHolidayLoading.tsx Bestand weergeven

@@ -0,0 +1,40 @@
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import React from "react";

// Can make this nicer
export const CompanyHolidayLoading: React.FC = () => {
return (
<>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton
variant="rounded"
height={50}
width={100}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
</Stack>
</CardContent>
</Card>
</>
);
};

export default CompanyHolidayLoading;

+ 34
- 0
src/components/CompanyHoliday/CompanyHolidayWrapper.tsx Bestand weergeven

@@ -0,0 +1,34 @@
// import { fetchCompanyCategories, fetchCompanys } from "@/app/api/companys";
import React, { useState, } from "react";
import CompanyHoliday from "./CompanyHoliday";
import CompanyHolidayLoading from "./CompanyHolidayLoading";
import { fetchCompanys } from "@/app/api/companys";
import Holidays from "date-holidays";
import { HolidaysResult, fetchHolidays, HolidaysList } from "@/app/api/holidays";
import { convertDateArrayToString } from "@/app/utils/formatUtil";

interface SubComponents {
Loading: typeof CompanyHolidayLoading;
}

const CompanyHolidayWrapper: React.FC & SubComponents = async () => {
// const Companys = await fetchCompanys();

const companyHolidays: HolidaysResult[] = await fetchHolidays()

// console.log(companyHolidays)
const convertedHolidays = companyHolidays.map((holiday) => {
return {
id: holiday.id.toString(),
title: holiday.name,
date: convertDateArrayToString(holiday.date, "YYYY-MM-DD", false)
}
})

return <CompanyHoliday holidays={convertedHolidays as HolidaysList[]} />;
};

CompanyHolidayWrapper.Loading = CompanyHolidayLoading;

export default CompanyHolidayWrapper;

+ 1
- 0
src/components/CompanyHoliday/index.ts Bestand weergeven

@@ -0,0 +1 @@
export { default } from "./CompanyHolidayWrapper";

+ 14
- 8
src/components/CreateDepartment/CreateDepartment.tsx Bestand weergeven

@@ -22,18 +22,23 @@ import { Error } from "@mui/icons-material";
import { ProjectCategory } from "@/app/api/projects";
import { Typography } from "@mui/material";
import DepartmentDetails from "./DepartmentDetails";
import { DepartmentResult } from "@/app/api/departments";

interface Props {
isEdit: Boolean;
department?: CreateDepartmentInputs;
}

const CreateDepartment: React.FC = ({
// allTasks,
// projectCategories,
// taskTemplates,
// teamLeads,
const CreateDepartment: React.FC<Props> = ({
isEdit,
department,
}) => {
const [serverError, setServerError] = useState("");
const { t } = useTranslation();
const router = useRouter();

console.log(department)
const handleCancel = () => {
router.back();
};
@@ -62,9 +67,10 @@ const CreateDepartment: React.FC = ({

const formProps = useForm<CreateDepartmentInputs>({
defaultValues: {
departmentCode: "",
departmentName: "",
description: "",
id: department?.id,
code: department?.code,
name: department?.name,
description: department?.description,
},
});



+ 15
- 9
src/components/CreateDepartment/CreateDepartmentWrapper.tsx Bestand weergeven

@@ -1,18 +1,24 @@
import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks";
import CreateDepartment from "./CreateDepartment";
import { fetchTeamLeads } from "@/app/api/staff";
import { DepartmentResult, fetchDepartmentDetails } from "@/app/api/departments";

const CreateDepartmentWrapper: React.FC = async () => {
// const [tasks, taskTemplates, DepartmentCategories, teamLeads] =
// await Promise.all([
// fetchAllTasks(),
// fetchTaskTemplates(),
// fetchDepartmentCategories(),
// fetchTeamLeads(),
// ]);
type CreateDepartmentProps = { isEdit: false };
interface EditDepartmentProps {
isEdit: true;
departmentId?: string;
}

type Props = CreateDepartmentProps | EditDepartmentProps;

const CreateDepartmentWrapper: React.FC<Props> = async (props) => {
const departmentInfo = props.isEdit
? await fetchDepartmentDetails(props.departmentId!)
: undefined;

return (
<CreateDepartment
<CreateDepartment isEdit department={departmentInfo}
/>
);
};


+ 4
- 4
src/components/CreateDepartment/DepartmentDetails.tsx Bestand weergeven

@@ -39,20 +39,20 @@ const DepartmentDetails: React.FC = ({
<TextField
label={t("Department Code")}
fullWidth
{...register("departmentCode", {
{...register("code", {
required: "Department code required!",
})}
error={Boolean(errors.departmentCode)}
error={Boolean(errors.code)}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Department Name")}
fullWidth
{...register("departmentName", {
{...register("name", {
required: "Department name required!",
})}
error={Boolean(errors.departmentName)}
error={Boolean(errors.name)}
/>
</Grid>
<Grid item xs={6}>


+ 208
- 0
src/components/CreateGroup/AuthorityAllocation.tsx Bestand weergeven

@@ -0,0 +1,208 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
FieldErrors,
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
useFormContext,
} from "react-hook-form";
import {
Box,
Card,
CardContent,
Grid,
IconButton,
InputAdornment,
Stack,
Tab,
Tabs,
TabsProps,
TextField,
Typography,
} from "@mui/material";
import { differenceBy } from "lodash";
import { CreateGroupInputs, auth } from "@/app/api/group/actions";
import SearchResults, { Column } from "../SearchResults";
import { Add, Clear, Remove, Search } from "@mui/icons-material";

export interface Props {
auth: auth[];
}

const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
const { t } = useTranslation();
const {
setValue,
getValues,
formState: { defaultValues },
reset,
resetField,
} = useFormContext<CreateGroupInputs>();
const initialAuths = auth.map((a) => ({ ...a })).sort((a, b) => a.id - b.id);
const [filteredAuths, setFilteredAuths] = useState(initialAuths);
const [selectedAuths, setSelectedAuths] = useState<typeof filteredAuths>(
() => {
return filteredAuths.filter(
(s) => getValues("addAuthIds")?.includes(s.id)
);
}
);

// Adding / Removing Auth
const addAuth = useCallback((auth: auth) => {
setSelectedAuths((a) => [...a, auth]);
}, []);
const removeAuth = useCallback((auth: auth) => {
setSelectedAuths((a) => a.filter((a) => a.id !== auth.id));
}, []);

const clearAuth = useCallback(() => {
if (defaultValues !== undefined) {
resetField("addAuthIds");
setSelectedAuths(
initialAuths.filter((s) => defaultValues.addAuthIds?.includes(s.id))
);
}
}, [defaultValues]);

// Sync with form
useEffect(() => {
setValue(
"addAuthIds",
selectedAuths.map((a) => a.id)
);
}, [selectedAuths, setValue]);

const AuthPoolColumns = useMemo<Column<auth>[]>(
() => [
{
label: t("Add"),
name: "id",
onClick: addAuth,
buttonIcon: <Add />,
},
{ label: t("authority"), name: "authority" },
{ label: t("Auth Name"), name: "name" },
// { label: t("Current Position"), name: "currentPosition" },
],
[addAuth, t]
);

const allocatedAuthColumns = useMemo<Column<auth>[]>(
() => [
{
label: t("Remove"),
name: "id",
onClick: removeAuth,
buttonIcon: <Remove color="warning"/>,
},
{ label: t("authority"), name: "authority" },
{ label: t("Auth Name"), name: "name" },
],
[removeAuth, selectedAuths, t]
);
const [query, setQuery] = React.useState("");
const onQueryInputChange = React.useCallback<
React.ChangeEventHandler<HTMLInputElement>
>((e) => {
setQuery(e.target.value);
}, []);
const clearQueryInput = React.useCallback(() => {
setQuery("");
}, []);

React.useEffect(() => {
// setFilteredStaff(
// initialStaffs.filter((s) => {
// const q = query.toLowerCase();
// // s.staffId.toLowerCase().includes(q)
// // const q = query.toLowerCase();
// // return s.name.toLowerCase().includes(q);
// // s.code.toString().includes(q) ||
// // (s.brNo != null && s.brNo.toLowerCase().includes(q))
// })
// );
}, [auth, query]);

const resetAuth = React.useCallback(() => {
clearQueryInput();
clearAuth();
}, [clearQueryInput, clearAuth]);

const formProps = useForm({});

// Tab related
const [tabIndex, setTabIndex] = React.useState(0);
const handleTabChange = React.useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[]
);

return (
<>
<FormProvider {...formProps}>
<Card sx={{ display: "block" }}>
<CardContent
sx={{ display: "flex", flexDirection: "column", gap: 1 }}
>
<Stack gap={2}>
<Typography variant="overline" display="block">
{t("Authority")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6} display="flex" alignItems="center">
<Search sx={{ marginInlineEnd: 1 }} />
<TextField
variant="standard"
fullWidth
onChange={onQueryInputChange}
value={query}
placeholder={t("Search by staff ID, name or position.")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">
<IconButton onClick={clearQueryInput}>
<Clear />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
</Grid>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("Authority Pool")} />
<Tab
label={`${t("Allocated Authority")} (${selectedAuths.length})`}
/>
</Tabs>
<Box sx={{ marginInline: -3 }}>
{tabIndex === 0 && (
<SearchResults
noWrapper
items={differenceBy(filteredAuths, selectedAuths, "id")}
columns={AuthPoolColumns}
/>
)}
{tabIndex === 1 && (
<SearchResults
noWrapper
items={selectedAuths}
columns={allocatedAuthColumns}
/>
)}
</Box>
</Stack>
</CardContent>
</Card>
</FormProvider>
</>
);
};

export default AuthorityAllocation;

+ 130
- 0
src/components/CreateGroup/CreateGroup.tsx Bestand weergeven

@@ -0,0 +1,130 @@
"use client";

import { CreateGroupInputs, auth, saveGroup } from "@/app/api/group/actions";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { FieldErrors, FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Button, Stack, Tab, Tabs, TabsProps, Typography } from "@mui/material";
import { Check, Close, Error } from "@mui/icons-material";
import GroupInfo from "./GroupInfo";
import AuthorityAllocation from "./AuthorityAllocation";
import UserAllocation from "./UserAllocation";
import { UserResult } from "@/app/api/user";

interface Props {
auth?: auth[]
users?: UserResult[]
}

const CreateGroup: React.FC<Props> = ({ auth, users }) => {
const formProps = useForm<CreateGroupInputs>();
const [serverError, setServerError] = useState("");
const router = useRouter();
const [tabIndex, setTabIndex] = useState(0);
const { t } = useTranslation();

const errors = formProps.formState.errors;

const onSubmit = useCallback<SubmitHandler<CreateGroupInputs>>(
async (data) => {
try {
console.log(data);
const postData = {
...data,
removeUserIds: [],
removeAuthIds: [],

}
console.log(postData)
await saveGroup(postData)
router.replace("/settings/group")
} catch (e) {
console.log(e);
setServerError(t("An error has occurred. Please try again later."));
}
},
[router]
);
const handleCancel = () => {
router.back();
};

const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[]
);

const hasErrorsInTab = (
tabIndex: number,
errors: FieldErrors<CreateGroupInputs>,
) => {
switch (tabIndex) {
case 0:
return Object.keys(errors).length > 0;
default:
false;
}
};

return (
<>
<FormProvider {...formProps}>
<Stack
spacing={2}
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Tabs
value={tabIndex}
onChange={handleTabChange}
variant="scrollable"
>
<Tab
label={t("Group Info")}
icon={
hasErrorsInTab(0, errors) ? (
<Error sx={{ marginInlineEnd: 1 }} color="error" />
) : undefined
}
iconPosition="end"
/>
<Tab label={t("Authority Allocation")} iconPosition="end" />
<Tab label={t("User Allocation")} iconPosition="end" />
</Tabs>
{serverError && (
<Typography variant="body2" color="error" alignSelf="flex-end">
{serverError}
</Typography>
)}
{tabIndex === 0 && <GroupInfo/>}
{tabIndex === 1 && <AuthorityAllocation auth={auth!!}/>}
{tabIndex === 2 && <UserAllocation users={users!!}/>}

<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={handleCancel}
>
{t("Cancel")}
</Button>
<Button
variant="contained"
startIcon={<Check />}
type="submit"
// disabled={Boolean(formProps.watch("isGridEditing"))}
>
{t("Confirm")}
</Button>
</Stack>
</Stack>
</FormProvider>
</>
);
};

export default CreateGroup;

+ 40
- 0
src/components/CreateGroup/CreateGroupLoading.tsx Bestand weergeven

@@ -0,0 +1,40 @@
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import React from "react";

// Can make this nicer
export const CreateGroupLoading: React.FC = () => {
return (
<>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton
variant="rounded"
height={50}
width={100}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
</CardContent>
</Card>
<Card>Create Group
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
</Stack>
</CardContent>
</Card>
</>
);
};

export default CreateGroupLoading;

+ 24
- 0
src/components/CreateGroup/CreateGroupWrapper.tsx Bestand weergeven

@@ -0,0 +1,24 @@
import React from "react";
import CreateGroupLoading from "./CreateGroupLoading";
import { fetchStaff, fetchTeamLeads } from "@/app/api/staff";
import { useSearchParams } from "next/navigation";
import CreateGroup from "./CreateGroup";
import { auth, fetchAuth } from "@/app/api/group/actions";
import { fetchUser } from "@/app/api/user";

interface SubComponents {
Loading: typeof CreateGroupLoading;
}

const CreateGroupWrapper: React.FC & SubComponents = async () => {
const records = await fetchAuth()
const users = await fetchUser()
console.log(users)
const auth = records.records as auth[]

return <CreateGroup auth={auth} users={users}/>;
};

CreateGroupWrapper.Loading = CreateGroupLoading;

export default CreateGroupWrapper;

+ 81
- 0
src/components/CreateGroup/GroupInfo.tsx Bestand weergeven

@@ -0,0 +1,81 @@
"use client";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { CreateGroupInputs } from "@/app/api/group/actions";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";

const GroupInfo: React.FC = () => {
const { t } = useTranslation();
const {
register,
formState: { errors, defaultValues },
control,
reset,
resetField,
setValue,
} = useFormContext<CreateGroupInputs>();

const resetGroup = useCallback(() => {
console.log(defaultValues);
if (defaultValues !== undefined) {
resetField("description");
}
}, [defaultValues]);


return (
<Card sx={{ display: "block" }}>
<CardContent component={Stack} spacing={4}>
<Box>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Group Info")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6}>
<TextField
label={t("Group Name")}
fullWidth
{...register("name", {
required: true,
})}
error={Boolean(errors.name)}
helperText={
Boolean(errors.name) &&
(errors.name?.message
? t(errors.name.message)
: t("Please input correct name"))
}
/>
</Grid>
<Grid item xs={12}>
<TextField
label={t("Group Description")}
fullWidth
multiline
rows={4}
{...register("description")}
error={Boolean(errors.description)}
helperText={
Boolean(errors.description) &&
(errors.description?.message
? t(errors.description.message)
: t("Please input correct description"))
}
/>
</Grid>
</Grid>
</Box>
</CardContent>
</Card>
);
};

export default GroupInfo;

+ 209
- 0
src/components/CreateGroup/UserAllocation.tsx Bestand weergeven

@@ -0,0 +1,209 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
FieldErrors,
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
useFormContext,
} from "react-hook-form";
import {
Box,
Card,
CardContent,
Grid,
IconButton,
InputAdornment,
Stack,
Tab,
Tabs,
TabsProps,
TextField,
Typography,
} from "@mui/material";
import { differenceBy } from "lodash";
import { CreateGroupInputs, auth } from "@/app/api/group/actions";
import SearchResults, { Column } from "../SearchResults";
import { Add, Clear, Remove, Search } from "@mui/icons-material";
import { UserResult } from "@/app/api/user";

export interface Props {
users: UserResult[];
}

const UserAllocation: React.FC<Props> = ({ users }) => {
const { t } = useTranslation();
const {
setValue,
getValues,
formState: { defaultValues },
reset,
resetField,
} = useFormContext<CreateGroupInputs>();
const initialUsers = users.map((u) => ({ ...u })).sort((a, b) => a.id - b.id).filter((u) => u.groupId !== null);
const [filteredUsers, setFilteredUsers] = useState(initialUsers);
const [selectedUsers, setSelectedUsers] = useState<typeof filteredUsers>(
() => {
return filteredUsers.filter(
(s) => getValues("addUserIds")?.includes(s.id)
);
}
);
// Adding / Removing Auth
const addUser = useCallback((users: UserResult) => {
setSelectedUsers((a) => [...a, users]);
}, []);

const removeUser = useCallback((users: UserResult) => {
setSelectedUsers((a) => a.filter((a) => a.id !== users.id));
}, []);

const clearUser = useCallback(() => {
if (defaultValues !== undefined) {
resetField("addUserIds");
setSelectedUsers(
initialUsers.filter((s) => defaultValues.addUserIds?.includes(s.id))
);
}
}, [defaultValues]);

// Sync with form
useEffect(() => {
setValue(
"addUserIds",
selectedUsers.map((u) => u.id)
);
}, [selectedUsers, setValue]);

const UserPoolColumns = useMemo<Column<UserResult>[]>(
() => [
{
label: t("Add"),
name: "id",
onClick: addUser,
buttonIcon: <Add />,
},
{ label: t("User Name"), name: "username" },
{ label: t("name"), name: "name" },
],
[addUser, t]
);

const allocatedUserColumns = useMemo<Column<UserResult>[]>(
() => [
{
label: t("Remove"),
name: "id",
onClick: removeUser,
buttonIcon: <Remove color="warning" />,
},
{ label: t("User Name"), name: "username" },
{ label: t("name"), name: "name" },
],
[removeUser, selectedUsers, t]
);

const [query, setQuery] = React.useState("");
const onQueryInputChange = React.useCallback<
React.ChangeEventHandler<HTMLInputElement>
>((e) => {
setQuery(e.target.value);
}, []);
const clearQueryInput = React.useCallback(() => {
setQuery("");
}, []);

React.useEffect(() => {
// setFilteredStaff(
// initialStaffs.filter((s) => {
// const q = query.toLowerCase();
// // s.staffId.toLowerCase().includes(q)
// // const q = query.toLowerCase();
// // return s.name.toLowerCase().includes(q);
// // s.code.toString().includes(q) ||
// // (s.brNo != null && s.brNo.toLowerCase().includes(q))
// })
// );
}, [users, query]);

const resetUser = React.useCallback(() => {
clearQueryInput();
clearUser();
}, [clearQueryInput, clearUser]);

const formProps = useForm({});

// Tab related
const [tabIndex, setTabIndex] = React.useState(0);
const handleTabChange = React.useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[]
);

return (
<>
<FormProvider {...formProps}>
<Card sx={{ display: "block" }}>
<CardContent
sx={{ display: "flex", flexDirection: "column", gap: 1 }}
>
<Stack gap={2}>
<Typography variant="overline" display="block">
{t("User")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6} display="flex" alignItems="center">
<Search sx={{ marginInlineEnd: 1 }} />
<TextField
variant="standard"
fullWidth
onChange={onQueryInputChange}
value={query}
placeholder={t("Search by staff ID, name or position.")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">
<IconButton onClick={clearQueryInput}>
<Clear />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
</Grid>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("User Pool")} />
<Tab
label={`${t("Allocated Users")} (${selectedUsers.length})`}
/>
</Tabs>
<Box sx={{ marginInline: -3 }}>
{tabIndex === 0 && (
<SearchResults
noWrapper
items={differenceBy(filteredUsers, selectedUsers, "id")}
columns={UserPoolColumns}
/>
)}
{tabIndex === 1 && (
<SearchResults
noWrapper
items={selectedUsers}
columns={allocatedUserColumns}
/>
)}
</Box>
</Stack>
</CardContent>
</Card>
</FormProvider>
</>
);
};

export default UserAllocation;

+ 1
- 0
src/components/CreateGroup/index.ts Bestand weergeven

@@ -0,0 +1 @@
export { default } from "./CreateGroupWrapper"

src/components/CreateInvoice/CreateInvoice.tsx → src/components/CreateInvoice_forGen/CreateInvoice.tsx Bestand weergeven


src/components/CreateInvoice/CreateInvoiceWrapper.tsx → src/components/CreateInvoice_forGen/CreateInvoiceWrapper.tsx Bestand weergeven


src/components/CreateInvoice/InvoiceDetails.tsx → src/components/CreateInvoice_forGen/InvoiceDetails.tsx Bestand weergeven


src/components/CreateInvoice/ProjectDetails.tsx → src/components/CreateInvoice_forGen/ProjectDetails.tsx Bestand weergeven


src/components/CreateInvoice/ProjectTotalFee.tsx → src/components/CreateInvoice_forGen/ProjectTotalFee.tsx Bestand weergeven


src/components/CreateInvoice/index.ts → src/components/CreateInvoice_forGen/index.ts Bestand weergeven


+ 4
- 4
src/components/CreatePosition/PositionDetails.tsx Bestand weergeven

@@ -39,20 +39,20 @@ const PositionDetails: React.FC = ({
<TextField
label={t("Position Code")}
fullWidth
{...register("positionCode", {
{...register("code", {
required: "Position code required!",
})}
error={Boolean(errors.positionCode)}
error={Boolean(errors.code)}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Position Name")}
fullWidth
{...register("positionName", {
{...register("name", {
required: "Position name required!",
})}
error={Boolean(errors.positionName)}
error={Boolean(errors.name)}
/>
</Grid>
<Grid item xs={6}>


+ 288
- 77
src/components/CreateProject/CreateProject.tsx Bestand weergeven

@@ -1,5 +1,6 @@
"use client";

import DoneIcon from "@mui/icons-material/Done";
import Check from "@mui/icons-material/Check";
import Close from "@mui/icons-material/Close";
import Button from "@mui/material/Button";
@@ -21,8 +22,12 @@ import {
SubmitHandler,
useForm,
} from "react-hook-form";
import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions";
import { Error } from "@mui/icons-material";
import {
CreateProjectInputs,
deleteProject,
saveProject,
} from "@/app/api/projects/actions";
import { Delete, Error, PlayArrow } from "@mui/icons-material";
import {
BuildingType,
ContractType,
@@ -36,8 +41,18 @@ import { StaffResult } from "@/app/api/staff";
import { Typography } from "@mui/material";
import { Grade } from "@/app/api/grades";
import { Customer, Subsidiary } from "@/app/api/customer";
import { isEmpty } from "lodash";
import {
deleteDialog,
errorDialog,
submitDialog,
successDialog,
} from "../Swal/CustomAlerts";
import dayjs from "dayjs";

export interface Props {
isEditMode: boolean;
defaultInputs?: CreateProjectInputs;
allTasks: Task[];
projectCategories: ProjectCategory[];
taskTemplates: TaskTemplate[];
@@ -63,12 +78,22 @@ const hasErrorsInTab = (
return (
errors.projectName || errors.projectCode || errors.projectDescription
);
case 2:
return (
errors.totalManhour || errors.manhourPercentageByGrade || errors.taskGroups
);
case 3:
return (
errors.milestones
)
default:
false;
}
};

const CreateProject: React.FC<Props> = ({
isEditMode,
defaultInputs,
allTasks,
projectCategories,
taskTemplates,
@@ -90,7 +115,19 @@ const CreateProject: React.FC<Props> = ({
const router = useRouter();

const handleCancel = () => {
router.back();
router.replace("/projects");
};

const handleDelete = () => {
deleteDialog(async () => {
await deleteProject(formProps.getValues("projectId")!);

const clickSuccessDialog = await successDialog("Delete Success", t);

if (clickSuccessDialog) {
router.replace("/projects");
}
}, t);
};

const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
@@ -101,11 +138,102 @@ const CreateProject: React.FC<Props> = ({
);

const onSubmit = useCallback<SubmitHandler<CreateProjectInputs>>(
async (data) => {
async (data, event) => {
try {
console.log(data);

// detect errors
let hasErrors = false

// Tab - Staff Allocation and Resource
if (data.totalManhour === null || data.totalManhour <= 0) {
formProps.setError("totalManhour", { message: "totalManhour value is not valid", type: "required" })
setTabIndex(2)
hasErrors = true
}

const manhourPercentageByGradeKeys = Object.keys(data.manhourPercentageByGrade)
if (manhourPercentageByGradeKeys.filter(k => data.manhourPercentageByGrade[k as any] < 0).length > 0 ||
manhourPercentageByGradeKeys.reduce((acc, value) => acc + data.manhourPercentageByGrade[value as any], 0) !== 100) {
formProps.setError("manhourPercentageByGrade", { message: "manhourPercentageByGrade value is not valid", type: "invalid" })
setTabIndex(2)
hasErrors = true
}

const taskGroupKeys = Object.keys(data.taskGroups)
if (taskGroupKeys.filter(k => data.taskGroups[k as any].percentAllocation < 0).length > 0 ||
taskGroupKeys.reduce((acc, value) => acc + data.taskGroups[value as any].percentAllocation, 0) !== 100) {
formProps.setError("taskGroups", { message: "Task Groups value is not invalid", type: "invalid" })
setTabIndex(2)
hasErrors = true
}

// Tab - Milestone
let projectTotal = 0
const milestonesKeys = Object.keys(data.milestones)
milestonesKeys.filter(key => Object.keys(data.taskGroups).includes(key)).forEach(key => {
const { startDate, endDate, payments } = data.milestones[parseFloat(key)]

if (!Boolean(startDate) || startDate === "Invalid Date" || !Boolean(endDate) || endDate === "Invalid Date" || new Date(startDate) > new Date(endDate)) {
formProps.setError("milestones", {message: "milestones is not valid", type: "invalid"})
setTabIndex(3)
hasErrors = true
}

projectTotal += payments.reduce((acc, payment) => acc + payment.amount, 0)
})

if (projectTotal !== data.expectedProjectFee) {
formProps.setError("milestones", {message: "milestones is not valid", type: "invalid"})
setTabIndex(3)
hasErrors = true
}

if (hasErrors) return false
// save project
setServerError("");
await saveProject(data);
router.replace("/projects");

let title = t("Do you want to submit?");
let confirmButtonText = t("Submit");
let successTitle = t("Submit Success");
let errorTitle = t("Submit Fail");
const buttonName = (event?.nativeEvent as any).submitter.name;

if (buttonName === "start") {
title = t("Do you want to start?");
confirmButtonText = t("Start");
successTitle = t("Start Success");
errorTitle = t("Start Fail");
} else if (buttonName === "complete") {
title = t("Do you want to complete?");
confirmButtonText = t("Complete");
successTitle = t("Complete Success");
errorTitle = t("Complete Fail");
}

submitDialog(
async () => {
if (buttonName === "start") {
data.projectActualStart = dayjs().format("YYYY-MM-DD");
} else if (buttonName === "complete") {
data.projectActualEnd = dayjs().format("YYYY-MM-DD");
}

const response = await saveProject(data);

if (response.id > 0) {
successDialog(successTitle, t).then(() => {
router.replace("/projects");
});
} else {
errorDialog(errorTitle, t).then(() => {
return false;
});
}
},
t,
{ title: title, confirmButtonText: confirmButtonText },
);
} catch (e) {
setServerError(t("An error has occurred. Please try again later."));
}
@@ -115,6 +243,7 @@ const CreateProject: React.FC<Props> = ({

const onSubmitError = useCallback<SubmitErrorHandler<CreateProjectInputs>>(
(errors) => {
console.log(errors)
// Set the tab so that the focus will go there
if (
errors.projectName ||
@@ -122,6 +251,10 @@ const CreateProject: React.FC<Props> = ({
errors.projectCode
) {
setTabIndex(0);
} else if (errors.totalManhour || errors.manhourPercentageByGrade || errors.taskGroups) {
setTabIndex(2)
} else if (errors.milestones) {
setTabIndex(3)
}
},
[],
@@ -133,85 +266,163 @@ const CreateProject: React.FC<Props> = ({
allocatedStaffIds: [],
milestones: {},
totalManhour: 0,
manhourPercentageByGrade: grades.reduce((acc, grade) => {
return { ...acc, [grade.id]: 1 / grades.length };
}, {}),
...defaultInputs,

// manhourPercentageByGrade should have a sensible default
manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade)
? grades.reduce((acc, grade) => {
return { ...acc, [grade.id]: 100 / grades.length };
}, {})
: defaultInputs?.manhourPercentageByGrade,
},
});

const errors = formProps.formState.errors;

return (
<FormProvider {...formProps}>
<Stack
spacing={2}
component="form"
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}
>
<Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable">
<Tab
label={t("Project and Client Details")}
icon={
hasErrorsInTab(0, errors) ? (
<Error sx={{ marginInlineEnd: 1 }} color="error" />
) : undefined
}
iconPosition="end"
/>
<Tab label={t("Project Task Setup")} iconPosition="end" />
<Tab label={t("Staff Allocation and Resource")} iconPosition="end" />
<Tab label={t("Milestone")} iconPosition="end" />
</Tabs>
{
<ProjectClientDetails
buildingTypes={buildingTypes}
workNatures={workNatures}
contractTypes={contractTypes}
fundingTypes={fundingTypes}
locationTypes={locationTypes}
serviceTypes={serviceTypes}
allCustomers={allCustomers}
allSubsidiaries={allSubsidiaries}
projectCategories={projectCategories}
teamLeads={teamLeads}
isActive={tabIndex === 0}
/>
}
{
<TaskSetup
allTasks={allTasks}
taskTemplates={taskTemplates}
isActive={tabIndex === 1}
/>
}
{
<StaffAllocation
isActive={tabIndex === 2}
allTasks={allTasks}
grades={grades}
allStaffs={allStaffs}
/>
}
{<Milestone allTasks={allTasks} isActive={tabIndex === 3} />}
{serverError && (
<Typography variant="body2" color="error" alignSelf="flex-end">
{serverError}
</Typography>
)}
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={handleCancel}
<>
<FormProvider {...formProps}>
<Stack
spacing={2}
component="form"
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}
>
{isEditMode && !(formProps.getValues("projectDeleted") === true) && (
<Stack direction="row" gap={1}>
{!formProps.getValues("projectActualStart") && (
<Button
name="start"
type="submit"
variant="contained"
startIcon={<PlayArrow />}
color="success"
>
{t("Start Project")}
</Button>
)}
{formProps.getValues("projectActualStart") &&
!formProps.getValues("projectActualEnd") && (
<Button
name="complete"
type="submit"
variant="contained"
startIcon={<DoneIcon />}
color="info"
>
{t("Complete Project")}
</Button>
)}
{!(
formProps.getValues("projectActualStart") &&
formProps.getValues("projectActualEnd")
) && (
<Button
variant="outlined"
startIcon={<Delete />}
color="error"
onClick={handleDelete}
>
{t("Delete Project")}
</Button>
)}
</Stack>
)}
<Tabs
value={tabIndex}
onChange={handleTabChange}
variant="scrollable"
>
{t("Cancel")}
</Button>
<Button variant="contained" startIcon={<Check />} type="submit">
{t("Confirm")}
</Button>
<Tab
label={t("Project and Client Details")}
sx={{ marginInlineEnd: !hasErrorsInTab(1, errors) && (hasErrorsInTab(2, errors) || hasErrorsInTab(3, errors)) ? 1 : undefined }}
icon={
hasErrorsInTab(0, errors) ? (
<Error sx={{ marginInlineEnd: 1 }} color="error" />
) : undefined
}
iconPosition="end"
/>
<Tab
label={t("Project Task Setup")}
sx={{ marginInlineEnd: hasErrorsInTab(2, errors) || hasErrorsInTab(3, errors) ? 1 : undefined }}
iconPosition="end" />
<Tab
label={t("Staff Allocation and Resource")}
sx={{ marginInlineEnd: !hasErrorsInTab(2, errors) && hasErrorsInTab(3, errors) ? 1 : undefined }}
icon={
hasErrorsInTab(2, errors) ? (
<Error sx={{ marginInlineEnd: 1 }} color="error" />
) : undefined
}
iconPosition="end"
/>
<Tab label={t("Milestone")}
icon={
hasErrorsInTab(3, errors) ? (
<Error sx={{ marginInlineEnd: 1 }} color="error" />)
: undefined}
iconPosition="end" />
</Tabs>
{
<ProjectClientDetails
buildingTypes={buildingTypes}
workNatures={workNatures}
contractTypes={contractTypes}
fundingTypes={fundingTypes}
locationTypes={locationTypes}
serviceTypes={serviceTypes}
allCustomers={allCustomers}
allSubsidiaries={allSubsidiaries}
projectCategories={projectCategories}
teamLeads={teamLeads}
isActive={tabIndex === 0}
/>
}
{
<TaskSetup
allTasks={allTasks}
taskTemplates={taskTemplates}
isActive={tabIndex === 1}
/>
}
{
<StaffAllocation
isActive={tabIndex === 2}
allTasks={allTasks}
grades={grades}
allStaffs={allStaffs}
/>
}
{<Milestone allTasks={allTasks} isActive={tabIndex === 3} />}
{serverError && (
<Typography variant="body2" color="error" alignSelf="flex-end">
{serverError}
</Typography>
)}
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={handleCancel}
>
{t("Cancel")}
</Button>
<Button
variant="contained"
startIcon={<Check />}
type="submit"
disabled={
formProps.getValues("projectDeleted") === true ||
(!!formProps.getValues("projectActualStart") &&
!!formProps.getValues("projectActualEnd"))
}
>
{isEditMode ? t("Save") : t("Confirm")}
</Button>
</Stack>
</Stack>
</Stack>
</FormProvider>
</FormProvider>
</>
);
};



+ 16
- 1
src/components/CreateProject/CreateProjectWrapper.tsx Bestand weergeven

@@ -4,6 +4,7 @@ import {
fetchProjectBuildingTypes,
fetchProjectCategories,
fetchProjectContractTypes,
fetchProjectDetails,
fetchProjectFundingTypes,
fetchProjectLocationTypes,
fetchProjectServiceTypes,
@@ -13,7 +14,15 @@ import { fetchStaff, fetchTeamLeads } from "@/app/api/staff";
import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer";
import { fetchGrades } from "@/app/api/grades";

const CreateProjectWrapper: React.FC = async () => {
type CreateProjectProps = { isEditMode: false };
interface EditProjectProps {
isEditMode: true;
projectId?: string;
}

type Props = CreateProjectProps | EditProjectProps;

const CreateProjectWrapper: React.FC<Props> = async (props) => {
const [
tasks,
taskTemplates,
@@ -46,8 +55,14 @@ const CreateProjectWrapper: React.FC = async () => {
fetchGrades(),
]);

const projectInfo = props.isEditMode
? await fetchProjectDetails(props.projectId!)
: undefined;

return (
<CreateProject
isEditMode={props.isEditMode}
defaultInputs={projectInfo}
allTasks={tasks}
projectCategories={projectCategories}
taskTemplates={taskTemplates}


+ 31
- 2
src/components/CreateProject/Milestone.tsx Bestand weergeven

@@ -4,7 +4,7 @@ import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import { useTranslation } from "react-i18next";
import Button from "@mui/material/Button";
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import CardActions from "@mui/material/CardActions";
import RestartAlt from "@mui/icons-material/RestartAlt";
import {
@@ -29,7 +29,7 @@ export interface Props {

const Milestone: React.FC<Props> = ({ allTasks, isActive }) => {
const { t } = useTranslation();
const { watch } = useFormContext<CreateProjectInputs>();
const { watch, setError, clearErrors } = useFormContext<CreateProjectInputs>();
const currentTaskGroups = watch("taskGroups");
const taskGroups = useMemo(
() =>
@@ -57,6 +57,35 @@ const Milestone: React.FC<Props> = ({ allTasks, isActive }) => {
[],
);

// handle error checking
const milestones = watch("milestones")
const expectedTotalFee = watch("expectedProjectFee");
useEffect(() => {
const milestonesKeys = Object.keys(milestones)
let hasError = false
let projectTotal = 0

milestonesKeys.filter(key => taskGroups.map(taskGroup => taskGroup.id).includes(parseInt(key))).forEach(key => {
const { startDate, endDate, payments } = milestones[parseFloat(key)]

if (new Date(startDate) > new Date(endDate) || !Boolean(startDate) || !Boolean(endDate)) {
hasError = true
}

projectTotal += payments.reduce((acc, payment) => acc + payment.amount, 0)
})

if (projectTotal !== expectedTotalFee) {
hasError = true
}
// console.log(Object.keys(milestones).reduce((acc, key) => acc + milestones[parseFloat(key)].payments.reduce((acc2, value) => acc2 + value.amount, 0), 0))
if (hasError) {
setError("milestones", {message: "milestones is not valid", type: "invalid"})
} else {
clearErrors("milestones")
}
}, [milestones])

return (
<>
<Card sx={{ display: isActive ? "block" : "none" }}>


+ 30
- 18
src/components/CreateProject/MilestoneSection.tsx Bestand weergeven

@@ -26,7 +26,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs from "dayjs";
import "dayjs/locale/zh-hk";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useFormContext } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import StyledDataGrid from "../StyledDataGrid";
import { INPUT_DATE_FORMAT, moneyFormatter } from "@/app/utils/formatUtil";
@@ -57,13 +57,15 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => {

const apiRef = useGridApiRef();
const addRow = useCallback(() => {
const id = Date.now();
// const id = Date.now();
const minId = Math.min(...payments.map((payment) => payment.id!!));
const id = minId >= 0 ? -1 : minId - 1
setPayments((p) => [...p, { id, _isNew: true }]);
setRowModesModel((model) => ({
...model,
[id]: { mode: GridRowModes.Edit, fieldToFocus: "description" },
}));
}, []);
}, [payments]);

const validateRow = useCallback(
(id: GridRowId) => {
@@ -239,21 +241,26 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => {
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs>
<FormControl fullWidth>
<DatePicker
label={t("Stage Start Date")}
value={startDate ? dayjs(startDate) : null}
onChange={(date) => {
if (!date) return;
const milestones = getValues("milestones");
setValue("milestones", {
...milestones,
[taskGroupId]: {
...milestones[taskGroupId],
startDate: date.format(INPUT_DATE_FORMAT),
},
});
}}
/>
<DatePicker
label={t("Stage Start Date")}
value={startDate ? dayjs(startDate) : null}
onChange={(date) => {
if (!date) return;
const milestones = getValues("milestones");
setValue("milestones", {
...milestones,
[taskGroupId]: {
...milestones[taskGroupId],
startDate: date.format(INPUT_DATE_FORMAT),
},
});
}}
slotProps={{
textField: {
error: startDate === "Invalid Date" || new Date(startDate) > new Date(endDate) || (Boolean(formState.errors.milestones) && !Boolean(startDate)),
},
}}
/>
</FormControl>
</Grid>
<Grid item xs>
@@ -272,6 +279,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => {
},
});
}}
slotProps={{
textField: {
error: endDate === "Invalid Date" || new Date(startDate) > new Date(endDate) || (Boolean(formState.errors.milestones) && !Boolean(endDate)),
},
}}
/>
</FormControl>
</Grid>


+ 3
- 2
src/components/CreateProject/ProjectTotalFee.tsx Bestand weergeven

@@ -23,6 +23,7 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => {
{taskGroups.map((group, index) => {
const payments = milestones[group.id]?.payments || [];
const paymentTotal = payments.reduce((acc, p) => acc + p.amount, 0);

projectTotal += paymentTotal;

return (
@@ -41,9 +42,9 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => {
<Typography variant="h6">{t("Project Total Fee")}</Typography>
<Typography>{moneyFormatter.format(projectTotal)}</Typography>
</Stack>
{projectTotal > expectedTotalFee && (
{projectTotal !== expectedTotalFee && (
<Typography variant="caption" color="warning.main" alignSelf="flex-end">
{t("Project total fee is larger than the expected total fee!")}
{t("Project total fee should be same as the expected total fee!")}
</Typography>
)}
</Stack>


+ 67
- 23
src/components/CreateProject/ResourceAllocation.tsx Bestand weergeven

@@ -45,24 +45,45 @@ const leftRightBorderCellSx: SxProps = {
borderColor: "divider",
};

const errorCellSx: SxProps = {
outline: "1px solid",
outlineColor: "error.main",

// borderLeft: "1px solid",
// borderRight: "1px solid",
// borderTop: "1px solid",
// borderBottom: "1px solid",
// borderColor: 'error.main'
}

const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => {
const { t } = useTranslation();
const { watch, register, setValue } = useFormContext<CreateProjectInputs>();
const { watch, register, setValue, formState: { errors }, setError, clearErrors } = useFormContext<CreateProjectInputs>();

const manhourPercentageByGrade = watch("manhourPercentageByGrade");
const totalManhour = watch("totalManhour");
const totalPercentage = Object.values(manhourPercentageByGrade).reduce(
const totalPercentage = Math.round(Object.values(manhourPercentageByGrade).reduce(
(acc, percent) => acc + percent,
0,
);
) * 100) / 100;

const makeUpdatePercentage = useCallback(
(gradeId: Grade["id"]) => (percentage?: number) => {
if (percentage !== undefined) {
setValue("manhourPercentageByGrade", {
const updatedManhourPercentageByGrade = {
...manhourPercentageByGrade,
[gradeId]: percentage,
});
}
setValue("manhourPercentageByGrade", updatedManhourPercentageByGrade);

const keys = Object.keys(updatedManhourPercentageByGrade)
if (keys.filter(k => updatedManhourPercentageByGrade[k as any] < 0).length > 0 ||
keys.reduce((acc, value) => acc + updatedManhourPercentageByGrade[value as any], 0) !== 100) {
setError("manhourPercentageByGrade", {message: "manhourPercentageByGrade value is not valid", type: "invalid"})
} else {
clearErrors("manhourPercentageByGrade")
}
}
},
[manhourPercentageByGrade, setValue],
@@ -79,7 +100,10 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => {
type="number"
{...register("totalManhour", {
valueAsNumber: true,
required: "totalManhour code required!",
min: 1,
})}
error={Boolean(errors.totalManhour)}
/>
<Box
sx={(theme) => ({
@@ -110,15 +134,18 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => {
<TableCellEdit
key={`${column.id}${idx}`}
value={manhourPercentageByGrade[column.id]}
renderValue={(val) => percentFormatter.format(val)}
renderValue={(val) => val + "%"}
// renderValue={(val) => percentFormatter.format(val)}
onChange={makeUpdatePercentage(column.id)}
convertValue={(inputValue) => Number(inputValue)}
cellSx={{ backgroundColor: "primary.lightest" }}
inputSx={{ width: "3rem" }}
error={manhourPercentageByGrade[column.id] < 0}
/>
))}
<TableCell sx={leftBorderCellSx}>
{percentFormatter.format(totalPercentage)}
<TableCell sx={{ ...(totalPercentage === 100 && leftBorderCellSx), ...(totalPercentage !== 100 && {...errorCellSx, borderRight: "1px solid", borderColor: "error.main"})}}>
{totalPercentage + "%"}
{/* {percentFormatter.format(totalPercentage)} */}
</TableCell>
</TableRow>
<TableRow>
@@ -126,7 +153,7 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => {
{grades.map((column, idx) => (
<TableCell key={`${column.id}${idx}`}>
{manhourFormatter.format(
manhourPercentageByGrade[column.id] * totalManhour,
manhourPercentageByGrade[column.id] / 100 * totalManhour,
)}
</TableCell>
))}
@@ -144,7 +171,7 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => {

const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => {
const { t } = useTranslation();
const { watch, setValue } = useFormContext<CreateProjectInputs>();
const { watch, setValue, clearErrors, setError } = useFormContext<CreateProjectInputs>();

const currentTaskGroups = watch("taskGroups");
const taskGroups = useMemo(
@@ -167,13 +194,22 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => {
const makeUpdatePercentage = useCallback(
(taskGroupId: TaskGroup["id"]) => (percentage?: number) => {
if (percentage !== undefined) {
setValue("taskGroups", {
const updatedTaskGroups = {
...currentTaskGroups,
[taskGroupId]: {
...currentTaskGroups[taskGroupId],
percentAllocation: percentage,
},
});
}
setValue("taskGroups", updatedTaskGroups);

const keys = Object.keys(updatedTaskGroups)
if (keys.filter(k => updatedTaskGroups[k as any].percentAllocation < 0).length > 0 ||
keys.reduce((acc, value) => acc + updatedTaskGroups[value as any].percentAllocation, 0) !== 100) {
setError("taskGroups", {message: "Task Groups value is not invalid", type: "invalid"})
} else {
clearErrors("taskGroups")
}
}
},
[currentTaskGroups, setValue],
@@ -216,24 +252,28 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => {
</TableCell>
<TableCellEdit
value={currentTaskGroups[tg.id].percentAllocation}
renderValue={(val) => percentFormatter.format(val)}
// renderValue={(val) => percentFormatter.format(val)}
renderValue={(val) => val + "%"}
onChange={makeUpdatePercentage(tg.id)}
convertValue={(inputValue) => Number(inputValue)}
cellSx={{ backgroundColor: "primary.lightest" }}
cellSx={{
backgroundColor: "primary.lightest",
}}
inputSx={{ width: "3rem" }}
error={currentTaskGroups[tg.id].percentAllocation < 0}
/>
<TableCell sx={rightBorderCellSx}>
{manhourFormatter.format(
currentTaskGroups[tg.id].percentAllocation * totalManhour,
currentTaskGroups[tg.id].percentAllocation / 100 * totalManhour,
)}
</TableCell>
{grades.map((column, idx) => {
const stageHours =
currentTaskGroups[tg.id].percentAllocation * totalManhour;
currentTaskGroups[tg.id].percentAllocation / 100 * totalManhour;
return (
<TableCell key={`${column.id}${idx}`}>
{manhourFormatter.format(
manhourPercentageByGrade[column.id] * stageHours,
manhourPercentageByGrade[column.id] / 100 * stageHours,
)}
</TableCell>
);
@@ -248,10 +288,14 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => {
0,
)}
</TableCell>
<TableCell sx={leftBorderCellSx}>
<TableCell sx={{
...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) === 100 && leftBorderCellSx),
...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) !== 100 && errorCellSx)
}}
>
{percentFormatter.format(
Object.values(currentTaskGroups).reduce(
(acc, tg) => acc + tg.percentAllocation,
(acc, tg) => acc + tg.percentAllocation / 100,
0,
),
)}
@@ -259,7 +303,7 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => {
<TableCell sx={rightBorderCellSx}>
{manhourFormatter.format(
Object.values(currentTaskGroups).reduce(
(acc, tg) => acc + tg.percentAllocation * totalManhour,
(acc, tg) => acc + tg.percentAllocation / 100 * totalManhour,
0,
),
)}
@@ -268,9 +312,9 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => {
const hours = Object.values(currentTaskGroups).reduce(
(acc, tg) =>
acc +
tg.percentAllocation *
totalManhour *
manhourPercentageByGrade[column.id],
tg.percentAllocation / 100 *
totalManhour *
manhourPercentageByGrade[column.id] / 100 ,
0,
);
return (


+ 1
- 1
src/components/CreateProject/TaskSetup.tsx Bestand weergeven

@@ -52,7 +52,7 @@ const TaskSetup: React.FC<Props> = ({
(e: SelectChangeEvent<number | "All">) => {
if (e.target.value === "All" || isNumber(e.target.value)) {
setSelectedTaskTemplateId(e.target.value);
onReset();
// onReset();
}
},
[onReset],


+ 14
- 108
src/components/CreateStaff/CreateStaff.tsx Bestand weergeven

@@ -22,7 +22,6 @@ import { fetchSkillCombo } from "@/app/api/skill/actions";
import { fetchSalaryCombo } from "@/app/api/salarys/actions";

interface Field {
// subtitle: string;
id: string;
label: string;
type: string;
@@ -33,12 +32,6 @@ interface Field {
options?: any[];
readOnly?: boolean;
}

interface formProps {
Title?: string[];
// fieldLists: Field[][];
}

export interface comboItem {
company: comboProp[];
team: comboProp[];
@@ -49,101 +42,14 @@ export interface comboItem {
salary: comboProp[];
}

const CreateStaff: React.FC<formProps> = ({ Title }) => {
// const router = useRouter();
const { t } = useTranslation();
const [companyCombo, setCompanyCombo] = useState<comboProp[]>();
const [teamCombo, setTeamCombo] = useState<comboProp[]>();
const [departmentCombo, setDepartmentCombo] = useState<comboProp[]>();
const [positionCombo, setPositionCombo] = useState<comboProp[]>();
const [gradeCombo, setGradeCombo] = useState<comboProp[]>();
const [skillCombo, setSkillCombo] = useState<comboProp[]>();
const [salaryCombo, setSalaryCombo] = useState<comboProp[]>();
// const [serverError, setServerError] = useState("");

let comboItem: comboItem = {
company: [],
team: [],
department: [],
position: [],
grade: [],
skill: [],
salary: [],
};

const fetchCompany = async () => {
await fetchCompanyCombo().then((data) => {
if (data) setCompanyCombo(data.records);
});
}

const fetchTeam = async () => {
await fetchTeamCombo().then((data) => {
if (data) setTeamCombo(data.records);
});
}

const fetchDepartment = async () => {
await fetchDepartmentCombo().then((data) => {
if (data) setDepartmentCombo(data.records);
});
}

const fetchPosition = async () => {
await fetchPositionCombo().then((data) => {
if (data) setPositionCombo(data.records);
});
}

const fetchGrade = async () => {
await fetchGradeCombo().then((data) => {
if (data) setGradeCombo(data.records);
});
}

const fetchSkill = async () => {
await fetchSkillCombo().then((data) => {
if (data) setSkillCombo(data.records);
});
}

const fetchSalary = async () => {
await fetchSalaryCombo().then((data) => {
if (data) setSalaryCombo(data.records);
});
}

useEffect(() => {
fetchCompany()
fetchTeam()
fetchDepartment()
fetchPosition()
fetchGrade()
fetchSkill()
fetchSalary()
}, []);

useEffect(() => {
if(!companyCombo)
fetchCompany()
if(!teamCombo)
fetchTeam()
if(!departmentCombo)
fetchDepartment()
if(!positionCombo)
fetchPosition()
if(!gradeCombo)
fetchGrade()
if(!skillCombo)
fetchSkill()
if(!salaryCombo)
fetchSalary()
interface formProps {
Title?: string[];
combos: comboItem;
}

}, [companyCombo, teamCombo, departmentCombo, positionCombo, gradeCombo, skillCombo, salaryCombo]);

// useEffect(() => {
// console.log(companyCombo)
// }, [companyCombo]);
const CreateStaff: React.FC<formProps> = ({ Title, combos }) => {
const { t } = useTranslation();

const fieldLists: Field[][] = [
[
@@ -163,49 +69,49 @@ const CreateStaff: React.FC<formProps> = ({ Title }) => {
id: "companyId",
label: t("Company"),
type: "combo-Obj",
options: companyCombo || [],
options: combos.company || [],
required: true,
},
{
id: "teamId",
label: t("Team"),
type: "combo-Obj",
options: teamCombo || [],
options: combos.team || [],
required: false,
},
{
id: "departmentId",
label: t("Department"),
type: "combo-Obj",
options: departmentCombo || [],
options: combos.department || [],
required: true,
},
{
id: "gradeId",
label: t("Grade"),
type: "combo-Obj",
options: gradeCombo || [],
options: combos.grade || [],
required: false,
},
{
id: "skillSetId",
label: t("Skillset"),
type: "multiSelect-Obj",
options: skillCombo || [],
options: combos.skill || [],
required: false,
},
{
id: "currentPositionId",
label: t("Current Position"),
type: "combo-Obj",
options: positionCombo || [],
options: combos.position || [],
required: true,
},
{
id: "salaryId",
label: t("Salary Point"),
type: "combo-Obj",
options: salaryCombo || [],
options: combos.salary || [],
required: true,
},
// {
@@ -279,7 +185,7 @@ const CreateStaff: React.FC<formProps> = ({ Title }) => {
id: "joinPositionId",
label: t("Join Position"),
type: "combo-Obj",
options: positionCombo || [],
options: combos.position || [],
required: true,
},
{


+ 35
- 4
src/components/CreateStaff/CreateStaffWrapper.tsx Bestand weergeven

@@ -1,17 +1,48 @@
import React from "react";
import CreateStaff from "./CreateStaff";
import CreateStaff, { comboItem } from "./CreateStaff";
import CreateStaffLoading from "./CreateStaffLoading";
import { fetchStaff, fetchTeamLeads } from "@/app/api/staff";
import { useSearchParams } from "next/navigation";

import { fetchTeamCombo } from "@/app/api/team/actions";
import { fetchDepartmentCombo } from "@/app/api/departments/actions";
import { fetchPositionCombo } from "@/app/api/positions/actions";
import { fetchGradeCombo } from "@/app/api/grades/actions";
import { fetchSkillCombo } from "@/app/api/skill/actions";
import { fetchSalaryCombo } from "@/app/api/salarys/actions";
import { fetchCompanyCombo } from "@/app/api/companys/actions";
interface SubComponents {
Loading: typeof CreateStaffLoading;
}

const CreateStaffWrapper: React.FC & SubComponents = async () => {
const [
CompanyCombo,
TeamCombo,
DepartmentCombo,
PositionCombo,
GradeCombo,
SkillCombo,
SalaryCombo,
] = await Promise.all([
fetchCompanyCombo(),
fetchTeamCombo(),
fetchDepartmentCombo(),
fetchPositionCombo(),
fetchGradeCombo(),
fetchSkillCombo(),
fetchSalaryCombo(),
]);
const combos: comboItem = {
company: CompanyCombo.records,
team: TeamCombo.records,
department: DepartmentCombo.records,
position: PositionCombo.records,
grade: GradeCombo.records,
skill: SkillCombo.records,
salary: SalaryCombo.records,
}


return <CreateStaff/>;
return <CreateStaff combos={combos}/>;
};

CreateStaffWrapper.Loading = CreateStaffLoading;


+ 105
- 64
src/components/CreateTaskTemplate/CreateTaskTemplate.tsx Bestand weergeven

@@ -10,26 +10,31 @@ import TransferList from "../TransferList";
import Button from "@mui/material/Button";
import Check from "@mui/icons-material/Check";
import Close from "@mui/icons-material/Close";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import React from "react";
import Stack from "@mui/material/Stack";
import { Task } from "@/app/api/tasks";
import { Task, TaskTemplate } from "@/app/api/tasks";
import {
NewTaskTemplateFormInputs,
fetchTaskTemplate,
saveTaskTemplate,
} from "@/app/api/tasks/actions";
import { SubmitHandler, useFieldArray, useForm } from "react-hook-form";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { errorDialog, submitDialog, successDialog } from "../Swal/CustomAlerts";
import { Grade } from "@/app/api/grades";
import { intersectionWith, isEmpty } from "lodash";
import ResourceAllocationWrapper from "./ResourceAllocation";

interface Props {
tasks: Task[];
defaultInputs?: NewTaskTemplateFormInputs;
grades: Grade[]
}

const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => {


const CreateTaskTemplate: React.FC<Props> = ({ tasks, defaultInputs, grades }) => {
const { t } = useTranslation();

const searchParams = useSearchParams()
const router = useRouter();
const handleCancel = () => {
router.back();
@@ -47,57 +52,53 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => {

const [serverError, setServerError] = React.useState("");

const {
register,
handleSubmit,
setValue,
watch,
resetField,
formState: { errors, isSubmitting },
} = useForm<NewTaskTemplateFormInputs>({ defaultValues: { taskIds: [] } });

const currentTaskIds = watch("taskIds");
const selectedItems = React.useMemo(() => {
return items.filter((item) => currentTaskIds.includes(item.id));
}, [currentTaskIds, items]);

const [refTaskTemplate, setRefTaskTemplate] = React.useState<NewTaskTemplateFormInputs>()
const id = searchParams.get('id')

const fetchCurrentTaskTemplate = async () => {
try {
const taskTemplate = await fetchTaskTemplate(parseInt(id!!))
const formProps = useForm<NewTaskTemplateFormInputs>({
defaultValues: {
taskGroups: {},
...defaultInputs,

const defaultValues = {
id: parseInt(id!!),
code: taskTemplate.code ?? null,
name: taskTemplate.name ?? null,
taskIds: taskTemplate.tasks.map(task => task.id) ?? [],
}

setRefTaskTemplate(defaultValues)
} catch (e) {
console.log(e)
}
}

React.useLayoutEffect(() => {
if (id !== null && parseInt(id) > 0) fetchCurrentTaskTemplate()
}, [id])

React.useEffect(() => {
if (refTaskTemplate) {
setValue("taskIds", refTaskTemplate.taskIds)
resetField("code", { defaultValue: refTaskTemplate.code })
resetField("name", { defaultValue: refTaskTemplate.name })
setValue("id", refTaskTemplate.id)
manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade)
? grades.reduce((acc, grade) => {
return { ...acc, [grade.id]: 100 / grades.length };
}, {})
: defaultInputs?.manhourPercentageByGrade,
}
}, [refTaskTemplate])
});

const currentTaskGroups = formProps.watch("taskGroups");
const currentTaskIds = Object.values(currentTaskGroups).reduce<Task["id"][]>(
(acc, group) => {
return [...acc, ...group.taskIds];
},
[],
);
const selectedItems = React.useMemo(() => {
return intersectionWith(
tasks,
currentTaskIds,
(task, taskId) => task.id === taskId,
).map((t) => ({ id: t.id, label: t.name, group: t.taskGroup }));
}, [currentTaskIds, tasks]);

const onSubmit: SubmitHandler<NewTaskTemplateFormInputs> = React.useCallback(
async (data) => {
try {
console.log(data)

setServerError("");

let hasErrors = false

// check the manhour allocation by stage by grade -> total = 100?
const taskGroupKeys = Object.keys(data.taskGroups)
if (taskGroupKeys.filter(k => data.taskGroups[k as any].percentAllocation < 0).length > 0 ||
taskGroupKeys.reduce((acc, value) => acc + data.taskGroups[value as any].percentAllocation, 0) !== 100) {
formProps.setError("taskGroups", { message: "Task Groups value is not invalid", type: "invalid" })
hasErrors = true
}

if (hasErrors) return false
submitDialog(async () => {
const response = await saveTaskTemplate(data);

@@ -120,8 +121,9 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => {

return (
<>
{
(id === null || refTaskTemplate !== undefined) && <Stack component="form" onSubmit={handleSubmit(onSubmit)} gap={2}>
<FormProvider {...formProps}>
<Stack component="form" onSubmit={formProps.handleSubmit(onSubmit)} gap={2}>
{/* Task List Setup */}
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="overline">{t("Task List Setup")}</Typography>
@@ -135,22 +137,22 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => {
<TextField
label={t("Task Template Code")}
fullWidth
{...register("code", {
{...formProps.register("code", {
required: t("Task template code is required"),
})}
error={Boolean(errors.code?.message)}
helperText={errors.code?.message}
error={Boolean(formProps.formState.errors.code?.message)}
helperText={formProps.formState.errors.code?.message}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Task Template Name")}
fullWidth
{...register("name", {
{...formProps.register("name", {
required: t("Task template name is required"),
})}
error={Boolean(errors.name?.message)}
helperText={errors.name?.message}
error={Boolean(formProps.formState.errors.name?.message)}
helperText={formProps.formState.errors.name?.message}
/>
</Grid>
</Grid>
@@ -158,16 +160,54 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => {
allItems={items}
selectedItems={selectedItems}
onChange={(selectedTasks) => {
setValue(
"taskIds",
selectedTasks.map((item) => item.id),
);
// formProps.setValue(
// "taskIds",
// selectedTasks.map((item) => item.id),
// );

const newTaskGroups = selectedTasks.reduce<
NewTaskTemplateFormInputs["taskGroups"]
>((acc, item) => {
if (!item.group) {
// TODO: this should not happen (all tasks are part of a group)
return acc;
}
if (!acc[item.group.id]) {
return {
...acc,
[item.group.id]: {
taskIds: [item.id],
percentAllocation:
currentTaskGroups[item.group.id]?.percentAllocation || 0,
},
};
}
return {
...acc,
[item.group.id]: {
...acc[item.group.id],
taskIds: [...acc[item.group.id].taskIds, item.id],
},
};
}, {});

formProps.setValue("taskGroups", newTaskGroups);
}}
allItemsLabel={t("Task Pool")}
selectedItemsLabel={t("Task List Template")}
/>
</CardContent>
</Card>

{/* Resource Allocation */}
<Card>
<CardContent>
<ResourceAllocationWrapper
allTasks={tasks}
grades={grades}
/>
</CardContent>
</Card>
{
serverError && (
<Typography variant="body2" color="error" alignSelf="flex-end">
@@ -183,12 +223,13 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks }) => {
variant="contained"
startIcon={<Check />}
type="submit"
disabled={isSubmitting}
disabled={formProps.formState.isSubmitting}
>
{t("Confirm")}
</Button>
</Stack>
</Stack >}
</Stack >
</FormProvider>
</>
);
};


+ 13
- 4
src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx Bestand weergeven

@@ -1,11 +1,20 @@
import React from "react";
import CreateTaskTemplate from "./CreateTaskTemplate";
import { fetchAllTasks } from "@/app/api/tasks";
import { fetchAllTasks, fetchTaskTemplateDetail } from "@/app/api/tasks";
import { fetchGrades } from "@/app/api/grades";

const CreateTaskTemplateWrapper: React.FC = async () => {
const tasks = await fetchAllTasks();
interface Props {
taskTemplateId?: string;
}

return <CreateTaskTemplate tasks={tasks} />;
const CreateTaskTemplateWrapper: React.FC<Props> = async (props) => {
const [tasks, grades] = await Promise.all([
fetchAllTasks(),
fetchGrades(),
]);

const taskTemplateInfo = props.taskTemplateId ? await fetchTaskTemplateDetail(props.taskTemplateId) : undefined
return <CreateTaskTemplate tasks={tasks} grades={grades} defaultInputs={taskTemplateInfo}/>;
};

export default CreateTaskTemplateWrapper;

+ 287
- 0
src/components/CreateTaskTemplate/ResourceAllocation.tsx Bestand weergeven

@@ -0,0 +1,287 @@
import { Task, TaskGroup } from "@/app/api/tasks";
import {
Box,
Typography,
TextField,
Alert,
TableContainer,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Stack,
SxProps,
} from "@mui/material";
import React, { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import uniqBy from "lodash/uniqBy";
import { Grade } from "@/app/api/grades";
import { percentFormatter } from "@/app/utils/formatUtil";
import TableCellEdit from "../TableCellEdit";
import { useFormContext } from "react-hook-form";
import { NewTaskTemplateFormInputs } from "@/app/api/tasks/actions";

interface Props {
allTasks: Task[];
grades: Grade[];
}

const leftBorderCellSx: SxProps = {
borderLeft: "1px solid",
borderColor: "divider",
};

const rightBorderCellSx: SxProps = {
borderRight: "1px solid",
borderColor: "divider",
};

const leftRightBorderCellSx: SxProps = {
borderLeft: "1px solid",
borderRight: "1px solid",
borderColor: "divider",
};

const errorCellSx: SxProps = {
outline: "1px solid",
outlineColor: "error.main",
}

const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => {
const { t } = useTranslation();
const { watch, register, setValue, formState: { errors }, setError, clearErrors } = useFormContext<NewTaskTemplateFormInputs>();

const manhourPercentageByGrade = watch("manhourPercentageByGrade");
const totalPercentage = Math.round(Object.values(manhourPercentageByGrade).reduce(
(acc, percent) => acc + percent,
0,
) * 100) / 100;

const makeUpdatePercentage = useCallback(
(gradeId: Grade["id"]) => (percentage?: number) => {
if (percentage !== undefined) {
const updatedManhourPercentageByGrade = {
...manhourPercentageByGrade,
[gradeId]: percentage,
}
setValue("manhourPercentageByGrade", updatedManhourPercentageByGrade);

const keys = Object.keys(updatedManhourPercentageByGrade)
if (keys.filter(k => updatedManhourPercentageByGrade[k as any] < 0).length > 0 ||
keys.reduce((acc, value) => acc + updatedManhourPercentageByGrade[value as any], 0) !== 100) {
setError("manhourPercentageByGrade", { message: "manhourPercentageByGrade value is not valid", type: "invalid" })
} else {
clearErrors("manhourPercentageByGrade")
}

}
},
[manhourPercentageByGrade, setValue],
);

return (
<Box>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Manhour Allocation By Grade")}
</Typography>
<Box
sx={(theme) => ({
marginBlockStart: 2,
marginInline: -3,
borderBottom: `1px solid ${theme.palette.divider}`,
})}
>
<TableContainer sx={{ maxHeight: 440 }}>
<Table>
<TableHead>
<TableRow>
<TableCell sx={rightBorderCellSx}>
{t("Allocation Type")}
</TableCell>
{grades.map((column, idx) => (
<TableCell key={`${column.id}${idx}`}>
{column.name}
</TableCell>
))}
<TableCell sx={leftBorderCellSx}>{t("Total")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell sx={rightBorderCellSx}>{t("Percentage")}</TableCell>
{grades.map((column, idx) => (
<TableCellEdit
key={`${column.id}${idx}`}
value={manhourPercentageByGrade[column.id]}
renderValue={(val) => val + "%"}
onChange={makeUpdatePercentage(column.id)}
convertValue={(inputValue) => Number(inputValue)}
cellSx={{ backgroundColor: "primary.lightest" }}
inputSx={{ width: "3rem" }}
error={manhourPercentageByGrade[column.id] < 0}
/>
))}
<TableCell sx={{ ...(totalPercentage === 100 && leftBorderCellSx), ...(totalPercentage !== 100 && { ...errorCellSx, borderRight: "1px solid", borderColor: "error.main" }) }}>
{totalPercentage + "%"}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Box>
</Box>
);
};

const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => {
const { t } = useTranslation();
const { watch, setValue, clearErrors, setError } = useFormContext<NewTaskTemplateFormInputs>();

const currentTaskGroups = watch("taskGroups");
const taskGroups = useMemo(
() =>
uniqBy(
allTasks.reduce<TaskGroup[]>((acc, task) => {
if (currentTaskGroups[task.taskGroup.id]) {
return [...acc, task.taskGroup];
}
return acc;
}, []),
"id",
),
[allTasks, currentTaskGroups],
);

const manhourPercentageByGrade = watch("manhourPercentageByGrade");

const makeUpdatePercentage = useCallback(
(taskGroupId: TaskGroup["id"]) => (percentage?: number) => {
console.log(percentage)
if (percentage !== undefined) {
const updatedTaskGroups = {
...currentTaskGroups,
[taskGroupId]: {
...currentTaskGroups[taskGroupId],
percentAllocation: percentage,
},
}
console.log(updatedTaskGroups)
setValue("taskGroups", updatedTaskGroups);

const keys = Object.keys(updatedTaskGroups)
if (keys.filter(k => updatedTaskGroups[k as any].percentAllocation < 0).length > 0 ||
keys.reduce((acc, value) => acc + updatedTaskGroups[value as any].percentAllocation, 0) !== 100) {
setError("taskGroups", { message: "Task Groups value is not invalid", type: "invalid" })
} else {
clearErrors("taskGroups")
}
}
},
[currentTaskGroups, setValue],
);

return (
<Box>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Manhour Allocation By Stage By Grade")}
</Typography>
<Box
sx={(theme) => ({
marginBlockStart: 2,
marginInline: -3,
borderBottom: `1px solid ${theme.palette.divider}`,
})}
>
<TableContainer sx={{ maxHeight: 440 }}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Stage")}</TableCell>
<TableCell sx={leftBorderCellSx}>{t("Task Count")}</TableCell>
<TableCell colSpan={2} sx={leftRightBorderCellSx}>
{t("Total Manhour")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{taskGroups.map((tg, idx) => (
<TableRow key={`${tg.id}${idx}`}>
<TableCell>{tg.name}</TableCell>
<TableCell sx={leftBorderCellSx}>
{currentTaskGroups[tg.id].taskIds.length}
</TableCell>
<TableCellEdit
value={currentTaskGroups[tg.id].percentAllocation}
// renderValue={(val) => percentFormatter.format(val)}
renderValue={(val) => val + "%"}
onChange={makeUpdatePercentage(tg.id)}
convertValue={(inputValue) => Number(inputValue)}
cellSx={{
backgroundColor: "primary.lightest",
...(currentTaskGroups[tg.id].percentAllocation < 0 && { ...errorCellSx, borderBottom: "0px", borderRight: "1px solid", borderColor: "error.main"})
}}
inputSx={{ width: "3rem" }}
error={currentTaskGroups[tg.id].percentAllocation < 0}
/>
</TableRow>
))}
<TableRow>
<TableCell>{t("Total")}</TableCell>
<TableCell sx={leftBorderCellSx}>
{Object.values(currentTaskGroups).reduce(
(acc, tg) => acc + tg.taskIds.length,
0,
)}
</TableCell>
<TableCell sx={{
...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) === 100 && leftBorderCellSx),
...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) !== 100 && { ...errorCellSx, borderRight: "1px solid", borderColor: "error.main"})
}}
>
{percentFormatter.format(
Object.values(currentTaskGroups).reduce(
(acc, tg) => acc + tg.percentAllocation / 100,
0,
),
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Box>
</Box>
);
};

const NoTaskState: React.FC = () => {
const { t } = useTranslation();
return (
<>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Task Breakdown")}
</Typography>
<Alert severity="warning">
{t('Please add some tasks first!')}
</Alert>
</>
);
};

const ResourceAllocationWrapper: React.FC<Props> = (props) => {
const { getValues } = useFormContext<NewTaskTemplateFormInputs>();

if (Object.keys(getValues("taskGroups")).length === 0) {
return <NoTaskState />;
}

return (
<Stack spacing={4}>
<ResourceAllocationByGrade {...props} />
<ResourceAllocationByStage {...props} />
</Stack>
);
};

export default ResourceAllocationWrapper;

+ 1
- 1
src/components/CreateTeam/TeamInfo.tsx Bestand weergeven

@@ -27,7 +27,7 @@ const TeamInfo: React.FC = (
setValue,
} = useFormContext<CreateTeamInputs>();

const resetCustomer = useCallback(() => {
const resetTeam = useCallback(() => {
console.log(defaultValues);
if (defaultValues !== undefined) {
resetField("description");


+ 14
- 14
src/components/CustomerSave/CustomerSave.tsx Bestand weergeven

@@ -199,20 +199,20 @@ const CustomerSave: React.FC<Props> = ({
setServerError("");

submitDialog(async () => {
const response = await saveCustomer(data);
if (response.message === "Success") {
successDialog(t("Submit Success"), t).then(() => {
router.replace("/settings/customer");
})
} else {
errorDialog(t("Submit Fail"), t).then(() => {
formProps.setError("code", { message: response.message, type: "custom" })
setTabIndex(0)
return false
})
}
}, t)
const response = await saveCustomer(data);
if (response.message === "Success") {
successDialog(t("Submit Success"), t).then(() => {
router.replace("/settings/customer");
})
} else {
errorDialog(t("Submit Fail"), t).then(() => {
formProps.setError("code", { message: response.message, type: "custom" })
setTabIndex(0)
return false
})
}
}, t)
} catch (e) {
console.log(e)
setServerError(t("An error has occurred. Please try again later."));


Some files were not shown because too many files changed in this diff

Laden…
Annuleren
Opslaan