From 2d7959f2027c92fdd89e940408778c6ca99b2f20 Mon Sep 17 00:00:00 2001 From: Ismail Hamzah Date: Tue, 21 Jul 2026 14:22:06 +0700 Subject: [PATCH] initial commit --- .gitignore | 31 + .../Attributes/AuthorizeAttribute.cs | 18 + .../Behaviours/AuthorizationBehaviour.cs | 68 ++ ConfigBackEnd/Behaviours/LoggingBehaviour.cs | 30 + .../Behaviours/PerformanceBehaviour.cs | 46 ++ .../Behaviours/UnhandledExceptionBehaviour.cs | 33 + .../Behaviours/ValidationBehaviour.cs | 42 ++ ConfigBackEnd/DI.cs | 30 + .../Exceptions/AlreadyExistsExceptions.cs | 24 + .../Exceptions/ForbiddenAccessException.cs | 17 + ConfigBackEnd/Exceptions/MismatchException.cs | 24 + ConfigBackEnd/Exceptions/NotFoundException.cs | 24 + .../Exceptions/ValidationException.cs | 36 + .../Extensions/AppDbContextExtensions.cs | 40 + .../Extensions/DateTimeExtensions.cs | 14 + ConfigBackEnd/Extensions/GenericExtensions.cs | 86 +++ .../Extensions/IQueryableExtensions.cs | 63 ++ ConfigBackEnd/Extensions/ListExtensions.cs | 51 ++ ConfigBackEnd/Extensions/StringExtensions.cs | 92 +++ .../Interfaces/ICurrentUserService.cs | 12 + ConfigBackEnd/Mappings/IMapFrom.cs | 8 + ConfigBackEnd/Mappings/MappingProfile.cs | 30 + .../Middleware/ExceptionHandlingMiddleware.cs | 105 +++ ConfigFrontEnd/Common/BaseAppPage.cs | 37 + ConfigFrontEnd/Common/BaseService.cs | 154 ++++ ConfigFrontEnd/DI.cs | 37 + .../IdentityClaimPrincipalExtension.cs | 30 + ConfigFrontEnd/Filter/CurrentUserFilter.cs | 47 ++ ConfigFrontEnd/Models/CurrentUserState.cs | 10 + ConfigFrontEnd/Service/CurrentUserService.cs | 54 ++ ConfigFrontEnd/Service/JwtService.cs | 62 ++ Data/Abstracts/BaseEntity.cs | 16 + Data/Entities/ApplicationUser.cs | 41 + Data/Entities/Appraisal.cs | 20 + Data/Entities/AutoNumberSequence.cs | 17 + Data/Entities/Branch.cs | 22 + Data/Entities/Company.cs | 32 + Data/Entities/Currency.cs | 15 + Data/Entities/Deduction.cs | 17 + Data/Entities/Department.cs | 17 + Data/Entities/Designation.cs | 17 + Data/Entities/Employee.cs | 57 ++ Data/Entities/EmployeeDeduction.cs | 17 + Data/Entities/EmployeeIncome.cs | 17 + Data/Entities/Evaluation.cs | 20 + Data/Entities/Grade.cs | 17 + Data/Entities/Income.cs | 17 + Data/Entities/LeaveBalance.cs | 23 + Data/Entities/LeaveCategory.cs | 20 + Data/Entities/LeaveRequest.cs | 23 + Data/Entities/PayrollComponent.cs | 15 + Data/Entities/PayrollDetail.cs | 20 + Data/Entities/PayrollProcess.cs | 23 + Data/Entities/Promotion.cs | 17 + Data/Entities/SerilogLogs.cs | 16 + Data/Entities/Tax.cs | 15 + Data/Entities/Tenant.cs | 21 + Data/Entities/TenantUser.cs | 12 + Data/Entities/Transfer.cs | 27 + Data/Interfaces/IHasAudit.cs | 9 + Data/Interfaces/IHasAutoNumber.cs | 6 + Data/Interfaces/IHasIsDeleted.cs | 6 + Data/Interfaces/IMultiTenant.cs | 7 + Dockerfile | 15 + .../AccessDenied/AccessDeniedPage.razor | 63 ++ .../AccessDenied/RedirectToAccessDenied.razor | 19 + Features/Account/AccountEndPoint.cs | 285 +++++++ Features/Account/AuthenticationLayout.razor | 366 +++++++++ .../ConfirmEmail/ConfirmEmailPage.razor | 101 +++ .../Components/ForgotPasswordPage.razor | 167 ++++ .../Cqrs/ForgotPasswordHandler.cs | 70 ++ .../Account/Login/Components/LoginPage.razor | 378 ++++++++++ Features/Account/Login/Cqrs/LoginHandler.cs | 74 ++ Features/Account/Logout/LogoutPage.razor | 152 ++++ .../Register/Components/RegisterPage.razor | 232 ++++++ .../Account/Register/Cqrs/RegisterHandler.cs | 131 ++++ .../Components/ResetPasswordPage.razor | 181 +++++ .../Cqrs/ResetPasswordHandler.cs | 51 ++ .../Components/TenantSelectionPage.razor | 131 ++++ .../Cqrs/TenantSelectionHandler.cs | 67 ++ Features/Account/_Imports.razor | 7 + Features/AppSettings/Json/JsonPage.razor | 259 +++++++ Features/FeaturesDI.cs | 81 ++ Features/FeaturesEndpointMap.cs | 80 ++ .../Components/LeaveBalancePage.razor | 46 ++ .../Components/_LeaveBalanceCreateForm.razor | 106 +++ .../Components/_LeaveBalanceDataTable.razor | 447 +++++++++++ .../Components/_LeaveBalanceUpdateForm.razor | 198 +++++ .../Cqrs/CreateLeaveBalanceHandler.cs | 81 ++ .../Cqrs/CreateLeaveBalanceValidator.cs | 16 + .../Cqrs/DeleteLeaveBalanceByIdHandler.cs | 27 + .../Cqrs/GetLeaveBalanceByIdHandler.cs | 67 ++ .../Cqrs/GetLeaveBalanceListHandler.cs | 58 ++ .../Cqrs/SyncLeaveBalanceHandler.cs | 40 + .../Cqrs/UpdateLeaveBalanceHandler.cs | 61 ++ .../Cqrs/UpdateLeaveBalanceValidator.cs | 15 + .../LeaveBalance/LeaveBalanceEndpoint.cs | 62 ++ .../Leave/LeaveBalance/LeaveBalanceService.cs | 61 ++ .../Components/LeaveCategoryPage.razor | 35 + .../Components/_LeaveCategoryCreateForm.razor | 117 +++ .../Components/_LeaveCategoryDataTable.razor | 391 ++++++++++ .../Components/_LeaveCategoryUpdateForm.razor | 160 ++++ .../Cqrs/CreateLeaveCategoryHandler.cs | 77 ++ .../Cqrs/CreateLeaveCategoryValidator.cs | 21 + .../Cqrs/DeleteLeaveCategoryByIdHandler.cs | 27 + .../Cqrs/GetLeaveCategoryByIdHandler.cs | 59 ++ .../Cqrs/GetLeaveCategoryListHandler.cs | 47 ++ .../Cqrs/UpdateLeaveCategoryHandler.cs | 67 ++ .../Cqrs/UpdateLeaveCategoryValidator.cs | 21 + .../LeaveCategory/LeaveCategoryEndpoint.cs | 55 ++ .../LeaveCategory/LeaveCategoryService.cs | 54 ++ Features/Leave/LeavePage.razor | 96 +++ .../Components/LeaveRequestPage.razor | 35 + .../Components/_LeaveRequestCreateForm.razor | 136 ++++ .../Components/_LeaveRequestDataTable.razor | 405 ++++++++++ .../Components/_LeaveRequestUpdateForm.razor | 225 ++++++ .../Cqrs/CreateLeaveRequestHandler.cs | 65 ++ .../Cqrs/CreateLeaveRequestValidator.cs | 15 + .../Cqrs/DeleteLeaveRequestByIdHandler.cs | 27 + .../Cqrs/GetEmployeeLeaveReference.cs | 35 + .../Cqrs/GetLeaveRequestByIdHandler.cs | 69 ++ .../Cqrs/GetLeaveRequestListHandler.cs | 53 ++ .../Cqrs/UpdateLeaveRequestHandler.cs | 65 ++ .../Cqrs/UpdateLeaveRequestValidator.cs | 28 + .../LeaveRequest/LeaveRequestEndpoint.cs | 63 ++ .../Leave/LeaveRequest/LeaveRequestService.cs | 60 ++ Features/Multitenant/MultitenantPage.razor | 110 +++ .../Tenant/Components/TenantPage.razor | 28 + .../Tenant/Components/_TenantCreateForm.razor | 127 ++++ .../Tenant/Components/_TenantDataTable.razor | 303 ++++++++ .../Tenant/Components/_TenantUpdateForm.razor | 219 ++++++ .../Components/_TenantUserCreateForm.razor | 69 ++ .../Components/_TenantUserDataTable.razor | 93 +++ .../Components/_TenantUserUpdateForm.razor | 76 ++ .../Tenant/Cqrs/CreateTenantHandler.cs | 73 ++ .../Tenant/Cqrs/CreateTenantValidator.cs | 14 + .../Tenant/Cqrs/DeleteTenantByIdHandler.cs | 27 + .../Tenant/Cqrs/GetTenantByIdHandler.cs | 82 ++ .../Tenant/Cqrs/GetTenantListHandler.cs | 41 + .../Tenant/Cqrs/LookupTenantHandler.cs | 38 + .../Tenant/Cqrs/UpdateTenantHandler.cs | 82 ++ .../Tenant/Cqrs/UpdateTenantValidator.cs | 17 + Features/Multitenant/Tenant/TenantEndpoint.cs | 107 +++ Features/Multitenant/Tenant/TenantService.cs | 87 +++ .../Components/TenantUserPage.razor | 28 + .../Components/_TenantUserCreateForm.razor | 98 +++ .../Components/_TenantUserDataTable.razor | 280 +++++++ .../Components/_TenantUserUpdateForm.razor | 140 ++++ .../Cqrs/CreateTenantUserHandler.cs | 47 ++ .../Cqrs/CreateTenantUserValidator.cs | 20 + .../Cqrs/DeleteTenantUserHandler.cs | 27 + .../Cqrs/GetTenantUserByIdHandler.cs | 47 ++ .../Cqrs/GetTenantUserListHandler.cs | 67 ++ .../Cqrs/LookupTenantUserHandler.cs | 45 ++ .../Cqrs/UpdateTenantUserHandler.cs | 57 ++ .../Cqrs/UpdateTenantUserValidator.cs | 23 + .../TenantUser/TenantUserEndpoint.cs | 74 ++ .../TenantUser/TenantUserService.cs | 65 ++ .../Organization/Branch/BranchEndpoint.cs | 55 ++ Features/Organization/Branch/BranchService.cs | 54 ++ .../Branch/Components/BranchPage.razor | 52 ++ .../Branch/Components/_BranchCreateForm.razor | 147 ++++ .../Branch/Components/_BranchDataTable.razor | 402 ++++++++++ .../Branch/Components/_BranchUpdateForm.razor | 198 +++++ .../Branch/Cqrs/CreateBranchHandler.cs | 83 ++ .../Branch/Cqrs/CreateBranchValidator.cs | 27 + .../Branch/Cqrs/DeleteBranchByIdHandler.cs | 27 + .../Branch/Cqrs/GetBranchByIdHandler.cs | 65 ++ .../Branch/Cqrs/GetBranchListHandler.cs | 49 ++ .../Branch/Cqrs/UpdateBranchHandler.cs | 70 ++ .../Branch/Cqrs/UpdateBranchValidator.cs | 30 + .../Components/DepartmentPage.razor | 23 + .../Components/_DepartmentCreateForm.razor | 93 +++ .../Components/_DepartmentDataTable.razor | 389 ++++++++++ .../Components/_DepartmentUpdateForm.razor | 132 ++++ .../Cqrs/CreateDepartmentHandler.cs | 73 ++ .../Cqrs/CreateDepartmentValidator.cs | 21 + .../Cqrs/DeleteDepartmentByIdHandler.cs | 26 + .../Cqrs/GetDepartmentByIdHandler.cs | 55 ++ .../Cqrs/GetDepartmentListHandler.cs | 43 ++ .../Cqrs/UpdateDepartmentHandler.cs | 61 ++ .../Cqrs/UpdateDepartmentValidator.cs | 14 + .../Department/DepartmentEndpoint.cs | 30 + .../Department/DepartmentService.cs | 54 ++ .../Components/DesignationPage.razor | 25 + .../Components/_DesignationCreateForm.razor | 93 +++ .../Components/_DesignationDataTable.razor | 389 ++++++++++ .../Components/_DesignationUpdateForm.razor | 132 ++++ .../Cqrs/CreateDesignationHandler.cs | 73 ++ .../Cqrs/CreateDesignationValidator.cs | 21 + .../Cqrs/DeleteDesignationByIdHandler.cs | 27 + .../Cqrs/GetDesignationByIdHandler.cs | 55 ++ .../Cqrs/GetDesignationListHandler.cs | 43 ++ .../Cqrs/UpdateDesignationHandler.cs | 62 ++ .../Cqrs/UpdateDesignationValidator.cs | 24 + .../Designation/DesignationEndpoint.cs | 30 + .../Designation/DesignationService.cs | 54 ++ .../Employee/Components/EmployeePage.razor | 54 ++ .../Components/_EmployeeCreateForm.razor | 303 ++++++++ .../Components/_EmployeeDataTable.razor | 457 +++++++++++ .../_EmployeeDeductionCreateForm.razor | 134 ++++ .../_EmployeeDeductionDataTable.razor | 150 ++++ .../_EmployeeDeductionUpdateForm.razor | 145 ++++ .../_EmployeeIncomeCreateForm.razor | 137 ++++ .../Components/_EmployeeIncomeDataTable.razor | 150 ++++ .../_EmployeeIncomeUpdateForm.razor | 151 ++++ .../Components/_EmployeeUpdateForm.razor | 285 +++++++ .../Cqrs/CreateEmployeeDeductionHandler.cs | 54 ++ .../Cqrs/CreateEmployeeDeductionValidator.cs | 15 + .../Employee/Cqrs/CreateEmployeeHandler.cs | 128 ++++ .../Cqrs/CreateEmployeeIncomeHandler.cs | 54 ++ .../Cqrs/CreateEmployeeIncomeValidator.cs | 15 + .../Employee/Cqrs/CreateEmployeeValidator.cs | 58 ++ .../Cqrs/DeleteEmployeeByIdHandler.cs | 27 + .../DeleteEmployeeDeductionByIdHandler.cs | 27 + .../Cqrs/DeleteEmployeeIncomeByIdHandler.cs | 27 + .../Employee/Cqrs/GetEmployeeByIdHandler.cs | 120 +++ .../Cqrs/GetEmployeeDeductionByIdHandler.cs | 41 + ...mployeeDeductionListByEmployeeIdHandler.cs | 46 ++ .../Cqrs/GetEmployeeIncomeByIdHandler.cs | 41 + ...etEmployeeIncomeListByEmployeeIdHandler.cs | 46 ++ .../Employee/Cqrs/GetEmployeeListHandler.cs | 53 ++ .../Employee/Cqrs/LookupHandler.cs | 78 ++ .../Cqrs/UpdateEmployeeDeductionHandler.cs | 45 ++ .../Cqrs/UpdateEmployeeDeductionValidator.cs | 15 + .../Employee/Cqrs/UpdateEmployeeHandler.cs | 82 ++ .../Cqrs/UpdateEmployeeIncomeHandler.cs | 45 ++ .../Cqrs/UpdateEmployeeIncomeValidator.cs | 15 + .../Employee/Cqrs/UpdateEmployeeValidator.cs | 44 ++ .../Organization/Employee/EmployeeEndpoint.cs | 66 ++ .../Organization/Employee/EmployeeService.cs | 130 ++++ Features/Organization/OrganizationPage.razor | 104 +++ .../Deduction/Components/DeductionPage.razor | 27 + .../Components/_DeductionCreateForm.razor | 140 ++++ .../Components/_DeductionDataTable.razor | 396 ++++++++++ .../Components/_DeductionUpdateForm.razor | 146 ++++ .../Deduction/Cqrs/CreateDeductionHandler.cs | 73 ++ .../Cqrs/CreateDeductionValidator.cs | 30 + .../Cqrs/DeleteDeductionByIdHandler.cs | 27 + .../Deduction/Cqrs/GetDeductionByIdHandler.cs | 53 ++ .../Deduction/Cqrs/GetDeductionListHandler.cs | 45 ++ .../Deduction/Cqrs/UpdateDeductionHandler.cs | 72 ++ .../Cqrs/UpdateDeductionValidator.cs | 33 + .../Payroll/Deduction/DeductionEndpoint.cs | 66 ++ .../Payroll/Deduction/DeductionService.cs | 59 ++ .../Payroll/Grade/Components/GradePage.razor | 28 + .../Grade/Components/_GradeCreateForm.razor | 133 ++++ .../Grade/Components/_GradeDataTable.razor | 423 +++++++++++ .../Grade/Components/_GradeUpdateForm.razor | 188 +++++ .../Payroll/Grade/Cqrs/CreateGradeHandler.cs | 73 ++ .../Grade/Cqrs/CreateGradeValidator.cs | 28 + .../Grade/Cqrs/DeleteGradeByIdHandler.cs | 27 + .../Payroll/Grade/Cqrs/GetGradeByIdHandler.cs | 53 ++ .../Payroll/Grade/Cqrs/GetGradeListHandler.cs | 47 ++ .../Payroll/Grade/Cqrs/UpdateGradeHandler.cs | 72 ++ .../Grade/Cqrs/UpdateGradeValidator.cs | 31 + Features/Payroll/Grade/GradeEndpoint.cs | 66 ++ Features/Payroll/Grade/GradeService.cs | 59 ++ .../Income/Components/IncomePage.razor | 31 + .../Income/Components/_IncomeCreateForm.razor | 138 ++++ .../Income/Components/_IncomeDataTable.razor | 394 ++++++++++ .../Income/Components/_IncomeUpdateForm.razor | 194 +++++ .../Income/Cqrs/CreateIncomeHandler.cs | 73 ++ .../Income/Cqrs/CreateIncomeValidator.cs | 30 + .../Income/Cqrs/DeleteIncomeByIdHandler.cs | 27 + .../Income/Cqrs/GetIncomeByIdHandler.cs | 53 ++ .../Income/Cqrs/GetIncomeListHandler.cs | 45 ++ .../Income/Cqrs/UpdateIncomeHandler.cs | 72 ++ .../Income/Cqrs/UpdateIncomeValidator.cs | 33 + Features/Payroll/Income/IncomeEndpoint.cs | 66 ++ Features/Payroll/Income/IncomeService.cs | 59 ++ Features/Payroll/PayrollPage.razor | 105 +++ .../Payrolls/Components/PayrollsPage.razor | 66 ++ .../Components/_PayrollCalculateForm.razor | 105 +++ .../Components/_PayrollDetailGrid.razor | 276 +++++++ .../Components/_PayrollProcessDataTable.razor | 364 +++++++++ .../Components/_PayrollSlipDialog.razor | 100 +++ .../Components/_PayrollUpdateForm.razor | 112 +++ .../Cqrs/CreatePayrollProcessHandler.cs | 142 ++++ .../Cqrs/DeletePayrollProcessByIdHandler.cs | 44 ++ .../Cqrs/GetPayrollDetailByIdHandler.cs | 76 ++ .../GetPayrollDetailListByProcessIdHandler.cs | 51 ++ .../Cqrs/GetPayrollProcessByIdHandler.cs | 71 ++ .../Cqrs/GetPayrollProcessListHandler.cs | 64 ++ .../Cqrs/GetPayrollSlipByIdHandler.cs | 93 +++ .../Cqrs/UpdatePayrollProcessHandler.cs | 52 ++ Features/Payroll/Payrolls/PayrollsEndpoint.cs | 75 ++ Features/Payroll/Payrolls/PayrollsService.cs | 72 ++ .../Appraisal/AppraisalEndpoint.cs | 55 ++ .../Performance/Appraisal/AppraisalService.cs | 54 ++ .../Appraisal/Components/AppraisalPage.razor | 46 ++ .../Components/_AppraisalCreateForm.razor | 148 ++++ .../Components/_AppraisalDataTable.razor | 447 +++++++++++ .../Components/_AppraisalUpdateForm.razor | 232 ++++++ .../Appraisal/Cqrs/CreateAppraisalHandler.cs | 67 ++ .../Cqrs/CreateAppraisalValidator.cs | 15 + .../Cqrs/DeleteAppraisalByIdHandler.cs | 27 + .../Appraisal/Cqrs/GetAppraisalByIdHandler.cs | 64 ++ .../Appraisal/Cqrs/GetAppraisalListHandler.cs | 54 ++ .../Appraisal/Cqrs/UpdateAppraisalHandler.cs | 60 ++ .../Cqrs/UpdateAppraisalValidator.cs | 13 + .../Components/EvaluationPage.razor | 46 ++ .../Components/_EvaluationCreateForm.razor | 130 ++++ .../Components/_EvaluationDataTable.razor | 450 +++++++++++ .../Components/_EvaluationUpdateForm.razor | 197 +++++ .../Cqrs/CreateEvaluationHandler.cs | 65 ++ .../Cqrs/CreateEvaluationValidator.cs | 26 + .../Cqrs/DeleteEvaluationByIdHandler.cs | 27 + .../Cqrs/GetEvaluationByIdHandler.cs | 67 ++ .../Cqrs/GetEvaluationListHandler.cs | 55 ++ .../Cqrs/UpdateEvaluationHandler.cs | 61 ++ .../Cqrs/UpdateEvaluationValidator.cs | 26 + .../Evaluation/EvaluationEndpoint.cs | 55 ++ .../Evaluation/EvaluationService.cs | 54 ++ Features/Performance/PerformancePage.razor | 105 +++ .../Promotion/Components/PromotionPage.razor | 46 ++ .../Components/_PromotionCreateForm.razor | 130 ++++ .../Components/_PromotionDataTable.razor | 439 +++++++++++ .../Components/_PromotionUpdateForm.razor | 174 +++++ .../Promotion/Cqrs/CreatePromotionHandler.cs | 61 ++ .../Cqrs/CreatePromotionValidator.cs | 14 + .../Cqrs/DeletePromotionByIdHandler.cs | 27 + .../Promotion/Cqrs/GetPromotionByIdHandler.cs | 58 ++ .../Promotion/Cqrs/GetPromotionListHandler.cs | 50 ++ .../Promotion/Cqrs/UpdatePromotionHandler.cs | 57 ++ .../Cqrs/UpdatePromotionValidator.cs | 12 + .../Promotion/PromotionEndpoint.cs | 55 ++ .../Performance/Promotion/PromotionService.cs | 54 ++ .../Transfer/Components/TransferPage.razor | 46 ++ .../Components/_TransferCreateForm.razor | 166 ++++ .../Components/_TransferDataTable.razor | 430 +++++++++++ .../Components/_TransferUpdateForm.razor | 218 ++++++ .../Transfer/Cqrs/CreateTransferHandler.cs | 68 ++ .../Transfer/Cqrs/CreateTransferValidator.cs | 15 + .../Cqrs/DeleteTransferByIdHandler.cs | 27 + .../Transfer/Cqrs/GetTransferByIdHandler.cs | 45 ++ .../Transfer/Cqrs/GetTransferListHandler.cs | 56 ++ .../Cqrs/GetTransferLookupsHandler.cs | 43 ++ .../Transfer/Cqrs/UpdateTransferHandler.cs | 54 ++ .../Transfer/Cqrs/UpdateTransferValidator.cs | 12 + .../Performance/Transfer/TransferEndpoint.cs | 65 ++ .../Performance/Transfer/TransferService.cs | 72 ++ Features/Profile/Avatar/AvatarEndpoint.cs | 54 ++ Features/Profile/Avatar/AvatarService.cs | 53 ++ .../Avatar/Components/AvatarPage.razor | 198 +++++ .../Avatar/Cqrs/ChangeAvatarHandler.cs | 66 ++ .../Avatar/Cqrs/GetAvatarInfoHandler.cs | 37 + .../Password/Components/PasswordPage.razor | 195 +++++ .../Password/Cqrs/UpdatePasswordHandler.cs | 50 ++ .../Password/Cqrs/UpdatePasswordValidator.cs | 18 + Features/Profile/Password/PasswordEndpoint.cs | 26 + Features/Profile/Password/PasswordService.cs | 28 + .../Components/PersonalInformationPage.razor | 275 +++++++ .../Cqrs/GetProfileByIdHandler.cs | 59 ++ .../Cqrs/UpdateProfileHandler.cs | 67 ++ .../Cqrs/UpdateProfileValidator.cs | 26 + .../PersonalInformationEndpoint.cs | 35 + .../PersonalInformationService.cs | 34 + Features/Profile/ProfilePage.razor | 103 +++ Features/Profile/Session/SessionPage.razor | 105 +++ Features/Root/App.razor | 123 +++ Features/Root/EmptyLayout.razor | 2 + Features/Root/Error/ErrorPage.razor | 59 ++ .../Root/Home/Components/DashboardPage.razor | 606 +++++++++++++++ Features/Root/Home/Cqrs/DashboardHandler.cs | 567 ++++++++++++++ Features/Root/Home/HomeEndpoint.cs | 23 + Features/Root/Home/HomeService.cs | 30 + Features/Root/MainLayout.razor | 287 +++++++ Features/Root/MainLayout.razor.css | 22 + Features/Root/NotFound/NotFoundPage.razor | 33 + Features/Root/Pages/LandingPage.cshtml | 474 ++++++++++++ Features/Root/ReconnectModal.razor | 258 +++++++ Features/Root/Routes.razor | 25 + .../Root/Shared/_DeleteConfirmation.razor | 58 ++ Features/Root/SsoFirebase.razor | 41 + Features/Root/SsoKeycloak.razor | 1 + Features/Root/_Imports.razor | 13 + .../Database/Components/DatabasePage.razor | 403 ++++++++++ .../Components/_SerilogDetailDialog.razor | 50 ++ .../Database/DeleteSerilogLogsAllHandler.cs | 28 + .../Database/DeleteSerilogLogsByIdHandler.cs | 30 + .../Database/GetSerilogLogsByIdHandler.cs | 34 + .../GetSerilogLogsPagedListHandler.cs | 53 ++ .../Serilogs/File/Components/FilePage.razor | 350 +++++++++ Features/Serilogs/SerilogsEndpoint.cs | 59 ++ Features/Serilogs/SerilogsODataController.cs | 29 + Features/Serilogs/SerilogsPage.razor | 88 +++ Features/Serilogs/SerilogsService.cs | 105 +++ .../AutoNumberSequenceEndpoint.cs | 48 ++ .../AutoNumberSequenceService.cs | 39 + .../Components/AutoNumberSequencePage.razor | 43 ++ .../_AutoNumberSequenceDataTable.razor | 355 +++++++++ .../Components/_AutoNumberSequenceForm.razor | 155 ++++ .../Cqrs/GetAutoNumberSequenceByIdHandler.cs | 28 + .../Cqrs/GetAutoNumberSequenceListHandler.cs | 45 ++ .../Cqrs/UpdateAutoNumberSequenceHandler.cs | 41 + .../Cqrs/UpdateAutoNumberSequenceValidator.cs | 14 + Features/Setting/Company/CompanyEndpoint.cs | 46 ++ Features/Setting/Company/CompanyService.cs | 54 ++ .../Company/Components/CompanyPage.razor | 52 ++ .../Components/_CompanyCreateForm.razor | 250 ++++++ .../Components/_CompanyDataTable.razor | 438 +++++++++++ .../Components/_CompanyUpdateForm.razor | 317 ++++++++ .../Company/Cqrs/CreateCompanyHandler.cs | 107 +++ .../Company/Cqrs/CreateCompanyValidator.cs | 24 + .../Company/Cqrs/DeleteCompanyByIdHandler.cs | 34 + .../Company/Cqrs/GetCompanyByIdHandler.cs | 81 ++ .../Company/Cqrs/GetCompanyListHandler.cs | 41 + .../Company/Cqrs/UpdateCompanyHandler.cs | 104 +++ .../Company/Cqrs/UpdateCompanyValidator.cs | 15 + .../Currency/Components/CurrencyPage.razor | 52 ++ .../Components/_CurrencyCreateForm.razor | 118 +++ .../Components/_CurrencyDataTable.razor | 401 ++++++++++ .../Components/_CurrencyUpdateForm.razor | 170 +++++ .../Currency/Cqrs/CreateCurrencyHandler.cs | 68 ++ .../Currency/Cqrs/CreateCurrencyValidator.cs | 26 + .../Cqrs/DeleteCurrencyByIdHandler.cs | 27 + .../Currency/Cqrs/GetCurrencyByIdHandler.cs | 49 ++ .../Currency/Cqrs/GetCurrencyListHandler.cs | 47 ++ .../Currency/Cqrs/UpdateCurrencyHandler.cs | 70 ++ .../Currency/Cqrs/UpdateCurrencyValidator.cs | 29 + Features/Setting/Currency/CurrencyEndpoint.cs | 73 ++ Features/Setting/Currency/CurrencyService.cs | 60 ++ Features/Setting/SettingPage.razor | 108 +++ .../Components/SystemUserPage.razor | 74 ++ .../_SystemUserChangeAvatarForm.razor | 176 +++++ .../_SystemUserChangePasswordForm.razor | 167 ++++ .../_SystemUserChangeRoleForm.razor | 125 +++ .../Components/_SystemUserCreateForm.razor | 218 ++++++ .../Components/_SystemUserDataTable.razor | 461 +++++++++++ .../Components/_SystemUserUpdateForm.razor | 273 +++++++ .../Cqrs/AdminForgotPasswordHandler.cs | 64 ++ .../SystemUser/Cqrs/ChangeAvatarHandler.cs | 63 ++ .../SystemUser/Cqrs/ChangePasswordHandler.cs | 52 ++ .../Cqrs/ChangePasswordValidator.cs | 16 + .../SystemUser/Cqrs/ChangeRoleHandler.cs | 47 ++ .../Cqrs/CreateSystemUserHandler.cs | 115 +++ .../Cqrs/CreateSystemUserValidator.cs | 30 + .../Cqrs/DeleteSystemUserByIdHandler.cs | 26 + .../Cqrs/GetSystemUserByIdHandler.cs | 86 +++ .../Cqrs/GetSystemUserListHandler.cs | 78 ++ .../Cqrs/UpdateSystemUserHandler.cs | 87 +++ .../Cqrs/UpdateSystemUserValidator.cs | 27 + .../Setting/SystemUser/SystemUserEndpoint.cs | 118 +++ .../Setting/SystemUser/SystemUserService.cs | 109 +++ Features/Setting/Tax/Components/TaxPage.razor | 52 ++ .../Tax/Components/_TaxCreateForm.razor | 117 +++ .../Tax/Components/_TaxDataTable.razor | 403 ++++++++++ .../Tax/Components/_TaxUpdateForm.razor | 169 +++++ Features/Setting/Tax/Cqrs/CreateTaxHandler.cs | 69 ++ .../Setting/Tax/Cqrs/CreateTaxValidator.cs | 22 + .../Setting/Tax/Cqrs/DeleteTaxByIdHandler.cs | 27 + .../Setting/Tax/Cqrs/GetTaxByIdHandler.cs | 49 ++ .../Setting/Tax/Cqrs/GetTaxListHandler.cs | 47 ++ Features/Setting/Tax/Cqrs/UpdateTaxHandler.cs | 70 ++ .../Setting/Tax/Cqrs/UpdateTaxValidator.cs | 25 + Features/Setting/Tax/TaxEndpoint.cs | 73 ++ Features/Setting/Tax/TaxService.cs | 60 ++ Features/_Imports.razor | 1 + Indotalent.csproj | 56 ++ Indotalent.slnx | 3 + Infrastructure/Authentication/DI.cs | 88 +++ .../Firebase/FirebaseService.cs | 13 + .../Firebase/FirebaseSettingsModel.cs | 13 + .../Authentication/Identity/CookieSettings.cs | 10 + .../Identity/CustomClaimsPrincipalFactory.cs | 26 + .../Identity/DefaultAdminSettings.cs | 8 + .../Identity/DefaultUserConfig.cs | 16 + ...RevalidatingAuthenticationStateProvider.cs | 56 ++ .../Identity/IdentityService.cs | 24 + .../Identity/IdentitySettingsModel.cs | 14 + .../Identity/JwtSettingsModel.cs | 9 + .../Identity/PasswordSettings.cs | 11 + .../Authentication/Identity/SignInSettings.cs | 6 + .../Authentication/Identity/TokenProvider.cs | 7 + .../Keycloak/KeycloakService.cs | 13 + .../Keycloak/KeycloakSettingsModel.cs | 11 + .../Identity/ApplicationRoles.cs | 10 + .../AutoNumberGeneratorService.cs | 133 ++++ Infrastructure/AutoNumberGenerator/DI.cs | 13 + .../IAutoNumberGenerator.cs | 12 + .../BackgroundJob/BackgroundJobService.cs | 10 + .../BackgroundJobSettingsModel.cs | 10 + Infrastructure/BackgroundJob/DI.cs | 11 + .../Hangfire/HangfireSettingsModel.cs | 8 + .../Quartz/QuartzSettingsModel.cs | 7 + Infrastructure/DI.cs | 43 ++ Infrastructure/Database/AppDbContext.cs | 282 +++++++ Infrastructure/Database/DI.cs | 50 ++ .../Database/DatabaseProviderModel.cs | 8 + Infrastructure/Database/DatabaseSeeder.cs | 713 ++++++++++++++++++ Infrastructure/Database/DatabaseService.cs | 18 + .../Database/DatabaseSettingsModel.cs | 8 + .../ApplicationUserConfiguration.cs | 68 ++ .../Configuration/AppraisalConfiguration.cs | 31 + .../AutoNumberSequenceConfiguration.cs | 24 + .../Configuration/BranchConfiguration.cs | 56 ++ .../Configuration/CompanyConfiguration.cs | 87 +++ .../Configuration/CurrencyConfiguration.cs | 36 + .../Configuration/DeductionConfiguration.cs | 37 + .../Configuration/DepartmentConfiguration.cs | 40 + .../Configuration/DesignationConfiguration.cs | 40 + .../Configuration/EmployeeConfiguration.cs | 167 ++++ .../EmployeeDeductionConfiguration.cs | 41 + .../EmployeeIncomeConfiguration.cs | 41 + .../Configuration/EvaluationConfiguration.cs | 35 + .../MsSQL/Configuration/GradeConfiguration.cs | 37 + .../Configuration/IncomeConfiguration.cs | 37 + .../LeaveBalanceConfiguration.cs | 30 + .../LeaveCategoryConfiguration.cs | 31 + .../LeaveRequestConfiguration.cs | 45 ++ .../PayrollComponentConfiguration.cs | 32 + .../PayrollDetailConfiguration.cs | 36 + .../PayrollProcessConfiguration.cs | 30 + .../Configuration/PromotionConfiguration.cs | 27 + .../Configuration/SerilogLogsConfiguration.cs | 39 + .../MsSQL/Configuration/TaxConfiguration.cs | 36 + .../Configuration/TransferConfiguration.cs | 39 + .../Database/MsSQL/MsSQLConfiguration.cs | 23 + .../Database/MySQL/MySQLConfiguration.cs | 17 + .../PostgreSQL/PostgreSQLConfiguration.cs | 17 + Infrastructure/Email/DI.cs | 46 ++ Infrastructure/Email/DefaultEmailSender.cs | 21 + Infrastructure/Email/EmailService.cs | 10 + Infrastructure/Email/EmailSettingsModel.cs | 14 + Infrastructure/Email/IEmailSender.cs | 6 + Infrastructure/Email/IdentityEmailManager.cs | 35 + .../Email/Mailgun/MailgunEmailSender.cs | 44 ++ .../Email/Mailgun/MailgunSettingsModel.cs | 9 + .../Email/Mailjet/MailjetEmailSender.cs | 52 ++ .../Email/Mailjet/MailjetSettingsModel.cs | 9 + .../Email/SendGrid/SendGridEmailSender.cs | 58 ++ .../Email/SendGrid/SendGridSettingsModel.cs | 8 + Infrastructure/Email/Smtp/SmtpEmailSender.cs | 54 ++ .../Email/Smtp/SmtpSettingsModel.cs | 12 + .../File/Avatar/AvatarStorageModel.cs | 8 + Infrastructure/File/Aws/AwsS3SettingsModel.cs | 9 + .../File/Azure/AzureBlobSettingsModel.cs | 8 + Infrastructure/File/DI.cs | 13 + .../File/Dropbox/DropboxSettingsModel.cs | 7 + Infrastructure/File/FileStorageService.cs | 107 +++ .../File/FileStorageSettingsModel.cs | 18 + .../File/Google/GoogleCloudSettingsModel.cs | 8 + .../File/Local/LocalStorageModel.cs | 8 + Infrastructure/Logging/DI.cs | 11 + Infrastructure/Logging/LoggerSettingsModel.cs | 10 + Infrastructure/Logging/LoggingService.cs | 10 + .../Logging/Serilog/DatabaseLoggerModel.cs | 8 + .../Logging/Serilog/FileLoggerModel.cs | 8 + .../Logging/Serilog/SeqLoggerModel.cs | 7 + .../Logging/Serilog/SerilogBuilder.cs | 81 ++ Infrastructure/OData/DI.cs | 35 + Infrastructure/OData/ODataResponse.cs | 12 + LICENSE.txt | 15 + Program.cs | 127 ++++ .../PublishProfiles/FolderProfile.pubxml | 15 + .../PublishProfiles/FolderProfile1.pubxml | 19 + Properties/launchSettings.json | 23 + Shared/Consts/GlobalConsts.cs | 40 + Shared/Models/ApiRequest.cs | 11 + Shared/Models/ApiResponse.cs | 22 + Shared/Models/PagedList.cs | 39 + Shared/Utils/FluentValidationHelper.cs | 30 + Shared/Utils/PayrollSlipPdfGenerator.cs | 125 +++ Shared/Utils/SequentialGuidGenerator.cs | 25 + appsettings.Development.json | 8 + appsettings.json | 41 + developer.txt | 2 + docker-compose.yml | 40 + dotnet-tools.json | 13 + wwwroot/avatars/readme.txt | 1 + wwwroot/favico.png | Bin 0 -> 202 bytes 572 files changed, 45295 insertions(+) create mode 100644 .gitignore create mode 100644 ConfigBackEnd/Attributes/AuthorizeAttribute.cs create mode 100644 ConfigBackEnd/Behaviours/AuthorizationBehaviour.cs create mode 100644 ConfigBackEnd/Behaviours/LoggingBehaviour.cs create mode 100644 ConfigBackEnd/Behaviours/PerformanceBehaviour.cs create mode 100644 ConfigBackEnd/Behaviours/UnhandledExceptionBehaviour.cs create mode 100644 ConfigBackEnd/Behaviours/ValidationBehaviour.cs create mode 100644 ConfigBackEnd/DI.cs create mode 100644 ConfigBackEnd/Exceptions/AlreadyExistsExceptions.cs create mode 100644 ConfigBackEnd/Exceptions/ForbiddenAccessException.cs create mode 100644 ConfigBackEnd/Exceptions/MismatchException.cs create mode 100644 ConfigBackEnd/Exceptions/NotFoundException.cs create mode 100644 ConfigBackEnd/Exceptions/ValidationException.cs create mode 100644 ConfigBackEnd/Extensions/AppDbContextExtensions.cs create mode 100644 ConfigBackEnd/Extensions/DateTimeExtensions.cs create mode 100644 ConfigBackEnd/Extensions/GenericExtensions.cs create mode 100644 ConfigBackEnd/Extensions/IQueryableExtensions.cs create mode 100644 ConfigBackEnd/Extensions/ListExtensions.cs create mode 100644 ConfigBackEnd/Extensions/StringExtensions.cs create mode 100644 ConfigBackEnd/Interfaces/ICurrentUserService.cs create mode 100644 ConfigBackEnd/Mappings/IMapFrom.cs create mode 100644 ConfigBackEnd/Mappings/MappingProfile.cs create mode 100644 ConfigBackEnd/Middleware/ExceptionHandlingMiddleware.cs create mode 100644 ConfigFrontEnd/Common/BaseAppPage.cs create mode 100644 ConfigFrontEnd/Common/BaseService.cs create mode 100644 ConfigFrontEnd/DI.cs create mode 100644 ConfigFrontEnd/Extensions/IdentityClaimPrincipalExtension.cs create mode 100644 ConfigFrontEnd/Filter/CurrentUserFilter.cs create mode 100644 ConfigFrontEnd/Models/CurrentUserState.cs create mode 100644 ConfigFrontEnd/Service/CurrentUserService.cs create mode 100644 ConfigFrontEnd/Service/JwtService.cs create mode 100644 Data/Abstracts/BaseEntity.cs create mode 100644 Data/Entities/ApplicationUser.cs create mode 100644 Data/Entities/Appraisal.cs create mode 100644 Data/Entities/AutoNumberSequence.cs create mode 100644 Data/Entities/Branch.cs create mode 100644 Data/Entities/Company.cs create mode 100644 Data/Entities/Currency.cs create mode 100644 Data/Entities/Deduction.cs create mode 100644 Data/Entities/Department.cs create mode 100644 Data/Entities/Designation.cs create mode 100644 Data/Entities/Employee.cs create mode 100644 Data/Entities/EmployeeDeduction.cs create mode 100644 Data/Entities/EmployeeIncome.cs create mode 100644 Data/Entities/Evaluation.cs create mode 100644 Data/Entities/Grade.cs create mode 100644 Data/Entities/Income.cs create mode 100644 Data/Entities/LeaveBalance.cs create mode 100644 Data/Entities/LeaveCategory.cs create mode 100644 Data/Entities/LeaveRequest.cs create mode 100644 Data/Entities/PayrollComponent.cs create mode 100644 Data/Entities/PayrollDetail.cs create mode 100644 Data/Entities/PayrollProcess.cs create mode 100644 Data/Entities/Promotion.cs create mode 100644 Data/Entities/SerilogLogs.cs create mode 100644 Data/Entities/Tax.cs create mode 100644 Data/Entities/Tenant.cs create mode 100644 Data/Entities/TenantUser.cs create mode 100644 Data/Entities/Transfer.cs create mode 100644 Data/Interfaces/IHasAudit.cs create mode 100644 Data/Interfaces/IHasAutoNumber.cs create mode 100644 Data/Interfaces/IHasIsDeleted.cs create mode 100644 Data/Interfaces/IMultiTenant.cs create mode 100644 Dockerfile create mode 100644 Features/Account/AccessDenied/AccessDeniedPage.razor create mode 100644 Features/Account/AccessDenied/RedirectToAccessDenied.razor create mode 100644 Features/Account/AccountEndPoint.cs create mode 100644 Features/Account/AuthenticationLayout.razor create mode 100644 Features/Account/ConfirmEmail/ConfirmEmailPage.razor create mode 100644 Features/Account/ForgotPassword/Components/ForgotPasswordPage.razor create mode 100644 Features/Account/ForgotPassword/Cqrs/ForgotPasswordHandler.cs create mode 100644 Features/Account/Login/Components/LoginPage.razor create mode 100644 Features/Account/Login/Cqrs/LoginHandler.cs create mode 100644 Features/Account/Logout/LogoutPage.razor create mode 100644 Features/Account/Register/Components/RegisterPage.razor create mode 100644 Features/Account/Register/Cqrs/RegisterHandler.cs create mode 100644 Features/Account/ResetPassword/Components/ResetPasswordPage.razor create mode 100644 Features/Account/ResetPassword/Cqrs/ResetPasswordHandler.cs create mode 100644 Features/Account/TenantSelection/Components/TenantSelectionPage.razor create mode 100644 Features/Account/TenantSelection/Cqrs/TenantSelectionHandler.cs create mode 100644 Features/Account/_Imports.razor create mode 100644 Features/AppSettings/Json/JsonPage.razor create mode 100644 Features/FeaturesDI.cs create mode 100644 Features/FeaturesEndpointMap.cs create mode 100644 Features/Leave/LeaveBalance/Components/LeaveBalancePage.razor create mode 100644 Features/Leave/LeaveBalance/Components/_LeaveBalanceCreateForm.razor create mode 100644 Features/Leave/LeaveBalance/Components/_LeaveBalanceDataTable.razor create mode 100644 Features/Leave/LeaveBalance/Components/_LeaveBalanceUpdateForm.razor create mode 100644 Features/Leave/LeaveBalance/Cqrs/CreateLeaveBalanceHandler.cs create mode 100644 Features/Leave/LeaveBalance/Cqrs/CreateLeaveBalanceValidator.cs create mode 100644 Features/Leave/LeaveBalance/Cqrs/DeleteLeaveBalanceByIdHandler.cs create mode 100644 Features/Leave/LeaveBalance/Cqrs/GetLeaveBalanceByIdHandler.cs create mode 100644 Features/Leave/LeaveBalance/Cqrs/GetLeaveBalanceListHandler.cs create mode 100644 Features/Leave/LeaveBalance/Cqrs/SyncLeaveBalanceHandler.cs create mode 100644 Features/Leave/LeaveBalance/Cqrs/UpdateLeaveBalanceHandler.cs create mode 100644 Features/Leave/LeaveBalance/Cqrs/UpdateLeaveBalanceValidator.cs create mode 100644 Features/Leave/LeaveBalance/LeaveBalanceEndpoint.cs create mode 100644 Features/Leave/LeaveBalance/LeaveBalanceService.cs create mode 100644 Features/Leave/LeaveCategory/Components/LeaveCategoryPage.razor create mode 100644 Features/Leave/LeaveCategory/Components/_LeaveCategoryCreateForm.razor create mode 100644 Features/Leave/LeaveCategory/Components/_LeaveCategoryDataTable.razor create mode 100644 Features/Leave/LeaveCategory/Components/_LeaveCategoryUpdateForm.razor create mode 100644 Features/Leave/LeaveCategory/Cqrs/CreateLeaveCategoryHandler.cs create mode 100644 Features/Leave/LeaveCategory/Cqrs/CreateLeaveCategoryValidator.cs create mode 100644 Features/Leave/LeaveCategory/Cqrs/DeleteLeaveCategoryByIdHandler.cs create mode 100644 Features/Leave/LeaveCategory/Cqrs/GetLeaveCategoryByIdHandler.cs create mode 100644 Features/Leave/LeaveCategory/Cqrs/GetLeaveCategoryListHandler.cs create mode 100644 Features/Leave/LeaveCategory/Cqrs/UpdateLeaveCategoryHandler.cs create mode 100644 Features/Leave/LeaveCategory/Cqrs/UpdateLeaveCategoryValidator.cs create mode 100644 Features/Leave/LeaveCategory/LeaveCategoryEndpoint.cs create mode 100644 Features/Leave/LeaveCategory/LeaveCategoryService.cs create mode 100644 Features/Leave/LeavePage.razor create mode 100644 Features/Leave/LeaveRequest/Components/LeaveRequestPage.razor create mode 100644 Features/Leave/LeaveRequest/Components/_LeaveRequestCreateForm.razor create mode 100644 Features/Leave/LeaveRequest/Components/_LeaveRequestDataTable.razor create mode 100644 Features/Leave/LeaveRequest/Components/_LeaveRequestUpdateForm.razor create mode 100644 Features/Leave/LeaveRequest/Cqrs/CreateLeaveRequestHandler.cs create mode 100644 Features/Leave/LeaveRequest/Cqrs/CreateLeaveRequestValidator.cs create mode 100644 Features/Leave/LeaveRequest/Cqrs/DeleteLeaveRequestByIdHandler.cs create mode 100644 Features/Leave/LeaveRequest/Cqrs/GetEmployeeLeaveReference.cs create mode 100644 Features/Leave/LeaveRequest/Cqrs/GetLeaveRequestByIdHandler.cs create mode 100644 Features/Leave/LeaveRequest/Cqrs/GetLeaveRequestListHandler.cs create mode 100644 Features/Leave/LeaveRequest/Cqrs/UpdateLeaveRequestHandler.cs create mode 100644 Features/Leave/LeaveRequest/Cqrs/UpdateLeaveRequestValidator.cs create mode 100644 Features/Leave/LeaveRequest/LeaveRequestEndpoint.cs create mode 100644 Features/Leave/LeaveRequest/LeaveRequestService.cs create mode 100644 Features/Multitenant/MultitenantPage.razor create mode 100644 Features/Multitenant/Tenant/Components/TenantPage.razor create mode 100644 Features/Multitenant/Tenant/Components/_TenantCreateForm.razor create mode 100644 Features/Multitenant/Tenant/Components/_TenantDataTable.razor create mode 100644 Features/Multitenant/Tenant/Components/_TenantUpdateForm.razor create mode 100644 Features/Multitenant/Tenant/Components/_TenantUserCreateForm.razor create mode 100644 Features/Multitenant/Tenant/Components/_TenantUserDataTable.razor create mode 100644 Features/Multitenant/Tenant/Components/_TenantUserUpdateForm.razor create mode 100644 Features/Multitenant/Tenant/Cqrs/CreateTenantHandler.cs create mode 100644 Features/Multitenant/Tenant/Cqrs/CreateTenantValidator.cs create mode 100644 Features/Multitenant/Tenant/Cqrs/DeleteTenantByIdHandler.cs create mode 100644 Features/Multitenant/Tenant/Cqrs/GetTenantByIdHandler.cs create mode 100644 Features/Multitenant/Tenant/Cqrs/GetTenantListHandler.cs create mode 100644 Features/Multitenant/Tenant/Cqrs/LookupTenantHandler.cs create mode 100644 Features/Multitenant/Tenant/Cqrs/UpdateTenantHandler.cs create mode 100644 Features/Multitenant/Tenant/Cqrs/UpdateTenantValidator.cs create mode 100644 Features/Multitenant/Tenant/TenantEndpoint.cs create mode 100644 Features/Multitenant/Tenant/TenantService.cs create mode 100644 Features/Multitenant/TenantUser/Components/TenantUserPage.razor create mode 100644 Features/Multitenant/TenantUser/Components/_TenantUserCreateForm.razor create mode 100644 Features/Multitenant/TenantUser/Components/_TenantUserDataTable.razor create mode 100644 Features/Multitenant/TenantUser/Components/_TenantUserUpdateForm.razor create mode 100644 Features/Multitenant/TenantUser/Cqrs/CreateTenantUserHandler.cs create mode 100644 Features/Multitenant/TenantUser/Cqrs/CreateTenantUserValidator.cs create mode 100644 Features/Multitenant/TenantUser/Cqrs/DeleteTenantUserHandler.cs create mode 100644 Features/Multitenant/TenantUser/Cqrs/GetTenantUserByIdHandler.cs create mode 100644 Features/Multitenant/TenantUser/Cqrs/GetTenantUserListHandler.cs create mode 100644 Features/Multitenant/TenantUser/Cqrs/LookupTenantUserHandler.cs create mode 100644 Features/Multitenant/TenantUser/Cqrs/UpdateTenantUserHandler.cs create mode 100644 Features/Multitenant/TenantUser/Cqrs/UpdateTenantUserValidator.cs create mode 100644 Features/Multitenant/TenantUser/TenantUserEndpoint.cs create mode 100644 Features/Multitenant/TenantUser/TenantUserService.cs create mode 100644 Features/Organization/Branch/BranchEndpoint.cs create mode 100644 Features/Organization/Branch/BranchService.cs create mode 100644 Features/Organization/Branch/Components/BranchPage.razor create mode 100644 Features/Organization/Branch/Components/_BranchCreateForm.razor create mode 100644 Features/Organization/Branch/Components/_BranchDataTable.razor create mode 100644 Features/Organization/Branch/Components/_BranchUpdateForm.razor create mode 100644 Features/Organization/Branch/Cqrs/CreateBranchHandler.cs create mode 100644 Features/Organization/Branch/Cqrs/CreateBranchValidator.cs create mode 100644 Features/Organization/Branch/Cqrs/DeleteBranchByIdHandler.cs create mode 100644 Features/Organization/Branch/Cqrs/GetBranchByIdHandler.cs create mode 100644 Features/Organization/Branch/Cqrs/GetBranchListHandler.cs create mode 100644 Features/Organization/Branch/Cqrs/UpdateBranchHandler.cs create mode 100644 Features/Organization/Branch/Cqrs/UpdateBranchValidator.cs create mode 100644 Features/Organization/Department/Components/DepartmentPage.razor create mode 100644 Features/Organization/Department/Components/_DepartmentCreateForm.razor create mode 100644 Features/Organization/Department/Components/_DepartmentDataTable.razor create mode 100644 Features/Organization/Department/Components/_DepartmentUpdateForm.razor create mode 100644 Features/Organization/Department/Cqrs/CreateDepartmentHandler.cs create mode 100644 Features/Organization/Department/Cqrs/CreateDepartmentValidator.cs create mode 100644 Features/Organization/Department/Cqrs/DeleteDepartmentByIdHandler.cs create mode 100644 Features/Organization/Department/Cqrs/GetDepartmentByIdHandler.cs create mode 100644 Features/Organization/Department/Cqrs/GetDepartmentListHandler.cs create mode 100644 Features/Organization/Department/Cqrs/UpdateDepartmentHandler.cs create mode 100644 Features/Organization/Department/Cqrs/UpdateDepartmentValidator.cs create mode 100644 Features/Organization/Department/DepartmentEndpoint.cs create mode 100644 Features/Organization/Department/DepartmentService.cs create mode 100644 Features/Organization/Designation/Components/DesignationPage.razor create mode 100644 Features/Organization/Designation/Components/_DesignationCreateForm.razor create mode 100644 Features/Organization/Designation/Components/_DesignationDataTable.razor create mode 100644 Features/Organization/Designation/Components/_DesignationUpdateForm.razor create mode 100644 Features/Organization/Designation/Cqrs/CreateDesignationHandler.cs create mode 100644 Features/Organization/Designation/Cqrs/CreateDesignationValidator.cs create mode 100644 Features/Organization/Designation/Cqrs/DeleteDesignationByIdHandler.cs create mode 100644 Features/Organization/Designation/Cqrs/GetDesignationByIdHandler.cs create mode 100644 Features/Organization/Designation/Cqrs/GetDesignationListHandler.cs create mode 100644 Features/Organization/Designation/Cqrs/UpdateDesignationHandler.cs create mode 100644 Features/Organization/Designation/Cqrs/UpdateDesignationValidator.cs create mode 100644 Features/Organization/Designation/DesignationEndpoint.cs create mode 100644 Features/Organization/Designation/DesignationService.cs create mode 100644 Features/Organization/Employee/Components/EmployeePage.razor create mode 100644 Features/Organization/Employee/Components/_EmployeeCreateForm.razor create mode 100644 Features/Organization/Employee/Components/_EmployeeDataTable.razor create mode 100644 Features/Organization/Employee/Components/_EmployeeDeductionCreateForm.razor create mode 100644 Features/Organization/Employee/Components/_EmployeeDeductionDataTable.razor create mode 100644 Features/Organization/Employee/Components/_EmployeeDeductionUpdateForm.razor create mode 100644 Features/Organization/Employee/Components/_EmployeeIncomeCreateForm.razor create mode 100644 Features/Organization/Employee/Components/_EmployeeIncomeDataTable.razor create mode 100644 Features/Organization/Employee/Components/_EmployeeIncomeUpdateForm.razor create mode 100644 Features/Organization/Employee/Components/_EmployeeUpdateForm.razor create mode 100644 Features/Organization/Employee/Cqrs/CreateEmployeeDeductionHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/CreateEmployeeDeductionValidator.cs create mode 100644 Features/Organization/Employee/Cqrs/CreateEmployeeHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/CreateEmployeeIncomeHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/CreateEmployeeIncomeValidator.cs create mode 100644 Features/Organization/Employee/Cqrs/CreateEmployeeValidator.cs create mode 100644 Features/Organization/Employee/Cqrs/DeleteEmployeeByIdHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/DeleteEmployeeDeductionByIdHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/DeleteEmployeeIncomeByIdHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/GetEmployeeByIdHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/GetEmployeeDeductionByIdHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/GetEmployeeDeductionListByEmployeeIdHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/GetEmployeeIncomeByIdHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/GetEmployeeIncomeListByEmployeeIdHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/GetEmployeeListHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/LookupHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/UpdateEmployeeDeductionHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/UpdateEmployeeDeductionValidator.cs create mode 100644 Features/Organization/Employee/Cqrs/UpdateEmployeeHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/UpdateEmployeeIncomeHandler.cs create mode 100644 Features/Organization/Employee/Cqrs/UpdateEmployeeIncomeValidator.cs create mode 100644 Features/Organization/Employee/Cqrs/UpdateEmployeeValidator.cs create mode 100644 Features/Organization/Employee/EmployeeEndpoint.cs create mode 100644 Features/Organization/Employee/EmployeeService.cs create mode 100644 Features/Organization/OrganizationPage.razor create mode 100644 Features/Payroll/Deduction/Components/DeductionPage.razor create mode 100644 Features/Payroll/Deduction/Components/_DeductionCreateForm.razor create mode 100644 Features/Payroll/Deduction/Components/_DeductionDataTable.razor create mode 100644 Features/Payroll/Deduction/Components/_DeductionUpdateForm.razor create mode 100644 Features/Payroll/Deduction/Cqrs/CreateDeductionHandler.cs create mode 100644 Features/Payroll/Deduction/Cqrs/CreateDeductionValidator.cs create mode 100644 Features/Payroll/Deduction/Cqrs/DeleteDeductionByIdHandler.cs create mode 100644 Features/Payroll/Deduction/Cqrs/GetDeductionByIdHandler.cs create mode 100644 Features/Payroll/Deduction/Cqrs/GetDeductionListHandler.cs create mode 100644 Features/Payroll/Deduction/Cqrs/UpdateDeductionHandler.cs create mode 100644 Features/Payroll/Deduction/Cqrs/UpdateDeductionValidator.cs create mode 100644 Features/Payroll/Deduction/DeductionEndpoint.cs create mode 100644 Features/Payroll/Deduction/DeductionService.cs create mode 100644 Features/Payroll/Grade/Components/GradePage.razor create mode 100644 Features/Payroll/Grade/Components/_GradeCreateForm.razor create mode 100644 Features/Payroll/Grade/Components/_GradeDataTable.razor create mode 100644 Features/Payroll/Grade/Components/_GradeUpdateForm.razor create mode 100644 Features/Payroll/Grade/Cqrs/CreateGradeHandler.cs create mode 100644 Features/Payroll/Grade/Cqrs/CreateGradeValidator.cs create mode 100644 Features/Payroll/Grade/Cqrs/DeleteGradeByIdHandler.cs create mode 100644 Features/Payroll/Grade/Cqrs/GetGradeByIdHandler.cs create mode 100644 Features/Payroll/Grade/Cqrs/GetGradeListHandler.cs create mode 100644 Features/Payroll/Grade/Cqrs/UpdateGradeHandler.cs create mode 100644 Features/Payroll/Grade/Cqrs/UpdateGradeValidator.cs create mode 100644 Features/Payroll/Grade/GradeEndpoint.cs create mode 100644 Features/Payroll/Grade/GradeService.cs create mode 100644 Features/Payroll/Income/Components/IncomePage.razor create mode 100644 Features/Payroll/Income/Components/_IncomeCreateForm.razor create mode 100644 Features/Payroll/Income/Components/_IncomeDataTable.razor create mode 100644 Features/Payroll/Income/Components/_IncomeUpdateForm.razor create mode 100644 Features/Payroll/Income/Cqrs/CreateIncomeHandler.cs create mode 100644 Features/Payroll/Income/Cqrs/CreateIncomeValidator.cs create mode 100644 Features/Payroll/Income/Cqrs/DeleteIncomeByIdHandler.cs create mode 100644 Features/Payroll/Income/Cqrs/GetIncomeByIdHandler.cs create mode 100644 Features/Payroll/Income/Cqrs/GetIncomeListHandler.cs create mode 100644 Features/Payroll/Income/Cqrs/UpdateIncomeHandler.cs create mode 100644 Features/Payroll/Income/Cqrs/UpdateIncomeValidator.cs create mode 100644 Features/Payroll/Income/IncomeEndpoint.cs create mode 100644 Features/Payroll/Income/IncomeService.cs create mode 100644 Features/Payroll/PayrollPage.razor create mode 100644 Features/Payroll/Payrolls/Components/PayrollsPage.razor create mode 100644 Features/Payroll/Payrolls/Components/_PayrollCalculateForm.razor create mode 100644 Features/Payroll/Payrolls/Components/_PayrollDetailGrid.razor create mode 100644 Features/Payroll/Payrolls/Components/_PayrollProcessDataTable.razor create mode 100644 Features/Payroll/Payrolls/Components/_PayrollSlipDialog.razor create mode 100644 Features/Payroll/Payrolls/Components/_PayrollUpdateForm.razor create mode 100644 Features/Payroll/Payrolls/Cqrs/CreatePayrollProcessHandler.cs create mode 100644 Features/Payroll/Payrolls/Cqrs/DeletePayrollProcessByIdHandler.cs create mode 100644 Features/Payroll/Payrolls/Cqrs/GetPayrollDetailByIdHandler.cs create mode 100644 Features/Payroll/Payrolls/Cqrs/GetPayrollDetailListByProcessIdHandler.cs create mode 100644 Features/Payroll/Payrolls/Cqrs/GetPayrollProcessByIdHandler.cs create mode 100644 Features/Payroll/Payrolls/Cqrs/GetPayrollProcessListHandler.cs create mode 100644 Features/Payroll/Payrolls/Cqrs/GetPayrollSlipByIdHandler.cs create mode 100644 Features/Payroll/Payrolls/Cqrs/UpdatePayrollProcessHandler.cs create mode 100644 Features/Payroll/Payrolls/PayrollsEndpoint.cs create mode 100644 Features/Payroll/Payrolls/PayrollsService.cs create mode 100644 Features/Performance/Appraisal/AppraisalEndpoint.cs create mode 100644 Features/Performance/Appraisal/AppraisalService.cs create mode 100644 Features/Performance/Appraisal/Components/AppraisalPage.razor create mode 100644 Features/Performance/Appraisal/Components/_AppraisalCreateForm.razor create mode 100644 Features/Performance/Appraisal/Components/_AppraisalDataTable.razor create mode 100644 Features/Performance/Appraisal/Components/_AppraisalUpdateForm.razor create mode 100644 Features/Performance/Appraisal/Cqrs/CreateAppraisalHandler.cs create mode 100644 Features/Performance/Appraisal/Cqrs/CreateAppraisalValidator.cs create mode 100644 Features/Performance/Appraisal/Cqrs/DeleteAppraisalByIdHandler.cs create mode 100644 Features/Performance/Appraisal/Cqrs/GetAppraisalByIdHandler.cs create mode 100644 Features/Performance/Appraisal/Cqrs/GetAppraisalListHandler.cs create mode 100644 Features/Performance/Appraisal/Cqrs/UpdateAppraisalHandler.cs create mode 100644 Features/Performance/Appraisal/Cqrs/UpdateAppraisalValidator.cs create mode 100644 Features/Performance/Evaluation/Components/EvaluationPage.razor create mode 100644 Features/Performance/Evaluation/Components/_EvaluationCreateForm.razor create mode 100644 Features/Performance/Evaluation/Components/_EvaluationDataTable.razor create mode 100644 Features/Performance/Evaluation/Components/_EvaluationUpdateForm.razor create mode 100644 Features/Performance/Evaluation/Cqrs/CreateEvaluationHandler.cs create mode 100644 Features/Performance/Evaluation/Cqrs/CreateEvaluationValidator.cs create mode 100644 Features/Performance/Evaluation/Cqrs/DeleteEvaluationByIdHandler.cs create mode 100644 Features/Performance/Evaluation/Cqrs/GetEvaluationByIdHandler.cs create mode 100644 Features/Performance/Evaluation/Cqrs/GetEvaluationListHandler.cs create mode 100644 Features/Performance/Evaluation/Cqrs/UpdateEvaluationHandler.cs create mode 100644 Features/Performance/Evaluation/Cqrs/UpdateEvaluationValidator.cs create mode 100644 Features/Performance/Evaluation/EvaluationEndpoint.cs create mode 100644 Features/Performance/Evaluation/EvaluationService.cs create mode 100644 Features/Performance/PerformancePage.razor create mode 100644 Features/Performance/Promotion/Components/PromotionPage.razor create mode 100644 Features/Performance/Promotion/Components/_PromotionCreateForm.razor create mode 100644 Features/Performance/Promotion/Components/_PromotionDataTable.razor create mode 100644 Features/Performance/Promotion/Components/_PromotionUpdateForm.razor create mode 100644 Features/Performance/Promotion/Cqrs/CreatePromotionHandler.cs create mode 100644 Features/Performance/Promotion/Cqrs/CreatePromotionValidator.cs create mode 100644 Features/Performance/Promotion/Cqrs/DeletePromotionByIdHandler.cs create mode 100644 Features/Performance/Promotion/Cqrs/GetPromotionByIdHandler.cs create mode 100644 Features/Performance/Promotion/Cqrs/GetPromotionListHandler.cs create mode 100644 Features/Performance/Promotion/Cqrs/UpdatePromotionHandler.cs create mode 100644 Features/Performance/Promotion/Cqrs/UpdatePromotionValidator.cs create mode 100644 Features/Performance/Promotion/PromotionEndpoint.cs create mode 100644 Features/Performance/Promotion/PromotionService.cs create mode 100644 Features/Performance/Transfer/Components/TransferPage.razor create mode 100644 Features/Performance/Transfer/Components/_TransferCreateForm.razor create mode 100644 Features/Performance/Transfer/Components/_TransferDataTable.razor create mode 100644 Features/Performance/Transfer/Components/_TransferUpdateForm.razor create mode 100644 Features/Performance/Transfer/Cqrs/CreateTransferHandler.cs create mode 100644 Features/Performance/Transfer/Cqrs/CreateTransferValidator.cs create mode 100644 Features/Performance/Transfer/Cqrs/DeleteTransferByIdHandler.cs create mode 100644 Features/Performance/Transfer/Cqrs/GetTransferByIdHandler.cs create mode 100644 Features/Performance/Transfer/Cqrs/GetTransferListHandler.cs create mode 100644 Features/Performance/Transfer/Cqrs/GetTransferLookupsHandler.cs create mode 100644 Features/Performance/Transfer/Cqrs/UpdateTransferHandler.cs create mode 100644 Features/Performance/Transfer/Cqrs/UpdateTransferValidator.cs create mode 100644 Features/Performance/Transfer/TransferEndpoint.cs create mode 100644 Features/Performance/Transfer/TransferService.cs create mode 100644 Features/Profile/Avatar/AvatarEndpoint.cs create mode 100644 Features/Profile/Avatar/AvatarService.cs create mode 100644 Features/Profile/Avatar/Components/AvatarPage.razor create mode 100644 Features/Profile/Avatar/Cqrs/ChangeAvatarHandler.cs create mode 100644 Features/Profile/Avatar/Cqrs/GetAvatarInfoHandler.cs create mode 100644 Features/Profile/Password/Components/PasswordPage.razor create mode 100644 Features/Profile/Password/Cqrs/UpdatePasswordHandler.cs create mode 100644 Features/Profile/Password/Cqrs/UpdatePasswordValidator.cs create mode 100644 Features/Profile/Password/PasswordEndpoint.cs create mode 100644 Features/Profile/Password/PasswordService.cs create mode 100644 Features/Profile/PersonalInformation/Components/PersonalInformationPage.razor create mode 100644 Features/Profile/PersonalInformation/Cqrs/GetProfileByIdHandler.cs create mode 100644 Features/Profile/PersonalInformation/Cqrs/UpdateProfileHandler.cs create mode 100644 Features/Profile/PersonalInformation/Cqrs/UpdateProfileValidator.cs create mode 100644 Features/Profile/PersonalInformation/PersonalInformationEndpoint.cs create mode 100644 Features/Profile/PersonalInformation/PersonalInformationService.cs create mode 100644 Features/Profile/ProfilePage.razor create mode 100644 Features/Profile/Session/SessionPage.razor create mode 100644 Features/Root/App.razor create mode 100644 Features/Root/EmptyLayout.razor create mode 100644 Features/Root/Error/ErrorPage.razor create mode 100644 Features/Root/Home/Components/DashboardPage.razor create mode 100644 Features/Root/Home/Cqrs/DashboardHandler.cs create mode 100644 Features/Root/Home/HomeEndpoint.cs create mode 100644 Features/Root/Home/HomeService.cs create mode 100644 Features/Root/MainLayout.razor create mode 100644 Features/Root/MainLayout.razor.css create mode 100644 Features/Root/NotFound/NotFoundPage.razor create mode 100644 Features/Root/Pages/LandingPage.cshtml create mode 100644 Features/Root/ReconnectModal.razor create mode 100644 Features/Root/Routes.razor create mode 100644 Features/Root/Shared/_DeleteConfirmation.razor create mode 100644 Features/Root/SsoFirebase.razor create mode 100644 Features/Root/SsoKeycloak.razor create mode 100644 Features/Root/_Imports.razor create mode 100644 Features/Serilogs/Database/Components/DatabasePage.razor create mode 100644 Features/Serilogs/Database/Components/_SerilogDetailDialog.razor create mode 100644 Features/Serilogs/Database/DeleteSerilogLogsAllHandler.cs create mode 100644 Features/Serilogs/Database/DeleteSerilogLogsByIdHandler.cs create mode 100644 Features/Serilogs/Database/GetSerilogLogsByIdHandler.cs create mode 100644 Features/Serilogs/Database/GetSerilogLogsPagedListHandler.cs create mode 100644 Features/Serilogs/File/Components/FilePage.razor create mode 100644 Features/Serilogs/SerilogsEndpoint.cs create mode 100644 Features/Serilogs/SerilogsODataController.cs create mode 100644 Features/Serilogs/SerilogsPage.razor create mode 100644 Features/Serilogs/SerilogsService.cs create mode 100644 Features/Setting/AutoNumberSequence/AutoNumberSequenceEndpoint.cs create mode 100644 Features/Setting/AutoNumberSequence/AutoNumberSequenceService.cs create mode 100644 Features/Setting/AutoNumberSequence/Components/AutoNumberSequencePage.razor create mode 100644 Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceDataTable.razor create mode 100644 Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceForm.razor create mode 100644 Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceByIdHandler.cs create mode 100644 Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceListHandler.cs create mode 100644 Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceHandler.cs create mode 100644 Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceValidator.cs create mode 100644 Features/Setting/Company/CompanyEndpoint.cs create mode 100644 Features/Setting/Company/CompanyService.cs create mode 100644 Features/Setting/Company/Components/CompanyPage.razor create mode 100644 Features/Setting/Company/Components/_CompanyCreateForm.razor create mode 100644 Features/Setting/Company/Components/_CompanyDataTable.razor create mode 100644 Features/Setting/Company/Components/_CompanyUpdateForm.razor create mode 100644 Features/Setting/Company/Cqrs/CreateCompanyHandler.cs create mode 100644 Features/Setting/Company/Cqrs/CreateCompanyValidator.cs create mode 100644 Features/Setting/Company/Cqrs/DeleteCompanyByIdHandler.cs create mode 100644 Features/Setting/Company/Cqrs/GetCompanyByIdHandler.cs create mode 100644 Features/Setting/Company/Cqrs/GetCompanyListHandler.cs create mode 100644 Features/Setting/Company/Cqrs/UpdateCompanyHandler.cs create mode 100644 Features/Setting/Company/Cqrs/UpdateCompanyValidator.cs create mode 100644 Features/Setting/Currency/Components/CurrencyPage.razor create mode 100644 Features/Setting/Currency/Components/_CurrencyCreateForm.razor create mode 100644 Features/Setting/Currency/Components/_CurrencyDataTable.razor create mode 100644 Features/Setting/Currency/Components/_CurrencyUpdateForm.razor create mode 100644 Features/Setting/Currency/Cqrs/CreateCurrencyHandler.cs create mode 100644 Features/Setting/Currency/Cqrs/CreateCurrencyValidator.cs create mode 100644 Features/Setting/Currency/Cqrs/DeleteCurrencyByIdHandler.cs create mode 100644 Features/Setting/Currency/Cqrs/GetCurrencyByIdHandler.cs create mode 100644 Features/Setting/Currency/Cqrs/GetCurrencyListHandler.cs create mode 100644 Features/Setting/Currency/Cqrs/UpdateCurrencyHandler.cs create mode 100644 Features/Setting/Currency/Cqrs/UpdateCurrencyValidator.cs create mode 100644 Features/Setting/Currency/CurrencyEndpoint.cs create mode 100644 Features/Setting/Currency/CurrencyService.cs create mode 100644 Features/Setting/SettingPage.razor create mode 100644 Features/Setting/SystemUser/Components/SystemUserPage.razor create mode 100644 Features/Setting/SystemUser/Components/_SystemUserChangeAvatarForm.razor create mode 100644 Features/Setting/SystemUser/Components/_SystemUserChangePasswordForm.razor create mode 100644 Features/Setting/SystemUser/Components/_SystemUserChangeRoleForm.razor create mode 100644 Features/Setting/SystemUser/Components/_SystemUserCreateForm.razor create mode 100644 Features/Setting/SystemUser/Components/_SystemUserDataTable.razor create mode 100644 Features/Setting/SystemUser/Components/_SystemUserUpdateForm.razor create mode 100644 Features/Setting/SystemUser/Cqrs/AdminForgotPasswordHandler.cs create mode 100644 Features/Setting/SystemUser/Cqrs/ChangeAvatarHandler.cs create mode 100644 Features/Setting/SystemUser/Cqrs/ChangePasswordHandler.cs create mode 100644 Features/Setting/SystemUser/Cqrs/ChangePasswordValidator.cs create mode 100644 Features/Setting/SystemUser/Cqrs/ChangeRoleHandler.cs create mode 100644 Features/Setting/SystemUser/Cqrs/CreateSystemUserHandler.cs create mode 100644 Features/Setting/SystemUser/Cqrs/CreateSystemUserValidator.cs create mode 100644 Features/Setting/SystemUser/Cqrs/DeleteSystemUserByIdHandler.cs create mode 100644 Features/Setting/SystemUser/Cqrs/GetSystemUserByIdHandler.cs create mode 100644 Features/Setting/SystemUser/Cqrs/GetSystemUserListHandler.cs create mode 100644 Features/Setting/SystemUser/Cqrs/UpdateSystemUserHandler.cs create mode 100644 Features/Setting/SystemUser/Cqrs/UpdateSystemUserValidator.cs create mode 100644 Features/Setting/SystemUser/SystemUserEndpoint.cs create mode 100644 Features/Setting/SystemUser/SystemUserService.cs create mode 100644 Features/Setting/Tax/Components/TaxPage.razor create mode 100644 Features/Setting/Tax/Components/_TaxCreateForm.razor create mode 100644 Features/Setting/Tax/Components/_TaxDataTable.razor create mode 100644 Features/Setting/Tax/Components/_TaxUpdateForm.razor create mode 100644 Features/Setting/Tax/Cqrs/CreateTaxHandler.cs create mode 100644 Features/Setting/Tax/Cqrs/CreateTaxValidator.cs create mode 100644 Features/Setting/Tax/Cqrs/DeleteTaxByIdHandler.cs create mode 100644 Features/Setting/Tax/Cqrs/GetTaxByIdHandler.cs create mode 100644 Features/Setting/Tax/Cqrs/GetTaxListHandler.cs create mode 100644 Features/Setting/Tax/Cqrs/UpdateTaxHandler.cs create mode 100644 Features/Setting/Tax/Cqrs/UpdateTaxValidator.cs create mode 100644 Features/Setting/Tax/TaxEndpoint.cs create mode 100644 Features/Setting/Tax/TaxService.cs create mode 100644 Features/_Imports.razor create mode 100644 Indotalent.csproj create mode 100644 Indotalent.slnx create mode 100644 Infrastructure/Authentication/DI.cs create mode 100644 Infrastructure/Authentication/Firebase/FirebaseService.cs create mode 100644 Infrastructure/Authentication/Firebase/FirebaseSettingsModel.cs create mode 100644 Infrastructure/Authentication/Identity/CookieSettings.cs create mode 100644 Infrastructure/Authentication/Identity/CustomClaimsPrincipalFactory.cs create mode 100644 Infrastructure/Authentication/Identity/DefaultAdminSettings.cs create mode 100644 Infrastructure/Authentication/Identity/DefaultUserConfig.cs create mode 100644 Infrastructure/Authentication/Identity/IdentityRevalidatingAuthenticationStateProvider.cs create mode 100644 Infrastructure/Authentication/Identity/IdentityService.cs create mode 100644 Infrastructure/Authentication/Identity/IdentitySettingsModel.cs create mode 100644 Infrastructure/Authentication/Identity/JwtSettingsModel.cs create mode 100644 Infrastructure/Authentication/Identity/PasswordSettings.cs create mode 100644 Infrastructure/Authentication/Identity/SignInSettings.cs create mode 100644 Infrastructure/Authentication/Identity/TokenProvider.cs create mode 100644 Infrastructure/Authentication/Keycloak/KeycloakService.cs create mode 100644 Infrastructure/Authentication/Keycloak/KeycloakSettingsModel.cs create mode 100644 Infrastructure/Authorization/Identity/ApplicationRoles.cs create mode 100644 Infrastructure/AutoNumberGenerator/AutoNumberGeneratorService.cs create mode 100644 Infrastructure/AutoNumberGenerator/DI.cs create mode 100644 Infrastructure/AutoNumberGenerator/IAutoNumberGenerator.cs create mode 100644 Infrastructure/BackgroundJob/BackgroundJobService.cs create mode 100644 Infrastructure/BackgroundJob/BackgroundJobSettingsModel.cs create mode 100644 Infrastructure/BackgroundJob/DI.cs create mode 100644 Infrastructure/BackgroundJob/Hangfire/HangfireSettingsModel.cs create mode 100644 Infrastructure/BackgroundJob/Quartz/QuartzSettingsModel.cs create mode 100644 Infrastructure/DI.cs create mode 100644 Infrastructure/Database/AppDbContext.cs create mode 100644 Infrastructure/Database/DI.cs create mode 100644 Infrastructure/Database/DatabaseProviderModel.cs create mode 100644 Infrastructure/Database/DatabaseSeeder.cs create mode 100644 Infrastructure/Database/DatabaseService.cs create mode 100644 Infrastructure/Database/DatabaseSettingsModel.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/ApplicationUserConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/AppraisalConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/AutoNumberSequenceConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/BranchConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/CompanyConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/CurrencyConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/DeductionConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/DepartmentConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/DesignationConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/EmployeeConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/EmployeeDeductionConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/EmployeeIncomeConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/EvaluationConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/GradeConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/IncomeConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/LeaveBalanceConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/LeaveCategoryConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/LeaveRequestConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/PayrollComponentConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/PayrollDetailConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/PayrollProcessConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/PromotionConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/SerilogLogsConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/TaxConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/Configuration/TransferConfiguration.cs create mode 100644 Infrastructure/Database/MsSQL/MsSQLConfiguration.cs create mode 100644 Infrastructure/Database/MySQL/MySQLConfiguration.cs create mode 100644 Infrastructure/Database/PostgreSQL/PostgreSQLConfiguration.cs create mode 100644 Infrastructure/Email/DI.cs create mode 100644 Infrastructure/Email/DefaultEmailSender.cs create mode 100644 Infrastructure/Email/EmailService.cs create mode 100644 Infrastructure/Email/EmailSettingsModel.cs create mode 100644 Infrastructure/Email/IEmailSender.cs create mode 100644 Infrastructure/Email/IdentityEmailManager.cs create mode 100644 Infrastructure/Email/Mailgun/MailgunEmailSender.cs create mode 100644 Infrastructure/Email/Mailgun/MailgunSettingsModel.cs create mode 100644 Infrastructure/Email/Mailjet/MailjetEmailSender.cs create mode 100644 Infrastructure/Email/Mailjet/MailjetSettingsModel.cs create mode 100644 Infrastructure/Email/SendGrid/SendGridEmailSender.cs create mode 100644 Infrastructure/Email/SendGrid/SendGridSettingsModel.cs create mode 100644 Infrastructure/Email/Smtp/SmtpEmailSender.cs create mode 100644 Infrastructure/Email/Smtp/SmtpSettingsModel.cs create mode 100644 Infrastructure/File/Avatar/AvatarStorageModel.cs create mode 100644 Infrastructure/File/Aws/AwsS3SettingsModel.cs create mode 100644 Infrastructure/File/Azure/AzureBlobSettingsModel.cs create mode 100644 Infrastructure/File/DI.cs create mode 100644 Infrastructure/File/Dropbox/DropboxSettingsModel.cs create mode 100644 Infrastructure/File/FileStorageService.cs create mode 100644 Infrastructure/File/FileStorageSettingsModel.cs create mode 100644 Infrastructure/File/Google/GoogleCloudSettingsModel.cs create mode 100644 Infrastructure/File/Local/LocalStorageModel.cs create mode 100644 Infrastructure/Logging/DI.cs create mode 100644 Infrastructure/Logging/LoggerSettingsModel.cs create mode 100644 Infrastructure/Logging/LoggingService.cs create mode 100644 Infrastructure/Logging/Serilog/DatabaseLoggerModel.cs create mode 100644 Infrastructure/Logging/Serilog/FileLoggerModel.cs create mode 100644 Infrastructure/Logging/Serilog/SeqLoggerModel.cs create mode 100644 Infrastructure/Logging/Serilog/SerilogBuilder.cs create mode 100644 Infrastructure/OData/DI.cs create mode 100644 Infrastructure/OData/ODataResponse.cs create mode 100644 LICENSE.txt create mode 100644 Program.cs create mode 100644 Properties/PublishProfiles/FolderProfile.pubxml create mode 100644 Properties/PublishProfiles/FolderProfile1.pubxml create mode 100644 Properties/launchSettings.json create mode 100644 Shared/Consts/GlobalConsts.cs create mode 100644 Shared/Models/ApiRequest.cs create mode 100644 Shared/Models/ApiResponse.cs create mode 100644 Shared/Models/PagedList.cs create mode 100644 Shared/Utils/FluentValidationHelper.cs create mode 100644 Shared/Utils/PayrollSlipPdfGenerator.cs create mode 100644 Shared/Utils/SequentialGuidGenerator.cs create mode 100644 appsettings.Development.json create mode 100644 appsettings.json create mode 100644 developer.txt create mode 100644 docker-compose.yml create mode 100644 dotnet-tools.json create mode 100644 wwwroot/avatars/readme.txt create mode 100644 wwwroot/favico.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a125955 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# OS files +Thumbs.db +.DS_Store +*.swp +*.swo + +# IDE +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# .NET +bin/ +obj/ +*.user +*.suo +*.cache +*.log +*.vs/ +packages/ +*.nupkg + +# Docker +.dockerignore + +# Uploads & Logs +wwwroot/uploads/* +!wwwroot/uploads/.gitkeep +wwwroot/xlogs/* +!wwwroot/xlogs/.gitkeep \ No newline at end of file diff --git a/ConfigBackEnd/Attributes/AuthorizeAttribute.cs b/ConfigBackEnd/Attributes/AuthorizeAttribute.cs new file mode 100644 index 0000000..9afaed8 --- /dev/null +++ b/ConfigBackEnd/Attributes/AuthorizeAttribute.cs @@ -0,0 +1,18 @@ +namespace Indotalent.ConfigBackEnd.Attributes; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] +public class AuthorizeAttribute : Attribute +{ + public string Role { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; + public string Policy { get; set; } = string.Empty; + + public AuthorizeAttribute() + { + } + + public AuthorizeAttribute(string role) + { + Role = role; + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Behaviours/AuthorizationBehaviour.cs b/ConfigBackEnd/Behaviours/AuthorizationBehaviour.cs new file mode 100644 index 0000000..90d308d --- /dev/null +++ b/ConfigBackEnd/Behaviours/AuthorizationBehaviour.cs @@ -0,0 +1,68 @@ +namespace Indotalent.ConfigBackEnd.Behaviours; + +using Indotalent.ConfigBackEnd.Attributes; +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Interfaces; +using MediatR; +using System.Reflection; + +public class AuthorizationBehaviour : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ICurrentUserService _currentUserService; + + public AuthorizationBehaviour(ICurrentUserService currentUserService) + { + _currentUserService = currentUserService; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var authorizeAttributes = request.GetType().GetCustomAttributes(); + + if (authorizeAttributes.Any()) + { + if (_currentUserService.UserId == null) + { + throw new UnauthorizedAccessException(); + } + + var authorizeAttributesWithRoles = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Role)); + + if (authorizeAttributesWithRoles.Any()) + { + var authorized = false; + foreach (var role in authorizeAttributesWithRoles.Select(a => a.Role)) + { + var isInRole = await _currentUserService.IsInRoleAsync(role.Trim()); + if (isInRole) + { + authorized = true; + break; + } + } + + if (!authorized) + { + throw new ForbiddenAccessException(); + } + } + + var authorizeAttributesWithPermissions = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Permission)); + + if (authorizeAttributesWithPermissions.Any()) + { + foreach (var permission in authorizeAttributesWithPermissions.Select(a => a.Permission)) + { + var hasPermission = await _currentUserService.HasPermissionAsync(permission.Trim()); + if (!hasPermission) + { + throw new ForbiddenAccessException(); + } + } + } + } + + return await next(); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Behaviours/LoggingBehaviour.cs b/ConfigBackEnd/Behaviours/LoggingBehaviour.cs new file mode 100644 index 0000000..a9912c2 --- /dev/null +++ b/ConfigBackEnd/Behaviours/LoggingBehaviour.cs @@ -0,0 +1,30 @@ +namespace Indotalent.ConfigBackEnd.Behaviours; + +using Indotalent.ConfigBackEnd.Interfaces; +using MediatR.Pipeline; +using Microsoft.Extensions.Logging; + +public class LoggingBehaviour : IRequestPreProcessor + where TRequest : notnull +{ + private readonly ILogger _logger; + private readonly ICurrentUserService _currentUserService; + + public LoggingBehaviour(ILogger logger, ICurrentUserService currentUserService) + { + _logger = logger; + _currentUserService = currentUserService; + } + + public Task Process(TRequest request, CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + var userId = _currentUserService.UserId ?? string.Empty; + var userName = _currentUserService.UserName ?? string.Empty; + + _logger.LogInformation("Request: {Name} {UserId} {UserName} {@Request}", + requestName, userId, userName, request); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Behaviours/PerformanceBehaviour.cs b/ConfigBackEnd/Behaviours/PerformanceBehaviour.cs new file mode 100644 index 0000000..4e455d1 --- /dev/null +++ b/ConfigBackEnd/Behaviours/PerformanceBehaviour.cs @@ -0,0 +1,46 @@ +namespace Indotalent.ConfigBackEnd.Behaviours; + +using Indotalent.ConfigBackEnd.Interfaces; +using MediatR; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +public class PerformanceBehaviour : IPipelineBehavior + where TRequest : IRequest +{ + private readonly Stopwatch _timer; + private readonly ILogger _logger; + private readonly ICurrentUserService _currentUserService; + + public PerformanceBehaviour( + ILogger logger, + ICurrentUserService currentUserService) + { + _timer = new Stopwatch(); + _logger = logger; + _currentUserService = currentUserService; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + _timer.Start(); + + var response = await next(); + + _timer.Stop(); + + var elapsedMilliseconds = _timer.ElapsedMilliseconds; + + if (elapsedMilliseconds > 500) + { + var requestName = typeof(TRequest).Name; + var userId = _currentUserService.UserId ?? string.Empty; + var userName = _currentUserService.UserName ?? string.Empty; + + _logger.LogWarning("Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@UserName} {@Request}", + requestName, elapsedMilliseconds, userId, userName, request); + } + + return response; + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Behaviours/UnhandledExceptionBehaviour.cs b/ConfigBackEnd/Behaviours/UnhandledExceptionBehaviour.cs new file mode 100644 index 0000000..08cfaa2 --- /dev/null +++ b/ConfigBackEnd/Behaviours/UnhandledExceptionBehaviour.cs @@ -0,0 +1,33 @@ +namespace Indotalent.ConfigBackEnd.Behaviours; + +using Indotalent.Shared.Consts; +using MediatR; +using Microsoft.Extensions.Logging; + +public class UnhandledExceptionBehaviour : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger _logger; + + public UnhandledExceptionBehaviour(ILogger logger) + { + _logger = logger; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + try + { + return await next(); + } + catch (Exception ex) + { + var requestName = typeof(TRequest).Name; + + _logger.LogError(ex, GlobalConsts.BehaviourError + " {Name} {@Request}", + requestName, request); + + throw; + } + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Behaviours/ValidationBehaviour.cs b/ConfigBackEnd/Behaviours/ValidationBehaviour.cs new file mode 100644 index 0000000..5325860 --- /dev/null +++ b/ConfigBackEnd/Behaviours/ValidationBehaviour.cs @@ -0,0 +1,42 @@ +namespace Indotalent.ConfigBackEnd.Behaviours; + +using FluentValidation; +using MediatR; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +public class ValidationBehaviour : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + + public ValidationBehaviour(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (_validators.Any()) + { + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + throw new ValidationException(failures); + } + } + + return await next(); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/DI.cs b/ConfigBackEnd/DI.cs new file mode 100644 index 0000000..77f2d52 --- /dev/null +++ b/ConfigBackEnd/DI.cs @@ -0,0 +1,30 @@ +namespace Indotalent.ConfigBackEnd; + +using FluentValidation; +using Indotalent.ConfigBackEnd.Behaviours; +using MediatR; +using MediatR.Pipeline; +using Microsoft.Extensions.DependencyInjection; + +public static class DI +{ + public static IServiceCollection AddConfigBackEndDI(this IServiceCollection services) + { + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(typeof(DI).Assembly); + + cfg.AddRequestPreProcessor(typeof(IRequestPreProcessor<>), typeof(LoggingBehaviour<>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(UnhandledExceptionBehaviour<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehaviour<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehaviour<,>)); + + cfg.Lifetime = ServiceLifetime.Scoped; + }); + + services.AddValidatorsFromAssembly(typeof(DI).Assembly); + + return services; + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Exceptions/AlreadyExistsExceptions.cs b/ConfigBackEnd/Exceptions/AlreadyExistsExceptions.cs new file mode 100644 index 0000000..62d4f1d --- /dev/null +++ b/ConfigBackEnd/Exceptions/AlreadyExistsExceptions.cs @@ -0,0 +1,24 @@ +namespace Indotalent.ConfigBackEnd.Exceptions; + +public class AlreadyExistsException : Exception +{ + public AlreadyExistsException() + : base() + { + } + + public AlreadyExistsException(string message) + : base(message) + { + } + + public AlreadyExistsException(string message, Exception innerException) + : base(message, innerException) + { + } + + public AlreadyExistsException(string name, object key) + : base($"Entity \"{name}\" ({key}) already exists.") + { + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Exceptions/ForbiddenAccessException.cs b/ConfigBackEnd/Exceptions/ForbiddenAccessException.cs new file mode 100644 index 0000000..8ed5281 --- /dev/null +++ b/ConfigBackEnd/Exceptions/ForbiddenAccessException.cs @@ -0,0 +1,17 @@ +namespace Indotalent.ConfigBackEnd.Exceptions; + +public class ForbiddenAccessException : Exception +{ + public ForbiddenAccessException() : base() + { + } + + public ForbiddenAccessException(string message) : base(message) + { + } + + public ForbiddenAccessException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/ConfigBackEnd/Exceptions/MismatchException.cs b/ConfigBackEnd/Exceptions/MismatchException.cs new file mode 100644 index 0000000..86380fb --- /dev/null +++ b/ConfigBackEnd/Exceptions/MismatchException.cs @@ -0,0 +1,24 @@ +namespace Indotalent.ConfigBackEnd.Exceptions; + +public class MismatchException : Exception +{ + public MismatchException() + : base() + { + } + + public MismatchException(string message) + : base(message) + { + } + + public MismatchException(string message, Exception innerException) + : base(message, innerException) + { + } + + public MismatchException(string name, object expected, object actual) + : base($"Mismatch detected for \"{name}\". Expected: {expected}, but received: {actual}.") + { + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Exceptions/NotFoundException.cs b/ConfigBackEnd/Exceptions/NotFoundException.cs new file mode 100644 index 0000000..5ed30c3 --- /dev/null +++ b/ConfigBackEnd/Exceptions/NotFoundException.cs @@ -0,0 +1,24 @@ +namespace Indotalent.ConfigBackEnd.Exceptions; + +public class NotFoundException : Exception +{ + public NotFoundException() + : base() + { + } + + public NotFoundException(string message) + : base(message) + { + } + + public NotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } + + public NotFoundException(string name, object key) + : base($"Entity \"{name}\" ({key}) was not found.") + { + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Exceptions/ValidationException.cs b/ConfigBackEnd/Exceptions/ValidationException.cs new file mode 100644 index 0000000..b397890 --- /dev/null +++ b/ConfigBackEnd/Exceptions/ValidationException.cs @@ -0,0 +1,36 @@ +namespace Indotalent.ConfigBackEnd.Exceptions; + +using FluentValidation.Results; +using System.Collections.Generic; +using System.Linq; + +public class ValidationException : Exception +{ + public IDictionary Errors { get; } + + public ValidationException() + : base("One or more validation failures have occurred.") + { + Errors = new Dictionary(); + } + + public ValidationException(IEnumerable failures) + : this() + { + Errors = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + } + + public ValidationException(string message) + : base(message) + { + Errors = new Dictionary(); + } + + public ValidationException(string message, Exception innerException) + : base(message, innerException) + { + Errors = new Dictionary(); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Extensions/AppDbContextExtensions.cs b/ConfigBackEnd/Extensions/AppDbContextExtensions.cs new file mode 100644 index 0000000..dd664db --- /dev/null +++ b/ConfigBackEnd/Extensions/AppDbContextExtensions.cs @@ -0,0 +1,40 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.Infrastructure.AutoNumberGenerator; +using Indotalent.Infrastructure.Database; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Indotalent.ConfigBackEnd.Extensions; + +public static class AppDbContextExtensions +{ + public static async Task GenerateAutoNumberAsync( + this AppDbContext context, + string entityName, + string prefixTemplate, + string? suffixTemplate = null, + int paddingLength = 4, + bool useYear = true, + bool useMonth = false, + CancellationToken ct = default) + { + var currentUserService = context.GetService(); + var tenantId = currentUserService?.TenantId; + + if (string.IsNullOrWhiteSpace(tenantId)) + { + throw new InvalidOperationException("TenantId cannot be null or empty for multi-tenant auto-number sequence generation. Ensure the user session context is valid."); + } + + var generator = context.GetService(); + + return await generator.GenerateNextNumberAsync( + tenantId, + entityName, + prefixTemplate, + suffixTemplate, + paddingLength, + useYear, + useMonth, + ct); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Extensions/DateTimeExtensions.cs b/ConfigBackEnd/Extensions/DateTimeExtensions.cs new file mode 100644 index 0000000..f876323 --- /dev/null +++ b/ConfigBackEnd/Extensions/DateTimeExtensions.cs @@ -0,0 +1,14 @@ +namespace Indotalent.ConfigBackEnd.Extensions; + +public static class DateTimeExtensions +{ + private const string DefaultFormat = "dd MMM yyyy, HH:mm"; + + public static string ToString(this DateTimeOffset? dateTimeOffset, string format = DefaultFormat) + { + if (!dateTimeOffset.HasValue) return "-"; + + return dateTimeOffset.Value.ToLocalTime().ToString(format); + } + +} diff --git a/ConfigBackEnd/Extensions/GenericExtensions.cs b/ConfigBackEnd/Extensions/GenericExtensions.cs new file mode 100644 index 0000000..88da31b --- /dev/null +++ b/ConfigBackEnd/Extensions/GenericExtensions.cs @@ -0,0 +1,86 @@ +namespace Indotalent.ConfigBackEnd.Extensions; + +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Http; +using System.Text.Json; + +public static class GenericExtensions +{ + public static bool IsNullOrEmpty(this IEnumerable? enumerable) + { + return enumerable == null || !enumerable.Any(); + } + + public static T? ToObject(this string json) + { + if (string.IsNullOrWhiteSpace(json)) return default; + + return JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + + public static string ToJson(this T obj) + { + return JsonSerializer.Serialize(obj); + } + + public static TDestination MapTo(this object source) + where TDestination : new() + { + var json = JsonSerializer.Serialize(source); + return JsonSerializer.Deserialize(json) ?? new TDestination(); + } + + public static string ToCurrency(this decimal value, string culture = "id-ID") + { + return value.ToString("C0", new System.Globalization.CultureInfo(culture)); + } + + public static bool IsBetween(this T value, T low, T high) where T : IComparable + { + return value.CompareTo(low) >= 0 && value.CompareTo(high) <= 0; + } + + public static IResult ToApiResponse(this PagedList result) + { + return Results.Ok(new ApiResponse> + { + IsSuccess = true, + StatusCode = 200, + Value = result.Value, + Pagination = new PaginationMetadata + { + Count = result.Count, + Top = result.Top, + Skip = result.Skip + }, + ServerTime = DateTime.UtcNow + }); + } + + public static IResult ToApiResponse(this T? result, string? message = null, int statusCode = 200) + { + if (result == null) + { + return Results.NotFound(new ApiResponse + { + IsSuccess = false, + StatusCode = 404, + Message = message ?? "Data not found", + ServerTime = DateTime.UtcNow + }); + } + + return Results.Ok(new ApiResponse + { + IsSuccess = true, + StatusCode = statusCode, + Message = message, + Value = result, + Pagination = null, + ServerTime = DateTime.UtcNow + }); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Extensions/IQueryableExtensions.cs b/ConfigBackEnd/Extensions/IQueryableExtensions.cs new file mode 100644 index 0000000..6b2b51a --- /dev/null +++ b/ConfigBackEnd/Extensions/IQueryableExtensions.cs @@ -0,0 +1,63 @@ +namespace Indotalent.ConfigBackEnd.Extensions; + +using Indotalent.Data.Interfaces; +using Indotalent.Shared.Models; +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; + +public static class IQueryableExtensions +{ + public static IQueryable WhereIf( + this IQueryable query, + bool condition, + Expression> predicate) + { + return condition ? query.Where(predicate) : query; + } + + public static async Task> ToPagedListAsync( + this IQueryable query, + int skip, + int top) + { + top = top <= 0 ? 5 : top; + skip = skip < 0 ? 0 : skip; + + var count = await query.CountAsync(); + + var items = await query + .Skip(skip) + .Take(top) + .ToListAsync(); + + return new PagedList(items, count, skip, top); + } + + public static IQueryable OrderByPropertyName( + this IQueryable query, + string? propertyName, + bool isDescending) + { + if (string.IsNullOrWhiteSpace(propertyName)) return query; + + var parameter = Expression.Parameter(typeof(T), "x"); + var property = Expression.Property(parameter, propertyName); + var lambda = Expression.Lambda(property, parameter); + + var methodName = isDescending ? "OrderByDescending" : "OrderBy"; + var resultExpression = Expression.Call( + typeof(Queryable), + methodName, + new Type[] { typeof(T), property.Type }, + query.Expression, + Expression.Quote(lambda)); + + return query.Provider.CreateQuery(resultExpression); + } + + public static IQueryable NotDeletedOnly(this IQueryable query) + where T : class, IHasIsDeleted + { + return query.Where(x => !x.IsDeleted); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Extensions/ListExtensions.cs b/ConfigBackEnd/Extensions/ListExtensions.cs new file mode 100644 index 0000000..bc8a78a --- /dev/null +++ b/ConfigBackEnd/Extensions/ListExtensions.cs @@ -0,0 +1,51 @@ +namespace Indotalent.ConfigBackEnd.Extensions; + +using System; +using System.Collections.Generic; +using System.Linq; + +public static class ListExtensions +{ + public static void AddIf(this IList list, bool condition, T item) + { + if (condition) + { + list.Add(item); + } + } + + public static IEnumerable> Chunk(this IEnumerable source, int size) + { + while (source.Any()) + { + yield return source.Take(size); + source = source.Skip(size); + } + } + + public static void Shuffle(this IList list) + { + var rng = new Random(); + int n = list.Count; + while (n > 1) + { + n--; + int k = rng.Next(n + 1); + T value = list[k]; + list[k] = list[n]; + list[n] = value; + } + } + + public static IEnumerable DistinctBy(this IEnumerable source, Func keySelector) + { + var seenKeys = new HashSet(); + foreach (var element in source) + { + if (seenKeys.Add(keySelector(element))) + { + yield return element; + } + } + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Extensions/StringExtensions.cs b/ConfigBackEnd/Extensions/StringExtensions.cs new file mode 100644 index 0000000..ba5cc61 --- /dev/null +++ b/ConfigBackEnd/Extensions/StringExtensions.cs @@ -0,0 +1,92 @@ +namespace Indotalent.ConfigBackEnd.Extensions; + +using System.Text.RegularExpressions; + +public static class StringExtensions +{ + public static string ToShortNameVowel(this string? value, int length = 3) + { + if (string.IsNullOrWhiteSpace(value)) return "GEN"; + + var vowels = new string(value.Where(c => "AEIOUaeiou".Contains(c)).ToArray()); + var baseName = vowels.Length >= length ? vowels : value; + + return new string(baseName.Trim().Take(length).ToArray()).ToUpper(); + } + + public static string ToShortNameConsonant(this string? value, int length = 3) + { + if (string.IsNullOrWhiteSpace(value)) return "GEN"; + + var consonants = new string(value.Where(c => char.IsLetter(c) && !"AEIOUaeiou".Contains(c)).ToArray()); + var baseName = consonants.Length >= length ? consonants : value; + + return new string(baseName.Trim().Take(length).ToArray()).ToUpper(); + } + + public static string ToInitial(this string? value) + { + if (string.IsNullOrWhiteSpace(value)) return "XX"; + + var words = value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (words.Length >= 2) + { + var firstInitial = words[0][0]; + var secondInitial = words[1][0]; + return $"{firstInitial}{secondInitial}".ToUpper(); + } + + return new string(value.Trim().Take(2).ToArray()).ToUpper(); + } + + public static bool IsNotNullOrWhiteSpace(this string? value) + { + return !string.IsNullOrWhiteSpace(value); + } + + public static string ToTitleCase(this string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + + return System.Globalization.CultureInfo.CurrentCulture.TextInfo + .ToTitleCase(value.ToLower().Trim()); + } + + public static string ToSlug(this string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + + var str = value.ToLower().Trim(); + str = Regex.Replace(str, @"[^a-z0-9\s-]", ""); + str = Regex.Replace(str, @"[\s-]+", " ").Trim(); + str = str.Replace(" ", "-"); + + return str; + } + + public static string MaskEmail(this string? email) + { + if (string.IsNullOrWhiteSpace(email) || !email.Contains("@")) return "******"; + + var parts = email.Split('@'); + var name = parts[0]; + var domain = parts[1]; + + if (name.Length <= 2) return $"{name}***@{domain}"; + + return $"{name[..2]}***{name[^1..]}@{domain}"; + } + + public static string Truncate(this string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + return value.Length <= maxLength ? value : $"{value[..maxLength]}..."; + } + + public static string RemoveNonNumeric(this string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + return Regex.Replace(value, "[^0-9]", ""); + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Interfaces/ICurrentUserService.cs b/ConfigBackEnd/Interfaces/ICurrentUserService.cs new file mode 100644 index 0000000..ffaa7c1 --- /dev/null +++ b/ConfigBackEnd/Interfaces/ICurrentUserService.cs @@ -0,0 +1,12 @@ +namespace Indotalent.ConfigBackEnd.Interfaces; + +public interface ICurrentUserService +{ + string? TenantId { get; set; } + string? UserId { get; set; } + string? UserName { get; set; } + string? Email { get; set; } + string? FullName { get; set; } + Task IsInRoleAsync(string role); + Task HasPermissionAsync(string permission); +} diff --git a/ConfigBackEnd/Mappings/IMapFrom.cs b/ConfigBackEnd/Mappings/IMapFrom.cs new file mode 100644 index 0000000..07202eb --- /dev/null +++ b/ConfigBackEnd/Mappings/IMapFrom.cs @@ -0,0 +1,8 @@ +namespace Indotalent.ConfigBackEnd.Mappings; + +using AutoMapper; + +public interface IMapFrom +{ + void Mapping(Profile profile) => profile.CreateMap(typeof(T), GetType()); +} \ No newline at end of file diff --git a/ConfigBackEnd/Mappings/MappingProfile.cs b/ConfigBackEnd/Mappings/MappingProfile.cs new file mode 100644 index 0000000..fa77a63 --- /dev/null +++ b/ConfigBackEnd/Mappings/MappingProfile.cs @@ -0,0 +1,30 @@ +namespace Indotalent.ConfigBackEnd.Mappings; + +using AutoMapper; +using System.Reflection; + +public class MappingProfile : Profile +{ + public MappingProfile() + { + ApplyMappingsFromAssembly(Assembly.GetExecutingAssembly()); + } + + private void ApplyMappingsFromAssembly(Assembly assembly) + { + var types = assembly.GetExportedTypes() + .Where(t => t.GetInterfaces().Any(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IMapFrom<>))) + .ToList(); + + foreach (var type in types) + { + var instance = Activator.CreateInstance(type); + + var methodInfo = type.GetMethod("Mapping") + ?? type.GetInterface("IMapFrom`1")?.GetMethod("Mapping"); + + methodInfo?.Invoke(instance, new object[] { this }); + } + } +} \ No newline at end of file diff --git a/ConfigBackEnd/Middleware/ExceptionHandlingMiddleware.cs b/ConfigBackEnd/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..8a7d805 --- /dev/null +++ b/ConfigBackEnd/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,105 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Shared.Consts; +using Indotalent.Shared.Models; +using System.Net; +using System.Text.Json; + +namespace Indotalent.ConfigBackEnd.Middleware; + +public class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task Invoke(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var code = HttpStatusCode.InternalServerError; + var message = "An unexpected error occurred on the server."; + var errors = new List(); + + switch (exception) + { + case ValidationException validationException: + code = HttpStatusCode.BadRequest; + message = "One or more validation failures have occurred."; + foreach (var error in validationException.Errors) + { + errors.AddRange(error.Value); + } + break; + + case NotFoundException notFoundException: + code = HttpStatusCode.NotFound; + message = notFoundException.Message; + errors.Add(notFoundException.Message); + break; + + case AlreadyExistsException alreadyExistsException: + code = HttpStatusCode.Conflict; + message = alreadyExistsException.Message; + errors.Add(alreadyExistsException.Message); + break; + + case MismatchException mismatchException: + code = HttpStatusCode.BadRequest; + message = mismatchException.Message; + errors.Add(mismatchException.Message); + break; + + case ForbiddenAccessException: + code = HttpStatusCode.Forbidden; + message = "You do not have permission to access this resource."; + errors.Add("Access denied to the requested resource."); + break; + + case UnauthorizedAccessException: + code = HttpStatusCode.Unauthorized; + message = "Authentication is required to access this resource."; + errors.Add("Session invalid or expired."); + break; + + default: + if (exception.StackTrace != null && exception.StackTrace.Contains(GlobalConsts.BehaviourError)) + { + _logger.LogError(exception, GlobalConsts.GlobalError + " {Message} on {Path}", + exception.Message, context.Request.Path); + } + message = "An unexpected error occurred on the HRM server."; + errors.Add(exception.Message); + break; + } + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)code; + + var response = new ApiResponse + { + IsSuccess = false, + StatusCode = (int)code, + Message = message, + Errors = errors, + ServerTime = DateTime.UtcNow + }; + + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + await context.Response.WriteAsync(JsonSerializer.Serialize(response, jsonOptions)); + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Common/BaseAppPage.cs b/ConfigFrontEnd/Common/BaseAppPage.cs new file mode 100644 index 0000000..db3b981 --- /dev/null +++ b/ConfigFrontEnd/Common/BaseAppPage.cs @@ -0,0 +1,37 @@ +using Indotalent.ConfigFrontEnd.Extensions; +using Indotalent.ConfigFrontEnd.Models; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; + +namespace Indotalent.ConfigFrontEnd.Common; + + +public abstract class BaseAppPage : ComponentBase +{ + [Inject] + protected AuthenticationStateProvider AuthStateProvider { get; set; } = default!; + + [Inject] + protected CurrentUserState State { get; set; } = default!; + + protected override async Task OnParametersSetAsync() + { + await SyncUserContext(); + await base.OnParametersSetAsync(); + } + + private async Task SyncUserContext() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user.Identity?.IsAuthenticated == true) + { + State.UserId = user.GetUserId(); + State.UserName = user.GetUserName(); + State.Email = user.GetEmail(); + State.FullName = user.GetFullName(); + State.TenantId = user.GetTenantId(); + } + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Common/BaseService.cs b/ConfigFrontEnd/Common/BaseService.cs new file mode 100644 index 0000000..1f57abc --- /dev/null +++ b/ConfigFrontEnd/Common/BaseService.cs @@ -0,0 +1,154 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.Features.Account.Login.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; +using System.Net; + +namespace Indotalent.ConfigFrontEnd.Common; + +public abstract class BaseService +{ + protected readonly IHttpClientFactory ClientFactory; + protected readonly NavigationManager Nav; + protected readonly ISnackbar Snackbar; + protected readonly ICurrentUserService CurrentUserService; + protected readonly TokenProvider TokenProvider; + + public BaseService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + { + ClientFactory = clientFactory; + Nav = nav; + Snackbar = snackbar; + CurrentUserService = currentUserService; + TokenProvider = tokenProvider; + } + + protected HttpClient CreateClient() + { + var client = ClientFactory.CreateClient(); + if (client.BaseAddress == null) + { + client.BaseAddress = new Uri(Nav.BaseUri); + } + return client; + } + + protected async Task?> ExecuteWithResponseAsync(RestClient client, RestRequest request) + { + try + { + if (!string.IsNullOrEmpty(TokenProvider.Token)) + request.AddOrUpdateHeader("Authorization", $"Bearer {TokenProvider.Token}"); + + if (!string.IsNullOrEmpty(TokenProvider.RefreshToken)) + request.AddOrUpdateHeader("X-Refresh-Token", TokenProvider.RefreshToken); + + if (!string.IsNullOrEmpty(CurrentUserService.UserId)) + { + request.AddOrUpdateHeader("X-UserId", CurrentUserService.UserId); + request.AddOrUpdateHeader("X-UserName", CurrentUserService.UserName ?? string.Empty); + request.AddOrUpdateHeader("X-Email", CurrentUserService.Email ?? string.Empty); + request.AddOrUpdateHeader("X-FullName", CurrentUserService.FullName ?? string.Empty); + request.AddOrUpdateHeader("X-TenantId", CurrentUserService.TenantId ?? string.Empty); + } + + var response = await client.ExecuteAsync>(request); + + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + var httpClient = CreateClient(); + + var refreshRequestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/account/refresh-token"); + + if (!string.IsNullOrEmpty(TokenProvider.RefreshToken)) + { + refreshRequestMessage.Headers.Add("X-Refresh-Token", TokenProvider.RefreshToken); + } + + var refreshResponse = await httpClient.SendAsync(refreshRequestMessage); + + if (refreshResponse.IsSuccessStatusCode) + { + var result = await refreshResponse.Content.ReadFromJsonAsync(); + if (result != null && !string.IsNullOrEmpty(result.Token)) + { + TokenProvider.Token = result.Token; + + request.AddOrUpdateHeader("Authorization", $"Bearer {TokenProvider.Token}"); + response = await client.ExecuteAsync>(request); + } + } + else + { + Nav.NavigateTo("/account/login"); + return default; + } + } + + if (response.Data != null) + { + if (!response.Data.IsSuccess) + { + HandleApiError(response.Data); + } + return response.Data; + } + + var fallbackMessage = GetFriendlyInfrastructureErrorMessage(response); + Snackbar.Add(fallbackMessage, Severity.Error); + + return new ApiResponse + { + IsSuccess = false, + StatusCode = (int)response.StatusCode, + Message = fallbackMessage + }; + } + catch (Exception ex) + { + var systemError = $"Client System Error: {ex.Message}"; + Snackbar.Add(systemError, Severity.Error); + return new ApiResponse { IsSuccess = false, Message = systemError }; + } + } + + private void HandleApiError(ApiResponse response) + { + var mainMessage = response.Message ?? "Operation failed"; + if (response.Errors != null && response.Errors.Any()) + { + var combinedErrors = string.Join(Environment.NewLine, response.Errors.Select(e => $"• {e}")); + Snackbar.Add(combinedErrors, Severity.Error, config => + { + config.ShowCloseIcon = true; + config.VisibleStateDuration = 3000; + }); + } + else + { + Snackbar.Add(mainMessage, Severity.Error); + } + } + + private string GetFriendlyInfrastructureErrorMessage(RestResponse response) + { + return response.StatusCode switch + { + HttpStatusCode.ServiceUnavailable => "Server is currently under maintenance.", + HttpStatusCode.GatewayTimeout => "Server took too long to respond.", + HttpStatusCode.Unauthorized => "Session expired. Please login again.", + HttpStatusCode.Forbidden => "You do not have permission to access this resource.", + HttpStatusCode.InternalServerError => "A critical error occurred on the server.", + _ => "Unable to reach the server. Please check your connection." + }; + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/DI.cs b/ConfigFrontEnd/DI.cs new file mode 100644 index 0000000..e11c56e --- /dev/null +++ b/ConfigFrontEnd/DI.cs @@ -0,0 +1,37 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Models; +using Indotalent.ConfigFrontEnd.Service; +using MudBlazor; +using MudBlazor.Services; + +namespace Indotalent.ConfigFrontEnd; + +public static class DI +{ + public static IServiceCollection AddConfigFrontEndDI(this IServiceCollection services) + { + services.AddMudServices(config => + { + config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.TopRight; + config.SnackbarConfiguration.PreventDuplicates = true; + config.SnackbarConfiguration.NewestOnTop = true; + config.SnackbarConfiguration.ShowCloseIcon = true; + config.SnackbarConfiguration.VisibleStateDuration = 5000; + config.SnackbarConfiguration.HideTransitionDuration = 300; + config.SnackbarConfiguration.ShowTransitionDuration = 300; + config.SnackbarConfiguration.SnackbarVariant = Variant.Filled; + }); + + + services.AddHttpClient("AppClient", client => + { + client.Timeout = TimeSpan.FromMinutes(30); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }); + + services.AddScoped(); + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Extensions/IdentityClaimPrincipalExtension.cs b/ConfigFrontEnd/Extensions/IdentityClaimPrincipalExtension.cs new file mode 100644 index 0000000..1dd86fd --- /dev/null +++ b/ConfigFrontEnd/Extensions/IdentityClaimPrincipalExtension.cs @@ -0,0 +1,30 @@ +using System.Security.Claims; + +namespace Indotalent.ConfigFrontEnd.Extensions; + +public static class IdentityClaimPrincipalExtension +{ + public static string? GetUserId(this ClaimsPrincipal? user) + { + return user?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } + + public static string? GetEmail(this ClaimsPrincipal? user) + { + return user?.FindFirst(ClaimTypes.Email)?.Value; + } + + public static string? GetUserName(this ClaimsPrincipal? user) + { + return user?.FindFirst(ClaimTypes.Name)?.Value; + } + + public static string? GetFullName(this ClaimsPrincipal? user) + { + return user?.FindFirst(ClaimTypes.GivenName)?.Value; + } + public static string? GetTenantId(this ClaimsPrincipal? user) + { + return user?.FindFirst(ClaimTypes.GroupSid)?.Value; + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Filter/CurrentUserFilter.cs b/ConfigFrontEnd/Filter/CurrentUserFilter.cs new file mode 100644 index 0000000..0820fd2 --- /dev/null +++ b/ConfigFrontEnd/Filter/CurrentUserFilter.cs @@ -0,0 +1,47 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Extensions; + +namespace Indotalent.ConfigFrontEnd.Filter; + +public class CurrentUserFilter : IEndpointFilter +{ + private readonly ICurrentUserService _currentUserService; + + public CurrentUserFilter(ICurrentUserService currentUserService) + { + _currentUserService = currentUserService; + } + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var httpContext = context.HttpContext; + var user = httpContext.User; + + string? userId = user.GetUserId(); + string? email = user.GetEmail(); + string? userName = user.GetUserName(); + string? fullName = user.GetFullName(); + string? tenantId = user.GetTenantId(); + + + if (string.IsNullOrEmpty(userId)) + { + userId = httpContext.Request.Headers["X-UserId"].ToString(); + userName = httpContext.Request.Headers["X-UserName"].ToString(); + email = httpContext.Request.Headers["X-Email"].ToString(); + fullName = httpContext.Request.Headers["X-FullName"].ToString(); + tenantId = httpContext.Request.Headers["X-TenantId"].ToString(); + } + + if (!string.IsNullOrEmpty(userId)) + { + _currentUserService.UserId = userId; + _currentUserService.UserName = userName; + _currentUserService.Email = email; + _currentUserService.FullName = fullName; + _currentUserService.TenantId = tenantId; + } + + return await next(context); + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Models/CurrentUserState.cs b/ConfigFrontEnd/Models/CurrentUserState.cs new file mode 100644 index 0000000..6bd8dc5 --- /dev/null +++ b/ConfigFrontEnd/Models/CurrentUserState.cs @@ -0,0 +1,10 @@ +namespace Indotalent.ConfigFrontEnd.Models; + +public class CurrentUserState +{ + public string? TenantId { get; set; } + public string? UserId { get; set; } + public string? UserName { get; set; } + public string? Email { get; set; } + public string? FullName { get; set; } +} diff --git a/ConfigFrontEnd/Service/CurrentUserService.cs b/ConfigFrontEnd/Service/CurrentUserService.cs new file mode 100644 index 0000000..764368b --- /dev/null +++ b/ConfigFrontEnd/Service/CurrentUserService.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Models; + +namespace Indotalent.ConfigFrontEnd.Service; + +public class CurrentUserService : ICurrentUserService +{ + private readonly CurrentUserState _state; + + public CurrentUserService(CurrentUserState state) + { + _state = state; + } + + public string? TenantId + { + get => _state.TenantId; + set => _state.TenantId = value; + } + + public string? UserId + { + get => _state.UserId; + set => _state.UserId = value; + } + + public string? UserName + { + get => _state.UserName; + set => _state.UserName = value; + } + + public string? Email + { + get => _state.Email; + set => _state.Email = value; + } + + public string? FullName + { + get => _state.FullName; + set => _state.FullName = value; + } + + public Task IsInRoleAsync(string role) + { + return Task.FromResult(false); + } + + public Task HasPermissionAsync(string permission) + { + return Task.FromResult(false); + } +} \ No newline at end of file diff --git a/ConfigFrontEnd/Service/JwtService.cs b/ConfigFrontEnd/Service/JwtService.cs new file mode 100644 index 0000000..c709893 --- /dev/null +++ b/ConfigFrontEnd/Service/JwtService.cs @@ -0,0 +1,62 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authentication.Identity; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace Indotalent.ConfigFrontEnd.Service; + +public static class JwtService +{ + public static string GenerateNewJwt(ApplicationUser user, JwtSettingsModel _jwtSettings) + { + var authClaims = new List + { + new(ClaimTypes.Name, user.UserName ?? ""), + new(ClaimTypes.Email, user.Email ?? ""), + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.GivenName, user.FullName ?? ""), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Key)); + + var token = new JwtSecurityToken( + issuer: _jwtSettings.Issuer, + audience: _jwtSettings.Audience, + expires: DateTime.Now.AddMinutes(_jwtSettings.DurationInMinutes), + claims: authClaims, + signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static string GenerateNewJwtWithClaims(ApplicationUser user, List extraClaims, JwtSettingsModel settings) + { + var authClaims = new List + { + new(ClaimTypes.Name, user.UserName ?? ""), + new(ClaimTypes.Email, user.Email ?? ""), + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.GivenName, user.FullName ?? ""), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + + authClaims.AddRange(extraClaims); + + var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.Key)); + + var token = new JwtSecurityToken( + issuer: settings.Issuer, + audience: settings.Audience, + expires: DateTime.Now.AddMinutes(settings.DurationInMinutes), + claims: authClaims, + signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + +} diff --git a/Data/Abstracts/BaseEntity.cs b/Data/Abstracts/BaseEntity.cs new file mode 100644 index 0000000..5173e00 --- /dev/null +++ b/Data/Abstracts/BaseEntity.cs @@ -0,0 +1,16 @@ +using Indotalent.Data.Interfaces; +using Indotalent.Shared.Utils; +using System.ComponentModel.DataAnnotations; + +namespace Indotalent.Data.Abstracts; + +public abstract class BaseEntity : IHasAudit, IHasIsDeleted +{ + [Key] + public string Id { get; set; } = SequentialGuidGenerator.NewSequentialId(); + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public bool IsDeleted { get; set; } = false; +} diff --git a/Data/Entities/ApplicationUser.cs b/Data/Entities/ApplicationUser.cs new file mode 100644 index 0000000..be6d9f9 --- /dev/null +++ b/Data/Entities/ApplicationUser.cs @@ -0,0 +1,41 @@ +using Indotalent.Data.Interfaces; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Data.Entities; + +public class ApplicationUser : IdentityUser, IHasIsDeleted +{ + public string? FullName { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? ShortBio { get; set; } + public string? JobTitle { get; set; } + public DateTime DateOfBirth { get; set; } + public string? StreetAddress { get; set; } + public string? City { get; set; } + public string? StateProvince { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string SocialMediaLinkedIn { get; set; } = string.Empty; + public string SocialMediaX { get; set; } = string.Empty; + public string SocialMediaFacebook { get; set; } = string.Empty; + public string SocialMediaInstagram { get; set; } = string.Empty; + public string SocialMediaTikTok { get; set; } = string.Empty; + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; + public string? SsoIdFirebase { get; set; } + public string? SsoIdKeycloak { get; set; } + public string? SsoIdAzure { get; set; } + public string? SsoIdAws { get; set; } + public string? SsoIdOther1 { get; set; } + public string? SsoIdOther2 { get; set; } + public string? SsoIdOther3 { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.Now; + public DateTime? LastLoginAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public bool IsActive { get; set; } = true; + public bool IsDeleted { get; set; } = false; + public string? AvatarFile { get; set; } + public string? RefreshToken { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/Appraisal.cs b/Data/Entities/Appraisal.cs new file mode 100644 index 0000000..015c80d --- /dev/null +++ b/Data/Entities/Appraisal.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Appraisal : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string EmployeeId { get; set; } = string.Empty; + public Employee? Employee { get; set; } + public string LastRating { get; set; } = string.Empty; + public decimal CurrentSalary { get; set; } + public decimal IncrementPercentage { get; set; } + public decimal NewSalary { get; set; } + public string AppraisalType { get; set; } = string.Empty; + public DateTime EffectiveDate { get; set; } + public string AppraisalNote { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/AutoNumberSequence.cs b/Data/Entities/AutoNumberSequence.cs new file mode 100644 index 0000000..8bbba41 --- /dev/null +++ b/Data/Entities/AutoNumberSequence.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; +using Indotalent.Infrastructure.AutoNumberGenerator; + +namespace Indotalent.Data.Entities; + +public class AutoNumberSequence : BaseEntity, IAutoNumberGenerator, IMultiTenant +{ + public string? EntityName { get; set; } + public int? Year { get; set; } + public int? Month { get; set; } + public long? CurrentSequence { get; set; } + public string? PrefixTemplate { get; set; } + public string? SuffixTemplate { get; set; } + public int? PaddingLength { get; set; } + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Branch.cs b/Data/Entities/Branch.cs new file mode 100644 index 0000000..4ccb837 --- /dev/null +++ b/Data/Entities/Branch.cs @@ -0,0 +1,22 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Branch : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string StreetAddress { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string StateProvince { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; + public string Phone { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Company.cs b/Data/Entities/Company.cs new file mode 100644 index 0000000..1f40e8c --- /dev/null +++ b/Data/Entities/Company.cs @@ -0,0 +1,32 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Company : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public bool IsDefault { get; set; } = false; + public string CurrencyId { get; set; } = string.Empty; //lookup to Currency + public Currency? Currency { get; set; } + public string TaxIdentification { get; set; } = string.Empty; + public string BusinessLicense { get; set; } = string.Empty; + public string CompanyLogo { get; set; } = string.Empty; + public string StreetAddress { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string StateProvince { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; + public string Phone { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string SocialMediaLinkedIn { get; set; } = string.Empty; + public string SocialMediaX { get; set; } = string.Empty; + public string SocialMediaFacebook { get; set; } = string.Empty; + public string SocialMediaInstagram { get; set; } = string.Empty; + public string SocialMediaTikTok { get; set; } = string.Empty; + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Currency.cs b/Data/Entities/Currency.cs new file mode 100644 index 0000000..6d7bae6 --- /dev/null +++ b/Data/Entities/Currency.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Currency : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Code { get; set; } + public string? Symbol { get; set; } + public string? CountryOwner { get; set; } + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Deduction.cs b/Data/Entities/Deduction.cs new file mode 100644 index 0000000..5eaa9ee --- /dev/null +++ b/Data/Entities/Deduction.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Deduction : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Category { get; set; } + public bool PreTax { get; set; } = false; + public string? CalculationMethod { get; set; } + public string? Status { get; set; } = "Active"; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Department.cs b/Data/Entities/Department.cs new file mode 100644 index 0000000..fa2ca53 --- /dev/null +++ b/Data/Entities/Department.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Department : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string? CostCenter { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string HeadOfDeptartment { get; set; } = string.Empty; + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Designation.cs b/Data/Entities/Designation.cs new file mode 100644 index 0000000..ec3dae3 --- /dev/null +++ b/Data/Entities/Designation.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Designation : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Level { get; set; } + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Employee.cs b/Data/Entities/Employee.cs new file mode 100644 index 0000000..61dbaf8 --- /dev/null +++ b/Data/Entities/Employee.cs @@ -0,0 +1,57 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Employee : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string Code { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string MiddleName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string JobDescription { get; set; } = string.Empty; + public string? GradeId { get; set; } + public Grade? Grade { get; set; } + public decimal BasicSalary { get; set; } + public string SalaryBankName { get; set; } = string.Empty; + public string SalaryBankAccountName { get; set; } = string.Empty; + public string SalaryBankAccountNumber { get; set; } = string.Empty; + public string PlaceOfBirth { get; set; } = string.Empty; + public DateTime? DateOfBirth { get; set; } + public string Gender { get; set; } = string.Empty; + public string MaritalStatus { get; set; } = string.Empty; + public string Religion { get; set; } = string.Empty; + public string BloodType { get; set; } = string.Empty; + public string IdentityNumber { get; set; } = string.Empty; + public string TaxNumber { get; set; } = string.Empty; + public string LastEducation { get; set; } = string.Empty; + public DateTime? JoinedDate { get; set; } + public DateTime? ResignedDate { get; set; } + public string EmployeeStatus { get; set; } = string.Empty; + public string EmploymentType { get; set; } = string.Empty; + public string StreetAddress { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string StateProvince { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; + public string Phone { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string BranchId { get; set; } = string.Empty; + public Branch? Branch { get; set; } + public string DepartmentId { get; set; } = string.Empty; + public Department? Department { get; set; } + public string DesignationId { get; set; } = string.Empty; + public Designation? Designation { get; set; } + public string SocialMediaLinkedIn { get; set; } = string.Empty; + public string SocialMediaX { get; set; } = string.Empty; + public string SocialMediaFacebook { get; set; } = string.Empty; + public string SocialMediaInstagram { get; set; } = string.Empty; + public string SocialMediaTikTok { get; set; } = string.Empty; + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; + public string? TenantId { get; set; } + + public virtual ICollection EmployeeIncome { get; set; } = new List(); + public virtual ICollection EmployeeDeduction { get; set; } = new List(); +} diff --git a/Data/Entities/EmployeeDeduction.cs b/Data/Entities/EmployeeDeduction.cs new file mode 100644 index 0000000..79a1156 --- /dev/null +++ b/Data/Entities/EmployeeDeduction.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class EmployeeDeduction : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public Employee? Employee { get; set; } + public string? DeductionId { get; set; } + public Deduction? Deduction { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/EmployeeIncome.cs b/Data/Entities/EmployeeIncome.cs new file mode 100644 index 0000000..888f103 --- /dev/null +++ b/Data/Entities/EmployeeIncome.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class EmployeeIncome : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public Employee? Employee { get; set; } + public string? IncomeId { get; set; } + public Income? Income { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Evaluation.cs b/Data/Entities/Evaluation.cs new file mode 100644 index 0000000..12e1f0a --- /dev/null +++ b/Data/Entities/Evaluation.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Evaluation : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string EmployeeId { get; set; } = string.Empty; + public Employee? Employee { get; set; } + public string Period { get; set; } = string.Empty; + public string FinalScore { get; set; } = string.Empty; + public string Rating { get; set; } = string.Empty; + public string EvaluatorId { get; set; } = string.Empty; + public Employee? Evaluator { get; set; } + public DateTime EvaluationDate { get; set; } + public string EvaluationNote { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Grade.cs b/Data/Entities/Grade.cs new file mode 100644 index 0000000..7338d41 --- /dev/null +++ b/Data/Entities/Grade.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Grade : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public decimal SalaryFrom { get; set; } + public decimal SalaryTo { get; set; } + public bool IsOverTimeEligible { get; set; } = true; + public string? Status { get; set; } = "Active"; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Income.cs b/Data/Entities/Income.cs new file mode 100644 index 0000000..694ff5a --- /dev/null +++ b/Data/Entities/Income.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Income : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Type { get; set; } + public bool IsTaxable { get; set; } = true; + public string? CalculationBase { get; set; } + public string? Status { get; set; } = "Active"; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/LeaveBalance.cs b/Data/Entities/LeaveBalance.cs new file mode 100644 index 0000000..eb5bf92 --- /dev/null +++ b/Data/Entities/LeaveBalance.cs @@ -0,0 +1,23 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class LeaveBalance : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + + public string EmployeeId { get; set; } = string.Empty; + public Employee? Employee { get; set; } + public string LeaveCategoryId { get; set; } = string.Empty; + public LeaveCategory? LeaveCategory { get; set; } + public int Entitlement { get; set; } + public int Used { get; set; } + public int Pending { get; set; } + public int Remaining { get; set; } + public int Year { get; set; } + public DateTime ValidFrom { get; set; } + public DateTime ValidTo { get; set; } + public int CarryForward { get; set; } + public string? TenantId { get; set; } +} diff --git a/Data/Entities/LeaveCategory.cs b/Data/Entities/LeaveCategory.cs new file mode 100644 index 0000000..abc6922 --- /dev/null +++ b/Data/Entities/LeaveCategory.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + + +public class LeaveCategory : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public int Quota { get; set; } + public bool IsPaidLeave { get; set; } + public string Description { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public bool AllowCarryForward { get; set; } + public int MaxCarryForward { get; set; } + public int MinServiceMonths { get; set; } + public string? TenantId { get; set; } +} diff --git a/Data/Entities/LeaveRequest.cs b/Data/Entities/LeaveRequest.cs new file mode 100644 index 0000000..7908d91 --- /dev/null +++ b/Data/Entities/LeaveRequest.cs @@ -0,0 +1,23 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class LeaveRequest : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string EmployeeId { get; set; } = string.Empty; + public Employee? Employee { get; set; } + public string LeaveCategoryId { get; set; } = string.Empty; + public LeaveCategory? LeaveCategory { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public double Days { get; set; } + public string Reason { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; // Pending, Approved, Rejected, Cancelled + public string? AttachmentPath { get; set; } + public string? ApproverId { get; set; } + public DateTime? ActionDate { get; set; } + public string? RejectReason { get; set; } + public string? TenantId { get; set; } +} diff --git a/Data/Entities/PayrollComponent.cs b/Data/Entities/PayrollComponent.cs new file mode 100644 index 0000000..e23c7c2 --- /dev/null +++ b/Data/Entities/PayrollComponent.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class PayrollComponent : BaseEntity, IMultiTenant +{ + public string? PayrollDetailId { get; set; } + public PayrollDetail? PayrollDetail { get; set; } + public string? ComponentName { get; set; } + public string? ComponentType { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public string? TenantId { get; set; } +} diff --git a/Data/Entities/PayrollDetail.cs b/Data/Entities/PayrollDetail.cs new file mode 100644 index 0000000..eee5482 --- /dev/null +++ b/Data/Entities/PayrollDetail.cs @@ -0,0 +1,20 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class PayrollDetail : BaseEntity, IMultiTenant +{ + public string? PayrollProcessId { get; set; } + public PayrollProcess? PayrollProcess { get; set; } + public string? EmployeeId { get; set; } + public Employee? Employee { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public decimal BasicSalary { get; set; } + public decimal TotalIncome { get; set; } + public decimal TotalDeduction { get; set; } + public decimal TakeHomePay { get; set; } + public virtual ICollection Components { get; set; } = new List(); + public string? TenantId { get; set; } +} diff --git a/Data/Entities/PayrollProcess.cs b/Data/Entities/PayrollProcess.cs new file mode 100644 index 0000000..0356459 --- /dev/null +++ b/Data/Entities/PayrollProcess.cs @@ -0,0 +1,23 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class PayrollProcess : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string? PeriodName { get; set; } + public DateTime FromDate { get; set; } + public DateTime ToDate { get; set; } + public int TotalEmployees { get; set; } + public decimal TotalBasicSalary { get; set; } + public decimal TotalIncome { get; set; } + public decimal TotalDeduction { get; set; } + public decimal TotalGross { get; set; } + public decimal TotalTakeHomePay { get; set; } + public string? Status { get; set; } + public string? ProcessedBy { get; set; } + public DateTime ProcessedAt { get; set; } + public string? Description { get; set; } + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Promotion.cs b/Data/Entities/Promotion.cs new file mode 100644 index 0000000..ddb7602 --- /dev/null +++ b/Data/Entities/Promotion.cs @@ -0,0 +1,17 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Promotion : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string EmployeeId { get; set; } = string.Empty; + public Employee? Employee { get; set; } + public string FromGrade { get; set; } = string.Empty; + public string ToGrade { get; set; } = string.Empty; + public DateTime? EffectiveDate { get; set; } + public string PromotionNote { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string? TenantId { get; set; } +} diff --git a/Data/Entities/SerilogLogs.cs b/Data/Entities/SerilogLogs.cs new file mode 100644 index 0000000..995a31b --- /dev/null +++ b/Data/Entities/SerilogLogs.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Indotalent.Data.Entities; + +public class SerilogLogs +{ + [Key] + public int Id { get; set; } + public string? Message { get; set; } + public string? MessageTemplate { get; set; } + public string? Level { get; set; } + public DateTimeOffset TimeStamp { get; set; } + public string? Exception { get; set; } + public string? Properties { get; set; } + public string? LogEvent { get; set; } +} \ No newline at end of file diff --git a/Data/Entities/Tax.cs b/Data/Entities/Tax.cs new file mode 100644 index 0000000..912484e --- /dev/null +++ b/Data/Entities/Tax.cs @@ -0,0 +1,15 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Tax : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Code { get; set; } + public decimal PercentageValue { get; set; } + public string? Category { get; set; } + public string? TenantId { get; set; } +} diff --git a/Data/Entities/Tenant.cs b/Data/Entities/Tenant.cs new file mode 100644 index 0000000..ce83c55 --- /dev/null +++ b/Data/Entities/Tenant.cs @@ -0,0 +1,21 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Tenant : BaseEntity +{ + public string? Name { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public bool IsActive { get; set; } = true; + public ICollection TenantUserList { get; set; } = new List(); +} diff --git a/Data/Entities/TenantUser.cs b/Data/Entities/TenantUser.cs new file mode 100644 index 0000000..ce62685 --- /dev/null +++ b/Data/Entities/TenantUser.cs @@ -0,0 +1,12 @@ +using Indotalent.Data.Abstracts; + +namespace Indotalent.Data.Entities; + +public class TenantUser : BaseEntity +{ + public string? TenantId { get; set; } + public Tenant? Tenant { get; set; } + public string? UserId { get; set; } + public string? Summary { get; set; } + public bool IsActive { get; set; } = true; +} diff --git a/Data/Entities/Transfer.cs b/Data/Entities/Transfer.cs new file mode 100644 index 0000000..0ded677 --- /dev/null +++ b/Data/Entities/Transfer.cs @@ -0,0 +1,27 @@ +using Indotalent.Data.Abstracts; +using Indotalent.Data.Interfaces; + +namespace Indotalent.Data.Entities; + +public class Transfer : BaseEntity, IHasAutoNumber, IMultiTenant +{ + public string? AutoNumber { get; set; } + public string EmployeeId { get; set; } = string.Empty; + public Employee? Employee { get; set; } + public string FromBranchId { get; set; } = string.Empty; + public Branch? FromBranch { get; set; } + public string FromDepartmentId { get; set; } = string.Empty; + public Department? FromDepartment { get; set; } + public string FromDesignationId { get; set; } = string.Empty; + public Designation? FromDesignation { get; set; } + public string ToBranchId { get; set; } = string.Empty; + public Branch? ToBranch { get; set; } + public string ToDepartmentId { get; set; } = string.Empty; + public Department? ToDepartment { get; set; } + public string ToDesignationId { get; set; } = string.Empty; + public Designation? ToDesignation { get; set; } + public DateTime? TransferDate { get; set; } + public string TransferNote { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string? TenantId { get; set; } +} diff --git a/Data/Interfaces/IHasAudit.cs b/Data/Interfaces/IHasAudit.cs new file mode 100644 index 0000000..d0b2de7 --- /dev/null +++ b/Data/Interfaces/IHasAudit.cs @@ -0,0 +1,9 @@ +namespace Indotalent.Data.Interfaces; + +public interface IHasAudit +{ + DateTimeOffset? CreatedAt { get; set; } + string? CreatedBy { get; set; } + DateTimeOffset? UpdatedAt { get; set; } + string? UpdatedBy { get; set; } +} \ No newline at end of file diff --git a/Data/Interfaces/IHasAutoNumber.cs b/Data/Interfaces/IHasAutoNumber.cs new file mode 100644 index 0000000..e09f3e9 --- /dev/null +++ b/Data/Interfaces/IHasAutoNumber.cs @@ -0,0 +1,6 @@ +namespace Indotalent.Data.Interfaces; + +public interface IHasAutoNumber +{ + string? AutoNumber { get; set; } +} diff --git a/Data/Interfaces/IHasIsDeleted.cs b/Data/Interfaces/IHasIsDeleted.cs new file mode 100644 index 0000000..802223a --- /dev/null +++ b/Data/Interfaces/IHasIsDeleted.cs @@ -0,0 +1,6 @@ +namespace Indotalent.Data.Interfaces; + +public interface IHasIsDeleted +{ + bool IsDeleted { get; set; } +} diff --git a/Data/Interfaces/IMultiTenant.cs b/Data/Interfaces/IMultiTenant.cs new file mode 100644 index 0000000..d2fc87c --- /dev/null +++ b/Data/Interfaces/IMultiTenant.cs @@ -0,0 +1,7 @@ +namespace Indotalent.Data.Interfaces; + +public interface IMultiTenant +{ + string? TenantId { get; set; } +} + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..287fa96 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY *.csproj . +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends libicu-dev && rm -rf /var/lib/apt/lists/* +COPY --from=build /app . +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "Indotalent.dll"] \ No newline at end of file diff --git a/Features/Account/AccessDenied/AccessDeniedPage.razor b/Features/Account/AccessDenied/AccessDeniedPage.razor new file mode 100644 index 0000000..fdcfaab --- /dev/null +++ b/Features/Account/AccessDenied/AccessDeniedPage.razor @@ -0,0 +1,63 @@ +@page "/account/access-denied" +@using Indotalent.Features.Root +@using MudBlazor +@layout MainLayout + +Access Denied - Insufficient Permissions + + + +
+ + + + Access Denied + + + Sorry, you do not have the required permissions to access this module.
+ This area is restricted to authorized administrative personnel only. +
+ + + + Error Code: AGS_AUTH_INSUFFICIENT_PERMISSIONS + + + Verification failed for the current security context. + + + +
+ + Back to Home + + + + Logout & Switch Account + +
+ +
+ +
+ +@code { + [Inject] private NavigationManager Nav { get; set; } = default!; + + private void GoHome() => Nav.NavigateTo("/"); + private void Logout() => Nav.NavigateTo("/account/logout"); +} \ No newline at end of file diff --git a/Features/Account/AccessDenied/RedirectToAccessDenied.razor b/Features/Account/AccessDenied/RedirectToAccessDenied.razor new file mode 100644 index 0000000..827c3dd --- /dev/null +++ b/Features/Account/AccessDenied/RedirectToAccessDenied.razor @@ -0,0 +1,19 @@ +@inject NavigationManager Navigation +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@attribute [AllowAnonymous] + + + + @{ + var returnUrl = Navigation.ToBaseRelativePath(Navigation.Uri); + Navigation.NavigateTo($"/account/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: false); + } + + + @{ + Navigation.NavigateTo("/account/access-denied"); + } + + + diff --git a/Features/Account/AccountEndPoint.cs b/Features/Account/AccountEndPoint.cs new file mode 100644 index 0000000..f3eb0d2 --- /dev/null +++ b/Features/Account/AccountEndPoint.cs @@ -0,0 +1,285 @@ +using Indotalent.ConfigFrontEnd.Service; +using Indotalent.Data.Entities; +using Indotalent.Features.Account.ForgotPassword.Cqrs; +using Indotalent.Features.Account.Login.Cqrs; +using Indotalent.Features.Account.Register.Cqrs; +using Indotalent.Features.Account.ResetPassword.Cqrs; +using Indotalent.Features.Account.TenantSelection.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace Indotalent.Features.Account; + +public static class AccountEndPoint +{ + public static IEndpointConventionBuilder MapAccountEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/account").WithTags("Account"); + + group.MapPost("/signin-sso", async ([FromQuery] string email, UserManager userManager, SignInManager signInManager, IOptions jwtOptions, HttpContext context) => + { + if (string.IsNullOrWhiteSpace(email)) return Results.Json(new { status = 401, title = "Email is required" }, statusCode: 401); + + var user = await userManager.FindByEmailAsync(email); + + if (user == null || !user.IsActive) + { + return Results.Json(new { status = 401, title = "Account not found or inactive" }, statusCode: 401); + } + + var jwtSettings = jwtOptions.Value; + var token = JwtService.GenerateNewJwt(user, jwtSettings); + var refreshToken = Guid.NewGuid().ToString().Replace("-", ""); + + user.RefreshToken = refreshToken; + user.LastLoginAt = DateTime.Now; + await userManager.UpdateAsync(user); + + await signInManager.SignInAsync(user, isPersistent: true); + + context.Response.Cookies.Append("X-Auth-Token", token, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(1) + }); + + context.Response.Cookies.Append("X-Refresh-Token", refreshToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(7) + }); + + return Results.Ok(new { status = 200, title = "SSO Login successful" }); + }); + + group.MapGet("/active-user-exists", async ([FromQuery] string email, UserManager userManager) => + { + if (string.IsNullOrWhiteSpace(email)) + { + return Results.Ok(new { exists = false, isActive = false }); + } + + var user = await userManager.FindByEmailAsync(email); + + if (user == null) + { + return Results.Ok(new { exists = false, isActive = false }); + } + + return Results.Ok(new { exists = true, isActive = user.IsActive }); + }); + + group.MapPost("/signin", async ([FromBody] LoginRequest request, IMediator mediator, HttpContext context, UserManager userManager) => + { + var command = new LoginCommand(request); + var response = await mediator.Send(command); + + if (response.Succeeded) + { + var user = await userManager.FindByEmailAsync(request.Email); + + if (user != null) + { + context.Response.Cookies.Append("X-Auth-Token", response.Token!, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(1) + }); + + context.Response.Cookies.Append("X-Refresh-Token", user.RefreshToken!, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(7) + }); + } + + return Results.Ok(new { status = 200, title = response.Message }); + } + return Results.Json(new { status = 401, title = response.Message }, statusCode: 401); + }); + + + + group.MapPost("/refresh-token", async (HttpContext context) => + { + var userManager = context.RequestServices.GetRequiredService>(); + var jwtSettings = context.RequestServices.GetRequiredService>().Value; + + var refreshToken = context.Request.Headers["X-Refresh-Token"].ToString(); + + if (string.IsNullOrEmpty(refreshToken)) return Results.Unauthorized(); + + var user = await userManager.Users.FirstOrDefaultAsync(u => u.RefreshToken == refreshToken); + + if (user == null || !user.IsActive) return Results.Unauthorized(); + + var newToken = JwtService.GenerateNewJwt(user, jwtSettings); + + context.Response.Cookies.Append("X-Auth-Token", newToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(1) + }); + + return Results.Ok(new LoginResponse { Succeeded = true, Token = newToken }); + }); + + group.MapPost("/signup", async ([FromBody] CreateUserRequest request, IMediator mediator) => + { + try + { + var command = new CreateUserCommand(request); + var response = await mediator.Send(command); + return Results.Ok(response); + } + catch (Exception ex) + { + return Results.BadRequest(new { errors = new[] { ex.Message } }); + } + }); + + group.MapPost("/signout", async ( + SignInManager signInManager, + UserManager userManager, + HttpContext context) => + { + var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); + + if (!string.IsNullOrEmpty(userId)) + { + var user = await userManager.FindByIdAsync(userId); + if (user != null) + { + user.RefreshToken = null; + await userManager.UpdateAsync(user); + } + } + + await signInManager.SignOutAsync(); + + context.Response.Cookies.Delete("X-Auth-Token"); + context.Response.Cookies.Delete("X-Refresh-Token"); + + return Results.Ok(new { status = 200, title = "Successfully signed out" }); + + }); + + group.MapPost("/forgot-password", async ([FromBody] ForgotPasswordRequest request, IMediator mediator) => + { + try + { + var command = new ForgotPasswordCommand(request); + var response = await mediator.Send(command); + return Results.Ok(response); + } + catch (Exception ex) + { + return Results.BadRequest(new { errors = new[] { ex.Message } }); + } + }); + + group.MapPost("/logout", async (SignInManager signInManager) => + { + await signInManager.SignOutAsync(); + return Results.Ok(new { title = "Success user logout" }); + }).RequireAuthorization(); + + group.MapPost("/reset-password", async ([FromBody] ResetPasswordRequest request, IMediator mediator) => + { + try + { + var command = new ResetPasswordCommand(request); + var response = await mediator.Send(command); + return Results.Ok(response); + } + catch (Exception ex) + { + return Results.BadRequest(new { errors = new[] { ex.Message } }); + } + }); + + group.MapGet("/my-tenants", async (IMediator mediator, HttpContext context) => + { + var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); + + var query = new GetTenantsQuery(userId); + var response = await mediator.Send(query); + return Results.Ok(response); + }).RequireAuthorization(); + + group.MapPost("/confirm-tenant", async ( + [FromBody] ConfirmTenantRequest request, + IMediator mediator, + HttpContext context, + SignInManager signInManager, + UserManager userManager) => + { + var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); + + var command = new ConfirmTenantCommand(userId, request); + var response = await mediator.Send(command); + + if (!response.Success) + { + return Results.Json(new { success = false, title = response.Message }, statusCode: 400); + } + + var user = await userManager.FindByIdAsync(userId); + if (user == null) return Results.Unauthorized(); + + var principal = await signInManager.CreateUserPrincipalAsync(user); + var identity = (ClaimsIdentity?)principal.Identity; + + if (identity != null) + { + identity.AddClaim(new Claim(ClaimTypes.GroupSid, request.TenantId)); + var jwtClaims = new List + { + new Claim(ClaimTypes.NameIdentifier, user.Id ?? string.Empty), + new Claim(ClaimTypes.Name, user.UserName ?? string.Empty), + new Claim(ClaimTypes.Email, user.Email ?? string.Empty), + new Claim(ClaimTypes.GivenName, user.FullName ?? string.Empty), + new Claim(ClaimTypes.GroupSid, request.TenantId) + }; + + var jwtSettings = context.RequestServices.GetRequiredService>().Value; + var newToken = JwtService.GenerateNewJwtWithClaims(user, jwtClaims, jwtSettings); + var newRefreshToken = Guid.NewGuid().ToString().Replace("-", ""); + + user.RefreshToken = newRefreshToken; + await userManager.UpdateAsync(user); + + context.Response.Cookies.Append("X-Auth-Token", newToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(1) + }); + + return Results.Ok(new { success = true, token = newToken }); + } + + return Results.Json(new { success = false, title = "Failed to reconstruct security claims" }, statusCode: 400); + }).RequireAuthorization(); + + return group; + } +} \ No newline at end of file diff --git a/Features/Account/AuthenticationLayout.razor b/Features/Account/AuthenticationLayout.razor new file mode 100644 index 0000000..7b255cd --- /dev/null +++ b/Features/Account/AuthenticationLayout.razor @@ -0,0 +1,366 @@ +@using Indotalent.Shared.Consts +@inherits LayoutComponentBase +@inject NavigationManager Navigation + + + + + + + + +
+ + +
+
+
+
+ +
+
+
@GlobalConsts.AppName
+
@GlobalConsts.AppTagline
+
+
+

Smart HR, Simplified

+

Manage your workforce with a complete, production-ready platform built on .NET 10 & Blazor Server.

+
+
+
+ +
+
+
Leave & Attendance
+
Request, approve, and track leave with balance overview
+
+
+
+
+ +
+
+
Performance & Appraisal
+
Evaluations, promotions, and transfer management
+
+
+
+
+ +
+
+
Payroll Processing
+
Salary grades, income, deduction, and payroll runs
+
+
+
+
+
+
+ +
+
+
Try the Live Demo
+
Explore every feature at blazor-saas-hrm.csharpasp.net
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
@GlobalConsts.AppName
+
@GlobalConsts.AppTagline
+
+
+ + + + @Body + + + +
+
+ +
+ +@code { + private MudTheme _theme = new MudTheme + { + PaletteLight = new PaletteLight + { + Primary = "#594AE2", + Secondary = "#7B6FE8", + AppbarBackground = "#594AE2", + DrawerBackground = "#ffffff", + Background = "#f8fafc", + Surface = "#ffffff", + TextPrimary = "#1e293b", + TextSecondary = "#64748b", + Divider = "#e2e8f0", + + } + }; + + private void NavigateToHome() + { + Navigation.NavigateTo("/", forceLoad: true); + } +} \ No newline at end of file diff --git a/Features/Account/ConfirmEmail/ConfirmEmailPage.razor b/Features/Account/ConfirmEmail/ConfirmEmailPage.razor new file mode 100644 index 0000000..836f20f --- /dev/null +++ b/Features/Account/ConfirmEmail/ConfirmEmailPage.razor @@ -0,0 +1,101 @@ +@page "/account/confirm-email" +@layout AuthenticationLayout +@using Indotalent.Shared.Consts +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using System.Text +@using Indotalent.Data.Entities +@inject UserManager UserManager +@inject NavigationManager Navigation +@inject ISnackbar Snackbar + +Confirming Email - @GlobalConsts.AppInitial + + + +
+
+
+ +
+
+ +
Verifying Your Account
+
Please wait while we validate your email address...
+ + +
+ +@code { + [SupplyParameterFromQuery] public string? userId { get; set; } + [SupplyParameterFromQuery] public string? code { get; set; } + + protected override async Task OnInitializedAsync() + { + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(code)) + { + Snackbar.Add("Invalid or expired confirmation link.", Severity.Error); + Navigation.NavigateTo("/account/login"); + return; + } + + var user = await UserManager.FindByIdAsync(userId); + if (user == null) + { + Snackbar.Add("User record not found.", Severity.Error); + Navigation.NavigateTo("/account/login"); + return; + } + + try + { + var decodedCode = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); + var result = await UserManager.ConfirmEmailAsync(user, decodedCode); + await Task.Delay(500); + if (result.Succeeded) + { + Snackbar.Add("Email verified successfully! You can now sign in.", Severity.Success); + } + else + { + Snackbar.Add("Verification failed. The link may have expired.", Severity.Error); + } + } + catch + { + Snackbar.Add("An error occurred during verification.", Severity.Error); + } + + Navigation.NavigateTo("/account/login"); + } +} \ No newline at end of file diff --git a/Features/Account/ForgotPassword/Components/ForgotPasswordPage.razor b/Features/Account/ForgotPassword/Components/ForgotPasswordPage.razor new file mode 100644 index 0000000..2a56c56 --- /dev/null +++ b/Features/Account/ForgotPassword/Components/ForgotPasswordPage.razor @@ -0,0 +1,167 @@ +@page "/account/forgot-password" +@layout AuthenticationLayout +@using System.ComponentModel.DataAnnotations +@using Indotalent.Shared.Consts +@using Microsoft.JSInterop +@inject IJSRuntime JSRuntime +@inject ISnackbar Snackbar +@inject NavigationManager Navigation + +Forgot Password - @GlobalConsts.AppInitial + + + + + + + +
+
Email Address
+ +
+ + + @if (isProcessing) + { + + Sending link... + } + else + { + Send Reset Link + } + +
+ +
+ + Remember your password? + Back to Sign In + +
+ +@code { + private MudForm form = default!; + private bool success; + private bool isProcessing; + private ForgotPasswordModel model = new(); + + private class ForgotPasswordModel + { + public string Email { get; set; } = ""; + } + + private async Task HandleForgotPassword() + { + await form.Validate(); + if (!success) return; + + isProcessing = true; + StateHasChanged(); + + var result = await JSRuntime.InvokeAsync("apiForgotPassword", model.Email); + + if (result.Status == 200) + { + Snackbar.Add("If your email is registered, a reset link has been sent.", Severity.Success); + await Task.Delay(500); + Navigation.NavigateTo("/account/login"); + } + else + { + Snackbar.Add(result.Title ?? "Failed to send reset link", Severity.Error); + } + + isProcessing = false; + StateHasChanged(); + } + + private class ApiResponse + { + public int Status { get; set; } + public string? Title { get; set; } + } +} + + \ No newline at end of file diff --git a/Features/Account/ForgotPassword/Cqrs/ForgotPasswordHandler.cs b/Features/Account/ForgotPassword/Cqrs/ForgotPasswordHandler.cs new file mode 100644 index 0000000..dc98150 --- /dev/null +++ b/Features/Account/ForgotPassword/Cqrs/ForgotPasswordHandler.cs @@ -0,0 +1,70 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; + +namespace Indotalent.Features.Account.ForgotPassword.Cqrs; + +public class ForgotPasswordRequest +{ + public string Email { get; set; } = string.Empty; +} + +public class ForgotPasswordResponse +{ + public string Message { get; set; } = string.Empty; +} + +public record ForgotPasswordCommand(ForgotPasswordRequest Data) : IRequest; + +public class ForgotPasswordHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ForgotPasswordHandler( + UserManager userManager, + IEmailSender emailSender, + IHttpContextAccessor httpContextAccessor) + { + _userManager = userManager; + _emailSender = emailSender; + _httpContextAccessor = httpContextAccessor; + } + + public async Task Handle(ForgotPasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByEmailAsync(request.Data.Email); + + if (user == null || !(await _userManager.IsEmailConfirmedAsync(user))) + { + return new ForgotPasswordResponse { Message = "If your email is registered, you will receive a reset link." }; + } + + var code = await _userManager.GeneratePasswordResetTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var requestHttp = _httpContextAccessor.HttpContext?.Request; + var scheme = requestHttp?.Scheme ?? "https"; + var host = requestHttp?.Host.Value ?? "localhost:8080"; + + var callbackUrl = $"{scheme}://{host}/account/reset-password?userId={user.Id}&code={code}"; + + var message = $@" +
+

Password Reset Request

+

Hello, {user.FullName}!

+

We received a request to reset your password. Click the link below to proceed:

+

Reset Password

+
+

If you didn't request this, you can safely ignore this email.

+

{callbackUrl}

+
"; + + await _emailSender.SendPasswordResetLinkAsync(user, user.Email!, message); + + return new ForgotPasswordResponse { Message = "Reset link has been sent." }; + } +} \ No newline at end of file diff --git a/Features/Account/Login/Components/LoginPage.razor b/Features/Account/Login/Components/LoginPage.razor new file mode 100644 index 0000000..c7a86d4 --- /dev/null +++ b/Features/Account/Login/Components/LoginPage.razor @@ -0,0 +1,378 @@ +@page "/account/login" +@layout AuthenticationLayout +@using Indotalent.Shared.Consts +@using Microsoft.JSInterop +@using Indotalent.Infrastructure.Authentication.Identity +@using Microsoft.Extensions.Options +@inject IJSRuntime JSRuntime +@inject IOptions IdentityOptions +@inject NavigationManager Navigation +@inject ISnackbar Snackbar + +Sign In - @GlobalConsts.AppInitial + + + + + + + +
+
Email Address
+ +
+ +
+
+ Password + Forgot password? +
+ +
+ +
+ +
+ + + @if (isProcessing) + { + + Signing in... + } + else + { + Sign In + } + + + @if (IdentityOptions.Value.SsoFirebase.IsUsed) + { +
Or continue with
+ + + Sign in with Google + + } +
+ +
+ + Don't have an account? + Create one + +
+ +@code { + private MudForm form = default!; + private bool success; + private bool isProcessing; + private bool showPassword; + private bool rememberMe; + private LoginModel model = new(); + + private async Task ValidateForm(EditContext context) => await Task.FromResult(true); + + private async Task HandleLogin() + { + await form.Validate(); + if (!success) return; + isProcessing = true; + StateHasChanged(); + + var result = await JSRuntime.InvokeAsync("apiAccountSignIn", model.Email, model.Password, rememberMe); + ProcessLoginResult(result); + } + + private async Task HandleFirebaseGoogleLogin() + { + isProcessing = true; + StateHasChanged(); + + var firebaseUser = await JSRuntime.InvokeAsync("signInWithGoogle"); + + if (firebaseUser == null || string.IsNullOrEmpty(firebaseUser.Email)) + { + Snackbar.Add("Google Sign-In failed or cancelled.", Severity.Error); + isProcessing = false; + return; + } + + var checkResult = await JSRuntime.InvokeAsync("apiCheckActiveUser", firebaseUser.Email); + bool openForPublic = IdentityOptions.Value.SsoFirebase.OpenForPublic; + + if (checkResult.Exists) + { + if (checkResult.IsActive) + { + await ExecuteSsoSignIn(firebaseUser.Email); + } + else + { + Snackbar.Add("Account is registered but not active.", Severity.Warning); + isProcessing = false; + } + } + else + { + var registerResult = await JSRuntime.InvokeAsync( + "apiAccountSignUpSso", + firebaseUser.Email, + firebaseUser.Email, + openForPublic + ); + + if (registerResult.Status == 200) + { + if (openForPublic) + { + await ExecuteSsoSignIn(firebaseUser.Email); + } + else + { + Snackbar.Add("Registration successful. Please wait for admin approval.", Severity.Info); + isProcessing = false; + } + } + else + { + Snackbar.Add("Failed to auto-register account.", Severity.Error); + isProcessing = false; + } + } + } + + private async Task ExecuteSsoSignIn(string email) + { + var result = await JSRuntime.InvokeAsync("apiAccountSsoSignIn", email); + ProcessLoginResult(result); + } + + private void ProcessLoginResult(ApiJSRuntimeResponse result) + { + if (result.Status == 200) + { + Snackbar.Add(result.Title ?? "Login successful!", Severity.Success); + Task.Run(async () => + { + await Task.Delay(500); + Navigation.NavigateTo("/account/tenant-selection", forceLoad: true); + }); + } + else + { + Snackbar.Add(result.Title ?? "Sign in failed.", Severity.Error); + isProcessing = false; + } + StateHasChanged(); + } + + private class ApiJSRuntimeResponse { public int? Status { get; set; } public string? Title { get; set; } public string? Message { get; set; } } + private class FirebaseUserResponse { public string? Email { get; set; } } + private class ActiveCheckResponse { public bool Exists { get; set; } public bool IsActive { get; set; } } + private class LoginModel { public string Email { get; set; } = ""; public string Password { get; set; } = ""; } +} + + \ No newline at end of file diff --git a/Features/Account/Login/Cqrs/LoginHandler.cs b/Features/Account/Login/Cqrs/LoginHandler.cs new file mode 100644 index 0000000..22609d9 --- /dev/null +++ b/Features/Account/Login/Cqrs/LoginHandler.cs @@ -0,0 +1,74 @@ +using Indotalent.ConfigFrontEnd.Service; +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authentication.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace Indotalent.Features.Account.Login.Cqrs; + +public class LoginRequest +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public bool RememberMe { get; set; } +} + +public class LoginResponse +{ + public bool Succeeded { get; set; } + public string Message { get; set; } = string.Empty; + public bool IsNotAllowed { get; set; } + public string? Token { get; set; } + public string? RefreshToken { get; set; } +} + +public record LoginCommand(LoginRequest Data) : IRequest; + +public class LoginHandler : IRequestHandler +{ + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly JwtSettingsModel _jwtSettings; + + public LoginHandler( + SignInManager signInManager, + UserManager userManager, + IOptions jwtSettings) + { + _signInManager = signInManager; + _userManager = userManager; + _jwtSettings = jwtSettings.Value; + } + + public async Task Handle(LoginCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByEmailAsync(request.Data.Email); + if (user == null) return new LoginResponse { Succeeded = false, Message = "Invalid credentials" }; + + var result = await _signInManager.PasswordSignInAsync(user, request.Data.Password, request.Data.RememberMe, false); + + if (result.Succeeded) + { + var token = JwtService.GenerateNewJwt(user, _jwtSettings); + + var refreshToken = Guid.NewGuid().ToString().Replace("-", ""); + + user.RefreshToken = refreshToken; + user.LastLoginAt = DateTime.Now; + await _userManager.UpdateAsync(user); + + return new LoginResponse + { + Succeeded = true, + Message = "Login successful", + Token = token, + RefreshToken = refreshToken + }; + } + + return new LoginResponse { Succeeded = false, Message = "Invalid credentials" }; + } + + +} \ No newline at end of file diff --git a/Features/Account/Logout/LogoutPage.razor b/Features/Account/Logout/LogoutPage.razor new file mode 100644 index 0000000..eb635f1 --- /dev/null +++ b/Features/Account/Logout/LogoutPage.razor @@ -0,0 +1,152 @@ +@page "/account/logout" +@layout AuthenticationLayout + +@using Indotalent.Shared.Consts +@using Microsoft.JSInterop +@inject IJSRuntime JSRuntime +@inject ISnackbar Snackbar +@inject NavigationManager Navigation + +Sign Out - @GlobalConsts.AppInitial + + + +
+
+
+ +
+
+ +
Sign Out
+
Are you sure you want to sign out of your account?
+ + + @if (isLoggingOut) + { + + Processing... + } + else + { + Sign Out + } + + + + Cancel + +
+ +@code { + private bool isLoggingOut; + + private async Task Logout() + { + isLoggingOut = true; + StateHasChanged(); + + var result = await JSRuntime.InvokeAsync("apiAccountLogout"); + + if (result.Status == 200) + { + Snackbar.Add("Successfully signed out!", Severity.Success); + await Task.Delay(500); + Navigation.NavigateTo("/", forceLoad: true); + } + else + { + Snackbar.Add(result.Title ?? "Failed to logout.", Severity.Error); + await Task.Delay(500); + } + + isLoggingOut = false; + StateHasChanged(); + } + + private class ApiJSRuntimeResponse + { + public int? Status { get; set; } + public string? Title { get; set; } + } +} + + \ No newline at end of file diff --git a/Features/Account/Register/Components/RegisterPage.razor b/Features/Account/Register/Components/RegisterPage.razor new file mode 100644 index 0000000..3f2dff5 --- /dev/null +++ b/Features/Account/Register/Components/RegisterPage.razor @@ -0,0 +1,232 @@ +@page "/account/register" +@layout AuthenticationLayout + +@using Indotalent.Shared.Consts +@using Microsoft.JSInterop +@inject IJSRuntime JSRuntime +@inject ISnackbar Snackbar +@inject NavigationManager Navigation +@inject IConfiguration Configuration + +Sign Up - @GlobalConsts.AppInitial + + + + + + + +
+
Full Name
+ +
+ +
+
Email Address
+ +
+ +
+
Password
+ +
+ + + @if (isProcessing) + { + + Creating account... + } + else + { + Create Account + } + +
+ +
+ + Already have an account? + Sign In + +
+ +@code { + private MudForm form = default!; + private bool success; + private bool isProcessing; + private bool showPassword; + + private RegisterModel model = new(); + + private class RegisterModel + { + public string FullName { get; set; } = ""; + public string Email { get; set; } = ""; + public string Password { get; set; } = ""; + } + + private async Task ValidateForm(EditContext context) + { + return await Task.FromResult(true); + } + + private async Task HandleRegister() + { + await form.Validate(); + + if (!success) return; + + isProcessing = true; + StateHasChanged(); + + var result = await JSRuntime.InvokeAsync("apiAccountRegister", model.FullName, model.Email, model.Password); + + if (result.Status == 200) + { + var requireConfirmation = Configuration.GetValue("IdentitySettings:SignIn:RequireConfirmedAccount"); + + if (requireConfirmation) + { + Snackbar.Add("Registration successful! Please check your email to confirm your account.", Severity.Info); + } + else + { + Snackbar.Add("Registration successful! You can now sign in.", Severity.Success); + } + + await Task.Delay(500); + Navigation.NavigateTo("/account/login"); + } + else + { + Snackbar.Add(result.Title ?? "Registration failed", Severity.Error); + } + + isProcessing = false; + StateHasChanged(); + } + + private class ApiJSRuntimeResponse + { + public int? Status { get; set; } + public string? Title { get; set; } + } +} + + \ No newline at end of file diff --git a/Features/Account/Register/Cqrs/RegisterHandler.cs b/Features/Account/Register/Cqrs/RegisterHandler.cs new file mode 100644 index 0000000..802db25 --- /dev/null +++ b/Features/Account/Register/Cqrs/RegisterHandler.cs @@ -0,0 +1,131 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authorization.Identity; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; +using System.Text; + +namespace Indotalent.Features.Account.Register.Cqrs; + +public class CreateUserRequest +{ + public string FullName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + public bool EmailConfirmed { get; set; } = false; +} + +public class CreateUserResponse +{ + public string? Id { get; set; } + public string? Email { get; set; } +} + +public record CreateUserCommand(CreateUserRequest Data) : IRequest; + +public class RegisterHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + private readonly IConfiguration _configuration; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly AppDbContext _context; + + public RegisterHandler( + UserManager userManager, + IEmailSender emailSender, + IConfiguration configuration, + IHttpContextAccessor httpContextAccessor, + AppDbContext context) + { + _userManager = userManager; + _emailSender = emailSender; + _configuration = configuration; + _httpContextAccessor = httpContextAccessor; + _context = context; + } + + public async Task Handle(CreateUserCommand request, CancellationToken cancellationToken) + { + var user = new ApplicationUser + { + UserName = request.Data.Email, + Email = request.Data.Email, + FullName = request.Data.FullName, + IsActive = request.Data.IsActive, + EmailConfirmed = request.Data.EmailConfirmed, + }; + + var result = await _userManager.CreateAsync(user, request.Data.Password); + + if (!result.Succeeded) + { + var errors = result.Errors.Select(x => x.Description).ToList(); + throw new Exception(string.Join(", ", errors)); + } + + // Assign default roles: Member and TenantAdmin (but not Admin) + var rolesToAdd = new List { ApplicationRoles.Member, ApplicationRoles.TenantAdmin }; + + await _userManager.AddToRolesAsync(user, rolesToAdd); + + // Create default tenant for the new user + var tenant = new Data.Entities.Tenant + { + Name = "Default Tenant", + Description = $"Default tenant for {request.Data.Email}", + EmailAddress = request.Data.Email, + IsActive = true + }; + + _context.Tenant.Add(tenant); + await _context.SaveChangesAsync(cancellationToken); + + // Create TenantUser linking the user to the default tenant + var tenantUser = new Data.Entities.TenantUser + { + TenantId = tenant.Id, + UserId = user.Id, + Summary = $"Default tenant assignment for {request.Data.FullName}", + IsActive = true + }; + + _context.TenantUser.Add(tenantUser); + await _context.SaveChangesAsync(cancellationToken); + + var requireConfirmation = _configuration.GetValue("IdentitySettings:SignIn:RequireConfirmedAccount"); + + if (requireConfirmation) + { + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var requestHttp = _httpContextAccessor.HttpContext?.Request; + var scheme = requestHttp?.Scheme ?? "https"; + var host = requestHttp?.Host.Value ?? "localhost:8080"; + + var callbackUrl = $"{scheme}://{host}/account/confirm-email?userId={user.Id}&code={code}"; + + var message = $@" +
+

Welcome, {user.FullName}!

+

Please confirm your account by clicking the link below:

+

Confirm Account

+
+

If the link doesn't work, copy and paste this URL into your browser:

+

{callbackUrl}

+
"; + + await _emailSender.SendConfirmationLinkAsync(user, user.Email!, message); + } + + return new CreateUserResponse + { + Id = user.Id, + Email = user.Email + }; + } +} \ No newline at end of file diff --git a/Features/Account/ResetPassword/Components/ResetPasswordPage.razor b/Features/Account/ResetPassword/Components/ResetPasswordPage.razor new file mode 100644 index 0000000..b033174 --- /dev/null +++ b/Features/Account/ResetPassword/Components/ResetPasswordPage.razor @@ -0,0 +1,181 @@ +@page "/account/reset-password" +@layout AuthenticationLayout +@using Indotalent.Shared.Consts +@using Microsoft.JSInterop +@inject IJSRuntime JSRuntime +@inject ISnackbar Snackbar +@inject NavigationManager Navigation + +Reset Password - @GlobalConsts.AppInitial + + + + + + +
+
New Password
+ +
+ +
+
Confirm New Password
+ +
+ + + @if (isProcessing) + { + + Resetting... + } + else + { + Reset Password + } + +
+ +@code { + [SupplyParameterFromQuery] public string? userId { get; set; } + [SupplyParameterFromQuery] public string? code { get; set; } + + private MudForm form = default!; + private bool success; + private bool isProcessing; + private bool showPassword; + private ResetModel model = new(); + + private class ResetModel + { + public string NewPassword { get; set; } = ""; + public string ConfirmPassword { get; set; } = ""; + } + + private string? PasswordMatch(string arg) + { + if (model.NewPassword != arg) return "Passwords do not match"; + return null; + } + + private async Task HandleResetPassword() + { + await form.Validate(); + if (!success) return; + + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(code)) + { + Snackbar.Add("Invalid reset token or user ID.", Severity.Error); + return; + } + + isProcessing = true; + StateHasChanged(); + + var result = await JSRuntime.InvokeAsync("apiResetPassword", userId, code, model.NewPassword); + + if (result.Status == 200) + { + Snackbar.Add("Password reset successful! You can now sign in.", Severity.Success); + await Task.Delay(500); + Navigation.NavigateTo("/account/login"); + } + else + { + Snackbar.Add(result.Title ?? "Failed to reset password", Severity.Error); + } + + isProcessing = false; + StateHasChanged(); + } + + private class ApiResponse + { + public int Status { get; set; } + public string? Title { get; set; } + } +} + + \ No newline at end of file diff --git a/Features/Account/ResetPassword/Cqrs/ResetPasswordHandler.cs b/Features/Account/ResetPassword/Cqrs/ResetPasswordHandler.cs new file mode 100644 index 0000000..0619619 --- /dev/null +++ b/Features/Account/ResetPassword/Cqrs/ResetPasswordHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; + +namespace Indotalent.Features.Account.ResetPassword.Cqrs; + +public class ResetPasswordRequest +{ + public string UserId { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; +} + +public class ResetPasswordResponse +{ + public string Message { get; set; } = string.Empty; +} + +public record ResetPasswordCommand(ResetPasswordRequest Data) : IRequest; + +public class ResetPasswordHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public ResetPasswordHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(ResetPasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.UserId); + if (user == null) + { + throw new Exception("User not found."); + } + + var decodedCode = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(request.Data.Code)); + var result = await _userManager.ResetPasswordAsync(user, decodedCode, request.Data.NewPassword); + + if (!result.Succeeded) + { + var errors = result.Errors.Select(x => x.Description).ToList(); + throw new Exception(string.Join(", ", errors)); + } + + return new ResetPasswordResponse { Message = "Password has been reset successfully." }; + } +} \ No newline at end of file diff --git a/Features/Account/TenantSelection/Components/TenantSelectionPage.razor b/Features/Account/TenantSelection/Components/TenantSelectionPage.razor new file mode 100644 index 0000000..ba658db --- /dev/null +++ b/Features/Account/TenantSelection/Components/TenantSelectionPage.razor @@ -0,0 +1,131 @@ +@page "/account/tenant-selection" +@layout AuthenticationLayout +@using Indotalent.Shared.Consts +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +@using Microsoft.JSInterop +@inject NavigationManager Navigation +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + +Select Tenant - @GlobalConsts.AppInitial + +
+ Select Tenant + Please select a tenant to continue +
+ +
+ @if (isLoading) + { +
+ +
+ } + else if (!myTenants.Any()) + { + + You don't have access to any tenant. Please contact your Administrator. + + } + else + { +
+ + @foreach (var item in myTenants) + { + + +
+ + @item.TenantName + + @if (!string.IsNullOrEmpty(item.Description)) + { + + @item.Description + + } +
+
+
+ } +
+
+ } +
+ +
+ + Want to sign in with another account? + Sign Out + +
+ +@code { + private bool isLoading = true; + private List myTenants = new(); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + myTenants = await JSRuntime.InvokeAsync>("apiAccountFetchMyTenants"); + isLoading = false; + StateHasChanged(); + } + } + + private async Task HandleSelectTenant(string tenantId) + { + var result = await JSRuntime.InvokeAsync("apiAccountConfirmTenant", tenantId); + + if (result.Success) + { + Snackbar.Add("Tenant workspace loaded successfully.", Severity.Success); + Navigation.NavigateTo("/home", forceLoad: true); + } + else + { + Snackbar.Add("Failed to assign tenant workspace.", Severity.Error); + } + } + + public class TenantDto { public string TenantId { get; set; } = ""; public string TenantName { get; set; } = ""; public string? Description { get; set; } } + public class ApiResult { public bool Success { get; set; } } +} + + \ No newline at end of file diff --git a/Features/Account/TenantSelection/Cqrs/TenantSelectionHandler.cs b/Features/Account/TenantSelection/Cqrs/TenantSelectionHandler.cs new file mode 100644 index 0000000..654e148 --- /dev/null +++ b/Features/Account/TenantSelection/Cqrs/TenantSelectionHandler.cs @@ -0,0 +1,67 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Account.TenantSelection.Cqrs; + +public record GetTenantsQuery(string UserId) : IRequest>; + +public class TenantLookupDto +{ + public string TenantId { get; set; } = string.Empty; + public string TenantName { get; set; } = string.Empty; + public string? Description { get; set; } +} + +public class GetTenantsHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetTenantsHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetTenantsQuery request, CancellationToken cancellationToken) + { + return await _context.Set() + .IgnoreQueryFilters() + .Where(tu => tu.UserId == request.UserId && tu.IsActive) + .Select(tu => new TenantLookupDto + { + TenantId = tu.TenantId ?? string.Empty, + TenantName = tu.Tenant != null ? (tu.Tenant.Name ?? "Unknown") : "Unknown", + Description = tu.Tenant != null ? (tu.Tenant.Description ?? "") : "" + }) + .ToListAsync(cancellationToken); + } +} + +public record ConfirmTenantRequest(string TenantId); +public record ConfirmTenantResponse(bool Success, string Message); +public record ConfirmTenantCommand(string UserId, ConfirmTenantRequest Data) : IRequest; + +public class ConfirmTenantHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public ConfirmTenantHandler(AppDbContext context) + { + _context = context; + } + + public async Task Handle(ConfirmTenantCommand request, CancellationToken cancellationToken) + { + var hasAccess = await _context.Set() + .IgnoreQueryFilters() + .AnyAsync(tu => tu.UserId == request.UserId && tu.TenantId == request.Data.TenantId && tu.IsActive, cancellationToken); + + if (!hasAccess) + { + return new ConfirmTenantResponse(false, "Access denied to the selected organization."); + } + + return new ConfirmTenantResponse(true, "Tenant authorized."); + } +} \ No newline at end of file diff --git a/Features/Account/_Imports.razor b/Features/Account/_Imports.razor new file mode 100644 index 0000000..a68bf17 --- /dev/null +++ b/Features/Account/_Imports.razor @@ -0,0 +1,7 @@ +@using MudBlazor +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Web +@using System.ComponentModel.DataAnnotations + +@inject NavigationManager Navigation +@inject ISnackbar Snackbar \ No newline at end of file diff --git a/Features/AppSettings/Json/JsonPage.razor b/Features/AppSettings/Json/JsonPage.razor new file mode 100644 index 0000000..9e327fa --- /dev/null +++ b/Features/AppSettings/Json/JsonPage.razor @@ -0,0 +1,259 @@ +@page "/appsettings/json" +@using Indotalent.Infrastructure.Authentication.Firebase +@using Indotalent.Infrastructure.Authentication.Keycloak +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = ApplicationRoles.Admin)] +@using Indotalent.Infrastructure.Authentication.Identity +@using Indotalent.Infrastructure.Database +@using Indotalent.Infrastructure.BackgroundJob +@using Indotalent.Infrastructure.Email +@using Indotalent.Infrastructure.File +@using Indotalent.Infrastructure.Logging +@using Indotalent.Infrastructure.Authentication +@using MudBlazor +@using System.Reflection +@inject IdentityService IdentitySvc +@inject DatabaseService DbSvc +@inject BackgroundJobService JobSvc +@inject EmailService EmailSvc +@inject FileStorageService StorageSvc +@inject LoggingService LogSvc +@inject FirebaseService FirebaseSvc +@inject KeycloakService KeycloakSvc + + + + +
+ + + + @RenderIdentityProperties(IdentitySvc.GetConfiguration(), "Security & Identity") + + + + @RenderObjectProperties(DbSvc.GetConfiguration(), "Database Engine") + + + + @RenderObjectProperties(JobSvc.GetConfiguration(), "Background Job Services") + + + + @RenderObjectProperties(EmailSvc.GetConfiguration(), "Email Providers") + + + + @RenderObjectProperties(StorageSvc.GetConfiguration(), "File Storage Systems") + + + + @RenderObjectProperties(LogSvc.GetConfiguration(), "Diagnostic Logging") + + + +
+
+ +@code { + private RenderFragment RenderIdentityProperties(IdentitySettingsModel settings, string sectionTitle) + { + return __builder => + { + +
+ @sectionTitle + Security Policy & Default Credentials +
+
+ + / + System + / + @sectionTitle +
+
+ + if (settings != null) + { + @RenderSectionCard("Password Policy", settings.Password) + @RenderSectionCard("Cookie Settings", settings.Cookies) + @RenderSectionCard("Default Admin", settings.DefaultAdmin) + @RenderSectionCard("SignIn Policy", settings.SignIn) + @RenderSectionCard("SSO Firebase", settings.SsoFirebase) + @RenderSectionCard("SSO Keycloak", settings.SsoKeycloak) + } + }; + } + + private RenderFragment RenderSectionCard(string title, object sectionValue) + { + return __builder => + { + if (sectionValue == null) return; + + +
+ @title + CONFIGURED +
+ + @{ + var props = sectionValue.GetType().GetProperties(); + int i = 0; + } + @foreach (var p in props) + { + var val = p.GetValue(sectionValue); +
+
@p.Name
+
+ @(MaskIfSensitive(p.Name, val?.ToString())) +
+
+ } +
+
+ }; + } + + private RenderFragment RenderObjectProperties(object settingsObj, string sectionTitle) + { + return __builder => + { + +
+ @sectionTitle + Configuration discovery (Full Inspection Mode) +
+
+ + / + System + / + @sectionTitle +
+
+ + if (settingsObj != null) + { + var providers = settingsObj.GetType().GetProperties(); + foreach (var provider in providers) + { + var providerValue = provider.GetValue(settingsObj); + if (providerValue == null) continue; + + +
+ @provider.Name + @{ + var isUsedProp = providerValue.GetType().GetProperty("IsUsed"); + bool isUsed = (bool)(isUsedProp?.GetValue(providerValue) ?? false); + } + + @(isUsed ? "ACTIVE" : "DISABLED") + +
+ + @{ + var props = providerValue.GetType().GetProperties(); + int i = 0; + } + @foreach (var p in props) + { + if (p.Name == "IsUsed") continue; +
+
@p.Name
+
+ @(MaskIfSensitive(p.Name, p.GetValue(providerValue)?.ToString())) +
+
+ } +
+
+ } + } + }; + } + + private string MaskIfSensitive(string propertyName, string value) + { + if (string.IsNullOrEmpty(value)) return "-"; + var sensitiveKeys = new[] { "Password", "Secret", "Key", "Token", "ApiKey" }; + if (sensitiveKeys.Any(k => propertyName.Contains(k, StringComparison.OrdinalIgnoreCase))) + { + return "•••••••••••••••• (Encrypted)"; + } + return value; + } +} \ No newline at end of file diff --git a/Features/FeaturesDI.cs b/Features/FeaturesDI.cs new file mode 100644 index 0000000..63b110c --- /dev/null +++ b/Features/FeaturesDI.cs @@ -0,0 +1,81 @@ +using Indotalent.Features.Leave.LeaveBalance; +using Indotalent.Features.Leave.LeaveCategory; +using Indotalent.Features.Leave.LeaveRequest; +using Indotalent.Features.Multitenant.Tenant; +using Indotalent.Features.Multitenant.TenantUser; +using Indotalent.Features.Organization.Branch; +using Indotalent.Features.Organization.Department; +using Indotalent.Features.Organization.Designation; +using Indotalent.Features.Organization.Employee; +using Indotalent.Features.Payroll.Deduction; +using Indotalent.Features.Payroll.Grade; +using Indotalent.Features.Payroll.Income; +using Indotalent.Features.Payroll.Payrolls; +using Indotalent.Features.Performance.Appraisal; +using Indotalent.Features.Performance.Evaluation; +using Indotalent.Features.Performance.Promotion; +using Indotalent.Features.Performance.Transfer; +using Indotalent.Features.Profile.Avatar; +using Indotalent.Features.Profile.Password; +using Indotalent.Features.Profile.PersonalInformation; +using Indotalent.Features.Root.Home; +using Indotalent.Features.Serilogs; +using Indotalent.Features.Setting.AutoNumberSequence; +using Indotalent.Features.Setting.Company; +using Indotalent.Features.Setting.Currency; +using Indotalent.Features.Setting.SystemUser; +using Indotalent.Features.Setting.Tax; + +namespace Indotalent.Features; + +public static class FeaturesDI +{ + public static IServiceCollection AddFeaturesDI(this IServiceCollection services) + { + //Setting + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + + //organization + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + //profile + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + //leave + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + //performance + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + //payroll + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + //root + services.AddScoped(); + + //multitenant + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/Features/FeaturesEndpointMap.cs b/Features/FeaturesEndpointMap.cs new file mode 100644 index 0000000..ced3385 --- /dev/null +++ b/Features/FeaturesEndpointMap.cs @@ -0,0 +1,80 @@ +using Indotalent.Features.Leave.LeaveBalance; +using Indotalent.Features.Leave.LeaveCategory; +using Indotalent.Features.Leave.LeaveRequest; +using Indotalent.Features.Multitenant.Tenant; +using Indotalent.Features.Multitenant.TenantUser; +using Indotalent.Features.Organization.Branch; +using Indotalent.Features.Organization.Department; +using Indotalent.Features.Organization.Designation; +using Indotalent.Features.Organization.Employee; +using Indotalent.Features.Payroll.Deduction; +using Indotalent.Features.Payroll.Grade; +using Indotalent.Features.Payroll.Income; +using Indotalent.Features.Payroll.Payrolls; +using Indotalent.Features.Performance.Appraisal; +using Indotalent.Features.Performance.Evaluation; +using Indotalent.Features.Performance.Promotion; +using Indotalent.Features.Performance.Transfer; +using Indotalent.Features.Profile.Avatar; +using Indotalent.Features.Profile.Password; +using Indotalent.Features.Profile.PersonalInformation; +using Indotalent.Features.Root.Home; +using Indotalent.Features.Serilogs; +using Indotalent.Features.Setting.AutoNumberSequence; +using Indotalent.Features.Setting.Company; +using Indotalent.Features.Setting.Currency; +using Indotalent.Features.Setting.SystemUser; +using Indotalent.Features.Setting.Tax; + +namespace Indotalent.Features; + +public static class FeaturesEndpointMap +{ + public static IEndpointRouteBuilder MapFeaturesEndpoint(this IEndpointRouteBuilder app) + { + //setting + app.MapSerilogsEndpoints(); + app.MapCurrencyEndpoints(); + app.MapAutoNumberSequenceEndpoints(); + app.MapSystemUserEndpoints(); + app.MapCompanyEndpoints(); + app.MapTaxEndpoints(); + + //organization + app.MapBranchEndpoints(); + app.MapDepartmentEndpoints(); + app.MapDesignationEndpoints(); + app.MapEmployeeEndpoints(); + + //profile + app.MapPersonalInformationEndpoints(); + app.MapPasswordEndpoints(); + app.MapAvatarEndpoints(); + + //leave + app.MapLeaveCategoryEndpoints(); + app.MapLeaveRequestEndpoints(); + app.MapLeaveBalanceEndpoints(); + + //performance + app.MapEvaluationEndpoints(); + app.MapAppraisalEndpoints(); + app.MapPromotionEndpoints(); + app.MapTransferEndpoints(); + + //payroll + app.MapIncomeEndpoints(); + app.MapDeductionEndpoints(); + app.MapGradeEndpoints(); + app.MapPayrollsEndpoints(); + + //root + app.MapHomeEndpoints(); + + //multitenant + app.MapTenantEndpoints(); + app.MapTenantUserEndpoints(); + + return app; + } +} diff --git a/Features/Leave/LeaveBalance/Components/LeaveBalancePage.razor b/Features/Leave/LeaveBalance/Components/LeaveBalancePage.razor new file mode 100644 index 0000000..1995b99 --- /dev/null +++ b/Features/Leave/LeaveBalance/Components/LeaveBalancePage.razor @@ -0,0 +1,46 @@ +@page "/leave/leave-balance" +@using Indotalent.Features.Leave.LeaveBalance.Components +@using Indotalent.Features.Leave.LeaveBalance.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_LeaveBalanceCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_LeaveBalanceUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_LeaveBalanceDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateLeaveBalanceRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateLeaveBalanceRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/Components/_LeaveBalanceCreateForm.razor b/Features/Leave/LeaveBalance/Components/_LeaveBalanceCreateForm.razor new file mode 100644 index 0000000..f93fbf4 --- /dev/null +++ b/Features/Leave/LeaveBalance/Components/_LeaveBalanceCreateForm.razor @@ -0,0 +1,106 @@ +@using Indotalent.Features.Leave.LeaveBalance +@using Indotalent.Features.Leave.LeaveBalance.Cqrs +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveRequest +@using Indotalent.Features.Leave.LeaveCategory +@using Indotalent.Features.Leave.LeaveCategory.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeaveBalanceService LeaveBalanceService +@inject LeaveRequestService LeaveRequestService +@inject LeaveCategoryService LeaveCategoryService +@inject ISnackbar Snackbar + + +
+ +
+ Create Leave Balance + Initialize new leave quota for an employee. +
+
+
+ + + + + + Employee + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + Leave Type + + @foreach (var cat in _categories) + { + @cat.Name + } + + + + Year + + + + Entitlement (Quota) + + + + Carry Forward + + + + Valid From + + + + Valid To + + + +
+ Cancel + Create Balance +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateLeaveBalanceValidator _validator = new(); + private CreateLeaveBalanceRequest _model = new() { Year = DateTime.Today.Year, ValidFrom = new DateTime(DateTime.Today.Year, 1, 1), ValidTo = new DateTime(DateTime.Today.Year, 12, 31) }; + private List _employees = new(); + private List _categories = new(); + private bool _processing = false; + private DateTime? _validFrom { get => _model.ValidFrom; set => _model.ValidFrom = value ?? DateTime.Today; } + private DateTime? _validTo { get => _model.ValidTo; set => _model.ValidTo = value ?? DateTime.Today; } + + protected override async Task OnInitializedAsync() + { + var empRes = await LeaveRequestService.GetEmployeeReferenceAsync(); + if (empRes?.IsSuccess == true) _employees = empRes.Value ?? new(); + var catRes = await LeaveCategoryService.GetLeaveCategoryListAsync(); + if (catRes?.IsSuccess == true) _categories = catRes.Value ?? new(); + } + + private async Task Submit() + { + await _form.Validate(); if (!_form.IsValid) return; + _processing = true; + try + { + _model.Remaining = (_model.Entitlement + _model.CarryForward) - _model.Used; + var res = await LeaveBalanceService.CreateLeaveBalanceAsync(_model); + await Task.Delay(500); + if (res?.IsSuccess == true) { Snackbar.Add("Balance created", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/Components/_LeaveBalanceDataTable.razor b/Features/Leave/LeaveBalance/Components/_LeaveBalanceDataTable.razor new file mode 100644 index 0000000..51050ab --- /dev/null +++ b/Features/Leave/LeaveBalance/Components/_LeaveBalanceDataTable.razor @@ -0,0 +1,447 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Leave.LeaveBalance +@using Indotalent.Features.Leave.LeaveBalance.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject LeaveBalanceService LeaveBalanceService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Leave Balances + Review employee leave entitlements, usage, and remaining days for the current period. +
+
+ + / + Leave + / + Balance +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedBalance != null) + { + View + Adjust Balance + + + } + else + { + + Recalculate All + + + Create Manual Balance + + } +
+
+ + + + + + Employee + + + Leave Type + + + Year + + ENTITLEMENT + USED + PENDING + REMAINING + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.EmployeeName) ? context.EmployeeName.Substring(0, 2).ToUpper() : "??") + +
+ @context.EmployeeName + @context.EmployeeCode +
+
+
+ + @context.LeaveCategoryName + + + @context.Year + + + @context.Entitlement Days + + + @context.Used + + + @context.Pending + + + + @context.Remaining + + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _balances = new(); + private GetLeaveBalanceListResponse? _selectedBalance; + private string _searchString = ""; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; _selectedBalance = null; + StateHasChanged(); + try + { + var response = await LeaveBalanceService.GetLeaveBalanceListAsync(); + await Task.Delay(800); + if (response != null && response.IsSuccess) + { + _balances = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _balances; + return _balances.Where(x => + (x.EmployeeName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EmployeeCode?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.LeaveCategoryName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Year.ToString().Contains(_searchString)) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("LeaveBalances"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Employee Name"; + worksheet.Cell(currentRow, 2).Value = "Employee Code"; + worksheet.Cell(currentRow, 3).Value = "Leave Type"; + worksheet.Cell(currentRow, 4).Value = "Year"; + worksheet.Cell(currentRow, 5).Value = "Entitlement"; + worksheet.Cell(currentRow, 6).Value = "Used"; + worksheet.Cell(currentRow, 7).Value = "Pending"; + worksheet.Cell(currentRow, 8).Value = "Remaining"; + + var headerRange = worksheet.Range(1, 1, 1, 8); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.EmployeeName; + worksheet.Cell(currentRow, 2).Value = item.EmployeeCode; + worksheet.Cell(currentRow, 3).Value = item.LeaveCategoryName; + worksheet.Cell(currentRow, 4).Value = item.Year; + worksheet.Cell(currentRow, 5).Value = item.Entitlement; + worksheet.Cell(currentRow, 6).Value = item.Used; + worksheet.Cell(currentRow, 7).Value = item.Pending; + worksheet.Cell(currentRow, 8).Value = item.Remaining; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Leave_Balance_Report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnSearchClick() + { + _skip = 0; + _selectedBalance = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedBalance = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedBalance = null; + StateHasChanged(); + } + + private Color GetRandomColor(string name) + { + int hash = name.GetHashCode(); + var colors = new[] { Color.Primary, Color.Secondary, Color.Tertiary, Color.Info, Color.Success, Color.Warning, Color.Dark }; + return colors[Math.Abs(hash) % colors.Length]; + } + + private async Task OnSyncAll() + { + _isRefreshing = true; + StateHasChanged(); + try + { + var success = await LeaveBalanceService.SyncAllLeaveBalancesAsync(); + if (success) + { + Snackbar.Add("All balances recalculated successfully", Severity.Success); + await LoadData(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private async Task InvokeEdit() + { + if (_selectedBalance != null) + { + var res = await LeaveBalanceService.GetLeaveBalanceByIdAsync(_selectedBalance.Id!); + if (res?.Value != null) await OnEdit.InvokeAsync(MapToUpdate(res.Value)); + } + } + + private async Task InvokeView() + { + if (_selectedBalance != null) + { + var res = await LeaveBalanceService.GetLeaveBalanceByIdAsync(_selectedBalance.Id!); + if (res?.Value != null) await OnView.InvokeAsync(MapToUpdate(res.Value)); + } + } + + private UpdateLeaveBalanceRequest MapToUpdate(GetLeaveBalanceByIdResponse d) => new UpdateLeaveBalanceRequest + { + Id = d.Id, + EmployeeId = d.EmployeeId!, + EmployeeName = d.EmployeeName, + LeaveCategoryId = d.LeaveCategoryId!, + LeaveCategoryName = d.LeaveCategoryName, + Entitlement = d.Entitlement, + Used = d.Used, + Pending = d.Pending, + Remaining = d.Remaining, + Year = d.Year, + ValidFrom = d.ValidFrom, + ValidTo = d.ValidTo, + CarryForward = d.CarryForward, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + + private async Task OnDelete() + { + if (_selectedBalance == null) return; + var dialog = await DialogService.ShowAsync("", new DialogParameters { { x => x.ContentText, $"Balance for {_selectedBalance.EmployeeName}" } }); + if (!(await dialog.Result).Canceled) + { + if (await LeaveBalanceService.DeleteLeaveBalanceByIdAsync(_selectedBalance.Id!)) { await LoadData(); Snackbar.Add("Balance removed", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/Components/_LeaveBalanceUpdateForm.razor b/Features/Leave/LeaveBalance/Components/_LeaveBalanceUpdateForm.razor new file mode 100644 index 0000000..51e8562 --- /dev/null +++ b/Features/Leave/LeaveBalance/Components/_LeaveBalanceUpdateForm.razor @@ -0,0 +1,198 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Leave.LeaveBalance +@using Indotalent.Features.Leave.LeaveBalance.Cqrs +@using Indotalent.Features.Leave.LeaveRequest +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveCategory +@using Indotalent.Features.Leave.LeaveCategory.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeaveBalanceService LeaveBalanceService +@inject LeaveRequestService LeaveRequestService +@inject LeaveCategoryService LeaveCategoryService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Balance Details" : "Adjust Balance") + Review or modify employee leave quota for the specified period. +
+
+
+ + + @if (_isInitialLoading) + { +
+ + Loading balance configuration... +
+ } + else + { + + + + Employee + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + Leave Type + + @foreach (var cat in _categories) + { + @cat.Name + } + + + + + Year + + + + Entitlement (Base Quota) + + + + Carry Forward + + + + + Validity Period (From) + + + + Validity Period (To) + + + + + Calculation Summary + + + + Used (Days) + + + + Pending (Days) + + + + Remaining Balance + + + + + Audit History + + + + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "-") + + + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + } + else + { + Save Adjustments + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateLeaveBalanceRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateLeaveBalanceValidator _validator = new(); + private UpdateLeaveBalanceRequest _model = new(); + private List _employees = new(); + private List _categories = new(); + private bool _processing = false; + private bool _isInitialLoading = true; + + private DateTime? _validFrom { get => _model.ValidFrom; set => _model.ValidFrom = value ?? DateTime.Today; } + private DateTime? _validTo { get => _model.ValidTo; set => _model.ValidTo = value ?? DateTime.Today; } + + protected override async Task OnInitializedAsync() + { + _isInitialLoading = true; + try + { + _model = Data; + var empRes = await LeaveRequestService.GetEmployeeReferenceAsync(); + if (empRes?.IsSuccess == true) _employees = empRes.Value ?? new(); + var catRes = await LeaveCategoryService.GetLeaveCategoryListAsync(); + if (catRes?.IsSuccess == true) _categories = catRes.Value ?? new(); + } + finally { _isInitialLoading = false; } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); if (!_form.IsValid) return; + _processing = true; + try + { + _model.Remaining = (_model.Entitlement + _model.CarryForward) - _model.Used - _model.Pending; + var response = await LeaveBalanceService.UpdateLeaveBalanceAsync(_model); + await Task.Delay(500); + if (response?.IsSuccess == true) { Snackbar.Add("Balance adjusted successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/Cqrs/CreateLeaveBalanceHandler.cs b/Features/Leave/LeaveBalance/Cqrs/CreateLeaveBalanceHandler.cs new file mode 100644 index 0000000..abaeb67 --- /dev/null +++ b/Features/Leave/LeaveBalance/Cqrs/CreateLeaveBalanceHandler.cs @@ -0,0 +1,81 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveBalance.Cqrs; + +public class CreateLeaveBalanceRequest +{ + public string EmployeeId { get; set; } = string.Empty; + public string LeaveCategoryId { get; set; } = string.Empty; + public int Entitlement { get; set; } + public int Used { get; set; } + public int Pending { get; set; } + public int Remaining { get; set; } + public int Year { get; set; } + public DateTime ValidFrom { get; set; } + public DateTime ValidTo { get; set; } + public int CarryForward { get; set; } +} + +public class CreateLeaveBalanceResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateLeaveBalanceCommand(CreateLeaveBalanceRequest Data) : IRequest; + +public class CreateLeaveBalanceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateLeaveBalanceHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateLeaveBalanceCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.LeaveBalance + .AnyAsync(x => x.EmployeeId == request.Data.EmployeeId && + x.LeaveCategoryId == request.Data.LeaveCategoryId && + x.Year == request.Data.Year, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Leave Balance", $"{request.Data.EmployeeId} for Year {request.Data.Year}"); + } + + var entityName = nameof(Data.Entities.LeaveBalance); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.LeaveBalance + { + AutoNumber = autoNo, + EmployeeId = request.Data.EmployeeId, + LeaveCategoryId = request.Data.LeaveCategoryId, + Entitlement = request.Data.Entitlement, + Used = request.Data.Used, + Pending = request.Data.Pending, + Remaining = request.Data.Remaining, + Year = request.Data.Year, + ValidFrom = request.Data.ValidFrom, + ValidTo = request.Data.ValidTo, + CarryForward = request.Data.CarryForward + }; + + _context.LeaveBalance.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateLeaveBalanceResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/Cqrs/CreateLeaveBalanceValidator.cs b/Features/Leave/LeaveBalance/Cqrs/CreateLeaveBalanceValidator.cs new file mode 100644 index 0000000..e512266 --- /dev/null +++ b/Features/Leave/LeaveBalance/Cqrs/CreateLeaveBalanceValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace Indotalent.Features.Leave.LeaveBalance.Cqrs; + +public class CreateLeaveBalanceValidator : AbstractValidator +{ + public CreateLeaveBalanceValidator() + { + RuleFor(x => x.EmployeeId).NotEmpty().WithMessage("Employee is required"); + RuleFor(x => x.LeaveCategoryId).NotEmpty().WithMessage("Leave Category is required"); + RuleFor(x => x.Year).GreaterThan(2000); + RuleFor(x => x.Entitlement).GreaterThanOrEqualTo(0); + RuleFor(x => x.ValidFrom).NotEmpty(); + RuleFor(x => x.ValidTo).NotEmpty().GreaterThanOrEqualTo(x => x.ValidFrom); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/Cqrs/DeleteLeaveBalanceByIdHandler.cs b/Features/Leave/LeaveBalance/Cqrs/DeleteLeaveBalanceByIdHandler.cs new file mode 100644 index 0000000..0917c58 --- /dev/null +++ b/Features/Leave/LeaveBalance/Cqrs/DeleteLeaveBalanceByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveBalance.Cqrs; + +public record DeleteLeaveBalanceByIdRequest(string Id); + +public record DeleteLeaveBalanceByIdCommand(DeleteLeaveBalanceByIdRequest Data) : IRequest; + +public class DeleteLeaveBalanceByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteLeaveBalanceByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteLeaveBalanceByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.LeaveBalance + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.LeaveBalance.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/Cqrs/GetLeaveBalanceByIdHandler.cs b/Features/Leave/LeaveBalance/Cqrs/GetLeaveBalanceByIdHandler.cs new file mode 100644 index 0000000..3b264ab --- /dev/null +++ b/Features/Leave/LeaveBalance/Cqrs/GetLeaveBalanceByIdHandler.cs @@ -0,0 +1,67 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveBalance.Cqrs; + +public class GetLeaveBalanceByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeName { get; set; } + public string? LeaveCategoryId { get; set; } + public string? LeaveCategoryName { get; set; } + public int Entitlement { get; set; } + public int Used { get; set; } + public int Pending { get; set; } + public int Remaining { get; set; } + public int Year { get; set; } + public DateTime ValidFrom { get; set; } + public DateTime ValidTo { get; set; } + public int CarryForward { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetLeaveBalanceByIdQuery(string Id) : IRequest; + +public class GetLeaveBalanceByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetLeaveBalanceByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetLeaveBalanceByIdQuery request, CancellationToken cancellationToken) + { + return await _context.LeaveBalance + .AsNoTracking() + .Include(x => x.Employee) + .Include(x => x.LeaveCategory) + .Where(x => x.Id == request.Id) + .Select(x => new GetLeaveBalanceByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.EmployeeId, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + LeaveCategoryId = x.LeaveCategoryId, + LeaveCategoryName = x.LeaveCategory != null ? x.LeaveCategory.Name : string.Empty, + Entitlement = x.Entitlement, + Used = x.Used, + Pending = x.Pending, + Remaining = x.Remaining, + Year = x.Year, + ValidFrom = x.ValidFrom, + ValidTo = x.ValidTo, + CarryForward = x.CarryForward, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/Cqrs/GetLeaveBalanceListHandler.cs b/Features/Leave/LeaveBalance/Cqrs/GetLeaveBalanceListHandler.cs new file mode 100644 index 0000000..b7ca389 --- /dev/null +++ b/Features/Leave/LeaveBalance/Cqrs/GetLeaveBalanceListHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveBalance.Cqrs; + +public class GetLeaveBalanceListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeName { get; set; } + public string? EmployeeCode { get; set; } + public string? LeaveCategoryId { get; set; } + public string? LeaveCategoryName { get; set; } + public int Entitlement { get; set; } + public int Used { get; set; } + public int Pending { get; set; } + public int Remaining { get; set; } + public int Year { get; set; } +} + +public record GetLeaveBalanceListQuery() : IRequest>; + +public class GetLeaveBalanceListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetLeaveBalanceListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetLeaveBalanceListQuery request, CancellationToken cancellationToken) + { + return await _context.LeaveBalance + .AsNoTracking() + .NotDeletedOnly() + .Include(x => x.Employee) + .Include(x => x.LeaveCategory) + .OrderByDescending(x => x.Year) + .ThenBy(x => x.Employee!.FirstName) + .Select(x => new GetLeaveBalanceListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.EmployeeId, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + EmployeeCode = x.Employee != null ? $"{x.Employee.Code}" : string.Empty, + LeaveCategoryId = x.LeaveCategoryId, + LeaveCategoryName = x.LeaveCategory != null ? x.LeaveCategory.Name : string.Empty, + Entitlement = x.Entitlement, + Used = x.Used, + Pending = x.Pending, + Remaining = x.Remaining, + Year = x.Year + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/Cqrs/SyncLeaveBalanceHandler.cs b/Features/Leave/LeaveBalance/Cqrs/SyncLeaveBalanceHandler.cs new file mode 100644 index 0000000..d5030d7 --- /dev/null +++ b/Features/Leave/LeaveBalance/Cqrs/SyncLeaveBalanceHandler.cs @@ -0,0 +1,40 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveBalance.Cqrs; + +public record SyncLeaveBalanceCommand() : IRequest; + +public class SyncLeaveBalanceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public SyncLeaveBalanceHandler(AppDbContext context) => _context = context; + + public async Task Handle(SyncLeaveBalanceCommand request, CancellationToken cancellationToken) + { + var balances = await _context.LeaveBalance.ToListAsync(cancellationToken); + + if (!balances.Any()) return true; + + var allRequests = await _context.LeaveRequest + .AsNoTracking() + .ToListAsync(cancellationToken); + + foreach (var balance in balances) + { + var empRequests = allRequests + .Where(x => x.EmployeeId == balance.EmployeeId && + x.LeaveCategoryId == balance.LeaveCategoryId && + x.StartDate.Year == balance.Year) + .ToList(); + + balance.Used = (int)empRequests.Where(x => x.Status == "Approved").Sum(x => x.Days); + balance.Pending = (int)empRequests.Where(x => x.Status == "Pending").Sum(x => x.Days); + balance.Remaining = (balance.Entitlement + balance.CarryForward) - balance.Used; + } + + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/Cqrs/UpdateLeaveBalanceHandler.cs b/Features/Leave/LeaveBalance/Cqrs/UpdateLeaveBalanceHandler.cs new file mode 100644 index 0000000..54c7c3c --- /dev/null +++ b/Features/Leave/LeaveBalance/Cqrs/UpdateLeaveBalanceHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveBalance.Cqrs; + +public class UpdateLeaveBalanceRequest : CreateLeaveBalanceRequest +{ + public string? Id { get; set; } + public string? EmployeeName { get; set; } + public string? LeaveCategoryName { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateLeaveBalanceResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateLeaveBalanceCommand(UpdateLeaveBalanceRequest Data) : IRequest; + +public class UpdateLeaveBalanceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateLeaveBalanceHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateLeaveBalanceCommand request, CancellationToken cancellationToken) + { + var entity = await _context.LeaveBalance + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateLeaveBalanceResponse { Id = request.Data.Id, Success = false }; + } + + entity.EmployeeId = request.Data.EmployeeId; + entity.LeaveCategoryId = request.Data.LeaveCategoryId; + entity.Entitlement = request.Data.Entitlement; + entity.Used = request.Data.Used; + entity.Pending = request.Data.Pending; + entity.Remaining = request.Data.Remaining; + entity.Year = request.Data.Year; + entity.ValidFrom = request.Data.ValidFrom; + entity.ValidTo = request.Data.ValidTo; + entity.CarryForward = request.Data.CarryForward; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateLeaveBalanceResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/Cqrs/UpdateLeaveBalanceValidator.cs b/Features/Leave/LeaveBalance/Cqrs/UpdateLeaveBalanceValidator.cs new file mode 100644 index 0000000..e78a714 --- /dev/null +++ b/Features/Leave/LeaveBalance/Cqrs/UpdateLeaveBalanceValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace Indotalent.Features.Leave.LeaveBalance.Cqrs; + +public class UpdateLeaveBalanceValidator : AbstractValidator +{ + public UpdateLeaveBalanceValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required for update"); + RuleFor(x => x.EmployeeId).NotEmpty().WithMessage("Employee is required"); + RuleFor(x => x.LeaveCategoryId).NotEmpty().WithMessage("Leave Category is required"); + RuleFor(x => x.Year).GreaterThan(2000); + RuleFor(x => x.Entitlement).GreaterThanOrEqualTo(0); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/LeaveBalanceEndpoint.cs b/Features/Leave/LeaveBalance/LeaveBalanceEndpoint.cs new file mode 100644 index 0000000..7ddc275 --- /dev/null +++ b/Features/Leave/LeaveBalance/LeaveBalanceEndpoint.cs @@ -0,0 +1,62 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Leave.LeaveBalance.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Leave.LeaveBalance; + +public static class LeaveBalanceEndpoint +{ + public static void MapLeaveBalanceEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/leave-balance").WithTags("Leave Balances") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetLeaveBalanceListQuery()); + return result.ToApiResponse("Data leave balance retrieved successfully"); + }) + .WithName("GetLeaveBalanceList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetLeaveBalanceByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Leave balance detail retrieved successfully" + : $"Leave balance with ID {id} not found"); + }) + .WithName("GetLeaveBalanceById"); + + group.MapPost("/", async (CreateLeaveBalanceRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateLeaveBalanceCommand(request)); + return result.ToApiResponse("Leave balance has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateLeaveBalance"); + + group.MapPost("/update", async (UpdateLeaveBalanceRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateLeaveBalanceCommand(request)); + if (!result.Success) return ((object?)null).ToApiResponse("Update failed."); + return result.ToApiResponse("Leave balance has been updated successfully"); + }) + .WithName("UpdateLeaveBalance"); + + group.MapPost("/sync", async (IMediator mediator) => + { + var result = await mediator.Send(new SyncLeaveBalanceCommand()); + return result.ToApiResponse("All leave balances recalculated successfully"); + }) + .WithName("SyncLeaveBalance"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteLeaveBalanceByIdCommand(new DeleteLeaveBalanceByIdRequest(id))); + if (!result) return ((object?)null).ToApiResponse("Delete failed."); + return true.ToApiResponse("Leave balance has been deleted successfully"); + }) + .WithName("DeleteLeaveBalanceById"); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveBalance/LeaveBalanceService.cs b/Features/Leave/LeaveBalance/LeaveBalanceService.cs new file mode 100644 index 0000000..446b369 --- /dev/null +++ b/Features/Leave/LeaveBalance/LeaveBalanceService.cs @@ -0,0 +1,61 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Leave.LeaveBalance.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Leave.LeaveBalance; + +public class LeaveBalanceService : BaseService +{ + private readonly RestClient _client; + + public LeaveBalanceService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetLeaveBalanceListAsync() + { + var request = new RestRequest("api/leave-balance", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetLeaveBalanceByIdAsync(string id) + { + var request = new RestRequest($"api/leave-balance/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateLeaveBalanceAsync(CreateLeaveBalanceRequest data) + { + var request = new RestRequest("api/leave-balance", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateLeaveBalanceAsync(UpdateLeaveBalanceRequest data) + { + var request = new RestRequest("api/leave-balance/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task SyncAllLeaveBalancesAsync() + { + var request = new RestRequest("api/leave-balance/sync", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task DeleteLeaveBalanceByIdAsync(string id) + { + var request = new RestRequest($"api/leave-balance/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/Components/LeaveCategoryPage.razor b/Features/Leave/LeaveCategory/Components/LeaveCategoryPage.razor new file mode 100644 index 0000000..30d2fc4 --- /dev/null +++ b/Features/Leave/LeaveCategory/Components/LeaveCategoryPage.razor @@ -0,0 +1,35 @@ +@page "/leave/leave-category" +@using Indotalent.Features.Leave.LeaveCategory.Components +@using Indotalent.Features.Leave.LeaveCategory.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_LeaveCategoryCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_LeaveCategoryUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_LeaveCategoryDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateLeaveCategoryRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + + private void ShowUpdate(UpdateLeaveCategoryRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/Components/_LeaveCategoryCreateForm.razor b/Features/Leave/LeaveCategory/Components/_LeaveCategoryCreateForm.razor new file mode 100644 index 0000000..ea81645 --- /dev/null +++ b/Features/Leave/LeaveCategory/Components/_LeaveCategoryCreateForm.razor @@ -0,0 +1,117 @@ +@using Indotalent.Features.Leave.LeaveCategory +@using Indotalent.Features.Leave.LeaveCategory.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeaveCategoryService LeaveCategoryService +@inject ISnackbar Snackbar + + +
+ +
+ Add Leave Category + Define new leave type and its entitlement rules. +
+
+
+ + + + + + Category Code + + + + Category Name + + + + Quota (Days) + + + + Status + + Active + Inactive + + + + + + + Description + + + + + + + Max Carry Forward + + + + Min Service (Months) + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Category + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateLeaveCategoryValidator _validator = new(); + private CreateLeaveCategoryRequest _model = new() { Status = "Active", IsPaidLeave = true }; + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await LeaveCategoryService.CreateLeaveCategoryAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Leave category created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/Components/_LeaveCategoryDataTable.razor b/Features/Leave/LeaveCategory/Components/_LeaveCategoryDataTable.razor new file mode 100644 index 0000000..f42e681 --- /dev/null +++ b/Features/Leave/LeaveCategory/Components/_LeaveCategoryDataTable.razor @@ -0,0 +1,391 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Leave.LeaveCategory +@using Indotalent.Features.Leave.LeaveCategory.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject LeaveCategoryService LeaveCategoryService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Leave Categories + Manage leave types, quotas, and entitlement rules for employees. +
+
+ + / + Leave + / + Category +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedCategory != null) + { + View + Edit + Remove + + } + else + { + + Create Leave Category + + } +
+
+ + + + + + Code + + + Category Name + + + Quota + + + Paid Leave + + + Description + + + Status + + + + + + + + @context.Code + + + @context.Name + + + @context.Quota Days + + + + + + @context.Description + + + @context.Status + + + + +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _categories = new(); + private GetLeaveCategoryListResponse? _selectedCategory; + private string _searchString = ""; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedCategory = null; + StateHasChanged(); + + try + { + var response = await LeaveCategoryService.GetLeaveCategoryListAsync(); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + _categories = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _categories; + return _categories.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Code?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Status?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("LeaveCategories"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Code"; + worksheet.Cell(currentRow, 2).Value = "Name"; + worksheet.Cell(currentRow, 3).Value = "Quota"; + worksheet.Cell(currentRow, 4).Value = "Is Paid Leave"; + worksheet.Cell(currentRow, 5).Value = "Status"; + worksheet.Cell(currentRow, 6).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#166534"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Code; + worksheet.Cell(currentRow, 2).Value = item.Name; + worksheet.Cell(currentRow, 3).Value = item.Quota; + worksheet.Cell(currentRow, 4).Value = item.IsPaidLeave ? "Yes" : "No"; + worksheet.Cell(currentRow, 5).Value = item.Status; + worksheet.Cell(currentRow, 6).Value = item.Description; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Leave_Category_Registry.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnSearchClick() + { + _skip = 0; + _selectedCategory = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedCategory = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedCategory = null; + StateHasChanged(); + } + + private async Task InvokeEdit() { if (_selectedCategory != null) { var res = await LeaveCategoryService.GetLeaveCategoryByIdAsync(_selectedCategory.Id!); if (res?.Value != null) await OnEdit.InvokeAsync(MapToUpdate(res.Value)); } } + + private async Task InvokeView() { if (_selectedCategory != null) { var res = await LeaveCategoryService.GetLeaveCategoryByIdAsync(_selectedCategory.Id!); if (res?.Value != null) await OnView.InvokeAsync(MapToUpdate(res.Value)); } } + + private UpdateLeaveCategoryRequest MapToUpdate(GetLeaveCategoryByIdResponse d) => new UpdateLeaveCategoryRequest + { + Id = d.Id, + Code = d.Code, + Name = d.Name, + Quota = d.Quota, + IsPaidLeave = d.IsPaidLeave, + Description = d.Description ?? "", + Status = d.Status ?? "Active", + AllowCarryForward = d.AllowCarryForward, + MaxCarryForward = d.MaxCarryForward, + MinServiceMonths = d.MinServiceMonths, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + + private async Task OnDelete() + { + if (_selectedCategory == null) return; + var dialog = await DialogService.ShowAsync("", new DialogParameters { { x => x.ContentText, _selectedCategory.Name } }); + if (!(await dialog.Result).Canceled) + { + if (await LeaveCategoryService.DeleteLeaveCategoryByIdAsync(_selectedCategory.Id!)) { await LoadData(); Snackbar.Add("Category deleted", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/Components/_LeaveCategoryUpdateForm.razor b/Features/Leave/LeaveCategory/Components/_LeaveCategoryUpdateForm.razor new file mode 100644 index 0000000..0094887 --- /dev/null +++ b/Features/Leave/LeaveCategory/Components/_LeaveCategoryUpdateForm.razor @@ -0,0 +1,160 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Leave.LeaveCategory +@using Indotalent.Features.Leave.LeaveCategory.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeaveCategoryService LeaveCategoryService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Category Details" : "Edit Category") + @(ReadOnly ? "Viewing policy configuration." : "Modify existing leave entitlement rules.") +
+
+
+ + + + + + Category Code + + + + Category Name + + + + Quota (Days) + + + + Status + + Active + Inactive + + + + + + + Description + + + + + + + + Max Carry Forward + + + + Min Service (Months) + + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateLeaveCategoryRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateLeaveCategoryValidator _validator = new(); + private UpdateLeaveCategoryRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateLeaveCategoryRequest + { + Id = Data.Id, + Code = Data.Code, + Name = Data.Name, + Quota = Data.Quota, + IsPaidLeave = Data.IsPaidLeave, + Description = Data.Description, + Status = Data.Status, + AllowCarryForward = Data.AllowCarryForward, + MaxCarryForward = Data.MaxCarryForward, + MinServiceMonths = Data.MinServiceMonths, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await LeaveCategoryService.UpdateLeaveCategoryAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Category updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/Cqrs/CreateLeaveCategoryHandler.cs b/Features/Leave/LeaveCategory/Cqrs/CreateLeaveCategoryHandler.cs new file mode 100644 index 0000000..e3573b9 --- /dev/null +++ b/Features/Leave/LeaveCategory/Cqrs/CreateLeaveCategoryHandler.cs @@ -0,0 +1,77 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveCategory.Cqrs; + +public class CreateLeaveCategoryRequest +{ + public string? Code { get; set; } + public string? Name { get; set; } + public int Quota { get; set; } + public bool IsPaidLeave { get; set; } + public string Description { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public bool AllowCarryForward { get; set; } + public int MaxCarryForward { get; set; } + public int MinServiceMonths { get; set; } +} + +public class CreateLeaveCategoryResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateLeaveCategoryCommand(CreateLeaveCategoryRequest Data) : IRequest; + +public class CreateLeaveCategoryHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateLeaveCategoryHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateLeaveCategoryCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.LeaveCategory + .AnyAsync(x => x.Code == request.Data.Code, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Leave Category", request.Data.Code ?? string.Empty); + } + + var entityName = nameof(Data.Entities.LeaveCategory); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.LeaveCategory + { + AutoNumber = autoNo, + Code = request.Data.Code, + Name = request.Data.Name, + Quota = request.Data.Quota, + IsPaidLeave = request.Data.IsPaidLeave, + Description = request.Data.Description, + Status = request.Data.Status, + AllowCarryForward = request.Data.AllowCarryForward, + MaxCarryForward = request.Data.MaxCarryForward, + MinServiceMonths = request.Data.MinServiceMonths + }; + + _context.LeaveCategory.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateLeaveCategoryResponse + { + Id = entity.Id, + Code = entity.Code + }; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/Cqrs/CreateLeaveCategoryValidator.cs b/Features/Leave/LeaveCategory/Cqrs/CreateLeaveCategoryValidator.cs new file mode 100644 index 0000000..af1b42a --- /dev/null +++ b/Features/Leave/LeaveCategory/Cqrs/CreateLeaveCategoryValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Leave.LeaveCategory.Cqrs; + +public class CreateLeaveCategoryValidator : AbstractValidator +{ + public CreateLeaveCategoryValidator() + { + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Category Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Category Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Quota) + .GreaterThanOrEqualTo(0).WithMessage("Quota cannot be negative"); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/Cqrs/DeleteLeaveCategoryByIdHandler.cs b/Features/Leave/LeaveCategory/Cqrs/DeleteLeaveCategoryByIdHandler.cs new file mode 100644 index 0000000..8477d8d --- /dev/null +++ b/Features/Leave/LeaveCategory/Cqrs/DeleteLeaveCategoryByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveCategory.Cqrs; + +public record DeleteLeaveCategoryByIdRequest(string Id); + +public record DeleteLeaveCategoryByIdCommand(DeleteLeaveCategoryByIdRequest Data) : IRequest; + +public class DeleteLeaveCategoryByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteLeaveCategoryByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteLeaveCategoryByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.LeaveCategory + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.LeaveCategory.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/Cqrs/GetLeaveCategoryByIdHandler.cs b/Features/Leave/LeaveCategory/Cqrs/GetLeaveCategoryByIdHandler.cs new file mode 100644 index 0000000..43424c1 --- /dev/null +++ b/Features/Leave/LeaveCategory/Cqrs/GetLeaveCategoryByIdHandler.cs @@ -0,0 +1,59 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveCategory.Cqrs; + +public class GetLeaveCategoryByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public int Quota { get; set; } + public bool IsPaidLeave { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } + public bool AllowCarryForward { get; set; } + public int MaxCarryForward { get; set; } + public int MinServiceMonths { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetLeaveCategoryByIdQuery(string Id) : IRequest; + +public class GetLeaveCategoryByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetLeaveCategoryByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetLeaveCategoryByIdQuery request, CancellationToken cancellationToken) + { + return await _context.LeaveCategory + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetLeaveCategoryByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Code = x.Code, + Name = x.Name, + Quota = x.Quota, + IsPaidLeave = x.IsPaidLeave, + Description = x.Description, + Status = x.Status, + AllowCarryForward = x.AllowCarryForward, + MaxCarryForward = x.MaxCarryForward, + MinServiceMonths = x.MinServiceMonths, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/Cqrs/GetLeaveCategoryListHandler.cs b/Features/Leave/LeaveCategory/Cqrs/GetLeaveCategoryListHandler.cs new file mode 100644 index 0000000..3f04c76 --- /dev/null +++ b/Features/Leave/LeaveCategory/Cqrs/GetLeaveCategoryListHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveCategory.Cqrs; + +public class GetLeaveCategoryListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public int Quota { get; set; } + public bool IsPaidLeave { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } +} + +public record GetLeaveCategoryListQuery() : IRequest>; + +public class GetLeaveCategoryListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetLeaveCategoryListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetLeaveCategoryListQuery request, CancellationToken cancellationToken) + { + return await _context.LeaveCategory + .AsNoTracking() + .NotDeletedOnly() + .OrderBy(x => x.Code) + .Select(x => new GetLeaveCategoryListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Code = x.Code, + Name = x.Name, + Quota = x.Quota, + IsPaidLeave = x.IsPaidLeave, + Description = x.Description, + Status = x.Status + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/Cqrs/UpdateLeaveCategoryHandler.cs b/Features/Leave/LeaveCategory/Cqrs/UpdateLeaveCategoryHandler.cs new file mode 100644 index 0000000..20027c2 --- /dev/null +++ b/Features/Leave/LeaveCategory/Cqrs/UpdateLeaveCategoryHandler.cs @@ -0,0 +1,67 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveCategory.Cqrs; + +public class UpdateLeaveCategoryRequest : CreateLeaveCategoryRequest +{ + public string? Id { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateLeaveCategoryResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateLeaveCategoryCommand(UpdateLeaveCategoryRequest Data) : IRequest; + +public class UpdateLeaveCategoryHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateLeaveCategoryHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateLeaveCategoryCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.LeaveCategory + .AnyAsync(x => x.Code == request.Data.Code && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Leave Category", request.Data.Code ?? string.Empty); + } + + var entity = await _context.LeaveCategory + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateLeaveCategoryResponse { Id = request.Data.Id, Success = false }; + } + + entity.Code = request.Data.Code; + entity.Name = request.Data.Name; + entity.Quota = request.Data.Quota; + entity.IsPaidLeave = request.Data.IsPaidLeave; + entity.Description = request.Data.Description; + entity.Status = request.Data.Status; + entity.AllowCarryForward = request.Data.AllowCarryForward; + entity.MaxCarryForward = request.Data.MaxCarryForward; + entity.MinServiceMonths = request.Data.MinServiceMonths; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateLeaveCategoryResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/Cqrs/UpdateLeaveCategoryValidator.cs b/Features/Leave/LeaveCategory/Cqrs/UpdateLeaveCategoryValidator.cs new file mode 100644 index 0000000..0adcfe0 --- /dev/null +++ b/Features/Leave/LeaveCategory/Cqrs/UpdateLeaveCategoryValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Leave.LeaveCategory.Cqrs; + +public class UpdateLeaveCategoryValidator : AbstractValidator +{ + public UpdateLeaveCategoryValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Category Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Category Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/LeaveCategoryEndpoint.cs b/Features/Leave/LeaveCategory/LeaveCategoryEndpoint.cs new file mode 100644 index 0000000..492ee9d --- /dev/null +++ b/Features/Leave/LeaveCategory/LeaveCategoryEndpoint.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Leave.LeaveCategory.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Leave.LeaveCategory; + +public static class LeaveCategoryEndpoint +{ + public static void MapLeaveCategoryEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/leave-category").WithTags("Leave Categories") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetLeaveCategoryListQuery()); + return result.ToApiResponse("Data leave category retrieved successfully"); + }) + .WithName("GetLeaveCategoryList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetLeaveCategoryByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Leave category detail retrieved successfully" + : $"Leave category with ID {id} not found"); + }) + .WithName("GetLeaveCategoryById"); + + group.MapPost("/", async (CreateLeaveCategoryRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateLeaveCategoryCommand(request)); + return result.ToApiResponse("Leave category has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateLeaveCategory"); + + group.MapPost("/update", async (UpdateLeaveCategoryRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateLeaveCategoryCommand(request)); + if (!result.Success) return ((object?)null).ToApiResponse("Update failed."); + return result.ToApiResponse("Leave category has been updated successfully"); + }) + .WithName("UpdateLeaveCategory"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteLeaveCategoryByIdCommand(new DeleteLeaveCategoryByIdRequest(id))); + if (!result) return ((object?)null).ToApiResponse("Delete failed."); + return true.ToApiResponse("Leave category has been deleted successfully"); + }) + .WithName("DeleteLeaveCategoryById"); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveCategory/LeaveCategoryService.cs b/Features/Leave/LeaveCategory/LeaveCategoryService.cs new file mode 100644 index 0000000..05c21f2 --- /dev/null +++ b/Features/Leave/LeaveCategory/LeaveCategoryService.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Leave.LeaveCategory.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Leave.LeaveCategory; + +public class LeaveCategoryService : BaseService +{ + private readonly RestClient _client; + + public LeaveCategoryService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetLeaveCategoryListAsync() + { + var request = new RestRequest("api/leave-category", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetLeaveCategoryByIdAsync(string id) + { + var request = new RestRequest($"api/leave-category/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateLeaveCategoryAsync(CreateLeaveCategoryRequest data) + { + var request = new RestRequest("api/leave-category", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteLeaveCategoryByIdAsync(string id) + { + var request = new RestRequest($"api/leave-category/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateLeaveCategoryAsync(UpdateLeaveCategoryRequest data) + { + var request = new RestRequest("api/leave-category/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Leave/LeavePage.razor b/Features/Leave/LeavePage.razor new file mode 100644 index 0000000..a9058b6 --- /dev/null +++ b/Features/Leave/LeavePage.razor @@ -0,0 +1,96 @@ +@page "/leave" +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] +@using Indotalent.Features.Leave.LeaveBalance.Components +@using Indotalent.Features.Leave.LeaveCategory +@using Indotalent.Features.Leave.LeaveCategory.Components +@using Indotalent.Features.Leave.LeaveRequest +@using Indotalent.Features.Leave.LeaveBalance +@using Indotalent.Features.Leave.LeaveRequest.Components +@using MudBlazor +@inject NavigationManager NavigationManager + + + + +
+ + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + _activeTabIndex = tabValue.ToString().ToLower() switch + { + "request" => 0, + "category" => 1, + "balance" => 2, + _ => 0 + }; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + string tabName = index switch + { + 0 => "request", + 1 => "category", + 2 => "balance", + _ => "request" + }; + + NavigationManager.NavigateTo($"/leave?tab={tabName}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Components/LeaveRequestPage.razor b/Features/Leave/LeaveRequest/Components/LeaveRequestPage.razor new file mode 100644 index 0000000..24815c7 --- /dev/null +++ b/Features/Leave/LeaveRequest/Components/LeaveRequestPage.razor @@ -0,0 +1,35 @@ +@page "/leave/leave-request" +@using Indotalent.Features.Leave.LeaveRequest.Components +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_LeaveRequestCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_LeaveRequestUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_LeaveRequestDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateLeaveRequestRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + + private void ShowUpdate(UpdateLeaveRequestRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Components/_LeaveRequestCreateForm.razor b/Features/Leave/LeaveRequest/Components/_LeaveRequestCreateForm.razor new file mode 100644 index 0000000..c510f02 --- /dev/null +++ b/Features/Leave/LeaveRequest/Components/_LeaveRequestCreateForm.razor @@ -0,0 +1,136 @@ +@using Indotalent.Features.Leave.LeaveRequest +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveCategory +@using Indotalent.Features.Leave.LeaveCategory.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeaveRequestService LeaveRequestService +@inject LeaveCategoryService LeaveCategoryService +@inject ISnackbar Snackbar + + +
+ +
+ Apply for Leave + Submit a new leave application for approval. +
+
+
+ + + + + + Select Employee + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + Leave Type + + @foreach (var cat in _categories) + { + @cat.Name + } + + + + Start Date + + + + End Date + + + + Total Days + + + + Reason + + + + +
+ Cancel + + @if (_processing) + { + + Submitting... + } + else + { + Submit Application + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateLeaveRequestValidator _validator = new(); + private CreateLeaveRequestRequest _model = new() { StartDate = DateTime.Today, EndDate = DateTime.Today }; + private List _categories = new(); + private List _employees = new(); + private bool _processing = false; + + private DateTime? _startDate { get => _model.StartDate; set { _model.StartDate = value ?? DateTime.Today; CalculateDays(); } } + private DateTime? _endDate { get => _model.EndDate; set { _model.EndDate = value ?? DateTime.Today; CalculateDays(); } } + + protected override async Task OnInitializedAsync() + { + var catRes = await LeaveCategoryService.GetLeaveCategoryListAsync(); + if (catRes != null && catRes.IsSuccess) _categories = catRes.Value ?? new(); + + var empRes = await LeaveRequestService.GetEmployeeReferenceAsync(); + if (empRes != null && empRes.IsSuccess) _employees = empRes.Value ?? new(); + + CalculateDays(); + } + + private void CalculateDays() + { + _model.Days = (_model.EndDate - _model.StartDate).TotalDays + 1; + if (_model.Days < 0) _model.Days = 0; + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await LeaveRequestService.CreateLeaveRequestAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Leave request submitted", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Components/_LeaveRequestDataTable.razor b/Features/Leave/LeaveRequest/Components/_LeaveRequestDataTable.razor new file mode 100644 index 0000000..eb1c2eb --- /dev/null +++ b/Features/Leave/LeaveRequest/Components/_LeaveRequestDataTable.razor @@ -0,0 +1,405 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Leave.LeaveRequest +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject LeaveRequestService LeaveRequestService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Leave Requests + Track and manage employee leave applications and their approval status. +
+
+ + / + Leave + / + Request +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedRequest != null) + { + View Details + @if (_selectedRequest.Status == "Pending") + { + Edit + } + Remove + + } + else + { + + Apply for Leave + + } +
+
+ + + + + + Employee + + + Leave Type + + + Period + + + Days + + + Reason + + + Status + + + + + + + +
+ @context.EmployeeName!.ToInitial() +
+ @context.EmployeeName + @context.EmployeeId +
+
+
+ + @context.LeaveType + + + @context.StartDate.ToString("dd MMM yyyy") + to @context.EndDate.ToString("dd MMM yyyy") + + + @context.Days + + + @context.Reason + + + @context.Status + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _requests = new(); + private GetLeaveRequestListResponse? _selectedRequest; + private string _searchString = ""; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; _selectedRequest = null; + StateHasChanged(); + try + { + var response = await LeaveRequestService.GetLeaveRequestListAsync(); + await Task.Delay(1000); + if (response != null && response.IsSuccess) + { + _requests = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _requests; + return _requests.Where(x => + (x.EmployeeName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EmployeeId?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.LeaveType?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Reason?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Status?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("LeaveRequests"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Employee Name"; + worksheet.Cell(currentRow, 2).Value = "Leave Type"; + worksheet.Cell(currentRow, 3).Value = "Start Date"; + worksheet.Cell(currentRow, 4).Value = "End Date"; + worksheet.Cell(currentRow, 5).Value = "Days"; + worksheet.Cell(currentRow, 6).Value = "Reason"; + worksheet.Cell(currentRow, 7).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 7); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.EmployeeName; + worksheet.Cell(currentRow, 2).Value = item.LeaveType; + worksheet.Cell(currentRow, 3).Value = item.StartDate.ToString("dd-MMM-yyyy"); + worksheet.Cell(currentRow, 4).Value = item.EndDate.ToString("dd-MMM-yyyy"); + worksheet.Cell(currentRow, 5).Value = item.Days; + worksheet.Cell(currentRow, 6).Value = item.Reason; + worksheet.Cell(currentRow, 7).Value = item.Status; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Leave_Request_Registry.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnSearchClick() + { + _skip = 0; + _selectedRequest = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedRequest = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedRequest = null; + StateHasChanged(); + } + + private Color GetRandomColor(string name) { int hash = name.GetHashCode(); var colors = new[] { Color.Primary, Color.Secondary, Color.Tertiary, Color.Info, Color.Success, Color.Warning, Color.Dark }; return colors[Math.Abs(hash) % colors.Length]; } + private Color GetStatusColor(string status) => status switch { "Approved" => Color.Success, "Pending" => Color.Warning, "Rejected" => Color.Error, _ => Color.Default }; + + private async Task InvokeEdit() { if (_selectedRequest != null) { var res = await LeaveRequestService.GetLeaveRequestByIdAsync(_selectedRequest.Id!); if (res?.Value != null) await OnEdit.InvokeAsync(MapToUpdate(res.Value)); } } + private async Task InvokeView() { if (_selectedRequest != null) { var res = await LeaveRequestService.GetLeaveRequestByIdAsync(_selectedRequest.Id!); if (res?.Value != null) await OnView.InvokeAsync(MapToUpdate(res.Value)); } } + + private UpdateLeaveRequestRequest MapToUpdate(GetLeaveRequestByIdResponse d) => new UpdateLeaveRequestRequest + { + Id = d.Id, + EmployeeId = d.EmployeeId!, + LeaveCategoryId = d.LeaveCategoryId!, + StartDate = d.StartDate, + EndDate = d.EndDate, + Days = d.Days, + Reason = d.Reason ?? "", + Status = d.Status ?? "Pending", + AttachmentPath = d.AttachmentPath, + ApproverId = d.ApproverId, + ActionDate = d.ActionDate, + RejectReason = d.RejectReason, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + + private async Task OnDelete() + { + if (_selectedRequest == null) return; + var dialog = await DialogService.ShowAsync("", new DialogParameters { { x => x.ContentText, $"Request for {_selectedRequest.EmployeeName}" } }); + if (!(await dialog.Result).Canceled) + { + if (await LeaveRequestService.DeleteLeaveRequestByIdAsync(_selectedRequest.Id!)) { await LoadData(); Snackbar.Add("Request deleted", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Components/_LeaveRequestUpdateForm.razor b/Features/Leave/LeaveRequest/Components/_LeaveRequestUpdateForm.razor new file mode 100644 index 0000000..35016a4 --- /dev/null +++ b/Features/Leave/LeaveRequest/Components/_LeaveRequestUpdateForm.razor @@ -0,0 +1,225 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Leave.LeaveRequest +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveCategory +@using Indotalent.Features.Leave.LeaveCategory.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject LeaveRequestService LeaveRequestService +@inject LeaveCategoryService LeaveCategoryService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Request Details" : "Edit Request") + Reviewing leave application details and status. +
+
+
+ + + @if (_isInitialLoading) + { +
+ + Loading application data... +
+ } + else + { + + + + Employee + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + Leave Type + + @foreach (var cat in _categories) + { + @cat.Name + } + + + + Start Date + + + + End Date + + + + Total Days + + + + Reason + + + + @if (!ReadOnly && _model.Status == "Pending") + { + + Approval Action + + + + Decision Status + + Pending + Approved + Rejected + + + + Notes / Reject Reason + + + } + + + Audit & Action History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Action By + @(!string.IsNullOrEmpty(_model.ApproverId) ? _model.ApproverId : "-") + + + Action Date + @(_model.ActionDate.HasValue? _model.ActionDate.Value.ToString("dd MMM yyyy HH:mm") : "-") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateLeaveRequestRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateLeaveRequestValidator _validator = new(); + private UpdateLeaveRequestRequest _model = new(); + private List _employees = new(); + private List _categories = new(); + private bool _processing = false; + private bool _isInitialLoading = true; + + private DateTime? _startDate { get => _model.StartDate; set { _model.StartDate = value ?? DateTime.Today; CalculateDays(); } } + private DateTime? _endDate { get => _model.EndDate; set { _model.EndDate = value ?? DateTime.Today; CalculateDays(); } } + + protected override async Task OnInitializedAsync() + { + _isInitialLoading = true; + try + { + _model = new UpdateLeaveRequestRequest + { + Id = Data.Id, + EmployeeId = Data.EmployeeId, + EmployeeName = Data.EmployeeName, + LeaveCategoryId = Data.LeaveCategoryId, + LeaveType = Data.LeaveType, + StartDate = Data.StartDate, + EndDate = Data.EndDate, + Days = Data.Days, + Reason = Data.Reason, + Status = Data.Status, + AttachmentPath = Data.AttachmentPath, + ApproverId = Data.ApproverId, + ActionDate = Data.ActionDate, + RejectReason = Data.RejectReason, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + + var empRes = await LeaveRequestService.GetEmployeeReferenceAsync(); + if (empRes != null && empRes.IsSuccess) _employees = empRes.Value ?? new(); + + var catRes = await LeaveCategoryService.GetLeaveCategoryListAsync(); + if (catRes != null && catRes.IsSuccess) _categories = catRes.Value ?? new(); + } + finally + { + _isInitialLoading = false; + } + } + + private void CalculateDays() + { + _model.Days = (_model.EndDate - _model.StartDate).TotalDays + 1; + if (_model.Days < 0) _model.Days = 0; + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await LeaveRequestService.UpdateLeaveRequestAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Request updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Cqrs/CreateLeaveRequestHandler.cs b/Features/Leave/LeaveRequest/Cqrs/CreateLeaveRequestHandler.cs new file mode 100644 index 0000000..c60884b --- /dev/null +++ b/Features/Leave/LeaveRequest/Cqrs/CreateLeaveRequestHandler.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Leave.LeaveRequest.Cqrs; + +public class CreateLeaveRequestRequest +{ + public string EmployeeId { get; set; } = string.Empty; + public string LeaveCategoryId { get; set; } = string.Empty; + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public double Days { get; set; } + public string Reason { get; set; } = string.Empty; + public string Status { get; set; } = "Pending"; + public string? AttachmentPath { get; set; } +} + +public class CreateLeaveRequestResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateLeaveRequestCommand(CreateLeaveRequestRequest Data) : IRequest; + +public class CreateLeaveRequestHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateLeaveRequestHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateLeaveRequestCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.LeaveRequest); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.LeaveRequest + { + AutoNumber = autoNo, + EmployeeId = request.Data.EmployeeId, + LeaveCategoryId = request.Data.LeaveCategoryId, + StartDate = request.Data.StartDate, + EndDate = request.Data.EndDate, + Days = request.Data.Days, + Reason = request.Data.Reason, + Status = request.Data.Status, + AttachmentPath = request.Data.AttachmentPath + }; + + _context.LeaveRequest.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateLeaveRequestResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Cqrs/CreateLeaveRequestValidator.cs b/Features/Leave/LeaveRequest/Cqrs/CreateLeaveRequestValidator.cs new file mode 100644 index 0000000..3be0ea9 --- /dev/null +++ b/Features/Leave/LeaveRequest/Cqrs/CreateLeaveRequestValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace Indotalent.Features.Leave.LeaveRequest.Cqrs; + +public class CreateLeaveRequestValidator : AbstractValidator +{ + public CreateLeaveRequestValidator() + { + RuleFor(x => x.EmployeeId).NotEmpty().WithMessage("Employee is required"); + RuleFor(x => x.LeaveCategoryId).NotEmpty().WithMessage("Leave Type is required"); + RuleFor(x => x.StartDate).NotEmpty(); + RuleFor(x => x.EndDate).NotEmpty().GreaterThanOrEqualTo(x => x.StartDate).WithMessage("End Date cannot be before Start Date"); + RuleFor(x => x.Reason).NotEmpty().WithMessage("Reason is required"); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Cqrs/DeleteLeaveRequestByIdHandler.cs b/Features/Leave/LeaveRequest/Cqrs/DeleteLeaveRequestByIdHandler.cs new file mode 100644 index 0000000..2fe678f --- /dev/null +++ b/Features/Leave/LeaveRequest/Cqrs/DeleteLeaveRequestByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveRequest.Cqrs; + +public record DeleteLeaveRequestByIdRequest(string Id); + +public record DeleteLeaveRequestByIdCommand(DeleteLeaveRequestByIdRequest Data) : IRequest; + +public class DeleteLeaveRequestByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteLeaveRequestByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteLeaveRequestByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.LeaveRequest + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.LeaveRequest.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Cqrs/GetEmployeeLeaveReference.cs b/Features/Leave/LeaveRequest/Cqrs/GetEmployeeLeaveReference.cs new file mode 100644 index 0000000..d4a4e91 --- /dev/null +++ b/Features/Leave/LeaveRequest/Cqrs/GetEmployeeLeaveReference.cs @@ -0,0 +1,35 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveRequest.Cqrs; + +public class EmployeeLeaveReferenceResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? FullName { get; set; } +} + +public record GetEmployeeLeaveReferenceQuery() : IRequest>; + +public class GetEmployeeLeaveReferenceHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetEmployeeLeaveReferenceHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetEmployeeLeaveReferenceQuery request, CancellationToken cancellationToken) + { + return await _context.Employee + .AsNoTracking() + .Where(x => x.EmployeeStatus == "Active") + .Select(x => new EmployeeLeaveReferenceResponse + { + Id = x.Id, + Code = x.Code, + FullName = $"{x.FirstName} {x.LastName}" + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Cqrs/GetLeaveRequestByIdHandler.cs b/Features/Leave/LeaveRequest/Cqrs/GetLeaveRequestByIdHandler.cs new file mode 100644 index 0000000..e47caf3 --- /dev/null +++ b/Features/Leave/LeaveRequest/Cqrs/GetLeaveRequestByIdHandler.cs @@ -0,0 +1,69 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveRequest.Cqrs; + +public class GetLeaveRequestByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeName { get; set; } + public string? LeaveCategoryId { get; set; } + public string? LeaveType { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public double Days { get; set; } + public string? Reason { get; set; } + public string? Status { get; set; } + public string? AttachmentPath { get; set; } + public string? ApproverId { get; set; } + public DateTime? ActionDate { get; set; } + public string? RejectReason { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetLeaveRequestByIdQuery(string Id) : IRequest; + +public class GetLeaveRequestByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetLeaveRequestByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetLeaveRequestByIdQuery request, CancellationToken cancellationToken) + { + return await _context.LeaveRequest + .AsNoTracking() + .Include(x => x.Employee) + .Include(x => x.LeaveCategory) + .Where(x => x.Id == request.Id) + .Select(x => new GetLeaveRequestByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.EmployeeId, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + LeaveCategoryId = x.LeaveCategoryId, + LeaveType = x.LeaveCategory != null ? x.LeaveCategory.Name : string.Empty, + StartDate = x.StartDate, + EndDate = x.EndDate, + Days = x.Days, + Reason = x.Reason, + Status = x.Status, + AttachmentPath = x.AttachmentPath, + ApproverId = x.ApproverId, + ActionDate = x.ActionDate, + RejectReason = x.RejectReason, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Cqrs/GetLeaveRequestListHandler.cs b/Features/Leave/LeaveRequest/Cqrs/GetLeaveRequestListHandler.cs new file mode 100644 index 0000000..ec84c2e --- /dev/null +++ b/Features/Leave/LeaveRequest/Cqrs/GetLeaveRequestListHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveRequest.Cqrs; + +public class GetLeaveRequestListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeName { get; set; } + public string? LeaveType { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public double Days { get; set; } + public string? Reason { get; set; } + public string? Status { get; set; } +} + +public record GetLeaveRequestListQuery() : IRequest>; + +public class GetLeaveRequestListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetLeaveRequestListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetLeaveRequestListQuery request, CancellationToken cancellationToken) + { + return await _context.LeaveRequest + .AsNoTracking() + .NotDeletedOnly() + .Include(x => x.Employee) + .Include(x => x.LeaveCategory) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetLeaveRequestListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.Employee != null ? x.Employee.Code : string.Empty, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + LeaveType = x.LeaveCategory != null ? x.LeaveCategory.Name : string.Empty, + StartDate = x.StartDate, + EndDate = x.EndDate, + Days = x.Days, + Reason = x.Reason, + Status = x.Status + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Cqrs/UpdateLeaveRequestHandler.cs b/Features/Leave/LeaveRequest/Cqrs/UpdateLeaveRequestHandler.cs new file mode 100644 index 0000000..dbbf72d --- /dev/null +++ b/Features/Leave/LeaveRequest/Cqrs/UpdateLeaveRequestHandler.cs @@ -0,0 +1,65 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Leave.LeaveRequest.Cqrs; + +public class UpdateLeaveRequestRequest : CreateLeaveRequestRequest +{ + public string? Id { get; set; } + public string? EmployeeName { get; set; } + public string? LeaveType { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public string? RejectReason { get; set; } + public string? ApproverId { get; set; } + public DateTime? ActionDate { get; set; } +} + +public class UpdateLeaveRequestResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateLeaveRequestCommand(UpdateLeaveRequestRequest Data) : IRequest; + +public class UpdateLeaveRequestHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateLeaveRequestHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateLeaveRequestCommand request, CancellationToken cancellationToken) + { + var entity = await _context.LeaveRequest + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateLeaveRequestResponse { Id = request.Data.Id, Success = false }; + } + + entity.EmployeeId = request.Data.EmployeeId; + entity.LeaveCategoryId = request.Data.LeaveCategoryId; + entity.StartDate = request.Data.StartDate; + entity.EndDate = request.Data.EndDate; + entity.Days = request.Data.Days; + entity.Reason = request.Data.Reason; + entity.Status = request.Data.Status; + entity.AttachmentPath = request.Data.AttachmentPath; + entity.RejectReason = request.Data.RejectReason; + entity.ApproverId = request.Data.ApproverId; + entity.ActionDate = request.Data.ActionDate; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateLeaveRequestResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/Cqrs/UpdateLeaveRequestValidator.cs b/Features/Leave/LeaveRequest/Cqrs/UpdateLeaveRequestValidator.cs new file mode 100644 index 0000000..6b091b1 --- /dev/null +++ b/Features/Leave/LeaveRequest/Cqrs/UpdateLeaveRequestValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; + +namespace Indotalent.Features.Leave.LeaveRequest.Cqrs; + +public class UpdateLeaveRequestValidator : AbstractValidator +{ + public UpdateLeaveRequestValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.EmployeeId) + .NotEmpty().WithMessage("Employee is required"); + + RuleFor(x => x.LeaveCategoryId) + .NotEmpty().WithMessage("Leave Type is required"); + + RuleFor(x => x.StartDate) + .NotEmpty().WithMessage("Start Date is required"); + + RuleFor(x => x.EndDate) + .NotEmpty() + .GreaterThanOrEqualTo(x => x.StartDate).WithMessage("End Date cannot be before Start Date"); + + RuleFor(x => x.Reason) + .NotEmpty().WithMessage("Reason is required"); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/LeaveRequestEndpoint.cs b/Features/Leave/LeaveRequest/LeaveRequestEndpoint.cs new file mode 100644 index 0000000..fee18e8 --- /dev/null +++ b/Features/Leave/LeaveRequest/LeaveRequestEndpoint.cs @@ -0,0 +1,63 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Leave.LeaveRequest.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Leave.LeaveRequest; + +public static class LeaveRequestEndpoint +{ + public static void MapLeaveRequestEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/leave-request").WithTags("Leave Requests") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetLeaveRequestListQuery()); + return result.ToApiResponse("Data leave request retrieved successfully"); + }) + .WithName("GetLeaveRequestList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetLeaveRequestByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Leave request detail retrieved successfully" + : $"Leave request with ID {id} not found"); + }) + .WithName("GetLeaveRequestById"); + + group.MapPost("/", async (CreateLeaveRequestRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateLeaveRequestCommand(request)); + return result.ToApiResponse("Leave request has been submitted successfully", StatusCodes.Status201Created); + }) + .WithName("CreateLeaveRequest"); + + group.MapPost("/update", async (UpdateLeaveRequestRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateLeaveRequestCommand(request)); + if (!result.Success) return ((object?)null).ToApiResponse("Update failed."); + return result.ToApiResponse("Leave request has been updated successfully"); + }) + .WithName("UpdateLeaveRequest"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteLeaveRequestByIdCommand(new DeleteLeaveRequestByIdRequest(id))); + if (!result) return ((object?)null).ToApiResponse("Delete failed."); + return true.ToApiResponse("Leave request has been deleted successfully"); + }) + .WithName("DeleteLeaveRequestById"); + + + group.MapGet("/employee-reference", async (IMediator mediator) => + { + var result = await mediator.Send(new GetEmployeeLeaveReferenceQuery()); + return result.ToApiResponse("Employee reference retrieved successfully"); + }) + .WithName("GetEmployeeLeaveReference"); + } +} \ No newline at end of file diff --git a/Features/Leave/LeaveRequest/LeaveRequestService.cs b/Features/Leave/LeaveRequest/LeaveRequestService.cs new file mode 100644 index 0000000..e8923f3 --- /dev/null +++ b/Features/Leave/LeaveRequest/LeaveRequestService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Leave.LeaveRequest.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Leave.LeaveRequest; + +public class LeaveRequestService : BaseService +{ + private readonly RestClient _client; + + public LeaveRequestService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetLeaveRequestListAsync() + { + var request = new RestRequest("api/leave-request", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetLeaveRequestByIdAsync(string id) + { + var request = new RestRequest($"api/leave-request/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateLeaveRequestAsync(CreateLeaveRequestRequest data) + { + var request = new RestRequest("api/leave-request", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateLeaveRequestAsync(UpdateLeaveRequestRequest data) + { + var request = new RestRequest("api/leave-request/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteLeaveRequestByIdAsync(string id) + { + var request = new RestRequest($"api/leave-request/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task>?> GetEmployeeReferenceAsync() + { + var request = new RestRequest("api/leave-request/employee-reference", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Multitenant/MultitenantPage.razor b/Features/Multitenant/MultitenantPage.razor new file mode 100644 index 0000000..52a12ee --- /dev/null +++ b/Features/Multitenant/MultitenantPage.razor @@ -0,0 +1,110 @@ +@page "/multitenant" +@using Indotalent.Features.Multitenant.Tenant.Components +@using Indotalent.Features.Multitenant.TenantUser.Components +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@using MudBlazor +@inject NavigationManager NavigationManager + +@implements IDisposable + +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] + + + + +
+ + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + private readonly Dictionary _tabMapping = new() + { + { 0, "tenant" }, + { 1, "tenant-user" } + }; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + var tabStr = tabValue.ToString().ToLower(); + var match = _tabMapping.FirstOrDefault(x => x.Value == tabStr); + _activeTabIndex = match.Value != null ? match.Key : 0; + } + + NavigationManager.LocationChanged += OnLocationChanged; + } + + private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e) + { + var uri = NavigationManager.ToAbsoluteUri(e.Location); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + var tabStr = tabValue.ToString().ToLower(); + var match = _tabMapping.FirstOrDefault(x => x.Value == tabStr); + if (match.Value != null) + { + _activeTabIndex = match.Key; + StateHasChanged(); + } + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + _tabMapping.TryGetValue(index, out var tabName); + + NavigationManager.NavigateTo($"/multitenant?tab={tabName ?? "tenant"}", replace: false); + } + + public void Dispose() + { + NavigationManager.LocationChanged -= OnLocationChanged; + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Components/TenantPage.razor b/Features/Multitenant/Tenant/Components/TenantPage.razor new file mode 100644 index 0000000..a16bbee --- /dev/null +++ b/Features/Multitenant/Tenant/Components/TenantPage.razor @@ -0,0 +1,28 @@ +@page "/multitenant/tenant" +@using Indotalent.Features.Multitenant.Tenant +@using Indotalent.Features.Multitenant.Tenant.Cqrs +@using Indotalent.Features.Multitenant.Tenant.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_TenantCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_TenantUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_TenantDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateTenantRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateTenantRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Components/_TenantCreateForm.razor b/Features/Multitenant/Tenant/Components/_TenantCreateForm.razor new file mode 100644 index 0000000..eb1af0e --- /dev/null +++ b/Features/Multitenant/Tenant/Components/_TenantCreateForm.razor @@ -0,0 +1,127 @@ +@using Indotalent.Features.Multitenant.Tenant.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TenantService TenantService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Tenant + Register a new multi-tenant organization. +
+
+
+ + + + + + General Information + + + + Tenant Name + + + + Phone Number + + + + Fax Number + + + + Email Address + + + + Website + + + + + Address Information + + + + Street + + + + City + + + + State + + + + Zip Code + + + + Country + + + + + Status + + + + Is Active + + + + + Additional Notes + + + + Description + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Tenant + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateTenantValidator _validator = new(); + private CreateTenantRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await TenantService.CreateTenantAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Tenant created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Components/_TenantDataTable.razor b/Features/Multitenant/Tenant/Components/_TenantDataTable.razor new file mode 100644 index 0000000..b35d792 --- /dev/null +++ b/Features/Multitenant/Tenant/Components/_TenantDataTable.razor @@ -0,0 +1,303 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Multitenant.Tenant +@using Indotalent.Features.Multitenant.Tenant.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject TenantService TenantService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Tenant + Manage multi-tenant organizations and their configuration. +
+
+ + / + Multitenant + / + Tenant +
+
+ + + +
+
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add New Tenant + + } +
+
+ + + + + TenantId + + Tenant Name + + Email + Phone + City + Status + + + + + + + @context.Id + + + @context.Name + + @context.EmailAddress + @context.PhoneNumber + @context.City + + @if (context.IsActive) + { + ACTIVE + } + else + { + INACTIVE + } + + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetTenantListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await TenantService.GetTenantListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) { _items = response.Value ?? new(); } + } + finally { _isRefreshing = false; StateHasChanged(); } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.Id?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EmailAddress?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.PhoneNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + private void OnSearchClick() { _skip = 0; _selectedItem = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Tenants"); + worksheet.Cell(1, 1).Value = "TenantId"; + worksheet.Cell(1, 2).Value = "Tenant Name"; + worksheet.Cell(1, 3).Value = "Email"; + worksheet.Cell(1, 4).Value = "Phone"; + worksheet.Cell(1, 5).Value = "City"; + worksheet.Cell(1, 6).Value = "Status"; + worksheet.Range(1, 1, 1, 6).Style.Font.Bold = true; + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Id; + worksheet.Cell(currentRow, 2).Value = item.Name; + worksheet.Cell(currentRow, 3).Value = item.EmailAddress; + worksheet.Cell(currentRow, 4).Value = item.PhoneNumber; + worksheet.Cell(currentRow, 5).Value = item.City; + worksheet.Cell(currentRow, 6).Value = item.IsActive ? "Active" : "Inactive"; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Tenant_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; StateHasChanged(); } + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedItem = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedItem = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedItem != null) { var req = await MapToUpdateRequest(_selectedItem.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedItem != null) { var req = await MapToUpdateRequest(_selectedItem.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await TenantService.GetTenantByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateTenantRequest + { + Id = d.Id, + Name = d.Name, + Description = d.Description, + Street = d.Street, + City = d.City, + State = d.State, + ZipCode = d.ZipCode, + Country = d.Country, + PhoneNumber = d.PhoneNumber, + FaxNumber = d.FaxNumber, + EmailAddress = d.EmailAddress, + Website = d.Website, + IsActive = d.IsActive, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedItem == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.Name } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await TenantService.DeleteTenantByIdAsync(_selectedItem.Id); + if (success) { _selectedItem = null; await LoadData(); Snackbar.Add("Deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Components/_TenantUpdateForm.razor b/Features/Multitenant/Tenant/Components/_TenantUpdateForm.razor new file mode 100644 index 0000000..a214268 --- /dev/null +++ b/Features/Multitenant/Tenant/Components/_TenantUpdateForm.razor @@ -0,0 +1,219 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Multitenant.Tenant.Cqrs +@using Indotalent.Features.Multitenant.Tenant.Components +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TenantService TenantService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Tenant Details" : "Edit Tenant") + @(ReadOnly ? "Viewing tenant information." : "Modify tenant details and manage users.") +
+
+
+ + + @if (_isDataLoading) + { +
+ } + else + { + + + + General Information + + + + Tenant Name + + + + Phone Number + + + + Fax Number + + + + Email Address + + + + Website + + + + + Address Information + + + + Street + + + + City + + + + State + + + + Zip Code + + + + Country + + + + + Status + + + + Is Active + + + + + Description + + + + + <_TenantUserDataTable Users="_model.Users" TenantId="@(_model.Id ?? string.Empty)" ReadOnly="ReadOnly" OnChanged="RefreshData" /> + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateTenantRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateTenantValidator _validator = new(); + private GetTenantByIdResponse _model = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + await RefreshData(); + } + + private async Task RefreshData() + { + _isDataLoading = true; + try + { + var tenant = await TenantService.GetTenantByIdAsync(Data.Id!); + if (tenant != null && tenant.IsSuccess) { _model = tenant.Value!; } + } + finally { _isDataLoading = false; } + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + StateHasChanged(); + + try + { + var updateRequest = new UpdateTenantRequest + { + Id = _model.Id, + Name = _model.Name, + Description = _model.Description, + Street = _model.Street, + City = _model.City, + State = _model.State, + ZipCode = _model.ZipCode, + Country = _model.Country, + PhoneNumber = _model.PhoneNumber, + FaxNumber = _model.FaxNumber, + EmailAddress = _model.EmailAddress, + Website = _model.Website, + IsActive = _model.IsActive, + CreatedAt = _model.CreatedAt, + CreatedBy = _model.CreatedBy, + UpdatedAt = _model.UpdatedAt, + UpdatedBy = _model.UpdatedBy + }; + + var response = await TenantService.UpdateTenantAsync(updateRequest); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + StateHasChanged(); + } + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Components/_TenantUserCreateForm.razor b/Features/Multitenant/Tenant/Components/_TenantUserCreateForm.razor new file mode 100644 index 0000000..8f9ad6d --- /dev/null +++ b/Features/Multitenant/Tenant/Components/_TenantUserCreateForm.razor @@ -0,0 +1,69 @@ +@using Indotalent.Features.Multitenant.Tenant.Cqrs +@using Indotalent.Features.Multitenant.TenantUser.Cqrs +@using MudBlazor +@inject TenantService TenantService +@inject ISnackbar Snackbar + + + + + + + User ID + + + + Summary + + + + Is Active + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Add User + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string TenantId { get; set; } = string.Empty; + private MudForm _form = default!; + private CreateTenantUserRequest _model = new(); + private bool _processing = false; + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + _model.TenantId = TenantId; + try + { + var response = await TenantService.CreateTenantUserAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("User added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Components/_TenantUserDataTable.razor b/Features/Multitenant/Tenant/Components/_TenantUserDataTable.razor new file mode 100644 index 0000000..4bc779d --- /dev/null +++ b/Features/Multitenant/Tenant/Components/_TenantUserDataTable.razor @@ -0,0 +1,93 @@ +@using Indotalent.Features.Multitenant.Tenant +@using Indotalent.Features.Multitenant.Tenant.Cqrs +@using MudBlazor +@using Features.Root.Shared +@inject TenantService TenantService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ Tenant Users + @if (!ReadOnly) + { + + Add User + + } +
+ + + + User ID + Summary + Is Active + @if (!ReadOnly) + { + Actions + } + + + @context.UserId + @context.Summary + + @if (context.IsActive) + { + ACTIVE + } + else + { + INACTIVE + } + + @if (!ReadOnly) + { + + + + + } + + +
+ +@code { + [Parameter] public string TenantId { get; set; } = string.Empty; + [Parameter] public List Users { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnChanged { get; set; } + + private async Task OnAddClick() + { + var parameters = new DialogParameters { ["TenantId"] = TenantId }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_TenantUserCreateForm>("Add Tenant User", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnEditClick(TenantUserItemResponse item) + { + var parameters = new DialogParameters { ["Data"] = item }; + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + var dialog = await DialogService.ShowAsync<_TenantUserUpdateForm>("Edit Tenant User", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await OnChanged.InvokeAsync(); + } + + private async Task OnDeleteClick(TenantUserItemResponse item) + { + var parameters = new DialogParameters { ["ContentText"] = $"Are you sure you want to remove this user?" }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("Delete Confirmation", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await TenantService.DeleteTenantUserAsync(item.Id!); + if (success) + { + Snackbar.Add("User removed successfully", Severity.Success); + await OnChanged.InvokeAsync(); + } + } + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Components/_TenantUserUpdateForm.razor b/Features/Multitenant/Tenant/Components/_TenantUserUpdateForm.razor new file mode 100644 index 0000000..290eea2 --- /dev/null +++ b/Features/Multitenant/Tenant/Components/_TenantUserUpdateForm.razor @@ -0,0 +1,76 @@ +@using Indotalent.Features.Multitenant.Tenant.Cqrs +@using Indotalent.Features.Multitenant.TenantUser.Cqrs +@using MudBlazor +@inject TenantService TenantService +@inject ISnackbar Snackbar + + + + + + + User ID + + + + Summary + + + + Is Active + + + + + + + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Save Changes + } + + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public TenantUserItemResponse Data { get; set; } = new(); + private MudForm _form = default!; + private UpdateTenantUserRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model.Id = Data.Id; + _model.UserId = Data.UserId; + _model.Summary = Data.Summary; + _model.IsActive = Data.IsActive; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await TenantService.UpdateTenantUserAsync(_model); + await Task.Delay(500); + if (response != null && response.Value!.Success) + { + Snackbar.Add("User updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Cqrs/CreateTenantHandler.cs b/Features/Multitenant/Tenant/Cqrs/CreateTenantHandler.cs new file mode 100644 index 0000000..6774f15 --- /dev/null +++ b/Features/Multitenant/Tenant/Cqrs/CreateTenantHandler.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Multitenant.Tenant.Cqrs; + +public class CreateTenantRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public bool IsActive { get; set; } = true; +} + +public class CreateTenantResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateTenantCommand(CreateTenantRequest Data) : IRequest; + +public class CreateTenantHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateTenantHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateTenantCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Tenant + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Tenant", request.Data.Name ?? string.Empty); + } + + var entity = new Data.Entities.Tenant + { + Name = request.Data.Name, + Description = request.Data.Description, + Street = request.Data.Street, + City = request.Data.City, + State = request.Data.State, + ZipCode = request.Data.ZipCode, + Country = request.Data.Country, + PhoneNumber = request.Data.PhoneNumber, + FaxNumber = request.Data.FaxNumber, + EmailAddress = request.Data.EmailAddress, + Website = request.Data.Website, + IsActive = request.Data.IsActive + }; + + _context.Tenant.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateTenantResponse + { + Id = entity.Id, + Name = entity.Name + }; + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Cqrs/CreateTenantValidator.cs b/Features/Multitenant/Tenant/Cqrs/CreateTenantValidator.cs new file mode 100644 index 0000000..2aefe94 --- /dev/null +++ b/Features/Multitenant/Tenant/Cqrs/CreateTenantValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Multitenant.Tenant.Cqrs; + +public class CreateTenantValidator : AbstractValidator +{ + public CreateTenantValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Tenant Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Cqrs/DeleteTenantByIdHandler.cs b/Features/Multitenant/Tenant/Cqrs/DeleteTenantByIdHandler.cs new file mode 100644 index 0000000..3d37a12 --- /dev/null +++ b/Features/Multitenant/Tenant/Cqrs/DeleteTenantByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Multitenant.Tenant.Cqrs; + +public record DeleteTenantByIdRequest(string Id); + +public record DeleteTenantByIdCommand(DeleteTenantByIdRequest Data) : IRequest; + +public class DeleteTenantByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteTenantByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteTenantByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Tenant + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Tenant.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Cqrs/GetTenantByIdHandler.cs b/Features/Multitenant/Tenant/Cqrs/GetTenantByIdHandler.cs new file mode 100644 index 0000000..60c2f43 --- /dev/null +++ b/Features/Multitenant/Tenant/Cqrs/GetTenantByIdHandler.cs @@ -0,0 +1,82 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Multitenant.Tenant.Cqrs; + +public class TenantUserItemResponse +{ + public string? Id { get; set; } + public string? UserId { get; set; } + public string? Summary { get; set; } + public bool IsActive { get; set; } +} + +public class GetTenantByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public bool IsActive { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public List Users { get; set; } = new(); +} + +public record GetTenantByIdQuery(string Id) : IRequest; + +public class GetTenantByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetTenantByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetTenantByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Tenant + .AsNoTracking() + .Include(x => x.TenantUserList) + .Where(x => x.Id == request.Id) + .Select(x => new GetTenantByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + Street = x.Street, + City = x.City, + State = x.State, + ZipCode = x.ZipCode, + Country = x.Country, + PhoneNumber = x.PhoneNumber, + FaxNumber = x.FaxNumber, + EmailAddress = x.EmailAddress, + Website = x.Website, + IsActive = x.IsActive, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + Users = x.TenantUserList + .OrderBy(u => u.Summary) + .Select(u => new TenantUserItemResponse + { + Id = u.Id, + UserId = u.UserId, + Summary = u.Summary, + IsActive = u.IsActive + }).ToList() + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Cqrs/GetTenantListHandler.cs b/Features/Multitenant/Tenant/Cqrs/GetTenantListHandler.cs new file mode 100644 index 0000000..dd0526b --- /dev/null +++ b/Features/Multitenant/Tenant/Cqrs/GetTenantListHandler.cs @@ -0,0 +1,41 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Multitenant.Tenant.Cqrs; + +public class GetTenantListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? PhoneNumber { get; set; } + public string? EmailAddress { get; set; } + public string? City { get; set; } + public bool IsActive { get; set; } +} + +public record GetTenantListQuery() : IRequest>; + +public class GetTenantListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetTenantListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetTenantListQuery request, CancellationToken cancellationToken) + { + return await _context.Tenant + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetTenantListResponse + { + Id = x.Id, + Name = x.Name, + PhoneNumber = x.PhoneNumber, + EmailAddress = x.EmailAddress, + City = x.City, + IsActive = x.IsActive + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Cqrs/LookupTenantHandler.cs b/Features/Multitenant/Tenant/Cqrs/LookupTenantHandler.cs new file mode 100644 index 0000000..7c1c399 --- /dev/null +++ b/Features/Multitenant/Tenant/Cqrs/LookupTenantHandler.cs @@ -0,0 +1,38 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Multitenant.Tenant.Cqrs; + +public class LookupTenantResponse +{ + public List Tenants { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record LookupTenantQuery() : IRequest; + +public class LookupTenantHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public LookupTenantHandler(AppDbContext context) => _context = context; + + public async Task Handle(LookupTenantQuery request, CancellationToken cancellationToken) + { + var result = new LookupTenantResponse(); + + result.Tenants = await _context.Tenant + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + return result; + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Cqrs/UpdateTenantHandler.cs b/Features/Multitenant/Tenant/Cqrs/UpdateTenantHandler.cs new file mode 100644 index 0000000..20b5b7a --- /dev/null +++ b/Features/Multitenant/Tenant/Cqrs/UpdateTenantHandler.cs @@ -0,0 +1,82 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Multitenant.Tenant.Cqrs; + +public class UpdateTenantRequest +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? FaxNumber { get; set; } + public string? EmailAddress { get; set; } + public string? Website { get; set; } + public bool IsActive { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateTenantResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateTenantCommand(UpdateTenantRequest Data) : IRequest; + +public class UpdateTenantHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateTenantHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateTenantCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Tenant + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Tenant", request.Data.Name ?? string.Empty); + } + + var entity = await _context.Tenant + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateTenantResponse { Id = request.Data.Id, Success = false }; + } + + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.Street = request.Data.Street; + entity.City = request.Data.City; + entity.State = request.Data.State; + entity.ZipCode = request.Data.ZipCode; + entity.Country = request.Data.Country; + entity.PhoneNumber = request.Data.PhoneNumber; + entity.FaxNumber = request.Data.FaxNumber; + entity.EmailAddress = request.Data.EmailAddress; + entity.Website = request.Data.Website; + entity.IsActive = request.Data.IsActive; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateTenantResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/Cqrs/UpdateTenantValidator.cs b/Features/Multitenant/Tenant/Cqrs/UpdateTenantValidator.cs new file mode 100644 index 0000000..54047c3 --- /dev/null +++ b/Features/Multitenant/Tenant/Cqrs/UpdateTenantValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Multitenant.Tenant.Cqrs; + +public class UpdateTenantValidator : AbstractValidator +{ + public UpdateTenantValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Tenant Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/TenantEndpoint.cs b/Features/Multitenant/Tenant/TenantEndpoint.cs new file mode 100644 index 0000000..b30ec0b --- /dev/null +++ b/Features/Multitenant/Tenant/TenantEndpoint.cs @@ -0,0 +1,107 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Multitenant.Tenant.Cqrs; +using Indotalent.Features.Multitenant.TenantUser.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Multitenant.Tenant; + +public static class TenantEndpoint +{ + public static void MapTenantEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/tenant").WithTags("Tenants") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetTenantListQuery()); + return result.ToApiResponse("Tenant list retrieved successfully"); + }) + .WithName("GetTenantList") + .WithTags("Tenants"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetTenantByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Tenant detail retrieved successfully" + : $"Tenant with ID {id} not found"); + }) + .WithName("GetTenantById") + .WithTags("Tenants"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new LookupTenantQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetTenantLookup") + .WithTags("Tenants"); + + group.MapPost("/", async (CreateTenantRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTenantCommand(request)); + return result.ToApiResponse("Tenant has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateTenant") + .WithTags("Tenants"); + + group.MapPost("/update", async (UpdateTenantRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTenantCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The tenant data could not be found."); + } + return result.ToApiResponse("Tenant has been updated successfully"); + }) + .WithName("UpdateTenant") + .WithTags("Tenants"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTenantByIdCommand(new DeleteTenantByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The tenant data could not be found."); + } + return true.ToApiResponse("Tenant has been deleted successfully"); + }) + .WithName("DeleteTenantById") + .WithTags("Tenants"); + + group.MapPost("/tenant-user", async (CreateTenantUserRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTenantUserCommand(request)); + return result.ToApiResponse("Tenant user has been added successfully"); + }) + .WithName("CreateTenantUserChild") + .WithTags("Tenants"); + + group.MapPost("/tenant-user/update", async (UpdateTenantUserRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTenantUserCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. Tenant user not found."); + } + return result.ToApiResponse("Tenant user has been updated successfully"); + }) + .WithName("UpdateTenantUserChild") + .WithTags("Tenants"); + + group.MapPost("/tenant-user/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTenantUserByIdCommand(new DeleteTenantUserByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. Tenant user not found."); + } + return true.ToApiResponse("Tenant user has been removed successfully"); + }) + .WithName("DeleteTenantUserChild") + .WithTags("Tenants"); + } +} \ No newline at end of file diff --git a/Features/Multitenant/Tenant/TenantService.cs b/Features/Multitenant/Tenant/TenantService.cs new file mode 100644 index 0000000..7afd919 --- /dev/null +++ b/Features/Multitenant/Tenant/TenantService.cs @@ -0,0 +1,87 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Multitenant.Tenant.Cqrs; +using Indotalent.Features.Multitenant.TenantUser.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Multitenant.Tenant; + +public class TenantService : BaseService +{ + private readonly RestClient _client; + + public TenantService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetTenantListAsync() + { + var request = new RestRequest("api/tenant", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetTenantByIdAsync(string id) + { + var request = new RestRequest($"api/tenant/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetTenantLookupAsync() + { + var request = new RestRequest("api/tenant/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateTenantAsync(CreateTenantRequest data) + { + var request = new RestRequest("api/tenant", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTenantByIdAsync(string id) + { + var request = new RestRequest($"api/tenant/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateTenantAsync(UpdateTenantRequest data) + { + var request = new RestRequest("api/tenant/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateTenantUserAsync(CreateTenantUserRequest data) + { + var request = new RestRequest("api/tenant/tenant-user", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateTenantUserAsync(UpdateTenantUserRequest data) + { + var request = new RestRequest("api/tenant/tenant-user/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTenantUserAsync(string id) + { + var request = new RestRequest($"api/tenant/tenant-user/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/Components/TenantUserPage.razor b/Features/Multitenant/TenantUser/Components/TenantUserPage.razor new file mode 100644 index 0000000..77ab718 --- /dev/null +++ b/Features/Multitenant/TenantUser/Components/TenantUserPage.razor @@ -0,0 +1,28 @@ +@page "/multitenant/tenant-user" +@using Indotalent.Features.Multitenant.TenantUser +@using Indotalent.Features.Multitenant.TenantUser.Cqrs +@using Indotalent.Features.Multitenant.TenantUser.Components +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_TenantUserCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_TenantUserUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_TenantUserDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateTenantUserRequest? _selectedData; + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateTenantUserRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/Components/_TenantUserCreateForm.razor b/Features/Multitenant/TenantUser/Components/_TenantUserCreateForm.razor new file mode 100644 index 0000000..8828341 --- /dev/null +++ b/Features/Multitenant/TenantUser/Components/_TenantUserCreateForm.razor @@ -0,0 +1,98 @@ +@using Indotalent.Features.Multitenant.TenantUser +@using Indotalent.Features.Multitenant.TenantUser.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TenantUserService TenantUserService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Tenant User + Assign a system user to a tenant organization. +
+
+
+ + + + + + User Assignment + + + + Tenant + + @foreach (var item in _lookupData.Tenants) + { + @item.Name + } + + + + System User + + @foreach (var item in _lookupData.SystemUsers) + { + @item.Name + } + + + + Summary + + + + Is Active + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create User + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateTenantUserValidator _validator = new(); + private CreateTenantUserRequest _model = new(); + private LookupTenantUserResponse _lookupData = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await TenantUserService.GetTenantUserLookupAsync(); + if (response != null && response.IsSuccess) { _lookupData = response.Value!; } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await TenantUserService.CreateTenantUserAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("User created successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/Components/_TenantUserDataTable.razor b/Features/Multitenant/TenantUser/Components/_TenantUserDataTable.razor new file mode 100644 index 0000000..7a42f4e --- /dev/null +++ b/Features/Multitenant/TenantUser/Components/_TenantUserDataTable.razor @@ -0,0 +1,280 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Multitenant.TenantUser +@using Indotalent.Features.Multitenant.TenantUser.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject TenantUserService TenantUserService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Tenant User + Manage user assignments across tenant organizations. +
+
+ + / + Multitenant + / + Tenant User +
+
+ + +
+
+ + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Add New User + + } +
+
+ + + + + + Full Name + + Email + Tenant + TenantId + Status + + + + + + + @context.FullName + + @context.Email + @context.TenantName + + @context.TenantId + + + @if (context.IsActive) + { + ACTIVE + } + else + { + INACTIVE + } + + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetTenantUserListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await TenantUserService.GetTenantUserListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) { _items = response.Value ?? new(); } + } + finally { _isRefreshing = false; StateHasChanged(); } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.FullName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Email?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Summary?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.UserId?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.TenantName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.TenantId?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + private void OnSearchClick() { _skip = 0; _selectedItem = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + + private async Task ExportToExcel() + { + _isExporting = true; + try + { + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("TenantUsers"); + worksheet.Cell(1, 1).Value = "Full Name"; + worksheet.Cell(1, 2).Value = "Email"; + worksheet.Cell(1, 3).Value = "Tenant"; + worksheet.Cell(1, 4).Value = "TenantId"; + worksheet.Cell(1, 5).Value = "Status"; + worksheet.Range(1, 1, 1, 5).Style.Font.Bold = true; + var currentRow = 1; + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.FullName; + worksheet.Cell(currentRow, 2).Value = item.Email; + worksheet.Cell(currentRow, 3).Value = item.TenantName; + worksheet.Cell(currentRow, 4).Value = item.TenantId; + worksheet.Cell(currentRow, 5).Value = item.IsActive ? "Active" : "Inactive"; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "TenantUser_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + } + } + } + finally { _isExporting = false; StateHasChanged(); } + } + + private void OnPageChanged(int page) { if (page >= 1 && page <= _totalPage) { _skip = (page - 1) * _top; _selectedItem = null; StateHasChanged(); } } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedItem = null; StateHasChanged(); } + + private async Task InvokeEdit() { if (_selectedItem != null) { var req = await MapToUpdateRequest(_selectedItem.Id); if (req != null) await OnEdit.InvokeAsync(req); } } + private async Task InvokeView() { if (_selectedItem != null) { var req = await MapToUpdateRequest(_selectedItem.Id); if (req != null) await OnView.InvokeAsync(req); } } + + private async Task MapToUpdateRequest(string id) + { + var response = await TenantUserService.GetTenantUserByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var d = response.Value; + return new UpdateTenantUserRequest { Id = d.Id, TenantId = d.TenantId, UserId = d.UserId, Summary = d.Summary, IsActive = d.IsActive, CreatedAt = d.CreatedAt, CreatedBy = d.CreatedBy, UpdatedAt = d.UpdatedAt, UpdatedBy = d.UpdatedBy }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedItem == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.Summary } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, new DialogOptions { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var success = await TenantUserService.DeleteTenantUserByIdAsync(_selectedItem.Id); + if (success) { _selectedItem = null; await LoadData(); Snackbar.Add("Deleted successfully", Severity.Success); } + } + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/Components/_TenantUserUpdateForm.razor b/Features/Multitenant/TenantUser/Components/_TenantUserUpdateForm.razor new file mode 100644 index 0000000..76223f1 --- /dev/null +++ b/Features/Multitenant/TenantUser/Components/_TenantUserUpdateForm.razor @@ -0,0 +1,140 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Multitenant.TenantUser +@using Indotalent.Features.Multitenant.TenantUser.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TenantUserService TenantUserService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "User Details" : "Edit User") + @(ReadOnly ? "Viewing tenant user assignment." : "Modify tenant user assignment.") +
+
+
+ + + @if (_isDataLoading) + { +
+ } + else + { + + + + User Assignment + + + + Tenant + + @foreach (var item in _lookupData.Tenants) + { + @item.Name + } + + + + System User + + @foreach (var item in _lookupData.SystemUsers) + { + @item.Name + } + + + + Summary + + + + Is Active + + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateTenantUserRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateTenantUserValidator _validator = new(); + private UpdateTenantUserRequest _model = new(); + private LookupTenantUserResponse _lookupData = new(); + private bool _processing = false; + private bool _isDataLoading = true; + + protected override async Task OnInitializedAsync() + { + _isDataLoading = true; + try + { + var response = await TenantUserService.GetTenantUserLookupAsync(); + if (response != null && response.IsSuccess) { _lookupData = response.Value!; } + _model = Data; + } + finally { _isDataLoading = false; } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await TenantUserService.UpdateTenantUserAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("User updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/Cqrs/CreateTenantUserHandler.cs b/Features/Multitenant/TenantUser/Cqrs/CreateTenantUserHandler.cs new file mode 100644 index 0000000..bd1e742 --- /dev/null +++ b/Features/Multitenant/TenantUser/Cqrs/CreateTenantUserHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Multitenant.TenantUser.Cqrs; + +public class CreateTenantUserRequest +{ + public string? TenantId { get; set; } + public string? UserId { get; set; } + public string? Summary { get; set; } + public bool IsActive { get; set; } = true; +} + +public class CreateTenantUserResponse +{ + public string? Id { get; set; } + public string? Summary { get; set; } +} + +public record CreateTenantUserCommand(CreateTenantUserRequest Data) : IRequest; + +public class CreateTenantUserHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateTenantUserHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateTenantUserCommand request, CancellationToken cancellationToken) + { + var entity = new Data.Entities.TenantUser + { + TenantId = request.Data.TenantId, + UserId = request.Data.UserId, + Summary = request.Data.Summary, + IsActive = request.Data.IsActive + }; + + _context.TenantUser.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateTenantUserResponse + { + Id = entity.Id, + Summary = entity.Summary + }; + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/Cqrs/CreateTenantUserValidator.cs b/Features/Multitenant/TenantUser/Cqrs/CreateTenantUserValidator.cs new file mode 100644 index 0000000..2412aac --- /dev/null +++ b/Features/Multitenant/TenantUser/Cqrs/CreateTenantUserValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Multitenant.TenantUser.Cqrs; + +public class CreateTenantUserValidator : AbstractValidator +{ + public CreateTenantUserValidator() + { + RuleFor(x => x.Summary) + .NotEmpty().WithMessage("Summary is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.TenantId) + .NotEmpty().WithMessage("Tenant is required"); + + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User is required"); + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/Cqrs/DeleteTenantUserHandler.cs b/Features/Multitenant/TenantUser/Cqrs/DeleteTenantUserHandler.cs new file mode 100644 index 0000000..7db7df9 --- /dev/null +++ b/Features/Multitenant/TenantUser/Cqrs/DeleteTenantUserHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Multitenant.TenantUser.Cqrs; + +public record DeleteTenantUserByIdRequest(string Id); + +public record DeleteTenantUserByIdCommand(DeleteTenantUserByIdRequest Data) : IRequest; + +public class DeleteTenantUserByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteTenantUserByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteTenantUserByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TenantUser + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.TenantUser.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/Cqrs/GetTenantUserByIdHandler.cs b/Features/Multitenant/TenantUser/Cqrs/GetTenantUserByIdHandler.cs new file mode 100644 index 0000000..ec55edb --- /dev/null +++ b/Features/Multitenant/TenantUser/Cqrs/GetTenantUserByIdHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Multitenant.TenantUser.Cqrs; + +public class GetTenantUserByIdResponse +{ + public string? Id { get; set; } + public string? TenantId { get; set; } + public string? UserId { get; set; } + public string? Summary { get; set; } + public bool IsActive { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetTenantUserByIdQuery(string Id) : IRequest; + +public class GetTenantUserByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetTenantUserByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetTenantUserByIdQuery request, CancellationToken cancellationToken) + { + return await _context.TenantUser + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetTenantUserByIdResponse + { + Id = x.Id, + TenantId = x.TenantId, + UserId = x.UserId, + Summary = x.Summary, + IsActive = x.IsActive, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/Cqrs/GetTenantUserListHandler.cs b/Features/Multitenant/TenantUser/Cqrs/GetTenantUserListHandler.cs new file mode 100644 index 0000000..aff9158 --- /dev/null +++ b/Features/Multitenant/TenantUser/Cqrs/GetTenantUserListHandler.cs @@ -0,0 +1,67 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Multitenant.TenantUser.Cqrs; + +public class GetTenantUserListResponse +{ + public string? Id { get; set; } + public string? UserId { get; set; } + public string? FullName { get; set; } + public string? Email { get; set; } + public string? Summary { get; set; } + public bool IsActive { get; set; } + public string? TenantName { get; set; } + public string? TenantId { get; set; } +} + +public record GetTenantUserListQuery() : IRequest>; + +public class GetTenantUserListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + private readonly UserManager _userManager; + + public GetTenantUserListHandler(AppDbContext context, UserManager userManager) + { + _context = context; + _userManager = userManager; + } + + public async Task> Handle(GetTenantUserListQuery request, CancellationToken cancellationToken) + { + var tenantUsers = await _context.TenantUser + .AsNoTracking() + .Include(x => x.Tenant) + .OrderBy(x => x.Summary) + .Select(x => new GetTenantUserListResponse + { + Id = x.Id, + UserId = x.UserId, + Summary = x.Summary, + IsActive = x.IsActive, + TenantName = x.Tenant != null ? x.Tenant.Name : string.Empty, + TenantId = x.TenantId + }) + .ToListAsync(cancellationToken); + + // Populate FullName and Email from ApplicationUser + foreach (var item in tenantUsers) + { + if (!string.IsNullOrEmpty(item.UserId)) + { + var user = await _userManager.FindByIdAsync(item.UserId); + if (user != null) + { + item.FullName = user.FullName; + item.Email = user.Email; + } + } + } + + return tenantUsers; + } +} diff --git a/Features/Multitenant/TenantUser/Cqrs/LookupTenantUserHandler.cs b/Features/Multitenant/TenantUser/Cqrs/LookupTenantUserHandler.cs new file mode 100644 index 0000000..6fd5c6a --- /dev/null +++ b/Features/Multitenant/TenantUser/Cqrs/LookupTenantUserHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Multitenant.TenantUser.Cqrs; + +public class LookupTenantUserResponse +{ + public List Tenants { get; set; } = new(); + public List SystemUsers { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record LookupTenantUserQuery() : IRequest; + +public class LookupTenantUserHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public LookupTenantUserHandler(AppDbContext context) => _context = context; + + public async Task Handle(LookupTenantUserQuery request, CancellationToken cancellationToken) + { + var result = new LookupTenantUserResponse(); + + result.Tenants = await _context.Tenant + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new LookupItem { Id = x.Id, Name = x.Name }) + .ToListAsync(cancellationToken); + + result.SystemUsers = await _context.Users + .AsNoTracking() + .OrderBy(x => x.FullName) + .Select(x => new LookupItem { Id = x.Id, Name = x.FullName }) + .ToListAsync(cancellationToken); + + return result; + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/Cqrs/UpdateTenantUserHandler.cs b/Features/Multitenant/TenantUser/Cqrs/UpdateTenantUserHandler.cs new file mode 100644 index 0000000..fe2ff75 --- /dev/null +++ b/Features/Multitenant/TenantUser/Cqrs/UpdateTenantUserHandler.cs @@ -0,0 +1,57 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Multitenant.TenantUser.Cqrs; + +public class UpdateTenantUserRequest +{ + public string? Id { get; set; } + public string? TenantId { get; set; } + public string? UserId { get; set; } + public string? Summary { get; set; } + public bool IsActive { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateTenantUserResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateTenantUserCommand(UpdateTenantUserRequest Data) : IRequest; + +public class UpdateTenantUserHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateTenantUserHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateTenantUserCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TenantUser + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateTenantUserResponse { Id = request.Data.Id, Success = false }; + } + + entity.TenantId = request.Data.TenantId; + entity.UserId = request.Data.UserId; + entity.Summary = request.Data.Summary; + entity.IsActive = request.Data.IsActive; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateTenantUserResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/Cqrs/UpdateTenantUserValidator.cs b/Features/Multitenant/TenantUser/Cqrs/UpdateTenantUserValidator.cs new file mode 100644 index 0000000..5c77f24 --- /dev/null +++ b/Features/Multitenant/TenantUser/Cqrs/UpdateTenantUserValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Multitenant.TenantUser.Cqrs; + +public class UpdateTenantUserValidator : AbstractValidator +{ + public UpdateTenantUserValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Summary) + .NotEmpty().WithMessage("Summary is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.TenantId) + .NotEmpty().WithMessage("Tenant is required"); + + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User is required"); + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/TenantUserEndpoint.cs b/Features/Multitenant/TenantUser/TenantUserEndpoint.cs new file mode 100644 index 0000000..573013e --- /dev/null +++ b/Features/Multitenant/TenantUser/TenantUserEndpoint.cs @@ -0,0 +1,74 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Multitenant.TenantUser.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Multitenant.TenantUser; + +public static class TenantUserEndpoint +{ + public static void MapTenantUserEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/tenant-user").WithTags("TenantUsers") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetTenantUserListQuery()); + return result.ToApiResponse("Tenant user list retrieved successfully"); + }) + .WithName("GetTenantUserList") + .WithTags("TenantUsers"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetTenantUserByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Tenant user detail retrieved successfully" + : $"Tenant user with ID {id} not found"); + }) + .WithName("GetTenantUserById") + .WithTags("TenantUsers"); + + group.MapGet("/lookup", async (IMediator mediator) => + { + var result = await mediator.Send(new LookupTenantUserQuery()); + return result.ToApiResponse("Lookup data retrieved successfully"); + }) + .WithName("GetTenantUserLookup") + .WithTags("TenantUsers"); + + group.MapPost("/", async (CreateTenantUserRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTenantUserCommand(request)); + return result.ToApiResponse("Tenant user has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateTenantUser") + .WithTags("TenantUsers"); + + group.MapPost("/update", async (UpdateTenantUserRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTenantUserCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The tenant user data could not be found."); + } + return result.ToApiResponse("Tenant user has been updated successfully"); + }) + .WithName("UpdateTenantUser") + .WithTags("TenantUsers"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTenantUserByIdCommand(new DeleteTenantUserByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The tenant user data could not be found."); + } + return true.ToApiResponse("Tenant user has been deleted successfully"); + }) + .WithName("DeleteTenantUserById") + .WithTags("TenantUsers"); + } +} \ No newline at end of file diff --git a/Features/Multitenant/TenantUser/TenantUserService.cs b/Features/Multitenant/TenantUser/TenantUserService.cs new file mode 100644 index 0000000..b960f80 --- /dev/null +++ b/Features/Multitenant/TenantUser/TenantUserService.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Multitenant.TenantUser.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Multitenant.TenantUser; + +public class TenantUserService : BaseService +{ + private readonly RestClient _client; + + public TenantUserService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetTenantUserListAsync() + { + var request = new RestRequest("api/tenant-user", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetTenantUserByIdAsync(string id) + { + var request = new RestRequest($"api/tenant-user/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetTenantUserLookupAsync() + { + var request = new RestRequest("api/tenant-user/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateTenantUserAsync(CreateTenantUserRequest data) + { + var request = new RestRequest("api/tenant-user", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTenantUserByIdAsync(string id) + { + var request = new RestRequest($"api/tenant-user/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateTenantUserAsync(UpdateTenantUserRequest data) + { + var request = new RestRequest("api/tenant-user/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/BranchEndpoint.cs b/Features/Organization/Branch/BranchEndpoint.cs new file mode 100644 index 0000000..1ef106e --- /dev/null +++ b/Features/Organization/Branch/BranchEndpoint.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Organization.Branch.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Organization.Branch; + +public static class BranchEndpoint +{ + public static void MapBranchEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/branch").WithTags("Branches") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetBranchListQuery()); + return result.ToApiResponse("Data branch retrieved successfully"); + }) + .WithName("GetBranchList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetBranchByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Branch detail retrieved successfully" + : $"Branch with ID {id} not found"); + }) + .WithName("GetBranchById"); + + group.MapPost("/", async (CreateBranchRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateBranchCommand(request)); + return result.ToApiResponse("Branch has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateBranch"); + + group.MapPost("/update", async (UpdateBranchRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateBranchCommand(request)); + if (!result.Success) return ((object?)null).ToApiResponse("Update failed."); + return result.ToApiResponse("Branch has been updated successfully"); + }) + .WithName("UpdateBranch"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteBranchByIdCommand(new DeleteBranchByIdRequest(id))); + if (!result) return ((object?)null).ToApiResponse("Delete failed."); + return true.ToApiResponse("Branch has been deleted successfully"); + }) + .WithName("DeleteBranchById"); + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/BranchService.cs b/Features/Organization/Branch/BranchService.cs new file mode 100644 index 0000000..e106896 --- /dev/null +++ b/Features/Organization/Branch/BranchService.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Organization.Branch.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Organization.Branch; + +public class BranchService : BaseService +{ + private readonly RestClient _client; + + public BranchService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetBranchListAsync() + { + var request = new RestRequest("api/branch", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetBranchByIdAsync(string id) + { + var request = new RestRequest($"api/branch/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateBranchAsync(CreateBranchRequest data) + { + var request = new RestRequest("api/branch", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteBranchByIdAsync(string id) + { + var request = new RestRequest($"api/branch/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateBranchAsync(UpdateBranchRequest data) + { + var request = new RestRequest("api/branch/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/Components/BranchPage.razor b/Features/Organization/Branch/Components/BranchPage.razor new file mode 100644 index 0000000..8ebdd39 --- /dev/null +++ b/Features/Organization/Branch/Components/BranchPage.razor @@ -0,0 +1,52 @@ +@page "/organization/branch" +@using Indotalent.Features.Organization.Branch +@using Indotalent.Features.Organization.Branch.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_BranchCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_BranchUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_BranchDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateBranchRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateBranchRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/Components/_BranchCreateForm.razor b/Features/Organization/Branch/Components/_BranchCreateForm.razor new file mode 100644 index 0000000..c43a1ed --- /dev/null +++ b/Features/Organization/Branch/Components/_BranchCreateForm.razor @@ -0,0 +1,147 @@ +@using Indotalent.Features.Organization.Branch +@using Indotalent.Features.Organization.Branch.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject BranchService BranchService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Branch + Configure new office or warehouse location. +
+
+
+ + + + + + Branch Code + + + + Branch Name + + + + City + + + + Street Address + + + + State/Province + + + + ZIP Code + + + + Phone + + + + Email + + + + Description + + + + Other Information 1 + + + + Other Information 2 + + + + Other Information 3 + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Branch + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateBranchValidator _validator = new(); + private CreateBranchRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await BranchService.CreateBranchAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Branch created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/Components/_BranchDataTable.razor b/Features/Organization/Branch/Components/_BranchDataTable.razor new file mode 100644 index 0000000..69a6baa --- /dev/null +++ b/Features/Organization/Branch/Components/_BranchDataTable.razor @@ -0,0 +1,402 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Organization.Branch +@using Indotalent.Features.Organization.Branch.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject BranchService BranchService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Branch Management + Manage physical office locations, regional branches, and site operational details. +
+ +
+ + / + Organization + / + Branch +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedBranch != null) + { + View + Edit + Remove + + } + else + { + + Add New Branch + + } +
+
+ + + + + + Branch ID + + + Branch Name + + + City / Region + + + Full Address + + + Contact Number + + + + + + + + + @context.Code + + + +
+ @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + @context.Name +
+
+ + @context.City + + + @context.StreetAddress + + + @context.Phone + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _branches = new(); + private GetBranchListResponse? _selectedBranch; + private string _searchString = ""; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; _selectedBranch = null; + StateHasChanged(); + try + { + var response = await BranchService.GetBranchListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _branches = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _branches; + return _branches.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Code?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.City?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.StreetAddress?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Branches"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Branch Code"; + worksheet.Cell(currentRow, 2).Value = "Branch Name"; + worksheet.Cell(currentRow, 3).Value = "City"; + worksheet.Cell(currentRow, 4).Value = "Full Address"; + worksheet.Cell(currentRow, 5).Value = "Phone"; + + var headerRange = worksheet.Range(1, 1, 1, 5); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Code; + worksheet.Cell(currentRow, 2).Value = item.Name; + worksheet.Cell(currentRow, 3).Value = item.City; + worksheet.Cell(currentRow, 4).Value = item.StreetAddress; + worksheet.Cell(currentRow, 5).Value = item.Phone; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Branch_Registry.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnSearchClick() + { + _skip = 0; + _selectedBranch = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedBranch = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedBranch = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedBranch == null) return; + var res = await BranchService.GetBranchByIdAsync(_selectedBranch.Id); + if (res?.Value != null) await OnEdit.InvokeAsync(MapToUpdate(res.Value)); + } + + private async Task InvokeView() + { + if (_selectedBranch == null) return; + var res = await BranchService.GetBranchByIdAsync(_selectedBranch.Id); + if (res?.Value != null) await OnView.InvokeAsync(MapToUpdate(res.Value)); + } + + private UpdateBranchRequest MapToUpdate(GetBranchByIdResponse d) => new UpdateBranchRequest + { + Id = d.Id, + Code = d.Code, + Name = d.Name, + Description = d.Description, + StreetAddress = d.StreetAddress, + City = d.City, + StateProvince = d.StateProvince, + ZipCode = d.ZipCode, + Phone = d.Phone, + Email = d.Email, + OtherInformation1 = d.OtherInformation1, + OtherInformation2 = d.OtherInformation2, + OtherInformation3 = d.OtherInformation3, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + + private async Task OnDelete() + { + if (_selectedBranch == null) return; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedBranch.Name } }); + if (!(await dialog.Result).Canceled) + { + if (await BranchService.DeleteBranchByIdAsync(_selectedBranch.Id)) + { + await LoadData(); Snackbar.Add("Branch deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/Components/_BranchUpdateForm.razor b/Features/Organization/Branch/Components/_BranchUpdateForm.razor new file mode 100644 index 0000000..1b047e4 --- /dev/null +++ b/Features/Organization/Branch/Components/_BranchUpdateForm.razor @@ -0,0 +1,198 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Organization.Branch +@using Indotalent.Features.Organization.Branch.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject BranchService BranchService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Branch Details" : "Edit Branch") + @(ReadOnly ? "Viewing office location profile." : "Modify existing branch information.") +
+
+
+ + + + + + Branch Code + + + + Branch Name + + + + City + + + + Street Address + + + + Phone + + + + Email + + + + Description + + + + Other Information 1 + + + + Other Information 2 + + + + Other Information 3 + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateBranchRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateBranchValidator _validator = new(); + private UpdateBranchRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateBranchRequest + { + Id = Data.Id, + Code = Data.Code, + Name = Data.Name, + Description = Data.Description, + StreetAddress = Data.StreetAddress, + City = Data.City, + StateProvince = Data.StateProvince, + ZipCode = Data.ZipCode, + Phone = Data.Phone, + Email = Data.Email, + OtherInformation1 = Data.OtherInformation1, + OtherInformation2 = Data.OtherInformation2, + OtherInformation3 = Data.OtherInformation3, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await BranchService.UpdateBranchAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Branch updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/Cqrs/CreateBranchHandler.cs b/Features/Organization/Branch/Cqrs/CreateBranchHandler.cs new file mode 100644 index 0000000..457f311 --- /dev/null +++ b/Features/Organization/Branch/Cqrs/CreateBranchHandler.cs @@ -0,0 +1,83 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Branch.Cqrs; + +public class CreateBranchRequest +{ + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string StreetAddress { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string StateProvince { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; + public string Phone { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; +} + +public class CreateBranchResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateBranchCommand(CreateBranchRequest Data) : IRequest; + +public class CreateBranchHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateBranchHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateBranchCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Branch + .AnyAsync(x => x.Code == request.Data.Code, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Branch", request.Data.Code ?? string.Empty); + } + + var entityName = nameof(Data.Entities.Branch); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Branch + { + AutoNumber = autoNo, + Code = request.Data.Code, + Name = request.Data.Name, + Description = request.Data.Description, + StreetAddress = request.Data.StreetAddress, + City = request.Data.City, + StateProvince = request.Data.StateProvince, + ZipCode = request.Data.ZipCode, + Phone = request.Data.Phone, + Email = request.Data.Email, + OtherInformation1 = request.Data.OtherInformation1, + OtherInformation2 = request.Data.OtherInformation2, + OtherInformation3 = request.Data.OtherInformation3 + }; + + _context.Branch.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateBranchResponse + { + Id = entity.Id, + Code = entity.Code + }; + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/Cqrs/CreateBranchValidator.cs b/Features/Organization/Branch/Cqrs/CreateBranchValidator.cs new file mode 100644 index 0000000..6316a7c --- /dev/null +++ b/Features/Organization/Branch/Cqrs/CreateBranchValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Organization.Branch.Cqrs; + +public class CreateBranchValidator : AbstractValidator +{ + public CreateBranchValidator() + { + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Branch Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Branch Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.City) + .NotEmpty().WithMessage("City is required"); + + RuleFor(x => x.StreetAddress) + .NotEmpty().WithMessage("Street Address is required"); + + RuleFor(x => x.Email) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.Email)); + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/Cqrs/DeleteBranchByIdHandler.cs b/Features/Organization/Branch/Cqrs/DeleteBranchByIdHandler.cs new file mode 100644 index 0000000..f116710 --- /dev/null +++ b/Features/Organization/Branch/Cqrs/DeleteBranchByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Branch.Cqrs; + +public record DeleteBranchByIdRequest(string Id); + +public record DeleteBranchByIdCommand(DeleteBranchByIdRequest Data) : IRequest; + +public class DeleteBranchByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteBranchByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteBranchByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Branch + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Branch.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/Cqrs/GetBranchByIdHandler.cs b/Features/Organization/Branch/Cqrs/GetBranchByIdHandler.cs new file mode 100644 index 0000000..afa3953 --- /dev/null +++ b/Features/Organization/Branch/Cqrs/GetBranchByIdHandler.cs @@ -0,0 +1,65 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Branch.Cqrs; + +public class GetBranchByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? StreetAddress { get; set; } + public string? City { get; set; } + public string? StateProvince { get; set; } + public string? ZipCode { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? OtherInformation1 { get; set; } + public string? OtherInformation2 { get; set; } + public string? OtherInformation3 { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetBranchByIdQuery(string Id) : IRequest; + +public class GetBranchByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetBranchByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetBranchByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Branch + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetBranchByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Code = x.Code, + Name = x.Name, + Description = x.Description, + StreetAddress = x.StreetAddress, + City = x.City, + StateProvince = x.StateProvince, + ZipCode = x.ZipCode, + Phone = x.Phone, + Email = x.Email, + OtherInformation1 = x.OtherInformation1, + OtherInformation2 = x.OtherInformation2, + OtherInformation3 = x.OtherInformation3, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/Cqrs/GetBranchListHandler.cs b/Features/Organization/Branch/Cqrs/GetBranchListHandler.cs new file mode 100644 index 0000000..22e3f1b --- /dev/null +++ b/Features/Organization/Branch/Cqrs/GetBranchListHandler.cs @@ -0,0 +1,49 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Branch.Cqrs; + +public class GetBranchListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? City { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? StreetAddress { get; set; } + public string? Description { get; set; } +} + +public record GetBranchListQuery() : IRequest>; + +public class GetBranchListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetBranchListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetBranchListQuery request, CancellationToken cancellationToken) + { + return await _context.Branch + .AsNoTracking() + .NotDeletedOnly() + .OrderBy(x => x.Code) + .Select(x => new GetBranchListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Code = x.Code, + Name = x.Name, + City = x.City, + Phone = x.Phone, + Email = x.Email, + StreetAddress = x.StreetAddress, + Description = x.Description, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/Cqrs/UpdateBranchHandler.cs b/Features/Organization/Branch/Cqrs/UpdateBranchHandler.cs new file mode 100644 index 0000000..a59ebd2 --- /dev/null +++ b/Features/Organization/Branch/Cqrs/UpdateBranchHandler.cs @@ -0,0 +1,70 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Branch.Cqrs; + +public class UpdateBranchRequest : CreateBranchRequest +{ + public string? Id { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateBranchResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateBranchCommand(UpdateBranchRequest Data) : IRequest; + +public class UpdateBranchHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateBranchHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateBranchCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Branch + .AnyAsync(x => x.Code == request.Data.Code && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Branch", request.Data.Code ?? string.Empty); + } + + var entity = await _context.Branch + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateBranchResponse { Id = request.Data.Id, Success = false }; + } + + entity.Code = request.Data.Code; + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.StreetAddress = request.Data.StreetAddress; + entity.City = request.Data.City; + entity.StateProvince = request.Data.StateProvince; + entity.ZipCode = request.Data.ZipCode; + entity.Phone = request.Data.Phone; + entity.Email = request.Data.Email; + entity.OtherInformation1 = request.Data.OtherInformation1; + entity.OtherInformation2 = request.Data.OtherInformation2; + entity.OtherInformation3 = request.Data.OtherInformation3; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateBranchResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Organization/Branch/Cqrs/UpdateBranchValidator.cs b/Features/Organization/Branch/Cqrs/UpdateBranchValidator.cs new file mode 100644 index 0000000..a5135f0 --- /dev/null +++ b/Features/Organization/Branch/Cqrs/UpdateBranchValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Organization.Branch.Cqrs; + +public class UpdateBranchValidator : AbstractValidator +{ + public UpdateBranchValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Branch Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Branch Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.City) + .NotEmpty().WithMessage("City is required"); + + RuleFor(x => x.StreetAddress) + .NotEmpty().WithMessage("Street Address is required"); + + RuleFor(x => x.Email) + .EmailAddress().When(x => !string.IsNullOrEmpty(x.Email)); + } +} \ No newline at end of file diff --git a/Features/Organization/Department/Components/DepartmentPage.razor b/Features/Organization/Department/Components/DepartmentPage.razor new file mode 100644 index 0000000..4fc30ec --- /dev/null +++ b/Features/Organization/Department/Components/DepartmentPage.razor @@ -0,0 +1,23 @@ +@page "/organization/department" +@using Indotalent.Features.Organization.Department +@using Indotalent.Features.Organization.Department.Cqrs +@using MudBlazor + +@if (_view == View.Create) +{ + <_DepartmentCreateForm OnCancel="() => _view = View.Table" OnSuccess="() => _view = View.Table" /> +} +else if (_view == View.Update || _view == View.Detail) +{ + <_DepartmentUpdateForm Data="_data!" ReadOnly="@(_view == View.Detail)" OnCancel="() => _view = View.Table" OnSuccess="() => _view = View.Table" /> +} +else +{ + <_DepartmentDataTable OnAdd="() => _view = View.Create" OnEdit="d => { _data = d; _view = View.Update; }" OnView="d => { _data = d; _view = View.Detail; }" /> +} + +@code { + private enum View { Table, Create, Update, Detail } + private View _view = View.Table; + private UpdateDepartmentRequest? _data; +} \ No newline at end of file diff --git a/Features/Organization/Department/Components/_DepartmentCreateForm.razor b/Features/Organization/Department/Components/_DepartmentCreateForm.razor new file mode 100644 index 0000000..7b7d777 --- /dev/null +++ b/Features/Organization/Department/Components/_DepartmentCreateForm.razor @@ -0,0 +1,93 @@ +@using Indotalent.Features.Organization.Department +@using Indotalent.Features.Organization.Department.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject DepartmentService DepartmentService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Department + Create a new department and assign a cost center. +
+
+
+ + + + + + Cost Center + + + + Department Name + + + + Head of Department + + + + Description + + + + Other Information 1 + + + + Other Information 2 + + + + Other Information 3 + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Department + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateDepartmentValidator _validator = new(); + private CreateDepartmentRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await DepartmentService.CreateDepartmentAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Department created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Organization/Department/Components/_DepartmentDataTable.razor b/Features/Organization/Department/Components/_DepartmentDataTable.razor new file mode 100644 index 0000000..eb08f9b --- /dev/null +++ b/Features/Organization/Department/Components/_DepartmentDataTable.razor @@ -0,0 +1,389 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Organization.Department +@using Indotalent.Features.Organization.Department.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject DepartmentService DepartmentService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Department Management + Organize your company structure by managing departments and cost centers. +
+ +
+ + / + Organization + / + Department +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedDept != null) + { + View + Edit + Remove + + } + else + { + + Add New Department + + } +
+
+ + + + + + Cost Center + + + Department Name + + + Head of Dept + + + Description + + + + + + + + + @context.CostCenter + + + +
+ @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + @context.Name +
+
+ + @context.HeadOfDeptartment + + + @context.Description + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _departments = new(); + private GetDepartmentListResponse? _selectedDept; + private string _searchString = ""; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; _selectedDept = null; + StateHasChanged(); + try + { + var response = await DepartmentService.GetDepartmentListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _departments = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _departments; + return _departments.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CostCenter?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.HeadOfDeptartment?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Departments"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Cost Center"; + worksheet.Cell(currentRow, 2).Value = "Department Name"; + worksheet.Cell(currentRow, 3).Value = "Head of Dept"; + worksheet.Cell(currentRow, 4).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 4); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.CostCenter; + worksheet.Cell(currentRow, 2).Value = item.Name; + worksheet.Cell(currentRow, 3).Value = item.HeadOfDeptartment; + worksheet.Cell(currentRow, 4).Value = item.Description; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Department_Registry.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnSearchClick() + { + _skip = 0; + _selectedDept = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedDept = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedDept = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedDept == null) return; + var res = await DepartmentService.GetDepartmentByIdAsync(_selectedDept.Id); + if (res?.Value != null) await OnEdit.InvokeAsync(Map(res.Value)); + } + + private async Task InvokeView() + { + if (_selectedDept == null) return; + var res = await DepartmentService.GetDepartmentByIdAsync(_selectedDept.Id); + if (res?.Value != null) await OnView.InvokeAsync(Map(res.Value)); + } + + private UpdateDepartmentRequest Map(GetDepartmentByIdResponse d) => new UpdateDepartmentRequest + { + Id = d.Id, + CostCenter = d.CostCenter, + Name = d.Name, + Description = d.Description, + HeadOfDeptartment = d.HeadOfDeptartment, + OtherInformation1 = d.OtherInformation1, + OtherInformation2 = d.OtherInformation2, + OtherInformation3 = d.OtherInformation3, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + + private async Task OnDelete() + { + if (_selectedDept == null) return; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedDept.Name } }); + if (!(await dialog.Result).Canceled) + { + if (await DepartmentService.DeleteDepartmentByIdAsync(_selectedDept.Id)) + { + await LoadData(); Snackbar.Add("Department deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Organization/Department/Components/_DepartmentUpdateForm.razor b/Features/Organization/Department/Components/_DepartmentUpdateForm.razor new file mode 100644 index 0000000..33bfc63 --- /dev/null +++ b/Features/Organization/Department/Components/_DepartmentUpdateForm.razor @@ -0,0 +1,132 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Organization.Department +@using Indotalent.Features.Organization.Department.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject DepartmentService DepartmentService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Department Details" : "Edit Department") + @(ReadOnly ? "Viewing department profile." : "Modify department information.") +
+
+
+ + + + + + Cost Center + + + + Department Name + + + + Head of Department + + + + Description + + + + Other Information 1 + + + + Other Information 2 + + + + Other Information 3 + + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateDepartmentRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateDepartmentValidator _validator = new(); + private UpdateDepartmentRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() => _model = new UpdateDepartmentRequest + { + Id = Data.Id, + CostCenter = Data.CostCenter, + Name = Data.Name, + Description = Data.Description, + HeadOfDeptartment = Data.HeadOfDeptartment, + OtherInformation1 = Data.OtherInformation1, + OtherInformation2 = Data.OtherInformation2, + OtherInformation3 = Data.OtherInformation3, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await DepartmentService.UpdateDepartmentAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Organization/Department/Cqrs/CreateDepartmentHandler.cs b/Features/Organization/Department/Cqrs/CreateDepartmentHandler.cs new file mode 100644 index 0000000..492ff77 --- /dev/null +++ b/Features/Organization/Department/Cqrs/CreateDepartmentHandler.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Department.Cqrs; + +public class CreateDepartmentRequest +{ + public string? CostCenter { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string HeadOfDeptartment { get; set; } = string.Empty; + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; +} + +public class CreateDepartmentResponse +{ + public string? Id { get; set; } + public string? CostCenter { get; set; } +} + +public record CreateDepartmentCommand(CreateDepartmentRequest Data) : IRequest; + +public class CreateDepartmentHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateDepartmentHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateDepartmentCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Department + .AnyAsync(x => x.CostCenter == request.Data.CostCenter, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Department", request.Data.CostCenter ?? string.Empty); + } + + var entityName = nameof(Data.Entities.Department); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Department + { + AutoNumber = autoNo, + CostCenter = request.Data.CostCenter, + Name = request.Data.Name, + Description = request.Data.Description, + HeadOfDeptartment = request.Data.HeadOfDeptartment, + OtherInformation1 = request.Data.OtherInformation1, + OtherInformation2 = request.Data.OtherInformation2, + OtherInformation3 = request.Data.OtherInformation3 + }; + + _context.Department.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateDepartmentResponse + { + Id = entity.Id, + CostCenter = entity.CostCenter + }; + } +} \ No newline at end of file diff --git a/Features/Organization/Department/Cqrs/CreateDepartmentValidator.cs b/Features/Organization/Department/Cqrs/CreateDepartmentValidator.cs new file mode 100644 index 0000000..c2242f5 --- /dev/null +++ b/Features/Organization/Department/Cqrs/CreateDepartmentValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Organization.Department.Cqrs; + +public class CreateDepartmentValidator : AbstractValidator +{ + public CreateDepartmentValidator() + { + RuleFor(x => x.CostCenter) + .NotEmpty().WithMessage("Cost Center is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Department Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.HeadOfDeptartment) + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Organization/Department/Cqrs/DeleteDepartmentByIdHandler.cs b/Features/Organization/Department/Cqrs/DeleteDepartmentByIdHandler.cs new file mode 100644 index 0000000..37b9bbc --- /dev/null +++ b/Features/Organization/Department/Cqrs/DeleteDepartmentByIdHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Department.Cqrs; + +public record DeleteDepartmentByIdRequest(string Id); +public record DeleteDepartmentByIdCommand(DeleteDepartmentByIdRequest Data) : IRequest; + +public class DeleteDepartmentByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteDepartmentByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteDepartmentByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Department + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Department.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Organization/Department/Cqrs/GetDepartmentByIdHandler.cs b/Features/Organization/Department/Cqrs/GetDepartmentByIdHandler.cs new file mode 100644 index 0000000..e267277 --- /dev/null +++ b/Features/Organization/Department/Cqrs/GetDepartmentByIdHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Department.Cqrs; + +public class GetDepartmentByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? CostCenter { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? HeadOfDeptartment { get; set; } + public string? OtherInformation1 { get; set; } + public string? OtherInformation2 { get; set; } + public string? OtherInformation3 { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetDepartmentByIdQuery(string Id) : IRequest; + +public class GetDepartmentByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetDepartmentByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetDepartmentByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Department + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetDepartmentByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + CostCenter = x.CostCenter, + Name = x.Name, + Description = x.Description, + HeadOfDeptartment = x.HeadOfDeptartment, + OtherInformation1 = x.OtherInformation1, + OtherInformation2 = x.OtherInformation2, + OtherInformation3 = x.OtherInformation3, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Organization/Department/Cqrs/GetDepartmentListHandler.cs b/Features/Organization/Department/Cqrs/GetDepartmentListHandler.cs new file mode 100644 index 0000000..3622468 --- /dev/null +++ b/Features/Organization/Department/Cqrs/GetDepartmentListHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Department.Cqrs; + +public class GetDepartmentListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? CostCenter { get; set; } + public string? Name { get; set; } + public string? HeadOfDeptartment { get; set; } + public string? Description { get; set; } +} + +public record GetDepartmentListQuery() : IRequest>; + +public class GetDepartmentListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetDepartmentListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetDepartmentListQuery request, CancellationToken cancellationToken) + { + return await _context.Department + .AsNoTracking() + .NotDeletedOnly() + .OrderBy(x => x.CostCenter) + .Select(x => new GetDepartmentListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + CostCenter = x.CostCenter, + Name = x.Name, + HeadOfDeptartment = x.HeadOfDeptartment, + Description = x.Description, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Organization/Department/Cqrs/UpdateDepartmentHandler.cs b/Features/Organization/Department/Cqrs/UpdateDepartmentHandler.cs new file mode 100644 index 0000000..00a3385 --- /dev/null +++ b/Features/Organization/Department/Cqrs/UpdateDepartmentHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Department.Cqrs; + +public class UpdateDepartmentRequest : CreateDepartmentRequest +{ + public string? Id { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateDepartmentResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateDepartmentCommand(UpdateDepartmentRequest Data) : IRequest; + +public class UpdateDepartmentHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateDepartmentHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateDepartmentCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Department + .AnyAsync(x => x.CostCenter == request.Data.CostCenter && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Department", request.Data.CostCenter ?? string.Empty); + } + + var entity = await _context.Department + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateDepartmentResponse { Id = request.Data.Id, Success = false }; + } + + entity.CostCenter = request.Data.CostCenter; + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.HeadOfDeptartment = request.Data.HeadOfDeptartment; + entity.OtherInformation1 = request.Data.OtherInformation1; + entity.OtherInformation2 = request.Data.OtherInformation2; + entity.OtherInformation3 = request.Data.OtherInformation3; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateDepartmentResponse { Id = entity.Id, Success = true }; + } +} \ No newline at end of file diff --git a/Features/Organization/Department/Cqrs/UpdateDepartmentValidator.cs b/Features/Organization/Department/Cqrs/UpdateDepartmentValidator.cs new file mode 100644 index 0000000..afbc7cd --- /dev/null +++ b/Features/Organization/Department/Cqrs/UpdateDepartmentValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Organization.Department.Cqrs; + +public class UpdateDepartmentValidator : AbstractValidator +{ + public UpdateDepartmentValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required"); + RuleFor(x => x.CostCenter).NotEmpty().MaximumLength(GlobalConsts.StringLengthShort); + RuleFor(x => x.Name).NotEmpty().MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Organization/Department/DepartmentEndpoint.cs b/Features/Organization/Department/DepartmentEndpoint.cs new file mode 100644 index 0000000..1de0adc --- /dev/null +++ b/Features/Organization/Department/DepartmentEndpoint.cs @@ -0,0 +1,30 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Organization.Department.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Organization.Department; + +public static class DepartmentEndpoint +{ + public static void MapDepartmentEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/department").WithTags("Departments") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme).RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + (await mediator.Send(new GetDepartmentListQuery())).ToApiResponse("Data retrieved")); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + (await mediator.Send(new GetDepartmentByIdQuery(id))).ToApiResponse("Detail retrieved")); + + group.MapPost("/", async (CreateDepartmentRequest request, IMediator mediator) => + (await mediator.Send(new CreateDepartmentCommand(request))).ToApiResponse("Created", StatusCodes.Status201Created)); + + group.MapPost("/update", async (UpdateDepartmentRequest request, IMediator mediator) => + (await mediator.Send(new UpdateDepartmentCommand(request))).ToApiResponse("Updated")); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + (await mediator.Send(new DeleteDepartmentByIdCommand(new DeleteDepartmentByIdRequest(id)))).ToApiResponse("Deleted")); + } +} \ No newline at end of file diff --git a/Features/Organization/Department/DepartmentService.cs b/Features/Organization/Department/DepartmentService.cs new file mode 100644 index 0000000..5580bef --- /dev/null +++ b/Features/Organization/Department/DepartmentService.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Organization.Department.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Organization.Department; + +public class DepartmentService : BaseService +{ + private readonly RestClient _client; + + public DepartmentService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetDepartmentListAsync() + { + var request = new RestRequest("api/department", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetDepartmentByIdAsync(string id) + { + var request = new RestRequest($"api/department/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateDepartmentAsync(CreateDepartmentRequest data) + { + var request = new RestRequest("api/department", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateDepartmentAsync(UpdateDepartmentRequest data) + { + var request = new RestRequest("api/department/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteDepartmentByIdAsync(string id) + { + var request = new RestRequest($"api/department/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/Components/DesignationPage.razor b/Features/Organization/Designation/Components/DesignationPage.razor new file mode 100644 index 0000000..91d5a3d --- /dev/null +++ b/Features/Organization/Designation/Components/DesignationPage.razor @@ -0,0 +1,25 @@ +@page "/organization/designation" +@using Indotalent.Features.Organization.Designation +@using Indotalent.Features.Organization.Designation.Cqrs +@using MudBlazor + +@if (_view == View.Create) +{ + <_DesignationCreateForm OnCancel="() => _view = View.Table" OnSuccess="() => _view = View.Table" /> +} +else if (_view == View.Update || _view == View.Detail) +{ + <_DesignationUpdateForm Data="_data!" ReadOnly="@(_view == View.Detail)" OnCancel="() => _view = View.Table" OnSuccess="() => _view = View.Table" /> +} +else +{ + <_DesignationDataTable OnAdd="() => _view = View.Create" + OnEdit="d => { _data = d; _view = View.Update; }" + OnView="d => { _data = d; _view = View.Detail; }" /> +} + +@code { + private enum View { Table, Create, Update, Detail } + private View _view = View.Table; + private UpdateDesignationRequest? _data; +} \ No newline at end of file diff --git a/Features/Organization/Designation/Components/_DesignationCreateForm.razor b/Features/Organization/Designation/Components/_DesignationCreateForm.razor new file mode 100644 index 0000000..3e71b48 --- /dev/null +++ b/Features/Organization/Designation/Components/_DesignationCreateForm.razor @@ -0,0 +1,93 @@ +@using Indotalent.Features.Organization.Designation +@using Indotalent.Features.Organization.Designation.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject DesignationService DesignationService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Designation + Create a new job title and career level. +
+
+
+ + + + + + Designation Code + + + + Designation Name + + + + Career Level + + + + Description + + + + Other Information 1 + + + + Other Information 2 + + + + Other Information 3 + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Designation + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private CreateDesignationValidator _validator = new(); + private CreateDesignationRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await DesignationService.CreateDesignationAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Designation created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/Components/_DesignationDataTable.razor b/Features/Organization/Designation/Components/_DesignationDataTable.razor new file mode 100644 index 0000000..3e64250 --- /dev/null +++ b/Features/Organization/Designation/Components/_DesignationDataTable.razor @@ -0,0 +1,389 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Organization.Designation +@using Indotalent.Features.Organization.Designation.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject DesignationService DesignationService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Designation Management + Manage job titles, hierarchies, and official designations across the organization. +
+ +
+ + / + Organization + / + Designation +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedDesig != null) + { + View + Edit + Remove + + } + else + { + + Add New Designation + + } +
+
+ + + + + + Code + + + Designation Name + + + Level + + + Job Description Brief + + + + + + + + + @context.Code + + + +
+ @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + @context.Name +
+
+ + @context.Level + + + @context.Description + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _designations = new(); + private GetDesignationListResponse? _selectedDesig; + private string _searchString = ""; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; _selectedDesig = null; + StateHasChanged(); + try + { + var response = await DesignationService.GetDesignationListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _designations = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _designations; + return _designations.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Code?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Level?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Description?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Designations"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Code"; + worksheet.Cell(currentRow, 2).Value = "Designation Name"; + worksheet.Cell(currentRow, 3).Value = "Level"; + worksheet.Cell(currentRow, 4).Value = "Description"; + + var headerRange = worksheet.Range(1, 1, 1, 4); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Code; + worksheet.Cell(currentRow, 2).Value = item.Name; + worksheet.Cell(currentRow, 3).Value = item.Level; + worksheet.Cell(currentRow, 4).Value = item.Description; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Designation_Registry.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnSearchClick() + { + _skip = 0; + _selectedDesig = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedDesig = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedDesig = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedDesig == null) return; + var res = await DesignationService.GetDesignationByIdAsync(_selectedDesig.Id); + if (res?.Value != null) await OnEdit.InvokeAsync(Map(res.Value)); + } + + private async Task InvokeView() + { + if (_selectedDesig == null) return; + var res = await DesignationService.GetDesignationByIdAsync(_selectedDesig.Id); + if (res?.Value != null) await OnView.InvokeAsync(Map(res.Value)); + } + + private UpdateDesignationRequest Map(GetDesignationByIdResponse d) => new UpdateDesignationRequest + { + Id = d.Id, + Code = d.Code, + Name = d.Name, + Description = d.Description, + Level = d.Level, + OtherInformation1 = d.OtherInformation1 ?? string.Empty, + OtherInformation2 = d.OtherInformation2 ?? string.Empty, + OtherInformation3 = d.OtherInformation3 ?? string.Empty, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + + private async Task OnDelete() + { + if (_selectedDesig == null) return; + var diag = await DialogService.ShowAsync<_DeleteConfirmation>("", new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedDesig.Name } }); + if (!(await diag.Result).Canceled) + { + if (await DesignationService.DeleteDesignationByIdAsync(_selectedDesig.Id)) + { + await LoadData(); Snackbar.Add("Designation deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/Components/_DesignationUpdateForm.razor b/Features/Organization/Designation/Components/_DesignationUpdateForm.razor new file mode 100644 index 0000000..a57e554 --- /dev/null +++ b/Features/Organization/Designation/Components/_DesignationUpdateForm.razor @@ -0,0 +1,132 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Organization.Designation +@using Indotalent.Features.Organization.Designation.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject DesignationService DesignationService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Designation Details" : "Edit Designation") + @(ReadOnly ? "Viewing designation profile." : "Modify existing designation information.") +
+
+
+ + + + + + Designation Code + + + + Designation Name + + + + Career Level + + + + Description + + + + Other Information 1 + + + + Other Information 2 + + + + Other Information 3 + + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateDesignationRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + private MudForm _form = default!; + private UpdateDesignationValidator _validator = new(); + private UpdateDesignationRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() => _model = new UpdateDesignationRequest + { + Id = Data.Id, + Code = Data.Code, + Name = Data.Name, + Description = Data.Description, + Level = Data.Level, + OtherInformation1 = Data.OtherInformation1, + OtherInformation2 = Data.OtherInformation2, + OtherInformation3 = Data.OtherInformation3, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await DesignationService.UpdateDesignationAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) { Snackbar.Add("Updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/Cqrs/CreateDesignationHandler.cs b/Features/Organization/Designation/Cqrs/CreateDesignationHandler.cs new file mode 100644 index 0000000..8c02d0d --- /dev/null +++ b/Features/Organization/Designation/Cqrs/CreateDesignationHandler.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Designation.Cqrs; + +public class CreateDesignationRequest +{ + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Level { get; set; } + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; +} + +public class CreateDesignationResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateDesignationCommand(CreateDesignationRequest Data) : IRequest; + +public class CreateDesignationHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateDesignationHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateDesignationCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Designation + .AnyAsync(x => x.Code == request.Data.Code, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Designation", request.Data.Code ?? string.Empty); + } + + var entityName = nameof(Data.Entities.Designation); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Designation + { + AutoNumber = autoNo, + Code = request.Data.Code, + Name = request.Data.Name, + Description = request.Data.Description, + Level = request.Data.Level, + OtherInformation1 = request.Data.OtherInformation1, + OtherInformation2 = request.Data.OtherInformation2, + OtherInformation3 = request.Data.OtherInformation3 + }; + + _context.Designation.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateDesignationResponse + { + Id = entity.Id, + Code = entity.Code + }; + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/Cqrs/CreateDesignationValidator.cs b/Features/Organization/Designation/Cqrs/CreateDesignationValidator.cs new file mode 100644 index 0000000..13ba7f4 --- /dev/null +++ b/Features/Organization/Designation/Cqrs/CreateDesignationValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Organization.Designation.Cqrs; + +public class CreateDesignationValidator : AbstractValidator +{ + public CreateDesignationValidator() + { + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Designation Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Designation Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Level) + .NotEmpty().WithMessage("Level is required"); + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/Cqrs/DeleteDesignationByIdHandler.cs b/Features/Organization/Designation/Cqrs/DeleteDesignationByIdHandler.cs new file mode 100644 index 0000000..e085792 --- /dev/null +++ b/Features/Organization/Designation/Cqrs/DeleteDesignationByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Designation.Cqrs; + +public record DeleteDesignationByIdRequest(string Id); + +public record DeleteDesignationByIdCommand(DeleteDesignationByIdRequest Data) : IRequest; + +public class DeleteDesignationByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteDesignationByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteDesignationByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Designation + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Designation.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/Cqrs/GetDesignationByIdHandler.cs b/Features/Organization/Designation/Cqrs/GetDesignationByIdHandler.cs new file mode 100644 index 0000000..037b6ee --- /dev/null +++ b/Features/Organization/Designation/Cqrs/GetDesignationByIdHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Designation.Cqrs; + +public class GetDesignationByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Level { get; set; } + public string? OtherInformation1 { get; set; } + public string? OtherInformation2 { get; set; } + public string? OtherInformation3 { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetDesignationByIdQuery(string Id) : IRequest; + +public class GetDesignationByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetDesignationByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetDesignationByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Designation + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetDesignationByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Code = x.Code, + Name = x.Name, + Description = x.Description, + Level = x.Level, + OtherInformation1 = x.OtherInformation1, + OtherInformation2 = x.OtherInformation2, + OtherInformation3 = x.OtherInformation3, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/Cqrs/GetDesignationListHandler.cs b/Features/Organization/Designation/Cqrs/GetDesignationListHandler.cs new file mode 100644 index 0000000..c51f0cc --- /dev/null +++ b/Features/Organization/Designation/Cqrs/GetDesignationListHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Designation.Cqrs; + +public class GetDesignationListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Level { get; set; } + public string? Description { get; set; } +} + +public record GetDesignationListQuery() : IRequest>; + +public class GetDesignationListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetDesignationListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetDesignationListQuery request, CancellationToken cancellationToken) + { + return await _context.Designation + .AsNoTracking() + .NotDeletedOnly() + .OrderBy(x => x.Code) + .Select(x => new GetDesignationListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Code = x.Code, + Name = x.Name, + Level = x.Level, + Description = x.Description, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/Cqrs/UpdateDesignationHandler.cs b/Features/Organization/Designation/Cqrs/UpdateDesignationHandler.cs new file mode 100644 index 0000000..f6d8cd4 --- /dev/null +++ b/Features/Organization/Designation/Cqrs/UpdateDesignationHandler.cs @@ -0,0 +1,62 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Designation.Cqrs; + +public class UpdateDesignationRequest : CreateDesignationRequest +{ + public string? Id { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateDesignationResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateDesignationCommand(UpdateDesignationRequest Data) : IRequest; + +public class UpdateDesignationHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateDesignationHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateDesignationCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Designation + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return new UpdateDesignationResponse { Id = request.Data.Id, Success = false }; + + var isExists = await _context.Designation + .AnyAsync(x => x.Code == request.Data.Code && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Designation", request.Data.Code ?? string.Empty); + } + + entity.Code = request.Data.Code; + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.Level = request.Data.Level; + entity.OtherInformation1 = request.Data.OtherInformation1; + entity.OtherInformation2 = request.Data.OtherInformation2; + entity.OtherInformation3 = request.Data.OtherInformation3; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateDesignationResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/Cqrs/UpdateDesignationValidator.cs b/Features/Organization/Designation/Cqrs/UpdateDesignationValidator.cs new file mode 100644 index 0000000..165282a --- /dev/null +++ b/Features/Organization/Designation/Cqrs/UpdateDesignationValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Organization.Designation.Cqrs; + +public class UpdateDesignationValidator : AbstractValidator +{ + public UpdateDesignationValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Designation Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Designation Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Level) + .NotEmpty().WithMessage("Level is required"); + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/DesignationEndpoint.cs b/Features/Organization/Designation/DesignationEndpoint.cs new file mode 100644 index 0000000..11adc13 --- /dev/null +++ b/Features/Organization/Designation/DesignationEndpoint.cs @@ -0,0 +1,30 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Organization.Designation.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Organization.Designation; + +public static class DesignationEndpoint +{ + public static void MapDesignationEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/designation").WithTags("Designations") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme).RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + (await mediator.Send(new GetDesignationListQuery())).ToApiResponse("Data retrieved")); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + (await mediator.Send(new GetDesignationByIdQuery(id))).ToApiResponse("Detail retrieved")); + + group.MapPost("/", async (CreateDesignationRequest request, IMediator mediator) => + (await mediator.Send(new CreateDesignationCommand(request))).ToApiResponse("Created", StatusCodes.Status201Created)); + + group.MapPost("/update", async (UpdateDesignationRequest request, IMediator mediator) => + (await mediator.Send(new UpdateDesignationCommand(request))).ToApiResponse("Updated")); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + (await mediator.Send(new DeleteDesignationByIdCommand(new DeleteDesignationByIdRequest(id)))).ToApiResponse("Deleted")); + } +} \ No newline at end of file diff --git a/Features/Organization/Designation/DesignationService.cs b/Features/Organization/Designation/DesignationService.cs new file mode 100644 index 0000000..5883b2f --- /dev/null +++ b/Features/Organization/Designation/DesignationService.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Organization.Designation.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Organization.Designation; + +public class DesignationService : BaseService +{ + private readonly RestClient _client; + + public DesignationService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetDesignationListAsync() + { + var request = new RestRequest("api/designation", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetDesignationByIdAsync(string id) + { + var request = new RestRequest($"api/designation/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateDesignationAsync(CreateDesignationRequest data) + { + var request = new RestRequest("api/designation", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateDesignationAsync(UpdateDesignationRequest data) + { + var request = new RestRequest("api/designation/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteDesignationByIdAsync(string id) + { + var request = new RestRequest($"api/designation/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Components/EmployeePage.razor b/Features/Organization/Employee/Components/EmployeePage.razor new file mode 100644 index 0000000..b3fddb8 --- /dev/null +++ b/Features/Organization/Employee/Components/EmployeePage.razor @@ -0,0 +1,54 @@ +@page "/organization/employee" +@using Indotalent.Features.Organization.Employee +@using Indotalent.Features.Organization.Employee.Cqrs +@using MudBlazor + +@if (_view == View.Create) +{ + <_EmployeeCreateForm OnCancel="() => _view = View.Table" OnSuccess="() => _view = View.Table" /> +} +else if (_view == View.Update || _view == View.Detail) +{ + <_EmployeeUpdateForm Data="_data!" ReadOnly="@(_view == View.Detail)" OnCancel="() => _view = View.Table" OnSuccess="() => _view = View.Table" /> +} +else if (_view == View.Income) +{ + <_EmployeeIncomeDataTable EmployeeId="@_selectedId" EmployeeName="@_selectedName" EmployeeCode="@_selectedCode" OnBack="() => _view = View.Table" /> +} +else if (_view == View.Deduction) +{ + <_EmployeeDeductionDataTable EmployeeId="@_selectedId" EmployeeName="@_selectedName" EmployeeCode="@_selectedCode" OnBack="() => _view = View.Table" /> +} +else +{ + <_EmployeeDataTable OnAdd="() => _view = View.Create" + OnEdit="d => { _data = d; _view = View.Update; }" + OnView="d => { _data = d; _view = View.Detail; }" + OnIncome="HandleIncome" + OnDeduction="HandleDeduction" /> +} + +@code { + private enum View { Table, Create, Update, Detail, Income, Deduction } + private View _view = View.Table; + private UpdateEmployeeRequest? _data; + private string _selectedId = string.Empty; + private string _selectedName = string.Empty; + private string _selectedCode = string.Empty; + + private void HandleIncome((string Id, string Name, string Code) args) + { + _selectedId = args.Id; + _selectedName = args.Name; + _selectedCode = args.Code; + _view = View.Income; + } + + private void HandleDeduction((string Id, string Name, string Code) args) + { + _selectedId = args.Id; + _selectedName = args.Name; + _selectedCode = args.Code; + _view = View.Deduction; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Components/_EmployeeCreateForm.razor b/Features/Organization/Employee/Components/_EmployeeCreateForm.razor new file mode 100644 index 0000000..51b6a20 --- /dev/null +++ b/Features/Organization/Employee/Components/_EmployeeCreateForm.razor @@ -0,0 +1,303 @@ +@using Indotalent.Features.Organization.Employee +@using Indotalent.Features.Organization.Employee.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@implements IDisposable +@inject EmployeeService EmployeeService +@inject ISnackbar Snackbar + + + + +
+ +
+ Add New Employee + Create a comprehensive employee profile and organizational placement. +
+
+
+ + + @if (_isLoadingData) + { +
+ + Synchronizing Records... +
+ } + else + { + + + + +
BASIC IDENTITY
+ + + + + + + + @foreach (var item in _lookupData.Grades) + { + +
+ @item.Name (@item.Code) + + Range: @item.SalaryFrom.ToString("N0") - @item.SalaryTo.ToString("N0") + +
+
+ } +
+
+ + + + + + + + + + + + +
+ + +
PAYROLL & BANKING
+ + + + + @if (!string.IsNullOrEmpty(_model.GradeId)) + { + var selectedGrade = _lookupData.Grades.FirstOrDefault(x => x.Id == _model.GradeId); + if (selectedGrade != null) + { + + Allowed range: @selectedGrade.SalaryFrom.ToString("N0") - @selectedGrade.SalaryTo.ToString("N0") + + } + } + + + + + + + + + + + + + +
+ + +
PERSONAL DETAILS
+ + + + + + + + + + + + Male + Female + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
ORGANIZATIONAL
+ + + + @foreach (var item in _lookupData.Branches) { @item.Name } + + + + + + @foreach (var item in _lookupData.Departments) { @item.Name } + + + + + + @foreach (var item in _lookupData.Designations) { @item.Name } + + + + + + + + + + + + + + + + + + + + + + +
+ + +
ADDRESS & CONTACT
+ + + + + + + + + + + + + + + +
+ + +
SOCIAL MEDIA
+ + + + + +
+ + +
OTHERS
+ + + +
+ +
+ Cancel + + @if (_processing) + { + + Creating... + } + else + { + Create Employee + } + +
+
+
+ } +
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm? _form; + private CreateEmployeeRequest _model = new(); + private CreateEmployeeValidator _validator = new(); + private LookupResponse _lookupData = new(); + private bool _processing = false; + private bool _isLoadingData = true; + private bool _isDisposed = false; + + public void Dispose() => _isDisposed = true; + + protected override async Task OnInitializedAsync() + { + try { + _isLoadingData = true; + var response = await EmployeeService.GetLookupAsync(); + if (response?.IsSuccess == true) + { + _lookupData = response.Value ?? new(); + } + } finally { + if (!_isDisposed) { _isLoadingData = false; StateHasChanged(); } + } + } + + private async Task Submit() + { + await _form!.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try { + var res = await EmployeeService.CreateEmployeeAsync(_model); + await Task.Delay(500); + if (res?.IsSuccess == true) + { + Snackbar.Add("Employee Created Successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Components/_EmployeeDataTable.razor b/Features/Organization/Employee/Components/_EmployeeDataTable.razor new file mode 100644 index 0000000..857b8bf --- /dev/null +++ b/Features/Organization/Employee/Components/_EmployeeDataTable.razor @@ -0,0 +1,457 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Organization.Employee +@using Indotalent.Features.Organization.Employee.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject EmployeeService EmployeeService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Employee Directory + Manage human resources and organizational structure. +
+
+ + / + Organization + / + Employee +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedEmp != null) + { + Income + Deduction + View + Edit + + + } + else + { + + Add Employee + + } +
+
+ + + + + + Code + + + Full Name + + + Grade + + + Salary + + + Designation + + + Department + + + + + + + + @context.Code + + +
+ @(!string.IsNullOrWhiteSpace(context.FullName) ? context.FullName.Substring(0, 1) : "?") + @context.FullName +
+
+ + @context.GradeName + + + @context.BasicSalary?.ToString("N0") + + + @context.DesignationName + + + @context.DepartmentName + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + [Parameter] public EventCallback<(string Id, string Name, string Code)> OnIncome { get; set; } + [Parameter] public EventCallback<(string Id, string Name, string Code)> OnDeduction { get; set; } + + private List _employees = new(); + private GetEmployeeListResponse? _selectedEmp; + private string _searchString = ""; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; _selectedEmp = null; + StateHasChanged(); + try + { + var response = await EmployeeService.GetEmployeeListAsync(); + await Task.Delay(800); + if (response?.IsSuccess == true) + { + _employees = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _employees; + return _employees.Where(x => + (x.FullName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Code?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.DesignationName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.DepartmentName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.BranchName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Employees"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Code"; + worksheet.Cell(currentRow, 2).Value = "Full Name"; + worksheet.Cell(currentRow, 3).Value = "Grade"; + worksheet.Cell(currentRow, 4).Value = "Basic Salary"; + worksheet.Cell(currentRow, 5).Value = "Designation"; + worksheet.Cell(currentRow, 6).Value = "Department"; + worksheet.Cell(currentRow, 7).Value = "Branch"; + + var headerRange = worksheet.Range(1, 1, 1, 7); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Code; + worksheet.Cell(currentRow, 2).Value = item.FullName; + worksheet.Cell(currentRow, 3).Value = item.GradeName; + worksheet.Cell(currentRow, 4).Value = item.BasicSalary; + worksheet.Cell(currentRow, 5).Value = item.DesignationName; + worksheet.Cell(currentRow, 6).Value = item.DepartmentName; + worksheet.Cell(currentRow, 7).Value = item.BranchName; + + worksheet.Cell(currentRow, 4).Style.NumberFormat.Format = "#,##0"; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Employee_Registry.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnSearchClick() + { + _skip = 0; + _selectedEmp = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedEmp = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedEmp = null; + StateHasChanged(); + } + + private async Task InvokeIncome() + { + if (_selectedEmp == null) return; + await OnIncome.InvokeAsync((_selectedEmp.Id!, _selectedEmp.FullName!, _selectedEmp.Code!)); + } + + private async Task InvokeDeduction() + { + if (_selectedEmp == null) return; + await OnDeduction.InvokeAsync((_selectedEmp.Id!, _selectedEmp.FullName!, _selectedEmp.Code!)); + } + + private async Task InvokeView() + { + if (_selectedEmp == null) return; + var res = await EmployeeService.GetEmployeeByIdAsync(_selectedEmp.Id); + if (res?.Value != null) await OnView.InvokeAsync(Map(res.Value)); + } + + private async Task InvokeEdit() + { + if (_selectedEmp == null) return; + var res = await EmployeeService.GetEmployeeByIdAsync(_selectedEmp.Id); + if (res?.Value != null) await OnEdit.InvokeAsync(Map(res.Value)); + } + + private UpdateEmployeeRequest Map(GetEmployeeByIdResponse d) => new UpdateEmployeeRequest + { + Id = d.Id, + Code = d.Code, + FirstName = d.FirstName, + MiddleName = d.MiddleName, + LastName = d.LastName, + JobDescription = d.JobDescription, + GradeId = d.GradeId, + BasicSalary = d.BasicSalary, + SalaryBankName = d.SalaryBankName, + SalaryBankAccountName = d.SalaryBankAccountName, + SalaryBankAccountNumber = d.SalaryBankAccountNumber, + PlaceOfBirth = d.PlaceOfBirth, + DateOfBirth = d.DateOfBirth, + Gender = d.Gender, + MaritalStatus = d.MaritalStatus, + Religion = d.Religion, + BloodType = d.BloodType, + IdentityNumber = d.IdentityNumber, + TaxNumber = d.TaxNumber, + LastEducation = d.LastEducation, + JoinedDate = d.JoinedDate, + ResignedDate = d.ResignedDate, + EmployeeStatus = d.EmployeeStatus, + EmploymentType = d.EmploymentType, + StreetAddress = d.StreetAddress, + City = d.City, + StateProvince = d.StateProvince, + ZipCode = d.ZipCode, + Phone = d.Phone, + Email = d.Email, + BranchId = d.BranchId, + DepartmentId = d.DepartmentId, + DesignationId = d.DesignationId, + SocialMediaLinkedIn = d.SocialMediaLinkedIn, + SocialMediaX = d.SocialMediaX, + SocialMediaFacebook = d.SocialMediaFacebook, + SocialMediaInstagram = d.SocialMediaInstagram, + SocialMediaTikTok = d.SocialMediaTikTok, + OtherInformation1 = d.OtherInformation1, + OtherInformation2 = d.OtherInformation2, + OtherInformation3 = d.OtherInformation3, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + + private async Task OnDelete() + { + if (_selectedEmp == null) return; + var diag = await DialogService.ShowAsync<_DeleteConfirmation>("", new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedEmp.FullName } }); + if (!(await diag.Result).Canceled) + { + if (await EmployeeService.DeleteEmployeeByIdAsync(_selectedEmp.Id)) + { + await LoadData(); + Snackbar.Add("Employee deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Components/_EmployeeDeductionCreateForm.razor b/Features/Organization/Employee/Components/_EmployeeDeductionCreateForm.razor new file mode 100644 index 0000000..8f454d6 --- /dev/null +++ b/Features/Organization/Employee/Components/_EmployeeDeductionCreateForm.razor @@ -0,0 +1,134 @@ +@using Indotalent.Features.Organization.Employee +@using Indotalent.Features.Organization.Employee.Cqrs +@using MudBlazor +@inject EmployeeService EmployeeService +@inject ISnackbar Snackbar + + + + + + + + Add Employee Deduction + + + + + + +
DEDUCTION INFORMATION
+ + + + + @foreach (var item in _lookupData.Deductions) + { + @item.Name (@item.Code) + } + + + + + + + + + + + + + + + + +
+
+
+
+ + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Save Deduction + } + + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string EmployeeId { get; set; } = string.Empty; + + private MudForm? _form; + private CreateEmployeeDeductionRequest _model = new() { IsActive = true }; + private LookupResponse _lookupData = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + _model.EmployeeId = EmployeeId; + var response = await EmployeeService.GetLookupAsync(); + if (response?.IsSuccess == true) + { + _lookupData = response.Value ?? new(); + } + } + + private async Task Submit() + { + await _form!.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var res = await EmployeeService.CreateEmployeeDeductionAsync(_model); + await Task.Delay(500); + if (res?.IsSuccess == true) + { + Snackbar.Add("Deduction added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } + + private void Cancel() => MudDialog.Cancel(); +} \ No newline at end of file diff --git a/Features/Organization/Employee/Components/_EmployeeDeductionDataTable.razor b/Features/Organization/Employee/Components/_EmployeeDeductionDataTable.razor new file mode 100644 index 0000000..4038e72 --- /dev/null +++ b/Features/Organization/Employee/Components/_EmployeeDeductionDataTable.razor @@ -0,0 +1,150 @@ +@using Indotalent.Features.Organization.Employee +@using Indotalent.Features.Organization.Employee.Cqrs +@using Features.Root.Shared +@using MudBlazor +@inject EmployeeService EmployeeService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ +
+ Employee Deduction Details + + Manage deduction components for @EmployeeName @(!string.IsNullOrEmpty(_displayCode) ? $"({_displayCode})" : "") + +
+
+
+ + + +
+ List of Deductions + + Add Deduction + +
+ + @if (_isLoading) + { +
+ +
+ } + else if (!_deductions.Any()) + { +
+ No deduction components assigned for this employee. +
+ } + else + { + + + Deduction Component + Category + Amount + Description + Status + Actions + + + @context.DeductionName + + + @context.DeductionCategory + + + (@context.Amount.ToString("N0")) + @context.Description + + + + +
+ + +
+
+
+
+ } +
+ +@code { + [Parameter] public string EmployeeId { get; set; } = string.Empty; + [Parameter] public string EmployeeName { get; set; } = string.Empty; + [Parameter] public string EmployeeCode { get; set; } = string.Empty; + [Parameter] public EventCallback OnBack { get; set; } + + private List _deductions = new(); + private bool _isLoading = true; + private string _displayCode = string.Empty; + + protected override async Task OnInitializedAsync() + { + _displayCode = EmployeeCode; + await LoadData(); + } + + private async Task LoadData() + { + _isLoading = true; + StateHasChanged(); + + var response = await EmployeeService.GetEmployeeDeductionListAsync(EmployeeId); + if (response?.IsSuccess == true) + { + _deductions = response.Value ?? new(); + if (_deductions.Any() && string.IsNullOrEmpty(_displayCode)) + { + _displayCode = _deductions.First().EmployeeCode ?? string.Empty; + } + } + + _isLoading = false; + StateHasChanged(); + } + + private async Task OnAdd() + { + var parameters = new DialogParameters { ["EmployeeId"] = EmployeeId }; + var options = new DialogOptions { CloseButton = true, BackdropClick = false }; + var dialog = await DialogService.ShowAsync<_EmployeeDeductionCreateForm>("Add Employee Deduction", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await LoadData(); + } + + private async Task OnEdit(GetEmployeeDeductionListResponse context) + { + var parameters = new DialogParameters { ["Id"] = context.Id }; + var options = new DialogOptions { CloseButton = true, BackdropClick = false }; + var dialog = await DialogService.ShowAsync<_EmployeeDeductionUpdateForm>("Edit Employee Deduction", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await LoadData(); + } + + private async Task OnDelete(GetEmployeeDeductionListResponse context) + { + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, $"{context.DeductionName} from {EmployeeName}" } }); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var success = await EmployeeService.DeleteEmployeeDeductionByIdAsync(context.Id!); + if (success) + { + Snackbar.Add("Deduction component removed", Severity.Success); + await LoadData(); + } + } + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Components/_EmployeeDeductionUpdateForm.razor b/Features/Organization/Employee/Components/_EmployeeDeductionUpdateForm.razor new file mode 100644 index 0000000..b1fcab9 --- /dev/null +++ b/Features/Organization/Employee/Components/_EmployeeDeductionUpdateForm.razor @@ -0,0 +1,145 @@ +@using Indotalent.Features.Organization.Employee +@using Indotalent.Features.Organization.Employee.Cqrs +@using MudBlazor +@inject EmployeeService EmployeeService +@inject ISnackbar Snackbar + + + + + + + + Edit Employee Deduction + + + + @if (_isLoading) + { +
+ + Fetching Details... +
+ } + else + { + + + +
UPDATE DEDUCTION DETAILS
+ + + + + + + + + + + + + + + + + + + +
+
+
+ } +
+ + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Update Changes + } + + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string Id { get; set; } = string.Empty; + + private MudForm? _form; + private UpdateEmployeeDeductionRequest _model = new(); + private string _deductionName = string.Empty; + private bool _isLoading = true; + private bool _processing = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isLoading = true; + var response = await EmployeeService.GetEmployeeDeductionByIdAsync(Id); + if (response?.IsSuccess == true && response.Value != null) + { + var data = response.Value; + _model.Id = data.Id; + _model.DeductionId = data.DeductionId; + _model.Amount = data.Amount; + _model.Description = data.Description; + _model.IsActive = data.IsActive; + _deductionName = data.DeductionName ?? string.Empty; + } + _isLoading = false; + } + + private async Task Submit() + { + await _form!.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var res = await EmployeeService.UpdateEmployeeDeductionAsync(_model); + await Task.Delay(500); + if (res?.IsSuccess == true) + { + Snackbar.Add("Deduction updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally { _processing = false; } + } + + private void Cancel() => MudDialog.Cancel(); +} \ No newline at end of file diff --git a/Features/Organization/Employee/Components/_EmployeeIncomeCreateForm.razor b/Features/Organization/Employee/Components/_EmployeeIncomeCreateForm.razor new file mode 100644 index 0000000..56c8a6e --- /dev/null +++ b/Features/Organization/Employee/Components/_EmployeeIncomeCreateForm.razor @@ -0,0 +1,137 @@ +@using Indotalent.Features.Organization.Employee +@using Indotalent.Features.Organization.Employee.Cqrs +@using MudBlazor +@inject EmployeeService EmployeeService +@inject ISnackbar Snackbar + + + + + + + + Add Employee Income + + + + + + +
INCOME INFORMATION
+ + + + + @foreach (var item in _lookupData.Incomes) + { + @item.Name (@item.Code) + } + + + + + + + + + + + + + + + + +
+
+
+
+ + Cancel + + @if (_processing) + { + + Processing... + } + else + { + Save Income + } + + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string EmployeeId { get; set; } = string.Empty; + + private MudForm? _form; + private CreateEmployeeIncomeRequest _model = new() { IsActive = true }; + private LookupResponse _lookupData = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + _model.EmployeeId = EmployeeId; + var response = await EmployeeService.GetLookupAsync(); + if (response?.IsSuccess == true) + { + _lookupData = response.Value ?? new(); + } + } + + private async Task Submit() + { + await _form!.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var res = await EmployeeService.CreateEmployeeIncomeAsync(_model); + await Task.Delay(500); + if (res?.IsSuccess == true) + { + Snackbar.Add("Income added successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally + { + _processing = false; + } + } + + private void Cancel() => MudDialog.Cancel(); +} \ No newline at end of file diff --git a/Features/Organization/Employee/Components/_EmployeeIncomeDataTable.razor b/Features/Organization/Employee/Components/_EmployeeIncomeDataTable.razor new file mode 100644 index 0000000..4dd9ca4 --- /dev/null +++ b/Features/Organization/Employee/Components/_EmployeeIncomeDataTable.razor @@ -0,0 +1,150 @@ +@using Indotalent.Features.Organization.Employee +@using Indotalent.Features.Organization.Employee.Cqrs +@using Features.Root.Shared +@using MudBlazor +@inject EmployeeService EmployeeService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + +
+ +
+ Employee Income Details + + Manage income components for @EmployeeName @(!string.IsNullOrEmpty(_displayCode) ? $"({_displayCode})" : "") + +
+
+
+ + + +
+ List of Incomes + + Add Income + +
+ + @if (_isLoading) + { +
+ +
+ } + else if (!_incomes.Any()) + { +
+ No income components assigned for this employee. +
+ } + else + { + + + Income Component + Type + Amount + Description + Status + Actions + + + @context.IncomeName + + + @context.IncomeType + + + @context.Amount.ToString("N0") + @context.Description + + + + +
+ + +
+
+
+
+ } +
+ +@code { + [Parameter] public string EmployeeId { get; set; } = string.Empty; + [Parameter] public string EmployeeName { get; set; } = string.Empty; + [Parameter] public string EmployeeCode { get; set; } = string.Empty; + [Parameter] public EventCallback OnBack { get; set; } + + private List _incomes = new(); + private bool _isLoading = true; + private string _displayCode = string.Empty; + + protected override async Task OnInitializedAsync() + { + _displayCode = EmployeeCode; + await LoadData(); + } + + private async Task LoadData() + { + _isLoading = true; + StateHasChanged(); + + var response = await EmployeeService.GetEmployeeIncomeListAsync(EmployeeId); + if (response?.IsSuccess == true) + { + _incomes = response.Value ?? new(); + if (_incomes.Any() && string.IsNullOrEmpty(_displayCode)) + { + _displayCode = _incomes.First().EmployeeCode ?? string.Empty; + } + } + + _isLoading = false; + StateHasChanged(); + } + + private async Task OnAdd() + { + var parameters = new DialogParameters { ["EmployeeId"] = EmployeeId }; + var options = new DialogOptions { CloseButton = true, BackdropClick = false }; + var dialog = await DialogService.ShowAsync<_EmployeeIncomeCreateForm>("Add Employee Income", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await LoadData(); + } + + private async Task OnEdit(GetEmployeeIncomeListResponse context) + { + var parameters = new DialogParameters { ["Id"] = context.Id }; + var options = new DialogOptions { CloseButton = true, BackdropClick = false }; + var dialog = await DialogService.ShowAsync<_EmployeeIncomeUpdateForm>("Edit Employee Income", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) await LoadData(); + } + + private async Task OnDelete(GetEmployeeIncomeListResponse context) + { + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, $"{context.IncomeName} from {EmployeeName}" } }); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var success = await EmployeeService.DeleteEmployeeIncomeByIdAsync(context.Id!); + if (success) + { + Snackbar.Add("Income component removed", Severity.Success); + await LoadData(); + } + } + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Components/_EmployeeIncomeUpdateForm.razor b/Features/Organization/Employee/Components/_EmployeeIncomeUpdateForm.razor new file mode 100644 index 0000000..f58b0b8 --- /dev/null +++ b/Features/Organization/Employee/Components/_EmployeeIncomeUpdateForm.razor @@ -0,0 +1,151 @@ +@using Indotalent.Features.Organization.Employee +@using Indotalent.Features.Organization.Employee.Cqrs +@using MudBlazor +@inject EmployeeService EmployeeService +@inject ISnackbar Snackbar + + + + + + + + Edit Employee Income + + + + @if (_isLoading) + { +
+ + Fetching Details... +
+ } + else + { + + + +
UPDATE INCOME DETAILS
+ + + + + + + + + + + + + + + + + + + +
+
+
+ } +
+ + Cancel + + @if (_processing) + { + + Updating... + } + else + { + Update Changes + } + + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string Id { get; set; } = string.Empty; + + private MudForm? _form; + private UpdateEmployeeIncomeRequest _model = new(); + private string _incomeName = string.Empty; + private bool _isLoading = true; + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + await LoadData(); + } + + private async Task LoadData() + { + _isLoading = true; + var response = await EmployeeService.GetEmployeeIncomeByIdAsync(Id); + if (response?.IsSuccess == true && response.Value != null) + { + var data = response.Value; + _model.Id = data.Id; + _model.IncomeId = data.IncomeId; + _model.Amount = data.Amount; + _model.Description = data.Description; + _model.IsActive = data.IsActive; + _incomeName = data.IncomeName; + } + _isLoading = false; + } + + private async Task Submit() + { + await _form!.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var res = await EmployeeService.UpdateEmployeeIncomeAsync(_model); + await Task.Delay(500); + if (res?.IsSuccess == true) + { + Snackbar.Add("Income updated successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + finally + { + _processing = false; + } + } + + private void Cancel() => MudDialog.Cancel(); +} \ No newline at end of file diff --git a/Features/Organization/Employee/Components/_EmployeeUpdateForm.razor b/Features/Organization/Employee/Components/_EmployeeUpdateForm.razor new file mode 100644 index 0000000..1570552 --- /dev/null +++ b/Features/Organization/Employee/Components/_EmployeeUpdateForm.razor @@ -0,0 +1,285 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Organization.Employee +@using Indotalent.Features.Organization.Employee.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@implements IDisposable +@inject EmployeeService EmployeeService +@inject ISnackbar Snackbar + + + + +
+ +
+ @(ReadOnly ? "Employee Profile" : "Edit Employee") + Managing records for @_model.FirstName @_model.LastName +
+
+
+ + + @if (_isLoadingData) + { +
+ + Synchronizing Records... +
+ } + else + { + + + + +
BASIC IDENTITY
+ + + + + + + + @foreach (var item in _lookupData.Grades) + { + +
+ @item.Name (@item.Code) + + Range: @item.SalaryFrom.ToString("N0") - @item.SalaryTo.ToString("N0") + +
+
+ } +
+
+ + + + + + + + + + + + +
+ + +
PAYROLL & BANKING
+ + + + + @if (!string.IsNullOrEmpty(_model.GradeId)) + { + var selectedGrade = _lookupData.Grades.FirstOrDefault(x => x.Id == _model.GradeId); + if (selectedGrade != null) + { + + Allowed range: @selectedGrade.SalaryFrom.ToString("N0") - @selectedGrade.SalaryTo.ToString("N0") + + } + } + + + + + + + + + + + + + +
+ + +
PERSONAL DETAILS
+ + + + + + Male + Female + + + + + + + + +
+ + +
ORGANIZATIONAL
+ + + + @foreach (var item in _lookupData.Branches) { @item.Name } + + + + + + @foreach (var item in _lookupData.Departments) { @item.Name } + + + + + + @foreach (var item in _lookupData.Designations) { @item.Name } + + + + + + + +
+ + +
ADDRESS & CONTACT
+ + + + + + + + + +
+ + +
SOCIAL MEDIA
+ + + + + +
+ + +
ADDITIONAL
+ + + +
+ + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) { Updating... } + else { Save Changes } + + } +
+
+
+ } +
+ +@code { + [Parameter] public UpdateEmployeeRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm? _form; + private UpdateEmployeeRequest _model = new(); + private UpdateEmployeeValidator _validator = new(); + + private LookupResponse _lookupData = new(); + private bool _processing = false; + private bool _isLoadingData = true; + private bool _isDisposed = false; + + public void Dispose() => _isDisposed = true; + + protected override void OnInitialized() { _model = Data; } + + protected override async Task OnInitializedAsync() + { + try { + _isLoadingData = true; + var response = await EmployeeService.GetLookupAsync(); + if (response?.IsSuccess == true) + { + _lookupData = response.Value ?? new(); + } + } finally { + if (!_isDisposed) { _isLoadingData = false; StateHasChanged(); } + } + } + + private async Task Submit() + { + await _form!.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try { + var res = await EmployeeService.UpdateEmployeeAsync(_model); + await Task.Delay(500); + if (res?.IsSuccess == true) + { + Snackbar.Add("Updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/CreateEmployeeDeductionHandler.cs b/Features/Organization/Employee/Cqrs/CreateEmployeeDeductionHandler.cs new file mode 100644 index 0000000..e690d93 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/CreateEmployeeDeductionHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class CreateEmployeeDeductionRequest +{ + public string? EmployeeId { get; set; } + public string? DeductionId { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; +} + +public class CreateEmployeeDeductionResponse +{ + public string? Id { get; set; } +} + +public record CreateEmployeeDeductionCommand(CreateEmployeeDeductionRequest Data) : IRequest; + +public class CreateEmployeeDeductionHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateEmployeeDeductionHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateEmployeeDeductionCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.EmployeeDeduction); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"EDED/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.EmployeeDeduction + { + AutoNumber = autoNo, + EmployeeId = request.Data.EmployeeId, + DeductionId = request.Data.DeductionId, + Amount = request.Data.Amount, + Description = request.Data.Description, + IsActive = request.Data.IsActive + }; + + _context.EmployeeDeduction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateEmployeeDeductionResponse { Id = entity.Id }; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/CreateEmployeeDeductionValidator.cs b/Features/Organization/Employee/Cqrs/CreateEmployeeDeductionValidator.cs new file mode 100644 index 0000000..5bc2ab9 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/CreateEmployeeDeductionValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class CreateEmployeeDeductionValidator : AbstractValidator +{ + public CreateEmployeeDeductionValidator() + { + RuleFor(x => x.EmployeeId).NotEmpty().WithMessage("Employee is required"); + RuleFor(x => x.DeductionId).NotEmpty().WithMessage("Deduction component is required"); + RuleFor(x => x.Amount).GreaterThanOrEqualTo(0).WithMessage("Amount must be 0 or greater"); + RuleFor(x => x.Description).MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/CreateEmployeeHandler.cs b/Features/Organization/Employee/Cqrs/CreateEmployeeHandler.cs new file mode 100644 index 0000000..ee0ce20 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/CreateEmployeeHandler.cs @@ -0,0 +1,128 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class CreateEmployeeRequest +{ + public string Code { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string MiddleName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string JobDescription { get; set; } = string.Empty; + public string GradeId { get; set; } = string.Empty; + public decimal BasicSalary { get; set; } + public string SalaryBankName { get; set; } = string.Empty; + public string SalaryBankAccountName { get; set; } = string.Empty; + public string SalaryBankAccountNumber { get; set; } = string.Empty; + public string PlaceOfBirth { get; set; } = string.Empty; + public DateTime? DateOfBirth { get; set; } + public string Gender { get; set; } = string.Empty; + public string MaritalStatus { get; set; } = string.Empty; + public string Religion { get; set; } = string.Empty; + public string BloodType { get; set; } = string.Empty; + public string IdentityNumber { get; set; } = string.Empty; + public string TaxNumber { get; set; } = string.Empty; + public string LastEducation { get; set; } = string.Empty; + public DateTime? JoinedDate { get; set; } + public DateTime? ResignedDate { get; set; } + public string EmployeeStatus { get; set; } = string.Empty; + public string EmploymentType { get; set; } = string.Empty; + public string StreetAddress { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string StateProvince { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; + public string Phone { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string BranchId { get; set; } = string.Empty; + public string DepartmentId { get; set; } = string.Empty; + public string DesignationId { get; set; } = string.Empty; + public string SocialMediaLinkedIn { get; set; } = string.Empty; + public string SocialMediaX { get; set; } = string.Empty; + public string SocialMediaFacebook { get; set; } = string.Empty; + public string SocialMediaInstagram { get; set; } = string.Empty; + public string SocialMediaTikTok { get; set; } = string.Empty; + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; +} + +public class CreateEmployeeResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateEmployeeCommand(CreateEmployeeRequest Data) : IRequest; + +public class CreateEmployeeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateEmployeeHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateEmployeeCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Employee.AnyAsync(x => x.Code == request.Data.Code, cancellationToken); + if (isExists) throw new AlreadyExistsException("Employee", request.Data.Code); + + var entityName = nameof(Data.Entities.Employee); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"EMP/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Employee + { + AutoNumber = autoNo, + Code = request.Data.Code, + FirstName = request.Data.FirstName, + MiddleName = request.Data.MiddleName, + LastName = request.Data.LastName, + JobDescription = request.Data.JobDescription, + GradeId = request.Data.GradeId, + BasicSalary = request.Data.BasicSalary, + SalaryBankName = request.Data.SalaryBankName, + SalaryBankAccountName = request.Data.SalaryBankAccountName, + SalaryBankAccountNumber = request.Data.SalaryBankAccountNumber, + PlaceOfBirth = request.Data.PlaceOfBirth, + DateOfBirth = request.Data.DateOfBirth, + Gender = request.Data.Gender, + MaritalStatus = request.Data.MaritalStatus, + Religion = request.Data.Religion, + BloodType = request.Data.BloodType, + IdentityNumber = request.Data.IdentityNumber, + TaxNumber = request.Data.TaxNumber, + LastEducation = request.Data.LastEducation, + JoinedDate = request.Data.JoinedDate, + ResignedDate = request.Data.ResignedDate, + EmployeeStatus = request.Data.EmployeeStatus, + EmploymentType = request.Data.EmploymentType, + StreetAddress = request.Data.StreetAddress, + City = request.Data.City, + StateProvince = request.Data.StateProvince, + ZipCode = request.Data.ZipCode, + Phone = request.Data.Phone, + Email = request.Data.Email, + BranchId = request.Data.BranchId, + DepartmentId = request.Data.DepartmentId, + DesignationId = request.Data.DesignationId, + SocialMediaLinkedIn = request.Data.SocialMediaLinkedIn, + SocialMediaX = request.Data.SocialMediaX, + SocialMediaFacebook = request.Data.SocialMediaFacebook, + SocialMediaInstagram = request.Data.SocialMediaInstagram, + SocialMediaTikTok = request.Data.SocialMediaTikTok, + OtherInformation1 = request.Data.OtherInformation1, + OtherInformation2 = request.Data.OtherInformation2, + OtherInformation3 = request.Data.OtherInformation3 + }; + + _context.Employee.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateEmployeeResponse { Id = entity.Id, Code = entity.Code }; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/CreateEmployeeIncomeHandler.cs b/Features/Organization/Employee/Cqrs/CreateEmployeeIncomeHandler.cs new file mode 100644 index 0000000..1105dbe --- /dev/null +++ b/Features/Organization/Employee/Cqrs/CreateEmployeeIncomeHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class CreateEmployeeIncomeRequest +{ + public string? EmployeeId { get; set; } + public string? IncomeId { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; +} + +public class CreateEmployeeIncomeResponse +{ + public string? Id { get; set; } +} + +public record CreateEmployeeIncomeCommand(CreateEmployeeIncomeRequest Data) : IRequest; + +public class CreateEmployeeIncomeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public CreateEmployeeIncomeHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateEmployeeIncomeCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.EmployeeIncome); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"EINC/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.EmployeeIncome + { + AutoNumber = autoNo, + EmployeeId = request.Data.EmployeeId, + IncomeId = request.Data.IncomeId, + Amount = request.Data.Amount, + Description = request.Data.Description, + IsActive = request.Data.IsActive + }; + + _context.EmployeeIncome.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateEmployeeIncomeResponse { Id = entity.Id }; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/CreateEmployeeIncomeValidator.cs b/Features/Organization/Employee/Cqrs/CreateEmployeeIncomeValidator.cs new file mode 100644 index 0000000..1188940 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/CreateEmployeeIncomeValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class CreateEmployeeIncomeValidator : AbstractValidator +{ + public CreateEmployeeIncomeValidator() + { + RuleFor(x => x.EmployeeId).NotEmpty().WithMessage("Employee is required"); + RuleFor(x => x.IncomeId).NotEmpty().WithMessage("Income component is required"); + RuleFor(x => x.Amount).GreaterThanOrEqualTo(0).WithMessage("Amount must be 0 or greater"); + RuleFor(x => x.Description).MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/CreateEmployeeValidator.cs b/Features/Organization/Employee/Cqrs/CreateEmployeeValidator.cs new file mode 100644 index 0000000..5d536f8 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/CreateEmployeeValidator.cs @@ -0,0 +1,58 @@ +using FluentValidation; +using Indotalent.Infrastructure.Database; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class CreateEmployeeValidator : AbstractValidator +{ + public CreateEmployeeValidator() + { + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Employee Code is required") + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"Code cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.FirstName) + .NotEmpty().WithMessage("First Name is required") + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"First Name cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.LastName) + .NotEmpty().WithMessage("Last Name is required") + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"Last Name cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.IdentityNumber) + .NotEmpty().WithMessage("Identity Number is required"); + + RuleFor(x => x.JoinedDate) + .NotEmpty().WithMessage("Joined Date is required"); + + RuleFor(x => x.BranchId) + .NotEmpty().WithMessage("Please select a Branch"); + + RuleFor(x => x.DepartmentId) + .NotEmpty().WithMessage("Please select a Department"); + + RuleFor(x => x.DesignationId) + .NotEmpty().WithMessage("Please select a Designation"); + + RuleFor(x => x.GradeId) + .NotEmpty().WithMessage("Please select a Salary Grade"); + + RuleFor(x => x.Gender) + .NotEmpty().WithMessage("Gender is required"); + + RuleFor(x => x.Phone) + .NotEmpty().WithMessage("Phone number is required"); + + RuleFor(x => x.BasicSalary) + .GreaterThan(0).WithMessage("Basic Salary must be greater than 0."); + + + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/DeleteEmployeeByIdHandler.cs b/Features/Organization/Employee/Cqrs/DeleteEmployeeByIdHandler.cs new file mode 100644 index 0000000..139a64d --- /dev/null +++ b/Features/Organization/Employee/Cqrs/DeleteEmployeeByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public record DeleteEmployeeByIdRequest(string Id); + +public record DeleteEmployeeByIdCommand(DeleteEmployeeByIdRequest Data) : IRequest; + +public class DeleteEmployeeByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteEmployeeByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteEmployeeByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Employee + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Employee.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/DeleteEmployeeDeductionByIdHandler.cs b/Features/Organization/Employee/Cqrs/DeleteEmployeeDeductionByIdHandler.cs new file mode 100644 index 0000000..3c74f2e --- /dev/null +++ b/Features/Organization/Employee/Cqrs/DeleteEmployeeDeductionByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public record DeleteEmployeeDeductionByIdRequest(string Id); + +public record DeleteEmployeeDeductionByIdCommand(DeleteEmployeeDeductionByIdRequest Data) : IRequest; + +public class DeleteEmployeeDeductionByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteEmployeeDeductionByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteEmployeeDeductionByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.EmployeeDeduction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.EmployeeDeduction.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/DeleteEmployeeIncomeByIdHandler.cs b/Features/Organization/Employee/Cqrs/DeleteEmployeeIncomeByIdHandler.cs new file mode 100644 index 0000000..dff56c2 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/DeleteEmployeeIncomeByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public record DeleteEmployeeIncomeByIdRequest(string Id); + +public record DeleteEmployeeIncomeByIdCommand(DeleteEmployeeIncomeByIdRequest Data) : IRequest; + +public class DeleteEmployeeIncomeByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteEmployeeIncomeByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteEmployeeIncomeByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.EmployeeIncome + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.EmployeeIncome.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/GetEmployeeByIdHandler.cs b/Features/Organization/Employee/Cqrs/GetEmployeeByIdHandler.cs new file mode 100644 index 0000000..456c9f4 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/GetEmployeeByIdHandler.cs @@ -0,0 +1,120 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class GetEmployeeByIdResponse +{ + public string Id { get; set; } = string.Empty; + public string? AutoNumber { get; set; } + public string Code { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string MiddleName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string JobDescription { get; set; } = string.Empty; + public string? GradeId { get; set; } + public decimal BasicSalary { get; set; } + public string SalaryBankName { get; set; } = string.Empty; + public string SalaryBankAccountName { get; set; } = string.Empty; + public string SalaryBankAccountNumber { get; set; } = string.Empty; + public string PlaceOfBirth { get; set; } = string.Empty; + public DateTime? DateOfBirth { get; set; } + public string Gender { get; set; } = string.Empty; + public string MaritalStatus { get; set; } = string.Empty; + public string Religion { get; set; } = string.Empty; + public string BloodType { get; set; } = string.Empty; + public string IdentityNumber { get; set; } = string.Empty; + public string TaxNumber { get; set; } = string.Empty; + public string LastEducation { get; set; } = string.Empty; + public DateTime? JoinedDate { get; set; } + public DateTime? ResignedDate { get; set; } + public string EmployeeStatus { get; set; } = string.Empty; + public string EmploymentType { get; set; } = string.Empty; + public string StreetAddress { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string StateProvince { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; + public string Phone { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string BranchId { get; set; } = string.Empty; + public string DepartmentId { get; set; } = string.Empty; + public string DesignationId { get; set; } = string.Empty; + public string SocialMediaLinkedIn { get; set; } = string.Empty; + public string SocialMediaX { get; set; } = string.Empty; + public string SocialMediaFacebook { get; set; } = string.Empty; + public string SocialMediaInstagram { get; set; } = string.Empty; + public string SocialMediaTikTok { get; set; } = string.Empty; + public string OtherInformation1 { get; set; } = string.Empty; + public string OtherInformation2 { get; set; } = string.Empty; + public string OtherInformation3 { get; set; } = string.Empty; + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetEmployeeByIdQuery(string Id) : IRequest; + +public class GetEmployeeByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetEmployeeByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetEmployeeByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Employee + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetEmployeeByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + Code = x.Code, + FirstName = x.FirstName, + MiddleName = x.MiddleName, + LastName = x.LastName, + JobDescription = x.JobDescription, + GradeId = x.GradeId, + BasicSalary = x.BasicSalary, + SalaryBankName = x.SalaryBankName, + SalaryBankAccountName = x.SalaryBankAccountName, + SalaryBankAccountNumber = x.SalaryBankAccountNumber, + PlaceOfBirth = x.PlaceOfBirth, + DateOfBirth = x.DateOfBirth, + Gender = x.Gender, + MaritalStatus = x.MaritalStatus, + Religion = x.Religion, + BloodType = x.BloodType, + IdentityNumber = x.IdentityNumber, + TaxNumber = x.TaxNumber, + LastEducation = x.LastEducation, + JoinedDate = x.JoinedDate, + ResignedDate = x.ResignedDate, + EmployeeStatus = x.EmployeeStatus, + EmploymentType = x.EmploymentType, + StreetAddress = x.StreetAddress, + City = x.City, + StateProvince = x.StateProvince, + ZipCode = x.ZipCode, + Phone = x.Phone, + Email = x.Email, + BranchId = x.BranchId, + DepartmentId = x.DepartmentId, + DesignationId = x.DesignationId, + SocialMediaLinkedIn = x.SocialMediaLinkedIn, + SocialMediaX = x.SocialMediaX, + SocialMediaFacebook = x.SocialMediaFacebook, + SocialMediaInstagram = x.SocialMediaInstagram, + SocialMediaTikTok = x.SocialMediaTikTok, + OtherInformation1 = x.OtherInformation1, + OtherInformation2 = x.OtherInformation2, + OtherInformation3 = x.OtherInformation3, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/GetEmployeeDeductionByIdHandler.cs b/Features/Organization/Employee/Cqrs/GetEmployeeDeductionByIdHandler.cs new file mode 100644 index 0000000..552d38e --- /dev/null +++ b/Features/Organization/Employee/Cqrs/GetEmployeeDeductionByIdHandler.cs @@ -0,0 +1,41 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class GetEmployeeDeductionByIdResponse +{ + public string? Id { get; set; } + public string? DeductionId { get; set; } + public string? DeductionName { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } +} + +public record GetEmployeeDeductionByIdQuery(string Id) : IRequest; + +public class GetEmployeeDeductionByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetEmployeeDeductionByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetEmployeeDeductionByIdQuery request, CancellationToken cancellationToken) + { + var data = await _context.EmployeeDeduction + .Where(x => x.Id == request.Id) + .Select(x => new GetEmployeeDeductionByIdResponse + { + Id = x.Id, + DeductionId = x.DeductionId, + DeductionName = x.Deduction != null ? x.Deduction.Name : string.Empty, + Amount = x.Amount, + Description = x.Description, + IsActive = x.IsActive + }) + .FirstOrDefaultAsync(cancellationToken); + + return data!; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/GetEmployeeDeductionListByEmployeeIdHandler.cs b/Features/Organization/Employee/Cqrs/GetEmployeeDeductionListByEmployeeIdHandler.cs new file mode 100644 index 0000000..6f8a880 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/GetEmployeeDeductionListByEmployeeIdHandler.cs @@ -0,0 +1,46 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class GetEmployeeDeductionListResponse +{ + public string? Id { get; set; } + public string? DeductionId { get; set; } + public string? EmployeeCode { get; set; } + public string? DeductionName { get; set; } + public string? DeductionCategory { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } +} + +public record GetEmployeeDeductionListByEmployeeIdQuery(string EmployeeId) : IRequest>; + +public class GetEmployeeDeductionListByEmployeeIdHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetEmployeeDeductionListByEmployeeIdHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetEmployeeDeductionListByEmployeeIdQuery request, CancellationToken cancellationToken) + { + var data = await _context.EmployeeDeduction + .Where(x => x.EmployeeId == request.EmployeeId) + .Select(x => new GetEmployeeDeductionListResponse + { + Id = x.Id, + DeductionId = x.DeductionId, + EmployeeCode = x.Employee != null ? x.Employee.Code : string.Empty, + DeductionName = x.Deduction != null ? x.Deduction.Name : string.Empty, + DeductionCategory = x.Deduction != null ? x.Deduction.Category : string.Empty, + Amount = x.Amount, + Description = x.Description, + IsActive = x.IsActive + }) + .OrderBy(x => x.DeductionName) + .ToListAsync(cancellationToken); + + return data; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/GetEmployeeIncomeByIdHandler.cs b/Features/Organization/Employee/Cqrs/GetEmployeeIncomeByIdHandler.cs new file mode 100644 index 0000000..7a74830 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/GetEmployeeIncomeByIdHandler.cs @@ -0,0 +1,41 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class GetEmployeeIncomeByIdResponse +{ + public string? Id { get; set; } + public string? IncomeId { get; set; } + public string? IncomeName { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } +} + +public record GetEmployeeIncomeByIdQuery(string Id) : IRequest; + +public class GetEmployeeIncomeByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetEmployeeIncomeByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetEmployeeIncomeByIdQuery request, CancellationToken cancellationToken) + { + var data = await _context.EmployeeIncome + .Where(x => x.Id == request.Id) + .Select(x => new GetEmployeeIncomeByIdResponse + { + Id = x.Id, + IncomeId = x.IncomeId, + IncomeName = x.Income != null ? x.Income.Name : string.Empty, + Amount = x.Amount, + Description = x.Description, + IsActive = x.IsActive + }) + .FirstOrDefaultAsync(cancellationToken); + + return data!; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/GetEmployeeIncomeListByEmployeeIdHandler.cs b/Features/Organization/Employee/Cqrs/GetEmployeeIncomeListByEmployeeIdHandler.cs new file mode 100644 index 0000000..eba3089 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/GetEmployeeIncomeListByEmployeeIdHandler.cs @@ -0,0 +1,46 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class GetEmployeeIncomeListResponse +{ + public string? Id { get; set; } + public string? IncomeId { get; set; } + public string? EmployeeCode { get; set; } + public string? IncomeName { get; set; } + public string? IncomeType { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } +} + +public record GetEmployeeIncomeListByEmployeeIdQuery(string EmployeeId) : IRequest>; + +public class GetEmployeeIncomeListByEmployeeIdHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + public GetEmployeeIncomeListByEmployeeIdHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetEmployeeIncomeListByEmployeeIdQuery request, CancellationToken cancellationToken) + { + var data = await _context.EmployeeIncome + .Where(x => x.EmployeeId == request.EmployeeId) + .Select(x => new GetEmployeeIncomeListResponse + { + Id = x.Id, + IncomeId = x.IncomeId, + EmployeeCode = x.Employee != null ? x.Employee.Code : string.Empty, + IncomeName = x.Income != null ? x.Income.Name : string.Empty, + IncomeType = x.Income != null ? x.Income.Type : string.Empty, + Amount = x.Amount, + Description = x.Description, + IsActive = x.IsActive + }) + .OrderBy(x => x.IncomeName) + .ToListAsync(cancellationToken); + + return data; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/GetEmployeeListHandler.cs b/Features/Organization/Employee/Cqrs/GetEmployeeListHandler.cs new file mode 100644 index 0000000..a9658d4 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/GetEmployeeListHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class GetEmployeeListResponse +{ + public string Id { get; set; } = string.Empty; + public string? AutoNumber { get; set; } + public string Code { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public string DesignationName { get; set; } = string.Empty; + public string DepartmentName { get; set; } = string.Empty; + public string BranchName { get; set; } = string.Empty; + public string EmployeeStatus { get; set; } = string.Empty; + public string? GradeName { get; set; } + public decimal? BasicSalary { get; set; } +} + +public record GetEmployeeListQuery() : IRequest>; + +public class GetEmployeeListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetEmployeeListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetEmployeeListQuery request, CancellationToken cancellationToken) + { + var data = await _context.Employee + .Include(x => x.Grade) + .Include(x => x.Branch) + .Include(x => x.Department) + .Include(x => x.Designation) + .AsNoTracking() + .Select(x => new GetEmployeeListResponse + { + Id = x.Id, + Code = x.Code, + FullName = $"{x.FirstName} {x.MiddleName} {x.LastName}".Replace(" ", " "), + DesignationName = x.Designation!.Name, + DepartmentName = x.Department!.Name, + BranchName = x.Branch!.Name, + GradeName = x.Grade != null ? x.Grade.Name : string.Empty, + BasicSalary = x.BasicSalary + }) + .ToListAsync(cancellationToken); + + return data; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/LookupHandler.cs b/Features/Organization/Employee/Cqrs/LookupHandler.cs new file mode 100644 index 0000000..32a31e2 --- /dev/null +++ b/Features/Organization/Employee/Cqrs/LookupHandler.cs @@ -0,0 +1,78 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class LookupResponse +{ + public List Branches { get; set; } = new(); + public List Departments { get; set; } = new(); + public List Designations { get; set; } = new(); + public List Grades { get; set; } = new(); + public List Incomes { get; set; } = new(); + public List Deductions { get; set; } = new(); +} + +public class LookupItem +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Code { get; set; } + public decimal SalaryFrom { get; set; } + public decimal SalaryTo { get; set; } +} + +public record LookupQuery() : IRequest; + +public class LookupHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public LookupHandler(AppDbContext context) => _context = context; + + public async Task Handle(LookupQuery request, CancellationToken cancellationToken) + { + var response = new LookupResponse(); + + response.Branches = await _context.Branch + .AsNoTracking() + .Select(x => new LookupItem { Id = x.Id, Name = x.Name, Code = x.Code }) + .ToListAsync(cancellationToken); + + response.Departments = await _context.Department + .AsNoTracking() + .Select(x => new LookupItem { Id = x.Id, Name = x.Name, Code = x.CostCenter }) + .ToListAsync(cancellationToken); + + response.Designations = await _context.Designation + .AsNoTracking() + .Select(x => new LookupItem { Id = x.Id, Name = x.Name, Code = x.Code }) + .ToListAsync(cancellationToken); + + response.Grades = await _context.Grade + .AsNoTracking() + .Select(x => new LookupItem + { + Id = x.Id, + Name = x.Name, + Code = x.Code, + SalaryFrom = x.SalaryFrom, + SalaryTo = x.SalaryTo + }) + .ToListAsync(cancellationToken); + + response.Incomes = await _context.Income + .AsNoTracking() + .Where(x => x.Status == "Active") + .Select(x => new LookupItem { Id = x.Id, Name = x.Name, Code = x.Code }) + .ToListAsync(cancellationToken); + + response.Deductions = await _context.Deduction + .AsNoTracking() + .Where(x => x.Status == "Active") + .Select(x => new LookupItem { Id = x.Id, Name = x.Name, Code = x.Code }) + .ToListAsync(cancellationToken); + + return response; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/UpdateEmployeeDeductionHandler.cs b/Features/Organization/Employee/Cqrs/UpdateEmployeeDeductionHandler.cs new file mode 100644 index 0000000..5758c3f --- /dev/null +++ b/Features/Organization/Employee/Cqrs/UpdateEmployeeDeductionHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class UpdateEmployeeDeductionRequest +{ + public string? Id { get; set; } + public string? DeductionId { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } +} + +public class UpdateEmployeeDeductionResponse +{ + public string? Id { get; set; } +} + +public record UpdateEmployeeDeductionCommand(UpdateEmployeeDeductionRequest Data) : IRequest; + +public class UpdateEmployeeDeductionHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateEmployeeDeductionHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateEmployeeDeductionCommand request, CancellationToken cancellationToken) + { + var entity = await _context.EmployeeDeduction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) throw new NotFoundException("EmployeeDeduction", request.Data.Id ?? string.Empty); + + entity.DeductionId = request.Data.DeductionId; + entity.Amount = request.Data.Amount; + entity.Description = request.Data.Description; + entity.IsActive = request.Data.IsActive; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateEmployeeDeductionResponse { Id = entity.Id }; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/UpdateEmployeeDeductionValidator.cs b/Features/Organization/Employee/Cqrs/UpdateEmployeeDeductionValidator.cs new file mode 100644 index 0000000..0859cfe --- /dev/null +++ b/Features/Organization/Employee/Cqrs/UpdateEmployeeDeductionValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class UpdateEmployeeDeductionValidator : AbstractValidator +{ + public UpdateEmployeeDeductionValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required"); + RuleFor(x => x.DeductionId).NotEmpty().WithMessage("Deduction component is required"); + RuleFor(x => x.Amount).GreaterThanOrEqualTo(0).WithMessage("Amount must be 0 or greater"); + RuleFor(x => x.Description).MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/UpdateEmployeeHandler.cs b/Features/Organization/Employee/Cqrs/UpdateEmployeeHandler.cs new file mode 100644 index 0000000..b9abd7c --- /dev/null +++ b/Features/Organization/Employee/Cqrs/UpdateEmployeeHandler.cs @@ -0,0 +1,82 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class UpdateEmployeeRequest : CreateEmployeeRequest +{ + public string Id { get; set; } = string.Empty; + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateEmployeeResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateEmployeeCommand(UpdateEmployeeRequest Data) : IRequest; + +public class UpdateEmployeeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateEmployeeHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateEmployeeCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Employee.FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + if (entity == null) return new UpdateEmployeeResponse { Id = request.Data.Id, Success = false }; + + var isExists = await _context.Employee.AnyAsync(x => x.Code == request.Data.Code && x.Id != request.Data.Id, cancellationToken); + if (isExists) throw new AlreadyExistsException("Employee", request.Data.Code); + + entity.Code = request.Data.Code; + entity.FirstName = request.Data.FirstName; + entity.MiddleName = request.Data.MiddleName; + entity.LastName = request.Data.LastName; + entity.JobDescription = request.Data.JobDescription; + entity.GradeId = request.Data.GradeId; + entity.BasicSalary = request.Data.BasicSalary; + entity.SalaryBankName = request.Data.SalaryBankName; + entity.SalaryBankAccountName = request.Data.SalaryBankAccountName; + entity.SalaryBankAccountNumber = request.Data.SalaryBankAccountNumber; + entity.PlaceOfBirth = request.Data.PlaceOfBirth; + entity.DateOfBirth = request.Data.DateOfBirth; + entity.Gender = request.Data.Gender; + entity.MaritalStatus = request.Data.MaritalStatus; + entity.Religion = request.Data.Religion; + entity.BloodType = request.Data.BloodType; + entity.IdentityNumber = request.Data.IdentityNumber; + entity.TaxNumber = request.Data.TaxNumber; + entity.LastEducation = request.Data.LastEducation; + entity.JoinedDate = request.Data.JoinedDate; + entity.ResignedDate = request.Data.ResignedDate; + entity.EmployeeStatus = request.Data.EmployeeStatus; + entity.EmploymentType = request.Data.EmploymentType; + entity.StreetAddress = request.Data.StreetAddress; + entity.City = request.Data.City; + entity.StateProvince = request.Data.StateProvince; + entity.ZipCode = request.Data.ZipCode; + entity.Phone = request.Data.Phone; + entity.Email = request.Data.Email; + entity.BranchId = request.Data.BranchId; + entity.DepartmentId = request.Data.DepartmentId; + entity.DesignationId = request.Data.DesignationId; + entity.SocialMediaLinkedIn = request.Data.SocialMediaLinkedIn; + entity.SocialMediaX = request.Data.SocialMediaX; + entity.SocialMediaFacebook = request.Data.SocialMediaFacebook; + entity.SocialMediaInstagram = request.Data.SocialMediaInstagram; + entity.SocialMediaTikTok = request.Data.SocialMediaTikTok; + entity.OtherInformation1 = request.Data.OtherInformation1; + entity.OtherInformation2 = request.Data.OtherInformation2; + entity.OtherInformation3 = request.Data.OtherInformation3; + + await _context.SaveChangesAsync(cancellationToken); + return new UpdateEmployeeResponse { Id = entity.Id, Success = true }; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/UpdateEmployeeIncomeHandler.cs b/Features/Organization/Employee/Cqrs/UpdateEmployeeIncomeHandler.cs new file mode 100644 index 0000000..21641de --- /dev/null +++ b/Features/Organization/Employee/Cqrs/UpdateEmployeeIncomeHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class UpdateEmployeeIncomeRequest +{ + public string? Id { get; set; } + public string? IncomeId { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } +} + +public class UpdateEmployeeIncomeResponse +{ + public string? Id { get; set; } +} + +public record UpdateEmployeeIncomeCommand(UpdateEmployeeIncomeRequest Data) : IRequest; + +public class UpdateEmployeeIncomeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateEmployeeIncomeHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateEmployeeIncomeCommand request, CancellationToken cancellationToken) + { + var entity = await _context.EmployeeIncome + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) throw new NotFoundException("EmployeeIncome", request.Data.Id ?? string.Empty); + + entity.IncomeId = request.Data.IncomeId; + entity.Amount = request.Data.Amount; + entity.Description = request.Data.Description; + entity.IsActive = request.Data.IsActive; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateEmployeeIncomeResponse { Id = entity.Id }; + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/UpdateEmployeeIncomeValidator.cs b/Features/Organization/Employee/Cqrs/UpdateEmployeeIncomeValidator.cs new file mode 100644 index 0000000..c2f52ca --- /dev/null +++ b/Features/Organization/Employee/Cqrs/UpdateEmployeeIncomeValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class UpdateEmployeeIncomeValidator : AbstractValidator +{ + public UpdateEmployeeIncomeValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required"); + RuleFor(x => x.IncomeId).NotEmpty().WithMessage("Income component is required"); + RuleFor(x => x.Amount).GreaterThanOrEqualTo(0).WithMessage("Amount must be 0 or greater"); + RuleFor(x => x.Description).MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/Cqrs/UpdateEmployeeValidator.cs b/Features/Organization/Employee/Cqrs/UpdateEmployeeValidator.cs new file mode 100644 index 0000000..ccd726c --- /dev/null +++ b/Features/Organization/Employee/Cqrs/UpdateEmployeeValidator.cs @@ -0,0 +1,44 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Organization.Employee.Cqrs; + +public class UpdateEmployeeValidator : AbstractValidator +{ + public UpdateEmployeeValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("Employee ID is required for update"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Employee Code is required") + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"Code cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.FirstName) + .NotEmpty().WithMessage("First Name is required") + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"First Name cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.LastName) + .NotEmpty().WithMessage("Last Name is required") + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"Last Name cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.BranchId) + .NotEmpty().WithMessage("Please select a Branch"); + + RuleFor(x => x.DepartmentId) + .NotEmpty().WithMessage("Please select a Department"); + + RuleFor(x => x.DesignationId) + .NotEmpty().WithMessage("Please select a Designation"); + + RuleFor(x => x.GradeId) + .NotEmpty().WithMessage("Please select a Salary Grade"); + + RuleFor(x => x.BasicSalary) + .GreaterThan(0).WithMessage("Basic Salary must be greater than 0"); + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/EmployeeEndpoint.cs b/Features/Organization/Employee/EmployeeEndpoint.cs new file mode 100644 index 0000000..5556bd3 --- /dev/null +++ b/Features/Organization/Employee/EmployeeEndpoint.cs @@ -0,0 +1,66 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Organization.Employee.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Organization.Employee; + +public static class EmployeeEndpoint +{ + public static void MapEmployeeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/employee") + .WithTags("Employees") + .RequireAuthorization(policy => + policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + (await mediator.Send(new GetEmployeeListQuery())).ToApiResponse("Employee list retrieved successfully")); + + group.MapGet("/lookup", async (IMediator mediator) => + (await mediator.Send(new LookupQuery())).ToApiResponse("Lookups retrieved successfully")); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + (await mediator.Send(new GetEmployeeByIdQuery(id))).ToApiResponse("Employee detail retrieved successfully")); + + group.MapPost("/", async (CreateEmployeeRequest request, IMediator mediator) => + (await mediator.Send(new CreateEmployeeCommand(request))).ToApiResponse("Employee created successfully", StatusCodes.Status201Created)); + + group.MapPost("/update", async (UpdateEmployeeRequest request, IMediator mediator) => + (await mediator.Send(new UpdateEmployeeCommand(request))).ToApiResponse("Employee updated successfully")); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + (await mediator.Send(new DeleteEmployeeByIdCommand(new DeleteEmployeeByIdRequest(id)))).ToApiResponse("Employee deleted successfully")); + + group.MapGet("/income/list/{employeeId}", async (string employeeId, IMediator mediator) => + (await mediator.Send(new GetEmployeeIncomeListByEmployeeIdQuery(employeeId))).ToApiResponse("Employee income list retrieved")); + + group.MapGet("/income/{id}", async (string id, IMediator mediator) => + (await mediator.Send(new GetEmployeeIncomeByIdQuery(id))).ToApiResponse("Employee income detail retrieved")); + + group.MapPost("/income", async (CreateEmployeeIncomeRequest request, IMediator mediator) => + (await mediator.Send(new CreateEmployeeIncomeCommand(request))).ToApiResponse("Employee income created", StatusCodes.Status201Created)); + + group.MapPost("/income/update", async (UpdateEmployeeIncomeRequest request, IMediator mediator) => + (await mediator.Send(new UpdateEmployeeIncomeCommand(request))).ToApiResponse("Employee income updated")); + + group.MapPost("/income/delete/{id}", async (string id, IMediator mediator) => + (await mediator.Send(new DeleteEmployeeIncomeByIdCommand(new DeleteEmployeeIncomeByIdRequest(id)))).ToApiResponse("Employee income deleted successfully")); + + group.MapGet("/deduction/list/{employeeId}", async (string employeeId, IMediator mediator) => + (await mediator.Send(new GetEmployeeDeductionListByEmployeeIdQuery(employeeId))).ToApiResponse("Employee deduction list retrieved")); + + group.MapGet("/deduction/{id}", async (string id, IMediator mediator) => + (await mediator.Send(new GetEmployeeDeductionByIdQuery(id))).ToApiResponse("Employee deduction detail retrieved")); + + group.MapPost("/deduction", async (CreateEmployeeDeductionRequest request, IMediator mediator) => + (await mediator.Send(new CreateEmployeeDeductionCommand(request))).ToApiResponse("Employee deduction created", StatusCodes.Status201Created)); + + group.MapPost("/deduction/update", async (UpdateEmployeeDeductionRequest request, IMediator mediator) => + (await mediator.Send(new UpdateEmployeeDeductionCommand(request))).ToApiResponse("Employee deduction updated")); + + group.MapPost("/deduction/delete/{id}", async (string id, IMediator mediator) => + (await mediator.Send(new DeleteEmployeeDeductionByIdCommand(new DeleteEmployeeDeductionByIdRequest(id)))).ToApiResponse("Employee deduction deleted successfully")); + } +} \ No newline at end of file diff --git a/Features/Organization/Employee/EmployeeService.cs b/Features/Organization/Employee/EmployeeService.cs new file mode 100644 index 0000000..f01f5ae --- /dev/null +++ b/Features/Organization/Employee/EmployeeService.cs @@ -0,0 +1,130 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Organization.Employee.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Organization.Employee; + +public class EmployeeService : BaseService +{ + private readonly RestClient _client; + + public EmployeeService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + // --- Employee Core --- + public async Task>?> GetEmployeeListAsync() + { + var request = new RestRequest("api/employee", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetLookupAsync() + { + var request = new RestRequest("api/employee/lookup", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> GetEmployeeByIdAsync(string id) + { + var request = new RestRequest($"api/employee/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateEmployeeAsync(CreateEmployeeRequest data) + { + var request = new RestRequest("api/employee", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateEmployeeAsync(UpdateEmployeeRequest data) + { + var request = new RestRequest("api/employee/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteEmployeeByIdAsync(string id) + { + var request = new RestRequest($"api/employee/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + + // --- Employee Income --- + public async Task>?> GetEmployeeIncomeListAsync(string employeeId) + { + var request = new RestRequest($"api/employee/income/list/{employeeId}", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> CreateEmployeeIncomeAsync(CreateEmployeeIncomeRequest data) + { + var request = new RestRequest("api/employee/income", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateEmployeeIncomeAsync(UpdateEmployeeIncomeRequest data) + { + var request = new RestRequest("api/employee/income/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteEmployeeIncomeByIdAsync(string id) + { + var request = new RestRequest($"api/employee/income/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> GetEmployeeIncomeByIdAsync(string id) + { + var request = new RestRequest($"api/employee/income/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + + // --- Employee Deduction --- + public async Task>?> GetEmployeeDeductionListAsync(string employeeId) + { + var request = new RestRequest($"api/employee/deduction/list/{employeeId}", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> CreateEmployeeDeductionAsync(CreateEmployeeDeductionRequest data) + { + var request = new RestRequest("api/employee/deduction", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateEmployeeDeductionAsync(UpdateEmployeeDeductionRequest data) + { + var request = new RestRequest("api/employee/deduction/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteEmployeeDeductionByIdAsync(string id) + { + var request = new RestRequest($"api/employee/deduction/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + public async Task?> GetEmployeeDeductionByIdAsync(string id) + { + var request = new RestRequest($"api/employee/deduction/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Organization/OrganizationPage.razor b/Features/Organization/OrganizationPage.razor new file mode 100644 index 0000000..eebf8b9 --- /dev/null +++ b/Features/Organization/OrganizationPage.razor @@ -0,0 +1,104 @@ +@page "/organization" +@using Indotalent.Features.Organization.Employee.Components +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] +@using Indotalent.Features.Organization.Branch +@using Indotalent.Features.Organization.Branch.Components +@using Indotalent.Features.Organization.Department +@using Indotalent.Features.Organization.Department.Components +@using Indotalent.Features.Organization.Designation +@using Indotalent.Features.Organization.Designation.Components +@using MudBlazor +@inject NavigationManager NavigationManager + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + _activeTabIndex = tabValue.ToString().ToLower() switch + { + "branch" => 0, + "department" => 1, + "designation" => 2, + "employee" => 3, + _ => 0 + }; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + string tabName = index switch + { + 0 => "branch", + 1 => "department", + 2 => "designation", + 3 => "employee", + _ => "branch" + }; + + NavigationManager.NavigateTo($"/organization?tab={tabName}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/Components/DeductionPage.razor b/Features/Payroll/Deduction/Components/DeductionPage.razor new file mode 100644 index 0000000..ef066a3 --- /dev/null +++ b/Features/Payroll/Deduction/Components/DeductionPage.razor @@ -0,0 +1,27 @@ +@page "/payroll/deduction" +@using Indotalent.Features.Payroll.Deduction.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_DeductionCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_DeductionUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_DeductionDataTable OnAdd="ShowCreate" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateDeductionRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateDeductionRequest data, bool readOnly) { _selectedData = data; _currentView = readOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() => _currentView = ViewMode.Table; + private void HandleSuccess() => _currentView = ViewMode.Table; +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/Components/_DeductionCreateForm.razor b/Features/Payroll/Deduction/Components/_DeductionCreateForm.razor new file mode 100644 index 0000000..c857dd5 --- /dev/null +++ b/Features/Payroll/Deduction/Components/_DeductionCreateForm.razor @@ -0,0 +1,140 @@ +@using Indotalent.Features.Payroll.Deduction +@using Indotalent.Features.Payroll.Deduction.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject DeductionService DeductionService +@inject ISnackbar Snackbar + + +
+ +
+ Add Deduction Component + Define new deduction element for payroll calculation. +
+
+
+ + + + + + Component Code + + + + Component Name + + + + Category + + Statutory + Loan + Penalty + Internal + + + + Tax Treatment +
+ +
+
+ + Calculation Method + + + + Status + + Active + Inactive + + + + Description + + +
+ +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Component + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateDeductionValidator _validator = new(); + private CreateDeductionRequest _model = new() { Category = "Statutory", Status = "Active", PreTax = false }; + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await DeductionService.CreateDeductionAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Deduction component created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/Components/_DeductionDataTable.razor b/Features/Payroll/Deduction/Components/_DeductionDataTable.razor new file mode 100644 index 0000000..719c9ad --- /dev/null +++ b/Features/Payroll/Deduction/Components/_DeductionDataTable.razor @@ -0,0 +1,396 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Payroll.Deduction +@using Indotalent.Features.Payroll.Deduction.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject DeductionService DeductionService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Deduction Components + Manage statutory contributions, insurance, and internal company deductions. +
+
+ + / + Payroll + / + Deduction +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedDeduction != null) + { + View + Edit + Remove + + } + else + { + + Add New Deduction + + } +
+
+ + + + + + Code + + + Component Name + + Category + Pre-Tax + Calculation Method + Status + + + + + + + + @context.Code + + + + @context.Name + + + @context.Category + + + + + + @context.CalculationMethod + + + + @context.Status + + + + + +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _deductions = new(); + private GetDeductionListResponse? _selectedDeduction; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedDeduction = null; + StateHasChanged(); + try + { + var response = await DeductionService.GetDeductionListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _deductions = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _deductions; + return _deductions.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Code?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Category?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedDeduction = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Deductions"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Code"; + worksheet.Cell(currentRow, 2).Value = "Component Name"; + worksheet.Cell(currentRow, 3).Value = "Category"; + worksheet.Cell(currentRow, 4).Value = "Pre-Tax"; + worksheet.Cell(currentRow, 5).Value = "Calculation Method"; + worksheet.Cell(currentRow, 6).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#C62828"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Code; + worksheet.Cell(currentRow, 2).Value = item.Name; + worksheet.Cell(currentRow, 3).Value = item.Category; + worksheet.Cell(currentRow, 4).Value = item.PreTax ? "Yes" : "No"; + worksheet.Cell(currentRow, 5).Value = item.CalculationMethod; + worksheet.Cell(currentRow, 6).Value = item.Status; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Deductions_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedDeduction = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedDeduction = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedDeduction == null) return; + var res = await DeductionService.GetDeductionByIdAsync(_selectedDeduction.Id!); + if (res != null && res.IsSuccess) await OnEdit.InvokeAsync(MapToRequest(res.Value!)); + } + + private async Task InvokeView() + { + if (_selectedDeduction == null) return; + var res = await DeductionService.GetDeductionByIdAsync(_selectedDeduction.Id!); + if (res != null && res.IsSuccess) await OnView.InvokeAsync(MapToRequest(res.Value!)); + } + + private UpdateDeductionRequest MapToRequest(GetDeductionByIdResponse data) => new() + { + Id = data.Id, Code = data.Code, Name = data.Name, Category = data.Category, + PreTax = data.PreTax, CalculationMethod = data.CalculationMethod, Status = data.Status, + Description = data.Description, CreatedAt = data.CreatedAt, CreatedBy = data.CreatedBy, + UpdatedAt = data.UpdatedAt, UpdatedBy = data.UpdatedBy + }; + + private async Task OnDelete() + { + if (_selectedDeduction == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedDeduction.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var isSuccess = await DeductionService.DeleteDeductionByIdAsync(_selectedDeduction.Id!); + if (isSuccess) + { + _selectedDeduction = null; + await LoadData(); + Snackbar.Add("Deduction deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/Components/_DeductionUpdateForm.razor b/Features/Payroll/Deduction/Components/_DeductionUpdateForm.razor new file mode 100644 index 0000000..b757e9d --- /dev/null +++ b/Features/Payroll/Deduction/Components/_DeductionUpdateForm.razor @@ -0,0 +1,146 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Payroll.Deduction +@using Indotalent.Features.Payroll.Deduction.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject DeductionService DeductionService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Deduction Details" : "Edit Deduction Component") + @(ReadOnly ? "Viewing deduction component specification." : "Modify existing deduction element.") +
+
+
+ + + + + + Component Code + + + + Component Name + + + + Category + + Statutory + Loan + Penalty + Internal + + + + Tax Treatment +
+ +
+
+ + Calculation Method + + + + Status + + Active + Inactive + + + + Description + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Processing... + } + else + { + + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateDeductionRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateDeductionValidator _validator = new(); + private UpdateDeductionRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateDeductionRequest + { + Id = Data.Id, + Code = Data.Code, + Name = Data.Name, + Category = Data.Category, + PreTax = Data.PreTax, + CalculationMethod = Data.CalculationMethod, + Status = Data.Status, + Description = Data.Description, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var res = await DeductionService.UpdateDeductionAsync(_model); + await Task.Delay(500); + if (res != null && res.IsSuccess) { Snackbar.Add("Updated successfully", Severity.Success); await OnSuccess.InvokeAsync(); } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/Cqrs/CreateDeductionHandler.cs b/Features/Payroll/Deduction/Cqrs/CreateDeductionHandler.cs new file mode 100644 index 0000000..246233a --- /dev/null +++ b/Features/Payroll/Deduction/Cqrs/CreateDeductionHandler.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Deduction.Cqrs; + +public class CreateDeductionRequest +{ + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Category { get; set; } + public bool PreTax { get; set; } + public string? CalculationMethod { get; set; } + public string? Status { get; set; } = "Active"; +} + +public class CreateDeductionResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateDeductionCommand(CreateDeductionRequest Data) : IRequest; + +public class CreateDeductionHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateDeductionHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateDeductionCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Deduction + .AnyAsync(x => x.Code == request.Data.Code, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Deduction Component", request.Data.Code ?? string.Empty); + } + + var entityName = nameof(Data.Entities.Deduction); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Deduction + { + AutoNumber = autoNo, + Code = request.Data.Code, + Name = request.Data.Name, + Description = request.Data.Description, + Category = request.Data.Category, + PreTax = request.Data.PreTax, + CalculationMethod = request.Data.CalculationMethod, + Status = request.Data.Status + }; + + _context.Deduction.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateDeductionResponse + { + Id = entity.Id, + Code = entity.Code + }; + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/Cqrs/CreateDeductionValidator.cs b/Features/Payroll/Deduction/Cqrs/CreateDeductionValidator.cs new file mode 100644 index 0000000..3369e26 --- /dev/null +++ b/Features/Payroll/Deduction/Cqrs/CreateDeductionValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Payroll.Deduction.Cqrs; + +public class CreateDeductionValidator : AbstractValidator +{ + public CreateDeductionValidator() + { + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CalculationMethod) + .NotEmpty().WithMessage("Calculation Method is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Status) + .NotEmpty().WithMessage("Status is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/Cqrs/DeleteDeductionByIdHandler.cs b/Features/Payroll/Deduction/Cqrs/DeleteDeductionByIdHandler.cs new file mode 100644 index 0000000..ca937e7 --- /dev/null +++ b/Features/Payroll/Deduction/Cqrs/DeleteDeductionByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Deduction.Cqrs; + +public record DeleteDeductionByIdRequest(string Id); + +public record DeleteDeductionByIdCommand(DeleteDeductionByIdRequest Data) : IRequest; + +public class DeleteDeductionByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteDeductionByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteDeductionByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Deduction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Deduction.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/Cqrs/GetDeductionByIdHandler.cs b/Features/Payroll/Deduction/Cqrs/GetDeductionByIdHandler.cs new file mode 100644 index 0000000..820d918 --- /dev/null +++ b/Features/Payroll/Deduction/Cqrs/GetDeductionByIdHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Deduction.Cqrs; + +public class GetDeductionByIdResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Category { get; set; } + public bool PreTax { get; set; } + public string? CalculationMethod { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetDeductionByIdQuery(string Id) : IRequest; + +public class GetDeductionByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetDeductionByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetDeductionByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Deduction + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetDeductionByIdResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + Description = x.Description, + Category = x.Category, + PreTax = x.PreTax, + CalculationMethod = x.CalculationMethod, + Status = x.Status, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/Cqrs/GetDeductionListHandler.cs b/Features/Payroll/Deduction/Cqrs/GetDeductionListHandler.cs new file mode 100644 index 0000000..e80d480 --- /dev/null +++ b/Features/Payroll/Deduction/Cqrs/GetDeductionListHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Deduction.Cqrs; + +public class GetDeductionListResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Category { get; set; } + public bool PreTax { get; set; } + public string? CalculationMethod { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } +} + +public record GetDeductionListQuery() : IRequest>; + +public class GetDeductionListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetDeductionListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetDeductionListQuery request, CancellationToken cancellationToken) + { + return await _context.Deduction + .AsNoTracking() + .OrderBy(x => x.Code) + .Select(x => new GetDeductionListResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + Category = x.Category, + PreTax = x.PreTax, + CalculationMethod = x.CalculationMethod, + Status = x.Status, + CreatedAt = x.CreatedAt + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/Cqrs/UpdateDeductionHandler.cs b/Features/Payroll/Deduction/Cqrs/UpdateDeductionHandler.cs new file mode 100644 index 0000000..f94e2aa --- /dev/null +++ b/Features/Payroll/Deduction/Cqrs/UpdateDeductionHandler.cs @@ -0,0 +1,72 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Deduction.Cqrs; + +public class UpdateDeductionRequest +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Category { get; set; } + public bool PreTax { get; set; } + public string? CalculationMethod { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateDeductionResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateDeductionCommand(UpdateDeductionRequest Data) : IRequest; + +public class UpdateDeductionHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateDeductionHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateDeductionCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Deduction + .AnyAsync(x => x.Code == request.Data.Code && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Deduction Component", request.Data.Code ?? string.Empty); + } + + var entity = await _context.Deduction + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateDeductionResponse { Id = request.Data.Id, Success = false }; + } + + entity.Code = request.Data.Code; + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.Category = request.Data.Category; + entity.PreTax = request.Data.PreTax; + entity.CalculationMethod = request.Data.CalculationMethod; + entity.Status = request.Data.Status; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateDeductionResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/Cqrs/UpdateDeductionValidator.cs b/Features/Payroll/Deduction/Cqrs/UpdateDeductionValidator.cs new file mode 100644 index 0000000..3a331e5 --- /dev/null +++ b/Features/Payroll/Deduction/Cqrs/UpdateDeductionValidator.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Payroll.Deduction.Cqrs; + +public class UpdateDeductionValidator : AbstractValidator +{ + public UpdateDeductionValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CalculationMethod) + .NotEmpty().WithMessage("Calculation Method is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Status) + .NotEmpty().WithMessage("Status is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/DeductionEndpoint.cs b/Features/Payroll/Deduction/DeductionEndpoint.cs new file mode 100644 index 0000000..65e1968 --- /dev/null +++ b/Features/Payroll/Deduction/DeductionEndpoint.cs @@ -0,0 +1,66 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Payroll.Deduction.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Payroll.Deduction; + +public static class DeductionEndpoint +{ + public static void MapDeductionEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/deduction").WithTags("Deductions") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetDeductionListQuery()); + return result.ToApiResponse("Deduction components retrieved successfully"); + }) + .WithName("GetDeductionList") + .WithTags("Deductions"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetDeductionByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Deduction component detail retrieved successfully" + : $"Deduction component with ID {id} not found"); + }) + .WithName("GetDeductionById") + .WithTags("Deductions"); + + group.MapPost("/", async (CreateDeductionRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateDeductionCommand(request)); + return result.ToApiResponse("Deduction component has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateDeduction") + .WithTags("Deductions"); + + group.MapPost("/update", async (UpdateDeductionRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateDeductionCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The deduction data could not be found."); + } + return result.ToApiResponse("Deduction component has been updated successfully"); + }) + .WithName("UpdateDeduction") + .WithTags("Deductions"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteDeductionByIdCommand(new DeleteDeductionByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The deduction data could not be found."); + } + return true.ToApiResponse("Deduction component has been deleted successfully"); + }) + .WithName("DeleteDeductionById") + .WithTags("Deductions"); + } +} \ No newline at end of file diff --git a/Features/Payroll/Deduction/DeductionService.cs b/Features/Payroll/Deduction/DeductionService.cs new file mode 100644 index 0000000..20c0b70 --- /dev/null +++ b/Features/Payroll/Deduction/DeductionService.cs @@ -0,0 +1,59 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Payroll.Deduction.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Payroll.Deduction; + +public class DeductionService : BaseService +{ + private readonly RestClient _client; + + public DeductionService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetDeductionListAsync() + { + var request = new RestRequest("api/deduction", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetDeductionByIdAsync(string id) + { + var request = new RestRequest($"api/deduction/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateDeductionAsync(CreateDeductionRequest data) + { + var request = new RestRequest("api/deduction", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteDeductionByIdAsync(string id) + { + var request = new RestRequest($"api/deduction/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateDeductionAsync(UpdateDeductionRequest data) + { + var request = new RestRequest("api/deduction/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/Components/GradePage.razor b/Features/Payroll/Grade/Components/GradePage.razor new file mode 100644 index 0000000..ce56b4e --- /dev/null +++ b/Features/Payroll/Grade/Components/GradePage.razor @@ -0,0 +1,28 @@ +@page "/payroll/grade" +@using Indotalent.Features.Payroll.Grade +@using Indotalent.Features.Payroll.Grade.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_GradeCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_GradeUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_GradeDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateGradeRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateGradeRequest data, bool isReadOnly) { _selectedData = data; _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; } + private void BackToTable() { _currentView = ViewMode.Table; _selectedData = null; } + private void HandleSuccess() { _currentView = ViewMode.Table; _selectedData = null; } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/Components/_GradeCreateForm.razor b/Features/Payroll/Grade/Components/_GradeCreateForm.razor new file mode 100644 index 0000000..3c90f58 --- /dev/null +++ b/Features/Payroll/Grade/Components/_GradeCreateForm.razor @@ -0,0 +1,133 @@ +@using Indotalent.Features.Payroll.Grade +@using Indotalent.Features.Payroll.Grade.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject GradeService GradeService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Grade + Set up salary structure and overtime eligibility for a new pay grade. +
+
+
+ + + + + + Grade Code + + + + Grade Name + + + + Salary From (Monthly) + + + + Salary To (Monthly) + + + + Overtime Eligibility +
+ +
+
+ + Status + + Active + Inactive + + + + Description + + +
+ +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Grade + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateGradeValidator _validator = new(); + private CreateGradeRequest _model = new() { Status = "Active", IsOverTimeEligible = true }; + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await GradeService.CreateGradeAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Grade created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/Components/_GradeDataTable.razor b/Features/Payroll/Grade/Components/_GradeDataTable.razor new file mode 100644 index 0000000..c968963 --- /dev/null +++ b/Features/Payroll/Grade/Components/_GradeDataTable.razor @@ -0,0 +1,423 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Payroll.Grade +@using Indotalent.Features.Payroll.Grade.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject GradeService GradeService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Salary Grade Configuration + Define salary structures, pay scales, and compensation brackets for the payroll engine. +
+
+ + / + Payroll + / + Grade +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedGrade != null) + { + View + Edit + Remove + + } + else + { + + Add New Grade + + } +
+
+ + + + + + Grade Code + + + Grade Name + + Salary Range (Monthly) + Description + OT Eligible + Status + + + + + + + + @context.Code + + + + @context.Name + + +
+ @context.SalaryFrom.ToString("N0") - @context.SalaryTo.ToString("N0") + +
+
+ + @context.Description + + + + + + + @context.Status + + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _grades = new(); + private GetGradeListResponse? _selectedGrade; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedGrade = null; + StateHasChanged(); + try + { + var response = await GradeService.GetGradeListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _grades = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _grades; + return _grades.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Code?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedGrade = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Salary_Grades"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Grade Code"; + worksheet.Cell(currentRow, 2).Value = "Grade Name"; + worksheet.Cell(currentRow, 3).Value = "Salary From"; + worksheet.Cell(currentRow, 4).Value = "Salary To"; + worksheet.Cell(currentRow, 5).Value = "OT Eligible"; + worksheet.Cell(currentRow, 6).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Code; + worksheet.Cell(currentRow, 2).Value = item.Name; + worksheet.Cell(currentRow, 3).Value = item.SalaryFrom; + worksheet.Cell(currentRow, 4).Value = item.SalaryTo; + worksheet.Cell(currentRow, 5).Value = item.IsOverTimeEligible ? "Yes" : "No"; + worksheet.Cell(currentRow, 6).Value = item.Status; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Grade_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedGrade = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedGrade = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedGrade == null) return; + var request = await MapToUpdateRequest(_selectedGrade.Id!); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedGrade == null) return; + var request = await MapToUpdateRequest(_selectedGrade.Id!); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await GradeService.GetGradeByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateGradeRequest + { + Id = detail.Id, + Code = detail.Code, + Name = detail.Name, + Description = detail.Description, + SalaryFrom = detail.SalaryFrom, + SalaryTo = detail.SalaryTo, + IsOverTimeEligible = detail.IsOverTimeEligible, + Status = detail.Status, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedGrade == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedGrade.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await GradeService.DeleteGradeByIdAsync(_selectedGrade.Id!); + if (isSuccess) + { + _selectedGrade = null; + await LoadData(); + Snackbar.Add("Grade deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/Components/_GradeUpdateForm.razor b/Features/Payroll/Grade/Components/_GradeUpdateForm.razor new file mode 100644 index 0000000..f454bff --- /dev/null +++ b/Features/Payroll/Grade/Components/_GradeUpdateForm.razor @@ -0,0 +1,188 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Payroll.Grade +@using Indotalent.Features.Payroll.Grade.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject GradeService GradeService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Grade Details" : "Edit Grade") + @(ReadOnly ? "Viewing salary structure and eligibility." : "Modify existing pay scale and compensation rules.") +
+
+
+ + + + + + Grade Code + + + + Grade Name + + + + Salary From (Monthly) + + + + Salary To (Monthly) + + + + Overtime Eligibility +
+ +
+
+ + Status + + Active + Inactive + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateGradeRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateGradeValidator _validator = new(); + private UpdateGradeRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateGradeRequest + { + Id = Data.Id, + Code = Data.Code, + Name = Data.Name, + Description = Data.Description, + SalaryFrom = Data.SalaryFrom, + SalaryTo = Data.SalaryTo, + IsOverTimeEligible = Data.IsOverTimeEligible, + Status = Data.Status, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await GradeService.UpdateGradeAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Grade updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/Cqrs/CreateGradeHandler.cs b/Features/Payroll/Grade/Cqrs/CreateGradeHandler.cs new file mode 100644 index 0000000..d01e1f2 --- /dev/null +++ b/Features/Payroll/Grade/Cqrs/CreateGradeHandler.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Grade.Cqrs; + +public class CreateGradeRequest +{ + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public decimal SalaryFrom { get; set; } + public decimal SalaryTo { get; set; } + public bool IsOverTimeEligible { get; set; } + public string? Status { get; set; } = "Active"; +} + +public class CreateGradeResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateGradeCommand(CreateGradeRequest Data) : IRequest; + +public class CreateGradeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateGradeHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateGradeCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Grade + .AnyAsync(x => x.Code == request.Data.Code, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Salary Grade", request.Data.Code ?? string.Empty); + } + + var entityName = nameof(Data.Entities.Grade); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Grade + { + AutoNumber = autoNo, + Code = request.Data.Code, + Name = request.Data.Name, + Description = request.Data.Description, + SalaryFrom = request.Data.SalaryFrom, + SalaryTo = request.Data.SalaryTo, + IsOverTimeEligible = request.Data.IsOverTimeEligible, + Status = request.Data.Status + }; + + _context.Grade.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateGradeResponse + { + Id = entity.Id, + Code = entity.Code + }; + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/Cqrs/CreateGradeValidator.cs b/Features/Payroll/Grade/Cqrs/CreateGradeValidator.cs new file mode 100644 index 0000000..fe0a89f --- /dev/null +++ b/Features/Payroll/Grade/Cqrs/CreateGradeValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Payroll.Grade.Cqrs; + +public class CreateGradeValidator : AbstractValidator +{ + public CreateGradeValidator() + { + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Grade Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Grade Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.SalaryFrom) + .GreaterThanOrEqualTo(0).WithMessage("Salary From must be 0 or greater"); + + RuleFor(x => x.SalaryTo) + .GreaterThanOrEqualTo(x => x.SalaryFrom).WithMessage("Salary To must be greater than or equal to Salary From"); + + RuleFor(x => x.Status) + .NotEmpty().WithMessage("Status is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/Cqrs/DeleteGradeByIdHandler.cs b/Features/Payroll/Grade/Cqrs/DeleteGradeByIdHandler.cs new file mode 100644 index 0000000..b72d5d3 --- /dev/null +++ b/Features/Payroll/Grade/Cqrs/DeleteGradeByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Grade.Cqrs; + +public record DeleteGradeByIdRequest(string Id); + +public record DeleteGradeByIdCommand(DeleteGradeByIdRequest Data) : IRequest; + +public class DeleteGradeByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteGradeByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteGradeByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Grade + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Grade.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/Cqrs/GetGradeByIdHandler.cs b/Features/Payroll/Grade/Cqrs/GetGradeByIdHandler.cs new file mode 100644 index 0000000..2d14b5f --- /dev/null +++ b/Features/Payroll/Grade/Cqrs/GetGradeByIdHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Grade.Cqrs; + +public class GetGradeByIdResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public decimal SalaryFrom { get; set; } + public decimal SalaryTo { get; set; } + public bool IsOverTimeEligible { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetGradeByIdQuery(string Id) : IRequest; + +public class GetGradeByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetGradeByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetGradeByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Grade + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetGradeByIdResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + Description = x.Description, + SalaryFrom = x.SalaryFrom, + SalaryTo = x.SalaryTo, + IsOverTimeEligible = x.IsOverTimeEligible, + Status = x.Status, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/Cqrs/GetGradeListHandler.cs b/Features/Payroll/Grade/Cqrs/GetGradeListHandler.cs new file mode 100644 index 0000000..6385520 --- /dev/null +++ b/Features/Payroll/Grade/Cqrs/GetGradeListHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Grade.Cqrs; + +public class GetGradeListResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public decimal SalaryFrom { get; set; } + public decimal SalaryTo { get; set; } + public string? Description { get; set; } + public bool IsOverTimeEligible { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } +} + +public record GetGradeListQuery() : IRequest>; + +public class GetGradeListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetGradeListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetGradeListQuery request, CancellationToken cancellationToken) + { + return await _context.Grade + .AsNoTracking() + .OrderBy(x => x.Code) + .Select(x => new GetGradeListResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + SalaryFrom = x.SalaryFrom, + SalaryTo = x.SalaryTo, + Description = x.Description, + IsOverTimeEligible = x.IsOverTimeEligible, + Status = x.Status, + CreatedAt = x.CreatedAt + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/Cqrs/UpdateGradeHandler.cs b/Features/Payroll/Grade/Cqrs/UpdateGradeHandler.cs new file mode 100644 index 0000000..d4d7711 --- /dev/null +++ b/Features/Payroll/Grade/Cqrs/UpdateGradeHandler.cs @@ -0,0 +1,72 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Grade.Cqrs; + +public class UpdateGradeRequest +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public decimal SalaryFrom { get; set; } + public decimal SalaryTo { get; set; } + public bool IsOverTimeEligible { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateGradeResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateGradeCommand(UpdateGradeRequest Data) : IRequest; + +public class UpdateGradeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateGradeHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateGradeCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Grade + .AnyAsync(x => x.Code == request.Data.Code && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Salary Grade", request.Data.Code ?? string.Empty); + } + + var entity = await _context.Grade + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateGradeResponse { Id = request.Data.Id, Success = false }; + } + + entity.Code = request.Data.Code; + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.SalaryFrom = request.Data.SalaryFrom; + entity.SalaryTo = request.Data.SalaryTo; + entity.IsOverTimeEligible = request.Data.IsOverTimeEligible; + entity.Status = request.Data.Status; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateGradeResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/Cqrs/UpdateGradeValidator.cs b/Features/Payroll/Grade/Cqrs/UpdateGradeValidator.cs new file mode 100644 index 0000000..ace6638 --- /dev/null +++ b/Features/Payroll/Grade/Cqrs/UpdateGradeValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Payroll.Grade.Cqrs; + +public class UpdateGradeValidator : AbstractValidator +{ + public UpdateGradeValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Grade Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Grade Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.SalaryFrom) + .GreaterThanOrEqualTo(0).WithMessage("Salary From must be 0 or greater"); + + RuleFor(x => x.SalaryTo) + .GreaterThanOrEqualTo(x => x.SalaryFrom).WithMessage("Salary To must be greater than or equal to Salary From"); + + RuleFor(x => x.Status) + .NotEmpty().WithMessage("Status is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/GradeEndpoint.cs b/Features/Payroll/Grade/GradeEndpoint.cs new file mode 100644 index 0000000..dc07d9e --- /dev/null +++ b/Features/Payroll/Grade/GradeEndpoint.cs @@ -0,0 +1,66 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Payroll.Grade.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Payroll.Grade; + +public static class GradeEndpoint +{ + public static void MapGradeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/grade").WithTags("Grades") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetGradeListQuery()); + return result.ToApiResponse("Salary grades retrieved successfully"); + }) + .WithName("GetGradeList") + .WithTags("Grades"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetGradeByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Salary grade detail retrieved successfully" + : $"Salary grade with ID {id} not found"); + }) + .WithName("GetGradeById") + .WithTags("Grades"); + + group.MapPost("/", async (CreateGradeRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateGradeCommand(request)); + return result.ToApiResponse("Salary grade has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateGrade") + .WithTags("Grades"); + + group.MapPost("/update", async (UpdateGradeRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateGradeCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The grade data could not be found."); + } + return result.ToApiResponse("Salary grade has been updated successfully"); + }) + .WithName("UpdateGrade") + .WithTags("Grades"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteGradeByIdCommand(new DeleteGradeByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The grade data could not be found."); + } + return true.ToApiResponse("Salary grade has been deleted successfully"); + }) + .WithName("DeleteGradeById") + .WithTags("Grades"); + } +} \ No newline at end of file diff --git a/Features/Payroll/Grade/GradeService.cs b/Features/Payroll/Grade/GradeService.cs new file mode 100644 index 0000000..c74f0f9 --- /dev/null +++ b/Features/Payroll/Grade/GradeService.cs @@ -0,0 +1,59 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Payroll.Grade.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Payroll.Grade; + +public class GradeService : BaseService +{ + private readonly RestClient _client; + + public GradeService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetGradeListAsync() + { + var request = new RestRequest("api/grade", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetGradeByIdAsync(string id) + { + var request = new RestRequest($"api/grade/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateGradeAsync(CreateGradeRequest data) + { + var request = new RestRequest("api/grade", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteGradeByIdAsync(string id) + { + var request = new RestRequest($"api/grade/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateGradeAsync(UpdateGradeRequest data) + { + var request = new RestRequest("api/grade/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/Components/IncomePage.razor b/Features/Payroll/Income/Components/IncomePage.razor new file mode 100644 index 0000000..a775427 --- /dev/null +++ b/Features/Payroll/Income/Components/IncomePage.razor @@ -0,0 +1,31 @@ +@page "/payroll/income" +@using Indotalent.Features.Payroll.Income.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_IncomeCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_IncomeUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_IncomeDataTable OnAdd="ShowCreate" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateIncomeRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateIncomeRequest data, bool readOnly) + { + _selectedData = data; + _currentView = readOnly ? ViewMode.View : ViewMode.Update; + } + private void BackToTable() => _currentView = ViewMode.Table; + private void HandleSuccess() => _currentView = ViewMode.Table; +} \ No newline at end of file diff --git a/Features/Payroll/Income/Components/_IncomeCreateForm.razor b/Features/Payroll/Income/Components/_IncomeCreateForm.razor new file mode 100644 index 0000000..f7bc69f --- /dev/null +++ b/Features/Payroll/Income/Components/_IncomeCreateForm.razor @@ -0,0 +1,138 @@ +@using Indotalent.Features.Payroll.Income +@using Indotalent.Features.Payroll.Income.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject IncomeService IncomeService +@inject ISnackbar Snackbar + + +
+ +
+ Add Income Component + Define new earning element for payroll processing. +
+
+
+ + + + + + Component Code + + + + Component Name + + + + Income Type + + Fixed + Variable + + + + Tax Treatment +
+ +
+
+ + Calculation Base + + + + Status + + Active + Inactive + + + + Description + + +
+ +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Component + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateIncomeValidator _validator = new(); + private CreateIncomeRequest _model = new() { Type = "Fixed", Status = "Active", IsTaxable = true }; + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await IncomeService.CreateIncomeAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Income component created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/Components/_IncomeDataTable.razor b/Features/Payroll/Income/Components/_IncomeDataTable.razor new file mode 100644 index 0000000..9a3ef85 --- /dev/null +++ b/Features/Payroll/Income/Components/_IncomeDataTable.razor @@ -0,0 +1,394 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Payroll.Income +@using Indotalent.Features.Payroll.Income.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject IncomeService IncomeService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Income Components + Configure earnings, allowances, and bonuses for payroll calculation. +
+
+ + / + Payroll + / + Income +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedIncome != null) + { + View + Edit Component + Remove + + } + else + { + + Add Income Component + + } +
+
+ + + + + + Code + + + Component Name + + + Type + + Taxable + Calculation Base + Status + + + + + + + + @context.Code + + + + @context.Name + + + + @context.Type + + + + + + + @context.CalculationBase + + + + @context.Status + + + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _pageSize, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _incomes = new(); + private GetIncomeListResponse? _selectedIncome; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + private int _skip = 0; + private int _pageSize = 5; + private int _currentPage => (_pageSize >= GetFilteredData().Count() || _pageSize == 0) ? 1 : (_skip / _pageSize) + 1; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_pageSize == 0 ? 1 : _pageSize)); + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedIncome = null; + StateHasChanged(); + try + { + var response = await IncomeService.GetIncomeListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _incomes = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _incomes; + return _incomes.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Code?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Type?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_pageSize); + + private void OnSearchClick() + { + _skip = 0; + _selectedIncome = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Incomes"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Code"; + worksheet.Cell(currentRow, 2).Value = "Component Name"; + worksheet.Cell(currentRow, 3).Value = "Type"; + worksheet.Cell(currentRow, 4).Value = "Is Taxable"; + worksheet.Cell(currentRow, 5).Value = "Calculation Base"; + worksheet.Cell(currentRow, 6).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Code; + worksheet.Cell(currentRow, 2).Value = item.Name; + worksheet.Cell(currentRow, 3).Value = item.Type; + worksheet.Cell(currentRow, 4).Value = item.IsTaxable ? "Yes" : "No"; + worksheet.Cell(currentRow, 5).Value = item.CalculationBase; + worksheet.Cell(currentRow, 6).Value = item.Status; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Income_Components_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _pageSize; + _selectedIncome = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _pageSize = size; + _skip = 0; + _selectedIncome = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedIncome == null) return; + var res = await IncomeService.GetIncomeByIdAsync(_selectedIncome.Id!); + if (res != null && res.IsSuccess) + { + var data = res.Value!; + await OnEdit.InvokeAsync(new UpdateIncomeRequest { + Id = data.Id, Code = data.Code, Name = data.Name, Description = data.Description, + Type = data.Type, IsTaxable = data.IsTaxable, CalculationBase = data.CalculationBase, + Status = data.Status, CreatedAt = data.CreatedAt, CreatedBy = data.CreatedBy, + UpdatedAt = data.UpdatedAt, UpdatedBy = data.UpdatedBy + }); + } + } + + private async Task InvokeView() + { + if (_selectedIncome == null) return; + var res = await IncomeService.GetIncomeByIdAsync(_selectedIncome.Id!); + if (res != null && res.IsSuccess) + { + var data = res.Value!; + await OnView.InvokeAsync(new UpdateIncomeRequest { + Id = data.Id, Code = data.Code, Name = data.Name, Description = data.Description, + Type = data.Type, IsTaxable = data.IsTaxable, CalculationBase = data.CalculationBase, + Status = data.Status, CreatedAt = data.CreatedAt, CreatedBy = data.CreatedBy, + UpdatedAt = data.UpdatedAt, UpdatedBy = data.UpdatedBy + }); + } + } + + private async Task OnDelete() + { + if (_selectedIncome == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedIncome.Name } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + await IncomeService.DeleteIncomeByIdAsync(_selectedIncome.Id!); + await LoadData(); + Snackbar.Add("Deleted successfully", Severity.Success); + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/Components/_IncomeUpdateForm.razor b/Features/Payroll/Income/Components/_IncomeUpdateForm.razor new file mode 100644 index 0000000..dff9836 --- /dev/null +++ b/Features/Payroll/Income/Components/_IncomeUpdateForm.razor @@ -0,0 +1,194 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Payroll.Income +@using Indotalent.Features.Payroll.Income.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject IncomeService IncomeService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Income Details" : "Edit Income Component") + @(ReadOnly ? "Viewing income component specification." : "Modify existing earning element.") +
+
+
+ + + + + + Component Code + + + + Component Name + + + + Income Type + + Fixed + Variable + + + + Tax Treatment +
+ +
+
+ + Calculation Base + + + + Status + + Active + Inactive + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + +
+ +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateIncomeRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateIncomeValidator _validator = new(); + private UpdateIncomeRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateIncomeRequest + { + Id = Data.Id, + Code = Data.Code, + Name = Data.Name, + Description = Data.Description, + Type = Data.Type, + IsTaxable = Data.IsTaxable, + CalculationBase = Data.CalculationBase, + Status = Data.Status, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await IncomeService.UpdateIncomeAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Income component updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/Cqrs/CreateIncomeHandler.cs b/Features/Payroll/Income/Cqrs/CreateIncomeHandler.cs new file mode 100644 index 0000000..b03fab0 --- /dev/null +++ b/Features/Payroll/Income/Cqrs/CreateIncomeHandler.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Income.Cqrs; + +public class CreateIncomeRequest +{ + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Type { get; set; } + public bool IsTaxable { get; set; } = true; + public string? CalculationBase { get; set; } + public string? Status { get; set; } = "Active"; +} + +public class CreateIncomeResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateIncomeCommand(CreateIncomeRequest Data) : IRequest; + +public class CreateIncomeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateIncomeHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateIncomeCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Income + .AnyAsync(x => x.Code == request.Data.Code, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Income Component", request.Data.Code ?? string.Empty); + } + + var entityName = nameof(Data.Entities.Income); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Income + { + AutoNumber = autoNo, + Code = request.Data.Code, + Name = request.Data.Name, + Description = request.Data.Description, + Type = request.Data.Type, + IsTaxable = request.Data.IsTaxable, + CalculationBase = request.Data.CalculationBase, + Status = request.Data.Status + }; + + _context.Income.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateIncomeResponse + { + Id = entity.Id, + Code = entity.Code + }; + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/Cqrs/CreateIncomeValidator.cs b/Features/Payroll/Income/Cqrs/CreateIncomeValidator.cs new file mode 100644 index 0000000..be6cbc4 --- /dev/null +++ b/Features/Payroll/Income/Cqrs/CreateIncomeValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Payroll.Income.Cqrs; + +public class CreateIncomeValidator : AbstractValidator +{ + public CreateIncomeValidator() + { + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Type) + .NotEmpty().WithMessage("Type is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CalculationBase) + .NotEmpty().WithMessage("Calculation Base is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Status) + .NotEmpty().WithMessage("Status is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/Cqrs/DeleteIncomeByIdHandler.cs b/Features/Payroll/Income/Cqrs/DeleteIncomeByIdHandler.cs new file mode 100644 index 0000000..e337112 --- /dev/null +++ b/Features/Payroll/Income/Cqrs/DeleteIncomeByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Income.Cqrs; + +public record DeleteIncomeByIdRequest(string Id); + +public record DeleteIncomeByIdCommand(DeleteIncomeByIdRequest Data) : IRequest; + +public class DeleteIncomeByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteIncomeByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteIncomeByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Income + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Income.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/Cqrs/GetIncomeByIdHandler.cs b/Features/Payroll/Income/Cqrs/GetIncomeByIdHandler.cs new file mode 100644 index 0000000..ba2012a --- /dev/null +++ b/Features/Payroll/Income/Cqrs/GetIncomeByIdHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Income.Cqrs; + +public class GetIncomeByIdResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Type { get; set; } + public bool IsTaxable { get; set; } + public string? CalculationBase { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetIncomeByIdQuery(string Id) : IRequest; + +public class GetIncomeByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetIncomeByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetIncomeByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Income + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetIncomeByIdResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + Description = x.Description, + Type = x.Type, + IsTaxable = x.IsTaxable, + CalculationBase = x.CalculationBase, + Status = x.Status, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/Cqrs/GetIncomeListHandler.cs b/Features/Payroll/Income/Cqrs/GetIncomeListHandler.cs new file mode 100644 index 0000000..8369e18 --- /dev/null +++ b/Features/Payroll/Income/Cqrs/GetIncomeListHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Income.Cqrs; + +public class GetIncomeListResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Type { get; set; } + public bool IsTaxable { get; set; } + public string? CalculationBase { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } +} + +public record GetIncomeListQuery() : IRequest>; + +public class GetIncomeListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetIncomeListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetIncomeListQuery request, CancellationToken cancellationToken) + { + return await _context.Income + .AsNoTracking() + .OrderBy(x => x.Code) + .Select(x => new GetIncomeListResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + Type = x.Type, + IsTaxable = x.IsTaxable, + CalculationBase = x.CalculationBase, + Status = x.Status, + CreatedAt = x.CreatedAt + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/Cqrs/UpdateIncomeHandler.cs b/Features/Payroll/Income/Cqrs/UpdateIncomeHandler.cs new file mode 100644 index 0000000..5e1ad95 --- /dev/null +++ b/Features/Payroll/Income/Cqrs/UpdateIncomeHandler.cs @@ -0,0 +1,72 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Income.Cqrs; + +public class UpdateIncomeRequest +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? Type { get; set; } + public bool IsTaxable { get; set; } + public string? CalculationBase { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateIncomeResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateIncomeCommand(UpdateIncomeRequest Data) : IRequest; + +public class UpdateIncomeHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateIncomeHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateIncomeCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Income + .AnyAsync(x => x.Code == request.Data.Code && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Income Component", request.Data.Code ?? string.Empty); + } + + var entity = await _context.Income + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateIncomeResponse { Id = request.Data.Id, Success = false }; + } + + entity.Code = request.Data.Code; + entity.Name = request.Data.Name; + entity.Description = request.Data.Description; + entity.Type = request.Data.Type; + entity.IsTaxable = request.Data.IsTaxable; + entity.CalculationBase = request.Data.CalculationBase; + entity.Status = request.Data.Status; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateIncomeResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/Cqrs/UpdateIncomeValidator.cs b/Features/Payroll/Income/Cqrs/UpdateIncomeValidator.cs new file mode 100644 index 0000000..376d42a --- /dev/null +++ b/Features/Payroll/Income/Cqrs/UpdateIncomeValidator.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Payroll.Income.Cqrs; + +public class UpdateIncomeValidator : AbstractValidator +{ + public UpdateIncomeValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Type) + .NotEmpty().WithMessage("Type is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CalculationBase) + .NotEmpty().WithMessage("Calculation Base is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Status) + .NotEmpty().WithMessage("Status is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/IncomeEndpoint.cs b/Features/Payroll/Income/IncomeEndpoint.cs new file mode 100644 index 0000000..35c4abd --- /dev/null +++ b/Features/Payroll/Income/IncomeEndpoint.cs @@ -0,0 +1,66 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Payroll.Income.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Payroll.Income; + +public static class IncomeEndpoint +{ + public static void MapIncomeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/income").WithTags("Incomes") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetIncomeListQuery()); + return result.ToApiResponse("Income components retrieved successfully"); + }) + .WithName("GetIncomeList") + .WithTags("Incomes"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetIncomeByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Income component detail retrieved successfully" + : $"Income component with ID {id} not found"); + }) + .WithName("GetIncomeById") + .WithTags("Incomes"); + + group.MapPost("/", async (CreateIncomeRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateIncomeCommand(request)); + return result.ToApiResponse("Income component has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateIncome") + .WithTags("Incomes"); + + group.MapPost("/update", async (UpdateIncomeRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateIncomeCommand(request)); + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The income data could not be found."); + } + return result.ToApiResponse("Income component has been updated successfully"); + }) + .WithName("UpdateIncome") + .WithTags("Incomes"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteIncomeByIdCommand(new DeleteIncomeByIdRequest(id))); + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The income data could not be found."); + } + return true.ToApiResponse("Income component has been deleted successfully"); + }) + .WithName("DeleteIncomeById") + .WithTags("Incomes"); + } +} \ No newline at end of file diff --git a/Features/Payroll/Income/IncomeService.cs b/Features/Payroll/Income/IncomeService.cs new file mode 100644 index 0000000..3789cd0 --- /dev/null +++ b/Features/Payroll/Income/IncomeService.cs @@ -0,0 +1,59 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Payroll.Income.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Payroll.Income; + +public class IncomeService : BaseService +{ + private readonly RestClient _client; + + public IncomeService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetIncomeListAsync() + { + var request = new RestRequest("api/income", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetIncomeByIdAsync(string id) + { + var request = new RestRequest($"api/income/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateIncomeAsync(CreateIncomeRequest data) + { + var request = new RestRequest("api/income", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteIncomeByIdAsync(string id) + { + var request = new RestRequest($"api/income/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateIncomeAsync(UpdateIncomeRequest data) + { + var request = new RestRequest("api/income/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Payroll/PayrollPage.razor b/Features/Payroll/PayrollPage.razor new file mode 100644 index 0000000..0b98adc --- /dev/null +++ b/Features/Payroll/PayrollPage.razor @@ -0,0 +1,105 @@ +@page "/payroll" +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] +@using Indotalent.Features.Payroll.Deduction.Components +@using Indotalent.Features.Payroll.Grade +@using Indotalent.Features.Payroll.Grade.Components +@using Indotalent.Features.Payroll.Income +@using Indotalent.Features.Payroll.Deduction +@using Indotalent.Features.Payroll.Income.Components +@using Indotalent.Features.Payroll.Payrolls +@using Indotalent.Features.Payroll.Payrolls.Components +@using MudBlazor +@inject NavigationManager NavigationManager + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + _activeTabIndex = tabValue.ToString().ToLower() switch + { + "process" => 0, + "grade" => 1, + "income" => 2, + "deduction" => 3, + _ => 0 + }; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + string tabName = index switch + { + 0 => "process", + 1 => "grade", + 2 => "income", + 3 => "deduction", + _ => "process" + }; + + NavigationManager.NavigateTo($"/payroll?tab={tabName}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Components/PayrollsPage.razor b/Features/Payroll/Payrolls/Components/PayrollsPage.razor new file mode 100644 index 0000000..9e862d6 --- /dev/null +++ b/Features/Payroll/Payrolls/Components/PayrollsPage.razor @@ -0,0 +1,66 @@ +@page "/payroll/payrolls" +@using Indotalent.Features.Payroll.Payrolls +@using Indotalent.Features.Payroll.Payrolls.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Table) +{ + <_PayrollProcessDataTable OnAdd="ShowCalculate" + OnEdit="ShowUpdate" + OnView="ShowDetails" /> +} +else if (_currentView == ViewMode.Calculate) +{ + <_PayrollCalculateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update && _selectedUpdateData != null) +{ + <_PayrollUpdateForm Data="_selectedUpdateData" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Details && _selectedProcess != null) +{ + <_PayrollDetailGrid ProcessId="@_selectedProcess.Id" + PeriodName="@_selectedProcess.PeriodName" + OnBack="BackToTable" /> +} + +@code { + private enum ViewMode { Table, Calculate, Details, Update } + private ViewMode _currentView = ViewMode.Table; + private GetPayrollProcessListResponse? _selectedProcess; + private UpdatePayrollProcessRequest? _selectedUpdateData; + + private void ShowCalculate() + { + _currentView = ViewMode.Calculate; + } + + private void ShowUpdate(UpdatePayrollProcessRequest data) + { + _selectedUpdateData = data; + _currentView = ViewMode.Update; + } + + private void ShowDetails(GetPayrollProcessListResponse data) + { + _selectedProcess = data; + _currentView = ViewMode.Details; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedProcess = null; + _selectedUpdateData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedProcess = null; + _selectedUpdateData = null; + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Components/_PayrollCalculateForm.razor b/Features/Payroll/Payrolls/Components/_PayrollCalculateForm.razor new file mode 100644 index 0000000..3d5736e --- /dev/null +++ b/Features/Payroll/Payrolls/Components/_PayrollCalculateForm.razor @@ -0,0 +1,105 @@ +@using Indotalent.Features.Payroll.Payrolls +@using Indotalent.Features.Payroll.Payrolls.Cqrs +@using MudBlazor +@inject PayrollsService PayrollsService +@inject ISnackbar Snackbar + + +
+ +
+ Calculate New Payroll + Specify the period to run the salary engine for all active employees. +
+
+
+ + + + + + Period Name + + + + From Date + + + + To Date + + + + Description / Notes + + + + +
+ + Cancel + + + @if (_processing) + { + + Executing Engine... + } + else + { + Run Calculation + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreatePayrollProcessRequest _model = new() + { + FromDate = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1), + ToDate = new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.DaysInMonth(DateTime.Now.Year, DateTime.Now.Month)) + }; + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await PayrollsService.CreatePayrollProcessAsync(_model); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Payroll has been calculated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Components/_PayrollDetailGrid.razor b/Features/Payroll/Payrolls/Components/_PayrollDetailGrid.razor new file mode 100644 index 0000000..868ace2 --- /dev/null +++ b/Features/Payroll/Payrolls/Components/_PayrollDetailGrid.razor @@ -0,0 +1,276 @@ +@using Indotalent.Features.Payroll.Payrolls +@using Indotalent.Features.Payroll.PayrollDetails.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using ClosedXML.Excel +@using System.IO +@inject PayrollsService PayrollsService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ +
+ Employee Payroll List + Reviewing distribution for period: @PeriodName +
+
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + } + else + { + + Excel + } + + + + @if (_loading) + { + + } + else + { + + Refresh + } + + + @if (_selectedRow != null) + { + + Detail + + + } +
+
+ + + + + NIK + Employee Name + Basic Salary + Incomes + Deductions + Take Home Pay + + + + + + @context.EmployeeCode + @context.EmployeeName + @context.BasicSalary.ToString("N0") + @context.TotalIncome.ToString("N0") + (@context.TotalDeduction.ToString("N0")) + + + @context.TakeHomePay.ToString("N0") + + + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + Page @_currentPage of @(_totalPage == 0 ? 1 : _totalPage) + Next + +
+
+
+ + + + + +@code { + [Parameter] public string ProcessId { get; set; } = string.Empty; + [Parameter] public string PeriodName { get; set; } = string.Empty; + [Parameter] public EventCallback OnBack { get; set; } + + private List _details = new(); + private GetPayrollDetailListByProcessIdResponse? _selectedRow; + private string _searchString = ""; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + private bool _loading = false; + private bool _isExporting = false; + + protected override async Task OnParametersSetAsync() + { + if (!string.IsNullOrEmpty(ProcessId)) await LoadDetails(); + } + + private async Task LoadDetails() + { + _loading = true; + _selectedRow = null; + StateHasChanged(); + var response = await PayrollsService.GetPayrollDetailListByProcessIdAsync(ProcessId); + if (response != null && response.IsSuccess) + { + _details = response.Value ?? new(); + } + _loading = false; + StateHasChanged(); + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _details; + return _details.Where(x => + (x.EmployeeCode?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EmployeeName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() { _skip = 0; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + private void OnPageChanged(int page) { _skip = (page - 1) * _top; _selectedRow = null; StateHasChanged(); } + private void OnPageSizeChanged(int size) { _top = size; _skip = 0; _selectedRow = null; StateHasChanged(); } + + private async Task OpenSlipPopup() + { + if (_selectedRow == null) return; + + var response = await PayrollsService.GetPayrollSlipByIdAsync(_selectedRow.Id!); + if (response != null && response.IsSuccess) + { + var parameters = new DialogParameters<_PayrollSlipDialog> + { + { x => x.Data, response.Value }, + { x => x.PeriodName, PeriodName } + }; + + var options = new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true, CloseButton = true }; + await DialogService.ShowAsync<_PayrollSlipDialog>("", parameters, options); + } + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(500); + using var workbook = new XLWorkbook(); + var worksheet = workbook.Worksheets.Add("EmployeePayroll"); + worksheet.Cell(1, 1).Value = "NIK"; + worksheet.Cell(1, 2).Value = "Employee Name"; + worksheet.Cell(1, 3).Value = "Basic Salary"; + worksheet.Cell(1, 4).Value = "Total Income"; + worksheet.Cell(1, 5).Value = "Total Deduction"; + worksheet.Cell(1, 6).Value = "Take Home Pay"; + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + int row = 2; + foreach (var item in GetFilteredData()) + { + worksheet.Cell(row, 1).Value = item.EmployeeCode; + worksheet.Cell(row, 2).Value = item.EmployeeName; + worksheet.Cell(row, 3).Value = item.BasicSalary; + worksheet.Cell(row, 4).Value = item.TotalIncome; + worksheet.Cell(row, 5).Value = item.TotalDeduction; + worksheet.Cell(row, 6).Value = item.TakeHomePay; + row++; + } + worksheet.Columns().AdjustToContents(); + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + await JSRuntime.InvokeVoidAsync("downloadFile", $"Payroll_Detail_{PeriodName}.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", Convert.ToBase64String(stream.ToArray())); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + catch (Exception ex) { Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); } + finally { _isExporting = false; StateHasChanged(); } + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Components/_PayrollProcessDataTable.razor b/Features/Payroll/Payrolls/Components/_PayrollProcessDataTable.razor new file mode 100644 index 0000000..7915cd7 --- /dev/null +++ b/Features/Payroll/Payrolls/Components/_PayrollProcessDataTable.razor @@ -0,0 +1,364 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Payroll.Payrolls +@using Indotalent.Features.Payroll.Payrolls.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PayrollsService PayrollsService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Payroll Processing & History + Execute monthly salary calculations and review historical payment distributions. +
+ +
+ + / + Payroll + / + Process +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + Remove + + } + else + { + + Calculate Payrolls + + } +
+
+ + + + + + Period + + Total Employees + Total Gross + Total Deduction + Total Net Pay (THP) + Status + + + + + + + @context.PeriodName + @context.FromDate.ToShortDateString() - @context.ToDate.ToShortDateString() + + @context.TotalEmployees Employees + @context.TotalGross.ToString("N0") + (@context.TotalDeduction.ToString("N0")) + + @context.TotalTakeHomePay.ToString("N0") + + + + @context.Status + + + + + +
+
+ Rows per page: + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _payrolls = new(); + private GetPayrollProcessListResponse? _selectedItem; + private string _searchString = ""; + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await PayrollsService.GetPayrollProcessListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _payrolls = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _payrolls; + return _payrolls.Where(x => + (x.PeriodName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Status?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.AutoNumber?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private Color GetStatusColor(string status) => status switch + { + "Paid" => Color.Success, + "Draft" => Color.Warning, + "Processed" => Color.Info, + _ => Color.Default + }; + + private void OnSearchClick() { _skip = 0; _selectedItem = null; StateHasChanged(); } + private void HandleSearchKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") OnSearchClick(); } + private void OnPageChanged(int page) { _skip = (page - 1) * _top; _selectedItem = null; StateHasChanged(); } + private void OnPageSizeChanged(int size) + { + _top = size; _skip = 0; _selectedItem = null; StateHasChanged(); + } + + private async Task InvokeView() + { + if (_selectedItem != null) await OnView.InvokeAsync(_selectedItem); + } + + private async Task InvokeEdit() + { + if (_selectedItem == null) return; + var res = await PayrollsService.GetPayrollProcessByIdAsync(_selectedItem.Id!); + if (res?.Value != null) + { + var updateRequest = new UpdatePayrollProcessRequest + { + Id = res.Value.Id, + Status = res.Value.Status, + Description = res.Value.Description, + PeriodName = res.Value.PeriodName + }; + await OnEdit.InvokeAsync(updateRequest); + } + } + + private async Task OnDelete() + { + if (_selectedItem == null) return; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedItem.PeriodName } }); + if (!(await dialog.Result).Canceled) + { + if (await PayrollsService.DeletePayrollProcessByIdAsync(_selectedItem.Id!)) + { + await LoadData(); + Snackbar.Add("Payroll process deleted", Severity.Success); + } + } + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("PayrollHistory"); + worksheet.Cell(1, 1).Value = "Period"; + worksheet.Cell(1, 2).Value = "Total Employees"; + worksheet.Cell(1, 3).Value = "Total Gross"; + worksheet.Cell(1, 4).Value = "Total Deduction"; + worksheet.Cell(1, 5).Value = "Net Pay (THP)"; + worksheet.Cell(1, 6).Value = "Status"; + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + int row = 2; + foreach (var item in GetFilteredData()) + { + worksheet.Cell(row, 1).Value = item.PeriodName; + worksheet.Cell(row, 2).Value = item.TotalEmployees; + worksheet.Cell(row, 3).Value = item.TotalGross; + worksheet.Cell(row, 4).Value = item.TotalDeduction; + worksheet.Cell(row, 5).Value = item.TotalTakeHomePay; + worksheet.Cell(row, 6).Value = item.Status; + row++; + } + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Payroll_History.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Components/_PayrollSlipDialog.razor b/Features/Payroll/Payrolls/Components/_PayrollSlipDialog.razor new file mode 100644 index 0000000..0db6a95 --- /dev/null +++ b/Features/Payroll/Payrolls/Components/_PayrollSlipDialog.razor @@ -0,0 +1,100 @@ +@using Indotalent.Features.Payroll.PayrollDetails.Cqrs +@using Indotalent.Features.Payroll.Payrolls +@using Indotalent.Shared.Utils +@using Microsoft.JSInterop +@using MudBlazor +@inject IJSRuntime JSRuntime +@inject ISnackbar Snackbar + + + + PAYROLL SLIP DETAILS + + + @if (Data != null) + { + + +
+ + + Employee Name + @Data.EmployeeName +
+ Code + @Data.EmployeeCode +
+
+ + Payroll Period + @PeriodName +
+ Basic Salary + @Data.BasicSalary.ToString("N0") +
+
+
+
+
+ + + Incomes (+) + + @foreach (var inc in Data.Incomes) + { +
+ @inc.ComponentName + @inc.Amount.ToString("N0") +
+ } +
+ + + Deductions (-) + + @foreach (var ded in Data.Deductions) + { +
+ @ded.ComponentName + (@ded.Amount.ToString("N0")) +
+ } +
+ + +
+ TAKE HOME PAY + @Data.TakeHomePay.ToString("N0") +
+
+
+ } +
+ + Close + Print PDF Slip + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public GetPayrollSlipByIdResponse Data { get; set; } = new(); + [Parameter] public string PeriodName { get; set; } = string.Empty; + + private void Cancel() => MudDialog.Cancel(); + + private async Task PrintPdf() + { + try + { + var pdfBytes = PayrollSlipPdfGenerator.Generate(Data, PeriodName); + var fileName = $"Slip_{Data.EmployeeCode}_{DateTime.Now:yyyyMMdd}.pdf"; + await JSRuntime.InvokeVoidAsync("downloadFile", fileName, "application/pdf", Convert.ToBase64String(pdfBytes)); + Snackbar.Add("PDF Slip downloaded", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"Print failed: {ex.Message}", Severity.Error); + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Components/_PayrollUpdateForm.razor b/Features/Payroll/Payrolls/Components/_PayrollUpdateForm.razor new file mode 100644 index 0000000..042f337 --- /dev/null +++ b/Features/Payroll/Payrolls/Components/_PayrollUpdateForm.razor @@ -0,0 +1,112 @@ +@using Indotalent.Features.Payroll.Payrolls +@using Indotalent.Features.Payroll.Payrolls.Cqrs +@using MudBlazor +@inject PayrollsService PayrollsService +@inject ISnackbar Snackbar + + +
+ +
+ Update Payroll Process + Modify process details or update payment status. +
+
+
+ + + + + + Period Name + + + + Status + + + + + + + + Description / Notes + + + + +
+ + Cancel + + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + +
+
+
+ +@code { + [Parameter] public UpdatePayrollProcessRequest Data { get; set; } = new(); + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdatePayrollProcessRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdatePayrollProcessRequest + { + Id = Data.Id, + PeriodName = Data.PeriodName, + Status = Data.Status, + Description = Data.Description + }; + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await PayrollsService.UpdatePayrollProcessAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Payroll process updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Cqrs/CreatePayrollProcessHandler.cs b/Features/Payroll/Payrolls/Cqrs/CreatePayrollProcessHandler.cs new file mode 100644 index 0000000..588d333 --- /dev/null +++ b/Features/Payroll/Payrolls/Cqrs/CreatePayrollProcessHandler.cs @@ -0,0 +1,142 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Payrolls.Cqrs; + +public class CreatePayrollProcessRequest +{ + public string? PeriodName { get; set; } + public DateTime? FromDate { get; set; } + public DateTime? ToDate { get; set; } + public string? Description { get; set; } +} + +public class CreatePayrollProcessResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreatePayrollProcessCommand(CreatePayrollProcessRequest Data) : IRequest; + +public class CreatePayrollProcessHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreatePayrollProcessHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePayrollProcessCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.PayrollProcess + .AnyAsync(x => x.PeriodName == request.Data.PeriodName, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Payroll Period", request.Data.PeriodName ?? string.Empty); + } + + var entityName = nameof(Data.Entities.PayrollProcess); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"PAY/{request.Data.FromDate?.ToString("yyyyMM") ?? DateTime.Now.ToString("yyyyMM")}/", + ct: cancellationToken + ); + + var payrollProcess = new Data.Entities.PayrollProcess + { + AutoNumber = autoNo, + PeriodName = request.Data.PeriodName, + FromDate = request.Data.FromDate ?? DateTime.Now, + ToDate = request.Data.ToDate ?? DateTime.Now, + Description = request.Data.Description, + Status = "Draft", + ProcessedBy = "System Admin", + ProcessedAt = DateTime.Now + }; + + var employees = await _context.Employee + .Include(x => x.EmployeeIncome) + .Include(x => x.EmployeeDeduction) + .Where(x => x.EmployeeStatus == "Active") + .ToListAsync(cancellationToken); + + decimal grandBasicSalary = 0; + decimal grandTotalIncome = 0; + decimal grandTotalDeduction = 0; + + foreach (var emp in employees) + { + var totalIncome = emp.EmployeeIncome?.Sum(x => x.Amount) ?? 0m; + var totalDeduction = emp.EmployeeDeduction?.Sum(x => x.Amount) ?? 0m; + var takeHomePay = emp.BasicSalary + totalIncome - totalDeduction; + + var detail = new Data.Entities.PayrollDetail + { + PayrollProcess = payrollProcess, + EmployeeId = emp.Id, + EmployeeCode = emp.Code, + EmployeeName = $"{emp.FirstName} {emp.LastName}", + BasicSalary = emp.BasicSalary, + TotalIncome = totalIncome, + TotalDeduction = totalDeduction, + TakeHomePay = takeHomePay + }; + + if (emp.EmployeeIncome != null) + { + foreach (var inc in emp.EmployeeIncome) + { + detail.Components.Add(new Data.Entities.PayrollComponent + { + PayrollDetail = detail, + ComponentName = "Income Component", + ComponentType = "Income", + Amount = inc.Amount, + Description = inc.Description + }); + } + } + + if (emp.EmployeeDeduction != null) + { + foreach (var ded in emp.EmployeeDeduction) + { + detail.Components.Add(new Data.Entities.PayrollComponent + { + PayrollDetail = detail, + ComponentName = "Deduction Component", + ComponentType = "Deduction", + Amount = ded.Amount, + Description = ded.Description + }); + } + } + + grandBasicSalary += detail.BasicSalary; + grandTotalIncome += totalIncome; + grandTotalDeduction += totalDeduction; + + _context.PayrollDetail.Add(detail); + } + + payrollProcess.TotalEmployees = employees.Count; + payrollProcess.TotalBasicSalary = grandBasicSalary; + payrollProcess.TotalIncome = grandTotalIncome; + payrollProcess.TotalDeduction = grandTotalDeduction; + payrollProcess.TotalGross = grandBasicSalary + grandTotalIncome; + payrollProcess.TotalTakeHomePay = payrollProcess.TotalGross - grandTotalDeduction; + + _context.PayrollProcess.Add(payrollProcess); + await _context.SaveChangesAsync(cancellationToken); + + return new CreatePayrollProcessResponse + { + Id = payrollProcess.Id, + AutoNumber = payrollProcess.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Cqrs/DeletePayrollProcessByIdHandler.cs b/Features/Payroll/Payrolls/Cqrs/DeletePayrollProcessByIdHandler.cs new file mode 100644 index 0000000..8b749ba --- /dev/null +++ b/Features/Payroll/Payrolls/Cqrs/DeletePayrollProcessByIdHandler.cs @@ -0,0 +1,44 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Payrolls.Cqrs; + +public class DeletePayrollProcessByIdRequest +{ + public string? Id { get; set; } +} + +public class DeletePayrollProcessByIdResponse +{ + public string? Id { get; set; } +} + +public record DeletePayrollProcessByIdCommand(DeletePayrollProcessByIdRequest Data) : IRequest; + +public class DeletePayrollProcessByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeletePayrollProcessByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePayrollProcessByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PayrollProcess + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + throw new NotFoundException("Payroll Process", request.Data.Id ?? string.Empty); + } + + _context.PayrollProcess.Remove(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new DeletePayrollProcessByIdResponse + { + Id = entity.Id + }; + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Cqrs/GetPayrollDetailByIdHandler.cs b/Features/Payroll/Payrolls/Cqrs/GetPayrollDetailByIdHandler.cs new file mode 100644 index 0000000..7691939 --- /dev/null +++ b/Features/Payroll/Payrolls/Cqrs/GetPayrollDetailByIdHandler.cs @@ -0,0 +1,76 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.PayrollDetails.Cqrs; + +public class GetPayrollDetailByIdRequest +{ + public string? Id { get; set; } +} + +public class GetPayrollDetailByIdResponse +{ + public string? Id { get; set; } + public string? PayrollProcessId { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public decimal BasicSalary { get; set; } + public decimal TotalIncome { get; set; } + public decimal TotalDeduction { get; set; } + public decimal TakeHomePay { get; set; } + public List Components { get; set; } = new(); +} + +public class PayrollDetailComponentResponse +{ + public string? ComponentName { get; set; } + public string? ComponentType { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } +} + +public record GetPayrollDetailByIdQuery(GetPayrollDetailByIdRequest Data) : IRequest; + +public class GetPayrollDetailByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetPayrollDetailByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPayrollDetailByIdQuery request, CancellationToken cancellationToken) + { + var entity = await _context.PayrollDetail + .Include(x => x.Components) + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + throw new NotFoundException("Payroll Detail", request.Data.Id ?? string.Empty); + } + + var response = new GetPayrollDetailByIdResponse + { + Id = entity.Id, + PayrollProcessId = entity.PayrollProcessId, + EmployeeId = entity.EmployeeId, + EmployeeCode = entity.EmployeeCode, + EmployeeName = entity.EmployeeName, + BasicSalary = entity.BasicSalary, + TotalIncome = entity.TotalIncome, + TotalDeduction = entity.TotalDeduction, + TakeHomePay = entity.TakeHomePay, + Components = entity.Components.Select(c => new PayrollDetailComponentResponse + { + ComponentName = c.ComponentName, + ComponentType = c.ComponentType, + Amount = c.Amount, + Description = c.Description + }).ToList() + }; + + return response; + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Cqrs/GetPayrollDetailListByProcessIdHandler.cs b/Features/Payroll/Payrolls/Cqrs/GetPayrollDetailListByProcessIdHandler.cs new file mode 100644 index 0000000..603a42e --- /dev/null +++ b/Features/Payroll/Payrolls/Cqrs/GetPayrollDetailListByProcessIdHandler.cs @@ -0,0 +1,51 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.PayrollDetails.Cqrs; + +public class GetPayrollDetailListByProcessIdRequest +{ + public string? PayrollProcessId { get; set; } +} + +public class GetPayrollDetailListByProcessIdResponse +{ + public string? Id { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public decimal BasicSalary { get; set; } + public decimal TotalIncome { get; set; } + public decimal TotalDeduction { get; set; } + public decimal TakeHomePay { get; set; } +} + +public record GetPayrollDetailListByProcessIdQuery(GetPayrollDetailListByProcessIdRequest Data) : IRequest>; + +public class GetPayrollDetailListByProcessIdHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetPayrollDetailListByProcessIdHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetPayrollDetailListByProcessIdQuery request, CancellationToken cancellationToken) + { + var data = await _context.PayrollDetail + .Where(x => x.PayrollProcessId == request.Data.PayrollProcessId) + .Select(x => new GetPayrollDetailListByProcessIdResponse + { + Id = x.Id, + EmployeeId = x.EmployeeId, + EmployeeCode = x.EmployeeCode, + EmployeeName = x.EmployeeName, + BasicSalary = x.BasicSalary, + TotalIncome = x.TotalIncome, + TotalDeduction = x.TotalDeduction, + TakeHomePay = x.TakeHomePay + }) + .ToListAsync(cancellationToken); + + return data; + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Cqrs/GetPayrollProcessByIdHandler.cs b/Features/Payroll/Payrolls/Cqrs/GetPayrollProcessByIdHandler.cs new file mode 100644 index 0000000..2d1c969 --- /dev/null +++ b/Features/Payroll/Payrolls/Cqrs/GetPayrollProcessByIdHandler.cs @@ -0,0 +1,71 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Payrolls.Cqrs; + +public class GetPayrollProcessByIdRequest +{ + public string? Id { get; set; } +} + +public class GetPayrollProcessByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? PeriodName { get; set; } + public DateTime FromDate { get; set; } + public DateTime ToDate { get; set; } + public int TotalEmployees { get; set; } + public decimal TotalBasicSalary { get; set; } + public decimal TotalIncome { get; set; } + public decimal TotalDeduction { get; set; } + public decimal TotalGross { get; set; } + public decimal TotalTakeHomePay { get; set; } + public string? Status { get; set; } + public string? ProcessedBy { get; set; } + public DateTime ProcessedAt { get; set; } + public string? Description { get; set; } +} + +public record GetPayrollProcessByIdQuery(GetPayrollProcessByIdRequest Data) : IRequest; + +public class GetPayrollProcessByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetPayrollProcessByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPayrollProcessByIdQuery request, CancellationToken cancellationToken) + { + var entity = await _context.PayrollProcess + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + throw new NotFoundException("Payroll Process", request.Data.Id ?? string.Empty); + } + + var response = new GetPayrollProcessByIdResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber, + PeriodName = entity.PeriodName, + FromDate = entity.FromDate, + ToDate = entity.ToDate, + TotalEmployees = entity.TotalEmployees, + TotalBasicSalary = entity.TotalBasicSalary, + TotalIncome = entity.TotalIncome, + TotalDeduction = entity.TotalDeduction, + TotalGross = entity.TotalGross, + TotalTakeHomePay = entity.TotalTakeHomePay, + Status = entity.Status, + ProcessedBy = entity.ProcessedBy, + ProcessedAt = entity.ProcessedAt, + Description = entity.Description + }; + + return response; + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Cqrs/GetPayrollProcessListHandler.cs b/Features/Payroll/Payrolls/Cqrs/GetPayrollProcessListHandler.cs new file mode 100644 index 0000000..a5acc29 --- /dev/null +++ b/Features/Payroll/Payrolls/Cqrs/GetPayrollProcessListHandler.cs @@ -0,0 +1,64 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Payrolls.Cqrs; + +public class GetPayrollProcessListRequest +{ +} + +public class GetPayrollProcessListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? PeriodName { get; set; } + public DateTime FromDate { get; set; } + public DateTime ToDate { get; set; } + public int TotalEmployees { get; set; } + public decimal TotalBasicSalary { get; set; } + public decimal TotalIncome { get; set; } + public decimal TotalDeduction { get; set; } + public decimal TotalGross { get; set; } + public decimal TotalTakeHomePay { get; set; } + public string? Status { get; set; } + public string? ProcessedBy { get; set; } + public DateTime ProcessedAt { get; set; } + public string? Description { get; set; } +} + +public record GetPayrollProcessListQuery(GetPayrollProcessListRequest Data) : IRequest>; + +public class GetPayrollProcessListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetPayrollProcessListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetPayrollProcessListQuery request, CancellationToken cancellationToken) + { + var data = await _context.PayrollProcess + .OrderByDescending(x => x.FromDate) + .Select(x => new GetPayrollProcessListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + PeriodName = x.PeriodName, + FromDate = x.FromDate, + ToDate = x.ToDate, + TotalEmployees = x.TotalEmployees, + TotalBasicSalary = x.TotalBasicSalary, + TotalIncome = x.TotalIncome, + TotalDeduction = x.TotalDeduction, + TotalGross = x.TotalGross, + TotalTakeHomePay = x.TotalTakeHomePay, + Status = x.Status, + ProcessedBy = x.ProcessedBy, + ProcessedAt = x.ProcessedAt, + Description = x.Description + }) + .ToListAsync(cancellationToken); + + return data; + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Cqrs/GetPayrollSlipByIdHandler.cs b/Features/Payroll/Payrolls/Cqrs/GetPayrollSlipByIdHandler.cs new file mode 100644 index 0000000..ed6c7a7 --- /dev/null +++ b/Features/Payroll/Payrolls/Cqrs/GetPayrollSlipByIdHandler.cs @@ -0,0 +1,93 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.PayrollDetails.Cqrs; + +public class GetPayrollSlipByIdRequest +{ + public string? Id { get; set; } +} + +public class GetPayrollSlipByIdResponse +{ + public string? Id { get; set; } + public string? EmployeeName { get; set; } + public string? EmployeeCode { get; set; } + public string? DesignationName { get; set; } + public string? DepartmentName { get; set; } + public string? PeriodName { get; set; } + public DateTime FromDate { get; set; } + public DateTime ToDate { get; set; } + public decimal BasicSalary { get; set; } + public decimal TotalIncome { get; set; } + public decimal TotalDeduction { get; set; } + public decimal TakeHomePay { get; set; } + public List Incomes { get; set; } = new(); + public List Deductions { get; set; } = new(); +} + +public class PayrollSlipComponentResponse +{ + public string? ComponentName { get; set; } + public decimal Amount { get; set; } +} + +public record GetPayrollSlipByIdQuery(GetPayrollSlipByIdRequest Data) : IRequest; + +public class GetPayrollSlipByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetPayrollSlipByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPayrollSlipByIdQuery request, CancellationToken cancellationToken) + { + var data = await _context.PayrollDetail + .Include(x => x.PayrollProcess) + .Include(x => x.Components) + .Include(x => x.Employee) + .ThenInclude(e => e!.Designation) + .Include(x => x.Employee) + .ThenInclude(e => e!.Department) + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (data == null) + { + throw new NotFoundException("Payroll Detail", request.Data.Id ?? string.Empty); + } + + var response = new GetPayrollSlipByIdResponse + { + Id = data.Id, + EmployeeName = data.EmployeeName, + EmployeeCode = data.EmployeeCode, + DesignationName = data.Employee?.Designation?.Name, + DepartmentName = data.Employee?.Department?.Name, + PeriodName = data.PayrollProcess?.PeriodName, + FromDate = data.PayrollProcess?.FromDate ?? DateTime.MinValue, + ToDate = data.PayrollProcess?.ToDate ?? DateTime.MinValue, + BasicSalary = data.BasicSalary, + TotalIncome = data.TotalIncome, + TotalDeduction = data.TotalDeduction, + TakeHomePay = data.TakeHomePay, + Incomes = data.Components + .Where(c => c.ComponentType == "Income") + .Select(c => new PayrollSlipComponentResponse + { + ComponentName = c.ComponentName, + Amount = c.Amount + }).ToList(), + Deductions = data.Components + .Where(c => c.ComponentType == "Deduction") + .Select(c => new PayrollSlipComponentResponse + { + ComponentName = c.ComponentName, + Amount = c.Amount + }).ToList() + }; + + return response; + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/Cqrs/UpdatePayrollProcessHandler.cs b/Features/Payroll/Payrolls/Cqrs/UpdatePayrollProcessHandler.cs new file mode 100644 index 0000000..9528c0b --- /dev/null +++ b/Features/Payroll/Payrolls/Cqrs/UpdatePayrollProcessHandler.cs @@ -0,0 +1,52 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Payroll.Payrolls.Cqrs; + +public class UpdatePayrollProcessRequest +{ + public string? Id { get; set; } + public string? Status { get; set; } + public string? Description { get; set; } + public string? PeriodName { get; set; } +} + +public class UpdatePayrollProcessResponse +{ + public string? Id { get; set; } + public string? Status { get; set; } +} + +public record UpdatePayrollProcessCommand(UpdatePayrollProcessRequest Data) : IRequest; + +public class UpdatePayrollProcessHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdatePayrollProcessHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePayrollProcessCommand request, CancellationToken cancellationToken) + { + var entity = await _context.PayrollProcess + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + throw new NotFoundException("Payroll Process", request.Data.Id ?? string.Empty); + } + + entity.Status = request.Data.Status; + entity.Description = request.Data.Description; + entity.PeriodName = request.Data.PeriodName; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdatePayrollProcessResponse + { + Id = entity.Id, + Status = entity.Status + }; + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/PayrollsEndpoint.cs b/Features/Payroll/Payrolls/PayrollsEndpoint.cs new file mode 100644 index 0000000..b6e9204 --- /dev/null +++ b/Features/Payroll/Payrolls/PayrollsEndpoint.cs @@ -0,0 +1,75 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Payroll.Payrolls.Cqrs; +using Indotalent.Features.Payroll.PayrollDetails.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Payroll.Payrolls; + +public static class PayrollsEndpoint +{ + public static void MapPayrollsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/payroll").WithTags("Payrolls") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPayrollProcessListQuery(new GetPayrollProcessListRequest())); + return result.ToApiResponse("Payroll processes retrieved successfully"); + }) + .WithName("GetPayrollProcessList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetPayrollProcessByIdQuery(new GetPayrollProcessByIdRequest { Id = id })); + return result.ToApiResponse(result is not null + ? "Payroll process detail retrieved successfully" + : $"Payroll process with ID {id} not found"); + }) + .WithName("GetPayrollProcessById"); + + group.MapPost("/", async (CreatePayrollProcessRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePayrollProcessCommand(request)); + return result.ToApiResponse("Payroll calculation completed successfully", StatusCodes.Status201Created); + }) + .WithName("CreatePayrollProcess"); + + group.MapPost("/update", async (UpdatePayrollProcessRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePayrollProcessCommand(request)); + return result.ToApiResponse("Payroll process updated successfully"); + }) + .WithName("UpdatePayrollProcess"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePayrollProcessByIdCommand(new DeletePayrollProcessByIdRequest { Id = id })); + return true.ToApiResponse("Payroll process deleted successfully"); + }) + .WithName("DeletePayrollProcessById"); + + group.MapGet("/detail/list/{processId}", async (string processId, IMediator mediator) => + { + var result = await mediator.Send(new GetPayrollDetailListByProcessIdQuery(new GetPayrollDetailListByProcessIdRequest { PayrollProcessId = processId })); + return result.ToApiResponse("Payroll employee details retrieved successfully"); + }) + .WithName("GetPayrollDetailListByProcessId"); + + group.MapGet("/detail/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetPayrollDetailByIdQuery(new GetPayrollDetailByIdRequest { Id = id })); + return result.ToApiResponse("Employee payroll component detail retrieved successfully"); + }) + .WithName("GetPayrollDetailById"); + + group.MapGet("/detail/slip/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetPayrollSlipByIdQuery(new GetPayrollSlipByIdRequest { Id = id })); + return result.ToApiResponse("Payroll slip retrieved successfully"); + }) + .WithName("GetPayrollSlipById"); + } +} \ No newline at end of file diff --git a/Features/Payroll/Payrolls/PayrollsService.cs b/Features/Payroll/Payrolls/PayrollsService.cs new file mode 100644 index 0000000..526eb6b --- /dev/null +++ b/Features/Payroll/Payrolls/PayrollsService.cs @@ -0,0 +1,72 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Payroll.Payrolls.Cqrs; +using Indotalent.Features.Payroll.PayrollDetails.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Payroll.Payrolls; + +public class PayrollsService : BaseService +{ + private readonly RestClient _client; + + public PayrollsService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPayrollProcessListAsync() + { + var request = new RestRequest("api/payroll", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetPayrollProcessByIdAsync(string id) + { + var request = new RestRequest($"api/payroll/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreatePayrollProcessAsync(CreatePayrollProcessRequest data) + { + var request = new RestRequest("api/payroll", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePayrollProcessAsync(UpdatePayrollProcessRequest data) + { + var request = new RestRequest("api/payroll/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePayrollProcessByIdAsync(string id) + { + var request = new RestRequest($"api/payroll/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task>?> GetPayrollDetailListByProcessIdAsync(string processId) + { + var request = new RestRequest($"api/payroll/detail/list/{processId}", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetPayrollDetailByIdAsync(string id) + { + var request = new RestRequest($"api/payroll/detail/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + public async Task?> GetPayrollSlipByIdAsync(string id) + { + var request = new RestRequest($"api/payroll/detail/slip/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/AppraisalEndpoint.cs b/Features/Performance/Appraisal/AppraisalEndpoint.cs new file mode 100644 index 0000000..69a613b --- /dev/null +++ b/Features/Performance/Appraisal/AppraisalEndpoint.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Performance.Appraisal.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Performance.Appraisal; + +public static class AppraisalEndpoint +{ + public static void MapAppraisalEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/performance-appraisal").WithTags("Performance Appraisals") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetAppraisalListQuery()); + return result.ToApiResponse("Appraisal list retrieved successfully"); + }) + .WithName("GetAppraisalList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetAppraisalByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Appraisal detail retrieved successfully" + : $"Appraisal with ID {id} not found"); + }) + .WithName("GetAppraisalById"); + + group.MapPost("/", async (CreateAppraisalRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateAppraisalCommand(request)); + return result.ToApiResponse("Appraisal has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateAppraisal"); + + group.MapPost("/update", async (UpdateAppraisalRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateAppraisalCommand(request)); + if (!result.Success) return ((object?)null).ToApiResponse("Update failed."); + return result.ToApiResponse("Appraisal has been updated successfully"); + }) + .WithName("UpdateAppraisal"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteAppraisalByIdCommand(new DeleteAppraisalByIdRequest(id))); + if (!result) return ((object?)null).ToApiResponse("Delete failed."); + return true.ToApiResponse("Appraisal has been deleted successfully"); + }) + .WithName("DeleteAppraisalById"); + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/AppraisalService.cs b/Features/Performance/Appraisal/AppraisalService.cs new file mode 100644 index 0000000..fb77364 --- /dev/null +++ b/Features/Performance/Appraisal/AppraisalService.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Performance.Appraisal.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Performance.Appraisal; + +public class AppraisalService : BaseService +{ + private readonly RestClient _client; + + public AppraisalService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetAppraisalListAsync() + { + var request = new RestRequest("api/performance-appraisal", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetAppraisalByIdAsync(string id) + { + var request = new RestRequest($"api/performance-appraisal/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateAppraisalAsync(CreateAppraisalRequest data) + { + var request = new RestRequest("api/performance-appraisal", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteAppraisalByIdAsync(string id) + { + var request = new RestRequest($"api/performance-appraisal/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateAppraisalAsync(UpdateAppraisalRequest data) + { + var request = new RestRequest("api/performance-appraisal/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/Components/AppraisalPage.razor b/Features/Performance/Appraisal/Components/AppraisalPage.razor new file mode 100644 index 0000000..121bbe7 --- /dev/null +++ b/Features/Performance/Appraisal/Components/AppraisalPage.razor @@ -0,0 +1,46 @@ +@page "/performance/appraisal" +@using Indotalent.Features.Performance.Appraisal.Components +@using Indotalent.Features.Performance.Appraisal.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_AppraisalCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_AppraisalUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_AppraisalDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateAppraisalRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateAppraisalRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/Components/_AppraisalCreateForm.razor b/Features/Performance/Appraisal/Components/_AppraisalCreateForm.razor new file mode 100644 index 0000000..d7cbee7 --- /dev/null +++ b/Features/Performance/Appraisal/Components/_AppraisalCreateForm.razor @@ -0,0 +1,148 @@ +@using Indotalent.Features.Performance.Appraisal +@using Indotalent.Features.Performance.Appraisal.Cqrs +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveRequest +@using MudBlazor +@inject AppraisalService AppraisalService +@inject LeaveRequestService LeaveRequestService +@inject ISnackbar Snackbar + + +
+ +
+
New Salary Appraisal
+
Initiate a new salary increment or compensation review.
+
+
+
+ + + + + + + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + + + + Annual Review + Promotion + Special Adjustment + + + + + + + + + + + + + + + + + Pending + Approved + Processed + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Appraisal + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateAppraisalRequest _model = new() { EffectiveDate = DateTime.Today, Status = "Pending" }; + private List _employees = new(); + private bool _processing = false; + + private DateTime? _effectiveDate { get => _model.EffectiveDate; set => _model.EffectiveDate = value ?? DateTime.Today; } + + protected override async Task OnInitializedAsync() + { + var empRes = await LeaveRequestService.GetEmployeeReferenceAsync(); + if (empRes?.IsSuccess == true) + { + _employees = empRes.Value ?? new(); + } + } + + private decimal CalculateNewSalary() + { + _model.NewSalary = _model.CurrentSalary + (_model.CurrentSalary * _model.IncrementPercentage / 100); + return _model.NewSalary; + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await AppraisalService.CreateAppraisalAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Appraisal created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/Components/_AppraisalDataTable.razor b/Features/Performance/Appraisal/Components/_AppraisalDataTable.razor new file mode 100644 index 0000000..64c0896 --- /dev/null +++ b/Features/Performance/Appraisal/Components/_AppraisalDataTable.razor @@ -0,0 +1,447 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Performance.Appraisal +@using Indotalent.Features.Performance.Appraisal.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject AppraisalService AppraisalService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Salary & Benefit Appraisal + Manage salary increments, bonuses, and compensation reviews based on performance. +
+ +
+ + / + Performance + / + Appraisal +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedAppraisal != null) + { + View Detail + Manage Appraisal + + + } + else + { + + New Appraisal + + } +
+
+ + + + + + Employee + + + Last Rating + + + Current Salary + + + Increment (%) + + + New Salary + + + Status + + + + + + + +
+ + @GetInitials(context.EmployeeName!) + +
+ @context.EmployeeName + @context.EmployeeCode +
+
+
+ + + @context.LastRating + + + + @context.CurrentSalary.ToString("N0") + + + +@context.IncrementPercentage% + + + @context.NewSalary.ToString("N0") + + + + @context.Status + + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _appraisals = new(); + private GetAppraisalListResponse? _selectedAppraisal; + private string _searchString = ""; + private int _currentPage = 1; + private int _top = 5; + private int _skip => (_currentPage - 1) * _top; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / _top); + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedAppraisal = null; + StateHasChanged(); + + try + { + await Task.Delay(800); + var response = await AppraisalService.GetAppraisalListAsync(); + if (response != null && response.IsSuccess) + { + _appraisals = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _appraisals; + return _appraisals.Where(x => + (x.EmployeeName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EmployeeCode?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.LastRating?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Status?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Appraisals"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Employee Code"; + worksheet.Cell(currentRow, 2).Value = "Employee Name"; + worksheet.Cell(currentRow, 3).Value = "Last Rating"; + worksheet.Cell(currentRow, 4).Value = "Current Salary"; + worksheet.Cell(currentRow, 5).Value = "Increment %"; + worksheet.Cell(currentRow, 6).Value = "New Salary"; + worksheet.Cell(currentRow, 7).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 7); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.EmployeeCode; + worksheet.Cell(currentRow, 2).Value = item.EmployeeName; + worksheet.Cell(currentRow, 3).Value = item.LastRating; + worksheet.Cell(currentRow, 4).Value = item.CurrentSalary; + worksheet.Cell(currentRow, 5).Value = item.IncrementPercentage; + worksheet.Cell(currentRow, 6).Value = item.NewSalary; + worksheet.Cell(currentRow, 7).Value = item.Status; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Salary_Appraisal_Report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnSearchClick() + { + _currentPage = 1; + _selectedAppraisal = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _currentPage = page; + _selectedAppraisal = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _currentPage = 1; + _selectedAppraisal = null; + StateHasChanged(); + } + + private string GetInitials(string name) + { + if (string.IsNullOrWhiteSpace(name)) return "??"; + var parts = name.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 1) return $"{parts[0][0]}{parts[1][0]}".ToUpper(); + return name.Length >= 2 ? name.Substring(0, 2).ToUpper() : name.ToUpper(); + } + + private Color GetRandomColor(string name) + { + int hash = name.GetHashCode(); + var colors = new[] { Color.Primary, Color.Secondary, Color.Tertiary, Color.Info, Color.Success, Color.Warning, Color.Dark }; + return colors[Math.Abs(hash) % colors.Length]; + } + + private Color GetStatusColor(string status) => status switch + { + "Processed" => Color.Success, + "Approved" => Color.Info, + "Pending" => Color.Warning, + _ => Color.Default + }; + + private async Task InvokeEdit() + { + if (_selectedAppraisal != null) + { + var res = await AppraisalService.GetAppraisalByIdAsync(_selectedAppraisal.Id!); + if (res?.Value != null) await OnEdit.InvokeAsync(MapToUpdate(res.Value)); + } + } + + private async Task InvokeView() + { + if (_selectedAppraisal != null) + { + var res = await AppraisalService.GetAppraisalByIdAsync(_selectedAppraisal.Id!); + if (res?.Value != null) await OnView.InvokeAsync(MapToUpdate(res.Value)); + } + } + + private UpdateAppraisalRequest MapToUpdate(GetAppraisalByIdResponse d) => new UpdateAppraisalRequest + { + Id = d.Id, + EmployeeId = d.EmployeeId!, + EmployeeCode = d.EmployeeCode, + EmployeeName = d.EmployeeName, + LastRating = d.LastRating!, + CurrentSalary = d.CurrentSalary, + IncrementPercentage = d.IncrementPercentage, + NewSalary = d.NewSalary, + AppraisalType = d.AppraisalType!, + EffectiveDate = d.EffectiveDate, + AppraisalNote = d.AppraisalNote!, + Status = d.Status!, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + + private async Task OnDelete() + { + if (_selectedAppraisal == null) return; + var dialog = await DialogService.ShowAsync("", new DialogParameters { { x => x.ContentText, $"Appraisal for {_selectedAppraisal.EmployeeName}" } }); + if (!(await dialog.Result).Canceled) + { + if (await AppraisalService.DeleteAppraisalByIdAsync(_selectedAppraisal.Id!)) + { + await LoadData(); Snackbar.Add("Appraisal record removed", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/Components/_AppraisalUpdateForm.razor b/Features/Performance/Appraisal/Components/_AppraisalUpdateForm.razor new file mode 100644 index 0000000..7297752 --- /dev/null +++ b/Features/Performance/Appraisal/Components/_AppraisalUpdateForm.razor @@ -0,0 +1,232 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Performance.Appraisal +@using Indotalent.Features.Performance.Appraisal.Cqrs +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveRequest +@using Indotalent.Shared.Utils +@using MudBlazor +@inject AppraisalService AppraisalService +@inject LeaveRequestService LeaveRequestService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Appraisal Details" : "Edit Appraisal") + Review or modify the compensation adjustment record. +
+
+
+ + + @if (_isInitialLoading) + { +
+ + Syncing appraisal data... +
+ } + else + { + + + + Employee + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + Appraisal Type + + Annual Review + Promotion + Special Adjustment + + + + Last Performance Rating + + + + Effective Date + + + + Status + + Pending + Approved + Processed + + + + Current Salary + + + + Increment Percentage (%) + + + + New Salary + + + + Appraisal Notes + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateAppraisalRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateAppraisalRequest _model = new(); + private UpdateAppraisalValidator _updateValidator = new(); + private List _employees = new(); + private bool _processing = false; + private bool _isInitialLoading = true; + + private DateTime? _effectiveDate { get => _model.EffectiveDate; set => _model.EffectiveDate = value ?? DateTime.Today; } + + protected override async Task OnInitializedAsync() + { + _isInitialLoading = true; + try + { + _model = new UpdateAppraisalRequest + { + Id = Data.Id, + EmployeeId = Data.EmployeeId, + LastRating = Data.LastRating, + CurrentSalary = Data.CurrentSalary, + IncrementPercentage = Data.IncrementPercentage, + NewSalary = Data.NewSalary, + AppraisalType = Data.AppraisalType, + EffectiveDate = Data.EffectiveDate, + AppraisalNote = Data.AppraisalNote, + Status = Data.Status, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + + var empRes = await LeaveRequestService.GetEmployeeReferenceAsync(); + if (empRes?.IsSuccess == true) _employees = empRes.Value ?? new(); + } + finally + { + _isInitialLoading = false; + } + } + + private async Task OnSalaryChanged(decimal value) + { + _model.CurrentSalary = value; + CalculateNewSalary(); + await Task.CompletedTask; + } + + private async Task OnPercentageChanged(decimal value) + { + _model.IncrementPercentage = value; + CalculateNewSalary(); + await Task.CompletedTask; + } + + private void CalculateNewSalary() + { + _model.NewSalary = _model.CurrentSalary + (_model.CurrentSalary * _model.IncrementPercentage / 100); + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await AppraisalService.UpdateAppraisalAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess && response.Value?.Success == true) + { + Snackbar.Add("Appraisal updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/Cqrs/CreateAppraisalHandler.cs b/Features/Performance/Appraisal/Cqrs/CreateAppraisalHandler.cs new file mode 100644 index 0000000..b13a0e5 --- /dev/null +++ b/Features/Performance/Appraisal/Cqrs/CreateAppraisalHandler.cs @@ -0,0 +1,67 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Performance.Appraisal.Cqrs; + +public class CreateAppraisalRequest +{ + public string EmployeeId { get; set; } = string.Empty; + public string LastRating { get; set; } = string.Empty; + public decimal CurrentSalary { get; set; } + public decimal IncrementPercentage { get; set; } + public decimal NewSalary { get; set; } + public string AppraisalType { get; set; } = string.Empty; + public DateTime EffectiveDate { get; set; } + public string AppraisalNote { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} + +public class CreateAppraisalResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateAppraisalCommand(CreateAppraisalRequest Data) : IRequest; + +public class CreateAppraisalHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateAppraisalHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateAppraisalCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Appraisal); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Appraisal + { + AutoNumber = autoNo, + EmployeeId = request.Data.EmployeeId, + LastRating = request.Data.LastRating, + CurrentSalary = request.Data.CurrentSalary, + IncrementPercentage = request.Data.IncrementPercentage, + NewSalary = request.Data.NewSalary, + AppraisalType = request.Data.AppraisalType, + EffectiveDate = request.Data.EffectiveDate, + AppraisalNote = request.Data.AppraisalNote, + Status = request.Data.Status + }; + + _context.Appraisal.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateAppraisalResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/Cqrs/CreateAppraisalValidator.cs b/Features/Performance/Appraisal/Cqrs/CreateAppraisalValidator.cs new file mode 100644 index 0000000..87e26f5 --- /dev/null +++ b/Features/Performance/Appraisal/Cqrs/CreateAppraisalValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace Indotalent.Features.Performance.Appraisal.Cqrs; + +public class CreateAppraisalValidator : AbstractValidator +{ + public CreateAppraisalValidator() + { + RuleFor(x => x.EmployeeId).NotEmpty().WithMessage("Employee is required"); + RuleFor(x => x.AppraisalType).NotEmpty().WithMessage("Appraisal Type is required"); + RuleFor(x => x.EffectiveDate).NotEmpty().WithMessage("Effective Date is required"); + RuleFor(x => x.CurrentSalary).GreaterThanOrEqualTo(0); + RuleFor(x => x.IncrementPercentage).GreaterThanOrEqualTo(0); + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/Cqrs/DeleteAppraisalByIdHandler.cs b/Features/Performance/Appraisal/Cqrs/DeleteAppraisalByIdHandler.cs new file mode 100644 index 0000000..ed2448f --- /dev/null +++ b/Features/Performance/Appraisal/Cqrs/DeleteAppraisalByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Appraisal.Cqrs; + +public record DeleteAppraisalByIdRequest(string Id); + +public record DeleteAppraisalByIdCommand(DeleteAppraisalByIdRequest Data) : IRequest; + +public class DeleteAppraisalByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteAppraisalByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteAppraisalByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Appraisal + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Appraisal.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/Cqrs/GetAppraisalByIdHandler.cs b/Features/Performance/Appraisal/Cqrs/GetAppraisalByIdHandler.cs new file mode 100644 index 0000000..52ab115 --- /dev/null +++ b/Features/Performance/Appraisal/Cqrs/GetAppraisalByIdHandler.cs @@ -0,0 +1,64 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Appraisal.Cqrs; + +public class GetAppraisalByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public string? LastRating { get; set; } + public decimal CurrentSalary { get; set; } + public decimal IncrementPercentage { get; set; } + public decimal NewSalary { get; set; } + public string? AppraisalType { get; set; } + public DateTime EffectiveDate { get; set; } + public string? AppraisalNote { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetAppraisalByIdQuery(string Id) : IRequest; + +public class GetAppraisalByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetAppraisalByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetAppraisalByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Appraisal + .AsNoTracking() + .Include(x => x.Employee) + .Where(x => x.Id == request.Id) + .Select(x => new GetAppraisalByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.EmployeeId, + EmployeeCode = x.Employee != null ? x.Employee.Code : string.Empty, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + LastRating = x.LastRating, + CurrentSalary = x.CurrentSalary, + IncrementPercentage = x.IncrementPercentage, + NewSalary = x.NewSalary, + AppraisalType = x.AppraisalType, + EffectiveDate = x.EffectiveDate, + AppraisalNote = x.AppraisalNote, + Status = x.Status, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/Cqrs/GetAppraisalListHandler.cs b/Features/Performance/Appraisal/Cqrs/GetAppraisalListHandler.cs new file mode 100644 index 0000000..64ceec5 --- /dev/null +++ b/Features/Performance/Appraisal/Cqrs/GetAppraisalListHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Appraisal.Cqrs; + +public class GetAppraisalListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public string? LastRating { get; set; } + public decimal CurrentSalary { get; set; } + public decimal IncrementPercentage { get; set; } + public decimal NewSalary { get; set; } + public string? Status { get; set; } + public DateTime EffectiveDate { get; set; } +} + +public record GetAppraisalListQuery() : IRequest>; + +public class GetAppraisalListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetAppraisalListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetAppraisalListQuery request, CancellationToken cancellationToken) + { + return await _context.Appraisal + .AsNoTracking() + .NotDeletedOnly() + .Include(x => x.Employee) + .OrderByDescending(x => x.EffectiveDate) + .Select(x => new GetAppraisalListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.EmployeeId, + EmployeeCode = x.Employee != null ? x.Employee.Code : string.Empty, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + LastRating = x.LastRating, + CurrentSalary = x.CurrentSalary, + IncrementPercentage = x.IncrementPercentage, + NewSalary = x.NewSalary, + Status = x.Status, + EffectiveDate = x.EffectiveDate + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/Cqrs/UpdateAppraisalHandler.cs b/Features/Performance/Appraisal/Cqrs/UpdateAppraisalHandler.cs new file mode 100644 index 0000000..dfe5efd --- /dev/null +++ b/Features/Performance/Appraisal/Cqrs/UpdateAppraisalHandler.cs @@ -0,0 +1,60 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Appraisal.Cqrs; + +public class UpdateAppraisalRequest : CreateAppraisalRequest +{ + public string? Id { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateAppraisalResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateAppraisalCommand(UpdateAppraisalRequest Data) : IRequest; + +public class UpdateAppraisalHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateAppraisalHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateAppraisalCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Appraisal + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateAppraisalResponse { Id = request.Data.Id, Success = false }; + } + + entity.EmployeeId = request.Data.EmployeeId; + entity.LastRating = request.Data.LastRating; + entity.CurrentSalary = request.Data.CurrentSalary; + entity.IncrementPercentage = request.Data.IncrementPercentage; + entity.NewSalary = request.Data.NewSalary; + entity.AppraisalType = request.Data.AppraisalType; + entity.EffectiveDate = request.Data.EffectiveDate; + entity.AppraisalNote = request.Data.AppraisalNote; + entity.Status = request.Data.Status; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateAppraisalResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Performance/Appraisal/Cqrs/UpdateAppraisalValidator.cs b/Features/Performance/Appraisal/Cqrs/UpdateAppraisalValidator.cs new file mode 100644 index 0000000..bab578a --- /dev/null +++ b/Features/Performance/Appraisal/Cqrs/UpdateAppraisalValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Indotalent.Features.Performance.Appraisal.Cqrs; + +public class UpdateAppraisalValidator : AbstractValidator +{ + public UpdateAppraisalValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required for update"); + RuleFor(x => x.EmployeeId).NotEmpty().WithMessage("Employee is required"); + RuleFor(x => x.AppraisalType).NotEmpty().WithMessage("Appraisal Type is required"); + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/Components/EvaluationPage.razor b/Features/Performance/Evaluation/Components/EvaluationPage.razor new file mode 100644 index 0000000..a3c5c85 --- /dev/null +++ b/Features/Performance/Evaluation/Components/EvaluationPage.razor @@ -0,0 +1,46 @@ +@page "/performance/evaluation" +@using Indotalent.Features.Performance.Evaluation.Components +@using Indotalent.Features.Performance.Evaluation.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_EvaluationCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_EvaluationUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_EvaluationDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateEvaluationRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateEvaluationRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/Components/_EvaluationCreateForm.razor b/Features/Performance/Evaluation/Components/_EvaluationCreateForm.razor new file mode 100644 index 0000000..f2a619c --- /dev/null +++ b/Features/Performance/Evaluation/Components/_EvaluationCreateForm.razor @@ -0,0 +1,130 @@ +@using Indotalent.Features.Performance.Evaluation +@using Indotalent.Features.Performance.Evaluation.Cqrs +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveRequest +@using MudBlazor +@inject EvaluationService EvaluationService +@inject LeaveRequestService LeaveRequestService +@inject ISnackbar Snackbar + + +
+ +
+
New Performance Evaluation
+
Create a new performance assessment record for an employee.
+
+
+
+ + + + + + + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + + + + + + + + + + + Draft + Reviewing + Completed + + + + + + + + +
+ @{ + int.TryParse(_model.Rating, out int ratingVal); + } + +
+
+ + + + +
+
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Evaluation + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateEvaluationRequest _model = new() { EvaluationDate = DateTime.Today, Status = "Draft", Rating = "0" }; + private List _employees = new(); + private bool _processing = false; + + private DateTime? _evalDate { get => _model.EvaluationDate; set => _model.EvaluationDate = value ?? DateTime.Today; } + + protected override async Task OnInitializedAsync() + { + var empRes = await LeaveRequestService.GetEmployeeReferenceAsync(); + if (empRes?.IsSuccess == true) + { + _employees = empRes.Value ?? new(); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await EvaluationService.CreateEvaluationAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Evaluation created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/Components/_EvaluationDataTable.razor b/Features/Performance/Evaluation/Components/_EvaluationDataTable.razor new file mode 100644 index 0000000..641dad1 --- /dev/null +++ b/Features/Performance/Evaluation/Components/_EvaluationDataTable.razor @@ -0,0 +1,450 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Performance.Evaluation +@using Indotalent.Features.Performance.Evaluation.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject EvaluationService EvaluationService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Performance Evaluation + Review and manage employee performance ratings and periodic assessments. +
+ +
+ + / + Performance + / + Evaluation +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedEval != null) + { + View Detail + Edit + Remove + + } + else + { + + New Evaluation + + } +
+
+ + + + + + Employee + + + Period + + + Final Score + + + Rating + + + Evaluator + + + Status + + + + + + + +
+ + @GetInitials(context.EmployeeName!) + +
+ @context.EmployeeName + @context.EmployeeCode +
+
+
+ + @context.Period + + + @context.FinalScore / 100 + + + @{ + int.TryParse(context.Rating, out int ratingValue); + } + + + + @context.EvaluatorName + + + + @context.Status + + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _evaluations = new(); + private GetEvaluationListResponse? _selectedEval; + private string _searchString = ""; + private int _currentPage = 1; + private int _top = 5; + private int _skip => (_currentPage - 1) * _top; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / _top); + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedEval = null; + StateHasChanged(); + + try + { + await Task.Delay(800); + var response = await EvaluationService.GetEvaluationListAsync(); + if (response != null && response.IsSuccess) + { + _evaluations = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _evaluations; + return _evaluations.Where(x => + (x.EmployeeName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EmployeeCode?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Period?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EvaluatorName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Status?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Evaluations"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Employee Code"; + worksheet.Cell(currentRow, 2).Value = "Employee Name"; + worksheet.Cell(currentRow, 3).Value = "Period"; + worksheet.Cell(currentRow, 4).Value = "Final Score"; + worksheet.Cell(currentRow, 5).Value = "Rating"; + worksheet.Cell(currentRow, 6).Value = "Evaluator"; + worksheet.Cell(currentRow, 7).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 7); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.EmployeeCode; + worksheet.Cell(currentRow, 2).Value = item.EmployeeName; + worksheet.Cell(currentRow, 3).Value = item.Period; + worksheet.Cell(currentRow, 4).Value = item.FinalScore; + worksheet.Cell(currentRow, 5).Value = item.Rating; + worksheet.Cell(currentRow, 6).Value = item.EvaluatorName; + worksheet.Cell(currentRow, 7).Value = item.Status; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Performance_Evaluation_Report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnSearchClick() + { + _currentPage = 1; + _selectedEval = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _currentPage = page; + _selectedEval = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _currentPage = 1; + _selectedEval = null; + StateHasChanged(); + } + + private string GetInitials(string name) + { + if (string.IsNullOrWhiteSpace(name)) return "??"; + var parts = name.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 1) return $"{parts[0][0]}{parts[1][0]}".ToUpper(); + return name.Length >= 2 ? name.Substring(0, 2).ToUpper() : name.ToUpper(); + } + + private Color GetRandomColor(string name) + { + int hash = name.GetHashCode(); + var colors = new[] { Color.Primary, Color.Secondary, Color.Tertiary, Color.Info, Color.Success, Color.Warning, Color.Dark }; + return colors[Math.Abs(hash) % colors.Length]; + } + + private Color GetStatusColor(string status) => status switch + { + "Completed" => Color.Success, + "Reviewing" => Color.Info, + "Draft" => Color.Default, + _ => Color.Default + }; + + private async Task InvokeEdit() + { + if (_selectedEval != null) + { + var res = await EvaluationService.GetEvaluationByIdAsync(_selectedEval.Id!); + if (res?.Value != null) await OnEdit.InvokeAsync(MapToUpdate(res.Value)); + } + } + + private async Task InvokeView() + { + if (_selectedEval != null) + { + var res = await EvaluationService.GetEvaluationByIdAsync(_selectedEval.Id!); + if (res?.Value != null) await OnView.InvokeAsync(MapToUpdate(res.Value)); + } + } + + private UpdateEvaluationRequest MapToUpdate(GetEvaluationByIdResponse d) => new UpdateEvaluationRequest + { + Id = d.Id, + EmployeeId = d.EmployeeId!, + EmployeeCode = d.EmployeeCode, + EmployeeName = d.EmployeeName, + Period = d.Period!, + FinalScore = d.FinalScore!, + Rating = d.Rating!, + EvaluatorId = d.EvaluatorId!, + EvaluatorCode = d.EvaluatorCode, + EvaluatorName = d.EvaluatorName, + EvaluationDate = d.EvaluationDate, + EvaluationNote = d.EvaluationNote!, + Status = d.Status!, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + + private async Task OnDelete() + { + if (_selectedEval == null) return; + var dialog = await DialogService.ShowAsync("", new DialogParameters { { x => x.ContentText, $"Evaluation for {_selectedEval.EmployeeName}" } }); + if (!(await dialog.Result).Canceled) + { + if (await EvaluationService.DeleteEvaluationByIdAsync(_selectedEval.Id!)) + { + await LoadData(); Snackbar.Add("Evaluation removed", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/Components/_EvaluationUpdateForm.razor b/Features/Performance/Evaluation/Components/_EvaluationUpdateForm.razor new file mode 100644 index 0000000..f2c62ac --- /dev/null +++ b/Features/Performance/Evaluation/Components/_EvaluationUpdateForm.razor @@ -0,0 +1,197 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Performance.Evaluation +@using Indotalent.Features.Performance.Evaluation.Cqrs +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveRequest +@using MudBlazor +@inject EvaluationService EvaluationService +@inject LeaveRequestService LeaveRequestService +@inject ISnackbar Snackbar + + +
+ +
+
@(ReadOnly ? "Evaluation Details" : "Edit Evaluation")
+
Review or modify the performance assessment record.
+
+
+
+ + + @if (_isInitialLoading) + { +
+ +
Loading data...
+
+ } + else + { + + + + + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + + + + + + + + + + + Draft + Reviewing + Completed + + + + + + + + +
+ @{ + int.TryParse(_model.Rating, out int ratingVal); + } + +
+
+ + + + + + +
Audit History
+ +
+ + + +
@DateTimeExtensions.ToString(_model.CreatedAt)
+
+ + +
@(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System")
+
+ + +
@DateTimeExtensions.ToString(_model.UpdatedAt)
+
+ + +
@(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System")
+
+
+ +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateEvaluationRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateEvaluationRequest _model = new(); + private List _employees = new(); + private bool _processing = false; + private bool _isInitialLoading = true; + + private DateTime? _evalDate { get => _model.EvaluationDate; set => _model.EvaluationDate = value ?? DateTime.Today; } + + protected override async Task OnInitializedAsync() + { + _isInitialLoading = true; + try + { + _model = new UpdateEvaluationRequest + { + Id = Data.Id, + EmployeeId = Data.EmployeeId, + Period = Data.Period, + FinalScore = Data.FinalScore, + Rating = Data.Rating, + EvaluatorId = Data.EvaluatorId, + EvaluationDate = Data.EvaluationDate, + EvaluationNote = Data.EvaluationNote, + Status = Data.Status, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + + var empRes = await LeaveRequestService.GetEmployeeReferenceAsync(); + if (empRes?.IsSuccess == true) + { + _employees = empRes.Value ?? new(); + } + } + finally + { + _isInitialLoading = false; + StateHasChanged(); + } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await EvaluationService.UpdateEvaluationAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess && response.Value?.Success == true) + { + Snackbar.Add("Evaluation updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/Cqrs/CreateEvaluationHandler.cs b/Features/Performance/Evaluation/Cqrs/CreateEvaluationHandler.cs new file mode 100644 index 0000000..e95d13c --- /dev/null +++ b/Features/Performance/Evaluation/Cqrs/CreateEvaluationHandler.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Performance.Evaluation.Cqrs; + +public class CreateEvaluationRequest +{ + public string EmployeeId { get; set; } = string.Empty; + public string Period { get; set; } = string.Empty; + public string FinalScore { get; set; } = string.Empty; + public string Rating { get; set; } = string.Empty; + public string EvaluatorId { get; set; } = string.Empty; + public DateTime EvaluationDate { get; set; } + public string EvaluationNote { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} + +public class CreateEvaluationResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateEvaluationCommand(CreateEvaluationRequest Data) : IRequest; + +public class CreateEvaluationHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateEvaluationHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateEvaluationCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Evaluation); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Evaluation + { + AutoNumber = autoNo, + EmployeeId = request.Data.EmployeeId, + Period = request.Data.Period, + FinalScore = request.Data.FinalScore, + Rating = request.Data.Rating, + EvaluatorId = request.Data.EvaluatorId, + EvaluationDate = request.Data.EvaluationDate, + EvaluationNote = request.Data.EvaluationNote, + Status = request.Data.Status + }; + + _context.Evaluation.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateEvaluationResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/Cqrs/CreateEvaluationValidator.cs b/Features/Performance/Evaluation/Cqrs/CreateEvaluationValidator.cs new file mode 100644 index 0000000..aefcec0 --- /dev/null +++ b/Features/Performance/Evaluation/Cqrs/CreateEvaluationValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Performance.Evaluation.Cqrs; + +public class CreateEvaluationValidator : AbstractValidator +{ + public CreateEvaluationValidator() + { + RuleFor(x => x.EmployeeId) + .NotEmpty().WithMessage("Employee is required"); + + RuleFor(x => x.Period) + .NotEmpty().WithMessage("Evaluation Period is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.FinalScore) + .NotEmpty().WithMessage("Final Score is required"); + + RuleFor(x => x.EvaluatorId) + .NotEmpty().WithMessage("Evaluator is required"); + + RuleFor(x => x.EvaluationDate) + .NotEmpty().WithMessage("Evaluation Date is required"); + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/Cqrs/DeleteEvaluationByIdHandler.cs b/Features/Performance/Evaluation/Cqrs/DeleteEvaluationByIdHandler.cs new file mode 100644 index 0000000..4ae492b --- /dev/null +++ b/Features/Performance/Evaluation/Cqrs/DeleteEvaluationByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Evaluation.Cqrs; + +public record DeleteEvaluationByIdRequest(string Id); + +public record DeleteEvaluationByIdCommand(DeleteEvaluationByIdRequest Data) : IRequest; + +public class DeleteEvaluationByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteEvaluationByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteEvaluationByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Evaluation + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Evaluation.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/Cqrs/GetEvaluationByIdHandler.cs b/Features/Performance/Evaluation/Cqrs/GetEvaluationByIdHandler.cs new file mode 100644 index 0000000..2569015 --- /dev/null +++ b/Features/Performance/Evaluation/Cqrs/GetEvaluationByIdHandler.cs @@ -0,0 +1,67 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Evaluation.Cqrs; + +public class GetEvaluationByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public string? Period { get; set; } + public string? FinalScore { get; set; } + public string? Rating { get; set; } + public string? EvaluatorId { get; set; } + public string? EvaluatorCode { get; set; } + public string? EvaluatorName { get; set; } + public DateTime EvaluationDate { get; set; } + public string? EvaluationNote { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetEvaluationByIdQuery(string Id) : IRequest; + +public class GetEvaluationByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetEvaluationByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetEvaluationByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Evaluation + .AsNoTracking() + .Include(x => x.Employee) + .Include(x => x.Evaluator) + .Where(x => x.Id == request.Id) + .Select(x => new GetEvaluationByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.EmployeeId, + EmployeeCode = x.Employee != null ? x.Employee.Code : string.Empty, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + Period = x.Period, + FinalScore = x.FinalScore, + Rating = x.Rating, + EvaluatorId = x.EvaluatorId, + EvaluatorCode = x.Evaluator != null ? x.Evaluator.Code : string.Empty, + EvaluatorName = x.Evaluator != null ? $"{x.Evaluator.FirstName} {x.Evaluator.LastName}" : string.Empty, + EvaluationDate = x.EvaluationDate, + EvaluationNote = x.EvaluationNote, + Status = x.Status, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/Cqrs/GetEvaluationListHandler.cs b/Features/Performance/Evaluation/Cqrs/GetEvaluationListHandler.cs new file mode 100644 index 0000000..9481161 --- /dev/null +++ b/Features/Performance/Evaluation/Cqrs/GetEvaluationListHandler.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Evaluation.Cqrs; + +public class GetEvaluationListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public string? Period { get; set; } + public string? FinalScore { get; set; } + public string? Rating { get; set; } + public string? EvaluatorName { get; set; } + public string? Status { get; set; } + public DateTime EvaluationDate { get; set; } +} + +public record GetEvaluationListQuery() : IRequest>; + +public class GetEvaluationListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetEvaluationListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetEvaluationListQuery request, CancellationToken cancellationToken) + { + return await _context.Evaluation + .AsNoTracking() + .NotDeletedOnly() + .Include(x => x.Employee) + .Include(x => x.Evaluator) + .OrderByDescending(x => x.EvaluationDate) + .Select(x => new GetEvaluationListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.EmployeeId, + EmployeeCode = x.Employee != null ? x.Employee.Code : string.Empty, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + Period = x.Period, + FinalScore = x.FinalScore, + Rating = x.Rating, + EvaluatorName = x.Evaluator != null ? $"{x.Evaluator.FirstName} {x.Evaluator.LastName}" : string.Empty, + Status = x.Status, + EvaluationDate = x.EvaluationDate + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/Cqrs/UpdateEvaluationHandler.cs b/Features/Performance/Evaluation/Cqrs/UpdateEvaluationHandler.cs new file mode 100644 index 0000000..98cdd23 --- /dev/null +++ b/Features/Performance/Evaluation/Cqrs/UpdateEvaluationHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Evaluation.Cqrs; + +public class UpdateEvaluationRequest : CreateEvaluationRequest +{ + public string? Id { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public string? EvaluatorCode { get; set; } + public string? EvaluatorName { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateEvaluationResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateEvaluationCommand(UpdateEvaluationRequest Data) : IRequest; + +public class UpdateEvaluationHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateEvaluationHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateEvaluationCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Evaluation + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateEvaluationResponse { Id = request.Data.Id, Success = false }; + } + + entity.EmployeeId = request.Data.EmployeeId; + entity.Period = request.Data.Period; + entity.FinalScore = request.Data.FinalScore; + entity.Rating = request.Data.Rating; + entity.EvaluatorId = request.Data.EvaluatorId; + entity.EvaluationDate = request.Data.EvaluationDate; + entity.EvaluationNote = request.Data.EvaluationNote; + entity.Status = request.Data.Status; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateEvaluationResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/Cqrs/UpdateEvaluationValidator.cs b/Features/Performance/Evaluation/Cqrs/UpdateEvaluationValidator.cs new file mode 100644 index 0000000..cacc0fa --- /dev/null +++ b/Features/Performance/Evaluation/Cqrs/UpdateEvaluationValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Performance.Evaluation.Cqrs; + +public class UpdateEvaluationValidator : AbstractValidator +{ + public UpdateEvaluationValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.EmployeeId) + .NotEmpty().WithMessage("Employee is required"); + + RuleFor(x => x.Period) + .NotEmpty().WithMessage("Evaluation Period is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.FinalScore) + .NotEmpty().WithMessage("Final Score is required"); + + RuleFor(x => x.EvaluatorId) + .NotEmpty().WithMessage("Evaluator is required"); + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/EvaluationEndpoint.cs b/Features/Performance/Evaluation/EvaluationEndpoint.cs new file mode 100644 index 0000000..34b756a --- /dev/null +++ b/Features/Performance/Evaluation/EvaluationEndpoint.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Performance.Evaluation.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Performance.Evaluation; + +public static class EvaluationEndpoint +{ + public static void MapEvaluationEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/performance-evaluation").WithTags("Performance Evaluations") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetEvaluationListQuery()); + return result.ToApiResponse("Evaluation list retrieved successfully"); + }) + .WithName("GetEvaluationList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetEvaluationByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Evaluation detail retrieved successfully" + : $"Evaluation with ID {id} not found"); + }) + .WithName("GetEvaluationById"); + + group.MapPost("/", async (CreateEvaluationRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateEvaluationCommand(request)); + return result.ToApiResponse("Evaluation has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateEvaluation"); + + group.MapPost("/update", async (UpdateEvaluationRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateEvaluationCommand(request)); + if (!result.Success) return ((object?)null).ToApiResponse("Update failed."); + return result.ToApiResponse("Evaluation has been updated successfully"); + }) + .WithName("UpdateEvaluation"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteEvaluationByIdCommand(new DeleteEvaluationByIdRequest(id))); + if (!result) return ((object?)null).ToApiResponse("Delete failed."); + return true.ToApiResponse("Evaluation has been deleted successfully"); + }) + .WithName("DeleteEvaluationById"); + } +} \ No newline at end of file diff --git a/Features/Performance/Evaluation/EvaluationService.cs b/Features/Performance/Evaluation/EvaluationService.cs new file mode 100644 index 0000000..4f8c3e5 --- /dev/null +++ b/Features/Performance/Evaluation/EvaluationService.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Performance.Evaluation.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Performance.Evaluation; + +public class EvaluationService : BaseService +{ + private readonly RestClient _client; + + public EvaluationService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetEvaluationListAsync() + { + var request = new RestRequest("api/performance-evaluation", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetEvaluationByIdAsync(string id) + { + var request = new RestRequest($"api/performance-evaluation/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateEvaluationAsync(CreateEvaluationRequest data) + { + var request = new RestRequest("api/performance-evaluation", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteEvaluationByIdAsync(string id) + { + var request = new RestRequest($"api/performance-evaluation/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateEvaluationAsync(UpdateEvaluationRequest data) + { + var request = new RestRequest("api/performance-evaluation/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Performance/PerformancePage.razor b/Features/Performance/PerformancePage.razor new file mode 100644 index 0000000..f5d3ff8 --- /dev/null +++ b/Features/Performance/PerformancePage.razor @@ -0,0 +1,105 @@ +@page "/performance" +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] +@using Indotalent.Features.Performance.Appraisal.Components +@using Indotalent.Features.Performance.Evaluation +@using Indotalent.Features.Performance.Appraisal +@using Indotalent.Features.Performance.Evaluation.Components +@using Indotalent.Features.Performance.Promotion +@using Indotalent.Features.Performance.Promotion.Components +@using Indotalent.Features.Performance.Transfer +@using Indotalent.Features.Performance.Transfer.Components +@using MudBlazor +@inject NavigationManager NavigationManager + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + _activeTabIndex = tabValue.ToString().ToLower() switch + { + "evaluation" => 0, + "appraisal" => 1, + "promotion" => 2, + "transfer" => 3, + _ => 0 + }; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + string tabName = index switch + { + 0 => "evaluation", + 1 => "appraisal", + 2 => "promotion", + 3 => "transfer", + _ => "evaluation" + }; + + NavigationManager.NavigateTo($"/performance?tab={tabName}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/Components/PromotionPage.razor b/Features/Performance/Promotion/Components/PromotionPage.razor new file mode 100644 index 0000000..f4d22c0 --- /dev/null +++ b/Features/Performance/Promotion/Components/PromotionPage.razor @@ -0,0 +1,46 @@ +@page "/performance/promotion" +@using Indotalent.Features.Performance.Promotion.Components +@using Indotalent.Features.Performance.Promotion.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_PromotionCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_PromotionUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_PromotionDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdatePromotionRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdatePromotionRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/Components/_PromotionCreateForm.razor b/Features/Performance/Promotion/Components/_PromotionCreateForm.razor new file mode 100644 index 0000000..e458953 --- /dev/null +++ b/Features/Performance/Promotion/Components/_PromotionCreateForm.razor @@ -0,0 +1,130 @@ +@using Indotalent.Features.Performance.Promotion +@using Indotalent.Features.Performance.Promotion.Cqrs +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveRequest +@using MudBlazor +@inject PromotionService PromotionService +@inject LeaveRequestService LeaveRequestService +@inject ISnackbar Snackbar + + +
+ +
+
Nominate for Promotion
+
Create a new career advancement proposal for an employee.
+
+
+
+ + + @if (_isInitialLoading) + { +
+ +
Preparing resources...
+
+ } + else + { + + + + + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + + + Pending + Confirmed + Canceled + + + + + + + + + + + + + + + + + + + + +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Submit Nomination + } + +
+
+ } +
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreatePromotionRequest _model = new(); + private List _employees = new(); + private bool _processing = false; + private bool _isInitialLoading = true; + + private DateTime? _effectiveDate { get => _model.EffectiveDate; set => _model.EffectiveDate = value ?? DateTime.Today; } + + protected override async Task OnInitializedAsync() + { + _isInitialLoading = true; + try + { + _model = new CreatePromotionRequest { EffectiveDate = DateTime.Today, Status = "Pending" }; + var empRes = await LeaveRequestService.GetEmployeeReferenceAsync(); + if (empRes?.IsSuccess == true) _employees = empRes.Value ?? new(); + } + finally + { + _isInitialLoading = false; + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await PromotionService.CreatePromotionAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Promotion nomination created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/Components/_PromotionDataTable.razor b/Features/Performance/Promotion/Components/_PromotionDataTable.razor new file mode 100644 index 0000000..9245fd9 --- /dev/null +++ b/Features/Performance/Promotion/Components/_PromotionDataTable.razor @@ -0,0 +1,439 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Performance.Promotion +@using Indotalent.Features.Performance.Promotion.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject PromotionService PromotionService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Career Promotion + Manage employee career advancement, grade changes, and role elevations. +
+ +
+ + / + Performance + / + Promotion +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedPromotion != null) + { + View Detail + Confirm Promotion + + + } + else + { + + Nominate Employee + + } +
+
+ + + + + + Employee + + + From (Current) + + + + To (Proposed) + + + Effective Date + + + Status + + + + + + + +
+ + @GetInitials(context.EmployeeName!) + +
+ @context.EmployeeName + @context.EmployeeCode +
+
+
+ + @context.FromGrade + + + + + + @context.ToGrade + + + @context.EffectiveDate?.ToString("dd MMM yyyy") + + + + @context.Status + + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _promotions = new(); + private GetPromotionListResponse? _selectedPromotion; + private string _searchString = ""; + private int _currentPage = 1; + private int _top = 5; + private int _skip => (_currentPage - 1) * _top; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / _top); + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedPromotion = null; + StateHasChanged(); + + try + { + await Task.Delay(800); + var response = await PromotionService.GetPromotionListAsync(); + if (response != null && response.IsSuccess) + { + _promotions = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _promotions; + return _promotions.Where(x => + (x.EmployeeName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EmployeeCode?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.FromGrade?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ToGrade?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Status?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Promotions"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Employee Code"; + worksheet.Cell(currentRow, 2).Value = "Employee Name"; + worksheet.Cell(currentRow, 3).Value = "Current Grade"; + worksheet.Cell(currentRow, 4).Value = "Proposed Grade"; + worksheet.Cell(currentRow, 5).Value = "Effective Date"; + worksheet.Cell(currentRow, 6).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 6); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.EmployeeCode; + worksheet.Cell(currentRow, 2).Value = item.EmployeeName; + worksheet.Cell(currentRow, 3).Value = item.FromGrade; + worksheet.Cell(currentRow, 4).Value = item.ToGrade; + worksheet.Cell(currentRow, 5).Value = item.EffectiveDate?.ToString("dd-MMM-yyyy"); + worksheet.Cell(currentRow, 6).Value = item.Status; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Employee_Promotion_Report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnSearchClick() + { + _currentPage = 1; + _selectedPromotion = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _currentPage = page; + _selectedPromotion = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _currentPage = 1; + _selectedPromotion = null; + StateHasChanged(); + } + + private string GetInitials(string name) + { + if (string.IsNullOrWhiteSpace(name)) return "??"; + var parts = name.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 1) return $"{parts[0][0]}{parts[1][0]}".ToUpper(); + return name.Length >= 2 ? name.Substring(0, 2).ToUpper() : name.ToUpper(); + } + + private Color GetRandomColor(string name) + { + int hash = name.GetHashCode(); + var colors = new[] { Color.Primary, Color.Secondary, Color.Tertiary, Color.Info, Color.Success, Color.Warning, Color.Dark }; + return colors[Math.Abs(hash) % colors.Length]; + } + + private Color GetStatusColor(string status) => status switch + { + "Confirmed" => Color.Success, + "Pending" => Color.Warning, + "Canceled" => Color.Error, + _ => Color.Default + }; + + private async Task InvokeEdit() + { + if (_selectedPromotion != null) + { + var res = await PromotionService.GetPromotionByIdAsync(_selectedPromotion.Id!); + if (res?.Value != null) await OnEdit.InvokeAsync(MapToUpdate(res.Value)); + } + } + + private async Task InvokeView() + { + if (_selectedPromotion != null) + { + var res = await PromotionService.GetPromotionByIdAsync(_selectedPromotion.Id!); + if (res?.Value != null) await OnView.InvokeAsync(MapToUpdate(res.Value)); + } + } + + private UpdatePromotionRequest MapToUpdate(GetPromotionByIdResponse d) => new UpdatePromotionRequest + { + Id = d.Id, + EmployeeId = d.EmployeeId!, + EmployeeCode = d.EmployeeCode, + EmployeeName = d.EmployeeName, + FromGrade = d.FromGrade!, + ToGrade = d.ToGrade!, + EffectiveDate = d.EffectiveDate, + PromotionNote = d.PromotionNote!, + Status = d.Status!, + CreatedAt = d.CreatedAt, + CreatedBy = d.CreatedBy, + UpdatedAt = d.UpdatedAt, + UpdatedBy = d.UpdatedBy + }; + + private async Task OnDelete() + { + if (_selectedPromotion == null) return; + var dialog = await DialogService.ShowAsync("", new DialogParameters { { x => x.ContentText, $"Promotion for {_selectedPromotion.EmployeeName}" } }); + if (!(await dialog.Result).Canceled) + { + if (await PromotionService.DeletePromotionByIdAsync(_selectedPromotion.Id!)) + { + await LoadData(); Snackbar.Add("Promotion record removed", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/Components/_PromotionUpdateForm.razor b/Features/Performance/Promotion/Components/_PromotionUpdateForm.razor new file mode 100644 index 0000000..0c02c53 --- /dev/null +++ b/Features/Performance/Promotion/Components/_PromotionUpdateForm.razor @@ -0,0 +1,174 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Performance.Promotion +@using Indotalent.Features.Performance.Promotion.Cqrs +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveRequest +@using MudBlazor +@inject PromotionService PromotionService +@inject LeaveRequestService LeaveRequestService +@inject ISnackbar Snackbar + + +
+ +
+
@(ReadOnly ? "Promotion Details" : "Edit Promotion Nomination")
+
Review or modify the career advancement record.
+
+
+
+ + + @if (_isInitialLoading) + { +
+ +
Syncing record...
+
+ } + else + { + + + + + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + + + Pending + Confirmed + Canceled + + + + + + + + + + + + + + + + + + + + +
Audit History
+ +
+ + + +
@DateTimeExtensions.ToString(_model.CreatedAt)
+
+ + +
@(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System")
+
+ + +
@DateTimeExtensions.ToString(_model.UpdatedAt)
+
+ + +
@(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System")
+
+
+ +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdatePromotionRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdatePromotionRequest _model = new(); + private List _employees = new(); + private bool _processing = false; + private bool _isInitialLoading = true; + + private DateTime? _effectiveDate { get => _model.EffectiveDate; set => _model.EffectiveDate = value ?? DateTime.Today; } + + protected override async Task OnInitializedAsync() + { + _isInitialLoading = true; + try + { + _model = new UpdatePromotionRequest + { + Id = Data.Id, + EmployeeId = Data.EmployeeId, + FromGrade = Data.FromGrade, + ToGrade = Data.ToGrade, + EffectiveDate = Data.EffectiveDate, + PromotionNote = Data.PromotionNote, + Status = Data.Status, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + + var empRes = await LeaveRequestService.GetEmployeeReferenceAsync(); + if (empRes?.IsSuccess == true) _employees = empRes.Value ?? new(); + } + finally + { + _isInitialLoading = false; + StateHasChanged(); + } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await PromotionService.UpdatePromotionAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess && response.Value?.Success == true) + { + Snackbar.Add("Promotion updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/Cqrs/CreatePromotionHandler.cs b/Features/Performance/Promotion/Cqrs/CreatePromotionHandler.cs new file mode 100644 index 0000000..f9958dd --- /dev/null +++ b/Features/Performance/Promotion/Cqrs/CreatePromotionHandler.cs @@ -0,0 +1,61 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Performance.Promotion.Cqrs; + +public class CreatePromotionRequest +{ + public string EmployeeId { get; set; } = string.Empty; + public string FromGrade { get; set; } = string.Empty; + public string ToGrade { get; set; } = string.Empty; + public DateTime? EffectiveDate { get; set; } + public string PromotionNote { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} + +public class CreatePromotionResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreatePromotionCommand(CreatePromotionRequest Data) : IRequest; + +public class CreatePromotionHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreatePromotionHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreatePromotionCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Promotion); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Promotion + { + AutoNumber = autoNo, + EmployeeId = request.Data.EmployeeId, + FromGrade = request.Data.FromGrade, + ToGrade = request.Data.ToGrade, + EffectiveDate = request.Data.EffectiveDate, + PromotionNote = request.Data.PromotionNote, + Status = request.Data.Status + }; + + _context.Promotion.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreatePromotionResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/Cqrs/CreatePromotionValidator.cs b/Features/Performance/Promotion/Cqrs/CreatePromotionValidator.cs new file mode 100644 index 0000000..773f92c --- /dev/null +++ b/Features/Performance/Promotion/Cqrs/CreatePromotionValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Indotalent.Features.Performance.Promotion.Cqrs; + +public class CreatePromotionValidator : AbstractValidator +{ + public CreatePromotionValidator() + { + RuleFor(x => x.EmployeeId).NotEmpty().WithMessage("Employee is required"); + RuleFor(x => x.ToGrade).NotEmpty().WithMessage("Target Grade is required"); + RuleFor(x => x.EffectiveDate).NotEmpty().WithMessage("Effective Date is required"); + } +} + diff --git a/Features/Performance/Promotion/Cqrs/DeletePromotionByIdHandler.cs b/Features/Performance/Promotion/Cqrs/DeletePromotionByIdHandler.cs new file mode 100644 index 0000000..2c60773 --- /dev/null +++ b/Features/Performance/Promotion/Cqrs/DeletePromotionByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Promotion.Cqrs; + +public record DeletePromotionByIdRequest(string Id); + +public record DeletePromotionByIdCommand(DeletePromotionByIdRequest Data) : IRequest; + +public class DeletePromotionByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeletePromotionByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeletePromotionByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Promotion + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Promotion.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/Cqrs/GetPromotionByIdHandler.cs b/Features/Performance/Promotion/Cqrs/GetPromotionByIdHandler.cs new file mode 100644 index 0000000..1f255e3 --- /dev/null +++ b/Features/Performance/Promotion/Cqrs/GetPromotionByIdHandler.cs @@ -0,0 +1,58 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Promotion.Cqrs; + +public class GetPromotionByIdResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public string? FromGrade { get; set; } + public string? ToGrade { get; set; } + public DateTime? EffectiveDate { get; set; } + public string? PromotionNote { get; set; } + public string? Status { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetPromotionByIdQuery(string Id) : IRequest; + +public class GetPromotionByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetPromotionByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetPromotionByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Promotion + .AsNoTracking() + .Include(x => x.Employee) + .Where(x => x.Id == request.Id) + .Select(x => new GetPromotionByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.EmployeeId, + EmployeeCode = x.Employee != null ? x.Employee.Code : string.Empty, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + FromGrade = x.FromGrade, + ToGrade = x.ToGrade, + EffectiveDate = x.EffectiveDate, + PromotionNote = x.PromotionNote, + Status = x.Status, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/Cqrs/GetPromotionListHandler.cs b/Features/Performance/Promotion/Cqrs/GetPromotionListHandler.cs new file mode 100644 index 0000000..9d3b558 --- /dev/null +++ b/Features/Performance/Promotion/Cqrs/GetPromotionListHandler.cs @@ -0,0 +1,50 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Promotion.Cqrs; + +public class GetPromotionListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public string? FromGrade { get; set; } + public string? ToGrade { get; set; } + public DateTime? EffectiveDate { get; set; } + public string? Status { get; set; } +} + +public record GetPromotionListQuery() : IRequest>; + +public class GetPromotionListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetPromotionListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetPromotionListQuery request, CancellationToken cancellationToken) + { + return await _context.Promotion + .AsNoTracking() + .NotDeletedOnly() + .Include(x => x.Employee) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetPromotionListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.EmployeeId, + EmployeeCode = x.Employee != null ? x.Employee.Code : string.Empty, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + FromGrade = x.FromGrade, + ToGrade = x.ToGrade, + EffectiveDate = x.EffectiveDate, + Status = x.Status + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/Cqrs/UpdatePromotionHandler.cs b/Features/Performance/Promotion/Cqrs/UpdatePromotionHandler.cs new file mode 100644 index 0000000..3f9dcbb --- /dev/null +++ b/Features/Performance/Promotion/Cqrs/UpdatePromotionHandler.cs @@ -0,0 +1,57 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Promotion.Cqrs; + +public class UpdatePromotionRequest : CreatePromotionRequest +{ + public string? Id { get; set; } + public string? EmployeeCode { get; set; } + public string? EmployeeName { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdatePromotionResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdatePromotionCommand(UpdatePromotionRequest Data) : IRequest; + +public class UpdatePromotionHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdatePromotionHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdatePromotionCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Promotion + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdatePromotionResponse { Id = request.Data.Id, Success = false }; + } + + entity.EmployeeId = request.Data.EmployeeId; + entity.FromGrade = request.Data.FromGrade; + entity.ToGrade = request.Data.ToGrade; + entity.EffectiveDate = request.Data.EffectiveDate; + entity.PromotionNote = request.Data.PromotionNote; + entity.Status = request.Data.Status; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdatePromotionResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/Cqrs/UpdatePromotionValidator.cs b/Features/Performance/Promotion/Cqrs/UpdatePromotionValidator.cs new file mode 100644 index 0000000..29c91b0 --- /dev/null +++ b/Features/Performance/Promotion/Cqrs/UpdatePromotionValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using Indotalent.Features.Performance.Promotion.Cqrs; + +public class UpdatePromotionValidator : AbstractValidator +{ + public UpdatePromotionValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required for update"); + RuleFor(x => x.EmployeeId).NotEmpty().WithMessage("Employee is required"); + RuleFor(x => x.ToGrade).NotEmpty().WithMessage("Target Grade is required"); + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/PromotionEndpoint.cs b/Features/Performance/Promotion/PromotionEndpoint.cs new file mode 100644 index 0000000..ef0f32a --- /dev/null +++ b/Features/Performance/Promotion/PromotionEndpoint.cs @@ -0,0 +1,55 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Performance.Promotion.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Performance.Promotion; + +public static class PromotionEndpoint +{ + public static void MapPromotionEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/performance-promotion").WithTags("Performance Promotions") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetPromotionListQuery()); + return result.ToApiResponse("Promotion list retrieved successfully"); + }) + .WithName("GetPromotionList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetPromotionByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Promotion detail retrieved successfully" + : $"Promotion with ID {id} not found"); + }) + .WithName("GetPromotionById"); + + group.MapPost("/", async (CreatePromotionRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreatePromotionCommand(request)); + return result.ToApiResponse("Promotion has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreatePromotion"); + + group.MapPost("/update", async (UpdatePromotionRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePromotionCommand(request)); + if (!result.Success) return ((object?)null).ToApiResponse("Update failed."); + return result.ToApiResponse("Promotion has been updated successfully"); + }) + .WithName("UpdatePromotion"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeletePromotionByIdCommand(new DeletePromotionByIdRequest(id))); + if (!result) return ((object?)null).ToApiResponse("Delete failed."); + return true.ToApiResponse("Promotion has been deleted successfully"); + }) + .WithName("DeletePromotionById"); + } +} \ No newline at end of file diff --git a/Features/Performance/Promotion/PromotionService.cs b/Features/Performance/Promotion/PromotionService.cs new file mode 100644 index 0000000..90aa691 --- /dev/null +++ b/Features/Performance/Promotion/PromotionService.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Performance.Promotion.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Performance.Promotion; + +public class PromotionService : BaseService +{ + private readonly RestClient _client; + + public PromotionService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetPromotionListAsync() + { + var request = new RestRequest("api/performance-promotion", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetPromotionByIdAsync(string id) + { + var request = new RestRequest($"api/performance-promotion/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreatePromotionAsync(CreatePromotionRequest data) + { + var request = new RestRequest("api/performance-promotion", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeletePromotionByIdAsync(string id) + { + var request = new RestRequest($"api/performance-promotion/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdatePromotionAsync(UpdatePromotionRequest data) + { + var request = new RestRequest("api/performance-promotion/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/Components/TransferPage.razor b/Features/Performance/Transfer/Components/TransferPage.razor new file mode 100644 index 0000000..e0f8239 --- /dev/null +++ b/Features/Performance/Transfer/Components/TransferPage.razor @@ -0,0 +1,46 @@ +@page "/performance/transfer" +@using Indotalent.Features.Performance.Transfer.Components +@using Indotalent.Features.Performance.Transfer.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_TransferCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_TransferUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_TransferDataTable OnAdd="() => ShowCreate()" OnEdit="(item) => ShowUpdate(item, false)" OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateTransferRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateTransferRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/Components/_TransferCreateForm.razor b/Features/Performance/Transfer/Components/_TransferCreateForm.razor new file mode 100644 index 0000000..a7cc97b --- /dev/null +++ b/Features/Performance/Transfer/Components/_TransferCreateForm.razor @@ -0,0 +1,166 @@ +@using Indotalent.Features.Performance.Transfer +@using Indotalent.Features.Performance.Transfer.Cqrs +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveRequest +@using MudBlazor +@inject TransferService TransferService +@inject LeaveRequestService LeaveRequestService +@inject ISnackbar Snackbar + + +
+ +
+
New Transfer Request
+
Initiate employee relocation or department rotation.
+
+
+
+ + + @if (_isInitialLoading) + { +
+ +
Loading master data...
+
+ } + else + { + + + + + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + + + + +
Destination (To)
+ + + + + @foreach (var b in _branches) + { + @b.Name + } + + + + + + @foreach (var d in _departments) + { + @d.Name + } + + + + + + @foreach (var ds in _designations) + { + @ds.Name + } + + + + + + + Pending + Processed + + + + + + +
+ +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Transfer + } + +
+
+ } +
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateTransferRequest _model = new(); + private List _employees = new(); + private List _branches = new(); + private List _departments = new(); + private List _designations = new(); + private bool _processing = false; + private bool _isInitialLoading = true; + + private DateTime? _transferDate { get => _model.TransferDate; set => _model.TransferDate = value ?? DateTime.Today; } + + protected override async Task OnInitializedAsync() + { + _isInitialLoading = true; + try + { + _model = new CreateTransferRequest { TransferDate = DateTime.Today, Status = "Pending" }; + + var empTask = LeaveRequestService.GetEmployeeReferenceAsync(); + var branchTask = TransferService.GetBranchLookupAsync(); + var deptTask = TransferService.GetDepartmentLookupAsync(); + var desigTask = TransferService.GetDesignationLookupAsync(); + + await Task.WhenAll(empTask, branchTask, deptTask, desigTask); + + if (empTask.Result?.IsSuccess == true) _employees = empTask.Result.Value ?? new(); + if (branchTask.Result?.IsSuccess == true) _branches = branchTask.Result.Value ?? new(); + if (deptTask.Result?.IsSuccess == true) _departments = deptTask.Result.Value ?? new(); + if (desigTask.Result?.IsSuccess == true) _designations = desigTask.Result.Value ?? new(); + } + finally { _isInitialLoading = false; } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await TransferService.CreateTransferAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Transfer created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/Components/_TransferDataTable.razor b/Features/Performance/Transfer/Components/_TransferDataTable.razor new file mode 100644 index 0000000..faf1063 --- /dev/null +++ b/Features/Performance/Transfer/Components/_TransferDataTable.razor @@ -0,0 +1,430 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Performance.Transfer +@using Indotalent.Features.Performance.Transfer.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject TransferService TransferService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Employee Transfer + Manage internal movements, department rotations, and branch relocations. +
+ +
+ + / + Performance + / + Transfer +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedTransfer != null) + { + View Detail + Execute Transfer + + + } + else + { + + Request Transfer + + } +
+
+ + + + + + Employee + + Origin (Dept/Loc) + + Destination (Dept/Loc) + + Transfer Date + + + Status + + + + + + + +
+ + @GetInitials(context.EmployeeName!) + +
+ @context.EmployeeName + @context.EmployeeId +
+
+
+ +
+ @context.FromDept + @context.FromLocation +
+
+ + + + +
+ @context.ToDept + @context.ToLocation +
+
+ + @context.TransferDate?.ToString("dd MMM yyyy") + + + + @context.Status + + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _transfers = new(); + private GetTransferListResponse? _selectedTransfer; + private string _searchString = ""; + private int _currentPage = 1; + private int _top = 5; + private int _skip => (_currentPage - 1) * _top; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / _top); + private bool _isRefreshing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedTransfer = null; + StateHasChanged(); + + try + { + await Task.Delay(800); + var response = await TransferService.GetTransferListAsync(); + if (response != null && response.IsSuccess) + { + _transfers = response.Value ?? new(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _transfers; + return _transfers.Where(x => + (x.EmployeeName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.EmployeeId?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.FromDept?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ToDept?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.FromLocation?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.ToLocation?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Status?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Transfers"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Employee ID"; + worksheet.Cell(currentRow, 2).Value = "Employee Name"; + worksheet.Cell(currentRow, 3).Value = "From Dept"; + worksheet.Cell(currentRow, 4).Value = "From Location"; + worksheet.Cell(currentRow, 5).Value = "To Dept"; + worksheet.Cell(currentRow, 6).Value = "To Location"; + worksheet.Cell(currentRow, 7).Value = "Transfer Date"; + worksheet.Cell(currentRow, 8).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 8); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.EmployeeId; + worksheet.Cell(currentRow, 2).Value = item.EmployeeName; + worksheet.Cell(currentRow, 3).Value = item.FromDept; + worksheet.Cell(currentRow, 4).Value = item.FromLocation; + worksheet.Cell(currentRow, 5).Value = item.ToDept; + worksheet.Cell(currentRow, 6).Value = item.ToLocation; + worksheet.Cell(currentRow, 7).Value = item.TransferDate?.ToString("dd-MMM-yyyy"); + worksheet.Cell(currentRow, 8).Value = item.Status; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Employee_Transfer_Report.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnSearchClick() + { + _currentPage = 1; + _selectedTransfer = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _currentPage = page; + _selectedTransfer = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _currentPage = 1; + _selectedTransfer = null; + StateHasChanged(); + } + + private string GetInitials(string name) + { + if (string.IsNullOrWhiteSpace(name)) return "??"; + var parts = name.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 1) return $"{parts[0][0]}{parts[1][0]}".ToUpper(); + return name.Length >= 2 ? name.Substring(0, 2).ToUpper() : name.ToUpper(); + } + + private Color GetRandomColor(string name) + { + int hash = name.GetHashCode(); + var colors = new[] { Color.Primary, Color.Secondary, Color.Tertiary, Color.Info, Color.Success, Color.Warning, Color.Dark }; + return colors[Math.Abs(hash) % colors.Length]; + } + + private Color GetStatusColor(string status) => status switch + { + "Processed" => Color.Success, + "Pending" => Color.Warning, + "Approved" => Color.Info, + _ => Color.Default + }; + + private async Task InvokeEdit() + { + if (_selectedTransfer != null) + { + var res = await TransferService.GetTransferByIdAsync(_selectedTransfer.Id!); + if (res?.Value != null) await OnEdit.InvokeAsync(res.Value); + } + } + + private async Task InvokeView() + { + if (_selectedTransfer != null) + { + var res = await TransferService.GetTransferByIdAsync(_selectedTransfer.Id!); + if (res?.Value != null) await OnView.InvokeAsync(res.Value); + } + } + + private async Task OnDelete() + { + if (_selectedTransfer == null) return; + var dialog = await DialogService.ShowAsync("", new DialogParameters { { x => x.ContentText, $"Transfer for {_selectedTransfer.EmployeeName}" } }); + if (!(await dialog.Result).Canceled) + { + if (await TransferService.DeleteTransferByIdAsync(_selectedTransfer.Id!)) + { + await LoadData(); Snackbar.Add("Transfer record removed", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/Components/_TransferUpdateForm.razor b/Features/Performance/Transfer/Components/_TransferUpdateForm.razor new file mode 100644 index 0000000..e3f93e1 --- /dev/null +++ b/Features/Performance/Transfer/Components/_TransferUpdateForm.razor @@ -0,0 +1,218 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Performance.Transfer +@using Indotalent.Features.Performance.Transfer.Cqrs +@using Indotalent.Features.Leave.LeaveRequest.Cqrs +@using Indotalent.Features.Leave.LeaveRequest +@using MudBlazor +@inject TransferService TransferService +@inject LeaveRequestService LeaveRequestService +@inject ISnackbar Snackbar + + +
+ +
+
@(ReadOnly ? "Transfer Details" : "Edit Transfer Record")
+
Review or modify the employee movement record.
+
+
+
+ + + @if (_isInitialLoading) + { +
+ +
Syncing record and lookups...
+
+ } + else + { + + + + + + @foreach (var emp in _employees) + { + @emp.FullName (@emp.Code) + } + + + + + + + +
Destination (To)
+ + + + + @foreach (var b in _branches) + { + @b.Name + } + + + + + + @foreach (var d in _departments) + { + @d.Name + } + + + + + + @foreach (var ds in _designations) + { + @ds.Name + } + + + + + + + Pending + Approved + Processed + + + + + + + + +
Audit History
+ +
+ + + +
@DateTimeExtensions.ToString(_model.CreatedAt)
+
+ + +
@(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System")
+
+ + +
@DateTimeExtensions.ToString(_model.UpdatedAt)
+
+ + +
@(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System")
+
+
+ +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+ } +
+ +@code { + [Parameter] public UpdateTransferRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateTransferRequest _model = new(); + private List _employees = new(); + private List _branches = new(); + private List _departments = new(); + private List _designations = new(); + private bool _processing = false; + private bool _isInitialLoading = true; + + private DateTime? _transferDate { get => _model.TransferDate; set => _model.TransferDate = value ?? DateTime.Today; } + + protected override async Task OnInitializedAsync() + { + _isInitialLoading = true; + try + { + _model = new UpdateTransferRequest + { + Id = Data.Id, + EmployeeId = Data.EmployeeId, + FromBranchId = Data.FromBranchId, + FromDepartmentId = Data.FromDepartmentId, + FromDesignationId = Data.FromDesignationId, + ToBranchId = Data.ToBranchId, + ToDepartmentId = Data.ToDepartmentId, + ToDesignationId = Data.ToDesignationId, + TransferDate = Data.TransferDate, + TransferNote = Data.TransferNote, + Status = Data.Status, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + + var empTask = LeaveRequestService.GetEmployeeReferenceAsync(); + var branchTask = TransferService.GetBranchLookupAsync(); + var deptTask = TransferService.GetDepartmentLookupAsync(); + var desigTask = TransferService.GetDesignationLookupAsync(); + + await Task.WhenAll(empTask, branchTask, deptTask, desigTask); + + if (empTask.Result?.IsSuccess == true) _employees = empTask.Result.Value ?? new(); + if (branchTask.Result?.IsSuccess == true) _branches = branchTask.Result.Value ?? new(); + if (deptTask.Result?.IsSuccess == true) _departments = deptTask.Result.Value ?? new(); + if (desigTask.Result?.IsSuccess == true) _designations = desigTask.Result.Value ?? new(); + } + finally + { + _isInitialLoading = false; + StateHasChanged(); + } + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await TransferService.UpdateTransferAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess && response.Value?.Success == true) + { + Snackbar.Add("Transfer record updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally { _processing = false; } + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/Cqrs/CreateTransferHandler.cs b/Features/Performance/Transfer/Cqrs/CreateTransferHandler.cs new file mode 100644 index 0000000..7437e9e --- /dev/null +++ b/Features/Performance/Transfer/Cqrs/CreateTransferHandler.cs @@ -0,0 +1,68 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Performance.Transfer.Cqrs; + +public class CreateTransferRequest +{ + public string EmployeeId { get; set; } = string.Empty; + public string FromBranchId { get; set; } = string.Empty; + public string FromDepartmentId { get; set; } = string.Empty; + public string FromDesignationId { get; set; } = string.Empty; + public string ToBranchId { get; set; } = string.Empty; + public string ToDepartmentId { get; set; } = string.Empty; + public string ToDesignationId { get; set; } = string.Empty; + public DateTime? TransferDate { get; set; } + public string TransferNote { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} + +public class CreateTransferResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } +} + +public record CreateTransferCommand(CreateTransferRequest Data) : IRequest; + +public class CreateTransferHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateTransferHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateTransferCommand request, CancellationToken cancellationToken) + { + var entityName = nameof(Data.Entities.Transfer); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"TRF/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Transfer + { + AutoNumber = autoNo, + EmployeeId = request.Data.EmployeeId, + FromBranchId = request.Data.FromBranchId, + FromDepartmentId = request.Data.FromDepartmentId, + FromDesignationId = request.Data.FromDesignationId, + ToBranchId = request.Data.ToBranchId, + ToDepartmentId = request.Data.ToDepartmentId, + ToDesignationId = request.Data.ToDesignationId, + TransferDate = request.Data.TransferDate, + TransferNote = request.Data.TransferNote, + Status = request.Data.Status + }; + + _context.Transfer.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateTransferResponse + { + Id = entity.Id, + AutoNumber = entity.AutoNumber + }; + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/Cqrs/CreateTransferValidator.cs b/Features/Performance/Transfer/Cqrs/CreateTransferValidator.cs new file mode 100644 index 0000000..a8bddc7 --- /dev/null +++ b/Features/Performance/Transfer/Cqrs/CreateTransferValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace Indotalent.Features.Performance.Transfer.Cqrs; + +public class CreateTransferValidator : AbstractValidator +{ + public CreateTransferValidator() + { + RuleFor(x => x.EmployeeId).NotEmpty().WithMessage("Employee is required"); + RuleFor(x => x.ToBranchId).NotEmpty().WithMessage("Destination Branch is required"); + RuleFor(x => x.ToDepartmentId).NotEmpty().WithMessage("Destination Department is required"); + RuleFor(x => x.TransferDate).NotEmpty().WithMessage("Transfer Date is required"); + } +} + diff --git a/Features/Performance/Transfer/Cqrs/DeleteTransferByIdHandler.cs b/Features/Performance/Transfer/Cqrs/DeleteTransferByIdHandler.cs new file mode 100644 index 0000000..5d7b3c8 --- /dev/null +++ b/Features/Performance/Transfer/Cqrs/DeleteTransferByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Transfer.Cqrs; + +public record DeleteTransferByIdRequest(string Id); + +public record DeleteTransferByIdCommand(DeleteTransferByIdRequest Data) : IRequest; + +public class DeleteTransferByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteTransferByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteTransferByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Transfer + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Transfer.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/Cqrs/GetTransferByIdHandler.cs b/Features/Performance/Transfer/Cqrs/GetTransferByIdHandler.cs new file mode 100644 index 0000000..c39ddd3 --- /dev/null +++ b/Features/Performance/Transfer/Cqrs/GetTransferByIdHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Transfer.Cqrs; + +public class GetTransferByIdResponse : UpdateTransferRequest { } + +public record GetTransferByIdQuery(string Id) : IRequest; + +public class GetTransferByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetTransferByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetTransferByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Transfer + .AsNoTracking() + .Include(x => x.Employee) + .Where(x => x.Id == request.Id) + .Select(x => new GetTransferByIdResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.EmployeeId, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + FromBranchId = x.FromBranchId, + FromDepartmentId = x.FromDepartmentId, + FromDesignationId = x.FromDesignationId, + ToBranchId = x.ToBranchId, + ToDepartmentId = x.ToDepartmentId, + ToDesignationId = x.ToDesignationId, + TransferDate = x.TransferDate, + TransferNote = x.TransferNote, + Status = x.Status, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/Cqrs/GetTransferListHandler.cs b/Features/Performance/Transfer/Cqrs/GetTransferListHandler.cs new file mode 100644 index 0000000..e331a76 --- /dev/null +++ b/Features/Performance/Transfer/Cqrs/GetTransferListHandler.cs @@ -0,0 +1,56 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Transfer.Cqrs; + +public class GetTransferListResponse +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeId { get; set; } + public string? EmployeeName { get; set; } + public string? FromDept { get; set; } + public string? FromLocation { get; set; } + public string? ToDept { get; set; } + public string? ToLocation { get; set; } + public DateTime? TransferDate { get; set; } + public string? Status { get; set; } +} + +public record GetTransferListQuery() : IRequest>; + +public class GetTransferListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetTransferListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetTransferListQuery request, CancellationToken cancellationToken) + { + return await _context.Transfer + .AsNoTracking() + .NotDeletedOnly() + .Include(x => x.Employee) + .Include(x => x.FromDepartment) + .Include(x => x.ToDepartment) + .Include(x => x.FromBranch) + .Include(x => x.ToBranch) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetTransferListResponse + { + Id = x.Id, + AutoNumber = x.AutoNumber, + EmployeeId = x.EmployeeId, + EmployeeName = x.Employee != null ? $"{x.Employee.FirstName} {x.Employee.LastName}" : string.Empty, + FromDept = x.FromDepartment != null ? x.FromDepartment.Name : string.Empty, + FromLocation = x.FromBranch != null ? x.FromBranch.Name : string.Empty, + ToDept = x.ToDepartment != null ? x.ToDepartment.Name : string.Empty, + ToLocation = x.ToBranch != null ? x.ToBranch.Name : string.Empty, + TransferDate = x.TransferDate, + Status = x.Status + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/Cqrs/GetTransferLookupsHandler.cs b/Features/Performance/Transfer/Cqrs/GetTransferLookupsHandler.cs new file mode 100644 index 0000000..32dd09b --- /dev/null +++ b/Features/Performance/Transfer/Cqrs/GetTransferLookupsHandler.cs @@ -0,0 +1,43 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Transfer.Cqrs; + +public class LookupResponse +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; +} + +public record GetBranchLookupQuery() : IRequest>; +public record GetDepartmentLookupQuery() : IRequest>; +public record GetDesignationLookupQuery() : IRequest>; + +public class GetTransferLookupsHandler : + IRequestHandler>, + IRequestHandler>, + IRequestHandler> +{ + private readonly AppDbContext _context; + public GetTransferLookupsHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetBranchLookupQuery request, CancellationToken ct) + { + return await _context.Branch.AsNoTracking().NotDeletedOnly() + .Select(x => new LookupResponse { Id = x.Id, Name = x.Name ?? "" }).ToListAsync(ct); + } + + public async Task> Handle(GetDepartmentLookupQuery request, CancellationToken ct) + { + return await _context.Department.AsNoTracking().NotDeletedOnly() + .Select(x => new LookupResponse { Id = x.Id, Name = x.Name ?? "" }).ToListAsync(ct); + } + + public async Task> Handle(GetDesignationLookupQuery request, CancellationToken ct) + { + return await _context.Designation.AsNoTracking().NotDeletedOnly() + .Select(x => new LookupResponse { Id = x.Id, Name = x.Name ?? "" }).ToListAsync(ct); + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/Cqrs/UpdateTransferHandler.cs b/Features/Performance/Transfer/Cqrs/UpdateTransferHandler.cs new file mode 100644 index 0000000..faf50cb --- /dev/null +++ b/Features/Performance/Transfer/Cqrs/UpdateTransferHandler.cs @@ -0,0 +1,54 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Performance.Transfer.Cqrs; + +public class UpdateTransferRequest : CreateTransferRequest +{ + public string? Id { get; set; } + public string? AutoNumber { get; set; } + public string? EmployeeName { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateTransferResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateTransferCommand(UpdateTransferRequest Data) : IRequest; + +public class UpdateTransferHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateTransferHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateTransferCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Transfer + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return new UpdateTransferResponse { Success = false }; + + entity.EmployeeId = request.Data.EmployeeId; + entity.FromBranchId = request.Data.FromBranchId; + entity.FromDepartmentId = request.Data.FromDepartmentId; + entity.FromDesignationId = request.Data.FromDesignationId; + entity.ToBranchId = request.Data.ToBranchId; + entity.ToDepartmentId = request.Data.ToDepartmentId; + entity.ToDesignationId = request.Data.ToDesignationId; + entity.TransferDate = request.Data.TransferDate; + entity.TransferNote = request.Data.TransferNote; + entity.Status = request.Data.Status; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateTransferResponse { Id = entity.Id, Success = true }; + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/Cqrs/UpdateTransferValidator.cs b/Features/Performance/Transfer/Cqrs/UpdateTransferValidator.cs new file mode 100644 index 0000000..5222c02 --- /dev/null +++ b/Features/Performance/Transfer/Cqrs/UpdateTransferValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using Indotalent.Features.Performance.Transfer.Cqrs; + +public class UpdateTransferValidator : AbstractValidator +{ + public UpdateTransferValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required for update"); + RuleFor(x => x.EmployeeId).NotEmpty().WithMessage("Employee is required"); + RuleFor(x => x.ToBranchId).NotEmpty().WithMessage("Destination Branch is required"); + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/TransferEndpoint.cs b/Features/Performance/Transfer/TransferEndpoint.cs new file mode 100644 index 0000000..ed21128 --- /dev/null +++ b/Features/Performance/Transfer/TransferEndpoint.cs @@ -0,0 +1,65 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Performance.Transfer.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Performance.Transfer; + +public static class TransferEndpoint +{ + public static void MapTransferEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/performance-transfer").WithTags("Performance Transfers") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetTransferListQuery()); + return result.ToApiResponse("Transfer list retrieved successfully"); + }) + .WithName("GetTransferList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetTransferByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Transfer detail retrieved successfully" + : $"Transfer with ID {id} not found"); + }) + .WithName("GetTransferById"); + + group.MapPost("/", async (CreateTransferRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTransferCommand(request)); + return result.ToApiResponse("Transfer request has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateTransfer"); + + group.MapPost("/update", async (UpdateTransferRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTransferCommand(request)); + if (!result.Success) return ((object?)null).ToApiResponse("Update failed."); + return result.ToApiResponse("Transfer record has been updated successfully"); + }) + .WithName("UpdateTransfer"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTransferByIdCommand(new DeleteTransferByIdRequest(id))); + if (!result) return ((object?)null).ToApiResponse("Delete failed."); + return true.ToApiResponse("Transfer record has been deleted successfully"); + }) + .WithName("DeleteTransferById"); + + group.MapGet("/lookup-branches", async (IMediator mediator) => + (await mediator.Send(new GetBranchLookupQuery())).ToApiResponse("Branches retrieved")); + + group.MapGet("/lookup-departments", async (IMediator mediator) => + (await mediator.Send(new GetDepartmentLookupQuery())).ToApiResponse("Departments retrieved")); + + group.MapGet("/lookup-designations", async (IMediator mediator) => + (await mediator.Send(new GetDesignationLookupQuery())).ToApiResponse("Designations retrieved")); + + } +} \ No newline at end of file diff --git a/Features/Performance/Transfer/TransferService.cs b/Features/Performance/Transfer/TransferService.cs new file mode 100644 index 0000000..44bc190 --- /dev/null +++ b/Features/Performance/Transfer/TransferService.cs @@ -0,0 +1,72 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Performance.Transfer.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Performance.Transfer; + +public class TransferService : BaseService +{ + private readonly RestClient _client; + + public TransferService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetTransferListAsync() + { + var request = new RestRequest("api/performance-transfer", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetTransferByIdAsync(string id) + { + var request = new RestRequest($"api/performance-transfer/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateTransferAsync(CreateTransferRequest data) + { + var request = new RestRequest("api/performance-transfer", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTransferByIdAsync(string id) + { + var request = new RestRequest($"api/performance-transfer/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateTransferAsync(UpdateTransferRequest data) + { + var request = new RestRequest("api/performance-transfer/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task>?> GetBranchLookupAsync() + { + var request = new RestRequest("api/performance-transfer/lookup-branches", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task>?> GetDepartmentLookupAsync() + { + var request = new RestRequest("api/performance-transfer/lookup-departments", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task>?> GetDesignationLookupAsync() + { + var request = new RestRequest("api/performance-transfer/lookup-designations", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } +} \ No newline at end of file diff --git a/Features/Profile/Avatar/AvatarEndpoint.cs b/Features/Profile/Avatar/AvatarEndpoint.cs new file mode 100644 index 0000000..7a28a56 --- /dev/null +++ b/Features/Profile/Avatar/AvatarEndpoint.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Profile.Avatar.Cqrs; +using Indotalent.Infrastructure.File; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; + +namespace Indotalent.Features.Profile.Avatar; + +public static class AvatarEndpoint +{ + public static void MapAvatarEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/profile-avatar").WithTags("Profile Avatar") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/info/{userId}", async (string userId, IMediator mediator) => + { + var result = await mediator.Send(new GetAvatarInfoQuery(userId)); + return result.ToApiResponse("Avatar info retrieved successfully"); + }) + .WithName("GetMyAvatarInfo"); + + group.MapPost("/change", async (ChangeAvatarRequest request, IMediator mediator) => + { + var result = await mediator.Send(new ChangeAvatarCommand(request)); + return result.IsSuccess + ? result.ToApiResponse(result.Message) + : result.ToApiResponse(result.Message, StatusCodes.Status400BadRequest); + }) + .WithName("ProfileChangeAvatar"); + + group.MapGet("/avatar-image/{fileName}", async (string fileName, IOptions options, IWebHostEnvironment env) => + { + var avatarSettings = options.Value.Avatar; + var folderPath = Path.Combine(env.WebRootPath, avatarSettings.StoragePath); + var filePath = Path.Combine(folderPath, fileName); + + if (!System.IO.File.Exists(filePath)) return Results.NotFound(); + + var bytes = await System.IO.File.ReadAllBytesAsync(filePath); + var extension = Path.GetExtension(fileName).ToLower(); + var contentType = extension switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + _ => "application/octet-stream" + }; + return Results.File(bytes, contentType); + }) + .WithName("ProfileGetAvatarImage"); + } +} \ No newline at end of file diff --git a/Features/Profile/Avatar/AvatarService.cs b/Features/Profile/Avatar/AvatarService.cs new file mode 100644 index 0000000..fa6f2a3 --- /dev/null +++ b/Features/Profile/Avatar/AvatarService.cs @@ -0,0 +1,53 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Profile.Avatar.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Profile.Avatar; + +public class AvatarService : BaseService +{ + private readonly RestClient _client; + + public AvatarService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task?> GetAvatarInfoAsync(string userId) + { + var request = new RestRequest($"api/profile-avatar/info/{userId}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> ChangeAvatarAsync(ChangeAvatarRequest data) + { + var request = new RestRequest("api/profile-avatar/change", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task GetAvatarBlobAsync(string fileName) + { + var request = new RestRequest($"api/profile-avatar/avatar-image/{fileName}", Method.Get); + + if (!string.IsNullOrEmpty(TokenProvider.Token)) + { + request.AddHeader("Authorization", $"Bearer {TokenProvider.Token}"); + } + + if (!string.IsNullOrEmpty(CurrentUserService.UserId)) + { + request.AddHeader("X-UserId", CurrentUserService.UserId); + } + + var response = await _client.ExecuteAsync(request); + + return response.IsSuccessful ? response.RawBytes : null; + } +} \ No newline at end of file diff --git a/Features/Profile/Avatar/Components/AvatarPage.razor b/Features/Profile/Avatar/Components/AvatarPage.razor new file mode 100644 index 0000000..3c0beb7 --- /dev/null +++ b/Features/Profile/Avatar/Components/AvatarPage.razor @@ -0,0 +1,198 @@ +@page "/profile/avatar" +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms +@using MudBlazor +@using System.IO +@using Indotalent.Features.Profile.Avatar +@using Indotalent.Features.Profile.Avatar.Cqrs +@using Indotalent.ConfigBackEnd.Interfaces +@inject ISnackbar Snackbar +@inject AvatarService AvatarService +@inject ICurrentUserService CurrentUserService + + +
+ Profile Picture + Update your avatar and how you appear to other members. +
+ +
+ + / + Profile + / + Avatar +
+
+ + + + + + Your Avatar + Recommended size: 200x200px. Supported formats: JPG, JPEG, PNG. (max 2MB). + + + +
+ + + @if (_isLoading) + { + + } + else if (!string.IsNullOrEmpty(_previewUrl)) + { +
+ +
+ } + else + { + + @(CurrentUserService.FullName?.Substring(0, 1).ToUpper() ?? "U") + + } + +
+ Avatar Preview + This is how your photo will look. +
+ + + + @(_selectedFile == null ? "Browse File" : _selectedFile.Name) + + +
+
+
+
+ +
+ + @if (_processing) + { + + Processing... + } + else + { + Save Changes + } + +
+
+
+ +@code { + private IBrowserFile? _selectedFile; + private string? _previewUrl; + private bool _processing = false; + private bool _isLoading = false; + private string? _fullName; + + protected override async Task OnInitializedAsync() + { + await LoadExistingAvatar(); + } + + private async Task LoadExistingAvatar() + { + var userId = CurrentUserService.UserId; + if (string.IsNullOrEmpty(userId)) return; + + _isLoading = true; + try + { + var response = await AvatarService.GetAvatarInfoAsync(userId); + + if (response != null && response.IsSuccess && response.Value != null) + { + _fullName = response.Value.FullName; + + if (!string.IsNullOrEmpty(response.Value.AvatarFile)) + { + var bytes = await AvatarService.GetAvatarBlobAsync(response.Value.AvatarFile); + if (bytes != null && bytes.Length > 0) + { + _previewUrl = $"data:image/png;base64,{Convert.ToBase64String(bytes)}"; + } + } + } + } + catch (Exception) { } + finally + { + _isLoading = false; + StateHasChanged(); + } + } + + private async Task HandleFileSelected(IBrowserFile file) + { + if (file == null) return; + if (file.Size > 2 * 1024 * 1024) + { + Snackbar.Add("File too large. Max 2MB allowed.", Severity.Warning); + return; + } + _selectedFile = file; + using var stream = file.OpenReadStream(2 * 1024 * 1024); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + _previewUrl = $"data:{file.ContentType};base64,{Convert.ToBase64String(ms.ToArray())}"; + StateHasChanged(); + } + + private async Task SubmitAvatar() + { + if (_selectedFile == null) return; + var userId = CurrentUserService.UserId; + if (string.IsNullOrEmpty(userId)) return; + + _processing = true; + try + { + using var stream = _selectedFile.OpenReadStream(2 * 1024 * 1024); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + + var request = new ChangeAvatarRequest + { + UserId = userId, + FileData = ms.ToArray(), + FileName = _selectedFile.Name + }; + + var response = await AvatarService.ChangeAvatarAsync(request); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Avatar updated successfully", Severity.Success); + _selectedFile = null; + await LoadExistingAvatar(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Upload failed: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + StateHasChanged(); + } + } +} \ No newline at end of file diff --git a/Features/Profile/Avatar/Cqrs/ChangeAvatarHandler.cs b/Features/Profile/Avatar/Cqrs/ChangeAvatarHandler.cs new file mode 100644 index 0000000..13b96fe --- /dev/null +++ b/Features/Profile/Avatar/Cqrs/ChangeAvatarHandler.cs @@ -0,0 +1,66 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.File; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Profile.Avatar.Cqrs; + +public class ChangeAvatarRequest +{ + public string UserId { get; set; } = string.Empty; + public byte[] FileData { get; set; } = Array.Empty(); + public string FileName { get; set; } = string.Empty; +} + +public class ChangeAvatarResponse +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; +} + +public record ChangeAvatarCommand(ChangeAvatarRequest Data) : IRequest; + +public class ChangeAvatarHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly FileStorageService _fileStorage; + + public ChangeAvatarHandler(UserManager userManager, FileStorageService fileStorage) + { + _userManager = userManager; + _fileStorage = fileStorage; + } + + public async Task Handle(ChangeAvatarCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.UserId); + if (user == null) return new ChangeAvatarResponse { IsSuccess = false, Message = "User not found" }; + + try + { + var extension = Path.GetExtension(request.Data.FileName); + + if (!string.IsNullOrEmpty(user.AvatarFile)) + { + _fileStorage.DeleteOldAvatar(user.AvatarFile); + } + + var newFileName = await _fileStorage.SaveAvatarAsync(user.Id, request.Data.FileData, extension); + + user.AvatarFile = newFileName; + user.UpdatedAt = DateTime.Now; + + var result = await _userManager.UpdateAsync(user); + + return new ChangeAvatarResponse + { + IsSuccess = result.Succeeded, + Message = result.Succeeded ? "Avatar updated successfully" : "Failed to update user profile" + }; + } + catch (Exception ex) + { + return new ChangeAvatarResponse { IsSuccess = false, Message = ex.Message }; + } + } +} \ No newline at end of file diff --git a/Features/Profile/Avatar/Cqrs/GetAvatarInfoHandler.cs b/Features/Profile/Avatar/Cqrs/GetAvatarInfoHandler.cs new file mode 100644 index 0000000..20fc4fa --- /dev/null +++ b/Features/Profile/Avatar/Cqrs/GetAvatarInfoHandler.cs @@ -0,0 +1,37 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Profile.Avatar.Cqrs; + +public class GetAvatarInfoResponse +{ + public string Id { get; set; } = string.Empty; + public string? FullName { get; set; } + public string? AvatarFile { get; set; } +} + +public record GetAvatarInfoQuery(string UserId) : IRequest; + +public class GetAvatarInfoHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public GetAvatarInfoHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(GetAvatarInfoQuery request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId); + if (user == null) return null; + + return new GetAvatarInfoResponse + { + Id = user.Id, + FullName = user.FullName, + AvatarFile = user.AvatarFile + }; + } +} \ No newline at end of file diff --git a/Features/Profile/Password/Components/PasswordPage.razor b/Features/Profile/Password/Components/PasswordPage.razor new file mode 100644 index 0000000..d414bb2 --- /dev/null +++ b/Features/Profile/Password/Components/PasswordPage.razor @@ -0,0 +1,195 @@ +@page "/profile/password" +@using System.ComponentModel.DataAnnotations +@using MudBlazor +@using Indotalent.Features.Profile.Password +@using Indotalent.Features.Profile.Password.Cqrs +@using Indotalent.ConfigBackEnd.Interfaces +@using Indotalent.Shared.Utils +@inject ISnackbar Snackbar +@inject PasswordService PasswordService +@inject ICurrentUserService CurrentUserService + + +
+ Security Settings + Manage your password and protect your account security. +
+ +
+ + / + Profile + / + Password +
+
+ + + + + + + Change Password + Update your password to keep your account secure. We recommend using a strong password. + + + +
+ Current Password + +
+ + + +
+ New Password + +
+ +
+ Confirm New Password + +
+
+
+
+ +
+ + @if (_isProcessing) + { + + Updating Password... + } + else + { + Update Password + } + +
+
+
+
+ +@code { + private MudForm _form = default!; + private UpdatePasswordValidator _validator = new(); + private bool _isProcessing = false; + private PasswordChangeModel _model = new(); + + private bool _currentPasswordVisibility; + private InputType _currentPasswordInput = InputType.Password; + private string _currentPasswordIcon = Icons.Material.Filled.VisibilityOff; + + private bool _newPasswordVisibility; + private InputType _newPasswordInput = InputType.Password; + private string _newPasswordIcon = Icons.Material.Filled.VisibilityOff; + + private void ToggleCurrentPasswordVisibility() + { + if (_currentPasswordVisibility) + { + _currentPasswordVisibility = false; + _currentPasswordIcon = Icons.Material.Filled.VisibilityOff; + _currentPasswordInput = InputType.Password; + } + else + { + _currentPasswordVisibility = true; + _currentPasswordIcon = Icons.Material.Filled.Visibility; + _currentPasswordInput = InputType.Text; + } + } + + private void ToggleNewPasswordVisibility() + { + if (_newPasswordVisibility) + { + _newPasswordVisibility = false; + _newPasswordIcon = Icons.Material.Filled.VisibilityOff; + _newPasswordInput = InputType.Password; + } + else + { + _newPasswordVisibility = true; + _newPasswordIcon = Icons.Material.Filled.Visibility; + _newPasswordInput = InputType.Text; + } + } + + private async Task UpdatePassword() + { + await _form.Validate(); + + if (!_form.IsValid) + { + Snackbar.Add("Please correct the validation errors.", Severity.Warning); + return; + } + + if (_model.NewPassword != _model.ConfirmPassword) + { + Snackbar.Add("New password and confirmation do not match.", Severity.Error); + return; + } + + var userId = CurrentUserService.UserId; + if (string.IsNullOrEmpty(userId)) + { + Snackbar.Add("User session not found.", Severity.Error); + return; + } + + _isProcessing = true; + + var request = new UpdatePasswordRequest + { + Id = userId, + CurrentPassword = _model.CurrentPassword, + NewPassword = _model.NewPassword + }; + + var response = await PasswordService.UpdatePasswordAsync(request); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Password updated successfully!", Severity.Success); + _model = new PasswordChangeModel(); + await _form.ResetAsync(); + } + else + { + Snackbar.Add(response?.Message ?? "Failed to update password", Severity.Error); + } + + _isProcessing = false; + } + + public class PasswordChangeModel : UpdatePasswordRequest + { + public string ConfirmPassword { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Features/Profile/Password/Cqrs/UpdatePasswordHandler.cs b/Features/Profile/Password/Cqrs/UpdatePasswordHandler.cs new file mode 100644 index 0000000..5b49644 --- /dev/null +++ b/Features/Profile/Password/Cqrs/UpdatePasswordHandler.cs @@ -0,0 +1,50 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Profile.Password.Cqrs; + +public class UpdatePasswordRequest +{ + public string Id { get; set; } = string.Empty; + public string CurrentPassword { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; +} + +public class UpdatePasswordResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public List? Errors { get; set; } +} + +public record UpdatePasswordCommand(UpdatePasswordRequest Data) : IRequest; + +public class UpdatePasswordHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public UpdatePasswordHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(UpdatePasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.Id); + if (user == null) + return new UpdatePasswordResponse { Success = false, Message = "User not found" }; + + var result = await _userManager.ChangePasswordAsync(user, request.Data.CurrentPassword, request.Data.NewPassword); + + if (result.Succeeded) + return new UpdatePasswordResponse { Success = true }; + + return new UpdatePasswordResponse + { + Success = false, + Message = "Failed to update password", + Errors = result.Errors.Select(x => x.Description).ToList() + }; + } +} \ No newline at end of file diff --git a/Features/Profile/Password/Cqrs/UpdatePasswordValidator.cs b/Features/Profile/Password/Cqrs/UpdatePasswordValidator.cs new file mode 100644 index 0000000..d1bf8fd --- /dev/null +++ b/Features/Profile/Password/Cqrs/UpdatePasswordValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace Indotalent.Features.Profile.Password.Cqrs; + +public class UpdatePasswordValidator : AbstractValidator +{ + public UpdatePasswordValidator() + { + RuleFor(x => x.Id).NotEmpty(); + + RuleFor(x => x.CurrentPassword) + .NotEmpty().WithMessage("Current password is required"); + + RuleFor(x => x.NewPassword) + .NotEmpty().WithMessage("New password is required") + .MinimumLength(4).WithMessage("Password must be at least 4 characters"); + } +} \ No newline at end of file diff --git a/Features/Profile/Password/PasswordEndpoint.cs b/Features/Profile/Password/PasswordEndpoint.cs new file mode 100644 index 0000000..6b8e6ef --- /dev/null +++ b/Features/Profile/Password/PasswordEndpoint.cs @@ -0,0 +1,26 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Profile.Password.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Profile.Password; + +public static class PasswordEndpoint +{ + public static void MapPasswordEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/profile-password").WithTags("Profile Password") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapPost("/update", async (UpdatePasswordRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdatePasswordCommand(request)); + if (!result.Success) + return ((object?)null).ToApiResponse(result.Message ?? "Failed to update password", StatusCodes.Status400BadRequest); + + return result.ToApiResponse("Password has been updated successfully"); + }) + .WithName("UpdateProfilePassword"); + } +} \ No newline at end of file diff --git a/Features/Profile/Password/PasswordService.cs b/Features/Profile/Password/PasswordService.cs new file mode 100644 index 0000000..6d6f720 --- /dev/null +++ b/Features/Profile/Password/PasswordService.cs @@ -0,0 +1,28 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Profile.Password.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Profile.Password; + +public class PasswordService : BaseService +{ + private readonly RestClient _client; + + public PasswordService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task?> UpdatePasswordAsync(UpdatePasswordRequest data) + { + var request = new RestRequest("api/profile-password/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/Components/PersonalInformationPage.razor b/Features/Profile/PersonalInformation/Components/PersonalInformationPage.razor new file mode 100644 index 0000000..27407ee --- /dev/null +++ b/Features/Profile/PersonalInformation/Components/PersonalInformationPage.razor @@ -0,0 +1,275 @@ +@page "/profile/personal-information" +@using System.ComponentModel.DataAnnotations +@using MudBlazor +@using Indotalent.Features.Profile.PersonalInformation +@using Indotalent.Features.Profile.PersonalInformation.Cqrs +@using Indotalent.ConfigBackEnd.Interfaces +@inject ISnackbar Snackbar +@inject PersonalInformationService PersonalInformationService +@inject ICurrentUserService CurrentUserService + + +
+ Personal Information + Manage your personal details, contact information, and how others see you. +
+ +
+ + / + Profile + / + Personal Information +
+
+ + + + + + Basic Profile + Your public identity and bio. + + + + + + First Name + + + + Last Name + + + + +
+ Short Bio + +
+ + + + Job Title / Position + + + + Date of Birth + + + +
+
+
+ + + + + + Contact & Location + How we can reach you and where you're based. + + + + + + Email Address + + + + Phone Number + + + + +
+ Residential Address + +
+ + + + City + + + + Country + + + + Postal Code + + + +
+
+
+ + + + + + Social Profiles + Your professional and social networking links. + + + +
+ LinkedIn Profile + +
+ +
+ GitHub / Portfolio + +
+ +
+ Instagram + +
+
+
+
+ +
+ + @if (_isProcessing) + { + + Saving... + } + else + { + Save Changes + } + +
+
+
+ +@code { + private bool _isProcessing = false; + private UserProfileModel user = new(); + + protected override async Task OnInitializedAsync() + { + await LoadProfile(); + } + + private async Task LoadProfile() + { + var userId = CurrentUserService.UserId; + if (string.IsNullOrEmpty(userId)) return; + + _isProcessing = true; + + var response = await PersonalInformationService.GetPersonalInformationByIdAsync(userId); + + if (response != null && response.IsSuccess && response.Value != null) + { + var profile = response.Value; + + user = new UserProfileModel + { + FirstName = profile.FirstName ?? string.Empty, + LastName = profile.LastName ?? string.Empty, + Bio = profile.ShortBio ?? string.Empty, + JobTitle = profile.JobTitle ?? string.Empty, + DateOfBirth = profile.DateOfBirth, + Contact = new ContactModel + { + Email = profile.Email ?? string.Empty, + Phone = profile.PhoneNumber ?? string.Empty, + Address = profile.StreetAddress ?? string.Empty, + City = profile.City ?? string.Empty, + Country = profile.Country ?? string.Empty, + PostalCode = profile.ZipCode ?? string.Empty + }, + Socials = new UserSocialModel + { + LinkedIn = profile.SocialMediaLinkedIn ?? string.Empty, + Instagram = profile.SocialMediaInstagram ?? string.Empty, + GitHub = profile.SocialMediaX ?? string.Empty + } + }; + } + _isProcessing = false; + } + + private async Task SaveProfile() + { + var userId = CurrentUserService.UserId; + if (string.IsNullOrEmpty(userId)) + { + Snackbar.Add("User session not found.", Severity.Error); + return; + } + + _isProcessing = true; + + var updateRequest = new UpdateProfileRequest + { + Id = userId, + FirstName = user.FirstName, + LastName = user.LastName, + ShortBio = user.Bio, + JobTitle = user.JobTitle, + DateOfBirth = user.DateOfBirth, + PhoneNumber = user.Contact.Phone, + StreetAddress = user.Contact.Address, + City = user.Contact.City, + Country = user.Contact.Country, + ZipCode = user.Contact.PostalCode, + SocialMediaLinkedIn = user.Socials.LinkedIn, + SocialMediaInstagram = user.Socials.Instagram, + SocialMediaX = user.Socials.GitHub + }; + + var result = await PersonalInformationService.UpdatePersonalInformationAsync(updateRequest); + await Task.Delay(500); + + if (result != null && result.IsSuccess) + { + Snackbar.Add("Profile updated! Please re-login to see all changes in the session.", Severity.Success, config => + { + config.VisibleStateDuration = 5000; + }); + + } + else + { + Snackbar.Add(result?.Message ?? "Update failed", Severity.Error); + } + + _isProcessing = false; + } + + public class UserProfileModel + { + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Bio { get; set; } = string.Empty; + public string JobTitle { get; set; } = string.Empty; + public DateTime? DateOfBirth { get; set; } + public ContactModel Contact { get; set; } = new(); + public UserSocialModel Socials { get; set; } = new(); + } + + public class ContactModel + { + public string Email { get; set; } = string.Empty; + public string Phone { get; set; } = string.Empty; + public string Address { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; + public string PostalCode { get; set; } = string.Empty; + } + + public class UserSocialModel + { + public string LinkedIn { get; set; } = string.Empty; + public string GitHub { get; set; } = string.Empty; + public string Instagram { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/Cqrs/GetProfileByIdHandler.cs b/Features/Profile/PersonalInformation/Cqrs/GetProfileByIdHandler.cs new file mode 100644 index 0000000..8d981d7 --- /dev/null +++ b/Features/Profile/PersonalInformation/Cqrs/GetProfileByIdHandler.cs @@ -0,0 +1,59 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Profile.PersonalInformation.Cqrs; + +public class GetProfileByIdResponse +{ + public string Id { get; set; } = string.Empty; + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? ShortBio { get; set; } + public string? JobTitle { get; set; } + public DateTime? DateOfBirth { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public string? StreetAddress { get; set; } + public string? City { get; set; } + public string? Country { get; set; } + public string? ZipCode { get; set; } + public string? SocialMediaLinkedIn { get; set; } + public string? SocialMediaInstagram { get; set; } + public string? SocialMediaX { get; set; } +} + +public record GetProfileByIdQuery(string Id) : IRequest; + +public class GetProfileByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetProfileByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetProfileByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Users + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetProfileByIdResponse + { + Id = x.Id, + FirstName = x.FirstName, + LastName = x.LastName, + ShortBio = x.ShortBio, + JobTitle = x.JobTitle, + DateOfBirth = x.DateOfBirth, + Email = x.Email, + PhoneNumber = x.PhoneNumber, + StreetAddress = x.StreetAddress, + City = x.City, + Country = x.Country, + ZipCode = x.ZipCode, + SocialMediaLinkedIn = x.SocialMediaLinkedIn, + SocialMediaInstagram = x.SocialMediaInstagram, + SocialMediaX = x.SocialMediaX + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/Cqrs/UpdateProfileHandler.cs b/Features/Profile/PersonalInformation/Cqrs/UpdateProfileHandler.cs new file mode 100644 index 0000000..0229793 --- /dev/null +++ b/Features/Profile/PersonalInformation/Cqrs/UpdateProfileHandler.cs @@ -0,0 +1,67 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Profile.PersonalInformation.Cqrs; + +public class UpdateProfileRequest +{ + public string Id { get; set; } = string.Empty; + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? ShortBio { get; set; } + public string? JobTitle { get; set; } + public DateTime? DateOfBirth { get; set; } + public string? PhoneNumber { get; set; } + public string? StreetAddress { get; set; } + public string? City { get; set; } + public string? Country { get; set; } + public string? ZipCode { get; set; } + public string? SocialMediaLinkedIn { get; set; } + public string? SocialMediaInstagram { get; set; } + public string? SocialMediaX { get; set; } +} + +public class UpdateProfileResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; +} + +public record UpdateProfileCommand(UpdateProfileRequest Data) : IRequest; + +public class UpdateProfileHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateProfileHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateProfileCommand request, CancellationToken cancellationToken) + { + var user = await _context.Users + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (user == null) + return new UpdateProfileResponse { Success = false, Message = "User not found" }; + + user.FullName = $"{request.Data.FirstName} {request.Data.LastName}"; + user.FirstName = request.Data.FirstName; + user.LastName = request.Data.LastName; + user.ShortBio = request.Data.ShortBio; + user.JobTitle = request.Data.JobTitle; + user.DateOfBirth = request.Data.DateOfBirth ?? user.DateOfBirth; + user.PhoneNumber = request.Data.PhoneNumber; + user.StreetAddress = request.Data.StreetAddress; + user.City = request.Data.City; + user.Country = request.Data.Country; + user.ZipCode = request.Data.ZipCode; + user.SocialMediaLinkedIn = request.Data.SocialMediaLinkedIn ?? string.Empty; + user.SocialMediaInstagram = request.Data.SocialMediaInstagram ?? string.Empty; + user.SocialMediaX = request.Data.SocialMediaX ?? string.Empty; + user.UpdatedAt = DateTime.Now; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateProfileResponse { Success = true }; + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/Cqrs/UpdateProfileValidator.cs b/Features/Profile/PersonalInformation/Cqrs/UpdateProfileValidator.cs new file mode 100644 index 0000000..3a5d065 --- /dev/null +++ b/Features/Profile/PersonalInformation/Cqrs/UpdateProfileValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Profile.PersonalInformation.Cqrs; + +public class UpdateProfileValidator : AbstractValidator +{ + public UpdateProfileValidator() + { + RuleFor(x => x.Id).NotEmpty(); + + RuleFor(x => x.FirstName) + .NotEmpty().WithMessage("First name is required") + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"First name cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.LastName) + .NotEmpty().WithMessage("Last name is required") + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"Last name cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.PhoneNumber) + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"Phone cannot exceed {GlobalConsts.StringLengthShort} characters"); + + RuleFor(x => x.ShortBio) + .MaximumLength(GlobalConsts.StringLengthShort).WithMessage($"Bio cannot exceed {GlobalConsts.StringLengthShort} characters"); + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/PersonalInformationEndpoint.cs b/Features/Profile/PersonalInformation/PersonalInformationEndpoint.cs new file mode 100644 index 0000000..9940a19 --- /dev/null +++ b/Features/Profile/PersonalInformation/PersonalInformationEndpoint.cs @@ -0,0 +1,35 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Profile.PersonalInformation.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Profile.PersonalInformation; + +public static class PersonalInformationEndpoint +{ + public static void MapPersonalInformationEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/personal-information").WithTags("Personal Information") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetProfileByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Personal information retrieved successfully" + : $"User with ID {id} not found"); + }) + .WithName("GetPersonalInformationById"); + + group.MapPost("/update", async (UpdateProfileRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateProfileCommand(request)); + if (!result.Success) + return ((object?)null).ToApiResponse(result.Message ?? "Update failed.", StatusCodes.Status400BadRequest); + + return result.ToApiResponse("Personal information has been updated successfully"); + }) + .WithName("UpdatePersonalInformation"); + } +} \ No newline at end of file diff --git a/Features/Profile/PersonalInformation/PersonalInformationService.cs b/Features/Profile/PersonalInformation/PersonalInformationService.cs new file mode 100644 index 0000000..06867f2 --- /dev/null +++ b/Features/Profile/PersonalInformation/PersonalInformationService.cs @@ -0,0 +1,34 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Profile.PersonalInformation.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Profile.PersonalInformation; + +public class PersonalInformationService : BaseService +{ + private readonly RestClient _client; + + public PersonalInformationService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task?> GetPersonalInformationByIdAsync(string id) + { + var request = new RestRequest($"api/personal-information/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdatePersonalInformationAsync(UpdateProfileRequest data) + { + var request = new RestRequest("api/personal-information/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Profile/ProfilePage.razor b/Features/Profile/ProfilePage.razor new file mode 100644 index 0000000..fde06dc --- /dev/null +++ b/Features/Profile/ProfilePage.razor @@ -0,0 +1,103 @@ +@page "/profile" +@using Indotalent.Features.Profile.Session +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] +@using Indotalent.Features.Profile.Avatar.Components +@using Indotalent.Features.Profile.Password.Components +@using Indotalent.Features.Profile.PersonalInformation +@using Indotalent.Features.Profile.Password +@using Indotalent.Features.Profile.Avatar +@using Indotalent.Features.Profile.PersonalInformation.Components +@using MudBlazor +@inject NavigationManager NavigationManager + + + + +
+ + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + _activeTabIndex = tabValue.ToString().ToLower() switch + { + "personal" => 0, + "password" => 1, + "avatar" => 2, + "session" => 3, + _ => 0 + }; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + string tabName = index switch + { + 0 => "personal", + 1 => "password", + 2 => "avatar", + 3 => "session", + _ => "personal" + }; + + NavigationManager.NavigateTo($"/profile?tab={tabName}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Profile/Session/SessionPage.razor b/Features/Profile/Session/SessionPage.razor new file mode 100644 index 0000000..35b0302 --- /dev/null +++ b/Features/Profile/Session/SessionPage.razor @@ -0,0 +1,105 @@ +@page "/profile/session" +@using Indotalent.ConfigBackEnd.Interfaces +@using Indotalent.ConfigFrontEnd.Common +@using Indotalent.Infrastructure.Authentication +@using Indotalent.Infrastructure.Authentication.Identity +@using Microsoft.JSInterop +@using MudBlazor +@inject ICurrentUserService CurrentUserService +@inject TokenProvider TokenProvider +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + + Active Session Information + Detailed information about your current authentication state. + +
+
+ Full Name + @(CurrentUserService.FullName ?? "Not Identified") +
+ +
+ User ID + @(CurrentUserService.UserId ?? "Not Identified") +
+ +
+ Username + @(CurrentUserService.UserName ?? "Not Identified") +
+ +
+ Email Address + @(CurrentUserService.Email ?? "Not Identified") +
+
+ + + +
+
+ JSON Web Token (JWT) + Copy this token to use in Swagger Authorization. +
+ + Copy JWT Token + +
+
+ +@code { + [Inject] private IHttpContextAccessor HttpContextAccessor { get; set; } = default!; + + protected override void OnInitialized() + { + if (string.IsNullOrEmpty(TokenProvider.Token)) + { + var tokenFromCookie = HttpContextAccessor.HttpContext?.Request.Cookies["X-Auth-Token"]; + if (!string.IsNullOrEmpty(tokenFromCookie)) + { + TokenProvider.Token = tokenFromCookie; + } + } + } + + private async Task CopyToClipboard() + { + var tokenValue = TokenProvider.Token; + + if (string.IsNullOrEmpty(tokenValue)) + { + Snackbar.Add("Token not found. Please re-login.", Severity.Error); + return; + } + + await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", tokenValue); + Snackbar.Add("Token copied to clipboard!", Severity.Success); + } + + private async Task ShowJwtDialog() + { + var tokenValue = TokenProvider.Token; + + if (string.IsNullOrEmpty(tokenValue)) + { + await DialogService.ShowMessageBoxAsync("System Note", "Token is not available."); + return; + } + + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + + await DialogService.ShowMessageBoxAsync( + "Your JWT Token", + (MarkupString)$@"

RAW TOKEN:

{tokenValue}
", + "Close", + options: options + ); + } +} \ No newline at end of file diff --git a/Features/Root/App.razor b/Features/Root/App.razor new file mode 100644 index 0000000..d925397 --- /dev/null +++ b/Features/Root/App.razor @@ -0,0 +1,123 @@ +@using Indotalent.Features.Root +@using System.Security.Claims + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Features/Root/EmptyLayout.razor b/Features/Root/EmptyLayout.razor new file mode 100644 index 0000000..e4ab3c9 --- /dev/null +++ b/Features/Root/EmptyLayout.razor @@ -0,0 +1,2 @@ +@inherits LayoutComponentBase +@Body \ No newline at end of file diff --git a/Features/Root/Error/ErrorPage.razor b/Features/Root/Error/ErrorPage.razor new file mode 100644 index 0000000..31064ba --- /dev/null +++ b/Features/Root/Error/ErrorPage.razor @@ -0,0 +1,59 @@ +@page "/Error" +@layout MainLayout +@using System.Diagnostics +@using Microsoft.AspNetCore.Mvc +@attribute [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + +Error - Something Went Wrong + + + +
+ + + + An Error Occurred + + + Sorry, an error occurred while processing your request.
+ Our team has been notified and is working to resolve the issue. +
+ + @if (ShowRequestId) + { + + + Request ID: @RequestId + + + Please provide this ID when contacting support to help us track the issue. + + + } + + + Back to Home + + +
+ +
+ +@code { + [CascadingParameter] private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() + { + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + } +} \ No newline at end of file diff --git a/Features/Root/Home/Components/DashboardPage.razor b/Features/Root/Home/Components/DashboardPage.razor new file mode 100644 index 0000000..df51b75 --- /dev/null +++ b/Features/Root/Home/Components/DashboardPage.razor @@ -0,0 +1,606 @@ +@page "/home" +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@using Indotalent.Features.Root.Home.Cqrs +@using Indotalent.Features.Root.Home +@attribute [Authorize(Roles = $"{ApplicationRoles.Admin},{ApplicationRoles.Member}")] +@inject HomeService HomeService +@inject IJSRuntime JS + +HRM Dashboard + +
+
+ + @* HEADER *@ +
+
+

HRM Dashboard

+
+
+ + @if (_isLoading) + { +
+ +
+ } + else + { + @* ROW 1: 12-col KPI layout *@ +
+ @* Key Metrics Card (spans 2 rows) *@ +
+
+
+
+

HRM Overview

+

Key Metrics

+
+
+ +
+
+
+
+

Total Staff

+

@_data.TotalStaff.ToString("N0")

+ FTE: @_data.FteValue.ToString("F2") +
+
+

Avg Cost/Hire

+

@_data.FormattedAvgCost

+ @_data.TotalAppraisals apps +
+
+

Retention Rate

+

@_data.RetentionRate%

+ Leavers: @_data.LeaversRate% +
+
+

Engagement

+

@_data.EngagementRate%

+ Survey-based +
+
+
+
+ Payroll: @_data.FormattedTotalPayroll + FY @DateTime.Now.Year +
+
+ + @* 8 Small KPI Cards (row 1, cols 6-12) *@ +
+

Total Staff

@_data.TotalStaff.ToString("N0")

+

Retention

@_data.RetentionRate%

+

Engagement

@_data.EngagementRate%

+

Branches

@_data.TotalBranches

+
+
+

Avg Cost/Hire

@_data.FormattedAvgCost

+

Appraisals

@_data.TotalAppraisals.ToString("N0")

+

Leave Reqs

@_data.TotalLeaveRequests.ToString("N0")

+

Payroll

@_data.FormattedTotalPayroll

+
+
+ + @* Metrics Matrix *@ +
+
+

HRM Metrics Matrix

Key indicators across all categories

+
+
+ @{ + var mmItems = new[] { + new { Title = "Total Staff", Sub = _data.TotalStaff.ToString("N0"), ColorClass = "mm-c1" }, + new { Title = "Retention Rate", Sub = $"{_data.RetentionRate}%", ColorClass = "mm-c2" }, + new { Title = "Engagement", Sub = $"{_data.EngagementRate}%", ColorClass = "mm-c3" }, + new { Title = "Branches", Sub = _data.TotalBranches.ToString(), ColorClass = "mm-c4" }, + new { Title = "Avg Cost/Hire", Sub = _data.FormattedAvgCost ?? "-", ColorClass = "mm-c5" }, + new { Title = "Departments", Sub = _data.TotalDepartments.ToString(), ColorClass = "mm-c6" }, + new { Title = "Transfer Reqs", Sub = _data.TotalTransferRequests.ToString("N0"), ColorClass = "mm-c7" }, + new { Title = "Payroll", Sub = _data.FormattedTotalPayroll ?? "0", ColorClass = "mm-c8" }, + new { Title = "Appraisals", Sub = _data.TotalAppraisals.ToString("N0"), ColorClass = "mm-c9" }, + new { Title = "Promotions", Sub = _data.ActivePromotions.ToString(), ColorClass = "mm-c10" }, + new { Title = "Leave Requests", Sub = _data.TotalLeaveRequests.ToString("N0"), ColorClass = "mm-c11" }, + new { Title = "Top Talent", Sub = _data.TopTalentCount.ToString(), ColorClass = "mm-c12" }, + new { Title = "Core Players", Sub = _data.CorePlayerCount.ToString(), ColorClass = "mm-c13" }, + new { Title = "Low Perf.", Sub = _data.LowPerfCount.ToString(), ColorClass = "mm-c14" }, + new { Title = "FTE Value", Sub = _data.FteValue.ToString("F2"), ColorClass = "mm-c15" }, + new { Title = "EX NPS", Sub = _data.EmployeeExperienceNps.ToString("F1"), ColorClass = "mm-c16" } + }; + } + @foreach (var item in mmItems) + { +
+
+ +
+

@item.Title

@item.Sub

+
+ } +
+
+ + @* ROW 2: Headcount Trend + Department Distribution *@ +
+
+
+

Headcount Trend

Monthly staff count vs new hires

+
+ Staff + New Hires +
+
+
+
+
+

Department Distribution

+

Staff by department

+
+
+ @{ + var deptList = _data.DeptDistributions?.Take(6).ToList() ?? new List(); + var doughnutColors = new[] { "#2563eb", "#f97316", "#ec4899", "#8b5cf6", "#06b6d4", "#10b981" }; + var deptTotal = deptList.Sum(x => x.Count); + if (deptTotal == 0) deptTotal = 1; + for (int i = 0; i < deptList.Count; i++) + { + var d = deptList[i]; + var dpct = (int)Math.Round((double)d.Count / deptTotal * 100); + var dcolor = i < doughnutColors.Length ? doughnutColors[i] : "#94a3b8"; +
+ @d.Department @dpct% +
+ } + } + @if (!deptList.Any()) + { +
No data
+ } +
+
+
+ + @* ROW 3: Pipeline Summary + Talent Segments + Leave Breakdown *@ +
+
+

HR Pipeline Summary

+

As of @DateTime.Now.ToString("MMMM dd, yyyy")

+
+
+
+

Total Employees

@_data.TotalStaff.ToString("N0")

+
+
+
+

Appraisals

@_data.TotalAppraisals.ToString("N0")

+
+
+
+

Total Payroll

@_data.FormattedTotalPayroll

+
+
+ Retention@_data.RetentionRate% + Engagement@_data.EngagementRate% +
+
+
+
+

Talent Segments

+

Performance by category

+
+
+
+

Top Talent

@_data.TopTalentCount

+
+
+
+

Core Players

@_data.CorePlayerCount

+
+
+
+

Low Performance

@_data.LowPerfCount

+
+
+
+

Active Promotions

@_data.ActivePromotions

+
+
+
Forecast: @_data.HeadcountForecast FTEFTE: @_data.FteValue.ToString("F2")
+
+
+

Leave Type Breakdown

+

By category this period

+
+
+ @{ + var leaveBreakdowns = _data.LeaveTypeBreakdowns?.Take(6).ToList() ?? new List(); + var totalLeaves = leaveBreakdowns.Sum(x => x.Count); + if (totalLeaves == 0) totalLeaves = 1; + foreach (var lb in leaveBreakdowns.Take(6)) + { + var lpct = (int)Math.Round((double)lb.Count / totalLeaves * 100); +
+@lb.Count

@lb.Type

+ } + } +
+
+
+ + @* ROW 4: Recent Appraisals + HRM Activity *@ +
+
+
+

Recent Appraisals

Latest salary reviews

+
+
+ + + + @{ + var dealList = _data.RecentDeals ?? new List(); + } + @if (dealList.Any()) + { + @foreach (var deal in dealList) + { + + + + + + + + } + } + else + { + + } + +
DateRef#DescriptionValueStatus
@deal.Date@deal.Ref@deal.Description@deal.Value@deal.Status
No appraisal data available
+
+
+ Active: @_data.TotalAppraisals reviews + Payroll: @_data.FormattedTotalPayroll +
+
+
+
+

HRM Activity

Real-time HR updates

+ Live +
+
+ @{ + var feedList = _data.ActivityFeeds ?? new List(); + } + @if (feedList.Any()) + { + @foreach (var act in feedList) + { +
+
@act.Initials
+
+

@((MarkupString)(act.Title ?? ""))

+

@act.Subtitle

+

@act.TimeAgo

+
+ @act.ChipLabel +
+ } + } + else + { +

No recent activity

+ } +
+
+
+ + @* ROW 5: Recruitment Pipeline + Top Performers + Compensation Breakdown *@ +
+
+

Recruitment Pipeline

+

@DateTime.Now.Year performance

+
+ @{ + var sourced = _data.PipelineSourced > 0 ? _data.PipelineSourced : 1; + var interviewed = _data.PipelineInterviewed > 0 ? _data.PipelineInterviewed : 1; + var pipelineItems = new[] { + ("Sourced", _data.PipelineSourced, _data.PipelineSourced > 0 ? _data.PipelineSourced : 1, "#2563eb"), + ("Interviewed", _data.PipelineInterviewed, _data.PipelineSourced > 0 ? _data.PipelineSourced : 1, "#f97316"), + ("Offered", _data.PipelineOffered, _data.PipelineInterviewed > 0 ? _data.PipelineInterviewed : 1, "#ec4899"), + ("Fill Rate", _data.PipelineOffered, _data.PipelineOffered > 0 ? _data.PipelineOffered : 1, "#10b981") + }; + } + @foreach (var (label, val, max, color) in pipelineItems) + { + var pct = max > 0 ? (double)val / max * 100 : 0; + pct = Math.Min(pct, 100); +
+
+
+ +
+
+
+

@label

+ @val +
+
+
+
+
+ } +
+
+ Target: @_data.HeadcountForecast FTE + On Track +
+
+
+

Top Performers

+

This period ranking

+
+ @{ + var perfColors = new[] { "#f97316", "#f59e0b", "#6366f1", "#8b5cf6" }; + var performerList = _data.TopPerformers?.Take(4).ToList() ?? new List(); + } + @for (int i = 0; i < performerList.Count; i++) + { + var p = performerList[i]; +
+
+ +
+

@p.Name

KPI: @p.KpiScore

+ @p.Grade +
+ } +
+
Top Talent: @_data.TopTalentCountAvg KPI: @(performerList.Any() ? performerList.Average(x => x.KpiScore).ToString("F0") : "-")
+
+
+

Compensation Breakdown

+

Payroll composition

+
+ @{ + var compList = _data.CompensationStats ?? new List(); + } + @foreach (var stat in compList) + { +
+
+ +
+

@stat.Label

@stat.Value

+
@($"{stat.Percentage:F0}%")
+
+ } + @if (!compList.Any()) + { +

No payroll data available

+ } +
+
+
+ + @* ROW 6: Active Transfers + Pending Leave Requests *@ +
+
+
+

Active Transfers

As of @DateTime.Now.ToString("MMM dd, yyyy")

+
+
+
+ + + + @{ + var transferList = _data.RecentTransfers ?? new List(); + } + @if (transferList.Any()) + { + @foreach (var tr in transferList) + { + + + + + + } + } + else + { + + } + +
EmployeeLocationImpact
@tr.EmployeeName@tr.ToLocation@tr.ImpactLevel/5
No active transfers
+
+
+
+ Total Transfers: @_data.TotalTransferRequests.ToString("N0") +
+
+
+
+

Latest Leave Requests

Recent submissions

+
+
+
+ + + + @{ + var leaveList = _data.PendingLeaves ?? new List(); + } + @if (leaveList.Any()) + { + @foreach (var lv in leaveList) + { + + + @{ + var chipClass = lv.Status switch + { + "Approved" => "chip-green", + "Rejected" => "chip-red", + "Cancelled" => "chip-red", + _ => "chip-amber" + }; + } + + + + } + } + else + { + + } + +
Request IDStatusDue
#@lv.TicketId@lv.Status@lv.DueInfo
No leave requests found
+
+
+
+ Total Leaves: @_data.TotalLeaveRequests.ToString("N0") + Showing: @leaveList.Count records +
+
+
+ } + +
+
+ +@code { + private DashboardDataResponse _data = new(); + private bool _isLoading = true; + private bool _chartsInitialized = false; + + protected override async Task OnInitializedAsync() + { + _isLoading = true; + try + { + var res = await HomeService.GetDashboardStatsAsync(); + if (res?.IsSuccess == true && res.Value != null) + _data = res.Value; + } + finally + { + _isLoading = false; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!_isLoading && !_chartsInitialized) + { + _chartsInitialized = true; + await InitCharts(); + } + } + + private async Task InitCharts() + { + try + { + // Headcount Trend Bar Chart + var hcLabels = _data.HeadcountTrends?.Select(x => x.Month).ToList() ?? new List(); + await JS.InvokeVoidAsync("HRMCharts.renderBarChart", "headcountChart", hcLabels, new[] { + new { label = "Staff", data = _data.HeadcountTrends?.Select(x => (object)x.Count).ToList() ?? new List(), backgroundColor = "#2563eb", borderRadius = 4, barPercentage = 0.4 }, + new { label = "New Hires", data = _data.HeadcountTrends?.Select(x => (object)x.NewHires).ToList() ?? new List(), backgroundColor = "#f97316", borderRadius = 4, barPercentage = 0.4 } + }); + + // Leave Type Doughnut Chart + var lbList = _data.LeaveTypeBreakdowns ?? new List(); + await JS.InvokeVoidAsync("HRMCharts.renderDoughnutChart", "leaveTypeChart", + lbList.Take(6).Select(x => x.Type).ToList(), + lbList.Take(6).Select(x => (object)x.Count).ToList(), + new[] { "#2563eb", "#f97316", "#ec4899", "#8b5cf6", "#06b6d4", "#10b981" }.Take(lbList.Count).ToList()); + + // Department Distribution Bar Chart + var deptList2 = _data.DeptDistributions ?? new List(); + await JS.InvokeVoidAsync("HRMCharts.renderBarChart", "deptChart", + deptList2.Select(x => x.Department).ToList(), + new[] { + new { label = "Staff", data = deptList2.Select(x => (object)x.Count).ToList(), backgroundColor = deptList2.Select((_, i) => (object)(i % 2 == 0 ? "#2563eb" : "#f97316")).ToList(), borderRadius = 4, barPercentage = 0.5 } + }); + } + catch { } + } +} + + \ No newline at end of file diff --git a/Features/Root/Home/Cqrs/DashboardHandler.cs b/Features/Root/Home/Cqrs/DashboardHandler.cs new file mode 100644 index 0000000..71ef076 --- /dev/null +++ b/Features/Root/Home/Cqrs/DashboardHandler.cs @@ -0,0 +1,567 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Root.Home.Cqrs; + +public class SalaryReviewDto +{ + public string? EmployeeName { get; set; } + public string? Increment { get; set; } + public string? Status { get; set; } +} + +public class CareerPathDto +{ + public string? EmployeeName { get; set; } + public string? ToGrade { get; set; } + public DateTime? Date { get; set; } +} + +public class StaffPerformanceDto +{ + public string? Name { get; set; } + public string? Dept { get; set; } + public string? LoS { get; set; } + public int KpiScore { get; set; } + public string? Grade { get; set; } + public int ReviewJourney { get; set; } +} + +public class ActiveTransferDto +{ + public string? EmployeeName { get; set; } + public string? ToLocation { get; set; } + public int ImpactLevel { get; set; } +} + +public class LeaveTicketDto +{ + public string? TicketId { get; set; } + public string? Status { get; set; } + public string? DueInfo { get; set; } +} + +public class CompStatsDto +{ + public string? Label { get; set; } + public string? Value { get; set; } + public double Percentage { get; set; } + public string? Color { get; set; } +} + +public class CareerBenchmarkDto +{ + public string? EmployeeName { get; set; } + public string? Position { get; set; } + public double SalaryRatio { get; set; } + public string? Status { get; set; } + public int StarRating { get; set; } +} + +// Chart data DTOs +public class HeadcountTrendDto +{ + public string? Month { get; set; } + public int Count { get; set; } + public int NewHires { get; set; } +} + +public class DeptDistributionDto +{ + public string? Department { get; set; } + public int Count { get; set; } +} + +public class LeaveTypeBreakdownDto +{ + public string? Type { get; set; } + public int Count { get; set; } +} + +public class RecentDealDto +{ + public string? Date { get; set; } + public string? Ref { get; set; } + public string? Description { get; set; } + public string? Value { get; set; } + public string? Status { get; set; } +} + +public class ActivityFeedDto +{ + public string? Initials { get; set; } + public string? AvatarColor { get; set; } + public string? Title { get; set; } + public string? Subtitle { get; set; } + public string? TimeAgo { get; set; } + public string? ChipLabel { get; set; } + public string? ChipClass { get; set; } +} + +public class DashboardDataResponse +{ + public int TotalStaff { get; set; } + public double FteValue { get; set; } + public decimal AvgCostPerHire { get; set; } + public int HeadcountForecast { get; set; } + public double RetentionRate { get; set; } + public double LeaversRate { get; set; } + public double EngagementRate { get; set; } + public double EmployeeExperienceNps { get; set; } + public double EmployeeExperienceSat { get; set; } + public double RoiPercent { get; set; } + public double CertifiedPercent { get; set; } + public int PipelineSourced { get; set; } + public int PipelineInterviewed { get; set; } + public int PipelineOffered { get; set; } + public int TotalBranches { get; set; } + public int TotalDepartments { get; set; } + public int TopTalentCount { get; set; } + public int CorePlayerCount { get; set; } + public int LowPerfCount { get; set; } + + public List TopPerformers { get; set; } = new(); + public List RecentTransfers { get; set; } = new(); + public List PendingLeaves { get; set; } = new(); + public List RecentAppraisals { get; set; } = new(); + public List UpcomingPromotions { get; set; } = new(); + public List CompensationStats { get; set; } = new(); + public List CareerBenchmarks { get; set; } = new(); + + // Chart data + public List HeadcountTrends { get; set; } = new(); + public List DeptDistributions { get; set; } = new(); + public List LeaveTypeBreakdowns { get; set; } = new(); + public List RecentDeals { get; set; } = new(); + public List ActivityFeeds { get; set; } = new(); + + // Additional KPIs + public int TotalLeaveRequests { get; set; } + public int TotalAppraisals { get; set; } + public int TotalTransferRequests { get; set; } + public int ActivePromotions { get; set; } + public decimal TotalPayrollAmount { get; set; } + + public string FormattedAvgCost + { + get + { + if (AvgCostPerHire >= 1000000) + return (AvgCostPerHire / 1000000m).ToString("F1") + "M"; + if (AvgCostPerHire >= 1000) + return (AvgCostPerHire / 1000m).ToString("F1") + "K"; + return AvgCostPerHire.ToString("C0"); + } + } + + public string FormattedTotalPayroll + { + get + { + if (TotalPayrollAmount >= 1000000) + return (TotalPayrollAmount / 1000000m).ToString("F1") + "M"; + if (TotalPayrollAmount >= 1000) + return (TotalPayrollAmount / 1000m).ToString("F1") + "K"; + return TotalPayrollAmount.ToString("N0"); + } + } +} + +public record GetDashboardQuery() : IRequest; + +public class DashboardHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public DashboardHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetDashboardQuery request, CancellationToken ct) + { + var today = DateTime.Today; + + // --- Employee counts --- + var totalStaff = await _context.Employee.NotDeletedOnly().CountAsync(ct); + var fullTimeStaff = await _context.Employee.NotDeletedOnly().CountAsync(x => x.EmploymentType == "Full-Time", ct); + double fteValue = totalStaff > 0 ? (double)fullTimeStaff / totalStaff : 0; + + // --- Resigned / Leavers --- + var resignedCount = await _context.Employee.NotDeletedOnly().CountAsync(x => x.ResignedDate != null && x.ResignedDate <= today, ct); + double retentionRate = totalStaff > 0 ? Math.Round((double)(totalStaff - resignedCount) / totalStaff * 100, 1) : 0; + double leaversRate = totalStaff > 0 ? Math.Round((double)resignedCount / totalStaff * 100, 1) : 0; + + // --- Average Cost per Hire (from appraisals) --- + var avgSalary = await _context.Appraisal.NotDeletedOnly().AverageAsync(x => (decimal?)x.NewSalary, ct) ?? 0; + var forecastCount = await _context.Employee.NotDeletedOnly().CountAsync(x => x.JoinedDate >= today.AddMonths(-3), ct); + + // --- Branches & Departments --- + var totalBranches = await _context.Branch.NotDeletedOnly().CountAsync(ct); + var totalDepts = await _context.Department.NotDeletedOnly().CountAsync(ct); + + // --- Totals --- + var totalLeaveRequests = await _context.LeaveRequest.NotDeletedOnly().CountAsync(ct); + var totalAppraisals = await _context.Appraisal.NotDeletedOnly().CountAsync(ct); + var totalTransfers = await _context.Transfer.NotDeletedOnly().CountAsync(ct); + var activePromotions = await _context.Promotion.NotDeletedOnly() + .CountAsync(x => x.Status == "Active" || x.Status == "Pending", ct); + + // --- Evaluation / Performance data --- + var allEvaluations = await _context.Evaluation + .Include(x => x.Employee).ThenInclude(e => e!.Department) + .Include(x => x.Employee).ThenInclude(e => e!.Grade) + .NotDeletedOnly() + .ToListAsync(ct); + + var rawEvaluationData = allEvaluations + .OrderByDescending(x => x.CreatedAt) + .Take(10) + .ToList(); + + var performers = rawEvaluationData.Select(x => + { + int.TryParse(x.FinalScore, out var s); + int.TryParse(x.Rating, out var r); + return new StaffPerformanceDto + { + Name = $"{x.Employee?.FirstName} {x.Employee?.LastName}", + Dept = x.Employee?.Department?.Name ?? "N/A", + Grade = x.Employee?.Grade?.Name ?? "-", + KpiScore = s, + ReviewJourney = r > 0 ? r : 1, + LoS = x.Employee?.JoinedDate.HasValue == true ? $"{(today.Year - x.Employee.JoinedDate.Value.Year)}y" : "0y" + }; + }).ToList(); + + // --- Engagement Rate: average final score from all evaluations --- + var scoredEvaluations = allEvaluations + .Select(x => { int.TryParse(x.FinalScore, out var s); return s; }) + .Where(s => s > 0) + .ToList(); + double engagementRate = scoredEvaluations.Any() ? Math.Round(scoredEvaluations.Average(), 1) : 0; + + // --- Employee Experience (EX): NPS & SAT --- + double employeeExperienceNps = 0; + double employeeExperienceSat = 0; + if (allEvaluations.Any()) + { + var ratingsWithValue = allEvaluations + .Select(x => { int.TryParse(x.Rating, out var r); return r; }) + .Where(r => r > 0) + .ToList(); + employeeExperienceNps = ratingsWithValue.Any() + ? Math.Round(ratingsWithValue.Average() / 10.0 * 10.0, 1) + : 0; + employeeExperienceSat = scoredEvaluations.Any() ? Math.Round(scoredEvaluations.Average(), 0) : 0; + } + + // --- ROI: derived from appraisal increment percentage average --- + var avgIncrementPct = await _context.Appraisal + .NotDeletedOnly() + .AverageAsync(x => (double?)x.IncrementPercentage, ct) ?? 0; + double roiPercent = Math.Round(avgIncrementPct, 1); + + // --- Certified % (Talent Scoring) --- + int totalScoredEmployees = scoredEvaluations.Count; + int certifiedCount = scoredEvaluations.Count(s => s >= 80); + double certifiedPercent = totalScoredEmployees > 0 + ? Math.Round((double)certifiedCount / totalScoredEmployees * 100, 0) + : 0; + + // --- Recruitment Pipeline --- + var employeesJoinedThisYear = await _context.Employee + .NotDeletedOnly() + .CountAsync(x => x.JoinedDate >= new DateTime(today.Year, 1, 1), ct); + var totalEvaluationsThisYear = allEvaluations.Count(x => x.EvaluationDate >= new DateTime(today.Year, 1, 1)); + var totalPromotionsThisYear = await _context.Promotion + .NotDeletedOnly() + .CountAsync(x => x.EffectiveDate >= new DateTime(today.Year, 1, 1), ct); + + int pipelineSourced = employeesJoinedThisYear + totalEvaluationsThisYear; + int pipelineInterviewed = totalEvaluationsThisYear; + int pipelineOffered = totalPromotionsThisYear; + + // --- Transfers --- + var transfers = await _context.Transfer + .Include(x => x.Employee).Include(x => x.ToBranch) + .NotDeletedOnly() + .OrderByDescending(x => x.CreatedAt) + .Take(5) + .Select(x => new ActiveTransferDto + { + EmployeeName = x.Employee != null ? x.Employee.FirstName : "User", + ToLocation = x.ToBranch != null ? x.ToBranch.Name : "Other", + ImpactLevel = x.Status == "Processed" ? 5 : 3 + }).ToListAsync(ct); + + // --- Leave requests (latest 5, any status) --- + var leaves = await _context.LeaveRequest + .NotDeletedOnly() + .OrderByDescending(x => x.CreatedAt) + .Take(5) + .Select(x => new LeaveTicketDto + { + TicketId = x.AutoNumber, + Status = x.Status ?? "Submitted", + DueInfo = x.StartDate.ToString("MMM dd") + }).ToListAsync(ct); + + // --- Appraisals (materialize first for safe memory projection) --- + var recentAppraisals = await _context.Appraisal + .Include(x => x.Employee) + .NotDeletedOnly() + .OrderByDescending(x => x.CreatedAt) + .Take(3) + .ToListAsync(ct); + + var salaryReviews = recentAppraisals.Select(x => new SalaryReviewDto + { + EmployeeName = x.Employee != null ? x.Employee.FirstName : "Staff", + Increment = x.IncrementPercentage.ToString("F1") + "%", + Status = x.Status + }).ToList(); + + // --- Promotions (materialize first) --- + var upcomingPromotionsRaw = await _context.Promotion + .Include(x => x.Employee) + .NotDeletedOnly() + .OrderByDescending(x => x.CreatedAt) + .Take(3) + .ToListAsync(ct); + + var promoDtos = upcomingPromotionsRaw.Select(x => new CareerPathDto + { + EmployeeName = x.Employee != null ? x.Employee.FirstName : "Staff", + ToGrade = x.ToGrade, + Date = x.EffectiveDate + }).ToList(); + + // --- Payroll compensation stats --- + var latestPayroll = await _context.PayrollProcess + .NotDeletedOnly() + .OrderByDescending(x => x.ToDate) + .FirstOrDefaultAsync(ct); + + var compStats = new List(); + if (latestPayroll != null) + { + var totalGross = latestPayroll.TotalGross > 0 ? latestPayroll.TotalGross : 1; + compStats.Add(new CompStatsDto + { + Label = "Take Home Pay Net", + Value = latestPayroll.TotalTakeHomePay.ToString("N0"), + Percentage = (double)(latestPayroll.TotalTakeHomePay / totalGross) * 100, + Color = "#3b82f6" + }); + compStats.Add(new CompStatsDto + { + Label = "Tax & Deductions", + Value = latestPayroll.TotalDeduction.ToString("N0"), + Percentage = (double)(latestPayroll.TotalDeduction / totalGross) * 100, + Color = "#ef4444" + }); + compStats.Add(new CompStatsDto + { + Label = "Incomes/Allowances", + Value = latestPayroll.TotalIncome.ToString("N0"), + Percentage = (double)(latestPayroll.TotalIncome / totalGross) * 100, + Color = "#10b981" + }); + } + + // --- Career benchmarks (materialize first) --- + var benchmarks = await _context.Employee + .Include(x => x.Grade) + .Include(x => x.Designation) + .NotDeletedOnly() + .Where(x => x.Grade != null) + .OrderByDescending(x => x.CreatedAt) + .Take(5) + .ToListAsync(ct); + + var careerBenchmarks = benchmarks.Select(x => + { + var ratio = x.Grade!.SalaryTo > 0 ? (double)(x.BasicSalary / x.Grade.SalaryTo) * 100 : 0; + return new CareerBenchmarkDto + { + EmployeeName = x.FirstName, + Position = x.Designation?.Name ?? "Staff", + SalaryRatio = ratio, + Status = ratio > 90 ? "Top Range" : ratio > 60 ? "Mid Range" : "Entry", + StarRating = ratio > 80 ? 3 : ratio > 40 ? 2 : 1 + }; + }).ToList(); + + // --- CHART: Headcount Trend (last 6 months) --- + var headcountTrends = new List(); + for (int i = 5; i >= 0; i--) + { + var monthStart = new DateTime(today.Year, today.Month, 1).AddMonths(-i); + var monthEnd = monthStart.AddMonths(1); + var count = await _context.Employee.NotDeletedOnly() + .CountAsync(x => x.JoinedDate < monthEnd + && (x.ResignedDate == null || x.ResignedDate >= monthStart), ct); + var newHires = await _context.Employee.NotDeletedOnly() + .CountAsync(x => x.JoinedDate >= monthStart && x.JoinedDate < monthEnd, ct); + headcountTrends.Add(new HeadcountTrendDto + { + Month = monthStart.ToString("MMM"), + Count = count, + NewHires = newHires + }); + } + + // --- CHART: Department Distribution --- + var allDepartments = await _context.Department + .NotDeletedOnly() + .Select(x => x.Name) + .ToListAsync(ct); + + var employeeDeptCounts = await _context.Employee + .Include(x => x.Department) + .NotDeletedOnly() + .Where(x => x.Department != null) + .Select(x => new { DeptName = x.Department!.Name }) + .ToListAsync(ct); + + var deptDistributions = allDepartments + .Select(dept => new DeptDistributionDto + { + Department = dept ?? "Other", + Count = employeeDeptCounts.Count(e => e.DeptName == dept) + }) + .OrderByDescending(x => x.Count) + .ThenBy(x => x.Department) + .ToList(); + + // --- CHART: Leave Type Breakdown (materialize first, group in memory) --- + var leaveTypeRawNames = await _context.LeaveRequest + .Include(x => x.LeaveCategory) + .NotDeletedOnly() + .Where(x => x.LeaveCategory != null) + .Select(x => new { CatName = x.LeaveCategory!.Name }) + .ToListAsync(ct); + + var leaveTypeBreakdowns = leaveTypeRawNames + .GroupBy(x => x.CatName) + .Select(g => new LeaveTypeBreakdownDto + { + Type = g.Key ?? "Other", + Count = g.Count() + }) + .OrderByDescending(x => x.Count) + .ToList(); + + // --- Recent Deals (appraisals as deals) --- + var recentDeals = recentAppraisals.Take(5).Select(x => new RecentDealDto + { + Date = x.CreatedAt?.ToString("MMM dd") ?? "-", + Ref = x.AutoNumber ?? "-", + Description = (x.Employee?.FirstName ?? "Staff") + " appraisal review", + Value = x.NewSalary.ToString("N0"), + Status = x.Status ?? "Pending" + }).ToList(); + + // --- Activity Feed --- + var activityFeeds = new List(); + + if (recentAppraisals.Any()) + { + var a = recentAppraisals.First(); + activityFeeds.Add(new ActivityFeedDto + { + Initials = (a.Employee?.FirstName?.Length > 0 ? a.Employee.FirstName[..1] : "S").ToUpper(), + AvatarColor = "#10b981", + Title = $"Appraisal Completed — {a.Employee?.FirstName ?? "Staff"}", + Subtitle = $"📋 Salary: {a.NewSalary:N0} · Increment: {a.IncrementPercentage:F1}%", + TimeAgo = "1 hour ago", + ChipLabel = a.Status ?? "Completed", + ChipClass = "chip-green" + }); + } + + if (upcomingPromotionsRaw.Any()) + { + var p = upcomingPromotionsRaw.First(); + activityFeeds.Add(new ActivityFeedDto + { + Initials = (p.Employee?.FirstName?.Length > 0 ? p.Employee.FirstName[..1] : "P").ToUpper(), + AvatarColor = "#6366f1", + Title = $"Promotion Scheduled — {p.Employee?.FirstName ?? "Staff"}", + Subtitle = $"🏷️ To Grade: {p.ToGrade} · Effective: {p.EffectiveDate:dd MMM yyyy}", + TimeAgo = "2 hours ago", + ChipLabel = "Scheduled", + ChipClass = "chip-indigo" + }); + } + + if (leaves.Any()) + { + var l = leaves.First(); + activityFeeds.Add(new ActivityFeedDto + { + Initials = "LR", + AvatarColor = "#f97316", + Title = $"Leave Request Pending — #{l.TicketId}", + Subtitle = $"📅 Status: {l.Status} · Due: {l.DueInfo}", + TimeAgo = "3 hours ago", + ChipLabel = "Pending", + ChipClass = "chip-amber" + }); + } + + if (transfers.Any()) + { + var t = transfers.First(); + activityFeeds.Add(new ActivityFeedDto + { + Initials = (t.EmployeeName?.Length > 0 ? t.EmployeeName[..1] : "T").ToUpper(), + AvatarColor = "#2563eb", + Title = $"Transfer Request — {t.EmployeeName}", + Subtitle = $"📍 To: {t.ToLocation} · Impact Level: {t.ImpactLevel}/5", + TimeAgo = "5 hours ago", + ChipLabel = t.ImpactLevel > 3 ? "Processing" : "Pending", + ChipClass = t.ImpactLevel > 3 ? "chip-blue" : "chip-indigo" + }); + } + + return new DashboardDataResponse + { + TotalStaff = totalStaff, + FteValue = Math.Round(fteValue, 2), + AvgCostPerHire = avgSalary, + HeadcountForecast = forecastCount + 5, + RetentionRate = retentionRate, + LeaversRate = leaversRate, + EngagementRate = engagementRate, + EmployeeExperienceNps = employeeExperienceNps, + EmployeeExperienceSat = employeeExperienceSat, + RoiPercent = roiPercent, + CertifiedPercent = certifiedPercent, + PipelineSourced = pipelineSourced, + PipelineInterviewed = pipelineInterviewed, + PipelineOffered = pipelineOffered, + TotalBranches = totalBranches, + TotalDepartments = totalDepts, + TotalLeaveRequests = totalLeaveRequests, + TotalAppraisals = totalAppraisals, + TotalTransferRequests = totalTransfers, + ActivePromotions = activePromotions, + TotalPayrollAmount = latestPayroll?.TotalGross ?? 0, + TopPerformers = performers, + RecentTransfers = transfers, + PendingLeaves = leaves, + RecentAppraisals = salaryReviews, + UpcomingPromotions = promoDtos, + CompensationStats = compStats, + CareerBenchmarks = careerBenchmarks, + HeadcountTrends = headcountTrends, + DeptDistributions = deptDistributions, + LeaveTypeBreakdowns = leaveTypeBreakdowns, + RecentDeals = recentDeals, + ActivityFeeds = activityFeeds, + TopTalentCount = performers.Count(x => x.KpiScore >= 85), + CorePlayerCount = performers.Count(x => x.KpiScore < 85 && x.KpiScore >= 60), + LowPerfCount = performers.Count(x => x.KpiScore < 60) + }; + } +} \ No newline at end of file diff --git a/Features/Root/Home/HomeEndpoint.cs b/Features/Root/Home/HomeEndpoint.cs new file mode 100644 index 0000000..8493ae9 --- /dev/null +++ b/Features/Root/Home/HomeEndpoint.cs @@ -0,0 +1,23 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Root.Home.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Root.Home; + +public static class HomeEndpoint +{ + public static void MapHomeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/dashboard").WithTags("Dashboard") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/stats", async (IMediator mediator) => + { + var result = await mediator.Send(new GetDashboardQuery()); + return result.ToApiResponse("Dashboard data retrieved successfully"); + }) + .WithName("GetDashboardStats"); + } +} \ No newline at end of file diff --git a/Features/Root/Home/HomeService.cs b/Features/Root/Home/HomeService.cs new file mode 100644 index 0000000..65cd6f3 --- /dev/null +++ b/Features/Root/Home/HomeService.cs @@ -0,0 +1,30 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Root.Home.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Root.Home; + +public class HomeService : BaseService +{ + private readonly RestClient _client; + + public HomeService(IHttpClientFactory clientFactory, NavigationManager nav, ISnackbar snackbar, ICurrentUserService currentUserService, TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task?> GetDashboardStatsAsync() + { + var request = new RestRequest("api/dashboard/stats", Method.Get); + + request.AddHeader("Accept", "application/json"); + + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Root/MainLayout.razor b/Features/Root/MainLayout.razor new file mode 100644 index 0000000..98b2d7c --- /dev/null +++ b/Features/Root/MainLayout.razor @@ -0,0 +1,287 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.ConfigBackEnd.Interfaces +@using Indotalent.Infrastructure.Authentication +@using Indotalent.Infrastructure.Authorization.Identity +@using Indotalent.Shared.Consts +@using Microsoft.AspNetCore.Components.Authorization +@inherits LayoutComponentBase +@inject NavigationManager NavigationManager +@inject ICurrentUserService CurrentUserService + + + + + + + + + + + + + + + + + Profile + + + + Change Tenant + + + + Logout + + + + + + + + + + +
+ + @if (_drawerOpen) + { + @GlobalConsts.AppInitial + } +
+
+ + + + @* Member & Admin *@ + + + @(_drawerOpen ? "Home" : "") + @(_drawerOpen ? "Profile" : "") + @(_drawerOpen ? "Change Tenant" : "") + @(_drawerOpen ? "Leave Management" : "") + @(_drawerOpen ? "Performance" : "") + @(_drawerOpen ? "Organization" : "") + @(_drawerOpen ? "Payroll" : "") + + + + @* Tenant Admin Only *@ + + + @(_drawerOpen ? "App Settings" : "") + + + + @* Admin Only *@ + + + @(_drawerOpen ? "Multitenant" : "") + @(_drawerOpen ? "System Logs" : "") + @(_drawerOpen ? "System Settings" : "") + + + + + +
+
+ + + + + + @Body + + +
+ + +@code { + private bool _drawerOpen = true; + + private void DrawerToggle() => _drawerOpen = !_drawerOpen; + private void GoToProfile() => NavigationManager.NavigateTo("/profile"); + private void GoToTenantSelection() => NavigationManager.NavigateTo("/account/tenant-selection"); + private void Logout() => NavigationManager.NavigateTo("/account/logout"); +} + + + + + + + + + diff --git a/Features/Root/MainLayout.razor.css b/Features/Root/MainLayout.razor.css new file mode 100644 index 0000000..10d6b84 --- /dev/null +++ b/Features/Root/MainLayout.razor.css @@ -0,0 +1,22 @@ + + +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/Features/Root/NotFound/NotFoundPage.razor b/Features/Root/NotFound/NotFoundPage.razor new file mode 100644 index 0000000..a7458f0 --- /dev/null +++ b/Features/Root/NotFound/NotFoundPage.razor @@ -0,0 +1,33 @@ +@page "/not-found" +@using Microsoft.AspNetCore.Mvc +@layout MainLayout +@attribute [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + + + +
+ + + + Page Not Found (404) + + + Sorry, the page you are looking for is unavailable or has been moved. +
+ Please double-check the URL or return to the home page. +
+ + + Back to Home + + +
+ +
\ No newline at end of file diff --git a/Features/Root/Pages/LandingPage.cshtml b/Features/Root/Pages/LandingPage.cshtml new file mode 100644 index 0000000..072c100 --- /dev/null +++ b/Features/Root/Pages/LandingPage.cshtml @@ -0,0 +1,474 @@ +@page "/" + + + + + + + HRM - Human Resource Management + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + Blazor HRM — SaaS · Multitenant +
+

+ The Next-Gen
+ People Engine +

+

+ Built for scale, engineered for excellence. A production-ready Blazor Server HRM source code + that works end-to-end — no more burning AI tokens building from scratch. +

+ +
+ Powered by + .NET 10 Ready + SaaS Multitenant + Production Ready +
+
+
+
+ + +
+
+
+ COMPREHENSIVE HR PLATFORM +

Everything HR, Ready to Deploy

+

From employee profiles to payroll processing — a complete HR ecosystem that covers your entire people operations.

+
+
+
Profile
Employee
+
Leave
Management
+
Performance
Evaluation
+
Organization
Structure
+
Payroll
Process
+
System Logs
Audit Trail
+
App Settings
Configuration
+
System Settings
Inspection Mode
+
MudBlazor 9
UI Library
+
VSA
Architecture
+
Monolithic
Single Project
+
LINQ
Query Language
+
+
+
+ + +
+
+
+ WHY HRM? +

Stop Prompting. Start Shipping.

+

AI can generate code snippets, but building a fully functional, end-to-end application is a different challenge entirely. HRM solves the real problems behind enterprise software delivery.

+
+
+
+
+ +
+

Guaranteed to Run & Beautiful

+

No AI hallucinations, no broken compiles. HRM runs on day one — and looks stunning doing it. A real-world showcase of Blazor + MudBlazor 9 proving .NET can deliver gorgeous enterprise UIs that actually work.

+
+
+
+ +
+

Save Weeks of Development

+

Even with AI assistance, building a complete HRM from scratch takes weeks of prompting, fixing, and refactoring. Get the finished product now and focus on what makes your business unique.

+
+
+
+ +
+

Vertical Slice Architecture

+

Feature-first organization. Each business capability is an independent slice — commands, queries, handlers, and validation — making the codebase intuitive to navigate and extend.

+
+
+
+ +
+

Single Project Monolith

+

No microservice complexity. No multi-project juggling. One solution, one project — open in Visual Studio 2026, hit F5, and you're running. Perfect for teams that value simplicity.

+
+
+
+ +
+

Clean, Modern C# Codebase

+

CQRS with MediatR, LINQ, and best-practice patterns throughout. Learn from production code that follows Microsoft's latest conventions for .NET 10 and ASP.NET Core.

+
+
+
+ +
+

Multi-Tenant Ready

+

Serve multiple organizations from a single deployment. Built-in tenant isolation ensures complete data separation — each tenant sees only their own employees, leaves, and payroll data.

+
+
+
+
+ + +
+
+
+
+ TECH STACK +

Powered by .NET 10 & Blazor

+

Built on Microsoft's latest stack. Blazor Server for real-time UI, MudBlazor for polished components, and MS SQL for rock-solid data persistence — all in one cohesive solution.

+
+
+
+
ASP.NET Core 10 & Blazor Server

Real-time, component-based web UI with C#

+
+
+
+
MudBlazor 9

Material Design component library for Blazor

+
+
+
+
MediatR + CQRS + VSA

Clean command/query separation with feature slices

+
+
+
+
MS SQL + EF Core

Reliable data layer with full migration support

+
+
+
+
+
.NET 10
Runtime
+
Blazor
Web UI
+
MudBlazor
Components
+
MS SQL
Database
+
+
+
+
+ + +
+
+
+ SEE IT IN ACTION +

Try the Live Demo

+

Don't take our word for it. Explore every feature in the live demo — no installation required.

+
+
+
+
+
+ +
+

Demo Credentials

+

Use these credentials to sign in and explore the full application:

+
+
+ +
+ Email + admin@root.com +
+
+ Password + 123456 +
+
+ +
+
+
+
+ + +
+
+

Stop Burning AI Tokens. Start Building.

+

AI can scaffold a page. It can't guarantee a working payroll engine. HRM gives you the real deal — a complete, battle-tested HR platform with full source code.

+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/Features/Root/ReconnectModal.razor b/Features/Root/ReconnectModal.razor new file mode 100644 index 0000000..7611248 --- /dev/null +++ b/Features/Root/ReconnectModal.razor @@ -0,0 +1,258 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +

+ The session has been paused by the server. +

+

+ Failed to resume the session.
Please retry or reload the page. +

+ +
+
+ + + + + diff --git a/Features/Root/Routes.razor b/Features/Root/Routes.razor new file mode 100644 index 0000000..5a524f4 --- /dev/null +++ b/Features/Root/Routes.razor @@ -0,0 +1,25 @@ +@using Indotalent.ConfigBackEnd.Interfaces +@using Indotalent.Features.Account.AccessDenied +@using Indotalent.Features.Root.NotFound +@using Microsoft.AspNetCore.Components.Authorization + + + + + + + + + + +
+ + Memverifikasi identitas... +
+
+
+ +
+
+
+ diff --git a/Features/Root/Shared/_DeleteConfirmation.razor b/Features/Root/Shared/_DeleteConfirmation.razor new file mode 100644 index 0000000..dc4c15c --- /dev/null +++ b/Features/Root/Shared/_DeleteConfirmation.razor @@ -0,0 +1,58 @@ +@using MudBlazor + + + +
+
+ + Confirm Delete +
+
+
+ +
+ Are you sure you want to delete @ContentText? + This action cannot be undone. +
+
+ +
+ Cancel + + @if (_isDeleting) + { + + Deleting... + } + else + { + Yes, Delete It + } + +
+
+
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string ContentText { get; set; } = string.Empty; + + private bool _isDeleting = false; + + private async Task HandleSubmit() + { + _isDeleting = true; + StateHasChanged(); + await Task.Delay(1000); + MudDialog.Close(DialogResult.Ok(true)); + } + + private void Cancel() => MudDialog.Cancel(); +} \ No newline at end of file diff --git a/Features/Root/SsoFirebase.razor b/Features/Root/SsoFirebase.razor new file mode 100644 index 0000000..949ad68 --- /dev/null +++ b/Features/Root/SsoFirebase.razor @@ -0,0 +1,41 @@ +@using Indotalent.Infrastructure.Authentication.Identity +@using Microsoft.Extensions.Options +@inject IOptions IdentityOptions + +@if (IdentityOptions.Value.SsoFirebase.IsUsed) +{ + + + +} \ No newline at end of file diff --git a/Features/Root/SsoKeycloak.razor b/Features/Root/SsoKeycloak.razor new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/Features/Root/SsoKeycloak.razor @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Features/Root/_Imports.razor b/Features/Root/_Imports.razor new file mode 100644 index 0000000..09f7c74 --- /dev/null +++ b/Features/Root/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Indotalent +@using Indotalent.Features.Root +@using MudBlazor + +@inject NavigationManager NavigationManager \ No newline at end of file diff --git a/Features/Serilogs/Database/Components/DatabasePage.razor b/Features/Serilogs/Database/Components/DatabasePage.razor new file mode 100644 index 0000000..b5c0d61 --- /dev/null +++ b/Features/Serilogs/Database/Components/DatabasePage.razor @@ -0,0 +1,403 @@ +@page "/serilogs/database" +@using Indotalent.Data.Entities +@using Indotalent.Infrastructure.Database +@using Indotalent.Shared.Consts +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.EntityFrameworkCore +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using Indotalent.Features.Serilogs.Database +@using Indotalent.Shared.Models +@using ClosedXML.Excel +@using System.IO +@inject SerilogsService SerilogsService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Database Logs + Monitor application events, errors, and system activities stored in MSSQL Server. +
+ +
+ + / + Serilogs + / + Database +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + + @if (_isClearing) + { + + } + else + { + Clear All + } + + + @if (_selectedLog != null) + { + View + Delete + + } +
+
+ + + + + Timestamp + Level + Message + Exception + + + + + + + @context.TimeStamp.ToString("yyyy-MM-dd HH:mm:ss") + + + + @context.Level + + + + @context.Message + + + @if (!string.IsNullOrEmpty(context.Exception)) + { + + Has Error + } + else + { + - + } + + + + +
+
+ Rows per page: + + + + + + + + + + + Showing @(_totalCount == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, _totalCount) of @_totalCount + +
+ +
+ + + + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + + + +
+
+
+ + + + + +@code { + private GetSerilogLogsPagedListResponse? _selectedLog = null; + private List _pagedItems = new(); + private int _skip = 0; + private int _top = 5; + private int _totalCount = 0; + private int _totalPage = 0; + private int _currentPage => (_skip / _top) + 1; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isClearing = false; + private bool _isExporting = false; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + StateHasChanged(); + var response = await SerilogsService.GetSerilogLogsODataAsync(_skip, _top, _searchString); + await Task.Delay(500); + if (response != null) + { + _pagedItems = response.Value ?? new(); + _totalCount = response.Count; + _totalPage = (int)Math.Ceiling((double)_totalCount / _top); + } + else + { + _pagedItems = new(); + _totalCount = 0; + _totalPage = 0; + } + _isRefreshing = false; + StateHasChanged(); + } + + private async Task HandleRefresh() + { + _skip = 0; + _selectedLog = null; + await LoadData(); + } + + private void HandleRowClick(TableRowClickEventArgs args) + { + _selectedLog = (_selectedLog?.Id == args.Item.Id) ? null : args.Item; + } + + private async Task OnSearchClick() + { + _skip = 0; + _selectedLog = null; + await LoadData(); + } + + private async Task HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") await OnSearchClick(); + } + + private async Task OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedLog = null; + await LoadData(); + } + } + + private async Task OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedLog = null; + await LoadData(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + var response = await SerilogsService.GetSerilogLogsODataAsync(0, GlobalConsts.ODataMaxTop, _searchString); + var dataToExport = response?.Value ?? new List(); + + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("DatabaseLogs"); + var currentRow = 1; + worksheet.Cell(currentRow, 1).Value = "Timestamp"; + worksheet.Cell(currentRow, 2).Value = "Level"; + worksheet.Cell(currentRow, 3).Value = "Message"; + worksheet.Cell(currentRow, 4).Value = "Exception"; + + var headerRange = worksheet.Range(1, 1, 1, 4); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in dataToExport) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.TimeStamp.ToString("yyyy-MM-dd HH:mm:ss"); + worksheet.Cell(currentRow, 2).Value = item.Level; + worksheet.Cell(currentRow, 3).Value = item.Message; + worksheet.Cell(currentRow, 4).Value = item.Exception; + } + + worksheet.Columns().AdjustToContents(); + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Database_Logs.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private async Task OnDeleteAll() + { + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, "ALL database logs" } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("Confirmation", parameters); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + _isClearing = true; + StateHasChanged(); + if (await SerilogsService.DeleteSerilogLogsAllAsync()) + { + Snackbar.Add("All logs cleared.", Severity.Success); + _selectedLog = null; _skip = 0; await LoadData(); + } + _isClearing = false; + StateHasChanged(); + } + } + + private async Task OnDeleteSingle() + { + if (_selectedLog == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, $"Log ID {_selectedLog.Id}" } }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("Confirmation", parameters); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + if (await SerilogsService.DeleteSerilogLogsByIdAsync(_selectedLog.Id)) + { + Snackbar.Add("Log deleted.", Severity.Success); + _selectedLog = null; await LoadData(); + } + } + } + + private async Task OnViewDetail() + { + if (_selectedLog == null) return; + var detail = await SerilogsService.GetSerilogLogsByIdAsync(_selectedLog.Id); + if (detail != null) + { + var parameters = new DialogParameters<_SerilogDetailDialog> { { x => x.Log, detail } }; + await DialogService.ShowAsync<_SerilogDetailDialog>("Log Details", parameters, new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true }); + } + } + + private Color GetLevelColor(string? level) => level switch + { + "Information" or "Info" => Color.Info, + "Warning" => Color.Warning, + "Error" or "Fatal" => Color.Error, + _ => Color.Default + }; +} \ No newline at end of file diff --git a/Features/Serilogs/Database/Components/_SerilogDetailDialog.razor b/Features/Serilogs/Database/Components/_SerilogDetailDialog.razor new file mode 100644 index 0000000..969a128 --- /dev/null +++ b/Features/Serilogs/Database/Components/_SerilogDetailDialog.razor @@ -0,0 +1,50 @@ +@using Indotalent.Features.Serilogs.Database +@using MudBlazor + + + + + + Log Detail [#@Log.Id] + + + + + + + Message + + @Log.Message + + + + @if (!string.IsNullOrEmpty(Log.Exception)) + { + + Exception / Stack Trace +
+ @Log.Exception +
+
+ } + + + Properties (JSON Context) +
+ @Log.Properties +
+
+
+
+
+ + Close + +
+ +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public GetSerilogLogsByIdResponse Log { get; set; } = default!; + + private void Close() => MudDialog.Close(); +} \ No newline at end of file diff --git a/Features/Serilogs/Database/DeleteSerilogLogsAllHandler.cs b/Features/Serilogs/Database/DeleteSerilogLogsAllHandler.cs new file mode 100644 index 0000000..0eebf62 --- /dev/null +++ b/Features/Serilogs/Database/DeleteSerilogLogsAllHandler.cs @@ -0,0 +1,28 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Serilogs.Database; + + + + +public record DeleteSerilogLogsAllCommand() : IRequest; + +public class DeleteSerilogLogsHandler : + IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteSerilogLogsHandler(AppDbContext context) + { + _context = context; + } + + + public async Task Handle(DeleteSerilogLogsAllCommand request, CancellationToken cancellationToken) + { + await _context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE SerilogLogs", cancellationToken); + return true; + } +} \ No newline at end of file diff --git a/Features/Serilogs/Database/DeleteSerilogLogsByIdHandler.cs b/Features/Serilogs/Database/DeleteSerilogLogsByIdHandler.cs new file mode 100644 index 0000000..8504f58 --- /dev/null +++ b/Features/Serilogs/Database/DeleteSerilogLogsByIdHandler.cs @@ -0,0 +1,30 @@ +using Indotalent.Infrastructure.Database; +using MediatR; + +namespace Indotalent.Features.Serilogs.Database; + + + +public record DeleteSerilogLogsByIdCommand(int Id) : IRequest; + + +public class DeleteSerilogLogsByIdHandler : + IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteSerilogLogsByIdHandler(AppDbContext context) + { + _context = context; + } + + public async Task Handle(DeleteSerilogLogsByIdCommand request, CancellationToken cancellationToken) + { + var log = await _context.Set().FindAsync(request.Id); + if (log == null) return false; + + _context.Set().Remove(log); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } + +} \ No newline at end of file diff --git a/Features/Serilogs/Database/GetSerilogLogsByIdHandler.cs b/Features/Serilogs/Database/GetSerilogLogsByIdHandler.cs new file mode 100644 index 0000000..f66a8e6 --- /dev/null +++ b/Features/Serilogs/Database/GetSerilogLogsByIdHandler.cs @@ -0,0 +1,34 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Serilogs.Database; + +public record GetSerilogLogsByIdResponse( + int Id, + string? Message, + string? MessageTemplate, + string? Level, + DateTimeOffset TimeStamp, + string? Exception, + string? Properties); + +public record GetSerilogLogsByIdQuery(int Id) : IRequest; + +public class GetSerilogLogsByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetSerilogLogsByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSerilogLogsByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Set() + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetSerilogLogsByIdResponse( + x.Id, x.Message, x.MessageTemplate, x.Level, x.TimeStamp, x.Exception, x.Properties)) + .FirstOrDefaultAsync(cancellationToken); + } +} diff --git a/Features/Serilogs/Database/GetSerilogLogsPagedListHandler.cs b/Features/Serilogs/Database/GetSerilogLogsPagedListHandler.cs new file mode 100644 index 0000000..54a84c9 --- /dev/null +++ b/Features/Serilogs/Database/GetSerilogLogsPagedListHandler.cs @@ -0,0 +1,53 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using Indotalent.Shared.Models; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Serilogs.Database; + +public record GetSerilogLogsPagedListResponse( + int Id, + string? Message, + string? Level, + DateTimeOffset TimeStamp, + string? Exception) +{ + public GetSerilogLogsPagedListResponse() : this(0, null, null, DateTimeOffset.Now, null) { } +} + +public record GetSerilogLogsPagedListQuery(ApiRequest Request) : IRequest>; + +public class GetSerilogLogsPagedListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetSerilogLogsPagedListHandler(AppDbContext context) + { + _context = context; + } + + public async Task> Handle(GetSerilogLogsPagedListQuery query, CancellationToken cancellationToken) + { + var r = query.Request; + var queryable = _context.Set().AsNoTracking(); + + if (!string.IsNullOrEmpty(r.SearchTerm)) + { + queryable = queryable.Where(x => x.Message.Contains(r.SearchTerm) || x.Level.Contains(r.SearchTerm)); + } + + var pagedList = await queryable + .OrderByDescending(x => x.TimeStamp) + .Select(x => new GetSerilogLogsPagedListResponse( + x.Id, + x.Message, + x.Level, + x.TimeStamp, + x.Exception)) + .ToPagedListAsync(r.Skip, r.Top); + + return pagedList; + } +} \ No newline at end of file diff --git a/Features/Serilogs/File/Components/FilePage.razor b/Features/Serilogs/File/Components/FilePage.razor new file mode 100644 index 0000000..b004c2a --- /dev/null +++ b/Features/Serilogs/File/Components/FilePage.razor @@ -0,0 +1,350 @@ +@page "/serilogs/file" +@using System.IO +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Hosting +@using Microsoft.JSInterop +@using MudBlazor +@using System.Linq +@inject IWebHostEnvironment Env +@inject IJSRuntime JS +@inject IConfiguration Configuration +@inject ISnackbar Snackbar + + +
+ File Logs + Browse and download log files stored on the local server storage. +
+ +
+ + / + Serilogs + / + File +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedFile != null) + { + + @if (_isDownloading) + { + + Downloading... + } + else + { + Download File + } + + + } +
+
+ + + + + + File Name + + + Last Modified + + + Size + + + + + + + +
+ + @context.FileName +
+
+ + @context.LastModified.ToString("yyyy-MM-dd HH:mm:ss") + + + @context.FileSize + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1) - @Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + + + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } + } + @for (int i = startPage; i <= endPage; i++) + { + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum + } + + + +
+
+
+ + + +@code { + private List _allLogFiles = new(); + private LogFileInfo? _selectedFile; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isDownloading = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / _top); + private int _currentPage => (_skip / _top) + 1; + + public class LogFileInfo + { + public string FileName { get; set; } = ""; + public string FullPath { get; set; } = ""; + public DateTime LastModified { get; set; } + public string FileSize { get; set; } = ""; + public long SizeInBytes { get; set; } + } + + protected override void OnInitialized() + { + LoadFiles(); + } + + private async Task HandleRefresh() + { + _isRefreshing = true; + _selectedFile = null; + _skip = 0; + StateHasChanged(); + await Task.Delay(800); + LoadFiles(); + _isRefreshing = false; + StateHasChanged(); + } + + private void LoadFiles() + { + _allLogFiles.Clear(); + string configPath = Configuration["LoggerSettings:File:Path"] ?? "wwwroot/xlogs/app-log-.txt"; + string? directoryPath = Path.GetDirectoryName(configPath); + if (string.IsNullOrEmpty(directoryPath)) directoryPath = "wwwroot/xlogs"; + string fullDirectoryPath = Path.Combine(Env.ContentRootPath, directoryPath); + + if (Directory.Exists(fullDirectoryPath)) + { + var files = new DirectoryInfo(fullDirectoryPath).GetFiles("*.txt"); + foreach (var file in files.OrderByDescending(f => f.LastWriteTime)) + { + _allLogFiles.Add(new LogFileInfo + { + FileName = file.Name, + FullPath = file.FullName, + LastModified = file.LastWriteTime, + SizeInBytes = file.Length, + FileSize = GetFriendlyFileSize(file.Length) + }); + } + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _allLogFiles; + return _allLogFiles.Where(x => x.FileName.Contains(_searchString, StringComparison.OrdinalIgnoreCase)); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedFile = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedFile = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedFile = null; + StateHasChanged(); + } + + private void HandleRowClick(TableRowClickEventArgs args) + { + _selectedFile = (_selectedFile?.FullPath == args.Item.FullPath) ? null : args.Item; + } + + private async Task DownloadFile() + { + if (_selectedFile == null) return; + + _isDownloading = true; + StateHasChanged(); + + try + { + await Task.Delay(1000); + var bytes = await File.ReadAllBytesAsync(_selectedFile.FullPath); + using var stream = new MemoryStream(bytes); + using var streamRef = new DotNetStreamReference(stream); + + await JS.InvokeVoidAsync("downloadFileFromStream", _selectedFile.FileName, streamRef); + Snackbar.Add("Log file downloaded successfully", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"Download failed: {ex.Message}", Severity.Error); + } + finally + { + _isDownloading = false; + StateHasChanged(); + } + } + + private string GetFriendlyFileSize(long bytes) + { + string[] suffix = { "B", "KB", "MB", "GB" }; + int i; + double dblSByte = bytes; + for (i = 0; i < suffix.Length && bytes >= 1024; i++, bytes /= 1024) + { + dblSByte = bytes / 1024.0; + } + return $"{dblSByte:0.##} {suffix[i]}"; + } +} + + \ No newline at end of file diff --git a/Features/Serilogs/SerilogsEndpoint.cs b/Features/Serilogs/SerilogsEndpoint.cs new file mode 100644 index 0000000..6a8cd66 --- /dev/null +++ b/Features/Serilogs/SerilogsEndpoint.cs @@ -0,0 +1,59 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Serilogs.Database; +using Indotalent.Shared.Models; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Serilogs; + +public static class SerilogsEndpoint +{ + public static void MapSerilogsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/serilog-logs").WithTags("Serilogs") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async ([AsParameters] ApiRequest request, IMediator mediator) => + { + var result = await mediator.Send(new GetSerilogLogsPagedListQuery(request)); + return result.ToApiResponse("Database logs retrieved successfully"); + }) + .WithName("GetSerilogLogsList") + .WithTags("Serilogs"); + + group.MapGet("/{id:int}", async (int id, IMediator mediator) => + { + var result = await mediator.Send(new GetSerilogLogsByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Log detail retrieved successfully" + : $"Log with ID {id} not found"); + }) + .WithName("GetSerilogLogById") + .WithTags("Serilogs"); + + group.MapPost("/delete/{id:int}", async (int id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSerilogLogsByIdCommand(id)); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The log data could not be found."); + } + + return true.ToApiResponse("Log entry has been deleted successfully"); + }) + .WithName("DeleteSerilogLogById") + .WithTags("Serilogs"); + + group.MapPost("/clear-all", async (IMediator mediator) => + { + await mediator.Send(new DeleteSerilogLogsAllCommand()); + + return true.ToApiResponse("All database logs have been cleared successfully"); + }) + .WithName("DeleteSerilogLogsAll") + .WithTags("Serilogs"); + } +} \ No newline at end of file diff --git a/Features/Serilogs/SerilogsODataController.cs b/Features/Serilogs/SerilogsODataController.cs new file mode 100644 index 0000000..98b706a --- /dev/null +++ b/Features/Serilogs/SerilogsODataController.cs @@ -0,0 +1,29 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Serilogs; + +[Route("odata/SerilogLogs")] +public class SerilogsODataController : ODataController +{ + private readonly AppDbContext _context; + + public SerilogsODataController(AppDbContext context) + { + _context = context; + } + + [EnableQuery] + [HttpGet] + public IQueryable Get() + { + return _context.Set() + .OrderByDescending(x => x.TimeStamp) + .AsNoTracking(); + } + +} \ No newline at end of file diff --git a/Features/Serilogs/SerilogsPage.razor b/Features/Serilogs/SerilogsPage.razor new file mode 100644 index 0000000..4949e69 --- /dev/null +++ b/Features/Serilogs/SerilogsPage.razor @@ -0,0 +1,88 @@ +@page "/serilogs" +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = ApplicationRoles.Admin)] +@using Indotalent.Features.Serilogs.Database +@using Indotalent.Features.Serilogs.Database.Components +@using Indotalent.Features.Serilogs.File +@using Indotalent.Features.Serilogs.File.Components +@using MudBlazor +@inject NavigationManager NavigationManager + + + + +
+ + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + _activeTabIndex = tabValue.ToString().ToLower() switch + { + "database" => 0, + "file" => 1, + _ => 0 + }; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + string tabName = index switch + { + 0 => "database", + 1 => "file", + _ => "database" + }; + + NavigationManager.NavigateTo($"/serilogs?tab={tabName}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Serilogs/SerilogsService.cs b/Features/Serilogs/SerilogsService.cs new file mode 100644 index 0000000..6073ef1 --- /dev/null +++ b/Features/Serilogs/SerilogsService.cs @@ -0,0 +1,105 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Serilogs.Database; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Infrastructure.OData; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Serilogs; + +public class SerilogsService : BaseService +{ + public SerilogsService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + } + + public async Task>?> GetSerilogLogsPagedListAsync(ApiRequest apiRequest) + { + var client = new RestClient(CreateClient()); + var request = new RestRequest("api/serilog-logs", Method.Get); + + request.AddObject(apiRequest); + + return await ExecuteWithResponseAsync>(client, request); + } + + public async Task DeleteSerilogLogsByIdAsync(int id) + { + var client = new RestClient(CreateClient()); + + var request = new RestRequest($"api/serilog-logs/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(client, request); + + return response?.IsSuccess ?? false; + } + + public async Task DeleteSerilogLogsAllAsync() + { + var client = new RestClient(CreateClient()); + + var request = new RestRequest("api/serilog-logs/clear-all", Method.Post); + + var response = await ExecuteWithResponseAsync(client, request); + + return response?.IsSuccess ?? false; + } + + public async Task GetSerilogLogsByIdAsync(int id) + { + var client = new RestClient(CreateClient()); + var request = new RestRequest($"api/serilog-logs/{id}", Method.Get); + + var response = await ExecuteWithResponseAsync(client, request); + + return response?.Value; + } + + public async Task?> GetSerilogLogsODataAsync(int skip, int top, string? searchTerm) + { + var client = new RestClient(CreateClient()); + var request = new RestRequest("odata/SerilogLogs", Method.Get); + + request.AddHeader("Accept", "application/json"); + + request.AddQueryParameter("$skip", skip.ToString()); + request.AddQueryParameter("$top", top.ToString()); + request.AddQueryParameter("$count", "true"); + request.AddQueryParameter("$orderby", "TimeStamp desc"); + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + string filter = $"contains(Message,'{searchTerm}') or contains(Level,'{searchTerm}')"; + request.AddQueryParameter("$filter", filter); + } + + var response = await client.ExecuteAsync(request); + + if (response.IsSuccessful && !string.IsNullOrWhiteSpace(response.Content)) + { + var data = System.Text.Json.JsonSerializer.Deserialize>(response.Content, new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (data != null) + { + return new PagedList(data.Value, data.Count, skip, top); + } + } + + return null; + } + + +} + diff --git a/Features/Setting/AutoNumberSequence/AutoNumberSequenceEndpoint.cs b/Features/Setting/AutoNumberSequence/AutoNumberSequenceEndpoint.cs new file mode 100644 index 0000000..bec0fc1 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/AutoNumberSequenceEndpoint.cs @@ -0,0 +1,48 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Setting.AutoNumberSequence.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Setting.AutoNumberSequence; + +public static class AutoNumberSequenceEndpoint +{ + public static void MapAutoNumberSequenceEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/auto-number-sequences").WithTags("Auto Number Sequences") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetAutoNumberSequenceListQuery()); + return result.ToApiResponse("Data auto number sequences retrieved successfully"); + }) + .WithName("GetAutoNumberSequenceList") + .WithTags("Auto Number Sequences"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetAutoNumberSequenceByIdQuery(id)); + return result.ToApiResponse(result is not null + ? "Sequence detail retrieved successfully" + : $"Sequence with ID {id} not found"); + }) + .WithName("GetAutoNumberSequenceById") + .WithTags("Auto Number Sequences"); + + group.MapPost("/update", async (UpdateAutoNumberSequenceRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateAutoNumberSequenceCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The sequence data could not be found."); + } + + return result.ToApiResponse("Sequence has been updated successfully"); + }) + .WithName("UpdateAutoNumberSequence") + .WithTags("Auto Number Sequences"); + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/AutoNumberSequenceService.cs b/Features/Setting/AutoNumberSequence/AutoNumberSequenceService.cs new file mode 100644 index 0000000..47cac81 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/AutoNumberSequenceService.cs @@ -0,0 +1,39 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Setting.AutoNumberSequence.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Setting.AutoNumberSequence; + +public class AutoNumberSequenceService : BaseService +{ + private readonly RestClient _client; + + public AutoNumberSequenceService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetAutoNumberSequenceListAsync() + { + var request = new RestRequest("api/auto-number-sequences", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> UpdateAutoNumberSequenceAsync(UpdateAutoNumberSequenceRequest data) + { + var request = new RestRequest("api/auto-number-sequences/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Components/AutoNumberSequencePage.razor b/Features/Setting/AutoNumberSequence/Components/AutoNumberSequencePage.razor new file mode 100644 index 0000000..5c1f601 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Components/AutoNumberSequencePage.razor @@ -0,0 +1,43 @@ +@page "/setting/auto-number-sequence" +@using Indotalent.Features.Setting.AutoNumberSequence.Cqrs +@using MudBlazor + +@if (_isShowingForm) +{ + <_AutoNumberSequenceForm ExistingData="_selectedData" + ReadOnly="_isReadOnly" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_AutoNumberSequenceDataTable OnEdit="(item) => ShowForm(item, false)" + OnView="(item) => ShowForm(item, true)" /> +} + +@code { + private bool _isShowingForm = false; + private bool _isReadOnly = false; + private GetAutoNumberSequenceListResponse? _selectedData; + + private void ShowForm(GetAutoNumberSequenceListResponse data, bool readOnly) + { + _selectedData = data; + _isReadOnly = readOnly; + _isShowingForm = true; + } + + private void BackToTable() + { + _isShowingForm = false; + _selectedData = null; + _isReadOnly = false; + } + + private void HandleSuccess() + { + _isShowingForm = false; + _selectedData = null; + _isReadOnly = false; + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceDataTable.razor b/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceDataTable.razor new file mode 100644 index 0000000..0e6c2a8 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceDataTable.razor @@ -0,0 +1,355 @@ +@using Indotalent.Features.Setting.AutoNumberSequence.Cqrs +@using Indotalent.Shared.Models +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using ClosedXML.Excel +@using System.IO +@inject AutoNumberSequenceService AutoNumberSequenceService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Auto Number Sequences + Manage numbering rules, prefixes, and sequence counters for various entities. +
+
+ + / + Settings + / + Auto Number +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedItem != null) + { + View + Edit + + } +
+
+ + + + + + Entity Name + + + Prefix + + Year + Month + Current Seq + Suffix + Padding + + + + + + + @context.EntityName + + + + @(string.IsNullOrEmpty(context.PrefixTemplate) ? "-" : context.PrefixTemplate) + + + @context.Year + @(context.Month?.ToString("D2") ?? "-") + + + @context.CurrentSequence + + + + + @(string.IsNullOrEmpty(context.SuffixTemplate) ? "-" : context.SuffixTemplate) + + + @context.PaddingLength + + + +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _items = new(); + private GetAutoNumberSequenceListResponse? _selectedItem; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedItem = null; + StateHasChanged(); + try + { + var response = await AutoNumberSequenceService.GetAutoNumberSequenceListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _items = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _items; + return _items.Where(x => + (x.EntityName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.PrefixTemplate?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.SuffixTemplate?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("AutoNumberSequences"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Entity Name"; + worksheet.Cell(currentRow, 2).Value = "Prefix"; + worksheet.Cell(currentRow, 3).Value = "Year"; + worksheet.Cell(currentRow, 4).Value = "Month"; + worksheet.Cell(currentRow, 5).Value = "Current Seq"; + worksheet.Cell(currentRow, 6).Value = "Suffix"; + worksheet.Cell(currentRow, 7).Value = "Padding"; + + var headerRange = worksheet.Range(1, 1, 1, 7); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.EntityName; + worksheet.Cell(currentRow, 2).Value = item.PrefixTemplate; + worksheet.Cell(currentRow, 3).Value = item.Year; + worksheet.Cell(currentRow, 4).Value = item.Month; + worksheet.Cell(currentRow, 5).Value = item.CurrentSequence; + worksheet.Cell(currentRow, 6).Value = item.SuffixTemplate; + worksheet.Cell(currentRow, 7).Value = item.PaddingLength; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Auto_Number_Sequences.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedItem = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedItem = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedItem != null) await OnEdit.InvokeAsync(_selectedItem); + } + + private async Task InvokeView() + { + if (_selectedItem != null) await OnView.InvokeAsync(_selectedItem); + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceForm.razor b/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceForm.razor new file mode 100644 index 0000000..727dbaa --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Components/_AutoNumberSequenceForm.razor @@ -0,0 +1,155 @@ +@using Indotalent.Features.Setting.AutoNumberSequence.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject AutoNumberSequenceService AutoNumberSequenceService +@inject ISnackbar Snackbar + + +
+ +
+ @GetTitle() + Adjust the numbering rules and template formats for system entities. +
+
+
+ + + + + + Entity Name + + + + + Prefix Template + + + + + Suffix Template + + + + @if (ReadOnly) + { + + Current Sequence + + + + Padding Length + + + + Period + + + } + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + + @if (!ReadOnly) + { + + @if (_processing) + { + + Processing... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public GetAutoNumberSequenceListResponse? ExistingData { get; set; } + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateAutoNumberSequenceValidator _validator = new(); + private UpdateAutoNumberSequenceRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + if (ExistingData != null) + { + _model = new UpdateAutoNumberSequenceRequest + { + Id = ExistingData.Id, + EntityName = ExistingData.EntityName, + PrefixTemplate = ExistingData.PrefixTemplate, + SuffixTemplate = ExistingData.SuffixTemplate + }; + } + } + + private string GetTitle() + { + if (ReadOnly) return "Auto Number Details"; + return "Edit Auto Number Template"; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await AutoNumberSequenceService.UpdateAutoNumberSequenceAsync(_model); + + await Task.Delay(500); + + if (response?.IsSuccess ?? false) + { + Snackbar.Add("Template updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"System Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceByIdHandler.cs b/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceByIdHandler.cs new file mode 100644 index 0000000..93ef761 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceByIdHandler.cs @@ -0,0 +1,28 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.AutoNumberSequence.Cqrs; + +public record GetAutoNumberSequenceByIdResponse( + string Id, string EntityName, int? Year, int? Month, + long CurrentSequence, string PrefixTemplate, string SuffixTemplate, int PaddingLength); + +public record GetAutoNumberSequenceByIdQuery(string Id) : IRequest; + +public class GetAutoNumberSequenceByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public GetAutoNumberSequenceByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetAutoNumberSequenceByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Set() + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetAutoNumberSequenceByIdResponse( + x.Id, x.EntityName ?? "", x.Year, x.Month, + x.CurrentSequence ?? 0, x.PrefixTemplate ?? "", x.SuffixTemplate ?? "", x.PaddingLength ?? 0)) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceListHandler.cs b/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceListHandler.cs new file mode 100644 index 0000000..4ff0a75 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Cqrs/GetAutoNumberSequenceListHandler.cs @@ -0,0 +1,45 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.AutoNumberSequence.Cqrs; + +public class GetAutoNumberSequenceListResponse +{ + public string? Id { get; set; } + public string? EntityName { get; set; } + public int? Year { get; set; } + public int? Month { get; set; } + public long? CurrentSequence { get; set; } + public string? PrefixTemplate { get; set; } + public string? SuffixTemplate { get; set; } + public int? PaddingLength { get; set; } +} + +public record GetAutoNumberSequenceListQuery() : IRequest>; + +public class GetAutoNumberSequenceListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetAutoNumberSequenceListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetAutoNumberSequenceListQuery request, CancellationToken cancellationToken) + { + return await _context.AutoNumberSequence + .AsNoTracking() + .OrderBy(x => x.EntityName) + .Select(x => new GetAutoNumberSequenceListResponse + { + Id = x.Id, + EntityName = x.EntityName, + Year = x.Year, + Month = x.Month, + CurrentSequence = x.CurrentSequence, + PrefixTemplate = x.PrefixTemplate, + SuffixTemplate = x.SuffixTemplate, + PaddingLength = x.PaddingLength + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceHandler.cs b/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceHandler.cs new file mode 100644 index 0000000..bd35026 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceHandler.cs @@ -0,0 +1,41 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.AutoNumberSequence.Cqrs; + +public class UpdateAutoNumberSequenceRequest +{ + public string? Id { get; set; } + public string? EntityName { get; set; } + public string? PrefixTemplate { get; set; } + public string? SuffixTemplate { get; set; } +} + +public class UpdateAutoNumberSequenceResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateAutoNumberSequenceCommand(UpdateAutoNumberSequenceRequest Data) : IRequest; + +public class UpdateAutoNumberSequenceHandler : IRequestHandler +{ + private readonly AppDbContext _context; + public UpdateAutoNumberSequenceHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateAutoNumberSequenceCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Set() + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return new UpdateAutoNumberSequenceResponse { Id = request.Data.Id, Success = false }; + + entity.PrefixTemplate = request.Data.PrefixTemplate; + entity.SuffixTemplate = request.Data.SuffixTemplate; + + await _context.SaveChangesAsync(cancellationToken); + return new UpdateAutoNumberSequenceResponse { Id = entity.Id, Success = true }; + } +} \ No newline at end of file diff --git a/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceValidator.cs b/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceValidator.cs new file mode 100644 index 0000000..c389e21 --- /dev/null +++ b/Features/Setting/AutoNumberSequence/Cqrs/UpdateAutoNumberSequenceValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.AutoNumberSequence.Cqrs; + +public class UpdateAutoNumberSequenceValidator : AbstractValidator +{ + public UpdateAutoNumberSequenceValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required"); + RuleFor(x => x.PrefixTemplate).MaximumLength(GlobalConsts.StringLengthShort); + RuleFor(x => x.SuffixTemplate).MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Setting/Company/CompanyEndpoint.cs b/Features/Setting/Company/CompanyEndpoint.cs new file mode 100644 index 0000000..4867de5 --- /dev/null +++ b/Features/Setting/Company/CompanyEndpoint.cs @@ -0,0 +1,46 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Setting.Company.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Setting.Company; + +public static class CompanyEndpoint +{ + public static void MapCompanyEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/company").WithTags("Companies") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCompanyListQuery()); + return result.ToApiResponse("Company list retrieved successfully"); + }); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetCompanyByIdQuery(id)); + return result.ToApiResponse(result is not null ? "Company retrieved" : "Not found"); + }); + + group.MapPost("/", async (CreateCompanyRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateCompanyCommand(request)); + return result.ToApiResponse("Company created successfully", StatusCodes.Status201Created); + }); + + group.MapPost("/update", async (UpdateCompanyRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateCompanyCommand(request)); + return result.Success ? result.ToApiResponse("Company updated") : result.ToApiResponse("Failed"); + }); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteCompanyByIdCommand(new DeleteCompanyByIdRequest(id))); + return result.ToApiResponse("Company deleted"); + }); + } +} \ No newline at end of file diff --git a/Features/Setting/Company/CompanyService.cs b/Features/Setting/Company/CompanyService.cs new file mode 100644 index 0000000..ed7d3e2 --- /dev/null +++ b/Features/Setting/Company/CompanyService.cs @@ -0,0 +1,54 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Setting.Company.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Setting.Company; + +public class CompanyService : BaseService +{ + private readonly RestClient _client; + + public CompanyService(IHttpClientFactory cf, NavigationManager nav, ISnackbar snack, ICurrentUserService cus, TokenProvider tp) + : base(cf, nav, snack, cus, tp) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetCompanyListAsync() + { + var request = new RestRequest("api/company", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetCompanyByIdAsync(string id) + { + var request = new RestRequest($"api/company/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateCompanyAsync(CreateCompanyRequest data) + { + var request = new RestRequest("api/company", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateCompanyAsync(UpdateCompanyRequest data) + { + var request = new RestRequest("api/company/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteCompanyByIdAsync(string id) + { + var request = new RestRequest($"api/company/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Components/CompanyPage.razor b/Features/Setting/Company/Components/CompanyPage.razor new file mode 100644 index 0000000..00c914f --- /dev/null +++ b/Features/Setting/Company/Components/CompanyPage.razor @@ -0,0 +1,52 @@ +@page "/setting/company" +@using Indotalent.Features.Setting.Company +@using Indotalent.Features.Setting.Company.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_CompanyCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_CompanyUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_CompanyDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateCompanyRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateCompanyRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Components/_CompanyCreateForm.razor b/Features/Setting/Company/Components/_CompanyCreateForm.razor new file mode 100644 index 0000000..1db55fb --- /dev/null +++ b/Features/Setting/Company/Components/_CompanyCreateForm.razor @@ -0,0 +1,250 @@ +@using Indotalent.Features.Setting.Company +@using Indotalent.Features.Setting.Company.Cqrs +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CompanyService CompanyService +@inject CurrencyService CurrencyService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Company + Configure organization identity and legal details. +
+
+
+ + + + + + + Company Profile + Basic identity and branding of your organization. + + + + + +
+ Company Name + +
+
+ +
+ Default Currency + + @foreach (var curr in _currencies) + { + +
+ @curr.Code + (@curr.Symbol) +
+
+ } +
+
+
+
+
+ Company Description + +
+ + + Set as Default Company + + + + + + Tax ID (NPWP) + + + + Business License (NIB) + + + +
+
+
+ + + + + + Head Office Address + Main office location and contact details. + + + +
+ Street Address + +
+ + + City + + + + State/Province + + + + ZIP Code + + + + + + Phone + + + + Email + + + +
+
+
+ + + + + + Social Media Presence + External links for recruitment branding. + + + +
+ LinkedIn + +
+
+ X (Twitter) + +
+
+ Facebook + +
+
+ Instagram + +
+
+ TikTok + +
+
+
+
+ + + + + + Other Information + Additional company metadata. + + + +
+ Other Information 1 + +
+
+ Other Information 2 + +
+
+ Other Information 3 + +
+
+
+
+ +
+ Cancel + + @if (_processing) + { + + Processing... + } + else + { + Create Company + } + +
+
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateCompanyValidator _validator = new(); + private CreateCompanyRequest _model = new(); + private List _currencies = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + var response = await CurrencyService.GetCurrencyListAsync(); + if (response != null && response.IsSuccess) + { + _currencies = response.Value ?? new(); + } + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + _processing = true; + try + { + var response = await CompanyService.CreateCompanyAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Company created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Components/_CompanyDataTable.razor b/Features/Setting/Company/Components/_CompanyDataTable.razor new file mode 100644 index 0000000..45249e8 --- /dev/null +++ b/Features/Setting/Company/Components/_CompanyDataTable.razor @@ -0,0 +1,438 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Company +@using Indotalent.Features.Setting.Company.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject CompanyService CompanyService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Company Management + Manage organization profile, legal entities, and office locations. +
+
+ + / + Settings + / + Company +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedCompany != null) + { + View + Edit + Remove + + } + else + { + + Add New Company + + } +
+
+ + + + + + Company Name + + + Email + + + Phone + + + City + + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name + @if (context.IsDefault) + { + DEFAULT + } +
+
+ @context.Email + @context.Phone + @context.City +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _companies = new(); + private GetCompanyListResponse? _selectedCompany; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 10; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedCompany = null; + StateHasChanged(); + try + { + var response = await CompanyService.GetCompanyListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _companies = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _companies; + return _companies.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Email?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.City?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Phone?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedCompany = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Companies"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Company Name"; + worksheet.Cell(currentRow, 2).Value = "Email"; + worksheet.Cell(currentRow, 3).Value = "Phone"; + worksheet.Cell(currentRow, 4).Value = "City"; + worksheet.Cell(currentRow, 5).Value = "Is Default"; + + var headerRange = worksheet.Range(1, 1, 1, 5); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Email; + worksheet.Cell(currentRow, 3).Value = item.Phone; + worksheet.Cell(currentRow, 4).Value = item.City; + worksheet.Cell(currentRow, 5).Value = item.IsDefault ? "Yes" : "No"; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Registered_Companies.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedCompany = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedCompany = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedCompany == null) return; + var request = await MapToUpdateRequest(_selectedCompany.Id); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedCompany == null) return; + var request = await MapToUpdateRequest(_selectedCompany.Id); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await CompanyService.GetCompanyByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateCompanyRequest + { + Id = detail.Id, + Name = detail.Name, + Description = detail.Description, + IsDefault = detail.IsDefault, + CurrencyId = detail.CurrencyId, + TaxIdentification = detail.TaxIdentification, + BusinessLicense = detail.BusinessLicense, + CompanyLogo = detail.CompanyLogo, + StreetAddress = detail.StreetAddress, + City = detail.City, + StateProvince = detail.StateProvince, + ZipCode = detail.ZipCode, + Phone = detail.Phone, + Email = detail.Email, + SocialMediaLinkedIn = detail.SocialMediaLinkedIn, + SocialMediaX = detail.SocialMediaX, + SocialMediaFacebook = detail.SocialMediaFacebook, + SocialMediaInstagram = detail.SocialMediaInstagram, + SocialMediaTikTok = detail.SocialMediaTikTok, + OtherInformation1 = detail.OtherInformation1, + OtherInformation2 = detail.OtherInformation2, + OtherInformation3 = detail.OtherInformation3, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedCompany == null) return; + + if (_selectedCompany.IsDefault) + { + Snackbar.Add("Cannot delete the default company. Please set another company as default first.", Severity.Warning); + return; + } + + if (_companies.Count <= 1) + { + Snackbar.Add("Cannot delete the last remaining company. At least one company must exist in the system.", Severity.Error); + return; + } + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedCompany.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await CompanyService.DeleteCompanyByIdAsync(_selectedCompany.Id); + if (isSuccess) + { + _selectedCompany = null; + await LoadData(); + Snackbar.Add("Company deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed. This record might be protected by the system.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Components/_CompanyUpdateForm.razor b/Features/Setting/Company/Components/_CompanyUpdateForm.razor new file mode 100644 index 0000000..a2506f3 --- /dev/null +++ b/Features/Setting/Company/Components/_CompanyUpdateForm.razor @@ -0,0 +1,317 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Company +@using Indotalent.Features.Setting.Company.Cqrs +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CompanyService CompanyService +@inject CurrencyService CurrencyService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Company Details" : "Edit Company") + @(ReadOnly ? "Viewing full organization profile." : "Modify existing company information.") +
+
+
+ + + + + + + Company Profile + Basic identity and branding of your organization. + + + + + +
+ Company Name + +
+
+ +
+ Default Currency + + @if (_currencies != null) + { + @foreach (var curr in _currencies) + { + +
+ @curr.Code + (@curr.Symbol) +
+
+ } + } +
+
+
+
+
+ Company Description + +
+ + + Primary Organization + + + + + + Tax ID (NPWP) + + + + Business License (NIB) + + + +
+
+
+ + + + + + Head Office Address + Main office location and contact details. + + + +
+ Street Address + +
+ + + City + + + + State/Province + + + + ZIP Code + + + + + + Phone + + + + Email + + + +
+
+
+ + + + + + Social Media Presence + External links for recruitment branding. + + + +
+ LinkedIn + +
+
+ X (Twitter) + +
+
+ Facebook + +
+
+ Instagram + +
+
+ TikTok + +
+
+
+
+ + + + + + Other Information + Additional company metadata. + + + +
+ Other Information 1 + +
+
+ Other Information 2 + +
+
+ Other Information 3 + +
+
+
+
+ + + + + + Audit History + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ @(ReadOnly ? "Back to List" : "Cancel") + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+
+ +@code { + [Parameter] public UpdateCompanyRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateCompanyValidator _validator = new(); + private UpdateCompanyRequest _model = new(); + private List _currencies = new(); + private bool _processing = false; + + protected override async Task OnInitializedAsync() + { + _processing = true; + var response = await CurrencyService.GetCurrencyListAsync(); + if (response != null && response.IsSuccess) + { + _currencies = response.Value ?? new(); + } + + _model = new UpdateCompanyRequest + { + Id = Data.Id, + Name = Data.Name, + Description = Data.Description, + IsDefault = Data.IsDefault, + CurrencyId = Data.CurrencyId, + TaxIdentification = Data.TaxIdentification, + BusinessLicense = Data.BusinessLicense, + StreetAddress = Data.StreetAddress, + City = Data.City, + StateProvince = Data.StateProvince, + ZipCode = Data.ZipCode, + Phone = Data.Phone, + Email = Data.Email, + SocialMediaLinkedIn = Data.SocialMediaLinkedIn, + SocialMediaX = Data.SocialMediaX, + SocialMediaFacebook = Data.SocialMediaFacebook, + SocialMediaInstagram = Data.SocialMediaInstagram, + SocialMediaTikTok = Data.SocialMediaTikTok, + OtherInformation1 = Data.OtherInformation1, + OtherInformation2 = Data.OtherInformation2, + OtherInformation3 = Data.OtherInformation3, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + _processing = false; + } + + private async Task Submit() + { + if (ReadOnly) return; + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await CompanyService.UpdateCompanyAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Company updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/CreateCompanyHandler.cs b/Features/Setting/Company/Cqrs/CreateCompanyHandler.cs new file mode 100644 index 0000000..f1b195f --- /dev/null +++ b/Features/Setting/Company/Cqrs/CreateCompanyHandler.cs @@ -0,0 +1,107 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class CreateCompanyRequest +{ + public string? Name { get; set; } + public string? Description { get; set; } + public bool IsDefault { get; set; } + public string? CurrencyId { get; set; } + public string? TaxIdentification { get; set; } + public string? BusinessLicense { get; set; } + public string? CompanyLogo { get; set; } + public string? StreetAddress { get; set; } + public string? City { get; set; } + public string? StateProvince { get; set; } + public string? ZipCode { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? SocialMediaLinkedIn { get; set; } + public string? SocialMediaX { get; set; } + public string? SocialMediaFacebook { get; set; } + public string? SocialMediaInstagram { get; set; } + public string? SocialMediaTikTok { get; set; } + public string? OtherInformation1 { get; set; } + public string? OtherInformation2 { get; set; } + public string? OtherInformation3 { get; set; } +} + +public class CreateCompanyResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public record CreateCompanyCommand(CreateCompanyRequest Data) : IRequest; + +public class CreateCompanyHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateCompanyHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateCompanyCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Company + .AnyAsync(x => x.Name == request.Data.Name, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Company", request.Data.Name ?? string.Empty); + } + + if (request.Data.IsDefault) + { + var existingDefaults = await _context.Company + .Where(x => x.IsDefault) + .ToListAsync(cancellationToken); + + foreach (var comp in existingDefaults) + { + comp.IsDefault = false; + } + } + + var entityName = nameof(Data.Entities.Company); + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Company + { + AutoNumber = autoNo, + Name = request.Data.Name ?? string.Empty, + Description = request.Data.Description ?? string.Empty, + IsDefault = request.Data.IsDefault, + CurrencyId = request.Data.CurrencyId ?? string.Empty, + TaxIdentification = request.Data.TaxIdentification ?? string.Empty, + BusinessLicense = request.Data.BusinessLicense ?? string.Empty, + StreetAddress = request.Data.StreetAddress ?? string.Empty, + City = request.Data.City ?? string.Empty, + StateProvince = request.Data.StateProvince ?? string.Empty, + ZipCode = request.Data.ZipCode ?? string.Empty, + Phone = request.Data.Phone ?? string.Empty, + Email = request.Data.Email ?? string.Empty, + SocialMediaLinkedIn = request.Data.SocialMediaLinkedIn ?? string.Empty, + SocialMediaX = request.Data.SocialMediaX ?? string.Empty, + SocialMediaFacebook = request.Data.SocialMediaFacebook ?? string.Empty, + SocialMediaInstagram = request.Data.SocialMediaInstagram ?? string.Empty, + SocialMediaTikTok = request.Data.SocialMediaTikTok ?? string.Empty, + OtherInformation1 = request.Data.OtherInformation1 ?? string.Empty, + OtherInformation2 = request.Data.OtherInformation2 ?? string.Empty, + OtherInformation3 = request.Data.OtherInformation3 ?? string.Empty + }; + + _context.Company.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateCompanyResponse { Id = entity.Id, Name = entity.Name }; + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/CreateCompanyValidator.cs b/Features/Setting/Company/Cqrs/CreateCompanyValidator.cs new file mode 100644 index 0000000..4fad946 --- /dev/null +++ b/Features/Setting/Company/Cqrs/CreateCompanyValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class CreateCompanyValidator : AbstractValidator +{ + public CreateCompanyValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Company Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CurrencyId) + .NotEmpty().WithMessage("Default Currency is required"); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.Phone) + .NotEmpty().WithMessage("Phone number is required"); + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/DeleteCompanyByIdHandler.cs b/Features/Setting/Company/Cqrs/DeleteCompanyByIdHandler.cs new file mode 100644 index 0000000..de56766 --- /dev/null +++ b/Features/Setting/Company/Cqrs/DeleteCompanyByIdHandler.cs @@ -0,0 +1,34 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public record DeleteCompanyByIdRequest(string Id); +public record DeleteCompanyByIdCommand(DeleteCompanyByIdRequest Data) : IRequest; + +public class DeleteCompanyByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteCompanyByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteCompanyByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Company + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + if (entity.IsDefault) return false; + + var totalCount = await _context.Company.CountAsync(cancellationToken); + if (totalCount <= 1) + { + return false; + } + + _context.Company.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/GetCompanyByIdHandler.cs b/Features/Setting/Company/Cqrs/GetCompanyByIdHandler.cs new file mode 100644 index 0000000..52819fa --- /dev/null +++ b/Features/Setting/Company/Cqrs/GetCompanyByIdHandler.cs @@ -0,0 +1,81 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class GetCompanyByIdResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public bool IsDefault { get; set; } + public string? CurrencyId { get; set; } + public string? TaxIdentification { get; set; } + public string? BusinessLicense { get; set; } + public string? CompanyLogo { get; set; } + public string? StreetAddress { get; set; } + public string? City { get; set; } + public string? StateProvince { get; set; } + public string? ZipCode { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? SocialMediaLinkedIn { get; set; } + public string? SocialMediaX { get; set; } + public string? SocialMediaFacebook { get; set; } + public string? SocialMediaInstagram { get; set; } + public string? SocialMediaTikTok { get; set; } + public string? OtherInformation1 { get; set; } + public string? OtherInformation2 { get; set; } + public string? OtherInformation3 { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCompanyByIdQuery(string Id) : IRequest; + +public class GetCompanyByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetCompanyByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCompanyByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Company + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetCompanyByIdResponse + { + Id = x.Id, + Name = x.Name, + Description = x.Description, + IsDefault = x.IsDefault, + CurrencyId = x.CurrencyId, + TaxIdentification = x.TaxIdentification, + BusinessLicense = x.BusinessLicense, + CompanyLogo = x.CompanyLogo, + StreetAddress = x.StreetAddress, + City = x.City, + StateProvince = x.StateProvince, + ZipCode = x.ZipCode, + Phone = x.Phone, + Email = x.Email, + SocialMediaLinkedIn = x.SocialMediaLinkedIn, + SocialMediaX = x.SocialMediaX, + SocialMediaFacebook = x.SocialMediaFacebook, + SocialMediaInstagram = x.SocialMediaInstagram, + SocialMediaTikTok = x.SocialMediaTikTok, + OtherInformation1 = x.OtherInformation1, + OtherInformation2 = x.OtherInformation2, + OtherInformation3 = x.OtherInformation3, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/GetCompanyListHandler.cs b/Features/Setting/Company/Cqrs/GetCompanyListHandler.cs new file mode 100644 index 0000000..c5aee8c --- /dev/null +++ b/Features/Setting/Company/Cqrs/GetCompanyListHandler.cs @@ -0,0 +1,41 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class GetCompanyListResponse +{ + public string? Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + public string? City { get; set; } + public bool IsDefault { get; set; } +} + +public record GetCompanyListQuery() : IRequest>; + +public class GetCompanyListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetCompanyListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetCompanyListQuery request, CancellationToken cancellationToken) + { + return await _context.Company + .AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new GetCompanyListResponse + { + Id = x.Id, + Name = x.Name, + Email = x.Email, + Phone = x.Phone, + City = x.City, + IsDefault = x.IsDefault + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/UpdateCompanyHandler.cs b/Features/Setting/Company/Cqrs/UpdateCompanyHandler.cs new file mode 100644 index 0000000..b9dedd0 --- /dev/null +++ b/Features/Setting/Company/Cqrs/UpdateCompanyHandler.cs @@ -0,0 +1,104 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class UpdateCompanyRequest : CreateCompanyRequest +{ + public string? Id { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateCompanyResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateCompanyCommand(UpdateCompanyRequest Data) : IRequest; + +public class UpdateCompanyHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateCompanyHandler(AppDbContext context) => _context = context; + + public async Task Handle(UpdateCompanyCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Company + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return new UpdateCompanyResponse { Id = request.Data.Id, Success = false }; + + var isNameExists = await _context.Company + .AnyAsync(x => x.Name == request.Data.Name && x.Id != request.Data.Id, cancellationToken); + + if (isNameExists) + { + throw new AlreadyExistsException("Company", request.Data.Name ?? string.Empty); + } + + if (entity.IsDefault && !request.Data.IsDefault) + { + var totalCount = await _context.Company.CountAsync(cancellationToken); + + if (totalCount <= 1) + { + request.Data.IsDefault = true; + throw new Exception("At least one company must be set as default."); + } + } + + if (request.Data.IsDefault) + { + var otherDefaults = await _context.Company + .Where(x => x.IsDefault && x.Id != entity.Id) + .ToListAsync(cancellationToken); + + foreach (var comp in otherDefaults) + { + comp.IsDefault = false; + } + } + else + { + var anyOtherDefault = await _context.Company + .AnyAsync(x => x.IsDefault && x.Id != entity.Id, cancellationToken); + + if (!anyOtherDefault) + { + request.Data.IsDefault = true; + } + } + + entity.Name = request.Data.Name ?? string.Empty; + entity.Description = request.Data.Description ?? string.Empty; + entity.IsDefault = request.Data.IsDefault; + entity.CurrencyId = request.Data.CurrencyId ?? string.Empty; + entity.TaxIdentification = request.Data.TaxIdentification ?? string.Empty; + entity.BusinessLicense = request.Data.BusinessLicense ?? string.Empty; + entity.StreetAddress = request.Data.StreetAddress ?? string.Empty; + entity.City = request.Data.City ?? string.Empty; + entity.StateProvince = request.Data.StateProvince ?? string.Empty; + entity.ZipCode = request.Data.ZipCode ?? string.Empty; + entity.Phone = request.Data.Phone ?? string.Empty; + entity.Email = request.Data.Email ?? string.Empty; + entity.SocialMediaLinkedIn = request.Data.SocialMediaLinkedIn ?? string.Empty; + entity.SocialMediaX = request.Data.SocialMediaX ?? string.Empty; + entity.SocialMediaFacebook = request.Data.SocialMediaFacebook ?? string.Empty; + entity.SocialMediaInstagram = request.Data.SocialMediaInstagram ?? string.Empty; + entity.SocialMediaTikTok = request.Data.SocialMediaTikTok ?? string.Empty; + entity.OtherInformation1 = request.Data.OtherInformation1 ?? string.Empty; + entity.OtherInformation2 = request.Data.OtherInformation2 ?? string.Empty; + entity.OtherInformation3 = request.Data.OtherInformation3 ?? string.Empty; + + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateCompanyResponse { Id = entity.Id, Success = true }; + } +} \ No newline at end of file diff --git a/Features/Setting/Company/Cqrs/UpdateCompanyValidator.cs b/Features/Setting/Company/Cqrs/UpdateCompanyValidator.cs new file mode 100644 index 0000000..e13815e --- /dev/null +++ b/Features/Setting/Company/Cqrs/UpdateCompanyValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Company.Cqrs; + +public class UpdateCompanyValidator : AbstractValidator +{ + public UpdateCompanyValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required for update"); + RuleFor(x => x.Name).NotEmpty().MaximumLength(GlobalConsts.StringLengthShort); + RuleFor(x => x.CurrencyId).NotEmpty(); + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Components/CurrencyPage.razor b/Features/Setting/Currency/Components/CurrencyPage.razor new file mode 100644 index 0000000..a9760c7 --- /dev/null +++ b/Features/Setting/Currency/Components/CurrencyPage.razor @@ -0,0 +1,52 @@ +@page "/setting/currency" +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_CurrencyCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_CurrencyUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_CurrencyDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateCurrencyRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateCurrencyRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Components/_CurrencyCreateForm.razor b/Features/Setting/Currency/Components/_CurrencyCreateForm.razor new file mode 100644 index 0000000..47c2500 --- /dev/null +++ b/Features/Setting/Currency/Components/_CurrencyCreateForm.razor @@ -0,0 +1,118 @@ +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CurrencyService CurrencyService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Currency + Enter currency details and international formatting. +
+
+
+ + + + + + Currency Code + + + + Symbol + + + + Currency Name + + + + Country Owner + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Currency + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateCurrencyValidator _validator = new(); + private CreateCurrencyRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await CurrencyService.CreateCurrencyAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Currency created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Components/_CurrencyDataTable.razor b/Features/Setting/Currency/Components/_CurrencyDataTable.razor new file mode 100644 index 0000000..659709d --- /dev/null +++ b/Features/Setting/Currency/Components/_CurrencyDataTable.razor @@ -0,0 +1,401 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject CurrencyService CurrencyService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Currency Management + Manage global currencies and exchange rates. +
+
+ + / + Settings + / + Currency +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedCurrency != null) + { + View + Edit + Remove + + } + else + { + + Add New Currency + + } +
+
+ + + + + + Currency Name + + + Code + + Symbol + + Country Owner + + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ @context.Code + @context.Symbol + @context.CountryOwner +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _currencies = new(); + private GetCurrencyListResponse? _selectedCurrency; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedCurrency = null; + StateHasChanged(); + try + { + var response = await CurrencyService.GetCurrencyListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _currencies = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _currencies; + return _currencies.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Code?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.CountryOwner?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedCurrency = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Currencies"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Currency Name"; + worksheet.Cell(currentRow, 2).Value = "Code"; + worksheet.Cell(currentRow, 3).Value = "Symbol"; + worksheet.Cell(currentRow, 4).Value = "Country Owner"; + + var headerRange = worksheet.Range(1, 1, 1, 4); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Code; + worksheet.Cell(currentRow, 3).Value = item.Symbol; + worksheet.Cell(currentRow, 4).Value = item.CountryOwner; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Currencies_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedCurrency = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedCurrency = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedCurrency == null) return; + var request = await MapToUpdateRequest(_selectedCurrency.Id); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedCurrency == null) return; + var request = await MapToUpdateRequest(_selectedCurrency.Id); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await CurrencyService.GetCurrencyByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateCurrencyRequest + { + Id = detail.Id, + Code = detail.Code, + Name = detail.Name, + Symbol = detail.Symbol, + CountryOwner = detail.CountryOwner, + Description = detail.Description, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedCurrency == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedCurrency.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await CurrencyService.DeleteCurrencyByIdAsync(_selectedCurrency.Id); + if (isSuccess) + { + _selectedCurrency = null; + await LoadData(); + Snackbar.Add("Currency deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed. This record might be protected by the system.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Components/_CurrencyUpdateForm.razor b/Features/Setting/Currency/Components/_CurrencyUpdateForm.razor new file mode 100644 index 0000000..6b3d35d --- /dev/null +++ b/Features/Setting/Currency/Components/_CurrencyUpdateForm.razor @@ -0,0 +1,170 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Currency +@using Indotalent.Features.Setting.Currency.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject CurrencyService CurrencyService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Currency Details" : "Edit Currency") + @(ReadOnly ? "Viewing international currency specification." : "Modify existing currency information.") +
+
+
+ + + + + + Currency Code + + + + Symbol + + + + Currency Name + + + + Country Owner + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateCurrencyRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateCurrencyValidator _validator = new(); + private UpdateCurrencyRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateCurrencyRequest + { + Id = Data.Id, + Code = Data.Code, + Name = Data.Name, + Symbol = Data.Symbol, + CountryOwner = Data.CountryOwner, + Description = Data.Description, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await CurrencyService.UpdateCurrencyAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Currency updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/CreateCurrencyHandler.cs b/Features/Setting/Currency/Cqrs/CreateCurrencyHandler.cs new file mode 100644 index 0000000..1f46ad7 --- /dev/null +++ b/Features/Setting/Currency/Cqrs/CreateCurrencyHandler.cs @@ -0,0 +1,68 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class CreateCurrencyRequest +{ + public string? Code { get; set; } + public string? Name { get; set; } + public string? Symbol { get; set; } + public string? CountryOwner { get; set; } + public string? Description { get; set; } +} + +public class CreateCurrencyResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateCurrencyCommand(CreateCurrencyRequest Data) : IRequest; +public class CreateCurrencyHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateCurrencyHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateCurrencyCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Currency + .AnyAsync(x => x.Code == request.Data.Code, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Currency", request.Data.Code ?? string.Empty); + } + + var entityName = nameof(Currency); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Currency + { + AutoNumber = autoNo, + Code = request.Data.Code, + Name = request.Data.Name, + Symbol = request.Data.Symbol, + CountryOwner = request.Data.CountryOwner, + Description = request.Data.Description + }; + + _context.Currency.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateCurrencyResponse + { + Id = entity.Id, + Code = entity.Code + }; + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/CreateCurrencyValidator.cs b/Features/Setting/Currency/Cqrs/CreateCurrencyValidator.cs new file mode 100644 index 0000000..6c6f117 --- /dev/null +++ b/Features/Setting/Currency/Cqrs/CreateCurrencyValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class CreateCurrencyValidator : AbstractValidator +{ + public CreateCurrencyValidator() + { + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Currency Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Currency Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Symbol) + .NotEmpty().WithMessage("Symbol is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CountryOwner) + .NotEmpty().WithMessage("Country Owner is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/DeleteCurrencyByIdHandler.cs b/Features/Setting/Currency/Cqrs/DeleteCurrencyByIdHandler.cs new file mode 100644 index 0000000..15e8b25 --- /dev/null +++ b/Features/Setting/Currency/Cqrs/DeleteCurrencyByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public record DeleteCurrencyByIdRequest(string Id); + +public class DeleteCurrencyByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteCurrencyByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteCurrencyByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Currency + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Currency.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} + +public record DeleteCurrencyByIdCommand(DeleteCurrencyByIdRequest Data) : IRequest; \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/GetCurrencyByIdHandler.cs b/Features/Setting/Currency/Cqrs/GetCurrencyByIdHandler.cs new file mode 100644 index 0000000..b85fb6f --- /dev/null +++ b/Features/Setting/Currency/Cqrs/GetCurrencyByIdHandler.cs @@ -0,0 +1,49 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class GetCurrencyByIdResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Symbol { get; set; } + public string? CountryOwner { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCurrencyByIdQuery(string Id) : IRequest; + +public class GetCurrencyByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetCurrencyByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetCurrencyByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Currency + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetCurrencyByIdResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + Symbol = x.Symbol, + CountryOwner = x.CountryOwner, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/GetCurrencyListHandler.cs b/Features/Setting/Currency/Cqrs/GetCurrencyListHandler.cs new file mode 100644 index 0000000..40b86bc --- /dev/null +++ b/Features/Setting/Currency/Cqrs/GetCurrencyListHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class GetCurrencyListResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Symbol { get; set; } + public string? CountryOwner { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetCurrencyListQuery() : IRequest>; + +public class GetCurrencyListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetCurrencyListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetCurrencyListQuery request, CancellationToken cancellationToken) + { + return await _context.Currency + .AsNoTracking() + .OrderBy(x => x.Code) + .Select(x => new GetCurrencyListResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + Symbol = x.Symbol, + CountryOwner = x.CountryOwner, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/UpdateCurrencyHandler.cs b/Features/Setting/Currency/Cqrs/UpdateCurrencyHandler.cs new file mode 100644 index 0000000..93ab29b --- /dev/null +++ b/Features/Setting/Currency/Cqrs/UpdateCurrencyHandler.cs @@ -0,0 +1,70 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class UpdateCurrencyRequest +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public string? Symbol { get; set; } + public string? CountryOwner { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateCurrencyResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateCurrencyCommand(UpdateCurrencyRequest Data) : IRequest; + +public class UpdateCurrencyHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateCurrencyHandler(AppDbContext context) + { + _context = context; + } + + public async Task Handle(UpdateCurrencyCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Currency + .AnyAsync(x => x.Code == request.Data.Code && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Currency", request.Data.Code ?? string.Empty); + } + + var entity = await _context.Currency + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateCurrencyResponse { Id = request.Data.Id, Success = false }; + } + + entity.Code = request.Data.Code; + entity.Name = request.Data.Name; + entity.Symbol = request.Data.Symbol; + entity.CountryOwner = request.Data.CountryOwner; + entity.Description = request.Data.Description; + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateCurrencyResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/Cqrs/UpdateCurrencyValidator.cs b/Features/Setting/Currency/Cqrs/UpdateCurrencyValidator.cs new file mode 100644 index 0000000..061e2ec --- /dev/null +++ b/Features/Setting/Currency/Cqrs/UpdateCurrencyValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Currency.Cqrs; + +public class UpdateCurrencyValidator : AbstractValidator +{ + public UpdateCurrencyValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Currency Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Currency Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Symbol) + .NotEmpty().WithMessage("Symbol is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.CountryOwner) + .NotEmpty().WithMessage("Country Owner is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/CurrencyEndpoint.cs b/Features/Setting/Currency/CurrencyEndpoint.cs new file mode 100644 index 0000000..4625766 --- /dev/null +++ b/Features/Setting/Currency/CurrencyEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Setting.Currency.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Setting.Currency; + +public static class CurrencyEndpoint +{ + public static void MapCurrencyEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/currency").WithTags("Currencies") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetCurrencyListQuery()); + + return result.ToApiResponse("Data currency retrieved successfully"); + }) + .WithName("GetCurrencyList") + .WithTags("Currencies"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetCurrencyByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Currency detail retrieved successfully" + : $"Currency with ID {id} not found"); + }) + .WithName("GetCurrencyById") + .WithTags("Currencies"); + + group.MapPost("/", async (CreateCurrencyRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateCurrencyCommand(request)); + + return result.ToApiResponse("Currency has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateCurrency") + .WithTags("Currencies"); + + group.MapPost("/update", async (UpdateCurrencyRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateCurrencyCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The currency data could not be found."); + } + + return result.ToApiResponse("Currency has been updated successfully"); + }) + .WithName("UpdateCurrency") + .WithTags("Currencies"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteCurrencyByIdCommand(new DeleteCurrencyByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The currency data could not be found."); + } + + return true.ToApiResponse("Currency has been deleted successfully"); + }) + .WithName("DeleteCurrencyById") + .WithTags("Currencies"); + } +} \ No newline at end of file diff --git a/Features/Setting/Currency/CurrencyService.cs b/Features/Setting/Currency/CurrencyService.cs new file mode 100644 index 0000000..7de716a --- /dev/null +++ b/Features/Setting/Currency/CurrencyService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Setting.Currency.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Setting.Currency; + +public class CurrencyService : BaseService +{ + private readonly RestClient _client; + + public CurrencyService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetCurrencyListAsync() + { + var request = new RestRequest("api/currency", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetCurrencyByIdAsync(string id) + { + var request = new RestRequest($"api/currency/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateCurrencyAsync(CreateCurrencyRequest data) + { + var request = new RestRequest("api/currency", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteCurrencyByIdAsync(string id) + { + var request = new RestRequest($"api/currency/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateCurrencyAsync(UpdateCurrencyRequest data) + { + var request = new RestRequest("api/currency/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/Setting/SettingPage.razor b/Features/Setting/SettingPage.razor new file mode 100644 index 0000000..b69305a --- /dev/null +++ b/Features/Setting/SettingPage.razor @@ -0,0 +1,108 @@ +@page "/setting" +@using Microsoft.AspNetCore.Authorization +@using Indotalent.Infrastructure.Authorization.Identity +@attribute [Authorize(Roles = ApplicationRoles.TenantAdmin)] +@using Indotalent.Features.Setting.AutoNumberSequence.Components +@using Indotalent.Features.Setting.Company.Components +@using Indotalent.Features.Setting.Currency.Components +@using Indotalent.Features.Setting.SystemUser.Components +@using Indotalent.Features.Setting.Tax.Components +@using MudBlazor +@inject NavigationManager NavigationManager + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +@code { + private int _activeTabIndex = 0; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("tab", out var tabValue)) + { + _activeTabIndex = tabValue.ToString().ToLower() switch + { + "company" => 0, + "user" => 1, + "currency" => 2, + "tax" => 3, + "autonumber" => 4, + _ => 0 + }; + } + } + + private void OnTabChanged(int index) + { + _activeTabIndex = index; + string tabName = index switch + { + 0 => "company", + 1 => "user", + 2 => "currency", + 3 => "tax", + 4 => "autonumber", + _ => "company" + }; + + NavigationManager.NavigateTo($"/setting?tab={tabName}", replace: false); + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/SystemUserPage.razor b/Features/Setting/SystemUser/Components/SystemUserPage.razor new file mode 100644 index 0000000..62442e2 --- /dev/null +++ b/Features/Setting/SystemUser/Components/SystemUserPage.razor @@ -0,0 +1,74 @@ +@page "/setting/system-user" +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_SystemUserCreateForm OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_SystemUserUpdateForm Data="_selectedData!" ReadOnly="@(_currentView == ViewMode.View)" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.ChangePassword) +{ + <_SystemUserChangePasswordForm Data="_selectedData!" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.ChangeRole) +{ + <_SystemUserChangeRoleForm Data="_selectedData!" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.ChangeAvatar) +{ + <_SystemUserChangeAvatarForm Data="_selectedData!" OnCancel="BackToTable" OnSuccess="HandleSuccess" /> +} +else +{ + <_SystemUserDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" + OnChangePassword="(item) => ShowChangePassword(item)" + OnManageRoles="(item) => ShowChangeRole(item)" + OnChangeAvatar="(item) => ShowChangeAvatar(item)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View, ChangePassword, ChangeRole, ChangeAvatar } + private ViewMode _currentView = ViewMode.Table; + private UpdateSystemUserRequest? _selectedData; + + private void ShowCreate() => _currentView = ViewMode.Create; + private void ShowUpdate(UpdateSystemUserRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + private void ShowChangePassword(UpdateSystemUserRequest data) + { + _selectedData = data; + _currentView = ViewMode.ChangePassword; + } + private void ShowChangeRole(UpdateSystemUserRequest data) + { + _selectedData = data; + _currentView = ViewMode.ChangeRole; + } + private void ShowChangeAvatar(UpdateSystemUserRequest data) + { + _selectedData = data; + _currentView = ViewMode.ChangeAvatar; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserChangeAvatarForm.razor b/Features/Setting/SystemUser/Components/_SystemUserChangeAvatarForm.razor new file mode 100644 index 0000000..6907c28 --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserChangeAvatarForm.razor @@ -0,0 +1,176 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using Microsoft.AspNetCore.Components.Forms +@using MudBlazor +@using System.IO +@inject SystemUserService SystemUserService +@inject ISnackbar Snackbar + + +
+ +
+ Update Profile Picture + Change avatar for user: @Data.FullName +
+
+
+ + + + + Preview Avatar + + @if (_isLoading) + { + + } + else if (!string.IsNullOrEmpty(_previewUrl)) + { +
+ +
+ } + else + { + + @Data.FullName.ToInitial() + + } + + + Allowed: JPG, JPEG, PNG. Max: 2MB. + +
+
+ + + + Upload New Image + + + + + @(_selectedFile == null ? "Click or Drag Image Here" : _selectedFile.Name) + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Update Avatar + } + +
+
+
+
+ +@code { + [Parameter] public UpdateSystemUserRequest Data { get; set; } = new(); + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private IBrowserFile? _selectedFile; + private string? _previewUrl; + private bool _processing = false; + private bool _isLoading = false; + + protected override async Task OnInitializedAsync() + { + if (!string.IsNullOrEmpty(Data.AvatarFile)) + { + _isLoading = true; + try + { + var bytes = await SystemUserService.GetAvatarBlobAsync(Data.AvatarFile); + if (bytes != null && bytes.Length > 0) + { + _previewUrl = $"data:image/png;base64,{Convert.ToBase64String(bytes)}"; + } + } + finally + { + _isLoading = false; + StateHasChanged(); + } + } + } + + private async Task HandleFileSelected(IBrowserFile file) + { + if (file == null) return; + + if (file.Size > 2 * 1024 * 1024) + { + Snackbar.Add("File too large. Max 2MB allowed.", Severity.Warning); + return; + } + + _selectedFile = file; + + using var stream = file.OpenReadStream(2 * 1024 * 1024); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + + _previewUrl = $"data:{file.ContentType};base64,{Convert.ToBase64String(ms.ToArray())}"; + StateHasChanged(); + } + + private async Task SubmitAvatar() + { + if (_selectedFile == null) return; + + _processing = true; + try + { + using var stream = _selectedFile.OpenReadStream(2 * 1024 * 1024); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + + var request = new ChangeAvatarRequest + { + UserId = Data.Id ?? string.Empty, + FileData = ms.ToArray(), + FileName = _selectedFile.Name + }; + + var response = await SystemUserService.ChangeAvatarAsync(request); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Avatar updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Upload failed: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserChangePasswordForm.razor b/Features/Setting/SystemUser/Components/_SystemUserChangePasswordForm.razor new file mode 100644 index 0000000..c8361b2 --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserChangePasswordForm.razor @@ -0,0 +1,167 @@ +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject SystemUserService SystemUserService +@inject ISnackbar Snackbar + + +
+ +
+ Security: Change Password + Manage password security for account: @Data.FullName +
+
+
+ + + + + Manual Password Overwrite + + + + + + New Password + + + + + Confirm New Password + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Set New Password + } + +
+
+
+
+ + + + Password Reset Link + + + + Instead of setting a password manually, you can send a secure reset link to the user's registered email: + + + @Data.Email + + + @if (_processingReset) + { + + Sending... + } + else + { + Send Forgot Password Email + } + + + + The user will receive an email with instructions to set their own password. + + + +
+ +@code { + [Parameter] public UpdateSystemUserRequest Data { get; set; } = new(); + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private ChangePasswordRequest _model = new(); + private ChangePasswordValidator _validator = new(); + + private bool _processing = false; + private bool _processingReset = false; + + protected override void OnInitialized() + { + _model.UserId = Data.Id ?? string.Empty; + } + + private async Task SubmitManual() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await SystemUserService.ChangePasswordAsync(_model); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + Snackbar.Add("Password updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } + + private async Task SendResetLink() + { + _processingReset = true; + try + { + var success = await SystemUserService.SendResetLinkAsync(Data.Id ?? string.Empty); + await Task.Delay(500); + if (success) + { + Snackbar.Add("Reset link sent to user email", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processingReset = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserChangeRoleForm.razor b/Features/Setting/SystemUser/Components/_SystemUserChangeRoleForm.razor new file mode 100644 index 0000000..48636b7 --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserChangeRoleForm.razor @@ -0,0 +1,125 @@ +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using Indotalent.Infrastructure.Authorization.Identity +@using MudBlazor +@inject SystemUserService SystemUserService +@inject ISnackbar Snackbar + + +
+ +
+ Manage User Roles + Assign or revoke roles for: @Data.FullName (@Data.Email) +
+
+
+ + + Available System Roles + + + + @foreach (var role in ApplicationRoles.AllRoles) + { + + + + + @role + System Role + + + + } + + +
+ + Cancel + + + @if (_processing) + { + + Saving Roles... + } + else + { + Update Role Assignments + } + +
+
+ +@code { + [Parameter] public UpdateSystemUserRequest Data { get; set; } = new(); + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private List _selectedRoles = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + if (Data.UserRoles != null) + { + _selectedRoles = new List(Data.UserRoles); + } + } + + private void OnRoleToggled(string role, bool isSelected) + { + if (isSelected) + { + if (!_selectedRoles.Contains(role)) _selectedRoles.Add(role); + } + else + { + _selectedRoles.Remove(role); + } + } + + private async Task Submit() + { + if (_selectedRoles.Count == 0) + { + Snackbar.Add("Please select at least one role", Severity.Warning); + return; + } + + _processing = true; + try + { + var request = new ChangeRoleRequest + { + UserId = Data.Id ?? string.Empty, + Roles = _selectedRoles + }; + + var success = await SystemUserService.ChangeRoleAsync(request); + await Task.Delay(500); + + if (success) + { + Snackbar.Add("User roles updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserCreateForm.razor b/Features/Setting/SystemUser/Components/_SystemUserCreateForm.razor new file mode 100644 index 0000000..9f65dd8 --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserCreateForm.razor @@ -0,0 +1,218 @@ +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using MudBlazor +@using Indotalent.Shared.Utils +@inject SystemUserService SystemUserService +@inject ISnackbar Snackbar + + +
+ +
+ Add New User + Fill in the account details and security settings for the new user. +
+
+
+ + + + + + Primary Information + + + + + Full Name + + + + + Email Address (as Username) + + + + + Password + + + + + Phone Number + + + + + SSO Identifiers + + + + + Firebase ID + + + + Keycloak ID + + + + Azure ID + + + + AWS ID + + + + Other SSO ID 1 + + + + Other SSO ID 2 + + + + Other SSO ID 3 + + + + + Account Settings & Status + + + + + +
+ + General availability of the account in the system. +
+ +
+ + Mark email as verified without requiring user action. +
+ +
+ + Mark phone number as verified. +
+ +
+ + Enforce Two-Factor Authentication for this user. +
+
+
+ + + +
+ + Allow account to be locked after failed attempts. +
+ +
+ Manual Lockout Until + + Set this only if you want to pre-lock this new account. +
+
+
+
+ +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create User + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateSystemUserRequest _model = new() { IsActive = true, LockoutEnabled = true }; + private CreateSystemUserValidator _validator = new(); + private DateTime? _lockoutDate; + private bool _processing = false; + + private void OnEmailChanged(string value) + { + _model.Email = value; + _model.UserName = value; + } + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + if (_lockoutDate.HasValue) + { + _model.LockoutEnd = new DateTimeOffset(_lockoutDate.Value, TimeSpan.Zero); + } + else + { + _model.LockoutEnd = null; + } + + var response = await SystemUserService.CreateSystemUserAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("User created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserDataTable.razor b/Features/Setting/SystemUser/Components/_SystemUserDataTable.razor new file mode 100644 index 0000000..df5d64b --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserDataTable.razor @@ -0,0 +1,461 @@ +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using Indotalent.Infrastructure.Authorization.Identity +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Shared.Consts +@using ClosedXML.Excel +@using System.IO +@inject SystemUserService SystemUserService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ User Management + Manage system users, status, and role assignments. +
+
+ + / + Settings + / + System User +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedUser != null) + { + Avatar + Roles + PW + View + Edit + + + } + else + { + + Add New User + + } +
+
+ + + + + + Full Name + + + Email + + Roles + + Status + + + + + + + +
+ + @context.FullName.ToInitial() + + @context.FullName +
+
+ @context.Email + +
+ @foreach (var roleName in ApplicationRoles.AllRoles) + { + + + @roleName + + } +
+
+ + @if (context.IsActive) + { + Active + } + else + { + Inactive + } + +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + [Parameter] public EventCallback OnChangePassword { get; set; } + [Parameter] public EventCallback OnManageRoles { get; set; } + [Parameter] public EventCallback OnChangeAvatar { get; set; } + + private List _users = new(); + private GetSystemUserListResponse? _selectedUser; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedUser = null; + StateHasChanged(); + try + { + var response = await SystemUserService.GetSystemUserListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _users = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _users; + return _users.Where(x => + (x.FullName?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Email?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedUser = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("SystemUsers"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Full Name"; + worksheet.Cell(currentRow, 2).Value = "Email"; + worksheet.Cell(currentRow, 3).Value = "Roles"; + worksheet.Cell(currentRow, 4).Value = "Status"; + + var headerRange = worksheet.Range(1, 1, 1, 4); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.FullName; + worksheet.Cell(currentRow, 2).Value = item.Email; + worksheet.Cell(currentRow, 3).Value = string.Join(", ", item.UserRoles); + worksheet.Cell(currentRow, 4).Value = item.IsActive ? "Active" : "Inactive"; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "System_Users_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedUser = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedUser = null; + StateHasChanged(); + } + + private async Task InvokeChangeAvatar() + { + if (_selectedUser == null) return; + var request = await MapToUpdateRequest(_selectedUser.Id); + if (request != null) await OnChangeAvatar.InvokeAsync(request); + } + + private async Task InvokeManageRoles() + { + if (_selectedUser == null) return; + var request = await MapToUpdateRequest(_selectedUser.Id); + if (request != null) await OnManageRoles.InvokeAsync(request); + } + + private async Task InvokeChangePassword() + { + if (_selectedUser == null) return; + var request = await MapToUpdateRequest(_selectedUser.Id); + if (request != null) await OnChangePassword.InvokeAsync(request); + } + + private async Task InvokeEdit() + { + if (_selectedUser == null) return; + var request = await MapToUpdateRequest(_selectedUser.Id); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedUser == null) return; + var request = await MapToUpdateRequest(_selectedUser.Id); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await SystemUserService.GetSystemUserByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateSystemUserRequest + { + Id = detail.Id, + UserName = detail.UserName, + FullName = detail.FullName, + Email = detail.Email, + PhoneNumber = detail.PhoneNumber, + EmailConfirmed = detail.EmailConfirmed, + PhoneNumberConfirmed = detail.PhoneNumberConfirmed, + TwoFactorEnabled = detail.TwoFactorEnabled, + IsActive = detail.IsActive, + LockoutEnabled = detail.LockoutEnabled, + LockoutEnd = detail.LockoutEnd, + SsoIdFirebase = detail.SsoIdFirebase, + SsoIdKeycloak = detail.SsoIdKeycloak, + SsoIdAzure = detail.SsoIdAzure, + SsoIdAws = detail.SsoIdAws, + SsoIdOther1 = detail.SsoIdOther1, + SsoIdOther2 = detail.SsoIdOther2, + SsoIdOther3 = detail.SsoIdOther3, + CreatedAt = detail.CreatedAt, + LastLoginAt = detail.LastLoginAt, + UpdatedAt = detail.UpdatedAt, + AvatarFile = detail.AvatarFile, + UserRoles = detail.UserRoles ?? new List() + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedUser == null) return; + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedUser.FullName } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + if (result != null && !result.Canceled) + { + var isSuccess = await SystemUserService.DeleteSystemUserByIdAsync(_selectedUser.Id); + if (isSuccess) + { + _selectedUser = null; + await LoadData(); + Snackbar.Add("User deleted successfully", Severity.Success); + } + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Components/_SystemUserUpdateForm.razor b/Features/Setting/SystemUser/Components/_SystemUserUpdateForm.razor new file mode 100644 index 0000000..f00f439 --- /dev/null +++ b/Features/Setting/SystemUser/Components/_SystemUserUpdateForm.razor @@ -0,0 +1,273 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.SystemUser +@using Indotalent.Features.Setting.SystemUser.Cqrs +@using MudBlazor +@using Indotalent.Shared.Utils +@inject SystemUserService SystemUserService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "User Details" : "Edit User") + @(ReadOnly ? "View complete user account information." : "Update account details and security settings.") +
+
+
+ + + + + + Primary Information + + + + + Full Name + + + + + Email Address & Username + + + Email and Username cannot be changed. Username is linked to the email address. + + + + + Phone Number + + + + + SSO Identifiers + + + + + Firebase ID + + + + Keycloak ID + + + + Azure ID + + + + AWS ID + + + + Other SSO ID 1 + + + + Other SSO ID 2 + + + + Other SSO ID 3 + + + + + Account Settings & Status + + + + + +
+ + General availability of the account in the system. +
+ +
+ + Current status of email verification. +
+ +
+ + Current status of phone number verification. +
+ +
+ + Requirement for Two-Factor Authentication. +
+
+
+ + + +
+ + Allow account to be locked after failed attempts. +
+ +
+ Lockout Until + + Current account lockout expiration date. +
+
+
+ + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + + Last Login At + @DateTimeExtensions.ToString(_model.LastLoginAt) + + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + +
+ +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Processing... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateSystemUserRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateSystemUserRequest _model = new(); + private UpdateSystemUserValidator _validator = new(); + private DateTime? _lockoutDate; + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateSystemUserRequest + { + Id = Data.Id, + UserName = Data.UserName, + FullName = Data.FullName, + Email = Data.Email, + PhoneNumber = Data.PhoneNumber, + EmailConfirmed = Data.EmailConfirmed, + PhoneNumberConfirmed = Data.PhoneNumberConfirmed, + TwoFactorEnabled = Data.TwoFactorEnabled, + IsActive = Data.IsActive, + LockoutEnabled = Data.LockoutEnabled, + LockoutEnd = Data.LockoutEnd, + SsoIdFirebase = Data.SsoIdFirebase, + SsoIdKeycloak = Data.SsoIdKeycloak, + SsoIdAzure = Data.SsoIdAzure, + SsoIdAws = Data.SsoIdAws, + SsoIdOther1 = Data.SsoIdOther1, + SsoIdOther2 = Data.SsoIdOther2, + SsoIdOther3 = Data.SsoIdOther3, + CreatedAt = Data.CreatedAt, + LastLoginAt = Data.LastLoginAt, + UpdatedAt = Data.UpdatedAt + }; + + if (_model.LockoutEnd.HasValue) + { + _lockoutDate = _model.LockoutEnd.Value.DateTime; + } + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + if (_lockoutDate.HasValue) + { + _model.LockoutEnd = new DateTimeOffset(_lockoutDate.Value, TimeSpan.Zero); + } + else + { + _model.LockoutEnd = null; + } + + var response = await SystemUserService.UpdateSystemUserAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("User updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/AdminForgotPasswordHandler.cs b/Features/Setting/SystemUser/Cqrs/AdminForgotPasswordHandler.cs new file mode 100644 index 0000000..a316f17 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/AdminForgotPasswordHandler.cs @@ -0,0 +1,64 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class AdminForgotPasswordResponse +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; +} + +public record AdminForgotPasswordCommand(string UserId) : IRequest; + +public class AdminForgotPasswordHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + private readonly IHttpContextAccessor _httpContextAccessor; + + public AdminForgotPasswordHandler( + UserManager userManager, + IEmailSender emailSender, + IHttpContextAccessor httpContextAccessor) + { + _userManager = userManager; + _emailSender = emailSender; + _httpContextAccessor = httpContextAccessor; + } + + public async Task Handle(AdminForgotPasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId); + if (user == null) return new AdminForgotPasswordResponse { IsSuccess = false, Message = "User not found" }; + + var code = await _userManager.GeneratePasswordResetTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var requestHttp = _httpContextAccessor.HttpContext?.Request; + var scheme = requestHttp?.Scheme ?? "https"; + var host = requestHttp?.Host.Value ?? "localhost:8080"; + + var callbackUrl = $"{scheme}://{host}/account/reset-password?userId={user.Id}&code={code}"; + + var message = $@" +
+

System Administrator Password Reset

+

Hello, {user.FullName}!

+

An administrator has initiated a password reset for your account. Please click the link below to set your new password:

+

Reset My Password

+
+

If you believe this is an error, please contact your IT support.

+
"; + + await _emailSender.SendPasswordResetLinkAsync(user, user.Email!, message); + return new AdminForgotPasswordResponse + { + IsSuccess = true, + Message = "Reset link sent successfully" + }; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/ChangeAvatarHandler.cs b/Features/Setting/SystemUser/Cqrs/ChangeAvatarHandler.cs new file mode 100644 index 0000000..f361059 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/ChangeAvatarHandler.cs @@ -0,0 +1,63 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.File; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class ChangeAvatarResponse +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; +} + +public class ChangeAvatarRequest +{ + public string UserId { get; set; } = string.Empty; + public byte[] FileData { get; set; } = Array.Empty(); + public string FileName { get; set; } = string.Empty; +} + +public record ChangeAvatarCommand(ChangeAvatarRequest Data) : IRequest; + +public class ChangeAvatarHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly FileStorageService _fileStorage; + + public ChangeAvatarHandler(UserManager userManager, FileStorageService fileStorage) + { + _userManager = userManager; + _fileStorage = fileStorage; + } + + public async Task Handle(ChangeAvatarCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.UserId); + if (user == null) return new ChangeAvatarResponse { IsSuccess = false, Message = "User not found" }; + + try + { + var extension = Path.GetExtension(request.Data.FileName); + + _fileStorage.DeleteOldAvatar(user.AvatarFile); + + var newFileName = await _fileStorage.SaveAvatarAsync(user.Id, request.Data.FileData, extension); + + user.AvatarFile = newFileName; + user.UpdatedAt = DateTime.Now; + + var result = await _userManager.UpdateAsync(user); + + return new ChangeAvatarResponse + { + IsSuccess = result.Succeeded, + Message = result.Succeeded ? "Avatar updated successfully" : "Failed to update database" + }; + } + catch (Exception ex) + { + return new ChangeAvatarResponse { IsSuccess = false, Message = ex.Message }; + } + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/ChangePasswordHandler.cs b/Features/Setting/SystemUser/Cqrs/ChangePasswordHandler.cs new file mode 100644 index 0000000..c2c97d5 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/ChangePasswordHandler.cs @@ -0,0 +1,52 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class ChangePasswordRequest +{ + public string UserId { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; + public string ConfirmPassword { get; set; } = string.Empty; +} + +public class ChangePasswordResponse +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; +} + + + +public record ChangePasswordCommand(ChangePasswordRequest Data) : IRequest; + +public class ChangePasswordHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public ChangePasswordHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(ChangePasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.UserId); + if (user == null) return new ChangePasswordResponse { IsSuccess = false, Message = "User not found" }; + + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, request.Data.NewPassword); + + if (result.Succeeded) + { + return new ChangePasswordResponse { IsSuccess = true, Message = "Password has been changed successfully" }; + } + + return new ChangePasswordResponse + { + IsSuccess = false, + Message = string.Join(", ", result.Errors.Select(e => e.Description)) + }; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/ChangePasswordValidator.cs b/Features/Setting/SystemUser/Cqrs/ChangePasswordValidator.cs new file mode 100644 index 0000000..e377094 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/ChangePasswordValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class ChangePasswordValidator : AbstractValidator +{ + public ChangePasswordValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.NewPassword) + .NotEmpty().WithMessage("New password is required") + .MinimumLength(6).WithMessage("Password must be at least 6 characters"); + RuleFor(x => x.ConfirmPassword) + .Equal(x => x.NewPassword).WithMessage("Passwords do not match"); + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/ChangeRoleHandler.cs b/Features/Setting/SystemUser/Cqrs/ChangeRoleHandler.cs new file mode 100644 index 0000000..08f44e7 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/ChangeRoleHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class ChangeRoleResponse +{ + public bool IsSuccess { get; set; } + public string Message { get; set; } = string.Empty; +} + +public class ChangeRoleRequest +{ + public string UserId { get; set; } = string.Empty; + public List Roles { get; set; } = new(); +} + +public record ChangeRoleCommand(ChangeRoleRequest Data) : IRequest; + +public class ChangeRoleHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public ChangeRoleHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(ChangeRoleCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.UserId); + if (user == null) return new ChangeRoleResponse { IsSuccess = false, Message = "User not found" }; + + var currentRoles = await _userManager.GetRolesAsync(user); + + var removeResult = await _userManager.RemoveFromRolesAsync(user, currentRoles); + if (!removeResult.Succeeded) return new ChangeRoleResponse { IsSuccess = false, Message = "Remove role fail" }; + + var addResult = await _userManager.AddToRolesAsync(user, request.Data.Roles); + return new ChangeRoleResponse + { + IsSuccess = true, + Message = "Roles updated successfully" + }; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/CreateSystemUserHandler.cs b/Features/Setting/SystemUser/Cqrs/CreateSystemUserHandler.cs new file mode 100644 index 0000000..99a2c8d --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/CreateSystemUserHandler.cs @@ -0,0 +1,115 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authorization.Identity; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class CreateSystemUserRequest +{ + public string? FullName { get; set; } + public string? Email { get; set; } + public string? Password { get; set; } + public string? PhoneNumber { get; set; } + public string? UserName { get; set; } + public bool EmailConfirmed { get; set; } + public bool PhoneNumberConfirmed { get; set; } + public bool TwoFactorEnabled { get; set; } + public bool IsActive { get; set; } + public bool LockoutEnabled { get; set; } + public DateTimeOffset? LockoutEnd { get; set; } + public string? SsoIdFirebase { get; set; } + public string? SsoIdKeycloak { get; set; } + public string? SsoIdAzure { get; set; } + public string? SsoIdAws { get; set; } + public string? SsoIdOther1 { get; set; } + public string? SsoIdOther2 { get; set; } + public string? SsoIdOther3 { get; set; } +} + +public class CreateSystemUserResponse +{ + public string? Id { get; set; } + public string? Email { get; set; } +} + +public record CreateSystemUserCommand(CreateSystemUserRequest Data) : IRequest; + +public class CreateSystemUserHandler : IRequestHandler +{ + private readonly UserManager _userManager; + private readonly AppDbContext _context; + private readonly ICurrentUserService _currentUserService; + + public CreateSystemUserHandler( + UserManager userManager, + AppDbContext context, + ICurrentUserService currentUserService) + { + _userManager = userManager; + _context = context; + _currentUserService = currentUserService; + } + + public async Task Handle(CreateSystemUserCommand request, CancellationToken cancellationToken) + { + var user = new ApplicationUser + { + FullName = request.Data.FullName, + Email = request.Data.Email, + UserName = request.Data.Email, + PhoneNumber = request.Data.PhoneNumber, + EmailConfirmed = request.Data.EmailConfirmed, + PhoneNumberConfirmed = request.Data.PhoneNumberConfirmed, + TwoFactorEnabled = request.Data.TwoFactorEnabled, + IsActive = request.Data.IsActive, + LockoutEnabled = request.Data.LockoutEnabled, + LockoutEnd = request.Data.LockoutEnd, + SsoIdFirebase = request.Data.SsoIdFirebase, + SsoIdKeycloak = request.Data.SsoIdKeycloak, + SsoIdAzure = request.Data.SsoIdAzure, + SsoIdAws = request.Data.SsoIdAws, + SsoIdOther1 = request.Data.SsoIdOther1, + SsoIdOther2 = request.Data.SsoIdOther2, + SsoIdOther3 = request.Data.SsoIdOther3, + CreatedAt = DateTime.Now + }; + + var result = await _userManager.CreateAsync(user, request.Data.Password ?? "123456"); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + throw new Exception(errors); + } + + await _userManager.AddToRoleAsync(user, ApplicationRoles.Member); + + // Automatically map the new user to the current user's tenant + var tenantId = _currentUserService.TenantId; + if (!string.IsNullOrEmpty(tenantId)) + { + var isMapped = await _context.Set() + .AnyAsync(tu => tu.TenantId == tenantId && tu.UserId == user.Id, cancellationToken); + + if (!isMapped) + { + var tenantUser = new TenantUser + { + TenantId = tenantId, + UserId = user.Id, + Summary = $"Created by {_currentUserService.UserName ?? _currentUserService.Email}", + IsActive = true + }; + + await _context.Set().AddAsync(tenantUser, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } + } + + return new CreateSystemUserResponse { Id = user.Id, Email = user.Email }; + } +} diff --git a/Features/Setting/SystemUser/Cqrs/CreateSystemUserValidator.cs b/Features/Setting/SystemUser/Cqrs/CreateSystemUserValidator.cs new file mode 100644 index 0000000..630b2a2 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/CreateSystemUserValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class CreateSystemUserValidator : AbstractValidator +{ + public CreateSystemUserValidator() + { + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Password is required") + .MinimumLength(4).WithMessage("Password must be at least 4 characters"); + + RuleFor(x => x.FullName) + .NotEmpty().WithMessage("Full Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.SsoIdFirebase).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdKeycloak).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdAzure).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdAws).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther1).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther2).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther3).MaximumLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/DeleteSystemUserByIdHandler.cs b/Features/Setting/SystemUser/Cqrs/DeleteSystemUserByIdHandler.cs new file mode 100644 index 0000000..86be374 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/DeleteSystemUserByIdHandler.cs @@ -0,0 +1,26 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + + +public record DeleteSystemUserByIdRequest(string Id); + +public record DeleteSystemUserByIdCommand(DeleteSystemUserByIdRequest Data) : IRequest; + +public class DeleteSystemUserByIdHandler : IRequestHandler +{ + private readonly UserManager _userManager; + + public DeleteSystemUserByIdHandler(UserManager userManager) => _userManager = userManager; + + public async Task Handle(DeleteSystemUserByIdCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.Id); + if (user == null) return false; + + var result = await _userManager.DeleteAsync(user); + return result.Succeeded; + } +} diff --git a/Features/Setting/SystemUser/Cqrs/GetSystemUserByIdHandler.cs b/Features/Setting/SystemUser/Cqrs/GetSystemUserByIdHandler.cs new file mode 100644 index 0000000..afa7b6c --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/GetSystemUserByIdHandler.cs @@ -0,0 +1,86 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class GetSystemUserByIdResponse +{ + public string? Id { get; set; } + public string? FullName { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public string? UserName { get; set; } + public bool EmailConfirmed { get; set; } + public bool PhoneNumberConfirmed { get; set; } + public bool TwoFactorEnabled { get; set; } + public bool IsActive { get; set; } + public bool LockoutEnabled { get; set; } + public DateTimeOffset? LockoutEnd { get; set; } + public int AccessFailedCount { get; set; } + public string? SsoIdFirebase { get; set; } + public string? SsoIdKeycloak { get; set; } + public string? SsoIdAzure { get; set; } + public string? SsoIdAws { get; set; } + public string? SsoIdOther1 { get; set; } + public string? SsoIdOther2 { get; set; } + public string? SsoIdOther3 { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastLoginAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public List UserRoles { get; set; } = new(); + public string? AvatarFile { get; set; } +} + +public record GetSystemUserByIdQuery(string Id) : IRequest; + +public class GetSystemUserByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetSystemUserByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetSystemUserByIdQuery request, CancellationToken cancellationToken) + { + var user = await _context.Users + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetSystemUserByIdResponse + { + Id = x.Id, + FullName = x.FullName, + Email = x.Email, + PhoneNumber = x.PhoneNumber, + UserName = x.UserName, + EmailConfirmed = x.EmailConfirmed, + PhoneNumberConfirmed = x.PhoneNumberConfirmed, + TwoFactorEnabled = x.TwoFactorEnabled, + IsActive = x.IsActive, + LockoutEnabled = x.LockoutEnabled, + LockoutEnd = x.LockoutEnd, + AccessFailedCount = x.AccessFailedCount, + SsoIdFirebase = x.SsoIdFirebase, + SsoIdKeycloak = x.SsoIdKeycloak, + SsoIdAzure = x.SsoIdAzure, + SsoIdAws = x.SsoIdAws, + SsoIdOther1 = x.SsoIdOther1, + SsoIdOther2 = x.SsoIdOther2, + SsoIdOther3 = x.SsoIdOther3, + CreatedAt = x.CreatedAt, + LastLoginAt = x.LastLoginAt, + UpdatedAt = x.UpdatedAt, + AvatarFile = x.AvatarFile, + }) + .FirstOrDefaultAsync(cancellationToken); + + if (user == null) return null; + + user.UserRoles = await (from ur in _context.UserRoles + join r in _context.Roles on ur.RoleId equals r.Id + where ur.UserId == user.Id + select r.Name!) + .ToListAsync(cancellationToken); + + return user; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/GetSystemUserListHandler.cs b/Features/Setting/SystemUser/Cqrs/GetSystemUserListHandler.cs new file mode 100644 index 0000000..6fdb41c --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/GetSystemUserListHandler.cs @@ -0,0 +1,78 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class GetSystemUserListResponse +{ + public string? Id { get; set; } + public string? FullName { get; set; } + public string? AvatarFile { get; set; } + public string? Email { get; set; } + public string? SsoIdFirebase { get; set; } + public string? SsoIdKeycloak { get; set; } + public bool IsActive { get; set; } + public List UserRoles { get; set; } = new(); +} + +public record GetSystemUserListQuery() : IRequest>; + +public class GetSystemUserListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + private readonly ICurrentUserService _currentUserService; + + public GetSystemUserListHandler(AppDbContext context, ICurrentUserService currentUserService) + { + _context = context; + _currentUserService = currentUserService; + } + + public async Task> Handle(GetSystemUserListQuery request, CancellationToken cancellationToken) + { + var currentTenantId = _currentUserService.TenantId; + + // Get user IDs that belong to the current tenant via TenantUser + var tenantUserIds = await _context.TenantUser + .AsNoTracking() + .Where(x => x.TenantId == currentTenantId) + .Select(x => x.UserId) + .ToListAsync(cancellationToken); + + var users = await _context.Users + .AsNoTracking() + .Where(x => tenantUserIds.Contains(x.Id)) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new GetSystemUserListResponse + { + Id = x.Id, + FullName = x.FullName, + Email = x.Email, + SsoIdFirebase = x.SsoIdFirebase, + SsoIdKeycloak = x.SsoIdKeycloak, + IsActive = x.IsActive, + AvatarFile = x.AvatarFile, + }) + .ToListAsync(cancellationToken); + + var userIds = users.Select(u => u.Id).ToList(); + + var userRolesMap = await (from ur in _context.UserRoles + join r in _context.Roles on ur.RoleId equals r.Id + where userIds.Contains(ur.UserId) + select new { ur.UserId, r.Name }) + .ToListAsync(cancellationToken); + + foreach (var user in users) + { + user.UserRoles = userRolesMap + .Where(x => x.UserId == user.Id) + .Select(x => x.Name!) + .ToList(); + } + + return users; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/UpdateSystemUserHandler.cs b/Features/Setting/SystemUser/Cqrs/UpdateSystemUserHandler.cs new file mode 100644 index 0000000..c4afbc7 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/UpdateSystemUserHandler.cs @@ -0,0 +1,87 @@ +using Indotalent.Data.Entities; +using MediatR; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class UpdateSystemUserRequest +{ + public string? Id { get; set; } + public string? FullName { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public string? UserName { get; set; } + public bool EmailConfirmed { get; set; } + public bool PhoneNumberConfirmed { get; set; } + public bool TwoFactorEnabled { get; set; } + public bool IsActive { get; set; } + public bool LockoutEnabled { get; set; } + public DateTimeOffset? LockoutEnd { get; set; } + public int AccessFailedCount { get; set; } + public string? SsoIdFirebase { get; set; } + public string? SsoIdKeycloak { get; set; } + public string? SsoIdAzure { get; set; } + public string? SsoIdAws { get; set; } + public string? SsoIdOther1 { get; set; } + public string? SsoIdOther2 { get; set; } + public string? SsoIdOther3 { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastLoginAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public List UserRoles { get; set; } = new(); + public string? AvatarFile { get; set; } +} + +public class UpdateSystemUserResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + + +public record UpdateSystemUserCommand(UpdateSystemUserRequest Data) : IRequest; + +public class UpdateSystemUserHandler : IRequestHandler +{ + private readonly UserManager _userManager; + public UpdateSystemUserHandler(UserManager userManager) => _userManager = userManager; + + public async Task Handle(UpdateSystemUserCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.Data.Id ?? string.Empty); + + if (user == null) + { + return new UpdateSystemUserResponse { Id = request.Data.Id, Success = false }; + } + + user.FullName = request.Data.FullName; + user.PhoneNumber = request.Data.PhoneNumber; + user.IsActive = request.Data.IsActive; + user.EmailConfirmed = request.Data.EmailConfirmed; + user.PhoneNumberConfirmed = request.Data.PhoneNumberConfirmed; + user.TwoFactorEnabled = request.Data.TwoFactorEnabled; + user.LockoutEnabled = request.Data.LockoutEnabled; + user.LockoutEnd = request.Data.LockoutEnd; + + user.SsoIdFirebase = request.Data.SsoIdFirebase; + user.SsoIdKeycloak = request.Data.SsoIdKeycloak; + user.SsoIdAzure = request.Data.SsoIdAzure; + user.SsoIdAws = request.Data.SsoIdAws; + user.SsoIdOther1 = request.Data.SsoIdOther1; + user.SsoIdOther2 = request.Data.SsoIdOther2; + user.SsoIdOther3 = request.Data.SsoIdOther3; + + user.UpdatedAt = DateTime.Now; + + user.AvatarFile = request.Data.AvatarFile; + + var result = await _userManager.UpdateAsync(user); + + return new UpdateSystemUserResponse + { + Id = user.Id, + Success = result.Succeeded + }; + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/Cqrs/UpdateSystemUserValidator.cs b/Features/Setting/SystemUser/Cqrs/UpdateSystemUserValidator.cs new file mode 100644 index 0000000..6ddc5a1 --- /dev/null +++ b/Features/Setting/SystemUser/Cqrs/UpdateSystemUserValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.SystemUser.Cqrs; + +public class UpdateSystemUserValidator : AbstractValidator +{ + public UpdateSystemUserValidator() + { + RuleFor(x => x.Id).NotEmpty().WithMessage("User ID is required for update"); + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format"); + + RuleFor(x => x.FullName) + .NotEmpty().WithMessage("Full Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.SsoIdFirebase).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdKeycloak).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdAzure).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdAws).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther1).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther2).MaximumLength(GlobalConsts.StringLengthMedium); + RuleFor(x => x.SsoIdOther3).MaximumLength(GlobalConsts.StringLengthMedium); + } +} diff --git a/Features/Setting/SystemUser/SystemUserEndpoint.cs b/Features/Setting/SystemUser/SystemUserEndpoint.cs new file mode 100644 index 0000000..1ee16a7 --- /dev/null +++ b/Features/Setting/SystemUser/SystemUserEndpoint.cs @@ -0,0 +1,118 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Setting.SystemUser.Cqrs; +using Indotalent.Infrastructure.File; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; + +namespace Indotalent.Features.Setting.SystemUser; + +public static class SystemUserEndpoint +{ + public static void MapSystemUserEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/system-user").WithTags("SystemUsers") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetSystemUserListQuery()); + return result.ToApiResponse("User list retrieved successfully"); + }) + .WithName("GetSystemUserList"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetSystemUserByIdQuery(id)); + return result.ToApiResponse(result is not null ? "User detail retrieved" : "User not found"); + }) + .WithName("GetSystemUserById"); + + group.MapPost("/", async (CreateSystemUserRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateSystemUserCommand(request)); + return result.ToApiResponse("User created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateSystemUser"); + + group.MapPost("/update", async (UpdateSystemUserRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateSystemUserCommand(request)); + return result.Success + ? result.ToApiResponse("User updated successfully") + : ((object?)null).ToApiResponse("Update failed"); + }) + .WithName("UpdateSystemUser"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteSystemUserByIdCommand(new DeleteSystemUserByIdRequest(id))); + return result.ToApiResponse("User deleted successfully"); + }) + .WithName("DeleteSystemUserById"); + + group.MapPost("/change-password", async (ChangePasswordRequest request, IMediator mediator) => + { + var result = await mediator.Send(new ChangePasswordCommand(request)); + return result.IsSuccess + ? result.ToApiResponse(result.Message) + : result.ToApiResponse(result.Message, StatusCodes.Status400BadRequest); + }) + .WithName("ChangePassword"); + + group.MapPost("/send-reset-link/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new AdminForgotPasswordCommand(id)); + return result.IsSuccess + ? result.ToApiResponse(result.Message) + : result.ToApiResponse(result.Message, StatusCodes.Status400BadRequest); + }) + .WithName("AdminSendResetLink"); + + group.MapPost("/change-role", async (ChangeRoleRequest request, IMediator mediator) => + { + var result = await mediator.Send(new ChangeRoleCommand(request)); + return result.IsSuccess + ? result.ToApiResponse(result.Message) + : result.ToApiResponse(result.Message, StatusCodes.Status400BadRequest); + }) + .WithName("ChangeRole"); + + group.MapPost("/change-avatar", async (ChangeAvatarRequest request, IMediator mediator) => + { + var result = await mediator.Send(new ChangeAvatarCommand(request)); + return result.IsSuccess + ? result.ToApiResponse(result.Message) + : result.ToApiResponse(result.Message, StatusCodes.Status400BadRequest); + }) + .WithName("ChangeAvatar"); + + group.MapGet("/avatar-image/{fileName}", async (string fileName, IOptions options, IWebHostEnvironment env) => + { + var avatarSettings = options.Value.Avatar; + + var folderPath = Path.Combine(env.WebRootPath, avatarSettings.StoragePath); + var filePath = Path.Combine(folderPath, fileName); + + if (!System.IO.File.Exists(filePath)) + { + return Results.NotFound(); + } + + var bytes = await System.IO.File.ReadAllBytesAsync(filePath); + + var extension = Path.GetExtension(fileName).ToLower(); + var contentType = extension switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + _ => "application/octet-stream" + }; + + return Results.File(bytes, contentType); + }) + .WithName("GetAvatarImage"); + + } +} \ No newline at end of file diff --git a/Features/Setting/SystemUser/SystemUserService.cs b/Features/Setting/SystemUser/SystemUserService.cs new file mode 100644 index 0000000..06e254a --- /dev/null +++ b/Features/Setting/SystemUser/SystemUserService.cs @@ -0,0 +1,109 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Setting.SystemUser.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Setting.SystemUser; + +public class SystemUserService : BaseService +{ + private readonly RestClient _client; + + public SystemUserService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetSystemUserListAsync() + { + var request = new RestRequest("api/system-user", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetSystemUserByIdAsync(string id) + { + var request = new RestRequest($"api/system-user/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateSystemUserAsync(CreateSystemUserRequest data) + { + var request = new RestRequest("api/system-user", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> UpdateSystemUserAsync(UpdateSystemUserRequest data) + { + var request = new RestRequest("api/system-user/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteSystemUserByIdAsync(string id) + { + var request = new RestRequest($"api/system-user/delete/{id}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> ChangePasswordAsync(ChangePasswordRequest data) + { + var request = new RestRequest("api/system-user/change-password", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task SendResetLinkAsync(string userId) + { + var request = new RestRequest($"api/system-user/send-reset-link/{userId}", Method.Post); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task ChangeRoleAsync(ChangeRoleRequest data) + { + var request = new RestRequest("api/system-user/change-role", Method.Post); + request.AddJsonBody(data); + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> ChangeAvatarAsync(ChangeAvatarRequest data) + { + var request = new RestRequest("api/system-user/change-avatar", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + + public async Task GetAvatarBlobAsync(string fileName) + { + var request = new RestRequest($"api/system-user/avatar-image/{fileName}", Method.Get); + + if (!string.IsNullOrEmpty(TokenProvider.Token)) + { + request.AddHeader("Authorization", $"Bearer {TokenProvider.Token}"); + } + + if (!string.IsNullOrEmpty(CurrentUserService.UserId)) + { + request.AddHeader("X-UserId", CurrentUserService.UserId); + } + + var response = await _client.ExecuteAsync(request); + + return response.IsSuccessful ? response.RawBytes : null; + } + +} \ No newline at end of file diff --git a/Features/Setting/Tax/Components/TaxPage.razor b/Features/Setting/Tax/Components/TaxPage.razor new file mode 100644 index 0000000..c3a9b5e --- /dev/null +++ b/Features/Setting/Tax/Components/TaxPage.razor @@ -0,0 +1,52 @@ +@page "/setting/tax" +@using Indotalent.Features.Setting.Tax +@using Indotalent.Features.Setting.Tax.Cqrs +@using MudBlazor + +@if (_currentView == ViewMode.Create) +{ + <_TaxCreateForm OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else if (_currentView == ViewMode.Update || _currentView == ViewMode.View) +{ + <_TaxUpdateForm Data="_selectedData!" + ReadOnly="@(_currentView == ViewMode.View)" + OnCancel="BackToTable" + OnSuccess="HandleSuccess" /> +} +else +{ + <_TaxDataTable OnAdd="() => ShowCreate()" + OnEdit="(item) => ShowUpdate(item, false)" + OnView="(item) => ShowUpdate(item, true)" /> +} + +@code { + private enum ViewMode { Table, Create, Update, View } + private ViewMode _currentView = ViewMode.Table; + private UpdateTaxRequest? _selectedData; + + private void ShowCreate() + { + _currentView = ViewMode.Create; + } + + private void ShowUpdate(UpdateTaxRequest data, bool isReadOnly) + { + _selectedData = data; + _currentView = isReadOnly ? ViewMode.View : ViewMode.Update; + } + + private void BackToTable() + { + _currentView = ViewMode.Table; + _selectedData = null; + } + + private void HandleSuccess() + { + _currentView = ViewMode.Table; + _selectedData = null; + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Components/_TaxCreateForm.razor b/Features/Setting/Tax/Components/_TaxCreateForm.razor new file mode 100644 index 0000000..08c67b6 --- /dev/null +++ b/Features/Setting/Tax/Components/_TaxCreateForm.razor @@ -0,0 +1,117 @@ +@using Indotalent.Features.Setting.Tax +@using Indotalent.Features.Setting.Tax.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TaxService TaxService +@inject ISnackbar Snackbar + + +
+ +
+ Add New Tax + Enter tax details and percentage rates. +
+
+
+ + + + + + Tax Code + + + + Percentage (%) + + + + Tax Name + + + + Category + + + + Description + + + + +
+ + Cancel + + + @if (_processing) + { + + Processing... + } + else + { + Create Tax + } + +
+
+
+ +@code { + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private CreateTaxValidator _validator = new(); + private CreateTaxRequest _model = new(); + private bool _processing = false; + + private async Task Submit() + { + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await TaxService.CreateTaxAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Tax created successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Components/_TaxDataTable.razor b/Features/Setting/Tax/Components/_TaxDataTable.razor new file mode 100644 index 0000000..fa98694 --- /dev/null +++ b/Features/Setting/Tax/Components/_TaxDataTable.razor @@ -0,0 +1,403 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Tax +@using Indotalent.Features.Setting.Tax.Cqrs +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using MudBlazor +@using Features.Root.Shared +@using ClosedXML.Excel +@using System.IO +@inject TaxService TaxService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IJSRuntime JSRuntime + + +
+ Tax Management + Manage tax codes, categories, and percentage rates. +
+
+ + / + Settings + / + Tax +
+
+ + + +
+ +
+ + + + Search + +
+ +
+ + @if (_isExporting) + { + + Processing... + } + else + { + Excel + } + + + + @if (_isRefreshing) + { + + Refreshing... + } + else + { + Refresh + } + + + @if (_selectedTax != null) + { + View + Edit + Remove + + } + else + { + + Add New Tax + + } +
+
+ + + + + + Tax Name + + + Code + + + Percentage + + + Category + + + + + + + +
+ + @(!string.IsNullOrWhiteSpace(context.Name) ? context.Name.ToInitial() : "?") + + @context.Name +
+
+ @context.Code + @context.PercentageValue% + @context.Category +
+
+ +
+
+ Rows per page: + + + + + + + + + + + Showing @(GetFilteredData().Count() == 0 ? 0 : _skip + 1)-@Math.Min(_skip + _top, GetFilteredData().Count()) of @GetFilteredData().Count() + +
+ +
+ + Prev + @{ + var totalPages = _totalPage == 0 ? 1 : _totalPage; + var maxVisible = 5; + var startPage = Math.Max(1, _currentPage - maxVisible / 2); + var endPage = Math.Min(totalPages, startPage + maxVisible - 1); + if (endPage - startPage < maxVisible - 1) { startPage = Math.Max(1, endPage - maxVisible + 1); } +} +@for (int i = startPage; i <= endPage; i++) +{ + var pageNum = i; + var isActive = pageNum == _currentPage; + @pageNum +} + Next + +
+
+
+ + + + + +@code { + [Parameter] public EventCallback OnAdd { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } + [Parameter] public EventCallback OnView { get; set; } + + private List _taxes = new(); + private GetTaxListResponse? _selectedTax; + private string _searchString = ""; + private bool _isRefreshing = false; + private bool _isExporting = false; + + private int _skip = 0; + private int _top = 5; + private int _totalPage => GetFilteredData().Count() == 0 ? 0 : (int)Math.Ceiling((double)GetFilteredData().Count() / (_top == 0 ? 1 : _top)); + private int _currentPage => (_top >= GetFilteredData().Count() || _top == 0) ? 1 : (_skip / _top) + 1; + + protected override async Task OnInitializedAsync() => await LoadData(); + + private async Task LoadData() + { + _isRefreshing = true; + _selectedTax = null; + StateHasChanged(); + try + { + var response = await TaxService.GetTaxListAsync(); + await Task.Delay(500); + if (response != null && response.IsSuccess) + { + _taxes = response.Value ?? new List(); + } + } + finally + { + _isRefreshing = false; + StateHasChanged(); + } + } + + private IEnumerable GetFilteredData() + { + if (string.IsNullOrWhiteSpace(_searchString)) return _taxes; + return _taxes.Where(x => + (x.Name?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Code?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) || + (x.Category?.Contains(_searchString, StringComparison.OrdinalIgnoreCase) ?? false) + ); + } + + private IEnumerable GetPagedData() => GetFilteredData().Skip(_skip).Take(_top); + + private void OnSearchClick() + { + _skip = 0; + _selectedTax = null; + StateHasChanged(); + } + + private void HandleSearchKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") OnSearchClick(); + } + + private async Task ExportToExcel() + { + _isExporting = true; + StateHasChanged(); + try + { + await Task.Delay(1000); + using (var workbook = new XLWorkbook()) + { + var worksheet = workbook.Worksheets.Add("Taxes"); + var currentRow = 1; + + worksheet.Cell(currentRow, 1).Value = "Tax Name"; + worksheet.Cell(currentRow, 2).Value = "Code"; + worksheet.Cell(currentRow, 3).Value = "Percentage"; + worksheet.Cell(currentRow, 4).Value = "Category"; + + var headerRange = worksheet.Range(1, 1, 1, 4); + headerRange.Style.Font.Bold = true; + headerRange.Style.Fill.BackgroundColor = XLColor.FromHtml("#0D47A1"); + headerRange.Style.Font.FontColor = XLColor.White; + + foreach (var item in GetFilteredData()) + { + currentRow++; + worksheet.Cell(currentRow, 1).Value = item.Name; + worksheet.Cell(currentRow, 2).Value = item.Code; + worksheet.Cell(currentRow, 3).Value = item.PercentageValue; + worksheet.Cell(currentRow, 4).Value = item.Category; + } + + worksheet.Columns().AdjustToContents(); + + using (var stream = new MemoryStream()) + { + workbook.SaveAs(stream); + var content = Convert.ToBase64String(stream.ToArray()); + await JSRuntime.InvokeVoidAsync("downloadFile", "Tax_Rates_List.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content); + Snackbar.Add("Excel exported successfully", Severity.Success); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + } + finally + { + _isExporting = false; + StateHasChanged(); + } + } + + private void OnPageChanged(int page) + { + if (page >= 1 && page <= _totalPage) + { + _skip = (page - 1) * _top; + _selectedTax = null; + StateHasChanged(); + } + } + + private void OnPageSizeChanged(int size) + { + _top = size; + _skip = 0; + _selectedTax = null; + StateHasChanged(); + } + + private async Task InvokeEdit() + { + if (_selectedTax == null) return; + var request = await MapToUpdateRequest(_selectedTax.Id); + if (request != null) await OnEdit.InvokeAsync(request); + } + + private async Task InvokeView() + { + if (_selectedTax == null) return; + var request = await MapToUpdateRequest(_selectedTax.Id); + if (request != null) await OnView.InvokeAsync(request); + } + + private async Task MapToUpdateRequest(string id) + { + var response = await TaxService.GetTaxByIdAsync(id); + if (response != null && response.IsSuccess && response.Value != null) + { + var detail = response.Value; + return new UpdateTaxRequest + { + Id = detail.Id, + Code = detail.Code, + Name = detail.Name, + PercentageValue = detail.PercentageValue, + Category = detail.Category, + Description = detail.Description, + CreatedAt = detail.CreatedAt, + CreatedBy = detail.CreatedBy, + UpdatedAt = detail.UpdatedAt, + UpdatedBy = detail.UpdatedBy + }; + } + return null; + } + + private async Task OnDelete() + { + if (_selectedTax == null) return; + + var parameters = new DialogParameters<_DeleteConfirmation> { { x => x.ContentText, _selectedTax.Name } }; + var options = new DialogOptions { CloseButton = false, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true }; + + var dialog = await DialogService.ShowAsync<_DeleteConfirmation>("", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled) + { + var isSuccess = await TaxService.DeleteTaxByIdAsync(_selectedTax.Id); + if (isSuccess) + { + _selectedTax = null; + await LoadData(); + Snackbar.Add("Tax deleted successfully", Severity.Success); + } + else + { + Snackbar.Add("Delete failed. This record might be protected by the system.", Severity.Error); + } + } + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Components/_TaxUpdateForm.razor b/Features/Setting/Tax/Components/_TaxUpdateForm.razor new file mode 100644 index 0000000..6505177 --- /dev/null +++ b/Features/Setting/Tax/Components/_TaxUpdateForm.razor @@ -0,0 +1,169 @@ +@using Indotalent.ConfigBackEnd.Extensions +@using Indotalent.Features.Setting.Tax +@using Indotalent.Features.Setting.Tax.Cqrs +@using Indotalent.Shared.Utils +@using MudBlazor +@inject TaxService TaxService +@inject ISnackbar Snackbar + + +
+ +
+ @(ReadOnly ? "Tax Details" : "Edit Tax") + @(ReadOnly ? "Viewing tax configuration." : "Modify existing tax information.") +
+
+
+ + + + + + Tax Code + + + + Percentage (%) + + + + Tax Name + + + + Category + + + + Description + + + + + Audit History + + + + + Created At + @DateTimeExtensions.ToString(_model.CreatedAt) + + + Created By + @(!string.IsNullOrEmpty(_model.CreatedBy) ? _model.CreatedBy : "System") + + + Last Updated At + @DateTimeExtensions.ToString(_model.UpdatedAt) + + + Last Updated By + @(!string.IsNullOrEmpty(_model.UpdatedBy) ? _model.UpdatedBy : "System") + + + +
+ + @(ReadOnly ? "Back to List" : "Cancel") + + @if (!ReadOnly) + { + + @if (_processing) + { + + Updating... + } + else + { + Save Changes + } + + } +
+
+
+ +@code { + [Parameter] public UpdateTaxRequest Data { get; set; } = new(); + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public EventCallback OnCancel { get; set; } + [Parameter] public EventCallback OnSuccess { get; set; } + + private MudForm _form = default!; + private UpdateTaxValidator _validator = new(); + private UpdateTaxRequest _model = new(); + private bool _processing = false; + + protected override void OnInitialized() + { + _model = new UpdateTaxRequest + { + Id = Data.Id, + Code = Data.Code, + Name = Data.Name, + PercentageValue = Data.PercentageValue, + Category = Data.Category, + Description = Data.Description, + CreatedAt = Data.CreatedAt, + CreatedBy = Data.CreatedBy, + UpdatedAt = Data.UpdatedAt, + UpdatedBy = Data.UpdatedBy + }; + } + + private async Task Submit() + { + if (ReadOnly) return; + + await _form.Validate(); + if (!_form.IsValid) return; + + _processing = true; + try + { + var response = await TaxService.UpdateTaxAsync(_model); + await Task.Delay(500); + + if (response != null && response.IsSuccess) + { + Snackbar.Add("Tax updated successfully", Severity.Success); + await OnSuccess.InvokeAsync(); + } + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + finally + { + _processing = false; + } + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/CreateTaxHandler.cs b/Features/Setting/Tax/Cqrs/CreateTaxHandler.cs new file mode 100644 index 0000000..e0c36f2 --- /dev/null +++ b/Features/Setting/Tax/Cqrs/CreateTaxHandler.cs @@ -0,0 +1,69 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class CreateTaxRequest +{ + public string? Code { get; set; } + public string? Name { get; set; } + public decimal PercentageValue { get; set; } + public string? Category { get; set; } + public string? Description { get; set; } +} + +public class CreateTaxResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } +} + +public record CreateTaxCommand(CreateTaxRequest Data) : IRequest; + +public class CreateTaxHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public CreateTaxHandler(AppDbContext context) => _context = context; + + public async Task Handle(CreateTaxCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Tax + .AnyAsync(x => x.Code == request.Data.Code, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Tax", request.Data.Code ?? string.Empty); + } + + var entityName = nameof(Data.Entities.Tax); + + var autoNo = await _context.GenerateAutoNumberAsync( + entityName: entityName, + prefixTemplate: $"{entityName.ToShortNameConsonant(3)}/{{Year}}/", + ct: cancellationToken + ); + + var entity = new Data.Entities.Tax + { + AutoNumber = autoNo, + Code = request.Data.Code, + Name = request.Data.Name, + PercentageValue = request.Data.PercentageValue, + Category = request.Data.Category, + Description = request.Data.Description + }; + + _context.Tax.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new CreateTaxResponse + { + Id = entity.Id, + Code = entity.Code + }; + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/CreateTaxValidator.cs b/Features/Setting/Tax/Cqrs/CreateTaxValidator.cs new file mode 100644 index 0000000..ab86a40 --- /dev/null +++ b/Features/Setting/Tax/Cqrs/CreateTaxValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class CreateTaxValidator : AbstractValidator +{ + public CreateTaxValidator() + { + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Tax Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Tax Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/DeleteTaxByIdHandler.cs b/Features/Setting/Tax/Cqrs/DeleteTaxByIdHandler.cs new file mode 100644 index 0000000..d7a97cb --- /dev/null +++ b/Features/Setting/Tax/Cqrs/DeleteTaxByIdHandler.cs @@ -0,0 +1,27 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public record DeleteTaxByIdRequest(string Id); + +public class DeleteTaxByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public DeleteTaxByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(DeleteTaxByIdCommand request, CancellationToken cancellationToken) + { + var entity = await _context.Tax + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) return false; + + _context.Tax.Remove(entity); + return await _context.SaveChangesAsync(cancellationToken) > 0; + } +} + +public record DeleteTaxByIdCommand(DeleteTaxByIdRequest Data) : IRequest; \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/GetTaxByIdHandler.cs b/Features/Setting/Tax/Cqrs/GetTaxByIdHandler.cs new file mode 100644 index 0000000..393b2f3 --- /dev/null +++ b/Features/Setting/Tax/Cqrs/GetTaxByIdHandler.cs @@ -0,0 +1,49 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class GetTaxByIdResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public decimal PercentageValue { get; set; } + public string? Category { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetTaxByIdQuery(string Id) : IRequest; + +public class GetTaxByIdHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public GetTaxByIdHandler(AppDbContext context) => _context = context; + + public async Task Handle(GetTaxByIdQuery request, CancellationToken cancellationToken) + { + return await _context.Tax + .AsNoTracking() + .Where(x => x.Id == request.Id) + .Select(x => new GetTaxByIdResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + PercentageValue = x.PercentageValue, + Category = x.Category, + Description = x.Description, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .FirstOrDefaultAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/GetTaxListHandler.cs b/Features/Setting/Tax/Cqrs/GetTaxListHandler.cs new file mode 100644 index 0000000..5650e8f --- /dev/null +++ b/Features/Setting/Tax/Cqrs/GetTaxListHandler.cs @@ -0,0 +1,47 @@ +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class GetTaxListResponse +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public decimal PercentageValue { get; set; } + public string? Category { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public record GetTaxListQuery() : IRequest>; + +public class GetTaxListHandler : IRequestHandler> +{ + private readonly AppDbContext _context; + + public GetTaxListHandler(AppDbContext context) => _context = context; + + public async Task> Handle(GetTaxListQuery request, CancellationToken cancellationToken) + { + return await _context.Tax + .AsNoTracking() + .OrderBy(x => x.Code) + .Select(x => new GetTaxListResponse + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + PercentageValue = x.PercentageValue, + Category = x.Category, + CreatedAt = x.CreatedAt, + CreatedBy = x.CreatedBy, + UpdatedAt = x.UpdatedAt, + UpdatedBy = x.UpdatedBy, + }) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/UpdateTaxHandler.cs b/Features/Setting/Tax/Cqrs/UpdateTaxHandler.cs new file mode 100644 index 0000000..d65c8bb --- /dev/null +++ b/Features/Setting/Tax/Cqrs/UpdateTaxHandler.cs @@ -0,0 +1,70 @@ +using Indotalent.ConfigBackEnd.Exceptions; +using Indotalent.Infrastructure.Database; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class UpdateTaxRequest +{ + public string? Id { get; set; } + public string? Code { get; set; } + public string? Name { get; set; } + public decimal PercentageValue { get; set; } + public string? Category { get; set; } + public string? Description { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +public class UpdateTaxResponse +{ + public string? Id { get; set; } + public bool Success { get; set; } +} + +public record UpdateTaxCommand(UpdateTaxRequest Data) : IRequest; + +public class UpdateTaxHandler : IRequestHandler +{ + private readonly AppDbContext _context; + + public UpdateTaxHandler(AppDbContext context) + { + _context = context; + } + + public async Task Handle(UpdateTaxCommand request, CancellationToken cancellationToken) + { + var isExists = await _context.Tax + .AnyAsync(x => x.Code == request.Data.Code && x.Id != request.Data.Id, cancellationToken); + + if (isExists) + { + throw new AlreadyExistsException("Tax", request.Data.Code ?? string.Empty); + } + + var entity = await _context.Tax + .FirstOrDefaultAsync(x => x.Id == request.Data.Id, cancellationToken); + + if (entity == null) + { + return new UpdateTaxResponse { Id = request.Data.Id, Success = false }; + } + + entity.Code = request.Data.Code; + entity.Name = request.Data.Name; + entity.PercentageValue = request.Data.PercentageValue; + entity.Category = request.Data.Category; + entity.Description = request.Data.Description; + await _context.SaveChangesAsync(cancellationToken); + + return new UpdateTaxResponse + { + Id = entity.Id, + Success = true + }; + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/Cqrs/UpdateTaxValidator.cs b/Features/Setting/Tax/Cqrs/UpdateTaxValidator.cs new file mode 100644 index 0000000..e1335d1 --- /dev/null +++ b/Features/Setting/Tax/Cqrs/UpdateTaxValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using Indotalent.Shared.Consts; + +namespace Indotalent.Features.Setting.Tax.Cqrs; + +public class UpdateTaxValidator : AbstractValidator +{ + public UpdateTaxValidator() + { + RuleFor(x => x.Id) + .NotEmpty().WithMessage("ID is required for update"); + + RuleFor(x => x.Code) + .NotEmpty().WithMessage("Tax Code is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Tax Name is required") + .MaximumLength(GlobalConsts.StringLengthShort); + + RuleFor(x => x.Category) + .NotEmpty().WithMessage("Category is required") + .MaximumLength(GlobalConsts.StringLengthShort); + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/TaxEndpoint.cs b/Features/Setting/Tax/TaxEndpoint.cs new file mode 100644 index 0000000..336ab0c --- /dev/null +++ b/Features/Setting/Tax/TaxEndpoint.cs @@ -0,0 +1,73 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.Features.Setting.Tax.Cqrs; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Indotalent.Features.Setting.Tax; + +public static class TaxEndpoint +{ + public static void MapTaxEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/tax").WithTags("Taxes") + .RequireAuthorization(policy => policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + + group.MapGet("/", async (IMediator mediator) => + { + var result = await mediator.Send(new GetTaxListQuery()); + + return result.ToApiResponse("Data tax retrieved successfully"); + }) + .WithName("GetTaxList") + .WithTags("Taxes"); + + group.MapGet("/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new GetTaxByIdQuery(id)); + + return result.ToApiResponse(result is not null + ? "Tax detail retrieved successfully" + : $"Tax with ID {id} not found"); + }) + .WithName("GetTaxById") + .WithTags("Taxes"); + + group.MapPost("/", async (CreateTaxRequest request, IMediator mediator) => + { + var result = await mediator.Send(new CreateTaxCommand(request)); + + return result.ToApiResponse("Tax has been created successfully", StatusCodes.Status201Created); + }) + .WithName("CreateTax") + .WithTags("Taxes"); + + group.MapPost("/update", async (UpdateTaxRequest request, IMediator mediator) => + { + var result = await mediator.Send(new UpdateTaxCommand(request)); + + if (!result.Success) + { + return ((object?)null).ToApiResponse("Update failed. The tax data could not be found."); + } + + return result.ToApiResponse("Tax has been updated successfully"); + }) + .WithName("UpdateTax") + .WithTags("Taxes"); + + group.MapPost("/delete/{id}", async (string id, IMediator mediator) => + { + var result = await mediator.Send(new DeleteTaxByIdCommand(new DeleteTaxByIdRequest(id))); + + if (!result) + { + return ((object?)null).ToApiResponse("Delete failed. The tax data could not be found."); + } + + return true.ToApiResponse("Tax has been deleted successfully"); + }) + .WithName("DeleteTaxById") + .WithTags("Taxes"); + } +} \ No newline at end of file diff --git a/Features/Setting/Tax/TaxService.cs b/Features/Setting/Tax/TaxService.cs new file mode 100644 index 0000000..8f29397 --- /dev/null +++ b/Features/Setting/Tax/TaxService.cs @@ -0,0 +1,60 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.ConfigFrontEnd.Common; +using Indotalent.Features.Setting.Tax.Cqrs; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Shared.Models; +using Microsoft.AspNetCore.Components; +using MudBlazor; +using RestSharp; + +namespace Indotalent.Features.Setting.Tax; + +public class TaxService : BaseService +{ + private readonly RestClient _client; + + public TaxService( + IHttpClientFactory clientFactory, + NavigationManager nav, + ISnackbar snackbar, + ICurrentUserService currentUserService, + TokenProvider tokenProvider) + : base(clientFactory, nav, snackbar, currentUserService, tokenProvider) + { + _client = new RestClient(nav.BaseUri); + } + + public async Task>?> GetTaxListAsync() + { + var request = new RestRequest("api/tax", Method.Get); + return await ExecuteWithResponseAsync>(_client, request); + } + + public async Task?> GetTaxByIdAsync(string id) + { + var request = new RestRequest($"api/tax/{id}", Method.Get); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task?> CreateTaxAsync(CreateTaxRequest data) + { + var request = new RestRequest("api/tax", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } + + public async Task DeleteTaxByIdAsync(string id) + { + var request = new RestRequest($"api/tax/delete/{id}", Method.Post); + + var response = await ExecuteWithResponseAsync(_client, request); + return response?.IsSuccess ?? false; + } + + public async Task?> UpdateTaxAsync(UpdateTaxRequest data) + { + var request = new RestRequest("api/tax/update", Method.Post); + request.AddJsonBody(data); + return await ExecuteWithResponseAsync(_client, request); + } +} \ No newline at end of file diff --git a/Features/_Imports.razor b/Features/_Imports.razor new file mode 100644 index 0000000..9b49447 --- /dev/null +++ b/Features/_Imports.razor @@ -0,0 +1 @@ +@inherits Indotalent.ConfigFrontEnd.Common.BaseAppPage \ No newline at end of file diff --git a/Indotalent.csproj b/Indotalent.csproj new file mode 100644 index 0000000..05e34ca --- /dev/null +++ b/Indotalent.csproj @@ -0,0 +1,56 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Indotalent.slnx b/Indotalent.slnx new file mode 100644 index 0000000..1b6748f --- /dev/null +++ b/Indotalent.slnx @@ -0,0 +1,3 @@ + + + diff --git a/Infrastructure/Authentication/DI.cs b/Infrastructure/Authentication/DI.cs new file mode 100644 index 0000000..531bded --- /dev/null +++ b/Infrastructure/Authentication/DI.cs @@ -0,0 +1,88 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authentication.Firebase; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Infrastructure.Authentication.Keycloak; +using Indotalent.Infrastructure.Database; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using System.Text; + +namespace Indotalent.Infrastructure.Authentication; + +public static class DI +{ + public static IServiceCollection AddAuthenticationService(this IServiceCollection services, IConfiguration configuration) + { + //Identity + services.AddScoped(); + + services.AddHttpContextAccessor(); + services.AddCascadingAuthenticationState(); + + services.Configure(configuration.GetSection("JwtSettings")); + services.AddScoped(); + + var identitySettings = configuration.GetSection("IdentitySettings").Get() ?? new IdentitySettingsModel(); + + services.AddIdentity(options => + { + options.Password.RequireDigit = identitySettings.Password.RequireDigit; + options.Password.RequiredLength = identitySettings.Password.RequiredLength; + options.Password.RequireNonAlphanumeric = identitySettings.Password.RequireNonAlphanumeric; + options.Password.RequireUppercase = identitySettings.Password.RequireUppercase; + options.Password.RequireLowercase = identitySettings.Password.RequireLowercase; + options.SignIn.RequireConfirmedAccount = identitySettings.SignIn.RequireConfirmedAccount; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders() + .AddClaimsPrincipalFactory(); + + services.AddScoped(); + + services.AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ApplicationScheme; + options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme; + }) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => + { + var jwtSettings = configuration.GetSection("JwtSettings").Get(); + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings?.Issuer, + ValidAudience = jwtSettings?.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings?.Key ?? "PRAGMATIC_FIX_KEY_MIN_32_CHARS_LONG_2026")) + }; + }); + + services.ConfigureApplicationCookie(options => + { + options.LoginPath = identitySettings.Cookies.LoginPath; + options.LogoutPath = identitySettings.Cookies.LogoutPath; + options.AccessDeniedPath = identitySettings.Cookies.AccessDeniedPath; + options.Cookie.Name = identitySettings.Cookies.Name; + options.ExpireTimeSpan = TimeSpan.FromDays(identitySettings.Cookies.ExpireDays); + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.Lax; + }); + + + //Firebase + + services.AddScoped(); + + //Keycloak + + services.AddScoped(); + + return services; + } +} diff --git a/Infrastructure/Authentication/Firebase/FirebaseService.cs b/Infrastructure/Authentication/Firebase/FirebaseService.cs new file mode 100644 index 0000000..1f2f7ed --- /dev/null +++ b/Infrastructure/Authentication/Firebase/FirebaseService.cs @@ -0,0 +1,13 @@ +using Indotalent.Infrastructure.Authentication.Identity; +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Authentication.Firebase; + +public class FirebaseService(IOptions options) +{ + private readonly IdentitySettingsModel _settings = options.Value; + + public IdentitySettingsModel GetConfiguration() => _settings; + + public FirebaseSettingsModel GetDefaultAdmin() => _settings.SsoFirebase; +} diff --git a/Infrastructure/Authentication/Firebase/FirebaseSettingsModel.cs b/Infrastructure/Authentication/Firebase/FirebaseSettingsModel.cs new file mode 100644 index 0000000..914a313 --- /dev/null +++ b/Infrastructure/Authentication/Firebase/FirebaseSettingsModel.cs @@ -0,0 +1,13 @@ +namespace Indotalent.Infrastructure.Authentication.Firebase; + +public class FirebaseSettingsModel +{ + public bool IsUsed { get; set; } + public bool OpenForPublic { get; set; } + public string ApiKey { get; set; } = string.Empty; + public string AuthDomain { get; set; } = string.Empty; + public string ProjectId { get; set; } = string.Empty; + public string StorageBucket { get; set; } = string.Empty; + public string MessagingSenderId { get; set; } = string.Empty; + public string AppId { get; set; } = string.Empty; +} diff --git a/Infrastructure/Authentication/Identity/CookieSettings.cs b/Infrastructure/Authentication/Identity/CookieSettings.cs new file mode 100644 index 0000000..eff1356 --- /dev/null +++ b/Infrastructure/Authentication/Identity/CookieSettings.cs @@ -0,0 +1,10 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class CookieSettings +{ + public string Name { get; set; } = string.Empty; + public string LoginPath { get; set; } = string.Empty; + public string LogoutPath { get; set; } = string.Empty; + public string AccessDeniedPath { get; set; } = string.Empty; + public int ExpireDays { get; set; } +} diff --git a/Infrastructure/Authentication/Identity/CustomClaimsPrincipalFactory.cs b/Infrastructure/Authentication/Identity/CustomClaimsPrincipalFactory.cs new file mode 100644 index 0000000..bff8ee8 --- /dev/null +++ b/Infrastructure/Authentication/Identity/CustomClaimsPrincipalFactory.cs @@ -0,0 +1,26 @@ +using Indotalent.Data.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class CustomClaimsPrincipalFactory : UserClaimsPrincipalFactory +{ + public CustomClaimsPrincipalFactory( + UserManager userManager, + RoleManager roleManager, + IOptions optionsAccessor) + : base(userManager, roleManager, optionsAccessor) + { + } + + protected override async Task GenerateClaimsAsync(ApplicationUser user) + { + var identity = await base.GenerateClaimsAsync(user); + + identity.AddClaim(new Claim(ClaimTypes.GivenName, user.FullName ?? string.Empty)); + + return identity; + } +} diff --git a/Infrastructure/Authentication/Identity/DefaultAdminSettings.cs b/Infrastructure/Authentication/Identity/DefaultAdminSettings.cs new file mode 100644 index 0000000..3d7c761 --- /dev/null +++ b/Infrastructure/Authentication/Identity/DefaultAdminSettings.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class DefaultAdminSettings +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; +} diff --git a/Infrastructure/Authentication/Identity/DefaultUserConfig.cs b/Infrastructure/Authentication/Identity/DefaultUserConfig.cs new file mode 100644 index 0000000..a1e4bac --- /dev/null +++ b/Infrastructure/Authentication/Identity/DefaultUserConfig.cs @@ -0,0 +1,16 @@ +using Indotalent.Data.Entities; + +namespace Indotalent.Infrastructure.Authentication.Identity; + +public static class DefaultUserConfig +{ + public static ApplicationUser GetAdminUser(DefaultAdminSettings settings) => new() + { + FullName = settings.FullName, + UserName = settings.Email, + Email = settings.Email, + EmailConfirmed = true, + CreatedAt = DateTime.Now, + IsActive = true + }; +} \ No newline at end of file diff --git a/Infrastructure/Authentication/Identity/IdentityRevalidatingAuthenticationStateProvider.cs b/Infrastructure/Authentication/Identity/IdentityRevalidatingAuthenticationStateProvider.cs new file mode 100644 index 0000000..0d26073 --- /dev/null +++ b/Infrastructure/Authentication/Identity/IdentityRevalidatingAuthenticationStateProvider.cs @@ -0,0 +1,56 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authentication.Identity; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; +using Microsoft.AspNetCore.Identity; +using System.Security.Claims; + +internal sealed class IdentityRevalidatingAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TokenProvider _tokenProvider; + + public IdentityRevalidatingAuthenticationStateProvider( + ILoggerFactory loggerFactory, + IServiceScopeFactory scopeFactory, + IHttpContextAccessor httpContextAccessor, + TokenProvider tokenProvider) + : base(loggerFactory) + { + _scopeFactory = scopeFactory; + _httpContextAccessor = httpContextAccessor; + _tokenProvider = tokenProvider; + + var context = httpContextAccessor.HttpContext; + if (context != null) + { + var token = context.Request.Cookies["X-Auth-Token"]; + var refreshToken = context.Request.Cookies["X-Refresh-Token"]; + if (!string.IsNullOrEmpty(token)) + { + _tokenProvider.Token = token; + } + if (!string.IsNullOrEmpty(refreshToken)) + { + _tokenProvider.RefreshToken = refreshToken; + } + } + } + + protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); + + protected override async Task ValidateAuthenticationStateAsync( + AuthenticationState authenticationState, CancellationToken cancellationToken) + { + await using var scopeAsync = _scopeFactory.CreateAsyncScope(); + var userManager = scopeAsync.ServiceProvider.GetRequiredService>(); + return await ValidateSecurityStampAsync(userManager, authenticationState.User); + } + + private async Task ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal) + { + var user = await userManager.GetUserAsync(principal); + return user != null; + } +} \ No newline at end of file diff --git a/Infrastructure/Authentication/Identity/IdentityService.cs b/Infrastructure/Authentication/Identity/IdentityService.cs new file mode 100644 index 0000000..0944238 --- /dev/null +++ b/Infrastructure/Authentication/Identity/IdentityService.cs @@ -0,0 +1,24 @@ +using Indotalent.Infrastructure.Authentication.Firebase; +using Indotalent.Infrastructure.Authentication.Keycloak; +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class IdentityService(IOptions options) +{ + private readonly IdentitySettingsModel _settings = options.Value; + + public IdentitySettingsModel GetConfiguration() => _settings; + + public DefaultAdminSettings GetDefaultAdmin() => _settings.DefaultAdmin; + + public PasswordSettings GetPasswordPolicy() => _settings.Password; + + public CookieSettings GetCookieSettings() => _settings.Cookies; + + public SignInSettings GetSignInSettings() => _settings.SignIn; + + public FirebaseSettingsModel GetFirebaseSettings() => _settings.SsoFirebase; + + public KeycloakSettingsModel GetKeycloakSettings() => _settings.SsoKeycloak; +} \ No newline at end of file diff --git a/Infrastructure/Authentication/Identity/IdentitySettingsModel.cs b/Infrastructure/Authentication/Identity/IdentitySettingsModel.cs new file mode 100644 index 0000000..d895749 --- /dev/null +++ b/Infrastructure/Authentication/Identity/IdentitySettingsModel.cs @@ -0,0 +1,14 @@ +using Indotalent.Infrastructure.Authentication.Firebase; +using Indotalent.Infrastructure.Authentication.Keycloak; + +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class IdentitySettingsModel +{ + public PasswordSettings Password { get; set; } = new(); + public CookieSettings Cookies { get; set; } = new(); + public DefaultAdminSettings DefaultAdmin { get; set; } = new(); + public SignInSettings SignIn { get; set; } = new(); + public FirebaseSettingsModel SsoFirebase { get; set; } = new(); + public KeycloakSettingsModel SsoKeycloak { get; set; } = new(); +} diff --git a/Infrastructure/Authentication/Identity/JwtSettingsModel.cs b/Infrastructure/Authentication/Identity/JwtSettingsModel.cs new file mode 100644 index 0000000..01733cc --- /dev/null +++ b/Infrastructure/Authentication/Identity/JwtSettingsModel.cs @@ -0,0 +1,9 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class JwtSettingsModel +{ + public string Key { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public int DurationInMinutes { get; set; } +} diff --git a/Infrastructure/Authentication/Identity/PasswordSettings.cs b/Infrastructure/Authentication/Identity/PasswordSettings.cs new file mode 100644 index 0000000..777cb97 --- /dev/null +++ b/Infrastructure/Authentication/Identity/PasswordSettings.cs @@ -0,0 +1,11 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class PasswordSettings +{ + public bool RequireDigit { get; set; } + public int RequiredLength { get; set; } + public bool RequireNonAlphanumeric { get; set; } + public bool RequireUppercase { get; set; } + public bool RequireLowercase { get; set; } + public int RequiredUniqueChars { get; set; } +} diff --git a/Infrastructure/Authentication/Identity/SignInSettings.cs b/Infrastructure/Authentication/Identity/SignInSettings.cs new file mode 100644 index 0000000..f9cad5d --- /dev/null +++ b/Infrastructure/Authentication/Identity/SignInSettings.cs @@ -0,0 +1,6 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class SignInSettings +{ + public bool RequireConfirmedAccount { get; set; } +} diff --git a/Infrastructure/Authentication/Identity/TokenProvider.cs b/Infrastructure/Authentication/Identity/TokenProvider.cs new file mode 100644 index 0000000..4796336 --- /dev/null +++ b/Infrastructure/Authentication/Identity/TokenProvider.cs @@ -0,0 +1,7 @@ +namespace Indotalent.Infrastructure.Authentication.Identity; + +public class TokenProvider +{ + public string? Token { get; set; } + public string? RefreshToken { get; set; } +} diff --git a/Infrastructure/Authentication/Keycloak/KeycloakService.cs b/Infrastructure/Authentication/Keycloak/KeycloakService.cs new file mode 100644 index 0000000..830f6e5 --- /dev/null +++ b/Infrastructure/Authentication/Keycloak/KeycloakService.cs @@ -0,0 +1,13 @@ +using Indotalent.Infrastructure.Authentication.Identity; +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Authentication.Keycloak; + +public class KeycloakService(IOptions options) +{ + private readonly IdentitySettingsModel _settings = options.Value; + + public IdentitySettingsModel GetConfiguration() => _settings; + + public KeycloakSettingsModel GetPasswordPolicy() => _settings.SsoKeycloak; +} diff --git a/Infrastructure/Authentication/Keycloak/KeycloakSettingsModel.cs b/Infrastructure/Authentication/Keycloak/KeycloakSettingsModel.cs new file mode 100644 index 0000000..d567a6a --- /dev/null +++ b/Infrastructure/Authentication/Keycloak/KeycloakSettingsModel.cs @@ -0,0 +1,11 @@ +namespace Indotalent.Infrastructure.Authentication.Keycloak; + +public class KeycloakSettingsModel +{ + public bool IsUsed { get; set; } + public string Authority { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string RequireHttpsMetadata { get; set; } = string.Empty; +} + diff --git a/Infrastructure/Authorization/Identity/ApplicationRoles.cs b/Infrastructure/Authorization/Identity/ApplicationRoles.cs new file mode 100644 index 0000000..b492b3f --- /dev/null +++ b/Infrastructure/Authorization/Identity/ApplicationRoles.cs @@ -0,0 +1,10 @@ +namespace Indotalent.Infrastructure.Authorization.Identity; + +public static class ApplicationRoles +{ + public const string Admin = "Admin"; + public const string TenantAdmin = "Tenant Admin"; + public const string Member = "Member"; + + public static IReadOnlyList AllRoles => new[] { Admin, Member, TenantAdmin }; +} \ No newline at end of file diff --git a/Infrastructure/AutoNumberGenerator/AutoNumberGeneratorService.cs b/Infrastructure/AutoNumberGenerator/AutoNumberGeneratorService.cs new file mode 100644 index 0000000..385c006 --- /dev/null +++ b/Infrastructure/AutoNumberGenerator/AutoNumberGeneratorService.cs @@ -0,0 +1,133 @@ +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Database; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using System.Collections.Concurrent; +using System.Data; + +namespace Indotalent.Infrastructure.AutoNumberGenerator; + +public class AutoNumberGeneratorService +{ + private readonly AppDbContext _context; + private static readonly ConcurrentDictionary _locks = new(); + + public AutoNumberGeneratorService(AppDbContext context) + { + _context = context; + } + + public async Task GenerateNextNumberAsync( + string tenantId, + string entityName, + string prefixTemplate, + string? suffixTemplate = null, + int paddingLength = 4, + bool useYear = true, + bool useMonth = false, + CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + int? year = useYear ? now.Year : null; + int? month = useMonth ? now.Month : null; + + var lockKey = $"{tenantId}_{entityName}_{year}_{month}"; + var semaphore = _locks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1)); + + await semaphore.WaitAsync(cancellationToken); + try + { + var connection = _context.Database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + { + await connection.OpenAsync(cancellationToken); + } + + var currentEfTransaction = _context.Database.CurrentTransaction?.GetDbTransaction(); + + long currentSequence = 0; + + var sql = @" + MERGE INTO AutoNumberSequence AS Target + USING (SELECT @TenantId AS TenantId, @EntityName AS EntityName, @Year AS Year, @Month AS Month) AS Source + ON (Target.TenantId = Source.TenantId AND Target.EntityName = Source.EntityName + AND (Target.Year = Source.Year OR (Target.Year IS NULL AND Source.Year IS NULL)) + AND (Target.Month = Source.Month OR (Target.Month IS NULL AND Source.Month IS NULL))) + WHEN MATCHED THEN + UPDATE SET Target.CurrentSequence = Target.CurrentSequence + 1 + WHEN NOT MATCHED THEN + INSERT (Id, TenantId, EntityName, Year, Month, CurrentSequence, PrefixTemplate, SuffixTemplate, PaddingLength, IsDeleted) + VALUES (@Id, Source.TenantId, Source.EntityName, Source.Year, Source.Month, 1, @PrefixTemplate, @SuffixTemplate, @PaddingLength, 0); + + SELECT CurrentSequence FROM AutoNumberSequence + WHERE TenantId = @TenantId AND EntityName = @EntityName + AND (Year = @Year OR (Year IS NULL AND @Year IS NULL)) + AND (Month = @Month OR (Month IS NULL AND @Month IS NULL));"; + + using (var command = connection.CreateCommand()) + { + command.CommandText = sql; + command.CommandType = CommandType.Text; + + if (currentEfTransaction != null) + { + command.Transaction = currentEfTransaction; + } + + command.Parameters.Add(new SqlParameter("@Id", Guid.NewGuid().ToString("N").Substring(0, 30))); + command.Parameters.Add(new SqlParameter("@TenantId", tenantId)); + command.Parameters.Add(new SqlParameter("@EntityName", entityName)); + command.Parameters.Add(new SqlParameter("@Year", (object?)year ?? DBNull.Value)); + command.Parameters.Add(new SqlParameter("@Month", (object?)month ?? DBNull.Value)); + command.Parameters.Add(new SqlParameter("@PrefixTemplate", prefixTemplate)); + command.Parameters.Add(new SqlParameter("@SuffixTemplate", (object?)suffixTemplate ?? DBNull.Value)); + command.Parameters.Add(new SqlParameter("@PaddingLength", paddingLength)); + + var result = await command.ExecuteScalarAsync(cancellationToken); + currentSequence = Convert.ToInt64(result); + } + + var formattedNumber = BuildFormattedNumber( + entityName: entityName, + prefixTemplate: prefixTemplate, + suffixTemplate: suffixTemplate, + paddingLength: paddingLength, + sequence: currentSequence, + now: now + ); + + return formattedNumber; + } + finally + { + semaphore.Release(); + if (semaphore.CurrentCount == 1) _locks.TryRemove(lockKey, out _); + } + } + + private string BuildFormattedNumber( + string entityName, + string prefixTemplate, + string? suffixTemplate, + int paddingLength, + long sequence, + DateTime now) + { + string ProcessTemplate(string? template) + { + if (string.IsNullOrEmpty(template)) return ""; + return template + .Replace("{EntityName}", entityName) + .Replace("{Year}", now.Year.ToString()) + .Replace("{Year2}", now.ToString("yy")) + .Replace("{Month}", now.Month.ToString("D2")); + } + + var prefix = ProcessTemplate(prefixTemplate); + var suffix = ProcessTemplate(suffixTemplate); + var paddedSequence = sequence.ToString($"D{paddingLength}"); + + return $"{prefix}{paddedSequence}{suffix}"; + } +} \ No newline at end of file diff --git a/Infrastructure/AutoNumberGenerator/DI.cs b/Infrastructure/AutoNumberGenerator/DI.cs new file mode 100644 index 0000000..dbae5a0 --- /dev/null +++ b/Infrastructure/AutoNumberGenerator/DI.cs @@ -0,0 +1,13 @@ + + +namespace Indotalent.Infrastructure.AutoNumberGenerator; + +public static class DI +{ + public static IServiceCollection AddAutoNumberGeneratorService(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/Infrastructure/AutoNumberGenerator/IAutoNumberGenerator.cs b/Infrastructure/AutoNumberGenerator/IAutoNumberGenerator.cs new file mode 100644 index 0000000..41ca0f7 --- /dev/null +++ b/Infrastructure/AutoNumberGenerator/IAutoNumberGenerator.cs @@ -0,0 +1,12 @@ +namespace Indotalent.Infrastructure.AutoNumberGenerator; + +public interface IAutoNumberGenerator +{ + string? EntityName { get; set; } + int? Year { get; set; } + int? Month { get; set; } + long? CurrentSequence { get; set; } + string? PrefixTemplate { get; set; } + string? SuffixTemplate { get; set; } + int? PaddingLength { get; set; } +} diff --git a/Infrastructure/BackgroundJob/BackgroundJobService.cs b/Infrastructure/BackgroundJob/BackgroundJobService.cs new file mode 100644 index 0000000..d1a1359 --- /dev/null +++ b/Infrastructure/BackgroundJob/BackgroundJobService.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.BackgroundJob; + +public class BackgroundJobService(IOptions options) +{ + private readonly BackgroundJobSettingsModel _settings = options.Value; + + public BackgroundJobSettingsModel GetConfiguration() => _settings; +} diff --git a/Infrastructure/BackgroundJob/BackgroundJobSettingsModel.cs b/Infrastructure/BackgroundJob/BackgroundJobSettingsModel.cs new file mode 100644 index 0000000..fa3ec08 --- /dev/null +++ b/Infrastructure/BackgroundJob/BackgroundJobSettingsModel.cs @@ -0,0 +1,10 @@ +using Indotalent.Infrastructure.BackgroundJob.Hangfire; +using Indotalent.Infrastructure.BackgroundJob.Quartz; + +namespace Indotalent.Infrastructure.BackgroundJob; + +public class BackgroundJobSettingsModel +{ + public HangfireSettingsModel Hangfire { get; set; } = new(); + public QuartzSettingsModel Quartz { get; set; } = new(); +} diff --git a/Infrastructure/BackgroundJob/DI.cs b/Infrastructure/BackgroundJob/DI.cs new file mode 100644 index 0000000..c7665e7 --- /dev/null +++ b/Infrastructure/BackgroundJob/DI.cs @@ -0,0 +1,11 @@ +namespace Indotalent.Infrastructure.BackgroundJob; + +public static class DI +{ + public static IServiceCollection AddBackgroundJobService(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/Infrastructure/BackgroundJob/Hangfire/HangfireSettingsModel.cs b/Infrastructure/BackgroundJob/Hangfire/HangfireSettingsModel.cs new file mode 100644 index 0000000..c43bcd5 --- /dev/null +++ b/Infrastructure/BackgroundJob/Hangfire/HangfireSettingsModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.BackgroundJob.Hangfire; + +public class HangfireSettingsModel +{ + public bool IsUsed { get; set; } + public string ConnectionString { get; set; } = string.Empty; + public string DashboardPath { get; set; } = "/hangfire"; +} diff --git a/Infrastructure/BackgroundJob/Quartz/QuartzSettingsModel.cs b/Infrastructure/BackgroundJob/Quartz/QuartzSettingsModel.cs new file mode 100644 index 0000000..2b9d092 --- /dev/null +++ b/Infrastructure/BackgroundJob/Quartz/QuartzSettingsModel.cs @@ -0,0 +1,7 @@ +namespace Indotalent.Infrastructure.BackgroundJob.Quartz; + +public class QuartzSettingsModel +{ + public bool IsUsed { get; set; } + public string TablePrefix { get; set; } = "QRTZ_"; +} diff --git a/Infrastructure/DI.cs b/Infrastructure/DI.cs new file mode 100644 index 0000000..1201b70 --- /dev/null +++ b/Infrastructure/DI.cs @@ -0,0 +1,43 @@ +using Indotalent.Infrastructure.Authentication; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Infrastructure.AutoNumberGenerator; +using Indotalent.Infrastructure.BackgroundJob; +using Indotalent.Infrastructure.Database; +using Indotalent.Infrastructure.Email; +using Indotalent.Infrastructure.File; +using Indotalent.Infrastructure.Logging; +using Indotalent.Infrastructure.OData; + +namespace Indotalent.Infrastructure; + +public static class DI +{ + public static IServiceCollection AddInfrastructureDI(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("DatabaseSettings")); + services.Configure(configuration.GetSection("BackgroundJobSettings")); + services.Configure(configuration.GetSection("EmailSettings")); + services.Configure(configuration.GetSection("FileStorageSettings")); + services.Configure(configuration.GetSection("LoggerSettings")); + services.Configure(configuration.GetSection("IdentitySettings")); + + services.AddLoggingService(); + + services.AddDatabaseService(configuration); + + services.AddBackgroundJobService(); + + services.AddEmailService(configuration); + + services.AddFileService(); + + services.AddAuthenticationService(configuration); + + services.AddAutoNumberGeneratorService(); + + services.AddODataService(); + + + return services; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/AppDbContext.cs b/Infrastructure/Database/AppDbContext.cs new file mode 100644 index 0000000..42bd9ee --- /dev/null +++ b/Infrastructure/Database/AppDbContext.cs @@ -0,0 +1,282 @@ +using Indotalent.ConfigBackEnd.Extensions; +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.Data.Abstracts; +using Indotalent.Data.Entities; +using Indotalent.Data.Interfaces; +using Indotalent.Infrastructure.AutoNumberGenerator; +using Indotalent.Shared.Consts; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database; + +public class AppDbContext : IdentityDbContext +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ICurrentUserService _currentUserService; + + public AppDbContext( + DbContextOptions options, + IServiceScopeFactory scopeFactory, + ICurrentUserService currentUserService) : base(options) + { + _scopeFactory = scopeFactory; + _currentUserService = currentUserService; + } + + public string CurrentTenantId => _currentUserService?.TenantId ?? "NO_TENANT_SELECTED"; + + public DbSet AutoNumberSequence { get; set; } = default!; + public DbSet Currency { get; set; } = default!; + public DbSet Company { get; set; } = default!; + public DbSet Tax { get; set; } = default!; + public DbSet Branch { get; set; } = default!; + public DbSet Department { get; set; } = default!; + public DbSet Designation { get; set; } = default!; + public DbSet Employee { get; set; } = default!; + public DbSet LeaveCategory { get; set; } = default!; + public DbSet LeaveRequest { get; set; } = default!; + public DbSet LeaveBalance { get; set; } = default!; + public DbSet Evaluation { get; set; } = default!; + public DbSet Appraisal { get; set; } = default!; + public DbSet Promotion { get; set; } = default!; + public DbSet Transfer { get; set; } = default!; + public DbSet Income { get; set; } = default!; + public DbSet Deduction { get; set; } = default!; + public DbSet Grade { get; set; } = default!; + public DbSet EmployeeIncome { get; set; } = default!; + public DbSet EmployeeDeduction { get; set; } = default!; + public DbSet PayrollProcess { get; set; } = default!; + public DbSet PayrollDetail { get; set; } = default!; + public DbSet PayrollComponent { get; set; } = default!; + public DbSet Tenant { get; set; } = default!; + public DbSet TenantUser { get; set; } = default!; + + public override int SaveChanges() + { + ApplySoftDelete(); + ApplyAudit(); + ApplyMultiTenant(); + return base.SaveChanges(); + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + ChangeTracker.DetectChanges(); + ApplySoftDelete(); + ApplyAudit(); + ApplyMultiTenant(); + await ApplyAutoNumberAsync(cancellationToken); + return await base.SaveChangesAsync(cancellationToken); + } + + private void ApplySoftDelete() + { + var entries = ChangeTracker.Entries() + .Where(e => e.State == EntityState.Deleted) + .ToList(); + + foreach (var entry in entries) + { + entry.State = EntityState.Modified; + entry.Entity.IsDeleted = true; + } + } + + private void ApplyAudit() + { + var entries = ChangeTracker.Entries() + .Where(e => e.State == EntityState.Added || e.State == EntityState.Modified) + .ToList(); + + if (!entries.Any()) return; + + var userId = _currentUserService.UserId ?? "SYSTEM"; + + if (string.IsNullOrEmpty(userId)) + { + throw new InvalidOperationException("UserId not found in the current user session context."); + } + + foreach (var entry in entries) + { + var now = DateTimeOffset.UtcNow; + var auditEntity = entry.Entity; + + if (entry.State == EntityState.Added) + { + auditEntity.CreatedAt = now; + auditEntity.CreatedBy = userId; + } + else + { + entry.Property(nameof(IHasAudit.CreatedAt)).IsModified = false; + entry.Property(nameof(IHasAudit.CreatedBy)).IsModified = false; + } + + auditEntity.UpdatedAt = now; + auditEntity.UpdatedBy = userId; + } + } + + private async Task ApplyAutoNumberAsync(CancellationToken ct) + { + var entries = ChangeTracker + .Entries() + .Where(e => e.State == EntityState.Added && string.IsNullOrEmpty(e.Entity.AutoNumber)) + .ToList(); + + if (!entries.Any()) return; + + var currentTenantId = _currentUserService.TenantId ?? "SYSTEM_GLOBAL_TENANT"; + + if (string.IsNullOrWhiteSpace(currentTenantId)) + { + throw new InvalidOperationException("TenantId cannot be null or empty for multi-tenant auto-number sequence generation. Ensure the user session context is valid."); + } + + using var scope = _scopeFactory.CreateScope(); + var generator = scope.ServiceProvider.GetRequiredService(); + + foreach (var entry in entries) + { + var entityName = entry.Entity.GetType().Name; + var shortName = entityName.ToShortNameConsonant(3); + + entry.Entity.AutoNumber = await generator.GenerateNextNumberAsync( + tenantId: currentTenantId ?? string.Empty, + entityName: entityName, + prefixTemplate: $"{shortName}/{{Year}}/", + paddingLength: 4, + cancellationToken: ct + ); + } + } + + private void ApplyMultiTenant() + { + var entries = ChangeTracker.Entries() + .Where(e => e.State == EntityState.Added || e.State == EntityState.Modified) + .ToList(); + + if (!entries.Any()) return; + + var currentTenantId = _currentUserService.TenantId; + + if (string.IsNullOrEmpty(currentTenantId)) + { + throw new InvalidOperationException("TenantId not found in the current user session context."); + } + + foreach (var entry in entries) + { + if (entry.State == EntityState.Added) + { + entry.Entity.TenantId = currentTenantId; + } + else if (entry.State == EntityState.Modified) + { + var dbTenantId = entry.Property(nameof(IMultiTenant.TenantId)).OriginalValue; + if (dbTenantId != null) + { + if (entry.Entity.TenantId != dbTenantId.ToString() || entry.Entity.TenantId != currentTenantId) + { + throw new InvalidOperationException("Cross-tenant operation detected or TenantId data corruption attempted."); + } + } + } + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + if (Database.ProviderName?.Contains("MySql") == true) + { + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + if (entity.GetTableName()!.StartsWith("AspNet")) + { + foreach (var property in entity.GetProperties().Where(p => p.ClrType == typeof(string))) + { + property.SetMaxLength(191); + } + } + } + } + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + var type = entityType.ClrType; + + if (typeof(BaseEntity).IsAssignableFrom(type)) + { + modelBuilder.Entity(type) + .Property("Id") + .HasMaxLength(GlobalConsts.StringLengthId) + .IsFixedLength(); + } + + if (typeof(IHasAudit).IsAssignableFrom(type)) + { + modelBuilder.Entity(type) + .Property(nameof(IHasAudit.CreatedBy)) + .HasMaxLength(GlobalConsts.StringLengthShort); + + modelBuilder.Entity(type) + .Property(nameof(IHasAudit.UpdatedBy)) + .HasMaxLength(GlobalConsts.StringLengthShort); + } + + if (typeof(IMultiTenant).IsAssignableFrom(type)) + { + modelBuilder.Entity(type) + .Property(nameof(IMultiTenant.TenantId)) + .HasMaxLength(GlobalConsts.StringLengthShort); + } + + var hasDeleted = typeof(IHasIsDeleted).IsAssignableFrom(type); + var isMultiTenant = typeof(IMultiTenant).IsAssignableFrom(type); + + if (hasDeleted || isMultiTenant) + { + modelBuilder.Entity(type).HasQueryFilter(GetGlobalFilters(type, hasDeleted, isMultiTenant)); + } + } + + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + } + + + private System.Linq.Expressions.LambdaExpression GetGlobalFilters(Type type, bool hasDeleted, bool isMultiTenant) + { + var parameter = System.Linq.Expressions.Expression.Parameter(type, "e"); + System.Linq.Expressions.Expression? combinedBody = null; + + if (hasDeleted) + { + var property = System.Linq.Expressions.Expression.Property(parameter, nameof(IHasIsDeleted.IsDeleted)); + var falseConstant = System.Linq.Expressions.Expression.Constant(false); + combinedBody = System.Linq.Expressions.Expression.Equal(property, falseConstant); + } + + if (isMultiTenant) + { + var tenantProperty = System.Linq.Expressions.Expression.Property(parameter, nameof(IMultiTenant.TenantId)); + + var dbContextInstance = System.Linq.Expressions.Expression.Constant(this); + + var currentTenantIdProperty = System.Linq.Expressions.Expression.Property(dbContextInstance, nameof(CurrentTenantId)); + + var tenantComparison = System.Linq.Expressions.Expression.Equal(tenantProperty, currentTenantIdProperty); + + combinedBody = combinedBody == null + ? tenantComparison + : System.Linq.Expressions.Expression.AndAlso(combinedBody, tenantComparison); + } + + return System.Linq.Expressions.Expression.Lambda(combinedBody!, parameter); + } + +} \ No newline at end of file diff --git a/Infrastructure/Database/DI.cs b/Infrastructure/Database/DI.cs new file mode 100644 index 0000000..182931b --- /dev/null +++ b/Infrastructure/Database/DI.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database; + +public static class DI +{ + public static IServiceCollection AddDatabaseService(this IServiceCollection services, IConfiguration configuration) + { + var dbSettings = configuration.GetSection("DatabaseSettings").Get(); + + if (dbSettings?.MsSQL?.IsUsed == true) + { + services.AddDbContext(options => + options.UseSqlServer( + dbSettings.MsSQL.ConnectionString, + sqlOptions => { + sqlOptions.CommandTimeout(dbSettings.MsSQL.TimeoutInSeconds); + } + ) + ); + } + else if (dbSettings?.PostgreSQL?.IsUsed == true) + { + services.AddDbContext(options => + options.UseNpgsql( + dbSettings.PostgreSQL.ConnectionString, + npgsqlOptions => { + npgsqlOptions.CommandTimeout(dbSettings.PostgreSQL.TimeoutInSeconds); + } + ) + ); + } + else if (dbSettings?.MySQL?.IsUsed == true) + { + services.AddDbContext(options => + options.UseMySQL( + dbSettings.MySQL.ConnectionString, + mySqlOptions => { + mySqlOptions.CommandTimeout(dbSettings.MySQL.TimeoutInSeconds); + mySqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName); + } + ) + ); + } + + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/DatabaseProviderModel.cs b/Infrastructure/Database/DatabaseProviderModel.cs new file mode 100644 index 0000000..d29f31d --- /dev/null +++ b/Infrastructure/Database/DatabaseProviderModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Database; + +public class DatabaseProviderModel +{ + public bool IsUsed { get; set; } + public string ConnectionString { get; set; } = string.Empty; + public int TimeoutInSeconds { get; set; } +} diff --git a/Infrastructure/Database/DatabaseSeeder.cs b/Infrastructure/Database/DatabaseSeeder.cs new file mode 100644 index 0000000..b9f3d1b --- /dev/null +++ b/Infrastructure/Database/DatabaseSeeder.cs @@ -0,0 +1,713 @@ +using Indotalent.ConfigBackEnd.Interfaces; +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Authentication.Identity; +using Indotalent.Infrastructure.Authorization.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Indotalent.Infrastructure.Database; + +public static class DatabaseSeeder +{ + private static string? adminUserId; + private static string? tenantId; + + public static async Task SeedAsync(IServiceProvider serviceProvider) + { + var roleManager = serviceProvider.GetRequiredService>(); + var userManager = serviceProvider.GetRequiredService>(); + var configuration = serviceProvider.GetRequiredService(); + var context = serviceProvider.GetRequiredService(); + + var currentUserService = serviceProvider.GetRequiredService(); + + await SeedIdentityAsync(roleManager, userManager, configuration); + currentUserService.UserId = adminUserId; + + await SeedDemoTenantAsync(context); + currentUserService.TenantId = tenantId; + + await SeedCurrenciesAsync(context); + await SeedCompanyAsync(context); + await SeedTaxAsync(context); + await SeedBranchAsync(context); + await SeedDepartmentAsync(context); + await SeedDesignationAsync(context); + await SeedGradeAsync(context); + await SeedIncomeAsync(context); + await SeedDeductionAsync(context); + await SeedEmployeeAsync(context); + await SeedLeaveCategoryAsync(context); + await SeedLeaveBalanceAsync(context); + await SeedLeaveRequestAsync(context); + await SeedEvaluationAsync(context); + await SeedAppraisalAsync(context); + await SeedPromotionAsync(context); + await SeedTransferAsync(context); + await SeedPayrollAsync(context); + } + private static async Task SeedDemoTenantAsync(AppDbContext context) + { + var existingTenant = await context.Set().FirstOrDefaultAsync(t => t.Name == "Demo Tenant"); + + if (existingTenant == null) + { + var newTenant = new Tenant + { + Name = "Demo Tenant", + Description = "Initial system tenant for demonstration and seed data.", + Street = "Sudirman Street No. 123", + City = "Bandung", + State = "West Java", + ZipCode = "40111", + Country = "Indonesia", + PhoneNumber = "+62221234567", + EmailAddress = "hi@indotalent.com", + Website = "https://indotalent.com", + IsActive = true + }; + + await context.Set().AddAsync(newTenant); + await context.SaveChangesAsync(); + + tenantId = newTenant.Id; + } + else + { + tenantId = existingTenant.Id; + } + + if (!string.IsNullOrEmpty(adminUserId) && !string.IsNullOrEmpty(tenantId)) + { + var isMapped = await context.Set() + .AnyAsync(tu => tu.TenantId == tenantId && tu.UserId == adminUserId); + + if (!isMapped) + { + var tenantUser = new TenantUser + { + TenantId = tenantId, + UserId = adminUserId, + Summary = "Administrator default member mapping", + IsActive = true + }; + + await context.Set().AddAsync(tenantUser); + await context.SaveChangesAsync(); + } + } + } + + private static async Task SeedIdentityAsync(RoleManager roleManager, UserManager userManager, IConfiguration configuration) + { + var adminSettings = configuration.GetSection("IdentitySettings:DefaultAdmin").Get(); + if (adminSettings == null) return; + + foreach (var roleName in ApplicationRoles.AllRoles) + { + if (!await roleManager.RoleExistsAsync(roleName)) + { + await roleManager.CreateAsync(new IdentityRole(roleName)); + } + } + + var existingUser = await userManager.FindByEmailAsync(adminSettings.Email); + if (existingUser == null) + { + var defaultAdmin = DefaultUserConfig.GetAdminUser(adminSettings); + var createAdmin = await userManager.CreateAsync(defaultAdmin, adminSettings.Password); + + if (createAdmin.Succeeded) + { + await userManager.AddToRolesAsync(defaultAdmin, ApplicationRoles.AllRoles); + adminUserId = defaultAdmin.Id; + } + } + } + + private static async Task SeedCurrenciesAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var currencies = new List + { + new() { Name = "US Dollar", Code = "USD", Symbol = "$", Description = "United States official currency", CountryOwner = "United States" }, + new() { Name = "Euro", Code = "EUR", Symbol = "€", Description = "Eurozone member states currency", CountryOwner = "European Union" }, + new() { Name = "British Pound", Code = "GBP", Symbol = "£", Description = "Official currency of the UK", CountryOwner = "United Kingdom" }, + new() { Name = "Indonesian Rupiah", Code = "IDR", Symbol = "Rp", Description = "Official currency of Indonesia", CountryOwner = "Indonesia" } + }; + + await context.Set().AddRangeAsync(currencies); + await context.SaveChangesAsync(); + } + + private static async Task SeedCompanyAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var usdCurrency = await context.Set().FirstOrDefaultAsync(x => x.Code == "USD"); + if (usdCurrency == null) return; + + var companies = new List + { + new() + { + Name = "Acme Global Solutions Inc.", + Description = "A leading provider of enterprise-grade cloud software solutions.", + IsDefault = true, + CurrencyId = usdCurrency.Id, + TaxIdentification = "EIN 12-3456789", + BusinessLicense = "LC-987654321", + StreetAddress = "One World Trade Center, Suite 85", + City = "New York", + StateProvince = "NY", + ZipCode = "10007", + Phone = "+1 212 555 0198", + Email = "hr@acmeglobal.com", + SocialMediaLinkedIn = "linkedin.com/company/acme-global", + CompanyLogo = "/images/logos/acme-logo.png", + OtherInformation1 = "Corporate Headquarters", + OtherInformation2 = "Technology & SaaS", + OtherInformation3 = "Fortune 500 Candidate" + } + }; + + await context.Set().AddRangeAsync(companies); + await context.SaveChangesAsync(); + } + + private static async Task SeedTaxAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var taxes = new List + { + new() { Code = "FED-INC", Name = "Federal Income Tax", Description = "US Federal Progressive Income Tax", PercentageValue = 22, Category = "Income Tax" }, + new() { Code = "SS-TAX", Name = "Social Security", Description = "Social Security (OASDI)", PercentageValue = 6.2m, Category = "Statutory" }, + new() { Code = "MED-TAX", Name = "Medicare", Description = "Medicare Hospital Insurance", PercentageValue = 1.45m, Category = "Statutory" }, + new() { Code = "NY-STATE", Name = "NY State Tax", Description = "New York State Income Tax", PercentageValue = 5.8m, Category = "State Tax" }, + new() { Code = "NY-CITY", Name = "NYC Local Tax", Description = "New York City Local Tax", PercentageValue = 3.5m, Category = "Local Tax" } + }; + + await context.Set().AddRangeAsync(taxes); + await context.SaveChangesAsync(); + } + + private static async Task SeedBranchAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var branches = new List + { + new() { Name = "New York HQ", City = "New York", StreetAddress = "One World Trade Center", Phone = "+1 212 555 0101", Code = "US-NYC" }, + new() { Name = "San Francisco Tech Center", City = "San Francisco", StreetAddress = "Market Street, Financial District", Phone = "+1 415 555 0202", Code = "US-SFO" }, + new() { Name = "Chicago Operations", City = "Chicago", StreetAddress = "Willis Tower, 233 S Wacker Dr", Phone = "+1 312 555 0303", Code = "US-CHI" }, + new() { Name = "Austin Innovation Hub", City = "Austin", StreetAddress = "Congress Avenue, Downtown", Phone = "+1 512 555 0404", Code = "US-AUS" }, + new() { Name = "Seattle Cloud Office", City = "Seattle", StreetAddress = "Westlake Ave, South Lake Union", Phone = "+1 206 555 0505", Code = "US-SEA" } + }; + + await context.Set().AddRangeAsync(branches); + await context.SaveChangesAsync(); + } + + private static async Task SeedDepartmentAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var departments = new List + { + new() { CostCenter = "US-IT", Name = "Engineering & IT", HeadOfDeptartment = "Michael Scott", Description = "Software engineering and cloud infrastructure." }, + new() { CostCenter = "US-HR", Name = "People & Culture", HeadOfDeptartment = "Pam Beesly", Description = "Talent acquisition and employee experience." }, + new() { CostCenter = "US-FIN", Name = "Finance", HeadOfDeptartment = "Angela Martin", Description = "Global financial planning and analysis." }, + new() { CostCenter = "US-MKT", Name = "Marketing", HeadOfDeptartment = "Ryan Howard", Description = "Digital marketing and brand growth." }, + new() { CostCenter = "US-SAL", Name = "Global Sales", HeadOfDeptartment = "Jim Halpert", Description = "Enterprise sales and business development." } + }; + + await context.Set().AddRangeAsync(departments); + await context.SaveChangesAsync(); + } + + private static async Task SeedDesignationAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var designations = new List + { + new() { Code = "C-LEVEL", Name = "Chief Executive Officer", Level = "Executive", Description = "Global strategy Lead." }, + new() { Code = "DIR-01", Name = "Director of Engineering", Level = "Director", Description = "Engineering organization Lead." }, + new() { Code = "MGR-01", Name = "Engineering Manager", Level = "Management", Description = "Team and project management." }, + new() { Code = "ENG-01", Name = "Principal Software Engineer", Level = "Professional", Description = "Systems architecture." }, + new() { Code = "ENG-02", Name = "Senior Software Engineer", Level = "Professional", Description = "Full-stack development." }, + new() { Code = "ENG-03", Name = "Cloud Architect", Level = "Professional", Description = "Azure/AWS Infrastructure." } + }; + + await context.Set().AddRangeAsync(designations); + await context.SaveChangesAsync(); + } + + private static async Task SeedIncomeAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var incomes = new List + { + new() { Code = "BASE", Name = "Base Salary", Type = "Fixed", IsTaxable = true, CalculationBase = "Contracted salary", Status = "Active", Description = "Monthly base compensation" }, + new() { Code = "BONUS", Name = "Performance Bonus", Type = "Variable", IsTaxable = true, CalculationBase = "Quarterly KPI Result", Status = "Active", Description = "KPI based performance incentive" }, + // Di US umum ada Stipend atau Reimbursement + new() { Code = "WFH", Name = "WFH Stipend", Type = "Fixed", IsTaxable = false, CalculationBase = "Remote work support", Status = "Active", Description = "Internet and utilities allowance" }, + new() { Code = "CAR", Name = "Car Allowance", Type = "Fixed", IsTaxable = true, CalculationBase = "Executive car benefit", Status = "Active", Description = "Executive transportation support" } + }; + + await context.Set().AddRangeAsync(incomes); + await context.SaveChangesAsync(); + } + + private static async Task SeedDeductionAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var deductions = new List + { + new() { Code = "FIT", Name = "Federal Income Tax", Category = "Statutory", PreTax = false, CalculationMethod = "IRS Tax Tables", Status = "Active", Description = "Mandatory Federal Tax" }, + new() { Code = "401K", Name = "401(k) Contribution", Category = "Retirement", PreTax = true, CalculationMethod = "% of Gross Salary", Status = "Active", Description = "Voluntary retirement savings" }, + new() { Code = "HLTH", Name = "Health Insurance Premium", Category = "Insurance", PreTax = true, CalculationMethod = "Flat employee portion", Status = "Active", Description = "Medical/Dental/Vision premium" }, + new() { Code = "LIFE", Name = "Group Life Insurance", Category = "Insurance", PreTax = false, CalculationMethod = "Fixed premium", Status = "Active", Description = "Optional life insurance coverage" } + }; + + await context.Set().AddRangeAsync(deductions); + await context.SaveChangesAsync(); + } + + private static async Task SeedGradeAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var grades = new List + { + new() { Code = "L-01", Name = "Executive Level", Description = "C-Suite and VPs", SalaryFrom = 180000, SalaryTo = 350000, IsOverTimeEligible = false, Status = "Active" }, + new() { Code = "L-02", Name = "Director Level", Description = "Department Directors", SalaryFrom = 140000, SalaryTo = 200000, IsOverTimeEligible = false, Status = "Active" }, + new() { Code = "L-03", Name = "Management Level", Description = "Engineering/Product Managers", SalaryFrom = 110000, SalaryTo = 160000, IsOverTimeEligible = false, Status = "Active" }, + new() { Code = "L-04", Name = "Senior Professional", Description = "Senior IC Roles", SalaryFrom = 95000, SalaryTo = 145000, IsOverTimeEligible = true, Status = "Active" }, + new() { Code = "L-05", Name = "Professional", Description = "Mid-Level IC Roles", SalaryFrom = 70000, SalaryTo = 100000, IsOverTimeEligible = true, Status = "Active" } + }; + + await context.Set().AddRangeAsync(grades); + await context.SaveChangesAsync(); + } + + private static async Task SeedEmployeeAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var hqBranch = await context.Set().FirstOrDefaultAsync(x => x.Code == "US-NYC"); + var itDept = await context.Set().FirstOrDefaultAsync(x => x.CostCenter == "US-IT"); + var managerDesig = await context.Set().FirstOrDefaultAsync(x => x.Code == "MGR-01"); + var allGrades = await context.Set().ToListAsync(); + + var wfhStipend = await context.Set().FirstOrDefaultAsync(x => x.Code == "WFH"); + var healthIns = await context.Set().FirstOrDefaultAsync(x => x.Code == "HLTH"); + var retirement401k = await context.Set().FirstOrDefaultAsync(x => x.Code == "401K"); + + if (hqBranch == null || itDept == null || managerDesig == null || !allGrades.Any()) return; + + var random = new Random(); + var employees = new List(); + var empIncomes = new List(); + var empDeductions = new List(); + + // Nama-nama Amerika + string[] firstNames = { "James", "Robert", "John", "Michael", "David", "William", "Richard", "Joseph", "Thomas", "Christopher", "Charles", "Daniel", "Matthew", "Anthony", "Mark", "Donald", "Steven", "Paul", "Andrew", "Joshua", "Emily", "Mary", "Patricia", "Jennifer", "Linda", "Elizabeth", "Barbara", "Susan", "Jessica", "Sarah" }; + string[] lastNames = { "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzales", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", "Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson" }; + string[] banks = { "JP Morgan Chase", "Bank of America", "Wells Fargo", "Citibank", "Capital One" }; + + for (int i = 1; i <= 30; i++) + { + var fName = firstNames[random.Next(firstNames.Length)]; + var lName = lastNames[random.Next(lastNames.Length)]; + var selectedGrade = allGrades[random.Next(allGrades.Count)]; + + // Perhitungan Gaji Tahunan (Annual) dibagi 12 + var salaryRange = selectedGrade.SalaryTo - selectedGrade.SalaryFrom; + var baseAnnualSalary = selectedGrade.SalaryFrom + (decimal)(random.NextDouble() * (double)salaryRange); + var baseSalary = Math.Round(baseAnnualSalary / 12, 0); + + var emp = new Employee + { + Code = $"ACME-{2000 + i}", + FirstName = fName, + LastName = lName, + JobDescription = "Specialized staff contributing to regional US operations.", + GradeId = selectedGrade.Id, + BasicSalary = baseSalary, + SalaryBankName = banks[random.Next(banks.Length)], + SalaryBankAccountName = $"{fName} {lName}", + SalaryBankAccountNumber = $"{random.Next(100, 999)}-{random.Next(100000, 999999)}", + PlaceOfBirth = "USA", + DateOfBirth = new DateTime(random.Next(1975, 2000), random.Next(1, 12), random.Next(1, 28)), + Gender = i % 2 == 0 ? "Female" : "Male", + MaritalStatus = "Single", + Religion = "Christian", + IdentityNumber = $"SSN-{random.Next(100, 999)}-{random.Next(10, 99)}-{random.Next(1000, 9999)}", + JoinedDate = new DateTime(2022, 1, 1).AddMonths(-random.Next(1, 48)), + EmployeeStatus = "Active", + EmploymentType = "Full-Time", + Email = $"{fName.ToLower()}.{lName.ToLower()}@acmeglobal.com", + BranchId = hqBranch.Id, + DepartmentId = itDept.Id, + DesignationId = managerDesig.Id + }; + + employees.Add(emp); + + if (wfhStipend != null) + { + empIncomes.Add(new EmployeeIncome + { + Employee = emp, + IncomeId = wfhStipend.Id, + Amount = 150, // USD 150 per month + Description = "Remote work utilities support", + IsActive = true + }); + } + + if (healthIns != null) + { + empDeductions.Add(new EmployeeDeduction + { + Employee = emp, + DeductionId = healthIns.Id, + Amount = 250, // USD 250 flat per month for insurance + Description = "Employee medical premium", + IsActive = true + }); + } + + if (retirement401k != null) + { + empDeductions.Add(new EmployeeDeduction + { + Employee = emp, + DeductionId = retirement401k.Id, + Amount = Math.Round(baseSalary * 0.05m, 0), // 5% for 401k + Description = "401(k) retirement plan contribution", + IsActive = true + }); + } + } + + await context.Set().AddRangeAsync(employees); + await context.Set().AddRangeAsync(empIncomes); + await context.Set().AddRangeAsync(empDeductions); + await context.SaveChangesAsync(); + } + + private static async Task SeedLeaveCategoryAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var categories = new List + { + new() { Code = "US-PTO", Name = "Paid Time Off (PTO)", Quota = 20, IsPaidLeave = true, Description = "Vacation and personal days", Status = "Active" }, + new() { Code = "US-SICK", Name = "Sick Leave", Quota = 10, IsPaidLeave = true, Description = "Medical leave with notification", Status = "Active" }, + new() { Code = "US-BEREAVE", Name = "Bereavement Leave", Quota = 5, IsPaidLeave = true, Description = "Loss of immediate family members", Status = "Active" }, + new() { Code = "US-JURY", Name = "Jury Duty", Quota = 0, IsPaidLeave = true, Description = "Court-mandated duty", Status = "Active" }, + new() { Code = "US-FMLA", Name = "Family Medical Leave (FMLA)", Quota = 60, IsPaidLeave = false, Description = "Long-term medical or family care", Status = "Active" } + }; + + await context.Set().AddRangeAsync(categories); + await context.SaveChangesAsync(); + } + + private static async Task SeedLeaveBalanceAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var employees = await context.Set().Take(15).ToListAsync(); + var ptoLeave = await context.Set().FirstOrDefaultAsync(x => x.Code == "US-PTO"); + + if (!employees.Any() || ptoLeave == null) return; + + var balances = new List(); + foreach (var emp in employees) + { + balances.Add(new LeaveBalance + { + EmployeeId = emp.Id, + LeaveCategoryId = ptoLeave.Id, + Entitlement = ptoLeave.Quota, + Used = 5, + Pending = 0, + Remaining = ptoLeave.Quota - 5, + Year = 2026, + ValidFrom = new DateTime(2026, 1, 1), + ValidTo = new DateTime(2026, 12, 31) + }); + } + + await context.Set().AddRangeAsync(balances); + await context.SaveChangesAsync(); + } + + private static async Task SeedLeaveRequestAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var categories = await context.Set().ToListAsync(); + var employees = await context.Set().ToListAsync(); + + var requests = new List(); + + var mockData = new[] { + new { Name = "James", Cat = "US-PTO", Start = DateTime.Now.AddDays(10), End = DateTime.Now.AddDays(14), Days = 5.0, Status = "Approved", Reason = "Grand Canyon trip" }, + new { Name = "Emily", Cat = "US-SICK", Start = DateTime.Now.AddDays(-3), End = DateTime.Now.AddDays(-1), Days = 3.0, Status = "Approved", Reason = "Medical checkup" }, + new { Name = "Robert", Cat = "US-PTO", Start = DateTime.Now.AddDays(20), End = DateTime.Now.AddDays(22), Days = 3.0, Status = "Pending", Reason = "Family reunion in Texas" } + }; + + foreach (var item in mockData) + { + var emp = employees.FirstOrDefault(x => x.FirstName.Contains(item.Name)); + var cat = categories.FirstOrDefault(x => x.Code == item.Cat); + + if (emp != null && cat != null) + { + requests.Add(new LeaveRequest + { + EmployeeId = emp.Id, + LeaveCategoryId = cat.Id, + StartDate = item.Start, + EndDate = item.End, + Days = item.Days, + Status = item.Status, + Reason = item.Reason + }); + } + } + + await context.Set().AddRangeAsync(requests); + await context.SaveChangesAsync(); + } + + private static async Task SeedEvaluationAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + + var allEmployees = await context.Set().ToListAsync(); + if (allEmployees.Count < 5) return; + + var evaluations = new List(); + var random = new Random(); + + for (int i = 0; i < Math.Min(20, allEmployees.Count); i++) + { + var targetEmployee = allEmployees[i]; + var potentialEvaluators = allEmployees.Where(x => x.Id != targetEmployee.Id).ToList(); + var evaluator = potentialEvaluators[random.Next(potentialEvaluators.Count)]; + + evaluations.Add(new Evaluation + { + EmployeeId = targetEmployee.Id, + Period = "Annual 2025 Review", + FinalScore = (85 + random.Next(1, 15)).ToString("F1"), + Rating = (random.Next(3, 5)).ToString(), + EvaluatorId = evaluator.Id, + EvaluationDate = DateTime.Now.AddDays(-random.Next(1, 60)), + EvaluationNote = "Employee demonstrates exceptional leadership and technical proficiency.", + Status = "Completed" + }); + } + + await context.Set().AddRangeAsync(evaluations); + await context.SaveChangesAsync(); + } + + private static async Task SeedAppraisalAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + var employees = await context.Set().Skip(5).Take(15).ToListAsync(); + var appraisals = new List(); + var ratings = new[] { "Exceeds Expectations", "Consistently High", "Outstanding" }; + + for (int i = 0; i < 15; i++) + { + decimal salary = employees[i].BasicSalary; + decimal incPercent = 3 + (i % 5); + appraisals.Add(new Appraisal + { + EmployeeId = employees[i].Id, + LastRating = ratings[i % ratings.Length], + CurrentSalary = salary, + IncrementPercentage = incPercent, + NewSalary = salary + (salary * incPercent / 100), + AppraisalType = "Annual Merit Increase", + EffectiveDate = DateTime.Now.AddMonths(1), + AppraisalNote = "Standard US merit increase based on FY2025 performance.", + Status = "Approved" + }); + } + await context.Set().AddRangeAsync(appraisals); + await context.SaveChangesAsync(); + } + + private static async Task SeedPromotionAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + var employees = await context.Set().Skip(2).Take(10).ToListAsync(); + var promotions = new List(); + var grades = new[] { "L-05", "L-04", "L-03", "L-02" }; + + for (int i = 0; i < 10; i++) + { + promotions.Add(new Promotion + { + EmployeeId = employees[i].Id, + FromGrade = grades[0], + ToGrade = grades[1], + EffectiveDate = DateTime.Now.AddDays(i + 10), + PromotionNote = "Promoted to Senior level for architectural excellence.", + Status = "Confirmed" + }); + } + await context.Set().AddRangeAsync(promotions); + await context.SaveChangesAsync(); + } + + private static async Task SeedTransferAsync(AppDbContext context) + { + if (await context.Set().AnyAsync()) return; + var employees = await context.Set().Take(10).ToListAsync(); + var branches = await context.Set().ToListAsync(); + var depts = await context.Set().ToListAsync(); + var desigs = await context.Set().ToListAsync(); + var transfers = new List(); + + for (int i = 0; i < 10; i++) + { + transfers.Add(new Transfer + { + EmployeeId = employees[i].Id, + FromBranchId = branches[0].Id, + ToBranchId = branches[1].Id, + FromDepartmentId = depts[0].Id, + ToDepartmentId = depts[0].Id, + FromDesignationId = desigs[4].Id, + ToDesignationId = desigs[3].Id, + TransferDate = DateTime.Now.AddDays(i + 5), + TransferNote = "Relocation to SFO for Cloud Project acceleration.", + Status = "Active" + }); + } + await context.Set().AddRangeAsync(transfers); + await context.SaveChangesAsync(); + } + + private static async Task SeedPayrollAsync(AppDbContext context) + { + if (await context.PayrollProcess.AnyAsync()) return; + + var employees = await context.Employee + .Include(x => x.EmployeeIncome) + .Include(x => x.EmployeeDeduction) + .ToListAsync(); + + if (!employees.Any()) return; + + var payrollProcesses = new List(); + var payrollDetails = new List(); + var payrollComponents = new List(); + + for (int m = 2; m >= 0; m--) + { + var date = DateTime.Now.AddMonths(-m); + var periodName = date.ToString("MMMM yyyy"); + var fromDate = new DateTime(date.Year, date.Month, 1); + var toDate = fromDate.AddMonths(1).AddDays(-1); + + var process = new PayrollProcess + { + Id = Guid.NewGuid().ToString(), + AutoNumber = $"US-PAY-{date:yyyyMM}-{3 - m:D3}", + PeriodName = periodName, + FromDate = fromDate, + ToDate = toDate, + Status = "Paid", + ProcessedBy = "US Payroll Admin", + ProcessedAt = DateTime.Now.AddMonths(-m), + Description = $"Global US Payroll processing for {periodName}" + }; + + decimal grandBasic = 0, grandIncome = 0, grandDeduction = 0; + + foreach (var emp in employees) + { + var totalInc = emp.EmployeeIncome?.Sum(x => x.Amount) ?? 0; + var totalDed = emp.EmployeeDeduction?.Sum(x => x.Amount) ?? 0; + var thp = emp.BasicSalary + totalInc - totalDed; + + var detail = new PayrollDetail + { + Id = Guid.NewGuid().ToString(), + PayrollProcessId = process.Id, + EmployeeId = emp.Id, + EmployeeCode = emp.Code, + EmployeeName = $"{emp.FirstName} {emp.LastName}", + BasicSalary = emp.BasicSalary, + TotalIncome = totalInc, + TotalDeduction = totalDed, + TakeHomePay = thp + }; + + if (emp.EmployeeIncome != null) + { + foreach (var inc in emp.EmployeeIncome) + { + payrollComponents.Add(new PayrollComponent + { + Id = Guid.NewGuid().ToString(), + PayrollDetailId = detail.Id, + ComponentName = inc.Income?.Name, + ComponentType = "Income", + Amount = inc.Amount, + Description = inc.Description + }); + } + } + + if (emp.EmployeeDeduction != null) + { + foreach (var ded in emp.EmployeeDeduction) + { + payrollComponents.Add(new PayrollComponent + { + Id = Guid.NewGuid().ToString(), + PayrollDetailId = detail.Id, + ComponentName = ded.Deduction?.Name, + ComponentType = "Deduction", + Amount = ded.Amount, + Description = ded.Description + }); + } + } + + grandBasic += detail.BasicSalary; + grandIncome += totalInc; + grandDeduction += totalDed; + payrollDetails.Add(detail); + } + + process.TotalEmployees = employees.Count; + process.TotalBasicSalary = grandBasic; + process.TotalIncome = grandIncome; + process.TotalDeduction = grandDeduction; + process.TotalGross = grandBasic + grandIncome; + process.TotalTakeHomePay = process.TotalGross - grandDeduction; + + payrollProcesses.Add(process); + } + + await context.PayrollProcess.AddRangeAsync(payrollProcesses); + await context.PayrollDetail.AddRangeAsync(payrollDetails); + await context.PayrollComponent.AddRangeAsync(payrollComponents); + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/DatabaseService.cs b/Infrastructure/Database/DatabaseService.cs new file mode 100644 index 0000000..287bdab --- /dev/null +++ b/Infrastructure/Database/DatabaseService.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Database; + +public class DatabaseService(IOptions options) +{ + private readonly DatabaseSettingsModel _settings = options.Value; + + public DatabaseSettingsModel GetConfiguration() => _settings; + + public DatabaseProviderModel GetActiveProvider() + { + if (_settings.MsSQL.IsUsed) return _settings.MsSQL; + if (_settings.MySQL.IsUsed) return _settings.MySQL; + if (_settings.PostgreSQL.IsUsed) return _settings.PostgreSQL; + return new DatabaseProviderModel(); + } +} diff --git a/Infrastructure/Database/DatabaseSettingsModel.cs b/Infrastructure/Database/DatabaseSettingsModel.cs new file mode 100644 index 0000000..7ee7ad4 --- /dev/null +++ b/Infrastructure/Database/DatabaseSettingsModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Database; + +public class DatabaseSettingsModel +{ + public DatabaseProviderModel MsSQL { get; set; } = new(); + public DatabaseProviderModel MySQL { get; set; } = new(); + public DatabaseProviderModel PostgreSQL { get; set; } = new(); +} diff --git a/Infrastructure/Database/MsSQL/Configuration/ApplicationUserConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/ApplicationUserConfiguration.cs new file mode 100644 index 0000000..ccc867b --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/ApplicationUserConfiguration.cs @@ -0,0 +1,68 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class ApplicationUserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.FullName).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(e => e.SsoIdFirebase).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdKeycloak).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdAzure).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdAws).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdOther1).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdOther2).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.SsoIdOther3).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.AvatarFile).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.RefreshToken).HasMaxLength(GlobalConsts.StringLengthMedium); + + + builder.Property(e => e.FirstName).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.LastName).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.ShortBio).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.JobTitle).HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.StreetAddress) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.City) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.StateProvince) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ZipCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Country) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SocialMediaLinkedIn) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaX) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaFacebook) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaInstagram) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaTikTok) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation1) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation2) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation3) + .HasMaxLength(GlobalConsts.StringLengthMedium); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/AppraisalConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/AppraisalConfiguration.cs new file mode 100644 index 0000000..4e13223 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/AppraisalConfiguration.cs @@ -0,0 +1,31 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class AppraisalConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(a => a.AutoNumber).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(a => a.EmployeeId).HasMaxLength(GlobalConsts.StringLengthId); + builder.Property(a => a.LastRating).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(a => a.AppraisalType).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(a => a.AppraisalNote).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(a => a.Status).HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(a => a.CurrentSalary).HasColumnType("decimal(18,2)"); + builder.Property(a => a.IncrementPercentage).HasColumnType("decimal(18,2)"); + builder.Property(a => a.NewSalary).HasColumnType("decimal(18,2)"); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(a => a.EmployeeId); + + builder.HasOne(a => a.Employee) + .WithMany() + .HasForeignKey(a => a.EmployeeId) + .OnDelete(DeleteBehavior.NoAction); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/AutoNumberSequenceConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/AutoNumberSequenceConfiguration.cs new file mode 100644 index 0000000..39ff1dd --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/AutoNumberSequenceConfiguration.cs @@ -0,0 +1,24 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class AutoNumberSequenceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + + builder.Property(e => e.EntityName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.PrefixTemplate) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SuffixTemplate) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasIndex(e => new { e.TenantId, e.EntityName, e.Year, e.Month }).IsUnique(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/BranchConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/BranchConfiguration.cs new file mode 100644 index 0000000..0b725d7 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/BranchConfiguration.cs @@ -0,0 +1,56 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class BranchConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Code) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.StreetAddress) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.City) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.StateProvince) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ZipCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Phone) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Email) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.OtherInformation1) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation2) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation3) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique(); + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.City); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/CompanyConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/CompanyConfiguration.cs new file mode 100644 index 0000000..f368e15 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/CompanyConfiguration.cs @@ -0,0 +1,87 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class CompanyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.CurrencyId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.HasOne(e => e.Currency) + .WithMany() + .HasForeignKey(e => e.CurrencyId) + .OnDelete(DeleteBehavior.NoAction); + + builder.Property(e => e.TaxIdentification) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.BusinessLicense) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CompanyLogo) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.StreetAddress) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.City) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.StateProvince) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ZipCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Phone) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Email) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SocialMediaLinkedIn) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaX) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaFacebook) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaInstagram) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.SocialMediaTikTok) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation1) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation2) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation3) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => e.Name); + builder.HasIndex(e => e.Email); + builder.HasIndex(e => e.CurrencyId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/CurrencyConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/CurrencyConfiguration.cs new file mode 100644 index 0000000..6b501cc --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/CurrencyConfiguration.cs @@ -0,0 +1,36 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class CurrencyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Code) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Symbol) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CountryOwner) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique(); + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/DeductionConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/DeductionConfiguration.cs new file mode 100644 index 0000000..d1f650e --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/DeductionConfiguration.cs @@ -0,0 +1,37 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class DeductionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Code) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Category) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CalculationMethod) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Status) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique(); + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/DepartmentConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/DepartmentConfiguration.cs new file mode 100644 index 0000000..bfa3792 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/DepartmentConfiguration.cs @@ -0,0 +1,40 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class DepartmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CostCenter) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.HeadOfDeptartment) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.OtherInformation1) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation2) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation3) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.CostCenter }).IsUnique(); + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/DesignationConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/DesignationConfiguration.cs new file mode 100644 index 0000000..28b85d2 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/DesignationConfiguration.cs @@ -0,0 +1,40 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class DesignationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Code) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.Level) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.OtherInformation1) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation2) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation3) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique(); + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/EmployeeConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/EmployeeConfiguration.cs new file mode 100644 index 0000000..2576a70 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/EmployeeConfiguration.cs @@ -0,0 +1,167 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class EmployeeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Code) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.FirstName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.MiddleName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.LastName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.JobDescription) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.GradeId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.BranchId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.DepartmentId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.DesignationId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.BasicSalary) + .HasColumnType("decimal(18,2)"); + + builder.Property(e => e.SalaryBankName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SalaryBankAccountName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SalaryBankAccountNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.PlaceOfBirth) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Gender) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.MaritalStatus) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Religion) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.BloodType) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.IdentityNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.TaxNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.LastEducation) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.EmployeeStatus) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.EmploymentType) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.StreetAddress) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.City) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.StateProvince) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ZipCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Phone) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Email) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SocialMediaLinkedIn) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SocialMediaX) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SocialMediaFacebook) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SocialMediaInstagram) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SocialMediaTikTok) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.OtherInformation1) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation2) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.OtherInformation3) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasOne(e => e.Grade) + .WithMany() + .HasForeignKey(e => e.GradeId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.Branch) + .WithMany() + .HasForeignKey(e => e.BranchId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.Department) + .WithMany() + .HasForeignKey(e => e.DepartmentId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.Designation) + .WithMany() + .HasForeignKey(e => e.DesignationId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasMany(e => e.EmployeeIncome) + .WithOne(ei => ei.Employee) + .HasForeignKey(ei => ei.EmployeeId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasMany(e => e.EmployeeDeduction) + .WithOne(ed => ed.Employee) + .HasForeignKey(ed => ed.EmployeeId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique(); + builder.HasIndex(e => e.FirstName); + builder.HasIndex(e => e.LastName); + builder.HasIndex(e => e.IdentityNumber); + builder.HasIndex(e => e.Email); + builder.HasIndex(e => e.GradeId); + builder.HasIndex(e => e.BranchId); + builder.HasIndex(e => e.DepartmentId); + builder.HasIndex(e => e.DesignationId); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/EmployeeDeductionConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/EmployeeDeductionConfiguration.cs new file mode 100644 index 0000000..9f896cb --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/EmployeeDeductionConfiguration.cs @@ -0,0 +1,41 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class EmployeeDeductionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.EmployeeId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.DeductionId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Amount) + .HasPrecision(18, 2); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => e.EmployeeId); + builder.HasIndex(e => e.DeductionId); + + builder.HasOne(e => e.Employee) + .WithMany(p => p.EmployeeDeduction) + .HasForeignKey(e => e.EmployeeId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.Deduction) + .WithMany() + .HasForeignKey(e => e.DeductionId) + .OnDelete(DeleteBehavior.NoAction); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/EmployeeIncomeConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/EmployeeIncomeConfiguration.cs new file mode 100644 index 0000000..b42bd1f --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/EmployeeIncomeConfiguration.cs @@ -0,0 +1,41 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class EmployeeIncomeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.EmployeeId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.IncomeId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Amount) + .HasPrecision(18, 2); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => e.EmployeeId); + builder.HasIndex(e => e.IncomeId); + + builder.HasOne(e => e.Employee) + .WithMany(p => p.EmployeeIncome) + .HasForeignKey(e => e.EmployeeId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.Income) + .WithMany() + .HasForeignKey(e => e.IncomeId) + .OnDelete(DeleteBehavior.NoAction); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/EvaluationConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/EvaluationConfiguration.cs new file mode 100644 index 0000000..dec881b --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/EvaluationConfiguration.cs @@ -0,0 +1,35 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class EvaluationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(e => e.EmployeeId).HasMaxLength(GlobalConsts.StringLengthId); + builder.Property(e => e.EvaluatorId).HasMaxLength(GlobalConsts.StringLengthId); + builder.Property(e => e.Period).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(e => e.FinalScore).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(e => e.Rating).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(e => e.EvaluationNote).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(e => e.Status).HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => e.EmployeeId); + builder.HasIndex(e => e.EvaluatorId); + + builder.HasOne(e => e.Employee) + .WithMany() + .HasForeignKey(e => e.EmployeeId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.Evaluator) + .WithMany() + .HasForeignKey(e => e.EvaluatorId) + .OnDelete(DeleteBehavior.NoAction); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/GradeConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/GradeConfiguration.cs new file mode 100644 index 0000000..59ebfd5 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/GradeConfiguration.cs @@ -0,0 +1,37 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class GradeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Code) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Status) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.SalaryFrom) + .HasColumnType("decimal(18,2)"); + + builder.Property(e => e.SalaryTo) + .HasColumnType("decimal(18,2)"); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique(); + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/IncomeConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/IncomeConfiguration.cs new file mode 100644 index 0000000..4534df8 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/IncomeConfiguration.cs @@ -0,0 +1,37 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class IncomeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Code) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Type) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.CalculationBase) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Status) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique(); + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/LeaveBalanceConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/LeaveBalanceConfiguration.cs new file mode 100644 index 0000000..61c69be --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/LeaveBalanceConfiguration.cs @@ -0,0 +1,30 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class LeaveBalanceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasOne(e => e.Employee) + .WithMany() + .HasForeignKey(e => e.EmployeeId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(e => e.LeaveCategory) + .WithMany() + .HasForeignKey(e => e.LeaveCategoryId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => e.Year); + + builder.HasIndex(e => new { e.TenantId, e.EmployeeId, e.LeaveCategoryId, e.Year }).IsUnique(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/LeaveCategoryConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/LeaveCategoryConfiguration.cs new file mode 100644 index 0000000..4e2814a --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/LeaveCategoryConfiguration.cs @@ -0,0 +1,31 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class LeaveCategoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Code) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.Status) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique(); + builder.HasIndex(e => e.Name); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/LeaveRequestConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/LeaveRequestConfiguration.cs new file mode 100644 index 0000000..e84c88c --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/LeaveRequestConfiguration.cs @@ -0,0 +1,45 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class LeaveRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Reason) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.Status) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.AttachmentPath) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.Property(e => e.ApproverId) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.RejectReason) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + builder.HasOne(e => e.Employee) + .WithMany() + .HasForeignKey(e => e.EmployeeId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(e => e.LeaveCategory) + .WithMany() + .HasForeignKey(e => e.LeaveCategoryId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => e.Status); + builder.HasIndex(e => e.StartDate); + builder.HasIndex(e => e.EndDate); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/PayrollComponentConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PayrollComponentConfiguration.cs new file mode 100644 index 0000000..2f0e7d6 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PayrollComponentConfiguration.cs @@ -0,0 +1,32 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class PayrollComponentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + + builder.Property(e => e.PayrollDetailId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.ComponentName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ComponentType) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasOne(e => e.PayrollDetail) + .WithMany(d => d.Components) + .HasForeignKey(e => e.PayrollDetailId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.ComponentName); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/PayrollDetailConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PayrollDetailConfiguration.cs new file mode 100644 index 0000000..cc24a99 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PayrollDetailConfiguration.cs @@ -0,0 +1,36 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class PayrollDetailConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.PayrollProcessId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.EmployeeId) + .HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(e => e.EmployeeCode) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.EmployeeName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasOne(e => e.PayrollProcess) + .WithMany() + .HasForeignKey(e => e.PayrollProcessId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(e => e.Employee) + .WithMany() + .HasForeignKey(e => e.EmployeeId) + .OnDelete(DeleteBehavior.NoAction); + + builder.HasIndex(e => e.EmployeeCode); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/PayrollProcessConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PayrollProcessConfiguration.cs new file mode 100644 index 0000000..d8cc702 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PayrollProcessConfiguration.cs @@ -0,0 +1,30 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class PayrollProcessConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.PeriodName) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Status) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.ProcessedBy) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => e.PeriodName); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/PromotionConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/PromotionConfiguration.cs new file mode 100644 index 0000000..f384888 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/PromotionConfiguration.cs @@ -0,0 +1,27 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class PromotionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(p => p.AutoNumber).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(p => p.EmployeeId).HasMaxLength(GlobalConsts.StringLengthId); + builder.Property(p => p.FromGrade).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(p => p.ToGrade).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(p => p.PromotionNote).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(p => p.Status).HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(p => p.EmployeeId); + + builder.HasOne(p => p.Employee) + .WithMany() + .HasForeignKey(p => p.EmployeeId) + .OnDelete(DeleteBehavior.NoAction); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/SerilogLogsConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/SerilogLogsConfiguration.cs new file mode 100644 index 0000000..d037d7e --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/SerilogLogsConfiguration.cs @@ -0,0 +1,39 @@ +using Indotalent.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class LogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SerilogLogs"); + + builder.HasKey(e => e.Id); + + builder.Property(e => e.Message) + .HasMaxLength(2000); + + builder.Property(e => e.Level) + .HasMaxLength(128); + + builder.Property(e => e.TimeStamp) + .IsRequired(); + + builder.Property(e => e.Exception) + .HasColumnType("nvarchar(max)"); + + builder.Property(e => e.Properties) + .HasColumnType("nvarchar(max)"); + + builder.Property(e => e.MessageTemplate) + .HasMaxLength(2000); + + builder.Property(e => e.LogEvent) + .HasColumnType("nvarchar(max)"); + + builder.HasIndex(e => e.TimeStamp) + .HasDatabaseName("IX_SerilogLogs_TimeStamp"); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/Configuration/TaxConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/TaxConfiguration.cs new file mode 100644 index 0000000..a8c5a51 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/TaxConfiguration.cs @@ -0,0 +1,36 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class TaxConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + + builder.Property(e => e.AutoNumber) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Code) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Name) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.PercentageValue) + .HasColumnType("decimal(18,2)"); + + builder.Property(e => e.Category) + .HasMaxLength(GlobalConsts.StringLengthShort); + + builder.Property(e => e.Description) + .HasMaxLength(GlobalConsts.StringLengthMedium); + + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.Code }).IsUnique(); + builder.HasIndex(e => e.Name); + } +} diff --git a/Infrastructure/Database/MsSQL/Configuration/TransferConfiguration.cs b/Infrastructure/Database/MsSQL/Configuration/TransferConfiguration.cs new file mode 100644 index 0000000..94aedc5 --- /dev/null +++ b/Infrastructure/Database/MsSQL/Configuration/TransferConfiguration.cs @@ -0,0 +1,39 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Indotalent.Infrastructure.Database.MsSQL.Configuration; + +public class TransferConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.AutoNumber).HasMaxLength(GlobalConsts.StringLengthShort); + builder.Property(t => t.EmployeeId).HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(t => t.FromBranchId).HasMaxLength(GlobalConsts.StringLengthId); + builder.Property(t => t.FromDepartmentId).HasMaxLength(GlobalConsts.StringLengthId); + builder.Property(t => t.FromDesignationId).HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(t => t.ToBranchId).HasMaxLength(GlobalConsts.StringLengthId); + builder.Property(t => t.ToDepartmentId).HasMaxLength(GlobalConsts.StringLengthId); + builder.Property(t => t.ToDesignationId).HasMaxLength(GlobalConsts.StringLengthId); + + builder.Property(t => t.TransferNote).HasMaxLength(GlobalConsts.StringLengthMedium); + builder.Property(t => t.Status).HasMaxLength(GlobalConsts.StringLengthShort); + + builder.HasIndex(e => new { e.TenantId, e.AutoNumber }).IsUnique(); + builder.HasIndex(t => t.EmployeeId); + + builder.HasOne(t => t.Employee).WithMany().HasForeignKey(t => t.EmployeeId).OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(t => t.FromBranch).WithMany().HasForeignKey(t => t.FromBranchId).OnDelete(DeleteBehavior.NoAction); + builder.HasOne(t => t.FromDepartment).WithMany().HasForeignKey(t => t.FromDepartmentId).OnDelete(DeleteBehavior.NoAction); + builder.HasOne(t => t.FromDesignation).WithMany().HasForeignKey(t => t.FromDesignationId).OnDelete(DeleteBehavior.NoAction); + + builder.HasOne(t => t.ToBranch).WithMany().HasForeignKey(t => t.ToBranchId).OnDelete(DeleteBehavior.NoAction); + builder.HasOne(t => t.ToDepartment).WithMany().HasForeignKey(t => t.ToDepartmentId).OnDelete(DeleteBehavior.NoAction); + builder.HasOne(t => t.ToDesignation).WithMany().HasForeignKey(t => t.ToDesignationId).OnDelete(DeleteBehavior.NoAction); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MsSQL/MsSQLConfiguration.cs b/Infrastructure/Database/MsSQL/MsSQLConfiguration.cs new file mode 100644 index 0000000..89e9723 --- /dev/null +++ b/Infrastructure/Database/MsSQL/MsSQLConfiguration.cs @@ -0,0 +1,23 @@ +namespace Indotalent.Infrastructure.Database.MsSQL; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +public static class MsSQLConfiguration +{ + public static IServiceCollection AddMsSQLContext(this IServiceCollection services, string connectionString) + where TContext : DbContext + { + services.AddDbContext(options => + options.UseSqlServer(connectionString, m => + m.MigrationsAssembly(typeof(TContext).Assembly.FullName))); + return services; + } + + public static void InitializeDatabase(IServiceProvider serviceProvider) where TContext : DbContext + { + using var scope = serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.Database.EnsureCreated(); + } +} \ No newline at end of file diff --git a/Infrastructure/Database/MySQL/MySQLConfiguration.cs b/Infrastructure/Database/MySQL/MySQLConfiguration.cs new file mode 100644 index 0000000..4242c58 --- /dev/null +++ b/Infrastructure/Database/MySQL/MySQLConfiguration.cs @@ -0,0 +1,17 @@ +namespace Indotalent.Infrastructure.Database.MySQL; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +public static class MySQLConfiguration +{ + public static IServiceCollection AddMySQLContext(this IServiceCollection services, string connectionString) + where TContext : DbContext + { + services.AddDbContext(options => + options.UseMySQL(connectionString, m => + m.MigrationsAssembly(typeof(TContext).Assembly.FullName))); + + return services; + } +} \ No newline at end of file diff --git a/Infrastructure/Database/PostgreSQL/PostgreSQLConfiguration.cs b/Infrastructure/Database/PostgreSQL/PostgreSQLConfiguration.cs new file mode 100644 index 0000000..9bf7ccb --- /dev/null +++ b/Infrastructure/Database/PostgreSQL/PostgreSQLConfiguration.cs @@ -0,0 +1,17 @@ +namespace Indotalent.Infrastructure.Database.PostgreSQL; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +public static class PostgreSQLConfiguration +{ + public static IServiceCollection AddPostgreSQLContext(this IServiceCollection services, string connectionString) + where TContext : DbContext + { + services.AddDbContext(options => + options.UseNpgsql(connectionString, m => + m.MigrationsAssembly(typeof(TContext).Assembly.FullName))); + + return services; + } +} \ No newline at end of file diff --git a/Infrastructure/Email/DI.cs b/Infrastructure/Email/DI.cs new file mode 100644 index 0000000..12a7561 --- /dev/null +++ b/Infrastructure/Email/DI.cs @@ -0,0 +1,46 @@ + + +using Indotalent.Data.Entities; +using Indotalent.Infrastructure.Email.Mailgun; +using Indotalent.Infrastructure.Email.Mailjet; +using Indotalent.Infrastructure.Email.SendGrid; +using Indotalent.Infrastructure.Email.Smtp; +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Infrastructure.Email; + +public static class DI +{ + public static IServiceCollection AddEmailService(this IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + + + var emailSettings = configuration.GetSection("EmailSettings").Get() ?? new EmailSettingsModel(); + + if (emailSettings.SendGrid?.IsUsed == true) + { + services.AddHttpClient(); + } + else if (emailSettings.Mailgun?.IsUsed == true) + { + services.AddHttpClient(); + } + else if (emailSettings.Mailjet?.IsUsed == true) + { + services.AddHttpClient(); + } + else if (emailSettings.Smtp?.IsUsed == true) + { + services.AddTransient(); + } + else + { + services.AddScoped(); + } + + services.AddTransient, IdentityEmailManager>(); + + return services; + } +} diff --git a/Infrastructure/Email/DefaultEmailSender.cs b/Infrastructure/Email/DefaultEmailSender.cs new file mode 100644 index 0000000..862823f --- /dev/null +++ b/Infrastructure/Email/DefaultEmailSender.cs @@ -0,0 +1,21 @@ +namespace Indotalent.Infrastructure.Email; + +public class DefaultEmailSender : IEmailSender +{ + private readonly ILogger _logger; + + public DefaultEmailSender(ILogger logger) + { + _logger = logger; + } + + public Task SendEmailAsync(string email, string subject, string htmlMessage) + { + _logger.LogWarning("Email sending requested but NO active email provider found in EmailSettings."); + _logger.LogInformation("To: {Email}", email); + _logger.LogInformation("Subject: {Subject}", subject); + _logger.LogInformation("Content: {Message}", htmlMessage); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Infrastructure/Email/EmailService.cs b/Infrastructure/Email/EmailService.cs new file mode 100644 index 0000000..7f63634 --- /dev/null +++ b/Infrastructure/Email/EmailService.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Email; + +public class EmailService(IOptions options) +{ + private readonly EmailSettingsModel _settings = options.Value; + + public EmailSettingsModel GetConfiguration() => _settings; +} diff --git a/Infrastructure/Email/EmailSettingsModel.cs b/Infrastructure/Email/EmailSettingsModel.cs new file mode 100644 index 0000000..33504ff --- /dev/null +++ b/Infrastructure/Email/EmailSettingsModel.cs @@ -0,0 +1,14 @@ +using Indotalent.Infrastructure.Email.Mailgun; +using Indotalent.Infrastructure.Email.Mailjet; +using Indotalent.Infrastructure.Email.SendGrid; +using Indotalent.Infrastructure.Email.Smtp; + +namespace Indotalent.Infrastructure.Email; + +public class EmailSettingsModel +{ + public SendGridSettingsModel SendGrid { get; set; } = new(); + public MailgunSettingsModel Mailgun { get; set; } = new(); + public MailjetSettingsModel Mailjet { get; set; } = new(); + public SmtpSettingsModel Smtp { get; set; } = new(); +} diff --git a/Infrastructure/Email/IEmailSender.cs b/Infrastructure/Email/IEmailSender.cs new file mode 100644 index 0000000..224009d --- /dev/null +++ b/Infrastructure/Email/IEmailSender.cs @@ -0,0 +1,6 @@ +namespace Indotalent.Infrastructure.Email; + +public interface IEmailSender +{ + Task SendEmailAsync(string email, string subject, string htmlMessage); +} diff --git a/Infrastructure/Email/IdentityEmailManager.cs b/Infrastructure/Email/IdentityEmailManager.cs new file mode 100644 index 0000000..99fa3cb --- /dev/null +++ b/Infrastructure/Email/IdentityEmailManager.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Identity; + +namespace Indotalent.Infrastructure.Email; + +public class IdentityEmailManager : IEmailSender where TUser : class +{ + private readonly IEmailSender _emailSender; + + public IdentityEmailManager(IEmailSender emailSender) + { + _emailSender = emailSender; + } + + public Task SendConfirmationLinkAsync(TUser user, string email, string confirmationLink) + { + var message = confirmationLink.Contains("<") + ? confirmationLink + : $"Please confirm your account by clicking this link: Confirm Account"; + + return _emailSender.SendEmailAsync(email, "Account Confirmation", message); + } + + public Task SendPasswordResetLinkAsync(TUser user, string email, string resetLink) + { + var message = resetLink.Contains("<") + ? resetLink + : $"To reset your password, please click the following link: Reset Password"; + + return _emailSender.SendEmailAsync(email, "Password Reset Request", message); + } + + public Task SendPasswordResetCodeAsync(TUser user, string email, string resetCode) => + _emailSender.SendEmailAsync(email, "Your Password Reset Code", + $"Your password reset security code is: {resetCode}. Please enter this code to proceed with the reset process."); +} \ No newline at end of file diff --git a/Infrastructure/Email/Mailgun/MailgunEmailSender.cs b/Infrastructure/Email/Mailgun/MailgunEmailSender.cs new file mode 100644 index 0000000..06cd747 --- /dev/null +++ b/Infrastructure/Email/Mailgun/MailgunEmailSender.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Text; + +namespace Indotalent.Infrastructure.Email.Mailgun; + +public class MailgunEmailSender : IEmailSender +{ + private readonly MailgunSettingsModel _settings; + private readonly HttpClient _httpClient; + + public MailgunEmailSender(IOptions emailOptions, HttpClient httpClient) + { + _settings = emailOptions.Value.Mailgun; + _httpClient = httpClient; + } + + public async Task SendEmailAsync(string email, string subject, string htmlMessage) + { + if (string.IsNullOrEmpty(_settings.ApiKey) || string.IsNullOrEmpty(_settings.Domain)) + { + throw new Exception("Mailgun configuration is missing ApiKey or Domain."); + } + + var authToken = Encoding.ASCII.GetBytes($"api:{_settings.ApiKey}"); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(authToken)); + + var formContent = new FormUrlEncodedContent(new[] + { + new KeyValuePair("from", _settings.FromEmail), + new KeyValuePair("to", email), + new KeyValuePair("subject", subject), + new KeyValuePair("html", htmlMessage) + }); + + var response = await _httpClient.PostAsync($"https://api.mailgun.net/v3/{_settings.Domain}/messages", formContent); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + throw new Exception($"Failed to send email via Mailgun. Status: {response.StatusCode}, Error: {errorBody}"); + } + } +} \ No newline at end of file diff --git a/Infrastructure/Email/Mailgun/MailgunSettingsModel.cs b/Infrastructure/Email/Mailgun/MailgunSettingsModel.cs new file mode 100644 index 0000000..e2b8dc4 --- /dev/null +++ b/Infrastructure/Email/Mailgun/MailgunSettingsModel.cs @@ -0,0 +1,9 @@ +namespace Indotalent.Infrastructure.Email.Mailgun; + +public class MailgunSettingsModel +{ + public bool IsUsed { get; set; } + public string ApiKey { get; set; } = string.Empty; + public string Domain { get; set; } = string.Empty; + public string FromEmail { get; set; } = string.Empty; +} diff --git a/Infrastructure/Email/Mailjet/MailjetEmailSender.cs b/Infrastructure/Email/Mailjet/MailjetEmailSender.cs new file mode 100644 index 0000000..d221fdf --- /dev/null +++ b/Infrastructure/Email/Mailjet/MailjetEmailSender.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Indotalent.Infrastructure.Email.Mailjet; + +public class MailjetEmailSender : IEmailSender +{ + private readonly MailjetSettingsModel _settings; + private readonly HttpClient _httpClient; + + public MailjetEmailSender(IOptions emailOptions, HttpClient httpClient) + { + _settings = emailOptions.Value.Mailjet; + _httpClient = httpClient; + } + + public async Task SendEmailAsync(string email, string subject, string htmlMessage) + { + if (string.IsNullOrEmpty(_settings.ApiKey) || string.IsNullOrEmpty(_settings.ApiSecret)) + { + throw new Exception("Mailjet configuration is missing ApiKey or ApiSecret."); + } + + var authToken = Encoding.UTF8.GetBytes($"{_settings.ApiKey}:{_settings.ApiSecret}"); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(authToken)); + + var payload = new + { + Messages = new[] + { + new + { + From = new { Email = _settings.FromEmail, Name = "Mailjet System" }, + To = new[] { new { Email = email } }, + Subject = subject, + HTMLPart = htmlMessage + } + } + }; + + var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync("https://api.mailjet.com/v3.1/send", content); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + throw new Exception($"Failed to send email via Mailjet. Status: {response.StatusCode}, Error: {errorBody}"); + } + } +} \ No newline at end of file diff --git a/Infrastructure/Email/Mailjet/MailjetSettingsModel.cs b/Infrastructure/Email/Mailjet/MailjetSettingsModel.cs new file mode 100644 index 0000000..fb3b28a --- /dev/null +++ b/Infrastructure/Email/Mailjet/MailjetSettingsModel.cs @@ -0,0 +1,9 @@ +namespace Indotalent.Infrastructure.Email.Mailjet; + +public class MailjetSettingsModel +{ + public bool IsUsed { get; set; } + public string ApiKey { get; set; } = string.Empty; + public string ApiSecret { get; set; } = string.Empty; + public string FromEmail { get; set; } = string.Empty; +} diff --git a/Infrastructure/Email/SendGrid/SendGridEmailSender.cs b/Infrastructure/Email/SendGrid/SendGridEmailSender.cs new file mode 100644 index 0000000..1117b34 --- /dev/null +++ b/Infrastructure/Email/SendGrid/SendGridEmailSender.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Indotalent.Infrastructure.Email.SendGrid; + +public class SendGridEmailSender : IEmailSender +{ + private readonly SendGridSettingsModel _settings; + private readonly HttpClient _httpClient; + + public SendGridEmailSender(IOptions emailOptions, HttpClient httpClient) + { + _settings = emailOptions.Value.SendGrid; + _httpClient = httpClient; + } + + public async Task SendEmailAsync(string email, string subject, string htmlMessage) + { + if (string.IsNullOrEmpty(_settings.ApiKey)) + { + throw new Exception("SendGrid configuration is missing ApiKey."); + } + + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _settings.ApiKey); + + var payload = new + { + personalizations = new[] + { + new + { + to = new[] { new { email = email } } + } + }, + from = new { email = _settings.FromEmail, name = "SendGrid System" }, + subject = subject, + content = new[] + { + new + { + type = "text/html", + value = htmlMessage + } + } + }; + + var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync("https://api.sendgrid.com/v3/mail/send", content); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + throw new Exception($"Failed to send email via SendGrid. Status: {response.StatusCode}, Error: {errorBody}"); + } + } +} \ No newline at end of file diff --git a/Infrastructure/Email/SendGrid/SendGridSettingsModel.cs b/Infrastructure/Email/SendGrid/SendGridSettingsModel.cs new file mode 100644 index 0000000..8e75081 --- /dev/null +++ b/Infrastructure/Email/SendGrid/SendGridSettingsModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Email.SendGrid; + +public class SendGridSettingsModel +{ + public bool IsUsed { get; set; } + public string ApiKey { get; set; } = string.Empty; + public string FromEmail { get; set; } = string.Empty; +} diff --git a/Infrastructure/Email/Smtp/SmtpEmailSender.cs b/Infrastructure/Email/Smtp/SmtpEmailSender.cs new file mode 100644 index 0000000..7a41791 --- /dev/null +++ b/Infrastructure/Email/Smtp/SmtpEmailSender.cs @@ -0,0 +1,54 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; + +namespace Indotalent.Infrastructure.Email.Smtp; + +public class SmtpEmailSender : IEmailSender +{ + private readonly SmtpSettingsModel _settings; + + public SmtpEmailSender(IOptions emailOptions) + { + _settings = emailOptions.Value.Smtp; + } + + public async Task SendEmailAsync(string email, string subject, string htmlMessage) + { + var message = new MimeMessage(); + + message.From.Add(new MailboxAddress(_settings.FromName, _settings.FromAddress)); + + message.To.Add(MailboxAddress.Parse(email)); + + message.Subject = subject; + + var bodyBuilder = new BodyBuilder + { + HtmlBody = htmlMessage + }; + message.Body = bodyBuilder.ToMessageBody(); + + using var client = new SmtpClient(); + try + { + await client.ConnectAsync(_settings.Host, _settings.Port, SecureSocketOptions.Auto); + + if (!string.IsNullOrEmpty(_settings.UserName)) + { + await client.AuthenticateAsync(_settings.UserName, _settings.Password); + } + + await client.SendAsync(message); + } + catch (Exception ex) + { + throw new Exception($"Failed to send email via SMTP to {email} via {_settings.Host}. Error: {ex.Message}", ex); + } + finally + { + await client.DisconnectAsync(true); + } + } +} \ No newline at end of file diff --git a/Infrastructure/Email/Smtp/SmtpSettingsModel.cs b/Infrastructure/Email/Smtp/SmtpSettingsModel.cs new file mode 100644 index 0000000..cd02f74 --- /dev/null +++ b/Infrastructure/Email/Smtp/SmtpSettingsModel.cs @@ -0,0 +1,12 @@ +namespace Indotalent.Infrastructure.Email.Smtp; + +public class SmtpSettingsModel +{ + public bool IsUsed { get; set; } + public string Host { get; set; } = string.Empty; + public int Port { get; set; } + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string FromAddress { get; set; } = string.Empty; + public string FromName { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/Avatar/AvatarStorageModel.cs b/Infrastructure/File/Avatar/AvatarStorageModel.cs new file mode 100644 index 0000000..964a595 --- /dev/null +++ b/Infrastructure/File/Avatar/AvatarStorageModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.File.Avatar; + +public class AvatarStorageModel +{ + public bool IsUsed { get; set; } + public string StoragePath { get; set; } = "wwwroot/avatars"; + public string BaseUrl { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/Aws/AwsS3SettingsModel.cs b/Infrastructure/File/Aws/AwsS3SettingsModel.cs new file mode 100644 index 0000000..99c67d1 --- /dev/null +++ b/Infrastructure/File/Aws/AwsS3SettingsModel.cs @@ -0,0 +1,9 @@ +namespace Indotalent.Infrastructure.File.Aws; + +public class AwsS3SettingsModel +{ + public bool IsUsed { get; set; } + public string AccessKey { get; set; } = string.Empty; + public string SecretKey { get; set; } = string.Empty; + public string BucketName { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/Azure/AzureBlobSettingsModel.cs b/Infrastructure/File/Azure/AzureBlobSettingsModel.cs new file mode 100644 index 0000000..95c3a53 --- /dev/null +++ b/Infrastructure/File/Azure/AzureBlobSettingsModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.File.Azure; + +public class AzureBlobSettingsModel +{ + public bool IsUsed { get; set; } + public string ConnectionString { get; set; } = string.Empty; + public string ContainerName { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/DI.cs b/Infrastructure/File/DI.cs new file mode 100644 index 0000000..c3dc97e --- /dev/null +++ b/Infrastructure/File/DI.cs @@ -0,0 +1,13 @@ + + +namespace Indotalent.Infrastructure.File; + +public static class DI +{ + public static IServiceCollection AddFileService(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/Infrastructure/File/Dropbox/DropboxSettingsModel.cs b/Infrastructure/File/Dropbox/DropboxSettingsModel.cs new file mode 100644 index 0000000..36f76a1 --- /dev/null +++ b/Infrastructure/File/Dropbox/DropboxSettingsModel.cs @@ -0,0 +1,7 @@ +namespace Indotalent.Infrastructure.File.Dropbox; + +public class DropboxSettingsModel +{ + public bool IsUsed { get; set; } + public string AccessToken { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/FileStorageService.cs b/Infrastructure/File/FileStorageService.cs new file mode 100644 index 0000000..6589743 --- /dev/null +++ b/Infrastructure/File/FileStorageService.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.File; + +public class FileStorageService +{ + private readonly FileStorageSettingsModel _settings; + private readonly IWebHostEnvironment _environment; + + private readonly string[] _allowedAvatarExtensions = { ".jpg", ".jpeg", ".png" }; + private readonly string[] _allowedDocExtensions = { ".pdf", ".xls", ".xlsx", ".doc", ".docx", ".txt" }; + + public FileStorageService(IOptions options, IWebHostEnvironment environment) + { + _settings = options.Value; + _environment = environment; + } + + public FileStorageSettingsModel GetConfiguration() => _settings; + + private string GetPhysicalPath(string subFolder) + { + return Path.Combine(_environment.ContentRootPath, "wwwroot", subFolder); + } + + public async Task SaveAvatarAsync(string userId, byte[] fileData, string extension) + { + var avatarSettings = _settings.Avatar; + if (!avatarSettings.IsUsed) throw new Exception("Avatar storage is disabled."); + + var ext = extension.ToLower(); + if (!_allowedAvatarExtensions.Contains(ext)) + throw new Exception("Invalid image type. Only JPG, JPEG, and PNG are allowed."); + + var folderPath = GetPhysicalPath(avatarSettings.StoragePath); + + if (!Directory.Exists(folderPath)) + { + Directory.CreateDirectory(folderPath); + } + + var fileName = $"{Guid.NewGuid()}{ext}"; + var filePath = Path.Combine(folderPath, fileName); + + await System.IO.File.WriteAllBytesAsync(filePath, fileData); + + return fileName; + } + + public void DeleteOldAvatar(string? fileName) + { + if (string.IsNullOrEmpty(fileName)) return; + + try + { + var folderPath = GetPhysicalPath(_settings.Avatar.StoragePath); + var filePath = Path.Combine(folderPath, fileName); + + if (System.IO.File.Exists(filePath)) + { + System.IO.File.Delete(filePath); + } + } + catch { } + } + + public async Task SaveFileAsync(byte[] fileData, string extension) + { + var localSettings = _settings.Local; + if (!localSettings.IsUsed) throw new Exception("Local file storage is disabled."); + + var ext = extension.ToLower(); + if (!_allowedDocExtensions.Contains(ext)) + throw new Exception($"File type {ext} is not allowed."); + + var folderPath = GetPhysicalPath(localSettings.StoragePath); + + if (!Directory.Exists(folderPath)) + { + Directory.CreateDirectory(folderPath); + } + + var fileName = $"{Guid.NewGuid()}{ext}"; + var filePath = Path.Combine(folderPath, fileName); + + await System.IO.File.WriteAllBytesAsync(filePath, fileData); + + return fileName; + } + + public void DeleteFile(string? fileName) + { + if (string.IsNullOrEmpty(fileName)) return; + + try + { + var folderPath = GetPhysicalPath(_settings.Local.StoragePath); + var filePath = Path.Combine(folderPath, fileName); + + if (System.IO.File.Exists(filePath)) + { + System.IO.File.Delete(filePath); + } + } + catch { } + } +} \ No newline at end of file diff --git a/Infrastructure/File/FileStorageSettingsModel.cs b/Infrastructure/File/FileStorageSettingsModel.cs new file mode 100644 index 0000000..b2aad6b --- /dev/null +++ b/Infrastructure/File/FileStorageSettingsModel.cs @@ -0,0 +1,18 @@ +using Indotalent.Infrastructure.File.Avatar; +using Indotalent.Infrastructure.File.Aws; +using Indotalent.Infrastructure.File.Azure; +using Indotalent.Infrastructure.File.Dropbox; +using Indotalent.Infrastructure.File.Google; +using Indotalent.Infrastructure.File.Local; + +namespace Indotalent.Infrastructure.File; + +public class FileStorageSettingsModel +{ + public AvatarStorageModel Avatar { get; set; } = new(); + public LocalStorageModel Local { get; set; } = new(); + public AwsS3SettingsModel AwsS3 { get; set; } = new(); + public AzureBlobSettingsModel AzureBlob { get; set; } = new(); + public GoogleCloudSettingsModel GoogleCloud { get; set; } = new(); + public DropboxSettingsModel Dropbox { get; set; } = new(); +} diff --git a/Infrastructure/File/Google/GoogleCloudSettingsModel.cs b/Infrastructure/File/Google/GoogleCloudSettingsModel.cs new file mode 100644 index 0000000..72f230c --- /dev/null +++ b/Infrastructure/File/Google/GoogleCloudSettingsModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.File.Google; + +public class GoogleCloudSettingsModel +{ + public bool IsUsed { get; set; } + public string ProjectId { get; set; } = string.Empty; + public string BucketName { get; set; } = string.Empty; +} diff --git a/Infrastructure/File/Local/LocalStorageModel.cs b/Infrastructure/File/Local/LocalStorageModel.cs new file mode 100644 index 0000000..f220017 --- /dev/null +++ b/Infrastructure/File/Local/LocalStorageModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.File.Local; + +public class LocalStorageModel +{ + public bool IsUsed { get; set; } + public string StoragePath { get; set; } = "wwwroot/uploads"; + public string BaseUrl { get; set; } = string.Empty; +} diff --git a/Infrastructure/Logging/DI.cs b/Infrastructure/Logging/DI.cs new file mode 100644 index 0000000..7fbc5a1 --- /dev/null +++ b/Infrastructure/Logging/DI.cs @@ -0,0 +1,11 @@ +namespace Indotalent.Infrastructure.Logging; + +public static class DI +{ + public static IServiceCollection AddLoggingService(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/Infrastructure/Logging/LoggerSettingsModel.cs b/Infrastructure/Logging/LoggerSettingsModel.cs new file mode 100644 index 0000000..c61f1b7 --- /dev/null +++ b/Infrastructure/Logging/LoggerSettingsModel.cs @@ -0,0 +1,10 @@ +using Indotalent.Infrastructure.Logging.Serilog; + +namespace Indotalent.Infrastructure.Logging; + +public class LoggerSettingsModel +{ + public FileLoggerModel File { get; set; } = new(); + public DatabaseLoggerModel Database { get; set; } = new(); + public SeqLoggerModel Seq { get; set; } = new(); +} diff --git a/Infrastructure/Logging/LoggingService.cs b/Infrastructure/Logging/LoggingService.cs new file mode 100644 index 0000000..7412192 --- /dev/null +++ b/Infrastructure/Logging/LoggingService.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Options; + +namespace Indotalent.Infrastructure.Logging; + +public class LoggingService(IOptions options) +{ + private readonly LoggerSettingsModel _settings = options.Value; + + public LoggerSettingsModel GetConfiguration() => _settings; +} diff --git a/Infrastructure/Logging/Serilog/DatabaseLoggerModel.cs b/Infrastructure/Logging/Serilog/DatabaseLoggerModel.cs new file mode 100644 index 0000000..64b8522 --- /dev/null +++ b/Infrastructure/Logging/Serilog/DatabaseLoggerModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Logging.Serilog; + +public class DatabaseLoggerModel +{ + public bool IsUsed { get; set; } + public string TableName { get; set; } = "SerilogLogs"; + public bool AutoCreateSqlTable { get; set; } = true; +} diff --git a/Infrastructure/Logging/Serilog/FileLoggerModel.cs b/Infrastructure/Logging/Serilog/FileLoggerModel.cs new file mode 100644 index 0000000..b1408a9 --- /dev/null +++ b/Infrastructure/Logging/Serilog/FileLoggerModel.cs @@ -0,0 +1,8 @@ +namespace Indotalent.Infrastructure.Logging.Serilog; + +public class FileLoggerModel +{ + public bool IsUsed { get; set; } + public string Path { get; set; } = "Logs/log-.txt"; + public string RollingInterval { get; set; } = "Day"; // Day, Hour, Month +} diff --git a/Infrastructure/Logging/Serilog/SeqLoggerModel.cs b/Infrastructure/Logging/Serilog/SeqLoggerModel.cs new file mode 100644 index 0000000..a643c5c --- /dev/null +++ b/Infrastructure/Logging/Serilog/SeqLoggerModel.cs @@ -0,0 +1,7 @@ +namespace Indotalent.Infrastructure.Logging.Serilog; + +public class SeqLoggerModel +{ + public bool IsUsed { get; set; } + public string ServerUrl { get; set; } = string.Empty; +} diff --git a/Infrastructure/Logging/Serilog/SerilogBuilder.cs b/Infrastructure/Logging/Serilog/SerilogBuilder.cs new file mode 100644 index 0000000..0433939 --- /dev/null +++ b/Infrastructure/Logging/Serilog/SerilogBuilder.cs @@ -0,0 +1,81 @@ +using Serilog; +using Serilog.Enrichers.Sensitive; +using Serilog.Events; +using Serilog.Sinks.MSSqlServer; + +namespace Indotalent.Infrastructure.Logging.Serilog; + +public static class SerilogBuilder +{ + public static void AddSerilogBuilder(this WebApplicationBuilder builder) + { + var settings = builder.Configuration.GetSection("LoggerSettings").Get(); + + var loggerConfiguration = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .Enrich.WithMachineName() + .Enrich.WithThreadId() + .Enrich.WithSensitiveDataMasking(options => + { + options.MaskValue = "***MASKED***"; + + options.MaskProperties.Add(MaskProperty.WithDefaults("Password")); + options.MaskProperties.Add(MaskProperty.WithDefaults("Secret")); + options.MaskProperties.Add(MaskProperty.WithDefaults("Token")); + options.MaskProperties.Add(MaskProperty.WithDefaults("ApiKey")); + options.MaskProperties.Add(MaskProperty.WithDefaults("ConfirmPassword")); + + }) + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"); + + if (settings?.File?.IsUsed == true) + { + var interval = settings.File.RollingInterval.ToLower() switch + { + "hour" => RollingInterval.Hour, + "month" => RollingInterval.Month, + _ => RollingInterval.Day + }; + + loggerConfiguration.WriteTo.File( + path: settings.File.Path, + rollingInterval: interval, + restrictedToMinimumLevel: LogEventLevel.Error, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"); + } + + if (settings?.Database?.IsUsed == true) + { + var columnOptions = new ColumnOptions(); + columnOptions.Store.Add(StandardColumn.LogEvent); + columnOptions.LogEvent.ColumnName = "LogEvent"; + var connectionString = builder.Configuration.GetSection("DatabaseSettings:MsSQL:ConnectionString").Value; + + if (!string.IsNullOrEmpty(connectionString)) + { + loggerConfiguration.WriteTo.MSSqlServer( + connectionString: connectionString, + sinkOptions: new MSSqlServerSinkOptions + { + TableName = settings.Database.TableName, + AutoCreateSqlTable = settings.Database.AutoCreateSqlTable + }, + columnOptions: columnOptions, + restrictedToMinimumLevel: LogEventLevel.Error); + } + } + + if (settings?.Seq?.IsUsed == true && !string.IsNullOrEmpty(settings.Seq.ServerUrl)) + { + // Send logs to the Seq server (Self-hosted dashboard by Datalust [https://datalust.co/]) + loggerConfiguration.WriteTo.Seq( + serverUrl: settings.Seq.ServerUrl, + restrictedToMinimumLevel: LogEventLevel.Information + ); + } + + Log.Logger = loggerConfiguration.CreateLogger(); + builder.Host.UseSerilog(); + } +} diff --git a/Infrastructure/OData/DI.cs b/Infrastructure/OData/DI.cs new file mode 100644 index 0000000..0146f8f --- /dev/null +++ b/Infrastructure/OData/DI.cs @@ -0,0 +1,35 @@ +using Indotalent.Data.Entities; +using Indotalent.Shared.Consts; +using Microsoft.AspNetCore.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Indotalent.Infrastructure.OData; + +public static class DI +{ + public static IServiceCollection AddODataService(this IServiceCollection services) + { + services.AddControllers().AddOData(options => + { + options.AddRouteComponents("odata", GetEdmModel()) + .Select() + .Filter() + .OrderBy() + .Expand() + .Count() + .SetMaxTop(GlobalConsts.ODataMaxTop); + }); + + return services; + } + + private static IEdmModel GetEdmModel() + { + var builder = new ODataConventionModelBuilder(); + + builder.EntitySet("SerilogLogs"); + + return builder.GetEdmModel(); + } +} \ No newline at end of file diff --git a/Infrastructure/OData/ODataResponse.cs b/Infrastructure/OData/ODataResponse.cs new file mode 100644 index 0000000..6cec61c --- /dev/null +++ b/Infrastructure/OData/ODataResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Indotalent.Infrastructure.OData; + +public class ODataResponse +{ + [JsonPropertyName("value")] + public List Value { get; set; } = new(); + + [JsonPropertyName("@odata.count")] + public int Count { get; set; } +} diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5a21f84 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,15 @@ +*** Regular Licenses *** + +Our Regular Licenses grant you the right to utilize the licensed item in a single end product. +However, it's imperative to note that this license does not permit the use of the item in an end product intended for sale. +Furthermore, it prohibits the creation of multiple products using the licensed item and prohibits actions such as resale, sublicense, or renting of the item. + +- Allowed to use in one end product only +- Not allowed to use in an end product that's sold +- Not allowed to use to create multiple product +- Not allowed to re-sale, sublicense and rent + +Attention freelancers and creative agencies: +Under the Regular License, you are permitted to charge your clients for your services in creating an end product. +Nevertheless, it's essential to remember that this license cannot be applied to multiple clients or projects simultaneously. +Each new client or project necessitates the procurement of a new Regular License. \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..bf3cd89 --- /dev/null +++ b/Program.cs @@ -0,0 +1,127 @@ +using Indotalent.ConfigBackEnd; +using Indotalent.ConfigBackEnd.Middleware; +using Indotalent.ConfigFrontEnd; +using Indotalent.ConfigFrontEnd.Filter; +using Indotalent.Data.Entities; +using Indotalent.Features; +using Indotalent.Features.Account; +using Indotalent.Features.Root; +using Indotalent.Infrastructure; +using Indotalent.Infrastructure.Database; +using Indotalent.Infrastructure.Logging.Serilog; +using Microsoft.OpenApi; +using MudBlazor.Services; +using QuestPDF.Infrastructure; +using Serilog; + + + +//private and non profit. commercial less than 1 million USD +//ref: https://www.questpdf.com/license/ +QuestPDF.Settings.License = LicenseType.Community; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddSerilogBuilder(); + +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddConfigBackEndDI(); + +builder.Services.AddInfrastructureDI(builder.Configuration); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddHubOptions(options => + { + options.MaximumReceiveMessageSize = null; //null => for unlimited + }); + +builder.Services.AddMudServices(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" }); + options.CustomSchemaIds(type => type.FullName); +}); + +builder.Services.AddConfigFrontEndDI(); +builder.Services.AddFeaturesDI(); + +builder.Services.AddRazorPages(options => +{ + options.RootDirectory = "/Features"; +}); + +var app = builder.Build(); + +app.MapRazorPages(); + +app.UseMiddleware(); + +app.MapGroup("/api/account").WithTags("Account").MapIdentityApi(); + +using (var scope = app.Services.CreateScope()) +{ + var settings = app.Configuration.GetSection("DatabaseSettings").Get(); + if (settings?.MsSQL?.IsUsed == true || + settings?.PostgreSQL?.IsUsed == true || + settings?.MySQL?.IsUsed == true) + { + var context = scope.ServiceProvider.GetRequiredService(); + context.Database.EnsureCreated(); + + await DatabaseSeeder.SeedAsync(scope.ServiceProvider); + } +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.MapSwagger().RequireAuthorization(); + +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.UseHttpsRedirection(); + +app.UseStaticFiles(); + +app.UseRouting(); + +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.MapControllers(); + +var apiGroup = app.MapGroup("/api").AddEndpointFilter(); + +apiGroup.MapAccountEndpoints(); +apiGroup.MapFeaturesEndpoint(); + +try +{ + Log.Information("Starting Web Application..."); + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} \ No newline at end of file diff --git a/Properties/PublishProfiles/FolderProfile.pubxml b/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..e977eef --- /dev/null +++ b/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,15 @@ + + + + + false + false + true + Release + Any CPU + FileSystem + bin\Release\net10.0\publish\ + FileSystem + <_TargetId>Folder + + \ No newline at end of file diff --git a/Properties/PublishProfiles/FolderProfile1.pubxml b/Properties/PublishProfiles/FolderProfile1.pubxml new file mode 100644 index 0000000..0ad629f --- /dev/null +++ b/Properties/PublishProfiles/FolderProfile1.pubxml @@ -0,0 +1,19 @@ + + + + + true + false + true + Release + Any CPU + FileSystem + bin\Release\net10.0\publish\ + FileSystem + <_TargetId>Folder + + net10.0 + 8ef814d3-27d3-2851-8c79-c47eeaab2a19 + false + + \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..ab08469 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/Shared/Consts/GlobalConsts.cs b/Shared/Consts/GlobalConsts.cs new file mode 100644 index 0000000..6f9159b --- /dev/null +++ b/Shared/Consts/GlobalConsts.cs @@ -0,0 +1,40 @@ +namespace Indotalent.Shared.Consts; + +public static class GlobalConsts +{ + public const string AppInitial = "HRM"; + public const string AppName = "HRM: Human Resource Management"; + public const string AppTagline = "The Next-Gen People Engine: Built for Scale, Engineered for Excellence."; + + public const int StringLengthId = 36; + public const int StringLengthShort = 500; + public const int StringLengthMedium = 1000; + public const int StringLengthLong = 4000; + + + public const int ODataMaxTop = 5000; + + + public const string BehaviourError = nameof(BehaviourError); + public const string GlobalError = nameof(GlobalError); + + public static class Roles + { + public const string Administrator = "Administrator"; + public const string Manager = "Manager"; + public const string Employee = "Employee"; + } + + public static class Formatting + { + public const string DateFormat = "dd MMM yyyy"; + public const string DateTimeFormat = "dd MMM yyyy HH:mm"; + public const string CurrencyCulture = "id-ID"; + } + + public static class Pagination + { + public const int DefaultPageSize = 10; + public static readonly int[] PageSizeOptions = { 10, 25, 50, 100 }; + } +} \ No newline at end of file diff --git a/Shared/Models/ApiRequest.cs b/Shared/Models/ApiRequest.cs new file mode 100644 index 0000000..ca9a006 --- /dev/null +++ b/Shared/Models/ApiRequest.cs @@ -0,0 +1,11 @@ +namespace Indotalent.Shared.Models; + +public class ApiRequest +{ + public int Skip { get; set; } = 1; + public int Top { get; set; } = 5; + public string? SearchTerm { get; set; } + public string? SortBy { get; set; } + public bool IsAscending { get; set; } = true; + public bool IsPagedEnabled { get; set; } = true; +} \ No newline at end of file diff --git a/Shared/Models/ApiResponse.cs b/Shared/Models/ApiResponse.cs new file mode 100644 index 0000000..891bd40 --- /dev/null +++ b/Shared/Models/ApiResponse.cs @@ -0,0 +1,22 @@ +namespace Indotalent.Shared.Models; + +public class ApiResponse +{ + public bool IsSuccess { get; set; } + public int StatusCode { get; set; } + public string? Message { get; set; } + public T? Value { get; set; } + public PaginationMetadata? Pagination { get; set; } + public List? Errors { get; set; } + public DateTime ServerTime { get; set; } = DateTime.UtcNow; +} + +public class PaginationMetadata +{ + public int Count { get; set; } + public int Top { get; set; } + public int Skip { get; set; } + public int TotalPages => (int)Math.Ceiling((double)Count / (Top == 0 ? 1 : Top)); + public bool HasPrevious => Skip > 0; + public bool HasNext => (Skip + Top) < Count; +} \ No newline at end of file diff --git a/Shared/Models/PagedList.cs b/Shared/Models/PagedList.cs new file mode 100644 index 0000000..1243a3e --- /dev/null +++ b/Shared/Models/PagedList.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace Indotalent.Shared.Models; + +public class PagedList +{ + public PagedList() + { + Value = new List(); + } + + public List Value { get; set; } + public int Skip { get; set; } + public int Top { get; set; } + public int TotalPages { get; set; } + public int Count { get; set; } + + [JsonConstructor] + public PagedList(List value, int count, int skip, int top, int totalPages) + { + Value = value; + Count = count; + Skip = skip; + Top = top; + TotalPages = totalPages; + } + + public PagedList(List value, int count, int skip, int top) + { + Value = value; + Count = count; + Skip = skip; + Top = top; + TotalPages = (int)Math.Ceiling(count / (double)top); + } + + public bool HasPrevious => Skip > 0; + public bool HasNext => (Skip + Top) < Count; +} \ No newline at end of file diff --git a/Shared/Utils/FluentValidationHelper.cs b/Shared/Utils/FluentValidationHelper.cs new file mode 100644 index 0000000..d7c470c --- /dev/null +++ b/Shared/Utils/FluentValidationHelper.cs @@ -0,0 +1,30 @@ +using FluentValidation; + +namespace Indotalent.Shared.Utils; + +//public static class FluentValidationHelper +//{ +// public static Func>> ValidateValue(this AbstractValidator validator) => async (model, propertyName) => +// { +// var result = await validator.ValidateAsync(ValidationContext.CreateWithOptions((T)model, x => x.IncludeProperties(propertyName))); +// if (result.IsValid) return Array.Empty(); +// return result.Errors.Select(e => e.ErrorMessage); +// }; +//} + +public static class FluentValidationHelper +{ + public static Func>> ValidateValue(this AbstractValidator validator) => async (model, propertyName) => + { + if (model is not T typedModel) + return Array.Empty(); + + var context = ValidationContext.CreateWithOptions(typedModel, x => x.IncludeProperties(propertyName)); + var result = await validator.ValidateAsync(context); + + if (result.IsValid) + return Array.Empty(); + + return result.Errors.Select(e => e.ErrorMessage); + }; +} diff --git a/Shared/Utils/PayrollSlipPdfGenerator.cs b/Shared/Utils/PayrollSlipPdfGenerator.cs new file mode 100644 index 0000000..1a950db --- /dev/null +++ b/Shared/Utils/PayrollSlipPdfGenerator.cs @@ -0,0 +1,125 @@ +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; +using Indotalent.Features.Payroll.PayrollDetails.Cqrs; + +namespace Indotalent.Features.Payroll.Payrolls; + +public class PayrollSlipPdfGenerator +{ + + public static byte[] Generate(GetPayrollSlipByIdResponse data, string periodName) + { + QuestPDF.Settings.License = LicenseType.Community; + + var primaryBlue = "#0D47A1"; + var lightGreyBg = "#F8FAFC"; + var borderColor = "#E2E8F0"; + var textSlate = "#64748b"; + + return Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A5.Landscape()); + page.Margin(1, Unit.Centimetre); + page.PageColor(Colors.White); + page.DefaultTextStyle(x => x.FontSize(9).FontFamily(Fonts.Verdana)); + + page.Header().PaddingBottom(10).Row(row => + { + row.RelativeItem().Text("PAYROLL SLIP DETAILS") + .FontSize(14) + .ExtraBold() + .FontColor(Colors.Black); + }); + + page.Content().Column(col => + { + col.Item().Border(1).BorderColor(borderColor).Background(lightGreyBg).Padding(10).Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().Text("Employee Name").FontSize(8).SemiBold().FontColor(textSlate); + c.Item().Text(data.EmployeeName).FontSize(11).ExtraBold().FontColor(primaryBlue); + c.Item().PaddingTop(5).Text("Code").FontSize(8).SemiBold().FontColor(textSlate); + c.Item().Text(data.EmployeeCode).FontSize(10).Bold(); + }); + + row.RelativeItem().AlignRight().Column(c => + { + c.Item().Text("Payroll Period").FontSize(8).SemiBold().FontColor(textSlate); + c.Item().Text(periodName).FontSize(11).Bold(); + c.Item().PaddingTop(5).Text("Basic Salary").FontSize(8).SemiBold().FontColor(textSlate); + c.Item().Text(data.BasicSalary.ToString("N0")).FontSize(11).ExtraBold().FontColor(primaryBlue); + }); + }); + + col.Item().PaddingTop(15).Row(row => + { + row.RelativeItem().Column(c => + { + c.Item().Text("Incomes (+)").FontSize(10).ExtraBold().FontColor(Colors.Green.Medium); + c.Item().PaddingVertical(5).LineHorizontal(1).LineColor(borderColor); + + foreach (var inc in data.Incomes) + { + c.Item().PaddingBottom(2).Row(r => + { + r.RelativeItem().Text(inc.ComponentName); + r.RelativeItem().AlignRight().Text(inc.Amount.ToString("N0")).Bold(); + }); + } + }); + + row.ConstantItem(20); + + row.RelativeItem().BorderLeft(1).BorderColor(borderColor).PaddingLeft(10).Column(c => + { + c.Item().Text("Deductions (-)").FontSize(10).ExtraBold().FontColor(Colors.Red.Medium); + c.Item().PaddingVertical(5).LineHorizontal(1).LineColor(borderColor); + + foreach (var ded in data.Deductions) + { + c.Item().PaddingBottom(2).Row(r => + { + r.RelativeItem().Text(ded.ComponentName); + r.RelativeItem().AlignRight().Text($"({ded.Amount.ToString("N0")})").Bold().FontColor(Colors.Red.Medium); + }); + } + }); + }); + + col.Item().PaddingTop(20).Background(primaryBlue).Padding(10).Row(row => + { + row.RelativeItem().AlignLeft().AlignMiddle().Text("TAKE HOME PAY") + .FontSize(12) + .ExtraBold() + .FontColor(Colors.White); + + row.RelativeItem().AlignRight().AlignMiddle().Text(data.TakeHomePay.ToString("N0")) + .FontSize(14) + .ExtraBold() + .FontColor(Colors.White); + }); + }); + + page.Footer().PaddingTop(10).Row(row => + { + row.RelativeItem().Text(x => + { + x.Span("Generated on: ").FontSize(8).FontColor(textSlate); + x.Span(DateTime.Now.ToString("dd MMM yyyy HH:mm")).FontSize(8).FontColor(textSlate); + }); + + row.RelativeItem().AlignRight().Text(x => + { + x.Span("Page ").FontSize(8); + x.CurrentPageNumber().FontSize(8); + }); + }); + }); + }).GeneratePdf(); + } + +} \ No newline at end of file diff --git a/Shared/Utils/SequentialGuidGenerator.cs b/Shared/Utils/SequentialGuidGenerator.cs new file mode 100644 index 0000000..9a74d3a --- /dev/null +++ b/Shared/Utils/SequentialGuidGenerator.cs @@ -0,0 +1,25 @@ +namespace Indotalent.Shared.Utils; + +public static class SequentialGuidGenerator +{ + public static string NewSequentialId() + { + byte[] guidBytes = Guid.NewGuid().ToByteArray(); + + byte[] timestampBytes = BitConverter.GetBytes(DateTime.UtcNow.Ticks); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(timestampBytes); + } + + guidBytes[10] = timestampBytes[2]; + guidBytes[11] = timestampBytes[3]; + guidBytes[12] = timestampBytes[4]; + guidBytes[13] = timestampBytes[5]; + guidBytes[14] = timestampBytes[6]; + guidBytes[15] = timestampBytes[7]; + + return new Guid(guidBytes).ToString(); + } +} \ No newline at end of file diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..e787821 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,41 @@ +{ + "IdentitySettings": { + "Password": { "RequireDigit": false, "RequiredLength": 4, "RequireNonAlphanumeric": false, "RequireUppercase": false, "RequireLowercase": false, "RequiredUniqueChars": 0 }, + "Cookies": { "Name": ".AspNetCore.Identity", "LoginPath": "/account/login", "LogoutPath": "/account/logout", "AccessDeniedPath": "/account/access-denied", "ExpireDays": 7 }, + "DefaultAdmin": { "FullName": "Super Admin", "Email": "admin@root.com", "Password": "123456" }, + "SignIn": { "RequireConfirmedAccount": true }, + "SsoFirebase": { "IsUsed": true, "OpenForPublic": true, "ProjectId": "sso-firebase-ea811", "ApiKey": "AIzaSyCr4ihZDDi8Sx_4gj4Qefu5QhygEZy9oXQ", "AuthDomain": "sso-firebase-ea811.firebaseapp.com", "StorageBucket": "sso-firebase-ea811.firebasestorage.app", "MessagingSenderId": "667215413835", "AppId": "1:667215413835:web:38d20ecedd582d008e43ce" }, + "SsoKeycloak": { "IsUsed": false, "Authority": "", "ClientId": "", "ClientSecret": "", "RequireHttpsMetadata": true } + }, + "JwtSettings": { "Key": "PRAGMATIC_FIX_KEY_MIN_32_CHARS_LONG_2026", "Issuer": "soltemp_Issuer", "Audience": "soltemp_Users", "DurationInMinutes": 1440 }, + "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Information", "Hangfire": "Information" } }, + "AllowedHosts": "*", + "DatabaseSettings": { + "MsSQL": { "IsUsed": true, "ConnectionString": "Server=mssql,1433;Database=Blazor-SaaS-HRM;User Id=sa;Password=nw9703AZz6YmgB@G;TrustServerCertificate=True;", "TimeoutInSeconds": 1800 }, + "MySQL": { "IsUsed": false, "ConnectionString": "", "TimeoutInSeconds": 1800 }, + "PostgreSQL": { "IsUsed": false, "ConnectionString": "", "TimeoutInSeconds": 1800 } + }, + "BackgroundJobSettings": { + "Hangfire": { "IsUsed": true, "ConnectionString": "Server=mssql,1433;Database=Blazor-SaaS-HRM-Hangfire;User Id=sa;Password=nw9703AZz6YmgB@G;TrustServerCertificate=True;", "DashboardPath": "/hangfire" }, + "Quartz": { "IsUsed": false, "TablePrefix": "QRTZ_" } + }, + "EmailSettings": { + "SendGrid": { "IsUsed": false, "ApiKey": "", "FromEmail": "" }, + "Mailgun": { "IsUsed": false, "ApiKey": "", "Domain": "", "FromEmail": "" }, + "Mailjet": { "IsUsed": false, "ApiKey": "", "ApiSecret": "", "FromEmail": "" }, + "Smtp": { "IsUsed": true, "Host": "smtp.gmail.com", "Port": 465, "UserName": "go2ismail@gmail.com", "Password": "lrbi cooi gcnt oquw", "FromAddress": "go2ismail@gmail.com", "FromName": "no-reply" } + }, + "FileStorageSettings": { + "Avatar": { "IsUsed": true, "StoragePath": "avatars", "BaseUrl": "https://saas-hrm.indotalent.com/avatars" }, + "Local": { "IsUsed": true, "StoragePath": "uploads", "BaseUrl": "https://saas-hrm.indotalent.com/uploads" }, + "AwsS3": { "IsUsed": false, "AccessKey": "", "SecretKey": "", "BucketName": "" }, + "AzureBlob": { "IsUsed": false, "ConnectionString": "", "ContainerName": "" }, + "GoogleCloud": { "IsUsed": false, "ProjectId": "", "BucketName": "" }, + "Dropbox": { "IsUsed": false, "AccessToken": "" } + }, + "LoggerSettings": { + "File": { "IsUsed": true, "Path": "wwwroot/xlogs/app-log-.txt", "RollingInterval": "Day" }, + "Database": { "IsUsed": true, "TableName": "SerilogLogs", "AutoCreateSqlTable": false }, + "Seq": { "IsUsed": false, "ServerUrl": "" } + } +} \ No newline at end of file diff --git a/developer.txt b/developer.txt new file mode 100644 index 0000000..fb3ebe4 --- /dev/null +++ b/developer.txt @@ -0,0 +1,2 @@ +this product developed by: go2ismail@gmail.com +open for freelance work (minimum contract length is one month): start from USD 2000 / month. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..06d7974 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +# ===================================================================== +# Blazor SaaS HRM - Docker Compose untuk Portainer Stack +# ===================================================================== +# ASP.NET Core Blazor .NET 10 - Multi-tenant SaaS HRM +# Database: Blazor-SaaS-HRM +# ===================================================================== + +services: + blazor-saas-hrm: + build: + context: . + dockerfile: Dockerfile + container_name: blazor-saas-hrm + restart: unless-stopped + expose: + - 8080 + networks: + - indotalent-network + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + - DatabaseSettings__MsSQL__ConnectionString=Server=mssql,1433;Database=Blazor-SaaS-HRM;User Id=sa;Password=nw9703AZz6YmgB@G;TrustServerCertificate=True; + - BackgroundJobSettings__Hangfire__ConnectionString=Server=mssql,1433;Database=Blazor-SaaS-HRM-Hangfire;User Id=sa;Password=nw9703AZz6YmgB@G;TrustServerCertificate=True; + - EmailSettings__Smtp__Host=smtp.gmail.com + - EmailSettings__Smtp__Port=465 + - EmailSettings__Smtp__UserName=go2ismail@gmail.com + - EmailSettings__Smtp__Password=lrbi cooi gcnt oquw + - EmailSettings__Smtp__FromAddress=go2ismail@gmail.com + # RESOURCE LIMITS + mem_limit: 512m + mem_reservation: 256m + cpus: '0.5' + labels: + - "indotalent.service=blazor-saas-hrm" + - "indotalent.description=Blazor SaaS HRM - Multi-tenant SaaS HRM" + +networks: + indotalent-network: + external: true + name: indotalent-network \ No newline at end of file diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..807729e --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.9", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/wwwroot/avatars/readme.txt b/wwwroot/avatars/readme.txt new file mode 100644 index 0000000..d587dd6 --- /dev/null +++ b/wwwroot/avatars/readme.txt @@ -0,0 +1 @@ +avatars file location (confirm with FileStorageSettings:avatar at appsettings.json). make sure IIS have write access to this folder. \ No newline at end of file diff --git a/wwwroot/favico.png b/wwwroot/favico.png new file mode 100644 index 0000000000000000000000000000000000000000..1b6625868e4605aa86827e4fbee0b87637597693 GIT binary patch literal 202 zcmeAS@N?(olHy`uVBq!ia0vp^%0R5h!3HFQ1%*HYjKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBugD~Uq{1quc!FW#>$B+p3w-+{YHaG~dUR-^iFXz6N3rp|& z1hMl8rHM_`|785gwLkY-|CU9~Nr@yL!!`+Lx#Me2U#r~h-lJ%mY;>h(;qCPA9fnKN qeA;Fimi&GrXV`Y7=VSs{WuE@sHyZ41Z?^-jV(@hJb6Mw<&;$TlCq&o) literal 0 HcmV?d00001