From 755df494fe0aacf3a64736ecd46afd9974ff7c2d Mon Sep 17 00:00:00 2001 From: Dhananjaya99 <152056742+ashanruu@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:54:56 +0530 Subject: [PATCH] develop full initial module --- .../Hrm/AttendanceBatchesController.cs | 99 + .../Controllers/Hrm/BranchesController.cs | 64 + .../Controllers/Hrm/DepartmentsController.cs | 65 + .../Controllers/Hrm/DesignationsController.cs | 64 + .../Controllers/Hrm/EmployeesController.cs | 217 ++ .../Hrm/EmploymentTypesController.cs | 64 + .../Hrm/HrDocumentTypesController.cs | 64 + .../Controllers/Hrm/HrReportsController.cs | 52 + .../Hrm/LeaveRequestsController.cs | 71 + .../Controllers/Hrm/LeaveTypesController.cs | 64 + .../Controllers/Hrm/PayrollRunsController.cs | 85 + .../Hrm/PayrollStatutorySettingsController.cs | 33 + .../Controllers/Hrm/PayslipsController.cs | 32 + .../Hrm/SalaryComponentsController.cs | 64 + .../Controllers/Hrm/TaxSlabsController.cs | 28 + .../Controllers/Hrm/WorkShiftsController.cs | 64 + .../ERPCore/Controllers/UsersController.cs | 14 +- .../Domain/Entities/AttendanceRecord.cs | 40 + .../Domain/Entities/AttendanceUploadBatch.cs | 31 + Backend/ERPCore/Domain/Entities/Branch.cs | 23 + Backend/ERPCore/Domain/Entities/Department.cs | 26 + .../ERPCore/Domain/Entities/Designation.cs | 19 + Backend/ERPCore/Domain/Entities/Employee.cs | 76 + .../Domain/Entities/EmployeeBankDetail.cs | 28 + .../Domain/Entities/EmployeeDocument.cs | 36 + .../ERPCore/Domain/Entities/EmployeeLoan.cs | 35 + .../Entities/EmployeeSalaryStructure.cs | 30 + .../Entities/EmployeeSalaryStructureLine.cs | 12 + .../ERPCore/Domain/Entities/EmploymentType.cs | 20 + .../ERPCore/Domain/Entities/HrDocumentType.cs | 24 + .../ERPCore/Domain/Entities/LeaveBalance.cs | 24 + .../ERPCore/Domain/Entities/LeaveRequest.cs | 31 + Backend/ERPCore/Domain/Entities/LeaveType.cs | 23 + .../Domain/Entities/LoanInstallment.cs | 26 + .../ERPCore/Domain/Entities/PayrollLine.cs | 41 + .../Domain/Entities/PayrollLineComponent.cs | 18 + Backend/ERPCore/Domain/Entities/PayrollRun.cs | 33 + .../Entities/PayrollStatutorySetting.cs | 21 + Backend/ERPCore/Domain/Entities/Payslip.cs | 16 + .../Domain/Entities/SalaryComponent.cs | 19 + Backend/ERPCore/Domain/Entities/TaxSlab.cs | 19 + Backend/ERPCore/Domain/Entities/User.cs | 8 + Backend/ERPCore/Domain/Entities/WorkShift.cs | 32 + .../Domain/Enums/AttendanceBatchStatus.cs | 15 + .../Domain/Enums/AttendanceSourceType.cs | 13 + .../ERPCore/Domain/Enums/AttendanceStatus.cs | 17 + .../Domain/Enums/EmployeeDocumentStatus.cs | 11 + .../ERPCore/Domain/Enums/EmployeeStatus.cs | 16 + Backend/ERPCore/Domain/Enums/Gender.cs | 9 + .../Domain/Enums/HrDocumentCategory.cs | 12 + .../Domain/Enums/LeaveRequestStatus.cs | 11 + .../Domain/Enums/LoanInstallmentStatus.cs | 13 + Backend/ERPCore/Domain/Enums/LoanKind.cs | 8 + Backend/ERPCore/Domain/Enums/LoanStatus.cs | 8 + .../Enums/PayrollLineComponentCategory.cs | 12 + .../ERPCore/Domain/Enums/PayrollRunStatus.cs | 15 + .../Domain/Enums/RowValidationStatus.cs | 12 + .../Domain/Enums/SalaryComponentType.cs | 12 + .../Domain/Enums/SalaryStructureStatus.cs | 8 + Backend/ERPCore/Dtos/Hrm/AttendanceDtos.cs | 44 + Backend/ERPCore/Dtos/Hrm/DocumentDtos.cs | 46 + Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs | 117 + .../ERPCore/Dtos/Hrm/EmployeeUserLinkDtos.cs | 17 + Backend/ERPCore/Dtos/Hrm/LeaveDtos.cs | 67 + Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs | 111 + Backend/ERPCore/Dtos/Hrm/PayrollDtos.cs | 130 ++ Backend/ERPCore/Dtos/Hrm/ReportDtos.cs | 26 + Backend/ERPCore/Dtos/Users/UserDtos.cs | 8 +- Backend/ERPCore/ERPCore.csproj | 2 + .../AttendanceRecordConfiguration.cs | 30 + .../AttendanceUploadBatchConfiguration.cs | 27 + .../Configurations/BranchConfiguration.cs | 29 + .../Configurations/DepartmentConfiguration.cs | 40 + .../DesignationConfiguration.cs | 28 + .../EmployeeBankDetailConfiguration.cs | 33 + .../Configurations/EmployeeConfiguration.cs | 71 + .../EmployeeDocumentConfiguration.cs | 36 + .../EmployeeLoanConfiguration.cs | 56 + .../EmployeeSalaryStructureConfiguration.cs | 43 + .../EmploymentTypeConfiguration.cs | 28 + .../HrDocumentTypeConfiguration.cs | 29 + .../LeaveBalanceConfiguration.cs | 28 + .../LeaveRequestConfiguration.cs | 35 + .../Configurations/LeaveTypeConfiguration.cs | 29 + .../PayrollLineConfiguration.cs | 52 + .../Configurations/PayrollRunConfiguration.cs | 31 + .../PayrollStatutorySettingConfiguration.cs | 42 + .../Configurations/PayslipConfiguration.cs | 20 + .../SalaryComponentConfiguration.cs | 29 + .../Configurations/UserConfiguration.cs | 6 + .../Configurations/WorkShiftConfiguration.cs | 29 + .../ERPCore/Infra/Persistence/ErpDbContext.cs | 60 + .../Migrations/ErpDbContextModelSnapshot.cs | 1916 +++++++++++++++++ .../Infra/Storage/IFileStorageService.cs | 22 + .../Infra/Storage/LocalFileStorageService.cs | 75 + Backend/ERPCore/Program.cs | 38 + .../Hrm/AttendanceComputationService.cs | 46 + .../Services/Hrm/AttendanceUploadService.cs | 407 ++++ Backend/ERPCore/Services/Hrm/BranchService.cs | 107 + .../ERPCore/Services/Hrm/DepartmentService.cs | 149 ++ .../Services/Hrm/DesignationService.cs | 105 + .../Services/Hrm/EmployeeDocumentService.cs | 129 ++ .../Services/Hrm/EmployeeLoanService.cs | 114 + .../Hrm/EmployeeSalaryStructureService.cs | 104 + .../ERPCore/Services/Hrm/EmployeeService.cs | 244 +++ .../Services/Hrm/EmployeeUserLinkService.cs | 73 + .../Services/Hrm/EmploymentTypeService.cs | 105 + .../Services/Hrm/HrDocumentTypeService.cs | 113 + .../ERPCore/Services/Hrm/HrReportService.cs | 133 ++ .../Services/Hrm/LeaveBalanceService.cs | 95 + .../Services/Hrm/LeaveRequestService.cs | 168 ++ .../ERPCore/Services/Hrm/LeaveTypeService.cs | 119 + .../Services/Hrm/PayrollCalculationService.cs | 152 ++ .../ERPCore/Services/Hrm/PayrollRunService.cs | 283 +++ .../Hrm/PayrollStatutorySettingService.cs | 63 + .../ERPCore/Services/Hrm/PayslipService.cs | 74 + .../Services/Hrm/SalaryComponentService.cs | 112 + .../ERPCore/Services/Hrm/TaxSlabService.cs | 67 + .../ERPCore/Services/Hrm/WorkShiftService.cs | 127 ++ .../IAttendanceComputationService.cs | 19 + .../Interfaces/IAttendanceUploadService.cs | 26 + .../Services/Interfaces/IBranchService.cs | 16 + .../Services/Interfaces/IDepartmentService.cs | 16 + .../Interfaces/IDesignationService.cs | 15 + .../Interfaces/IEmployeeDocumentService.cs | 15 + .../Interfaces/IEmployeeLoanService.cs | 14 + .../IEmployeeSalaryStructureService.cs | 11 + .../Services/Interfaces/IEmployeeService.cs | 20 + .../Interfaces/IEmployeeUserLinkService.cs | 22 + .../Interfaces/IEmploymentTypeService.cs | 15 + .../Interfaces/IHrDocumentTypeService.cs | 16 + .../Services/Interfaces/IHrReportService.cs | 19 + .../Interfaces/ILeaveBalanceService.cs | 12 + .../Interfaces/ILeaveRequestService.cs | 20 + .../Services/Interfaces/ILeaveTypeService.cs | 15 + .../Interfaces/IPayrollCalculationService.cs | 14 + .../Services/Interfaces/IPayrollRunService.cs | 20 + .../IPayrollStatutorySettingService.cs | 10 + .../Services/Interfaces/IPayslipService.cs | 10 + .../Interfaces/ISalaryComponentService.cs | 15 + .../Services/Interfaces/ITaxSlabService.cs | 10 + .../Services/Interfaces/IWorkShiftService.cs | 15 + .../ERPCore/Services/UserManagementService.cs | 16 +- Backend/ERPCore/System/Errors/ErrorCodes.cs | 15 + Backend/ERPCore/appsettings.json | 4 + Backend/PROGRESS.md | 68 + Frontend/PROGRESS.md | 28 + .../dashboard/hrm/attendance/[id]/page.tsx | 194 ++ .../app/dashboard/hrm/attendance/page.tsx | 152 ++ .../app/dashboard/hrm/employees/[id]/page.tsx | 485 +++++ .../app/dashboard/hrm/employees/page.tsx | 284 +++ .../app/dashboard/hrm/leave/page.tsx | 198 ++ .../hrm/payroll/[id]/lines/[lineId]/page.tsx | 76 + .../app/dashboard/hrm/payroll/[id]/page.tsx | 170 ++ .../app/dashboard/hrm/payroll/page.tsx | 139 ++ .../app/dashboard/hrm/reports/page.tsx | 175 ++ .../dashboard/hrm/settings/branches/page.tsx | 16 + .../hrm/settings/departments/page.tsx | 198 ++ .../hrm/settings/designations/page.tsx | 16 + .../hrm/settings/document-types/page.tsx | 176 ++ .../hrm/settings/employment-types/page.tsx | 16 + .../hrm/settings/leave-types/page.tsx | 179 ++ .../app/dashboard/hrm/settings/page.tsx | 44 + .../hrm/settings/salary-components/page.tsx | 176 ++ .../dashboard/hrm/settings/statutory/page.tsx | 179 ++ .../hrm/settings/work-shifts/page.tsx | 222 ++ .../app/dashboard/settings/users/page.tsx | 41 +- .../components/Layouts/AppSidebar.tsx | 37 +- .../components/hrm/CodeNameMasterPage.tsx | 193 ++ Frontend/erp-system/lib/api-client.ts | 7 +- Frontend/erp-system/lib/api/attendance.ts | 57 + Frontend/erp-system/lib/api/employees.ts | 128 ++ Frontend/erp-system/lib/api/hr-reports.ts | 35 + .../erp-system/lib/api/hrm-master-factory.ts | 32 + Frontend/erp-system/lib/api/hrm-masters.ts | 83 + Frontend/erp-system/lib/api/leave.ts | 43 + Frontend/erp-system/lib/api/payroll.ts | 70 + Frontend/erp-system/lib/error-map.ts | 13 + Frontend/erp-system/package-lock.json | 54 +- Frontend/erp-system/types/hrm.ts | 536 +++++ Frontend/erp-system/types/users.ts | 3 + docs/00-CORE.md | 17 +- docs/01-DOC-GUIDE.md | 15 +- docs/02-SECURITY.md | 16 +- docs/10-BACKEND-PHASE1.md | 2 +- docs/12-BACKEND-HRM.md | 198 ++ docs/13-BACKEND-HRM-API.md | 156 ++ docs/21-FRONTEND-HRM.md | 50 + 188 files changed, 13894 insertions(+), 49 deletions(-) create mode 100644 Backend/ERPCore/Controllers/Hrm/AttendanceBatchesController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/BranchesController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/DesignationsController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/EmployeesController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/HrReportsController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/LeaveRequestsController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/PayrollRunsController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/PayrollStatutorySettingsController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/PayslipsController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/TaxSlabsController.cs create mode 100644 Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs create mode 100644 Backend/ERPCore/Domain/Entities/AttendanceRecord.cs create mode 100644 Backend/ERPCore/Domain/Entities/AttendanceUploadBatch.cs create mode 100644 Backend/ERPCore/Domain/Entities/Branch.cs create mode 100644 Backend/ERPCore/Domain/Entities/Department.cs create mode 100644 Backend/ERPCore/Domain/Entities/Designation.cs create mode 100644 Backend/ERPCore/Domain/Entities/Employee.cs create mode 100644 Backend/ERPCore/Domain/Entities/EmployeeBankDetail.cs create mode 100644 Backend/ERPCore/Domain/Entities/EmployeeDocument.cs create mode 100644 Backend/ERPCore/Domain/Entities/EmployeeLoan.cs create mode 100644 Backend/ERPCore/Domain/Entities/EmployeeSalaryStructure.cs create mode 100644 Backend/ERPCore/Domain/Entities/EmployeeSalaryStructureLine.cs create mode 100644 Backend/ERPCore/Domain/Entities/EmploymentType.cs create mode 100644 Backend/ERPCore/Domain/Entities/HrDocumentType.cs create mode 100644 Backend/ERPCore/Domain/Entities/LeaveBalance.cs create mode 100644 Backend/ERPCore/Domain/Entities/LeaveRequest.cs create mode 100644 Backend/ERPCore/Domain/Entities/LeaveType.cs create mode 100644 Backend/ERPCore/Domain/Entities/LoanInstallment.cs create mode 100644 Backend/ERPCore/Domain/Entities/PayrollLine.cs create mode 100644 Backend/ERPCore/Domain/Entities/PayrollLineComponent.cs create mode 100644 Backend/ERPCore/Domain/Entities/PayrollRun.cs create mode 100644 Backend/ERPCore/Domain/Entities/PayrollStatutorySetting.cs create mode 100644 Backend/ERPCore/Domain/Entities/Payslip.cs create mode 100644 Backend/ERPCore/Domain/Entities/SalaryComponent.cs create mode 100644 Backend/ERPCore/Domain/Entities/TaxSlab.cs create mode 100644 Backend/ERPCore/Domain/Entities/WorkShift.cs create mode 100644 Backend/ERPCore/Domain/Enums/AttendanceBatchStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/AttendanceSourceType.cs create mode 100644 Backend/ERPCore/Domain/Enums/AttendanceStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/EmployeeDocumentStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/EmployeeStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/Gender.cs create mode 100644 Backend/ERPCore/Domain/Enums/HrDocumentCategory.cs create mode 100644 Backend/ERPCore/Domain/Enums/LeaveRequestStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/LoanInstallmentStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/LoanKind.cs create mode 100644 Backend/ERPCore/Domain/Enums/LoanStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/PayrollLineComponentCategory.cs create mode 100644 Backend/ERPCore/Domain/Enums/PayrollRunStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/RowValidationStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/SalaryComponentType.cs create mode 100644 Backend/ERPCore/Domain/Enums/SalaryStructureStatus.cs create mode 100644 Backend/ERPCore/Dtos/Hrm/AttendanceDtos.cs create mode 100644 Backend/ERPCore/Dtos/Hrm/DocumentDtos.cs create mode 100644 Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs create mode 100644 Backend/ERPCore/Dtos/Hrm/EmployeeUserLinkDtos.cs create mode 100644 Backend/ERPCore/Dtos/Hrm/LeaveDtos.cs create mode 100644 Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs create mode 100644 Backend/ERPCore/Dtos/Hrm/PayrollDtos.cs create mode 100644 Backend/ERPCore/Dtos/Hrm/ReportDtos.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/AttendanceRecordConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/AttendanceUploadBatchConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/DepartmentConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/DesignationConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/EmployeeBankDetailConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/EmployeeConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/EmployeeDocumentConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/EmployeeLoanConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/EmployeeSalaryStructureConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/EmploymentTypeConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/HrDocumentTypeConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/LeaveBalanceConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/LeaveRequestConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/LeaveTypeConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/PayrollLineConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/PayrollRunConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/PayrollStatutorySettingConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/PayslipConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/SalaryComponentConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/WorkShiftConfiguration.cs create mode 100644 Backend/ERPCore/Infra/Storage/IFileStorageService.cs create mode 100644 Backend/ERPCore/Infra/Storage/LocalFileStorageService.cs create mode 100644 Backend/ERPCore/Services/Hrm/AttendanceComputationService.cs create mode 100644 Backend/ERPCore/Services/Hrm/AttendanceUploadService.cs create mode 100644 Backend/ERPCore/Services/Hrm/BranchService.cs create mode 100644 Backend/ERPCore/Services/Hrm/DepartmentService.cs create mode 100644 Backend/ERPCore/Services/Hrm/DesignationService.cs create mode 100644 Backend/ERPCore/Services/Hrm/EmployeeDocumentService.cs create mode 100644 Backend/ERPCore/Services/Hrm/EmployeeLoanService.cs create mode 100644 Backend/ERPCore/Services/Hrm/EmployeeSalaryStructureService.cs create mode 100644 Backend/ERPCore/Services/Hrm/EmployeeService.cs create mode 100644 Backend/ERPCore/Services/Hrm/EmployeeUserLinkService.cs create mode 100644 Backend/ERPCore/Services/Hrm/EmploymentTypeService.cs create mode 100644 Backend/ERPCore/Services/Hrm/HrDocumentTypeService.cs create mode 100644 Backend/ERPCore/Services/Hrm/HrReportService.cs create mode 100644 Backend/ERPCore/Services/Hrm/LeaveBalanceService.cs create mode 100644 Backend/ERPCore/Services/Hrm/LeaveRequestService.cs create mode 100644 Backend/ERPCore/Services/Hrm/LeaveTypeService.cs create mode 100644 Backend/ERPCore/Services/Hrm/PayrollCalculationService.cs create mode 100644 Backend/ERPCore/Services/Hrm/PayrollRunService.cs create mode 100644 Backend/ERPCore/Services/Hrm/PayrollStatutorySettingService.cs create mode 100644 Backend/ERPCore/Services/Hrm/PayslipService.cs create mode 100644 Backend/ERPCore/Services/Hrm/SalaryComponentService.cs create mode 100644 Backend/ERPCore/Services/Hrm/TaxSlabService.cs create mode 100644 Backend/ERPCore/Services/Hrm/WorkShiftService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IAttendanceComputationService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IAttendanceUploadService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IBranchService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IDepartmentService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IDesignationService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IEmployeeDocumentService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IEmployeeLoanService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IEmployeeSalaryStructureService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IEmployeeService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IEmployeeUserLinkService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IEmploymentTypeService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IHrDocumentTypeService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IHrReportService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ILeaveBalanceService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ILeaveRequestService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ILeaveTypeService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IPayrollCalculationService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IPayrollRunService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IPayrollStatutorySettingService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IPayslipService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ISalaryComponentService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ITaxSlabService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IWorkShiftService.cs create mode 100644 Frontend/erp-system/app/dashboard/hrm/attendance/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/attendance/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/employees/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/employees/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/leave/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/payroll/[id]/lines/[lineId]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/payroll/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/reports/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/settings/branches/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/settings/departments/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/settings/designations/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/settings/document-types/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/settings/employment-types/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/settings/leave-types/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/settings/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/settings/salary-components/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/settings/statutory/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/hrm/settings/work-shifts/page.tsx create mode 100644 Frontend/erp-system/components/hrm/CodeNameMasterPage.tsx create mode 100644 Frontend/erp-system/lib/api/attendance.ts create mode 100644 Frontend/erp-system/lib/api/employees.ts create mode 100644 Frontend/erp-system/lib/api/hr-reports.ts create mode 100644 Frontend/erp-system/lib/api/hrm-master-factory.ts create mode 100644 Frontend/erp-system/lib/api/hrm-masters.ts create mode 100644 Frontend/erp-system/lib/api/leave.ts create mode 100644 Frontend/erp-system/lib/api/payroll.ts create mode 100644 Frontend/erp-system/types/hrm.ts create mode 100644 docs/12-BACKEND-HRM.md create mode 100644 docs/13-BACKEND-HRM-API.md create mode 100644 docs/21-FRONTEND-HRM.md diff --git a/Backend/ERPCore/Controllers/Hrm/AttendanceBatchesController.cs b/Backend/ERPCore/Controllers/Hrm/AttendanceBatchesController.cs new file mode 100644 index 0000000..83c1f20 --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/AttendanceBatchesController.cs @@ -0,0 +1,99 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.Auth; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4). +[Route("api/v1/attendance-batches")] +public sealed class AttendanceBatchesController : ApiControllerBase +{ + private readonly IAttendanceUploadService _attendance; + private readonly ICurrentUser _currentUser; + + public AttendanceBatchesController(IAttendanceUploadService attendance, ICurrentUser currentUser) + { + _attendance = attendance; + _currentUser = currentUser; + } + + [HttpGet("template.xlsx")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult DownloadTemplate([FromQuery] string? format) + { + var (content, contentType, fileName) = _attendance.GenerateTemplate(string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase)); + return File(content, contentType, fileName); + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] AttendanceBatchStatus? status, + [FromQuery] int? periodYear, [FromQuery] int? periodMonth, CancellationToken ct) + => Ok(await _attendance.ListBatchesAsync(query, status, periodYear, periodMonth, ct)); + + [HttpGet("{batchId:int}")] + [ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int batchId, CancellationToken ct) + { + var result = await _attendance.GetBatchAsync(batchId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + [RequestSizeLimit(20 * 1024 * 1024)] + [ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Upload( + [FromForm] UploadAttendanceBatchMetadata metadata, IFormFile file, CancellationToken ct) + { + await using var stream = file.OpenReadStream(); + var result = await _attendance.UploadAsync(stream, file.FileName, metadata.PeriodStart, metadata.PeriodEnd, _currentUser.AuditUserId, ct); + return Created($"/api/v1/attendance-batches/{result.AttendanceUploadBatchId}", result); + } + + [HttpGet("{batchId:int}/records")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> ListRecords( + int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct) + => Ok(await _attendance.ListRecordsAsync(batchId, status, ct)); + + [HttpPut("{batchId:int}/records/{recordId:int}")] + [ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> UpdateRecord( + int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct) + => Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct)); + + [HttpPost("{batchId:int}/resolve-duplicate")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task ResolveDuplicate(int batchId, [FromBody] ResolveDuplicateRequest request, CancellationToken ct) + { + await _attendance.ResolveDuplicateAsync(batchId, request, ct); + return NoContent(); + } + + [HttpPost("{batchId:int}/validate")] + [ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Validate(int batchId, CancellationToken ct) + => Ok(await _attendance.ValidateAsync(batchId, ct)); + + [HttpPost("{batchId:int}/confirm")] + [ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Confirm(int batchId, CancellationToken ct) + => Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct)); + + [HttpPost("{batchId:int}/unlock")] + [ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Unlock(int batchId, [FromBody] UnlockAttendanceBatchRequest request, CancellationToken ct) + => Ok(await _attendance.UnlockAsync(batchId, request.Reason, _currentUser.AuditUserId, ct)); +} diff --git a/Backend/ERPCore/Controllers/Hrm/BranchesController.cs b/Backend/ERPCore/Controllers/Hrm/BranchesController.cs new file mode 100644 index 0000000..fb62839 --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/BranchesController.cs @@ -0,0 +1,64 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Branch master endpoints (docs/13-BACKEND-HRM-API.md §2). +[Route("api/v1/branches")] +public sealed class BranchesController : ApiControllerBase +{ + private readonly IBranchService _branches; + + public BranchesController(IBranchService branches) => _branches = branches; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _branches.ListAsync(query, status, ct)); + + [HttpGet("{branchId:int}")] + [ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int branchId, CancellationToken ct) + { + var result = await _branches.GetAsync(branchId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateBranchRequest request, CancellationToken ct) + { + var result = await _branches.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value); + } + + [HttpPut("{branchId:int}")] + [ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int branchId, [FromBody] UpdateBranchRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _branches.UpdateAsync(branchId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPatch("{branchId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int branchId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct) + { + await _branches.SetStatusAsync(branchId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs b/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs new file mode 100644 index 0000000..2a84611 --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs @@ -0,0 +1,65 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Department master endpoints (docs/13-BACKEND-HRM-API.md §2). +[Route("api/v1/departments")] +public sealed class DepartmentsController : ApiControllerBase +{ + private readonly IDepartmentService _departments; + + public DepartmentsController(IDepartmentService departments) => _departments = departments; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _departments.ListAsync(query, status, ct)); + + [HttpGet("{departmentId:int}")] + [ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int departmentId, CancellationToken ct) + { + var result = await _departments.GetAsync(departmentId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateDepartmentRequest request, CancellationToken ct) + { + var result = await _departments.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value); + } + + [HttpPut("{departmentId:int}")] + [ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Update(int departmentId, [FromBody] UpdateDepartmentRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _departments.UpdateAsync(departmentId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPatch("{departmentId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int departmentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct) + { + await _departments.SetStatusAsync(departmentId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs b/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs new file mode 100644 index 0000000..6ebc1fe --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs @@ -0,0 +1,64 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2). +[Route("api/v1/designations")] +public sealed class DesignationsController : ApiControllerBase +{ + private readonly IDesignationService _designations; + + public DesignationsController(IDesignationService designations) => _designations = designations; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _designations.ListAsync(query, status, ct)); + + [HttpGet("{designationId:int}")] + [ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int designationId, CancellationToken ct) + { + var result = await _designations.GetAsync(designationId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateDesignationRequest request, CancellationToken ct) + { + var result = await _designations.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value); + } + + [HttpPut("{designationId:int}")] + [ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int designationId, [FromBody] UpdateDesignationRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _designations.UpdateAsync(designationId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPatch("{designationId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int designationId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct) + { + await _designations.SetStatusAsync(designationId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs b/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs new file mode 100644 index 0000000..645a90b --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs @@ -0,0 +1,217 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.Auth; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// +/// Employee (staff) endpoints, incl. the Employee<->User cross-link and staff +/// document sub-resources (docs/13-BACKEND-HRM-API.md §3). +/// +[Route("api/v1/employees")] +public sealed class EmployeesController : ApiControllerBase +{ + private readonly IEmployeeService _employees; + private readonly IEmployeeUserLinkService _links; + private readonly IEmployeeDocumentService _documents; + private readonly ILeaveBalanceService _leaveBalances; + private readonly IEmployeeSalaryStructureService _salaryStructures; + private readonly IEmployeeLoanService _loans; + private readonly ICurrentUser _currentUser; + + public EmployeesController( + IEmployeeService employees, IEmployeeUserLinkService links, IEmployeeDocumentService documents, + ILeaveBalanceService leaveBalances, IEmployeeSalaryStructureService salaryStructures, + IEmployeeLoanService loans, ICurrentUser currentUser) + { + _employees = employees; + _links = links; + _documents = documents; + _leaveBalances = leaveBalances; + _salaryStructures = salaryStructures; + _loans = loans; + _currentUser = currentUser; + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EmployeeStatus? status, + [FromQuery] int? departmentId, [FromQuery] int? designationId, [FromQuery] int? branchId, CancellationToken ct) + => Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct)); + + /// Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5) + [HttpGet("email-lookup")] + [ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)] + public async Task> EmailLookup([FromQuery] string email, CancellationToken ct) + => Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct))); + + [HttpGet("{employeeId:int}")] + [ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int employeeId, CancellationToken ct) + { + var result = await _employees.GetAsync(employeeId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateEmployeeRequest request, CancellationToken ct) + { + var result = await _employees.CreateAsync(request, _currentUser.AuditUserId, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value); + } + + [HttpPut("{employeeId:int}")] + [ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int employeeId, [FromBody] UpdateEmployeeRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _employees.UpdateAsync(employeeId, request, expected, _currentUser.AuditUserId, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2). + [HttpPatch("{employeeId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int employeeId, [FromBody] UpdateEmployeeStatusRequest request, CancellationToken ct) + { + await _employees.SetStatusAsync(employeeId, request.Status, ct); + return NoContent(); + } + + [HttpPost("{employeeId:int}/link-user")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task LinkUser(int employeeId, [FromBody] LinkUserRequest request, CancellationToken ct) + { + await _links.LinkAsync(employeeId, request.UserId, ct); + return NoContent(); + } + + [HttpDelete("{employeeId:int}/link-user")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UnlinkUser(int employeeId, CancellationToken ct) + { + await _links.UnlinkAsync(employeeId, ct); + return NoContent(); + } + + [HttpGet("{employeeId:int}/bank-details")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> ListBankDetails(int employeeId, CancellationToken ct) + => Ok(await _employees.ListBankDetailsAsync(employeeId, ct)); + + [HttpPut("{employeeId:int}/bank-details")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task>> ReplaceBankDetails( + int employeeId, [FromBody] ReplaceEmployeeBankDetailsRequest request, CancellationToken ct) + => Ok(await _employees.ReplaceBankDetailsAsync(employeeId, request, ct)); + + [HttpGet("{employeeId:int}/leave-balances")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct) + => Ok(await _leaveBalances.ListAsync(employeeId, year, ct)); + + [HttpPut("{employeeId:int}/leave-balances")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> UpdateLeaveBalances( + int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct) + => Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct)); + + [HttpGet("{employeeId:int}/salary-structure")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetSalaryStructureHistory(int employeeId, CancellationToken ct) + => Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct)); + + [HttpPost("{employeeId:int}/salary-structure")] + [ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> CreateSalaryStructure( + int employeeId, [FromBody] CreateSalaryStructureRequest request, CancellationToken ct) + { + var result = await _salaryStructures.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct); + return Created($"/api/v1/employees/{employeeId}/salary-structure", result); + } + + [HttpGet("{employeeId:int}/loans")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> ListLoans(int employeeId, CancellationToken ct) + => Ok(await _loans.ListAsync(employeeId, ct)); + + [HttpGet("{employeeId:int}/loans/{loanId:int}")] + [ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetLoan(int employeeId, int loanId, CancellationToken ct) + { + var result = await _loans.GetAsync(employeeId, loanId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost("{employeeId:int}/loans")] + [ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> CreateLoan(int employeeId, [FromBody] CreateEmployeeLoanRequest request, CancellationToken ct) + { + var result = await _loans.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct); + return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result); + } + + [HttpGet("{employeeId:int}/documents")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> ListDocuments(int employeeId, CancellationToken ct) + => Ok(await _documents.ListAsync(employeeId, ct)); + + [HttpPost("{employeeId:int}/documents")] + [RequestSizeLimit(20 * 1024 * 1024)] + [ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> UploadDocument( + int employeeId, [FromForm] UploadEmployeeDocumentRequest request, IFormFile file, CancellationToken ct) + { + await using var stream = file.OpenReadStream(); + var result = await _documents.UploadAsync( + employeeId, request, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct); + return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result); + } + + [HttpGet("{employeeId:int}/documents/{documentId:int}/download")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DownloadDocument(int employeeId, int documentId, CancellationToken ct) + { + var (content, fileName, contentType) = await _documents.DownloadAsync(employeeId, documentId, ct); + return File(content, contentType, fileName); + } + + [HttpPatch("{employeeId:int}/documents/{documentId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetDocumentStatus( + int employeeId, int documentId, [FromBody] UpdateEmployeeDocumentStatusRequest request, CancellationToken ct) + { + await _documents.SetStatusAsync(employeeId, documentId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs b/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs new file mode 100644 index 0000000..e80d878 --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs @@ -0,0 +1,64 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2). +[Route("api/v1/employment-types")] +public sealed class EmploymentTypesController : ApiControllerBase +{ + private readonly IEmploymentTypeService _employmentTypes; + + public EmploymentTypesController(IEmploymentTypeService employmentTypes) => _employmentTypes = employmentTypes; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _employmentTypes.ListAsync(query, status, ct)); + + [HttpGet("{employmentTypeId:int}")] + [ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int employmentTypeId, CancellationToken ct) + { + var result = await _employmentTypes.GetAsync(employmentTypeId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateEmploymentTypeRequest request, CancellationToken ct) + { + var result = await _employmentTypes.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value); + } + + [HttpPut("{employmentTypeId:int}")] + [ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int employmentTypeId, [FromBody] UpdateEmploymentTypeRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _employmentTypes.UpdateAsync(employmentTypeId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPatch("{employmentTypeId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int employmentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct) + { + await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs b/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs new file mode 100644 index 0000000..6b4b557 --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs @@ -0,0 +1,64 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2). +[Route("api/v1/hr-document-types")] +public sealed class HrDocumentTypesController : ApiControllerBase +{ + private readonly IHrDocumentTypeService _types; + + public HrDocumentTypesController(IHrDocumentTypeService types) => _types = types; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _types.ListAsync(query, status, ct)); + + [HttpGet("{hrDocumentTypeId:int}")] + [ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int hrDocumentTypeId, CancellationToken ct) + { + var result = await _types.GetAsync(hrDocumentTypeId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateHrDocumentTypeRequest request, CancellationToken ct) + { + var result = await _types.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value); + } + + [HttpPut("{hrDocumentTypeId:int}")] + [ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int hrDocumentTypeId, [FromBody] UpdateHrDocumentTypeRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _types.UpdateAsync(hrDocumentTypeId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPatch("{hrDocumentTypeId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int hrDocumentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct) + { + await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/Hrm/HrReportsController.cs b/Backend/ERPCore/Controllers/Hrm/HrReportsController.cs new file mode 100644 index 0000000..e139ee9 --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/HrReportsController.cs @@ -0,0 +1,52 @@ +using ERPCore.Dtos.Hrm; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables. +[Route("api/v1/reports/hrm")] +public sealed class HrReportsController : ApiControllerBase +{ + private readonly IHrReportService _reports; + + public HrReportsController(IHrReportService reports) => _reports = reports; + + [HttpGet("attendance-summary")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> AttendanceSummary( + [FromQuery] int periodYear, [FromQuery] int periodMonth, [FromQuery] int? departmentId, CancellationToken ct) + => Ok(await _reports.AttendanceSummaryAsync(periodYear, periodMonth, departmentId, ct)); + + [HttpGet("overtime")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> Overtime( + [FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct) + => Ok(await _reports.OvertimeReportAsync(periodYear, periodMonth, ct)); + + [HttpGet("late-arrivals")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> LateArrivals( + [FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct) + => Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct)); + + [HttpGet("payroll-register")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct) + => Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct)); + + [HttpGet("salary-history")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct) + => Ok(await _reports.SalaryHistoryAsync(employeeId, ct)); + + [HttpGet("leave-balances")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> LeaveBalances([FromQuery] int year, CancellationToken ct) + => Ok(await _reports.LeaveBalanceReportAsync(year, ct)); + + [HttpGet("document-expiry")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> DocumentExpiry([FromQuery] int withinDays, CancellationToken ct) + => Ok(await _reports.DocumentExpiryReportAsync(withinDays, ct)); +} diff --git a/Backend/ERPCore/Controllers/Hrm/LeaveRequestsController.cs b/Backend/ERPCore/Controllers/Hrm/LeaveRequestsController.cs new file mode 100644 index 0000000..7b6c1fc --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/LeaveRequestsController.cs @@ -0,0 +1,71 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.Auth; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Leave request endpoints (docs/13-BACKEND-HRM-API.md §5). +[Route("api/v1/leave-requests")] +public sealed class LeaveRequestsController : ApiControllerBase +{ + private readonly ILeaveRequestService _requests; + private readonly ICurrentUser _currentUser; + + public LeaveRequestsController(ILeaveRequestService requests, ICurrentUser currentUser) + { + _requests = requests; + _currentUser = currentUser; + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] int? employeeId, [FromQuery] LeaveRequestStatus? status, CancellationToken ct) + => Ok(await _requests.ListAsync(query, employeeId, status, ct)); + + [HttpGet("{leaveRequestId:int}")] + [ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int leaveRequestId, CancellationToken ct) + { + var result = await _requests.GetAsync(leaveRequestId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + [ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateLeaveRequestRequest request, CancellationToken ct) + { + var result = await _requests.CreateAsync(request, _currentUser.AuditUserId, ct); + return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result); + } + + [HttpPost("{leaveRequestId:int}/submit")] + [ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Submit(int leaveRequestId, CancellationToken ct) + => Ok(await _requests.SubmitAsync(leaveRequestId, ct)); + + [HttpPost("{leaveRequestId:int}/approve")] + [ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Approve(int leaveRequestId, CancellationToken ct) + => Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct)); + + [HttpPost("{leaveRequestId:int}/reject")] + [ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct) + => Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct)); + + [HttpPost("{leaveRequestId:int}/cancel")] + [ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Cancel(int leaveRequestId, CancellationToken ct) + => Ok(await _requests.CancelAsync(leaveRequestId, ct)); +} diff --git a/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs b/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs new file mode 100644 index 0000000..9f28099 --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs @@ -0,0 +1,64 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5). +[Route("api/v1/leave-types")] +public sealed class LeaveTypesController : ApiControllerBase +{ + private readonly ILeaveTypeService _leaveTypes; + + public LeaveTypesController(ILeaveTypeService leaveTypes) => _leaveTypes = leaveTypes; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _leaveTypes.ListAsync(query, status, ct)); + + [HttpGet("{leaveTypeId:int}")] + [ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int leaveTypeId, CancellationToken ct) + { + var result = await _leaveTypes.GetAsync(leaveTypeId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateLeaveTypeRequest request, CancellationToken ct) + { + var result = await _leaveTypes.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value); + } + + [HttpPut("{leaveTypeId:int}")] + [ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int leaveTypeId, [FromBody] UpdateLeaveTypeRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _leaveTypes.UpdateAsync(leaveTypeId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPatch("{leaveTypeId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int leaveTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct) + { + await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/Hrm/PayrollRunsController.cs b/Backend/ERPCore/Controllers/Hrm/PayrollRunsController.cs new file mode 100644 index 0000000..d2bc6c2 --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/PayrollRunsController.cs @@ -0,0 +1,85 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.Auth; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6). +[Route("api/v1/payroll-runs")] +public sealed class PayrollRunsController : ApiControllerBase +{ + private readonly IPayrollRunService _payrollRuns; + private readonly ICurrentUser _currentUser; + + public PayrollRunsController(IPayrollRunService payrollRuns, ICurrentUser currentUser) + { + _payrollRuns = payrollRuns; + _currentUser = currentUser; + } + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] int? periodYear, [FromQuery] int? periodMonth, + [FromQuery] PayrollRunStatus? status, CancellationToken ct) + => Ok(await _payrollRuns.ListAsync(query, periodYear, periodMonth, status, ct)); + + [HttpGet("{payrollRunId:int}")] + [ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int payrollRunId, CancellationToken ct) + { + var result = await _payrollRuns.GetAsync(payrollRunId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{payrollRunId:int}/lines")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> ListLines(int payrollRunId, CancellationToken ct) + => Ok(await _payrollRuns.ListLinesAsync(payrollRunId, ct)); + + [HttpGet("{payrollRunId:int}/lines/{lineId:int}")] + [ProducesResponseType(typeof(PayrollLineDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetLine(int payrollRunId, int lineId, CancellationToken ct) + { + var result = await _payrollRuns.GetLineAsync(payrollRunId, lineId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + [ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Generate([FromBody] GeneratePayrollRunRequest request, CancellationToken ct) + { + var result = await _payrollRuns.GenerateAsync(request, _currentUser.AuditUserId, ct); + return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result); + } + + [HttpPost("{payrollRunId:int}/approve")] + [ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Approve(int payrollRunId, CancellationToken ct) + => Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct)); + + [HttpPost("{payrollRunId:int}/lock")] + [ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Lock(int payrollRunId, CancellationToken ct) + => Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct)); + + [HttpPost("{payrollRunId:int}/unlock")] + [ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct) + => Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct)); + + [HttpPost("{payrollRunId:int}/generate-payslips")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task>> GeneratePayslips(int payrollRunId, CancellationToken ct) + => Ok(await _payrollRuns.GeneratePayslipsAsync(payrollRunId, ct)); +} diff --git a/Backend/ERPCore/Controllers/Hrm/PayrollStatutorySettingsController.cs b/Backend/ERPCore/Controllers/Hrm/PayrollStatutorySettingsController.cs new file mode 100644 index 0000000..15dac2f --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/PayrollStatutorySettingsController.cs @@ -0,0 +1,33 @@ +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.Auth; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6). +[Route("api/v1/payroll-statutory-settings")] +public sealed class PayrollStatutorySettingsController : ApiControllerBase +{ + private readonly IPayrollStatutorySettingService _settings; + private readonly ICurrentUser _currentUser; + + public PayrollStatutorySettingsController(IPayrollStatutorySettingService settings, ICurrentUser currentUser) + { + _settings = settings; + _currentUser = currentUser; + } + + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> List(CancellationToken ct) + => Ok(await _settings.ListAsync(ct)); + + [HttpPost] + [ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)] + public async Task> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct) + { + var result = await _settings.CreateAsync(request, _currentUser.AuditUserId, ct); + return Created($"/api/v1/payroll-statutory-settings/{result.PayrollStatutorySettingId}", result); + } +} diff --git a/Backend/ERPCore/Controllers/Hrm/PayslipsController.cs b/Backend/ERPCore/Controllers/Hrm/PayslipsController.cs new file mode 100644 index 0000000..4163d81 --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/PayslipsController.cs @@ -0,0 +1,32 @@ +using ERPCore.Dtos.Hrm; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6). +[Route("api/v1/payslips")] +public sealed class PayslipsController : ApiControllerBase +{ + private readonly IPayslipService _payslips; + + public PayslipsController(IPayslipService payslips) => _payslips = payslips; + + [HttpGet("{payslipId:int}")] + [ProducesResponseType(typeof(PayslipDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int payslipId, CancellationToken ct) + { + var result = await _payslips.GetAsync(payslipId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{payslipId:int}/view")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task View(int payslipId, CancellationToken ct) + { + var html = await _payslips.RenderHtmlAsync(payslipId, ct); + return html is null ? NotFound() : Content(html, "text/html"); + } +} diff --git a/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs b/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs new file mode 100644 index 0000000..d8108cd --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs @@ -0,0 +1,64 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6). +[Route("api/v1/salary-components")] +public sealed class SalaryComponentsController : ApiControllerBase +{ + private readonly ISalaryComponentService _components; + + public SalaryComponentsController(ISalaryComponentService components) => _components = components; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _components.ListAsync(query, status, ct)); + + [HttpGet("{salaryComponentId:int}")] + [ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int salaryComponentId, CancellationToken ct) + { + var result = await _components.GetAsync(salaryComponentId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateSalaryComponentRequest request, CancellationToken ct) + { + var result = await _components.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value); + } + + [HttpPut("{salaryComponentId:int}")] + [ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int salaryComponentId, [FromBody] UpdateSalaryComponentRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _components.UpdateAsync(salaryComponentId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPatch("{salaryComponentId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int salaryComponentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct) + { + await _components.SetStatusAsync(salaryComponentId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/Hrm/TaxSlabsController.cs b/Backend/ERPCore/Controllers/Hrm/TaxSlabsController.cs new file mode 100644 index 0000000..df8aec4 --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/TaxSlabsController.cs @@ -0,0 +1,28 @@ +using ERPCore.Dtos.Hrm; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6). +[Route("api/v1/tax-slabs")] +public sealed class TaxSlabsController : ApiControllerBase +{ + private readonly ITaxSlabService _taxSlabs; + + public TaxSlabsController(ITaxSlabService taxSlabs) => _taxSlabs = taxSlabs; + + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> List(CancellationToken ct) + => Ok(await _taxSlabs.ListAsync(ct)); + + [HttpPost] + [ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateTaxSlabRequest request, CancellationToken ct) + { + var result = await _taxSlabs.CreateAsync(request, ct); + return Created($"/api/v1/tax-slabs/{result.TaxSlabId}", result); + } +} diff --git a/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs b/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs new file mode 100644 index 0000000..88148d9 --- /dev/null +++ b/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs @@ -0,0 +1,64 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers.Hrm; + +/// WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2). +[Route("api/v1/work-shifts")] +public sealed class WorkShiftsController : ApiControllerBase +{ + private readonly IWorkShiftService _shifts; + + public WorkShiftsController(IWorkShiftService shifts) => _shifts = shifts; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) + => Ok(await _shifts.ListAsync(query, status, ct)); + + [HttpGet("{workShiftId:int}")] + [ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int workShiftId, CancellationToken ct) + { + var result = await _shifts.GetAsync(workShiftId, ct); + if (result is null) return NotFound(); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPost] + [ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateWorkShiftRequest request, CancellationToken ct) + { + var result = await _shifts.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value); + } + + [HttpPut("{workShiftId:int}")] + [ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int workShiftId, [FromBody] UpdateWorkShiftRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _shifts.UpdateAsync(workShiftId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + [HttpPatch("{workShiftId:int}/status")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetStatus(int workShiftId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct) + { + await _shifts.SetStatusAsync(workShiftId, request.Status, ct); + return NoContent(); + } +} diff --git a/Backend/ERPCore/Controllers/UsersController.cs b/Backend/ERPCore/Controllers/UsersController.cs index 60e2fe3..bb1e3cf 100644 --- a/Backend/ERPCore/Controllers/UsersController.cs +++ b/Backend/ERPCore/Controllers/UsersController.cs @@ -1,4 +1,5 @@ using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; using ERPCore.Dtos.Users; using ERPCore.Services.Interfaces; using Microsoft.AspNetCore.Mvc; @@ -13,8 +14,19 @@ namespace ERPCore.Controllers; public sealed class UsersController : ApiControllerBase { private readonly IUserManagementService _users; + private readonly IEmployeeUserLinkService _links; - public UsersController(IUserManagementService users) => _users = users; + public UsersController(IUserManagementService users, IEmployeeUserLinkService links) + { + _users = users; + _links = links; + } + + /// Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5) + [HttpGet("email-lookup")] + [ProducesResponseType(typeof(EmployeeMatchResponse), StatusCodes.Status200OK)] + public async Task> EmailLookup([FromQuery] string email, CancellationToken ct) + => Ok(new EmployeeMatchResponse(await _links.FindStaffCandidateByEmailAsync(email, ct))); [HttpGet] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Domain/Entities/AttendanceRecord.cs b/Backend/ERPCore/Domain/Entities/AttendanceRecord.cs new file mode 100644 index 0000000..5e4a890 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/AttendanceRecord.cs @@ -0,0 +1,40 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Per employee/day attendance row (FR-HR-ATT). is +/// snapshotted from the employee's shift at ingestion time (docs/12-BACKEND-HRM.md +/// A.3) so a later shift reassignment never retroactively changes historical +/// Late/OT figures. Model: docs/12-BACKEND-HRM.md Part C.4. +/// +public class AttendanceRecord +{ + public int AttendanceRecordId { get; set; } + public int? AttendanceUploadBatchId { get; set; } + public AttendanceUploadBatch? AttendanceUploadBatch { get; set; } + public int EmployeeId { get; set; } + public Employee? Employee { get; set; } + + public DateTime AttendanceDate { get; set; } + public TimeSpan? CheckIn { get; set; } + public TimeSpan? CheckOut { get; set; } + public int WorkShiftId { get; set; } + public WorkShift? WorkShift { get; set; } + + public int WorkingMinutes { get; set; } + public int LateMinutes { get; set; } + public int EarlyLeaveMinutes { get; set; } + public int OvertimeMinutes { get; set; } + + public AttendanceStatus AttendanceStatus { get; set; } + public RowValidationStatus RowValidationStatus { get; set; } = RowValidationStatus.Valid; + public int? DuplicateOfAttendanceRecordId { get; set; } + + public string? Notes { get; set; } + public bool IsManualOverride { get; set; } + public int? EditedBy { get; set; } + public DateTime? EditedAt { get; set; } + + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/AttendanceUploadBatch.cs b/Backend/ERPCore/Domain/Entities/AttendanceUploadBatch.cs new file mode 100644 index 0000000..8e7dd32 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/AttendanceUploadBatch.cs @@ -0,0 +1,31 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Attendance upload batch (FR-HR-ATT) — the transactional document driving the +/// exact status flow Draft→Validated→Confirmed→UsedInPayroll. Scoped to exactly +/// one payroll period, numbered via (docType "ATT"). +/// Model: docs/12-BACKEND-HRM.md Part C.4. +/// +public class AttendanceUploadBatch +{ + public int AttendanceUploadBatchId { get; set; } + public string DocNo { get; set; } = string.Empty; + public DateTime PeriodStart { get; set; } + public DateTime PeriodEnd { get; set; } + public AttendanceSourceType SourceType { get; set; } + public string? OriginalFileName { get; set; } + + public int UploadedBy { get; set; } + public DateTime UploadedAt { get; set; } + public AttendanceBatchStatus Status { get; set; } = AttendanceBatchStatus.Draft; + public int? ConfirmedBy { get; set; } + public DateTime? ConfirmedAt { get; set; } + + public int RowCountTotal { get; set; } + public int RowCountDuplicate { get; set; } + public int RowCountError { get; set; } + + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Branch.cs b/Backend/ERPCore/Domain/Entities/Branch.cs new file mode 100644 index 0000000..7a71081 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Branch.cs @@ -0,0 +1,23 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced +/// optionally by and . +/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1. +/// +public class Branch +{ + public int BranchId { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Address { get; set; } + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Department.cs b/Backend/ERPCore/Domain/Entities/Department.cs new file mode 100644 index 0000000..3f8868b --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Department.cs @@ -0,0 +1,26 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Department master (FR-HR-MD-01) — unlimited self-nesting for a real org chart +/// (unlike the two-level-capped ); cycle prevention is a +/// service-level check on write, not a DB constraint. Model: docs/12-BACKEND-HRM.md Part C.1. +/// +public class Department +{ + public int DepartmentId { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public int? ParentDepartmentId { get; set; } + public Department? ParentDepartment { get; set; } + public int? HeadEmployeeId { get; set; } + public Employee? HeadEmployee { get; set; } + public int? BranchId { get; set; } + public Branch? Branch { get; set; } + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Designation.cs b/Backend/ERPCore/Domain/Entities/Designation.cs new file mode 100644 index 0000000..6995027 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Designation.cs @@ -0,0 +1,19 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Job title master (FR-HR-MD-01), standalone — not FK'd to Department, since a +/// title like "Accountant" can exist in multiple departments. Model: docs/12-BACKEND-HRM.md Part C.1. +/// +public class Designation +{ + public int DesignationId { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Employee.cs b/Backend/ERPCore/Domain/Entities/Employee.cs new file mode 100644 index 0000000..2248904 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Employee.cs @@ -0,0 +1,76 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Staff record (FR-HR-MD-02) — distinct from (the system login +/// account): not every employee has a login, and not every login belongs to an +/// employee. is the optional, explicit, human-confirmed link +/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted — +/// separation is recorded via + . +/// is user-entered (not -issued): +/// HR departments keep their own legacy numbering scheme, and NumberSequence's +/// year-scoping is the wrong shape for an identifier that must never look "reset". +/// +public class Employee +{ + public int EmployeeId { get; set; } + public string EmployeeCode { get; set; } = string.Empty; + + // Identity + public string FullName { get; set; } = string.Empty; + public string? Nic { get; set; } + public DateTime? DateOfBirth { get; set; } + public Gender? Gender { get; set; } + public string? Nationality { get; set; } + public string? ProfilePhotoPath { get; set; } + + // Contact + /// The field used for the bidirectional Employee<->User email cross-check. + public string? Email { get; set; } + public string? PersonalMobile { get; set; } + public string? AddressLine1 { get; set; } + public string? AddressLine2 { get; set; } + public string? City { get; set; } + public string? PostalCode { get; set; } + public string? Country { get; set; } + + // Emergency contact + public string? EmergencyContactName { get; set; } + public string? EmergencyContactRelationship { get; set; } + public string? EmergencyContactPhone { get; set; } + + // Employment + public DateTime HireDate { get; set; } + public DateTime? ConfirmationDate { get; set; } + public DateTime? LastWorkingDate { get; set; } + public int DepartmentId { get; set; } + public Department? Department { get; set; } + public int DesignationId { get; set; } + public Designation? Designation { get; set; } + public int EmploymentTypeId { get; set; } + public EmploymentType? EmploymentType { get; set; } + public int? BranchId { get; set; } + public Branch? Branch { get; set; } + public int WorkShiftId { get; set; } + public WorkShift? WorkShift { get; set; } + public int? ReportingManagerId { get; set; } + public Employee? ReportingManager { get; set; } + + // Statutory (Sri Lanka) + public string? EpfNumber { get; set; } + public string? EtfNumber { get; set; } + public string? TaxIdentificationNumber { get; set; } + + /// Optional login account link (unique — one User backs at most one Employee). + public int? UserId { get; set; } + public User? User { get; set; } + + public EmployeeStatus Status { get; set; } = EmployeeStatus.Active; + + public int CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public int? UpdatedBy { get; set; } + public DateTime? UpdatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/EmployeeBankDetail.cs b/Backend/ERPCore/Domain/Entities/EmployeeBankDetail.cs new file mode 100644 index 0000000..488c23f --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/EmployeeBankDetail.cs @@ -0,0 +1,28 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Employee bank account (FR-HR-MD-03), one-to-many — a future split-payment +/// improvement is possible since this isn't a 1:1 scalar set. Exactly one row per +/// employee is ; payroll disbursement targets it. +/// Model: docs/12-BACKEND-HRM.md Part C.2. +/// +public class EmployeeBankDetail +{ + public int EmployeeBankDetailId { get; set; } + public int EmployeeId { get; set; } + public Employee? Employee { get; set; } + + public string BankName { get; set; } = string.Empty; + public string BranchName { get; set; } = string.Empty; + public string AccountNumber { get; set; } = string.Empty; + public string AccountHolderName { get; set; } = string.Empty; + public string? SwiftCode { get; set; } + public bool IsPrimary { get; set; } + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/EmployeeDocument.cs b/Backend/ERPCore/Domain/Entities/EmployeeDocument.cs new file mode 100644 index 0000000..45f0727 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/EmployeeDocument.cs @@ -0,0 +1,36 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Uploaded staff document (FR-HR-DOC-02..04) — the user's "Doc". / +/// are server-generated (never the client's filename), so the +/// file is only ever reachable through , +/// never a guessable static path. Archived, not deleted, so the audit trail of what was +/// once on file is retained. Model: docs/12-BACKEND-HRM.md Part C.3. +/// +public class EmployeeDocument +{ + public int EmployeeDocumentId { get; set; } + public int EmployeeId { get; set; } + public Employee? Employee { get; set; } + public int HrDocumentTypeId { get; set; } + public HrDocumentType? HrDocumentType { get; set; } + + public string OriginalFileName { get; set; } = string.Empty; + public string StoredFileName { get; set; } = string.Empty; + public string RelativePath { get; set; } = string.Empty; + public string ContentType { get; set; } = string.Empty; + public long SizeBytes { get; set; } + public DateTime? IssueDate { get; set; } + public DateTime? ExpiryDate { get; set; } + public string? Notes { get; set; } + + public int UploadedBy { get; set; } + public DateTime UploadedAt { get; set; } + public int? VerifiedBy { get; set; } + public DateTime? VerifiedAt { get; set; } + + public EmployeeDocumentStatus Status { get; set; } = EmployeeDocumentStatus.Active; + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/EmployeeLoan.cs b/Backend/ERPCore/Domain/Entities/EmployeeLoan.cs new file mode 100644 index 0000000..e00c9b9 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/EmployeeLoan.cs @@ -0,0 +1,35 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Loan/Advance (FR-HR-PAY-03) — discriminates, structurally +/// identical otherwise. is denormalized (parallel to +/// StockLayer.QtyRemaining). Numbered via (docType "LOAN"). +/// Model: docs/12-BACKEND-HRM.md Part C.6. +/// +public class EmployeeLoan +{ + public int EmployeeLoanId { get; set; } + public string DocNo { get; set; } = string.Empty; + public int EmployeeId { get; set; } + public Employee? Employee { get; set; } + public LoanKind LoanKind { get; set; } + + public decimal PrincipalAmount { get; set; } + public decimal InterestRate { get; set; } + public decimal InstallmentAmount { get; set; } + public int NumberOfInstallments { get; set; } + public int StartYear { get; set; } + public int StartMonth { get; set; } + public decimal OutstandingBalance { get; set; } + public LoanStatus Status { get; set; } = LoanStatus.Active; + + public int ApprovedBy { get; set; } + public DateTime ApprovedAt { get; set; } + public int CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public uint RowVersion { get; set; } + + public List Installments { get; set; } = new(); +} diff --git a/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructure.cs b/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructure.cs new file mode 100644 index 0000000..dcdd914 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructure.cs @@ -0,0 +1,30 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Effective-dated salary structure header (FR-HR-PAY-02) — the audit trail a +/// salary revision needs (docs/12-BACKEND-HRM.md §13): exactly one row with +/// null (the current one) per employee at a time. +/// Model: docs/12-BACKEND-HRM.md Part C.6. +/// +public class EmployeeSalaryStructure +{ + public int EmployeeSalaryStructureId { get; set; } + public int EmployeeId { get; set; } + public Employee? Employee { get; set; } + + public DateTime EffectiveFrom { get; set; } + public DateTime? EffectiveTo { get; set; } + public decimal BasicSalary { get; set; } + public string Currency { get; set; } = "LKR"; + public SalaryStructureStatus Status { get; set; } = SalaryStructureStatus.Active; + + public int ApprovedBy { get; set; } + public DateTime ApprovedAt { get; set; } + public int CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public uint RowVersion { get; set; } + + public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructureLine.cs b/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructureLine.cs new file mode 100644 index 0000000..457b0e7 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructureLine.cs @@ -0,0 +1,12 @@ +namespace ERPCore.Domain.Entities; + +/// Allowance/other-deduction line on a salary structure. Model: docs/12-BACKEND-HRM.md Part C.6. +public class EmployeeSalaryStructureLine +{ + public int EmployeeSalaryStructureLineId { get; set; } + public int EmployeeSalaryStructureId { get; set; } + public EmployeeSalaryStructure? EmployeeSalaryStructure { get; set; } + public int SalaryComponentId { get; set; } + public SalaryComponent? SalaryComponent { get; set; } + public decimal Amount { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/EmploymentType.cs b/Backend/ERPCore/Domain/Entities/EmploymentType.cs new file mode 100644 index 0000000..48ad99a --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/EmploymentType.cs @@ -0,0 +1,20 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Labor category master (FR-HR-MD-01) — a master, not an enum, mirroring +/// : employment categories change with company/labor-law +/// policy without wanting a code deploy. Model: docs/12-BACKEND-HRM.md Part C.1. +/// +public class EmploymentType +{ + public int EmploymentTypeId { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/HrDocumentType.cs b/Backend/ERPCore/Domain/Entities/HrDocumentType.cs new file mode 100644 index 0000000..f60a71c --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/HrDocumentType.cs @@ -0,0 +1,24 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Staff document catalog (FR-HR-DOC-01) — the user's "DocType": a category of +/// document (NIC, contract, certificate...), not the uploaded file itself (see +/// , the "Doc"). Deactivated, not deleted, when +/// referenced. Model: docs/12-BACKEND-HRM.md Part C.3. +/// +public class HrDocumentType +{ + public int HrDocumentTypeId { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public HrDocumentCategory Category { get; set; } + public bool RequiredAtOnboarding { get; set; } + public bool ExpiryTracked { get; set; } + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/LeaveBalance.cs b/Backend/ERPCore/Domain/Entities/LeaveBalance.cs new file mode 100644 index 0000000..9061ba0 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/LeaveBalance.cs @@ -0,0 +1,24 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Per employee/type/year leave entitlement (FR-HR-LV-03). Unique on +/// (EmployeeId, LeaveTypeId, Year). RemainingDays is a computed projection, not +/// stored. Model: docs/12-BACKEND-HRM.md Part C.5. +/// +public class LeaveBalance +{ + public int LeaveBalanceId { get; set; } + public int EmployeeId { get; set; } + public Employee? Employee { get; set; } + public int LeaveTypeId { get; set; } + public LeaveType? LeaveType { get; set; } + public int Year { get; set; } + + public decimal EntitledDays { get; set; } + public decimal TakenDays { get; set; } + public decimal CarriedForwardDays { get; set; } + public decimal AdjustmentDays { get; set; } + + public uint RowVersion { get; set; } + public DateTime? UpdatedAt { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/LeaveRequest.cs b/Backend/ERPCore/Domain/Entities/LeaveRequest.cs new file mode 100644 index 0000000..2467daa --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/LeaveRequest.cs @@ -0,0 +1,31 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Leave request (FR-HR-LV-02) — transactional document, numbered via +/// (docType "LV"). Model: docs/12-BACKEND-HRM.md Part C.5. +/// +public class LeaveRequest +{ + public int LeaveRequestId { get; set; } + public string DocNo { get; set; } = string.Empty; + public int EmployeeId { get; set; } + public Employee? Employee { get; set; } + public int LeaveTypeId { get; set; } + public LeaveType? LeaveType { get; set; } + + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public decimal DaysCount { get; set; } + public string? Reason { get; set; } + + public LeaveRequestStatus Status { get; set; } = LeaveRequestStatus.Draft; + public int? ApprovedBy { get; set; } + public DateTime? ApprovedAt { get; set; } + public string? RejectionReason { get; set; } + + public int CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/LeaveType.cs b/Backend/ERPCore/Domain/Entities/LeaveType.cs new file mode 100644 index 0000000..f489ecf --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/LeaveType.cs @@ -0,0 +1,23 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// Leave type master (FR-HR-LV-01). Model: docs/12-BACKEND-HRM.md Part C.5. +public class LeaveType +{ + public int LeaveTypeId { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public bool IsPaid { get; set; } = true; + /// Feeds Payroll's No-Pay deduction when true (docs/12-BACKEND-HRM.md §6). + public bool CountsAsNoPay { get; set; } + public decimal AccrualPerYear { get; set; } + public bool CarryForwardAllowed { get; set; } + public int? MaxCarryForwardDays { get; set; } + public bool RequiresApproval { get; set; } = true; + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/LoanInstallment.cs b/Backend/ERPCore/Domain/Entities/LoanInstallment.cs new file mode 100644 index 0000000..0d3187c --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/LoanInstallment.cs @@ -0,0 +1,26 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Loan installment ledger row. is stamped only when the +/// consuming reaches Locked (docs/12-BACKEND-HRM.md A.4). +/// Model: docs/12-BACKEND-HRM.md Part C.6. +/// +public class LoanInstallment +{ + public int LoanInstallmentId { get; set; } + public int EmployeeLoanId { get; set; } + public EmployeeLoan? EmployeeLoan { get; set; } + + public int InstallmentNumber { get; set; } + public int DueYear { get; set; } + public int DueMonth { get; set; } + public decimal ScheduledAmount { get; set; } + public decimal? PaidAmount { get; set; } + public int? PayrollRunId { get; set; } + public PayrollRun? PayrollRun { get; set; } + public LoanInstallmentStatus Status { get; set; } = LoanInstallmentStatus.Pending; + + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/PayrollLine.cs b/Backend/ERPCore/Domain/Entities/PayrollLine.cs new file mode 100644 index 0000000..40e2df9 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/PayrollLine.cs @@ -0,0 +1,41 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Per-employee payroll summary row (FR-HR-PAY-05). EpfEmployerAmount/EtfEmployerAmount +/// are informational/liability only, never subtracted from NetSalary +/// (docs/12-BACKEND-HRM.md B.4). Model: docs/12-BACKEND-HRM.md Part C.6. +/// +public class PayrollLine +{ + public int PayrollLineId { get; set; } + public int PayrollRunId { get; set; } + public PayrollRun? PayrollRun { get; set; } + public int EmployeeId { get; set; } + public Employee? Employee { get; set; } + + public decimal BasicSalary { get; set; } + public decimal TotalAllowances { get; set; } + public decimal OvertimeAmount { get; set; } + public decimal GrossSalary { get; set; } + + public decimal LateDeductionAmount { get; set; } + public decimal NoPayAmount { get; set; } + public decimal LoanDeductionAmount { get; set; } + public decimal EpfEmployeeAmount { get; set; } + public decimal EpfEmployerAmount { get; set; } + public decimal EtfEmployerAmount { get; set; } + public decimal TaxAmount { get; set; } + public decimal OtherDeductionsAmount { get; set; } + public decimal NetSalary { get; set; } + + public int WorkingDays { get; set; } + public int PresentDays { get; set; } + public int AbsentDays { get; set; } + public int LeaveDays { get; set; } + public int OtMinutesTotal { get; set; } + public int LateMinutesTotal { get; set; } + + public uint RowVersion { get; set; } + + public List Components { get; set; } = new(); +} diff --git a/Backend/ERPCore/Domain/Entities/PayrollLineComponent.cs b/Backend/ERPCore/Domain/Entities/PayrollLineComponent.cs new file mode 100644 index 0000000..4b9d13c --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/PayrollLineComponent.cs @@ -0,0 +1,18 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// The detailed Basic/Transport/Meal/OT/Late/No-Pay/Loan/EPF/ETF/Tax breakdown. Model: docs/12-BACKEND-HRM.md Part C.6. +public class PayrollLineComponent +{ + public int PayrollLineComponentId { get; set; } + public int PayrollLineId { get; set; } + public PayrollLine? PayrollLine { get; set; } + public PayrollLineComponentCategory ComponentCategory { get; set; } + /// Set only for structure-sourced Allowance/OtherDeduction lines; null for system-computed lines. + public int? SalaryComponentId { get; set; } + public SalaryComponent? SalaryComponent { get; set; } + public string Label { get; set; } = string.Empty; + public decimal Amount { get; set; } + public int SortOrder { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/PayrollRun.cs b/Backend/ERPCore/Domain/Entities/PayrollRun.cs new file mode 100644 index 0000000..f180d1f --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/PayrollRun.cs @@ -0,0 +1,33 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Payroll run (FR-HR-PAY-05/06) — the transactional document. Numbered via +/// (docType "PAY"). Model: docs/12-BACKEND-HRM.md Part C.6. +/// +public class PayrollRun +{ + public int PayrollRunId { get; set; } + public string DocNo { get; set; } = string.Empty; + public int PeriodYear { get; set; } + public int PeriodMonth { get; set; } + /// Null = company-wide run. + public int? BranchId { get; set; } + public Branch? Branch { get; set; } + public PayrollRunStatus Status { get; set; } = PayrollRunStatus.Draft; + + public int GeneratedBy { get; set; } + public DateTime GeneratedAt { get; set; } + public int? ApprovedBy { get; set; } + public DateTime? ApprovedAt { get; set; } + public int? LockedBy { get; set; } + public DateTime? LockedAt { get; set; } + public int? UnlockedBy { get; set; } + public DateTime? UnlockedAt { get; set; } + public string? UnlockReason { get; set; } + + public uint RowVersion { get; set; } + + public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Domain/Entities/PayrollStatutorySetting.cs b/Backend/ERPCore/Domain/Entities/PayrollStatutorySetting.cs new file mode 100644 index 0000000..769e660 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/PayrollStatutorySetting.cs @@ -0,0 +1,21 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Effective-dated EPF/ETF rates (FR-HR-PAY-04) — Sri Lanka defaults (EPF 8% +/// employee / 12% employer, ETF 3% employer-only), configurable since government +/// rates can change. Model: docs/12-BACKEND-HRM.md Part C.6. +/// +public class PayrollStatutorySetting +{ + public int PayrollStatutorySettingId { get; set; } + public decimal EpfEmployeeRate { get; set; } = 0.08m; + public decimal EpfEmployerRate { get; set; } = 0.12m; + public decimal EtfEmployerRate { get; set; } = 0.03m; + public decimal OtMultiplierDefault { get; set; } = 1.5m; + public DateTime EffectiveFrom { get; set; } + public DateTime? EffectiveTo { get; set; } + + public int CreatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Payslip.cs b/Backend/ERPCore/Domain/Entities/Payslip.cs new file mode 100644 index 0000000..15ca156 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Payslip.cs @@ -0,0 +1,16 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Thin generation/release marker over a — ships as an +/// HTML print view in this phase, per the confirmed decision (no PDF dependency). +/// Model: docs/12-BACKEND-HRM.md Part C.6. +/// +public class Payslip +{ + public int PayslipId { get; set; } + public int PayrollLineId { get; set; } + public PayrollLine? PayrollLine { get; set; } + public DateTime GeneratedAt { get; set; } + public DateTime? ReleasedAt { get; set; } + public int? ReleasedBy { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SalaryComponent.cs b/Backend/ERPCore/Domain/Entities/SalaryComponent.cs new file mode 100644 index 0000000..1f36147 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalaryComponent.cs @@ -0,0 +1,19 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// Allowance/ad hoc deduction master (FR-HR-PAY-01). Model: docs/12-BACKEND-HRM.md Part C.6. +public class SalaryComponent +{ + public int SalaryComponentId { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public SalaryComponentType ComponentType { get; set; } + public bool IsTaxable { get; set; } + public bool IsEpfEtfApplicable { get; set; } + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/TaxSlab.cs b/Backend/ERPCore/Domain/Entities/TaxSlab.cs new file mode 100644 index 0000000..267418a --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/TaxSlab.cs @@ -0,0 +1,19 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Configurable APIT-style marginal tax slab (FR-HR-PAY-04) — government slabs +/// change with the yearly budget, so this is never hardcoded. +/// null means "and above". Model: docs/12-BACKEND-HRM.md Part C.6. +/// +public class TaxSlab +{ + public int TaxSlabId { get; set; } + public DateTime EffectiveFrom { get; set; } + public DateTime? EffectiveTo { get; set; } + public decimal LowerBound { get; set; } + public decimal? UpperBound { get; set; } + public decimal Rate { get; set; } + + public DateTime CreatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/User.cs b/Backend/ERPCore/Domain/Entities/User.cs index 45ad625..aaec55d 100644 --- a/Backend/ERPCore/Domain/Entities/User.cs +++ b/Backend/ERPCore/Domain/Entities/User.cs @@ -23,6 +23,14 @@ public class User /// AuthHex identity (token UserId GUID); null for the seeded system user. public Guid? AuthUserId { get; set; } + /// + /// Mirrors the AuthHex identity's email (backfilled at create time by + /// UsersController.Create, best-effort by JIT provisioning otherwise). + /// Used only for the Employee<->User cross-link soft match (docs/12-BACKEND-HRM.md + /// A.5) — never for authentication, which stays AuthHex's responsibility. + /// + public string? Email { get; set; } + /// Local shadow assignment; null until an admin assigns one. public int? RoleId { get; set; } public Role? Role { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/WorkShift.cs b/Backend/ERPCore/Domain/Entities/WorkShift.cs new file mode 100644 index 0000000..4897a69 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/WorkShift.cs @@ -0,0 +1,32 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Attendance baseline (FR-HR-MD-01) — the shift definition Late/Early/OT figures +/// are computed against (docs/12-BACKEND-HRM.md A.3, C.1). +/// is explicit rather than inferred from End<Start, since that comparison alone +/// is ambiguous for a shift that starts and ends the same clock time next day. +/// +public class WorkShift +{ + public int WorkShiftId { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public TimeSpan StartTime { get; set; } + public TimeSpan EndTime { get; set; } + public bool IsOvernight { get; set; } + public int GraceMinutes { get; set; } = 15; + public int BreakMinutes { get; set; } = 60; + public int StandardWorkingMinutes { get; set; } = 480; + public decimal OtMultiplier { get; set; } = 1.5m; + + /// Bitmask, bit 0 = Monday .. bit 6 = Sunday. + public int WorkingDaysMask { get; set; } = 0b0111111; + + public EntityStatus Status { get; set; } = EntityStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Enums/AttendanceBatchStatus.cs b/Backend/ERPCore/Domain/Enums/AttendanceBatchStatus.cs new file mode 100644 index 0000000..40e7e1f --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/AttendanceBatchStatus.cs @@ -0,0 +1,15 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Attendance upload batch lifecycle (FR-HR-ATT, docs/12-BACKEND-HRM.md C.4) — +/// exactly the flow specified by the business: once it +/// becomes payroll's source of truth; once it is +/// immutable even to Unlock (a payroll run must be unlocked/regenerated first). +/// +public enum AttendanceBatchStatus +{ + Draft, + Validated, + Confirmed, + UsedInPayroll +} diff --git a/Backend/ERPCore/Domain/Enums/AttendanceSourceType.cs b/Backend/ERPCore/Domain/Enums/AttendanceSourceType.cs new file mode 100644 index 0000000..7a64667 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/AttendanceSourceType.cs @@ -0,0 +1,13 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Origin of an attendance batch (docs/12-BACKEND-HRM.md C.4). +/// is a reserved future integration seam (docs §B.7) — no device feed exists yet. +/// +public enum AttendanceSourceType +{ + Excel, + Csv, + Manual, + BiometricDevice +} diff --git a/Backend/ERPCore/Domain/Enums/AttendanceStatus.cs b/Backend/ERPCore/Domain/Enums/AttendanceStatus.cs new file mode 100644 index 0000000..7c21ee5 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/AttendanceStatus.cs @@ -0,0 +1,17 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Per-day attendance classification (docs/12-BACKEND-HRM.md C.4). Lateness/OT are +/// derived facts (LateMinutes/OvertimeMinutes > 0) on an otherwise Present record, +/// not separate statuses. is derived from an overlapping +/// Approved LeaveRequest with no uploaded punch (§6). +/// +public enum AttendanceStatus +{ + Present, + Absent, + HalfDay, + OnLeave, + Holiday, + WeekOff +} diff --git a/Backend/ERPCore/Domain/Enums/EmployeeDocumentStatus.cs b/Backend/ERPCore/Domain/Enums/EmployeeDocumentStatus.cs new file mode 100644 index 0000000..6ff864f --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/EmployeeDocumentStatus.cs @@ -0,0 +1,11 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Uploaded staff document status (docs/12-BACKEND-HRM.md C.3). Never hard-deleted — +/// archived instead, mirroring the deactivate-not-delete master convention (FR-MD-08). +/// +public enum EmployeeDocumentStatus +{ + Active, + Archived +} diff --git a/Backend/ERPCore/Domain/Enums/EmployeeStatus.cs b/Backend/ERPCore/Domain/Enums/EmployeeStatus.cs new file mode 100644 index 0000000..f38e354 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/EmployeeStatus.cs @@ -0,0 +1,16 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Employee lifecycle status (docs/12-BACKEND-HRM.md C.2). An Employee is never +/// hard-deleted; separation is recorded here instead (with LastWorkingDate +/// set), matching the deactivate-not-delete convention for masters (FR-MD-08) +/// taken one step further since the record must be retained for audit/payroll history. +/// +public enum EmployeeStatus +{ + Active, + Suspended, + Resigned, + Terminated, + Retired +} diff --git a/Backend/ERPCore/Domain/Enums/Gender.cs b/Backend/ERPCore/Domain/Enums/Gender.cs new file mode 100644 index 0000000..0d727c5 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/Gender.cs @@ -0,0 +1,9 @@ +namespace ERPCore.Domain.Enums; + +/// Employee gender (docs/12-BACKEND-HRM.md C.2). Optional field, stored as a string. +public enum Gender +{ + Male, + Female, + Other +} diff --git a/Backend/ERPCore/Domain/Enums/HrDocumentCategory.cs b/Backend/ERPCore/Domain/Enums/HrDocumentCategory.cs new file mode 100644 index 0000000..6636523 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/HrDocumentCategory.cs @@ -0,0 +1,12 @@ +namespace ERPCore.Domain.Enums; + +/// Staff document catalog category (docs/12-BACKEND-HRM.md C.3). Stored as a string. +public enum HrDocumentCategory +{ + Identity, + Educational, + Contract, + Certification, + Statutory, + Other +} diff --git a/Backend/ERPCore/Domain/Enums/LeaveRequestStatus.cs b/Backend/ERPCore/Domain/Enums/LeaveRequestStatus.cs new file mode 100644 index 0000000..a987711 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/LeaveRequestStatus.cs @@ -0,0 +1,11 @@ +namespace ERPCore.Domain.Enums; + +/// Leave request approval lifecycle (FR-HR-LV-02, docs/12-BACKEND-HRM.md C.5). +public enum LeaveRequestStatus +{ + Draft, + Submitted, + Approved, + Rejected, + Cancelled +} diff --git a/Backend/ERPCore/Domain/Enums/LoanInstallmentStatus.cs b/Backend/ERPCore/Domain/Enums/LoanInstallmentStatus.cs new file mode 100644 index 0000000..583da06 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/LoanInstallmentStatus.cs @@ -0,0 +1,13 @@ +namespace ERPCore.Domain.Enums; + +/// +/// An installment flips Pending→Deducted only when its PayrollRun reaches Locked +/// (docs/12-BACKEND-HRM.md A.4) — never at Generate/Draft, so a discarded/regenerated +/// draft never prematurely consumes it. +/// +public enum LoanInstallmentStatus +{ + Pending, + Deducted, + Skipped +} diff --git a/Backend/ERPCore/Domain/Enums/LoanKind.cs b/Backend/ERPCore/Domain/Enums/LoanKind.cs new file mode 100644 index 0000000..8799a52 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/LoanKind.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// Loan vs Advance discriminator (docs/12-BACKEND-HRM.md C.6) — structurally identical, differ only in intent/labeling. +public enum LoanKind +{ + Loan, + Advance +} diff --git a/Backend/ERPCore/Domain/Enums/LoanStatus.cs b/Backend/ERPCore/Domain/Enums/LoanStatus.cs new file mode 100644 index 0000000..c6df7fa --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/LoanStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +public enum LoanStatus +{ + Active, + Closed, + Cancelled +} diff --git a/Backend/ERPCore/Domain/Enums/PayrollLineComponentCategory.cs b/Backend/ERPCore/Domain/Enums/PayrollLineComponentCategory.cs new file mode 100644 index 0000000..7c89339 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/PayrollLineComponentCategory.cs @@ -0,0 +1,12 @@ +namespace ERPCore.Domain.Enums; + +/// +/// EmployerContribution lines (EPF-employer, ETF) are informational/liability only — +/// never subtracted from Net Salary (docs/12-BACKEND-HRM.md B.4). +/// +public enum PayrollLineComponentCategory +{ + Earning, + Deduction, + EmployerContribution +} diff --git a/Backend/ERPCore/Domain/Enums/PayrollRunStatus.cs b/Backend/ERPCore/Domain/Enums/PayrollRunStatus.cs new file mode 100644 index 0000000..484db4a --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/PayrollRunStatus.cs @@ -0,0 +1,15 @@ +namespace ERPCore.Domain.Enums; + +/// +/// PayrollRun approval workflow (FR-HR-PAY-06, docs/12-BACKEND-HRM.md C.6). Maps the +/// business's 5-step flow to 3 stored states: Generate→Draft, Review is a human +/// action (not stored), Approve→Approved, Lock→Locked (the point loan installments +/// and attendance batches are stamped consumed — see A.4), Generate Payslips is an +/// action gated on Locked, not a state. +/// +public enum PayrollRunStatus +{ + Draft, + Approved, + Locked +} diff --git a/Backend/ERPCore/Domain/Enums/RowValidationStatus.cs b/Backend/ERPCore/Domain/Enums/RowValidationStatus.cs new file mode 100644 index 0000000..627f636 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/RowValidationStatus.cs @@ -0,0 +1,12 @@ +namespace ERPCore.Domain.Enums; + +/// Per-record outcome of the attendance upload validation pipeline (docs/12-BACKEND-HRM.md §B.3.4). +public enum RowValidationStatus +{ + Valid, + DuplicateWithinBatch, + DuplicateConfirmed, + EmployeeNotFound, + InvalidDateTime, + Error +} diff --git a/Backend/ERPCore/Domain/Enums/SalaryComponentType.cs b/Backend/ERPCore/Domain/Enums/SalaryComponentType.cs new file mode 100644 index 0000000..1267aa1 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/SalaryComponentType.cs @@ -0,0 +1,12 @@ +namespace ERPCore.Domain.Enums; + +/// +/// SalaryComponent master category (docs/12-BACKEND-HRM.md C.6) — for Allowances and +/// ad hoc Other Deductions only. OT/Late/No-Pay/Loan/EPF/ETF/Tax are system-computed, +/// not user-defined components, to avoid a generic formula engine nobody asked for. +/// +public enum SalaryComponentType +{ + Earning, + Deduction +} diff --git a/Backend/ERPCore/Domain/Enums/SalaryStructureStatus.cs b/Backend/ERPCore/Domain/Enums/SalaryStructureStatus.cs new file mode 100644 index 0000000..504036d --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/SalaryStructureStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// Effective-dated salary structure status (docs/12-BACKEND-HRM.md C.6) — exactly one Active (open-ended) row per employee at a time. +public enum SalaryStructureStatus +{ + Active, + Superseded +} diff --git a/Backend/ERPCore/Dtos/Hrm/AttendanceDtos.cs b/Backend/ERPCore/Dtos/Hrm/AttendanceDtos.cs new file mode 100644 index 0000000..6ea99c6 --- /dev/null +++ b/Backend/ERPCore/Dtos/Hrm/AttendanceDtos.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Hrm; + +public sealed record AttendanceUploadBatchDto( + int AttendanceUploadBatchId, string DocNo, DateTime PeriodStart, DateTime PeriodEnd, + AttendanceSourceType SourceType, string? OriginalFileName, int UploadedBy, DateTime UploadedAt, + AttendanceBatchStatus Status, int? ConfirmedBy, DateTime? ConfirmedAt, + int RowCountTotal, int RowCountDuplicate, int RowCountError); + +public sealed class UploadAttendanceBatchMetadata +{ + [Required] public DateTime PeriodStart { get; set; } + [Required] public DateTime PeriodEnd { get; set; } +} + +public sealed record AttendanceRecordDto( + int AttendanceRecordId, int? AttendanceUploadBatchId, int EmployeeId, string? EmployeeCode, string? EmployeeName, + DateTime AttendanceDate, TimeSpan? CheckIn, TimeSpan? CheckOut, + int WorkingMinutes, int LateMinutes, int EarlyLeaveMinutes, int OvertimeMinutes, + AttendanceStatus AttendanceStatus, RowValidationStatus RowValidationStatus, + int? DuplicateOfAttendanceRecordId, string? Notes); + +public sealed class UpdateAttendanceRecordRequest +{ + public TimeSpan? CheckIn { get; set; } + public TimeSpan? CheckOut { get; set; } + public AttendanceStatus? AttendanceStatus { get; set; } + [StringLength(1000)] public string? Notes { get; set; } +} + +public sealed class ResolveDuplicateRequest +{ + [Required] public int RecordId { get; set; } + /// "keep" discards the other duplicate row(s); "discard" removes this row; "supersede" (cross-batch-confirmed only) replaces the prior confirmed record. + [Required, RegularExpression("^(keep|discard|supersede)$")] + public string Action { get; set; } = string.Empty; +} + +public sealed class UnlockAttendanceBatchRequest +{ + [Required, StringLength(500)] public string Reason { get; set; } = string.Empty; +} diff --git a/Backend/ERPCore/Dtos/Hrm/DocumentDtos.cs b/Backend/ERPCore/Dtos/Hrm/DocumentDtos.cs new file mode 100644 index 0000000..a09d426 --- /dev/null +++ b/Backend/ERPCore/Dtos/Hrm/DocumentDtos.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Hrm; + +public sealed record HrDocumentTypeDto( + int HrDocumentTypeId, string Code, string Name, HrDocumentCategory Category, + bool RequiredAtOnboarding, bool ExpiryTracked, EntityStatus Status, + DateTime CreatedAt, DateTime? UpdatedAt); + +public sealed class CreateHrDocumentTypeRequest +{ + [Required, StringLength(20)] public string Code { get; set; } = string.Empty; + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + [Required, EnumDataType(typeof(HrDocumentCategory))] public HrDocumentCategory Category { get; set; } + public bool RequiredAtOnboarding { get; set; } + public bool ExpiryTracked { get; set; } +} + +public sealed class UpdateHrDocumentTypeRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + [Required, EnumDataType(typeof(HrDocumentCategory))] public HrDocumentCategory Category { get; set; } + public bool RequiredAtOnboarding { get; set; } + public bool ExpiryTracked { get; set; } +} + +public sealed record EmployeeDocumentDto( + int EmployeeDocumentId, int EmployeeId, int HrDocumentTypeId, string? HrDocumentTypeName, + string OriginalFileName, string ContentType, long SizeBytes, + DateTime? IssueDate, DateTime? ExpiryDate, string? Notes, + int UploadedBy, DateTime UploadedAt, EmployeeDocumentStatus Status); + +/// Metadata accompanying a multipart file upload (the file itself is bound separately). +public sealed class UploadEmployeeDocumentRequest +{ + [Required] public int HrDocumentTypeId { get; set; } + public DateTime? IssueDate { get; set; } + public DateTime? ExpiryDate { get; set; } + [StringLength(1000)] public string? Notes { get; set; } +} + +public sealed class UpdateEmployeeDocumentStatusRequest +{ + [Required, EnumDataType(typeof(EmployeeDocumentStatus))] public EmployeeDocumentStatus Status { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs b/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs new file mode 100644 index 0000000..2be6c3a --- /dev/null +++ b/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs @@ -0,0 +1,117 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Hrm; + +public sealed record EmployeeListItemDto( + int EmployeeId, string EmployeeCode, string FullName, string? Email, + int DepartmentId, string? DepartmentName, int DesignationId, string? DesignationName, + int EmploymentTypeId, string? EmploymentTypeName, int? BranchId, string? BranchName, + EmployeeStatus Status, bool HasUserLink, DateTime HireDate); + +public sealed record EmployeeDetailDto( + int EmployeeId, string EmployeeCode, string FullName, string? Nic, DateTime? DateOfBirth, + Gender? Gender, string? Nationality, string? ProfilePhotoPath, + string? Email, string? PersonalMobile, string? AddressLine1, string? AddressLine2, + string? City, string? PostalCode, string? Country, + string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone, + DateTime HireDate, DateTime? ConfirmationDate, DateTime? LastWorkingDate, + int DepartmentId, int DesignationId, int EmploymentTypeId, int? BranchId, int WorkShiftId, + int? ReportingManagerId, string? EpfNumber, string? EtfNumber, string? TaxIdentificationNumber, + int? UserId, EmployeeStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +public sealed class CreateEmployeeRequest +{ + [Required, StringLength(30)] public string EmployeeCode { get; set; } = string.Empty; + [Required, StringLength(200)] public string FullName { get; set; } = string.Empty; + [StringLength(30)] public string? Nic { get; set; } + public DateTime? DateOfBirth { get; set; } + public Gender? Gender { get; set; } + [StringLength(100)] public string? Nationality { get; set; } + + [EmailAddress, StringLength(320)] public string? Email { get; set; } + [StringLength(30)] public string? PersonalMobile { get; set; } + [StringLength(200)] public string? AddressLine1 { get; set; } + [StringLength(200)] public string? AddressLine2 { get; set; } + [StringLength(100)] public string? City { get; set; } + [StringLength(20)] public string? PostalCode { get; set; } + [StringLength(100)] public string? Country { get; set; } + + [StringLength(200)] public string? EmergencyContactName { get; set; } + [StringLength(100)] public string? EmergencyContactRelationship { get; set; } + [StringLength(30)] public string? EmergencyContactPhone { get; set; } + + [Required] public DateTime HireDate { get; set; } + [Required] public int DepartmentId { get; set; } + [Required] public int DesignationId { get; set; } + [Required] public int EmploymentTypeId { get; set; } + public int? BranchId { get; set; } + [Required] public int WorkShiftId { get; set; } + public int? ReportingManagerId { get; set; } + + [StringLength(30)] public string? EpfNumber { get; set; } + [StringLength(30)] public string? EtfNumber { get; set; } + [StringLength(30)] public string? TaxIdentificationNumber { get; set; } + + /// Explicit, human-confirmed link to an existing User found via email-lookup — never automatic. + public int? LinkUserId { get; set; } +} + +public sealed class UpdateEmployeeRequest +{ + [Required, StringLength(200)] public string FullName { get; set; } = string.Empty; + [StringLength(30)] public string? Nic { get; set; } + public DateTime? DateOfBirth { get; set; } + public Gender? Gender { get; set; } + [StringLength(100)] public string? Nationality { get; set; } + + [EmailAddress, StringLength(320)] public string? Email { get; set; } + [StringLength(30)] public string? PersonalMobile { get; set; } + [StringLength(200)] public string? AddressLine1 { get; set; } + [StringLength(200)] public string? AddressLine2 { get; set; } + [StringLength(100)] public string? City { get; set; } + [StringLength(20)] public string? PostalCode { get; set; } + [StringLength(100)] public string? Country { get; set; } + + [StringLength(200)] public string? EmergencyContactName { get; set; } + [StringLength(100)] public string? EmergencyContactRelationship { get; set; } + [StringLength(30)] public string? EmergencyContactPhone { get; set; } + + public DateTime? ConfirmationDate { get; set; } + public DateTime? LastWorkingDate { get; set; } + [Required] public int DepartmentId { get; set; } + [Required] public int DesignationId { get; set; } + [Required] public int EmploymentTypeId { get; set; } + public int? BranchId { get; set; } + [Required] public int WorkShiftId { get; set; } + public int? ReportingManagerId { get; set; } + + [StringLength(30)] public string? EpfNumber { get; set; } + [StringLength(30)] public string? EtfNumber { get; set; } + [StringLength(30)] public string? TaxIdentificationNumber { get; set; } +} + +public sealed class UpdateEmployeeStatusRequest +{ + [Required, EnumDataType(typeof(EmployeeStatus))] public EmployeeStatus Status { get; set; } +} + +public sealed record EmployeeBankDetailDto( + int EmployeeBankDetailId, string BankName, string BranchName, string AccountNumber, + string AccountHolderName, string? SwiftCode, bool IsPrimary, EntityStatus Status); + +public sealed class UpsertEmployeeBankDetailRequest +{ + public int? EmployeeBankDetailId { get; set; } + [Required, StringLength(200)] public string BankName { get; set; } = string.Empty; + [Required, StringLength(200)] public string BranchName { get; set; } = string.Empty; + [Required, StringLength(50)] public string AccountNumber { get; set; } = string.Empty; + [Required, StringLength(200)] public string AccountHolderName { get; set; } = string.Empty; + [StringLength(20)] public string? SwiftCode { get; set; } + public bool IsPrimary { get; set; } +} + +public sealed class ReplaceEmployeeBankDetailsRequest +{ + [Required] public List Items { get; set; } = new(); +} diff --git a/Backend/ERPCore/Dtos/Hrm/EmployeeUserLinkDtos.cs b/Backend/ERPCore/Dtos/Hrm/EmployeeUserLinkDtos.cs new file mode 100644 index 0000000..8b2130e --- /dev/null +++ b/Backend/ERPCore/Dtos/Hrm/EmployeeUserLinkDtos.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace ERPCore.Dtos.Hrm; + +/// Advisory match surfaced by the reverse-direction email-lookup (docs/12-BACKEND-HRM.md A.5). +public sealed record EmployeeMatchDto(int EmployeeId, string EmployeeCode, string FullName, string Email); + +public sealed record UserMatchDto(int UserId, string Username, string DisplayName, string Email); + +public sealed record EmployeeMatchResponse(EmployeeMatchDto? Match); + +public sealed record UserMatchResponse(UserMatchDto? Match); + +public sealed class LinkUserRequest +{ + [Required] public int UserId { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Hrm/LeaveDtos.cs b/Backend/ERPCore/Dtos/Hrm/LeaveDtos.cs new file mode 100644 index 0000000..2deafe0 --- /dev/null +++ b/Backend/ERPCore/Dtos/Hrm/LeaveDtos.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Hrm; + +public sealed record LeaveTypeDto( + int LeaveTypeId, string Code, string Name, bool IsPaid, bool CountsAsNoPay, decimal AccrualPerYear, + bool CarryForwardAllowed, int? MaxCarryForwardDays, bool RequiresApproval, EntityStatus Status, + DateTime CreatedAt, DateTime? UpdatedAt); + +public sealed class CreateLeaveTypeRequest +{ + [Required, StringLength(20)] public string Code { get; set; } = string.Empty; + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + public bool IsPaid { get; set; } = true; + public bool CountsAsNoPay { get; set; } + [Range(0, 365)] public decimal AccrualPerYear { get; set; } + public bool CarryForwardAllowed { get; set; } + public int? MaxCarryForwardDays { get; set; } + public bool RequiresApproval { get; set; } = true; +} + +public sealed class UpdateLeaveTypeRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + public bool IsPaid { get; set; } + public bool CountsAsNoPay { get; set; } + [Range(0, 365)] public decimal AccrualPerYear { get; set; } + public bool CarryForwardAllowed { get; set; } + public int? MaxCarryForwardDays { get; set; } + public bool RequiresApproval { get; set; } +} + +public sealed record LeaveRequestDto( + int LeaveRequestId, string DocNo, int EmployeeId, string? EmployeeName, int LeaveTypeId, string? LeaveTypeName, + DateTime StartDate, DateTime EndDate, decimal DaysCount, string? Reason, + LeaveRequestStatus Status, int? ApprovedBy, DateTime? ApprovedAt, string? RejectionReason, DateTime CreatedAt); + +public sealed class CreateLeaveRequestRequest +{ + [Required] public int EmployeeId { get; set; } + [Required] public int LeaveTypeId { get; set; } + [Required] public DateTime StartDate { get; set; } + [Required] public DateTime EndDate { get; set; } + [StringLength(1000)] public string? Reason { get; set; } +} + +public sealed class RejectLeaveRequestRequest +{ + [Required, StringLength(1000)] public string Reason { get; set; } = string.Empty; +} + +public sealed record LeaveBalanceDto( + int LeaveBalanceId, int EmployeeId, int LeaveTypeId, string? LeaveTypeName, int Year, + decimal EntitledDays, decimal TakenDays, decimal CarriedForwardDays, decimal AdjustmentDays, decimal RemainingDays); + +public sealed class LeaveBalanceAdjustmentItem +{ + [Required] public int LeaveTypeId { get; set; } + public decimal AdjustmentDays { get; set; } +} + +public sealed class UpdateLeaveBalancesRequest +{ + [Required] public int Year { get; set; } + [Required] public List Items { get; set; } = new(); +} diff --git a/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs b/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs new file mode 100644 index 0000000..c90c9eb --- /dev/null +++ b/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs @@ -0,0 +1,111 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Hrm; + +// Narrow DTOs for the five HRM org masters — server-controlled fields (status, +// ids, timestamps) excluded from create/update requests (02-SECURITY B.6/C.1). + +public sealed record BranchDto( + int BranchId, string Code, string Name, string? Address, EntityStatus Status, + DateTime CreatedAt, DateTime? UpdatedAt); + +public sealed class CreateBranchRequest +{ + [Required, StringLength(20)] public string Code { get; set; } = string.Empty; + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + [StringLength(500)] public string? Address { get; set; } +} + +public sealed class UpdateBranchRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + [StringLength(500)] public string? Address { get; set; } +} + +public sealed record DepartmentDto( + int DepartmentId, string Code, string Name, int? ParentDepartmentId, int? HeadEmployeeId, + int? BranchId, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +public sealed class CreateDepartmentRequest +{ + [Required, StringLength(20)] public string Code { get; set; } = string.Empty; + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + public int? ParentDepartmentId { get; set; } + public int? HeadEmployeeId { get; set; } + public int? BranchId { get; set; } +} + +public sealed class UpdateDepartmentRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + public int? ParentDepartmentId { get; set; } + public int? HeadEmployeeId { get; set; } + public int? BranchId { get; set; } +} + +public sealed record DesignationDto( + int DesignationId, string Code, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +public sealed class CreateDesignationRequest +{ + [Required, StringLength(20)] public string Code { get; set; } = string.Empty; + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateDesignationRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed record EmploymentTypeDto( + int EmploymentTypeId, string Code, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +public sealed class CreateEmploymentTypeRequest +{ + [Required, StringLength(20)] public string Code { get; set; } = string.Empty; + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateEmploymentTypeRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; +} + +public sealed record WorkShiftDto( + int WorkShiftId, string Code, string Name, TimeSpan StartTime, TimeSpan EndTime, bool IsOvernight, + int GraceMinutes, int BreakMinutes, int StandardWorkingMinutes, decimal OtMultiplier, int WorkingDaysMask, + EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +public sealed class CreateWorkShiftRequest +{ + [Required, StringLength(20)] public string Code { get; set; } = string.Empty; + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + [Required] public TimeSpan StartTime { get; set; } + [Required] public TimeSpan EndTime { get; set; } + public bool IsOvernight { get; set; } + [Range(0, 240)] public int GraceMinutes { get; set; } = 15; + [Range(0, 240)] public int BreakMinutes { get; set; } = 60; + [Range(1, 1440)] public int StandardWorkingMinutes { get; set; } = 480; + [Range(1, 5)] public decimal OtMultiplier { get; set; } = 1.5m; + [Range(0, 127)] public int WorkingDaysMask { get; set; } = 0b0111111; +} + +public sealed class UpdateWorkShiftRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + [Required] public TimeSpan StartTime { get; set; } + [Required] public TimeSpan EndTime { get; set; } + public bool IsOvernight { get; set; } + [Range(0, 240)] public int GraceMinutes { get; set; } + [Range(0, 240)] public int BreakMinutes { get; set; } + [Range(1, 1440)] public int StandardWorkingMinutes { get; set; } + [Range(1, 5)] public decimal OtMultiplier { get; set; } + [Range(0, 127)] public int WorkingDaysMask { get; set; } +} + +/// Shared by all five masters' PATCH .../status endpoints. +public sealed class UpdateHrMasterStatusRequest +{ + [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Hrm/PayrollDtos.cs b/Backend/ERPCore/Dtos/Hrm/PayrollDtos.cs new file mode 100644 index 0000000..d9fd15c --- /dev/null +++ b/Backend/ERPCore/Dtos/Hrm/PayrollDtos.cs @@ -0,0 +1,130 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Hrm; + +// --- Salary components --- + +public sealed record SalaryComponentDto( + int SalaryComponentId, string Code, string Name, SalaryComponentType ComponentType, + bool IsTaxable, bool IsEpfEtfApplicable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); + +public sealed class CreateSalaryComponentRequest +{ + [Required, StringLength(20)] public string Code { get; set; } = string.Empty; + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + [Required, EnumDataType(typeof(SalaryComponentType))] public SalaryComponentType ComponentType { get; set; } + public bool IsTaxable { get; set; } + public bool IsEpfEtfApplicable { get; set; } +} + +public sealed class UpdateSalaryComponentRequest +{ + [Required, StringLength(200)] public string Name { get; set; } = string.Empty; + public bool IsTaxable { get; set; } + public bool IsEpfEtfApplicable { get; set; } +} + +// --- Salary structure --- + +public sealed record EmployeeSalaryStructureLineDto(int SalaryComponentId, string? SalaryComponentName, decimal Amount); + +public sealed record EmployeeSalaryStructureDto( + int EmployeeSalaryStructureId, int EmployeeId, DateTime EffectiveFrom, DateTime? EffectiveTo, + decimal BasicSalary, string Currency, SalaryStructureStatus Status, + List Lines, DateTime CreatedAt); + +public sealed class SalaryStructureLineRequest +{ + [Required] public int SalaryComponentId { get; set; } + [Range(0, double.MaxValue)] public decimal Amount { get; set; } +} + +public sealed class CreateSalaryStructureRequest +{ + [Required] public DateTime EffectiveFrom { get; set; } + [Range(0, double.MaxValue)] public decimal BasicSalary { get; set; } + public List Lines { get; set; } = new(); +} + +// --- Loans --- + +public sealed record LoanInstallmentDto( + int LoanInstallmentId, int InstallmentNumber, int DueYear, int DueMonth, + decimal ScheduledAmount, decimal? PaidAmount, int? PayrollRunId, LoanInstallmentStatus Status); + +public sealed record EmployeeLoanDto( + int EmployeeLoanId, string DocNo, int EmployeeId, LoanKind LoanKind, decimal PrincipalAmount, + decimal InterestRate, decimal InstallmentAmount, int NumberOfInstallments, int StartYear, int StartMonth, + decimal OutstandingBalance, LoanStatus Status, List Installments, DateTime CreatedAt); + +public sealed class CreateEmployeeLoanRequest +{ + [Required, EnumDataType(typeof(LoanKind))] public LoanKind LoanKind { get; set; } + [Range(0.01, double.MaxValue)] public decimal PrincipalAmount { get; set; } + [Range(0, 1)] public decimal InterestRate { get; set; } + [Range(0.01, double.MaxValue)] public decimal InstallmentAmount { get; set; } + [Range(1, 360)] public int NumberOfInstallments { get; set; } + [Range(2000, 2100)] public int StartYear { get; set; } + [Range(1, 12)] public int StartMonth { get; set; } +} + +// --- Statutory settings --- + +public sealed record PayrollStatutorySettingDto( + int PayrollStatutorySettingId, decimal EpfEmployeeRate, decimal EpfEmployerRate, decimal EtfEmployerRate, + decimal OtMultiplierDefault, DateTime EffectiveFrom, DateTime? EffectiveTo); + +public sealed class UpsertPayrollStatutorySettingRequest +{ + [Range(0, 1)] public decimal EpfEmployeeRate { get; set; } = 0.08m; + [Range(0, 1)] public decimal EpfEmployerRate { get; set; } = 0.12m; + [Range(0, 1)] public decimal EtfEmployerRate { get; set; } = 0.03m; + [Range(1, 5)] public decimal OtMultiplierDefault { get; set; } = 1.5m; + [Required] public DateTime EffectiveFrom { get; set; } +} + +public sealed record TaxSlabDto(int TaxSlabId, DateTime EffectiveFrom, DateTime? EffectiveTo, decimal LowerBound, decimal? UpperBound, decimal Rate); + +public sealed class CreateTaxSlabRequest +{ + [Required] public DateTime EffectiveFrom { get; set; } + [Range(0, double.MaxValue)] public decimal LowerBound { get; set; } + public decimal? UpperBound { get; set; } + [Range(0, 1)] public decimal Rate { get; set; } +} + +// --- Payroll run --- + +public sealed record PayrollLineComponentDto( + PayrollLineComponentCategory ComponentCategory, int? SalaryComponentId, string Label, decimal Amount, int SortOrder); + +public sealed record PayrollLineDto( + int PayrollLineId, int PayrollRunId, int EmployeeId, string? EmployeeCode, string? EmployeeName, + decimal BasicSalary, decimal TotalAllowances, decimal OvertimeAmount, decimal GrossSalary, + decimal LateDeductionAmount, decimal NoPayAmount, decimal LoanDeductionAmount, + decimal EpfEmployeeAmount, decimal EpfEmployerAmount, decimal EtfEmployerAmount, + decimal TaxAmount, decimal OtherDeductionsAmount, decimal NetSalary, + int WorkingDays, int PresentDays, int AbsentDays, int LeaveDays, int OtMinutesTotal, int LateMinutesTotal); + +public sealed record PayrollLineDetailDto(PayrollLineDto Line, List Components); + +public sealed record PayrollRunDto( + int PayrollRunId, string DocNo, int PeriodYear, int PeriodMonth, int? BranchId, PayrollRunStatus Status, + int GeneratedBy, DateTime GeneratedAt, int? ApprovedBy, DateTime? ApprovedAt, + int? LockedBy, DateTime? LockedAt, int? UnlockedBy, DateTime? UnlockedAt, string? UnlockReason, + decimal TotalGross, decimal TotalNet, int EmployeeCount); + +public sealed class GeneratePayrollRunRequest +{ + [Range(2000, 2100)] public int PeriodYear { get; set; } + [Range(1, 12)] public int PeriodMonth { get; set; } + public int? BranchId { get; set; } +} + +public sealed class UnlockPayrollRunRequest +{ + [Required, StringLength(500)] public string Reason { get; set; } = string.Empty; +} + +public sealed record PayslipDto(int PayslipId, int PayrollLineId, DateTime GeneratedAt, DateTime? ReleasedAt, int? ReleasedBy); diff --git a/Backend/ERPCore/Dtos/Hrm/ReportDtos.cs b/Backend/ERPCore/Dtos/Hrm/ReportDtos.cs new file mode 100644 index 0000000..6429fd1 --- /dev/null +++ b/Backend/ERPCore/Dtos/Hrm/ReportDtos.cs @@ -0,0 +1,26 @@ +namespace ERPCore.Dtos.Hrm; + +public sealed record AttendanceSummaryRowDto( + int EmployeeId, string EmployeeCode, string EmployeeName, string? DepartmentName, + int PresentDays, int AbsentDays, int LeaveDays, int HalfDays, int OtMinutesTotal, int LateMinutesTotal); + +public sealed record OvertimeReportRowDto( + int EmployeeId, string EmployeeCode, string EmployeeName, DateTime AttendanceDate, int OvertimeMinutes); + +public sealed record LateArrivalReportRowDto( + int EmployeeId, string EmployeeCode, string EmployeeName, DateTime AttendanceDate, int LateMinutes); + +public sealed record PayrollRegisterRowDto( + int PayrollLineId, int EmployeeId, string EmployeeCode, string EmployeeName, + decimal GrossSalary, decimal TotalDeductions, decimal NetSalary); + +public sealed record SalaryHistoryRowDto( + int EmployeeSalaryStructureId, DateTime EffectiveFrom, DateTime? EffectiveTo, decimal BasicSalary, string Status); + +public sealed record LeaveBalanceReportRowDto( + int EmployeeId, string EmployeeCode, string EmployeeName, string LeaveTypeName, + decimal EntitledDays, decimal TakenDays, decimal RemainingDays); + +public sealed record DocumentExpiryReportRowDto( + int EmployeeDocumentId, int EmployeeId, string EmployeeCode, string EmployeeName, + string DocumentTypeName, DateTime ExpiryDate, int DaysUntilExpiry); diff --git a/Backend/ERPCore/Dtos/Users/UserDtos.cs b/Backend/ERPCore/Dtos/Users/UserDtos.cs index 0d4c5ce..2de2099 100644 --- a/Backend/ERPCore/Dtos/Users/UserDtos.cs +++ b/Backend/ERPCore/Dtos/Users/UserDtos.cs @@ -4,7 +4,7 @@ using ERPCore.Domain.Enums; namespace ERPCore.Dtos.Users; public sealed record ManagedUserDto( - int UserId, string Username, string DisplayName, EntityStatus Status, + int UserId, string Username, string DisplayName, string? Email, EntityStatus Status, int? RoleId, string? RoleCode, string? RoleName); /// @@ -24,6 +24,12 @@ public sealed class CreateUserRequest public string? MobileNumber { get; set; } /// Left empty to auto-generate (AuthHex emails it to ). public string? Password { get; set; } + + /// + /// Explicit, human-confirmed link to an existing unlinked Employee found via + /// email-lookup — never automatic, even on an exact email match (docs/12-BACKEND-HRM.md A.5). + /// + public int? LinkEmployeeId { get; set; } } public sealed class UpdateUserRoleRequest diff --git a/Backend/ERPCore/ERPCore.csproj b/Backend/ERPCore/ERPCore.csproj index 9bf3c00..77137ac 100644 --- a/Backend/ERPCore/ERPCore.csproj +++ b/Backend/ERPCore/ERPCore.csproj @@ -7,6 +7,8 @@ + + all diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceRecordConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceRecordConfiguration.cs new file mode 100644 index 0000000..64e1803 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceRecordConfiguration.cs @@ -0,0 +1,30 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class AttendanceRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_attendance_records"); + builder.HasKey(r => r.AttendanceRecordId); + + builder.Property(r => r.Notes).HasMaxLength(1000); + builder.Property(r => r.AttendanceStatus).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(r => r.RowValidationStatus).HasConversion().HasMaxLength(20).IsRequired(); + + builder.HasOne(r => r.AttendanceUploadBatch).WithMany() + .HasForeignKey(r => r.AttendanceUploadBatchId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(r => r.Employee).WithMany() + .HasForeignKey(r => r.EmployeeId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.WorkShift).WithMany() + .HasForeignKey(r => r.WorkShiftId).OnDelete(DeleteBehavior.Restrict); + + builder.Property(r => r.RowVersion).IsRowVersion(); + + builder.HasIndex(r => new { r.EmployeeId, r.AttendanceDate }); + builder.HasIndex(r => r.AttendanceUploadBatchId); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceUploadBatchConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceUploadBatchConfiguration.cs new file mode 100644 index 0000000..30645a4 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceUploadBatchConfiguration.cs @@ -0,0 +1,27 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class AttendanceUploadBatchConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_attendance_upload_batches"); + builder.HasKey(b => b.AttendanceUploadBatchId); + + builder.Property(b => b.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(b => b.DocNo).IsUnique(); + builder.Property(b => b.OriginalFileName).HasMaxLength(260); + + builder.Property(b => b.SourceType).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(b => b.Status).HasConversion().HasMaxLength(20).IsRequired(); + + builder.Property(b => b.UploadedAt).IsRequired(); + builder.Property(b => b.RowVersion).IsRowVersion(); + + builder.HasIndex(b => b.Status); + builder.HasIndex(b => new { b.PeriodStart, b.PeriodEnd }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs new file mode 100644 index 0000000..660539e --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs @@ -0,0 +1,29 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class BranchConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_branches"); + builder.HasKey(b => b.BranchId); + + builder.Property(b => b.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(b => b.Code).IsUnique(); + builder.Property(b => b.Name).IsRequired().HasMaxLength(200); + builder.Property(b => b.Address).HasMaxLength(500); + + builder.Property(b => b.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(b => b.CreatedAt).IsRequired(); + builder.Property(b => b.RowVersion).IsRowVersion(); + + builder.HasIndex(b => b.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/DepartmentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/DepartmentConfiguration.cs new file mode 100644 index 0000000..dc51ed5 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/DepartmentConfiguration.cs @@ -0,0 +1,40 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class DepartmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_departments"); + builder.HasKey(d => d.DepartmentId); + + builder.Property(d => d.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(d => d.Code).IsUnique(); + builder.Property(d => d.Name).IsRequired().HasMaxLength(200); + + builder.Property(d => d.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + // Self-nesting (unlimited depth, unlike Category) — cycle prevention is + // service-level, not a DB constraint (docs/12-BACKEND-HRM.md A.1/C.1). + builder.HasOne(d => d.ParentDepartment).WithMany() + .HasForeignKey(d => d.ParentDepartmentId).OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(d => d.HeadEmployee).WithMany() + .HasForeignKey(d => d.HeadEmployeeId).OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(d => d.Branch).WithMany() + .HasForeignKey(d => d.BranchId).OnDelete(DeleteBehavior.Restrict); + + builder.Property(d => d.CreatedAt).IsRequired(); + builder.Property(d => d.RowVersion).IsRowVersion(); + + builder.HasIndex(d => d.Status); + builder.HasIndex(d => d.ParentDepartmentId); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/DesignationConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/DesignationConfiguration.cs new file mode 100644 index 0000000..f5255d8 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/DesignationConfiguration.cs @@ -0,0 +1,28 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class DesignationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_designations"); + builder.HasKey(d => d.DesignationId); + + builder.Property(d => d.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(d => d.Code).IsUnique(); + builder.Property(d => d.Name).IsRequired().HasMaxLength(200); + + builder.Property(d => d.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(d => d.CreatedAt).IsRequired(); + builder.Property(d => d.RowVersion).IsRowVersion(); + + builder.HasIndex(d => d.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeBankDetailConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeBankDetailConfiguration.cs new file mode 100644 index 0000000..35af1a9 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeBankDetailConfiguration.cs @@ -0,0 +1,33 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class EmployeeBankDetailConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_employee_bank_details"); + builder.HasKey(b => b.EmployeeBankDetailId); + + builder.Property(b => b.BankName).IsRequired().HasMaxLength(200); + builder.Property(b => b.BranchName).IsRequired().HasMaxLength(200); + builder.Property(b => b.AccountNumber).IsRequired().HasMaxLength(50); + builder.Property(b => b.AccountHolderName).IsRequired().HasMaxLength(200); + builder.Property(b => b.SwiftCode).HasMaxLength(20); + + builder.Property(b => b.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.HasOne(b => b.Employee).WithMany() + .HasForeignKey(b => b.EmployeeId).OnDelete(DeleteBehavior.Cascade); + + builder.Property(b => b.CreatedAt).IsRequired(); + builder.Property(b => b.RowVersion).IsRowVersion(); + + builder.HasIndex(b => b.EmployeeId); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeConfiguration.cs new file mode 100644 index 0000000..08daa78 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeConfiguration.cs @@ -0,0 +1,71 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class EmployeeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_employees"); + builder.HasKey(e => e.EmployeeId); + + builder.Property(e => e.EmployeeCode).IsRequired().HasMaxLength(30); + builder.HasIndex(e => e.EmployeeCode).IsUnique(); + + builder.Property(e => e.FullName).IsRequired().HasMaxLength(200); + builder.Property(e => e.Nic).HasMaxLength(30); + builder.Property(e => e.Nationality).HasMaxLength(100); + builder.Property(e => e.ProfilePhotoPath).HasMaxLength(500); + builder.Property(e => e.Gender).HasConversion().HasMaxLength(20); + + builder.Property(e => e.Email).HasMaxLength(320); + builder.HasIndex(e => e.Email); + builder.Property(e => e.PersonalMobile).HasMaxLength(30); + builder.Property(e => e.AddressLine1).HasMaxLength(200); + builder.Property(e => e.AddressLine2).HasMaxLength(200); + builder.Property(e => e.City).HasMaxLength(100); + builder.Property(e => e.PostalCode).HasMaxLength(20); + builder.Property(e => e.Country).HasMaxLength(100); + + builder.Property(e => e.EmergencyContactName).HasMaxLength(200); + builder.Property(e => e.EmergencyContactRelationship).HasMaxLength(100); + builder.Property(e => e.EmergencyContactPhone).HasMaxLength(30); + + builder.Property(e => e.EpfNumber).HasMaxLength(30); + builder.Property(e => e.EtfNumber).HasMaxLength(30); + builder.Property(e => e.TaxIdentificationNumber).HasMaxLength(30); + + builder.HasOne(e => e.Department).WithMany() + .HasForeignKey(e => e.DepartmentId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(e => e.Designation).WithMany() + .HasForeignKey(e => e.DesignationId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(e => e.EmploymentType).WithMany() + .HasForeignKey(e => e.EmploymentTypeId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(e => e.Branch).WithMany() + .HasForeignKey(e => e.BranchId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(e => e.WorkShift).WithMany() + .HasForeignKey(e => e.WorkShiftId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(e => e.ReportingManager).WithMany() + .HasForeignKey(e => e.ReportingManagerId).OnDelete(DeleteBehavior.Restrict); + + // One User backs at most one Employee (docs/12-BACKEND-HRM.md A.5/C.2). + // Postgres unique indexes allow multiple NULLs natively, so no explicit + // filter is needed (same pattern as User.AuthUserId). + builder.HasOne(e => e.User).WithOne() + .HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Restrict); + builder.HasIndex(e => e.UserId).IsUnique(); + + builder.Property(e => e.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EmployeeStatus.Active); + + builder.Property(e => e.CreatedAt).IsRequired(); + builder.Property(e => e.RowVersion).IsRowVersion(); + + builder.HasIndex(e => e.Status); + builder.HasIndex(e => e.DepartmentId); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeDocumentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeDocumentConfiguration.cs new file mode 100644 index 0000000..b75f6cb --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeDocumentConfiguration.cs @@ -0,0 +1,36 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class EmployeeDocumentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_employee_documents"); + builder.HasKey(d => d.EmployeeDocumentId); + + builder.Property(d => d.OriginalFileName).IsRequired().HasMaxLength(260); + builder.Property(d => d.StoredFileName).IsRequired().HasMaxLength(260); + builder.Property(d => d.RelativePath).IsRequired().HasMaxLength(500); + builder.Property(d => d.ContentType).IsRequired().HasMaxLength(200); + builder.Property(d => d.Notes).HasMaxLength(1000); + + builder.Property(d => d.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EmployeeDocumentStatus.Active); + + builder.HasOne(d => d.Employee).WithMany() + .HasForeignKey(d => d.EmployeeId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(d => d.HrDocumentType).WithMany() + .HasForeignKey(d => d.HrDocumentTypeId).OnDelete(DeleteBehavior.Restrict); + + builder.Property(d => d.UploadedAt).IsRequired(); + builder.Property(d => d.RowVersion).IsRowVersion(); + + builder.HasIndex(d => d.EmployeeId); + builder.HasIndex(d => d.ExpiryDate); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeLoanConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeLoanConfiguration.cs new file mode 100644 index 0000000..02dab41 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeLoanConfiguration.cs @@ -0,0 +1,56 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class EmployeeLoanConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_employee_loans"); + builder.HasKey(l => l.EmployeeLoanId); + + builder.Property(l => l.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(l => l.DocNo).IsUnique(); + builder.Property(l => l.LoanKind).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(l => l.Status).HasConversion().HasMaxLength(20).IsRequired(); + + builder.Property(l => l.PrincipalAmount).HasPrecision(18, 2); + builder.Property(l => l.InterestRate).HasPrecision(6, 4); + builder.Property(l => l.InstallmentAmount).HasPrecision(18, 2); + builder.Property(l => l.OutstandingBalance).HasPrecision(18, 2); + + builder.HasOne(l => l.Employee).WithMany() + .HasForeignKey(l => l.EmployeeId).OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(l => l.Installments).WithOne(i => i.EmployeeLoan!) + .HasForeignKey(i => i.EmployeeLoanId).OnDelete(DeleteBehavior.Cascade); + + builder.Property(l => l.CreatedAt).IsRequired(); + builder.Property(l => l.RowVersion).IsRowVersion(); + + builder.HasIndex(l => l.EmployeeId); + builder.HasIndex(l => l.Status); + } +} + +public sealed class LoanInstallmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_loan_installments"); + builder.HasKey(i => i.LoanInstallmentId); + + builder.Property(i => i.ScheduledAmount).HasPrecision(18, 2); + builder.Property(i => i.PaidAmount).HasPrecision(18, 2); + builder.Property(i => i.Status).HasConversion().HasMaxLength(20).IsRequired(); + + builder.HasOne(i => i.PayrollRun).WithMany() + .HasForeignKey(i => i.PayrollRunId).OnDelete(DeleteBehavior.Restrict); + + builder.Property(i => i.RowVersion).IsRowVersion(); + + builder.HasIndex(i => new { i.DueYear, i.DueMonth }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeSalaryStructureConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeSalaryStructureConfiguration.cs new file mode 100644 index 0000000..44aef27 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeSalaryStructureConfiguration.cs @@ -0,0 +1,43 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class EmployeeSalaryStructureConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_employee_salary_structures"); + builder.HasKey(s => s.EmployeeSalaryStructureId); + + builder.Property(s => s.BasicSalary).HasPrecision(18, 2); + builder.Property(s => s.Currency).IsRequired().HasMaxLength(3); + builder.Property(s => s.Status).HasConversion().HasMaxLength(20).IsRequired(); + + builder.HasOne(s => s.Employee).WithMany() + .HasForeignKey(s => s.EmployeeId).OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(s => s.Lines).WithOne(l => l.EmployeeSalaryStructure!) + .HasForeignKey(l => l.EmployeeSalaryStructureId).OnDelete(DeleteBehavior.Cascade); + + builder.Property(s => s.CreatedAt).IsRequired(); + builder.Property(s => s.RowVersion).IsRowVersion(); + + builder.HasIndex(s => new { s.EmployeeId, s.EffectiveTo }); + } +} + +public sealed class EmployeeSalaryStructureLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_employee_salary_structure_lines"); + builder.HasKey(l => l.EmployeeSalaryStructureLineId); + + builder.Property(l => l.Amount).HasPrecision(18, 2); + + builder.HasOne(l => l.SalaryComponent).WithMany() + .HasForeignKey(l => l.SalaryComponentId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmploymentTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmploymentTypeConfiguration.cs new file mode 100644 index 0000000..bd8eaef --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmploymentTypeConfiguration.cs @@ -0,0 +1,28 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class EmploymentTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_employment_types"); + builder.HasKey(e => e.EmploymentTypeId); + + builder.Property(e => e.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(e => e.Code).IsUnique(); + builder.Property(e => e.Name).IsRequired().HasMaxLength(200); + + builder.Property(e => e.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(e => e.CreatedAt).IsRequired(); + builder.Property(e => e.RowVersion).IsRowVersion(); + + builder.HasIndex(e => e.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/HrDocumentTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/HrDocumentTypeConfiguration.cs new file mode 100644 index 0000000..935afd4 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/HrDocumentTypeConfiguration.cs @@ -0,0 +1,29 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class HrDocumentTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_document_types"); + builder.HasKey(t => t.HrDocumentTypeId); + + builder.Property(t => t.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(t => t.Code).IsUnique(); + builder.Property(t => t.Name).IsRequired().HasMaxLength(200); + builder.Property(t => t.Category).HasConversion().HasMaxLength(20).IsRequired(); + + builder.Property(t => t.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(t => t.CreatedAt).IsRequired(); + builder.Property(t => t.RowVersion).IsRowVersion(); + + builder.HasIndex(t => t.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/LeaveBalanceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveBalanceConfiguration.cs new file mode 100644 index 0000000..093ff09 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveBalanceConfiguration.cs @@ -0,0 +1,28 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class LeaveBalanceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_leave_balances"); + builder.HasKey(b => b.LeaveBalanceId); + + builder.Property(b => b.EntitledDays).HasPrecision(6, 2); + builder.Property(b => b.TakenDays).HasPrecision(6, 2); + builder.Property(b => b.CarriedForwardDays).HasPrecision(6, 2); + builder.Property(b => b.AdjustmentDays).HasPrecision(6, 2); + + builder.HasOne(b => b.Employee).WithMany() + .HasForeignKey(b => b.EmployeeId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(b => b.LeaveType).WithMany() + .HasForeignKey(b => b.LeaveTypeId).OnDelete(DeleteBehavior.Restrict); + + builder.Property(b => b.RowVersion).IsRowVersion(); + + builder.HasIndex(b => new { b.EmployeeId, b.LeaveTypeId, b.Year }).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/LeaveRequestConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveRequestConfiguration.cs new file mode 100644 index 0000000..fe370c3 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveRequestConfiguration.cs @@ -0,0 +1,35 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class LeaveRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_leave_requests"); + builder.HasKey(r => r.LeaveRequestId); + + builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(r => r.DocNo).IsUnique(); + builder.Property(r => r.DaysCount).HasPrecision(6, 2); + builder.Property(r => r.Reason).HasMaxLength(1000); + builder.Property(r => r.RejectionReason).HasMaxLength(1000); + + builder.Property(r => r.Status).HasConversion().HasMaxLength(20).IsRequired(); + + builder.HasOne(r => r.Employee).WithMany() + .HasForeignKey(r => r.EmployeeId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.LeaveType).WithMany() + .HasForeignKey(r => r.LeaveTypeId).OnDelete(DeleteBehavior.Restrict); + + builder.Property(r => r.CreatedAt).IsRequired(); + builder.Property(r => r.RowVersion).IsRowVersion(); + + builder.HasIndex(r => r.EmployeeId); + builder.HasIndex(r => r.Status); + builder.HasIndex(r => new { r.StartDate, r.EndDate }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/LeaveTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveTypeConfiguration.cs new file mode 100644 index 0000000..dec12cb --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveTypeConfiguration.cs @@ -0,0 +1,29 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class LeaveTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_leave_types"); + builder.HasKey(t => t.LeaveTypeId); + + builder.Property(t => t.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(t => t.Code).IsUnique(); + builder.Property(t => t.Name).IsRequired().HasMaxLength(200); + builder.Property(t => t.AccrualPerYear).HasPrecision(6, 2); + + builder.Property(t => t.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(t => t.CreatedAt).IsRequired(); + builder.Property(t => t.RowVersion).IsRowVersion(); + + builder.HasIndex(t => t.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PayrollLineConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollLineConfiguration.cs new file mode 100644 index 0000000..56b62ee --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollLineConfiguration.cs @@ -0,0 +1,52 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PayrollLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_payroll_lines"); + builder.HasKey(l => l.PayrollLineId); + + foreach (var money in new[] + { + nameof(PayrollLine.BasicSalary), nameof(PayrollLine.TotalAllowances), nameof(PayrollLine.OvertimeAmount), + nameof(PayrollLine.GrossSalary), nameof(PayrollLine.LateDeductionAmount), nameof(PayrollLine.NoPayAmount), + nameof(PayrollLine.LoanDeductionAmount), nameof(PayrollLine.EpfEmployeeAmount), nameof(PayrollLine.EpfEmployerAmount), + nameof(PayrollLine.EtfEmployerAmount), nameof(PayrollLine.TaxAmount), nameof(PayrollLine.OtherDeductionsAmount), + nameof(PayrollLine.NetSalary) + }) + { + builder.Property(money).HasColumnType("numeric(18,2)"); + } + + builder.HasOne(l => l.Employee).WithMany() + .HasForeignKey(l => l.EmployeeId).OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(l => l.Components).WithOne(c => c.PayrollLine!) + .HasForeignKey(c => c.PayrollLineId).OnDelete(DeleteBehavior.Cascade); + + builder.Property(l => l.RowVersion).IsRowVersion(); + + builder.HasIndex(l => new { l.PayrollRunId, l.EmployeeId }).IsUnique(); + } +} + +public sealed class PayrollLineComponentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_payroll_line_components"); + builder.HasKey(c => c.PayrollLineComponentId); + + builder.Property(c => c.ComponentCategory).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(c => c.Label).IsRequired().HasMaxLength(200); + builder.Property(c => c.Amount).HasPrecision(18, 2); + + builder.HasOne(c => c.SalaryComponent).WithMany() + .HasForeignKey(c => c.SalaryComponentId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PayrollRunConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollRunConfiguration.cs new file mode 100644 index 0000000..4edc274 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollRunConfiguration.cs @@ -0,0 +1,31 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PayrollRunConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_payroll_runs"); + builder.HasKey(r => r.PayrollRunId); + + builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(r => r.DocNo).IsUnique(); + builder.Property(r => r.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(r => r.UnlockReason).HasMaxLength(500); + + builder.HasOne(r => r.Branch).WithMany() + .HasForeignKey(r => r.BranchId).OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(r => r.Lines).WithOne(l => l.PayrollRun!) + .HasForeignKey(l => l.PayrollRunId).OnDelete(DeleteBehavior.Cascade); + + builder.Property(r => r.GeneratedAt).IsRequired(); + builder.Property(r => r.RowVersion).IsRowVersion(); + + builder.HasIndex(r => new { r.PeriodYear, r.PeriodMonth, r.BranchId }); + builder.HasIndex(r => r.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PayrollStatutorySettingConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollStatutorySettingConfiguration.cs new file mode 100644 index 0000000..37da71e --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollStatutorySettingConfiguration.cs @@ -0,0 +1,42 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PayrollStatutorySettingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_payroll_statutory_settings"); + builder.HasKey(s => s.PayrollStatutorySettingId); + + builder.Property(s => s.EpfEmployeeRate).HasPrecision(6, 4); + builder.Property(s => s.EpfEmployerRate).HasPrecision(6, 4); + builder.Property(s => s.EtfEmployerRate).HasPrecision(6, 4); + builder.Property(s => s.OtMultiplierDefault).HasPrecision(6, 2); + + builder.Property(s => s.CreatedAt).IsRequired(); + builder.Property(s => s.RowVersion).IsRowVersion(); + + builder.HasIndex(s => s.EffectiveFrom); + } +} + +public sealed class TaxSlabConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_tax_slabs"); + builder.HasKey(s => s.TaxSlabId); + + builder.Property(s => s.LowerBound).HasPrecision(18, 2); + builder.Property(s => s.UpperBound).HasPrecision(18, 2); + builder.Property(s => s.Rate).HasPrecision(6, 4); + + builder.Property(s => s.CreatedAt).IsRequired(); + builder.Property(s => s.RowVersion).IsRowVersion(); + + builder.HasIndex(s => s.EffectiveFrom); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PayslipConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PayslipConfiguration.cs new file mode 100644 index 0000000..2e45622 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PayslipConfiguration.cs @@ -0,0 +1,20 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PayslipConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_payslips"); + builder.HasKey(p => p.PayslipId); + + builder.HasOne(p => p.PayrollLine).WithOne() + .HasForeignKey(p => p.PayrollLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(p => p.PayrollLineId).IsUnique(); + + builder.Property(p => p.GeneratedAt).IsRequired(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalaryComponentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalaryComponentConfiguration.cs new file mode 100644 index 0000000..b67800c --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalaryComponentConfiguration.cs @@ -0,0 +1,29 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class SalaryComponentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_salary_components"); + builder.HasKey(c => c.SalaryComponentId); + + builder.Property(c => c.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(c => c.Code).IsUnique(); + builder.Property(c => c.Name).IsRequired().HasMaxLength(200); + builder.Property(c => c.ComponentType).HasConversion().HasMaxLength(20).IsRequired(); + + builder.Property(c => c.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(c => c.CreatedAt).IsRequired(); + builder.Property(c => c.RowVersion).IsRowVersion(); + + builder.HasIndex(c => c.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs index 685a9aa..ada9c26 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs @@ -23,6 +23,12 @@ public sealed class UserConfiguration : IEntityTypeConfiguration builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id"); builder.HasIndex(u => u.AuthUserId).IsUnique(); + // Employee<->User cross-link match field (docs/12-BACKEND-HRM.md A.5). Unique, + // like AuthUserId — Postgres allows multiple NULLs in a unique index natively, + // so employees/users without an email don't collide. + builder.Property(u => u.Email).HasMaxLength(320); + builder.HasIndex(u => u.Email).IsUnique(); + // Local shadow Role assignment (nullable — unset until an admin assigns one). builder.HasOne(u => u.Role).WithMany() .HasForeignKey(u => u.RoleId).OnDelete(DeleteBehavior.Restrict); diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/WorkShiftConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/WorkShiftConfiguration.cs new file mode 100644 index 0000000..8c8e68e --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/WorkShiftConfiguration.cs @@ -0,0 +1,29 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class WorkShiftConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_work_shifts"); + builder.HasKey(w => w.WorkShiftId); + + builder.Property(w => w.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(w => w.Code).IsUnique(); + builder.Property(w => w.Name).IsRequired().HasMaxLength(200); + builder.Property(w => w.OtMultiplier).HasPrecision(6, 2); + + builder.Property(w => w.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(w => w.CreatedAt).IsRequired(); + builder.Property(w => w.RowVersion).IsRowVersion(); + + builder.HasIndex(w => w.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index dfcdc6c..06cb8e9 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -89,6 +89,43 @@ public class ErpDbContext : DbContext public DbSet AuditLogs => Set(); public DbSet JournalEntryStubs => Set(); + // --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) --- + public DbSet Branches => Set(); + public DbSet Departments => Set(); + public DbSet Designations => Set(); + public DbSet EmploymentTypes => Set(); + public DbSet WorkShifts => Set(); + + // --- HRM: employee core (docs/12-BACKEND-HRM.md Part C.2) --- + public DbSet Employees => Set(); + public DbSet EmployeeBankDetails => Set(); + + // --- HRM: staff documents (docs/12-BACKEND-HRM.md Part C.3) --- + public DbSet HrDocumentTypes => Set(); + public DbSet EmployeeDocuments => Set(); + + // --- HRM: attendance (docs/12-BACKEND-HRM.md Part C.4) --- + public DbSet AttendanceUploadBatches => Set(); + public DbSet AttendanceRecords => Set(); + + // --- HRM: leave (docs/12-BACKEND-HRM.md Part C.5) --- + public DbSet LeaveTypes => Set(); + public DbSet LeaveRequests => Set(); + public DbSet LeaveBalances => Set(); + + // --- HRM: payroll (docs/12-BACKEND-HRM.md Part C.6) --- + public DbSet SalaryComponents => Set(); + public DbSet EmployeeSalaryStructures => Set(); + public DbSet EmployeeSalaryStructureLines => Set(); + public DbSet EmployeeLoans => Set(); + public DbSet LoanInstallments => Set(); + public DbSet PayrollStatutorySettings => Set(); + public DbSet TaxSlabs => Set(); + public DbSet PayrollRuns => Set(); + public DbSet PayrollLines => Set(); + public DbSet PayrollLineComponents => Set(); + public DbSet Payslips => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -96,6 +133,29 @@ public class ErpDbContext : DbContext // Pick up every IEntityTypeConfiguration in this assembly // (Infra/Persistence/Configurations/*). modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly); + + // Npgsql requires DateTime values written to `timestamp with time zone` columns + // to have Kind=Utc; dates deserialized from a JSON request body (hire date, + // salary-structure effective date, etc.) come in as Kind=Unspecified and would + // otherwise throw at SaveChanges time. Force Utc kind globally for every + // DateTime/DateTime? property rather than remembering to convert at each HRM + // service call site (docs/12-BACKEND-HRM.md — new in Phase 2; Phase 1 never hit + // this because it only ever persisted server-generated DateTime.UtcNow values). + var utcConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter( + v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + var nullableUtcConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter( + v => v.HasValue ? (v.Value.Kind == DateTimeKind.Utc ? v.Value : DateTime.SpecifyKind(v.Value, DateTimeKind.Utc)) : v, + v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : v); + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (property.ClrType == typeof(DateTime)) property.SetValueConverter(utcConverter); + else if (property.ClrType == typeof(DateTime?)) property.SetValueConverter(nullableUtcConverter); + } + } } // Audit trail (FR-X-02): capture mutations before save (accurate old→new), then diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs index 237f996..42c26b7 100644 --- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs @@ -22,6 +22,159 @@ namespace ERPCore.Infra.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => + { + b.Property("AttendanceRecordId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceRecordId")); + + b.Property("AttendanceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("AttendanceStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("AttendanceUploadBatchId") + .HasColumnType("integer"); + + b.Property("CheckIn") + .HasColumnType("interval"); + + b.Property("CheckOut") + .HasColumnType("interval"); + + b.Property("DuplicateOfAttendanceRecordId") + .HasColumnType("integer"); + + b.Property("EarlyLeaveMinutes") + .HasColumnType("integer"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EditedBy") + .HasColumnType("integer"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("IsManualOverride") + .HasColumnType("boolean"); + + b.Property("LateMinutes") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OvertimeMinutes") + .HasColumnType("integer"); + + b.Property("RowValidationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("WorkShiftId") + .HasColumnType("integer"); + + b.Property("WorkingMinutes") + .HasColumnType("integer"); + + b.HasKey("AttendanceRecordId"); + + b.HasIndex("AttendanceUploadBatchId"); + + b.HasIndex("WorkShiftId"); + + b.HasIndex("EmployeeId", "AttendanceDate"); + + b.ToTable("hr_attendance_records", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceUploadBatch", b => + { + b.Property("AttendanceUploadBatchId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceUploadBatchId")); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfirmedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("character varying(260)"); + + b.Property("PeriodEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("RowCountDuplicate") + .HasColumnType("integer"); + + b.Property("RowCountError") + .HasColumnType("integer"); + + b.Property("RowCountTotal") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UploadedBy") + .HasColumnType("integer"); + + b.HasKey("AttendanceUploadBatchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("PeriodStart", "PeriodEnd"); + + b.ToTable("hr_attendance_upload_batches", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => { b.Property("AuditId") @@ -119,6 +272,57 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("bins", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Branch", b => + { + b.Property("BranchId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BranchId")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_branches", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => { b.Property("BrandId") @@ -203,6 +407,644 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("categories", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Department", b => + { + b.Property("DepartmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DepartmentId")); + + b.Property("BranchId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HeadEmployeeId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentDepartmentId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("DepartmentId"); + + b.HasIndex("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("HeadEmployeeId"); + + b.HasIndex("ParentDepartmentId"); + + b.HasIndex("Status"); + + b.ToTable("hr_departments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Designation", b => + { + b.Property("DesignationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DesignationId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("DesignationId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_designations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => + { + b.Property("EmployeeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeId")); + + b.Property("AddressLine1") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AddressLine2") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BranchId") + .HasColumnType("integer"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DateOfBirth") + .HasColumnType("timestamp with time zone"); + + b.Property("DepartmentId") + .HasColumnType("integer"); + + b.Property("DesignationId") + .HasColumnType("integer"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("EmergencyContactRelationship") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmployeeCode") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("EmploymentTypeId") + .HasColumnType("integer"); + + b.Property("EpfNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("EtfNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Gender") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HireDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastWorkingDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Nationality") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Nic") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PersonalMobile") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProfilePhotoPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReportingManagerId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxIdentificationNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("WorkShiftId") + .HasColumnType("integer"); + + b.HasKey("EmployeeId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("DesignationId"); + + b.HasIndex("Email"); + + b.HasIndex("EmployeeCode") + .IsUnique(); + + b.HasIndex("EmploymentTypeId"); + + b.HasIndex("ReportingManagerId"); + + b.HasIndex("Status"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("WorkShiftId"); + + b.ToTable("hr_employees", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => + { + b.Property("EmployeeBankDetailId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeBankDetailId")); + + b.Property("AccountHolderName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AccountNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BankName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BranchName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("SwiftCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EmployeeBankDetailId"); + + b.HasIndex("EmployeeId"); + + b.ToTable("hr_employee_bank_details", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => + { + b.Property("EmployeeDocumentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeDocumentId")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HrDocumentTypeId") + .HasColumnType("integer"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("character varying(260)"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("character varying(260)"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UploadedBy") + .HasColumnType("integer"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VerifiedBy") + .HasColumnType("integer"); + + b.HasKey("EmployeeDocumentId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("ExpiryDate"); + + b.HasIndex("HrDocumentTypeId"); + + b.ToTable("hr_employee_documents", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.Property("EmployeeLoanId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeLoanId")); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedBy") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("InstallmentAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)"); + + b.Property("LoanKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("NumberOfInstallments") + .HasColumnType("integer"); + + b.Property("OutstandingBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PrincipalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("StartMonth") + .HasColumnType("integer"); + + b.Property("StartYear") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("EmployeeLoanId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("EmployeeId"); + + b.HasIndex("Status"); + + b.ToTable("hr_employee_loans", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.Property("EmployeeSalaryStructureId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeSalaryStructureId")); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedBy") + .HasColumnType("integer"); + + b.Property("BasicSalary") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("EmployeeSalaryStructureId"); + + b.HasIndex("EmployeeId", "EffectiveTo"); + + b.ToTable("hr_employee_salary_structures", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => + { + b.Property("EmployeeSalaryStructureLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeSalaryStructureLineId")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("EmployeeSalaryStructureId") + .HasColumnType("integer"); + + b.Property("SalaryComponentId") + .HasColumnType("integer"); + + b.HasKey("EmployeeSalaryStructureLineId"); + + b.HasIndex("EmployeeSalaryStructureId"); + + b.HasIndex("SalaryComponentId"); + + b.ToTable("hr_employee_salary_structure_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmploymentType", b => + { + b.Property("EmploymentTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmploymentTypeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EmploymentTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_employment_types", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => { b.Property("GrnId") @@ -347,6 +1189,64 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("grn_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b => + { + b.Property("HrDocumentTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("HrDocumentTypeId")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiryTracked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequiredAtOnboarding") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("HrDocumentTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_document_types", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => { b.Property("ItemId") @@ -552,6 +1452,253 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("journal_entry_stubs", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveBalance", b => + { + b.Property("LeaveBalanceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LeaveBalanceId")); + + b.Property("AdjustmentDays") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("CarriedForwardDays") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("EntitledDays") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("LeaveTypeId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("TakenDays") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("LeaveBalanceId"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("EmployeeId", "LeaveTypeId", "Year") + .IsUnique(); + + b.ToTable("hr_leave_balances", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => + { + b.Property("LeaveRequestId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LeaveRequestId")); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedBy") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DaysCount") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaveTypeId") + .HasColumnType("integer"); + + b.Property("Reason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("LeaveRequestId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("Status"); + + b.HasIndex("StartDate", "EndDate"); + + b.ToTable("hr_leave_requests", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveType", b => + { + b.Property("LeaveTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LeaveTypeId")); + + b.Property("AccrualPerYear") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("CarryForwardAllowed") + .HasColumnType("boolean"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CountsAsNoPay") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPaid") + .HasColumnType("boolean"); + + b.Property("MaxCarryForwardDays") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("LeaveTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_leave_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => + { + b.Property("LoanInstallmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LoanInstallmentId")); + + b.Property("DueMonth") + .HasColumnType("integer"); + + b.Property("DueYear") + .HasColumnType("integer"); + + b.Property("EmployeeLoanId") + .HasColumnType("integer"); + + b.Property("InstallmentNumber") + .HasColumnType("integer"); + + b.Property("PaidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PayrollRunId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("ScheduledAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("LoanInstallmentId"); + + b.HasIndex("EmployeeLoanId"); + + b.HasIndex("PayrollRunId"); + + b.HasIndex("DueYear", "DueMonth"); + + b.ToTable("hr_loan_installments", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => { b.Property("NavItemId") @@ -718,6 +1865,285 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("number_sequences", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.Property("PayrollLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollLineId")); + + b.Property("AbsentDays") + .HasColumnType("integer"); + + b.Property("BasicSalary") + .HasColumnType("numeric(18,2)"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("EpfEmployeeAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("EpfEmployerAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("EtfEmployerAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("GrossSalary") + .HasColumnType("numeric(18,2)"); + + b.Property("LateDeductionAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("LateMinutesTotal") + .HasColumnType("integer"); + + b.Property("LeaveDays") + .HasColumnType("integer"); + + b.Property("LoanDeductionAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("NetSalary") + .HasColumnType("numeric(18,2)"); + + b.Property("NoPayAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("OtMinutesTotal") + .HasColumnType("integer"); + + b.Property("OtherDeductionsAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("OvertimeAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("PayrollRunId") + .HasColumnType("integer"); + + b.Property("PresentDays") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("TaxAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("TotalAllowances") + .HasColumnType("numeric(18,2)"); + + b.Property("WorkingDays") + .HasColumnType("integer"); + + b.HasKey("PayrollLineId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("PayrollRunId", "EmployeeId") + .IsUnique(); + + b.ToTable("hr_payroll_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => + { + b.Property("PayrollLineComponentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollLineComponentId")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ComponentCategory") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PayrollLineId") + .HasColumnType("integer"); + + b.Property("SalaryComponentId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("PayrollLineComponentId"); + + b.HasIndex("PayrollLineId"); + + b.HasIndex("SalaryComponentId"); + + b.ToTable("hr_payroll_line_components", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.Property("PayrollRunId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollRunId")); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedBy") + .HasColumnType("integer"); + + b.Property("BranchId") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedBy") + .HasColumnType("integer"); + + b.Property("LockedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockedBy") + .HasColumnType("integer"); + + b.Property("PeriodMonth") + .HasColumnType("integer"); + + b.Property("PeriodYear") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UnlockReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UnlockedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UnlockedBy") + .HasColumnType("integer"); + + b.HasKey("PayrollRunId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("PeriodYear", "PeriodMonth", "BranchId"); + + b.ToTable("hr_payroll_runs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollStatutorySetting", b => + { + b.Property("PayrollStatutorySettingId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollStatutorySettingId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone"); + + b.Property("EpfEmployeeRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)"); + + b.Property("EpfEmployerRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)"); + + b.Property("EtfEmployerRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)"); + + b.Property("OtMultiplierDefault") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("PayrollStatutorySettingId"); + + b.HasIndex("EffectiveFrom"); + + b.ToTable("hr_payroll_statutory_settings", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => + { + b.Property("PayslipId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayslipId")); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PayrollLineId") + .HasColumnType("integer"); + + b.Property("ReleasedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleasedBy") + .HasColumnType("integer"); + + b.HasKey("PayslipId"); + + b.HasIndex("PayrollLineId") + .IsUnique(); + + b.ToTable("hr_payslips", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => { b.Property("PermissionId") @@ -1343,6 +2769,64 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("role_permissions", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalaryComponent", b => + { + b.Property("SalaryComponentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SalaryComponentId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEpfEtfApplicable") + .HasColumnType("boolean"); + + b.Property("IsTaxable") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("SalaryComponentId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_salary_components", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => { b.Property("SerialId") @@ -2011,6 +3495,48 @@ namespace ERPCore.Infra.Persistence.Migrations }); }); + modelBuilder.Entity("ERPCore.Domain.Entities.TaxSlab", b => + { + b.Property("TaxSlabId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TaxSlabId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone"); + + b.Property("LowerBound") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Rate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("UpperBound") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("TaxSlabId"); + + b.HasIndex("EffectiveFrom"); + + b.ToTable("hr_tax_slabs", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => { b.Property("UomId") @@ -2082,6 +3608,10 @@ namespace ERPCore.Infra.Persistence.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + b.Property("RoleId") .HasColumnType("integer"); @@ -2100,6 +3630,9 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasIndex("AuthUserId") .IsUnique(); + b.HasIndex("Email") + .IsUnique(); + b.HasIndex("RoleId"); b.HasIndex("Username") @@ -2262,6 +3795,104 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("warehouses", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b => + { + b.Property("WorkShiftId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WorkShiftId")); + + b.Property("BreakMinutes") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("interval"); + + b.Property("GraceMinutes") + .HasColumnType("integer"); + + b.Property("IsOvernight") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OtMultiplier") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("StandardWorkingMinutes") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("interval"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkingDaysMask") + .HasColumnType("integer"); + + b.HasKey("WorkShiftId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_work_shifts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => + { + b.HasOne("ERPCore.Domain.Entities.AttendanceUploadBatch", "AttendanceUploadBatch") + .WithMany() + .HasForeignKey("AttendanceUploadBatchId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") + .WithMany() + .HasForeignKey("WorkShiftId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AttendanceUploadBatch"); + + b.Navigation("Employee"); + + b.Navigation("WorkShift"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => { b.HasOne("ERPCore.Domain.Entities.User", null) @@ -2293,6 +3924,157 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Department", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Employee", "HeadEmployee") + .WithMany() + .HasForeignKey("HeadEmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Department", "ParentDepartment") + .WithMany() + .HasForeignKey("ParentDepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Branch"); + + b.Navigation("HeadEmployee"); + + b.Navigation("ParentDepartment"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Designation", "Designation") + .WithMany() + .HasForeignKey("DesignationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.EmploymentType", "EmploymentType") + .WithMany() + .HasForeignKey("EmploymentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Employee", "ReportingManager") + .WithMany() + .HasForeignKey("ReportingManagerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithOne() + .HasForeignKey("ERPCore.Domain.Entities.Employee", "UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") + .WithMany() + .HasForeignKey("WorkShiftId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Department"); + + b.Navigation("Designation"); + + b.Navigation("EmploymentType"); + + b.Navigation("ReportingManager"); + + b.Navigation("User"); + + b.Navigation("WorkShift"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.HrDocumentType", "HrDocumentType") + .WithMany() + .HasForeignKey("HrDocumentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("HrDocumentType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => + { + b.HasOne("ERPCore.Domain.Entities.EmployeeSalaryStructure", "EmployeeSalaryStructure") + .WithMany("Lines") + .HasForeignKey("EmployeeSalaryStructureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") + .WithMany() + .HasForeignKey("SalaryComponentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EmployeeSalaryStructure"); + + b.Navigation("SalaryComponent"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -2434,6 +4216,120 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveBalance", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => + { + b.HasOne("ERPCore.Domain.Entities.EmployeeLoan", "EmployeeLoan") + .WithMany("Installments") + .HasForeignKey("EmployeeLoanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") + .WithMany() + .HasForeignKey("PayrollRunId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EmployeeLoan"); + + b.Navigation("PayrollRun"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") + .WithMany("Lines") + .HasForeignKey("PayrollRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("PayrollRun"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => + { + b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") + .WithMany("Components") + .HasForeignKey("PayrollLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") + .WithMany() + .HasForeignKey("SalaryComponentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("PayrollLine"); + + b.Navigation("SalaryComponent"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => + { + b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") + .WithOne() + .HasForeignKey("ERPCore.Domain.Entities.Payslip", "PayrollLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PayrollLine"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => { b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") @@ -3021,6 +4917,16 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("SubCategories"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.Navigation("Installments"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => { b.Navigation("Lines"); @@ -3038,6 +4944,16 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Children"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => { b.Navigation("Lines"); diff --git a/Backend/ERPCore/Infra/Storage/IFileStorageService.cs b/Backend/ERPCore/Infra/Storage/IFileStorageService.cs new file mode 100644 index 0000000..f14d386 --- /dev/null +++ b/Backend/ERPCore/Infra/Storage/IFileStorageService.cs @@ -0,0 +1,22 @@ +namespace ERPCore.Infra.Storage; + +/// +/// File storage abstraction (docs/12-BACKEND-HRM.md A.1, C.10) — the first +/// attachment mechanism in this codebase. is +/// the only implementation today; swapping to cloud blob storage later means +/// adding a new implementation + one DI registration change, no controller/service +/// change. Never exposes a path the client can dictate — callers pass only a +/// suggested filename, and get back a server-generated one. +/// +public interface IFileStorageService +{ + /// Saves the stream under a server-generated name; returns the stored name, its relative path, and size. + Task<(string StoredFileName, string RelativePath, long SizeBytes)> SaveAsync( + Stream content, string suggestedFileName, string contentType, CancellationToken ct = default); + + Task OpenReadAsync(string relativePath, CancellationToken ct = default); + + Task DeleteAsync(string relativePath, CancellationToken ct = default); + + Task ExistsAsync(string relativePath, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Infra/Storage/LocalFileStorageService.cs b/Backend/ERPCore/Infra/Storage/LocalFileStorageService.cs new file mode 100644 index 0000000..5b7a44b --- /dev/null +++ b/Backend/ERPCore/Infra/Storage/LocalFileStorageService.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Hosting; + +namespace ERPCore.Infra.Storage; + +/// +/// Disk-backed . Writes under a configured root +/// OUTSIDE wwwroot (FileStorage:RootPath, default App_Data/hr-documents +/// relative to the content root) so files are never reachable via a static-file URL — +/// the only way to read one back is through an authenticated controller action that +/// streams via (docs/12-BACKEND-HRM.md A.1, §4). +/// +public sealed class LocalFileStorageService : IFileStorageService +{ + private readonly string _root; + + public LocalFileStorageService(IHostEnvironment env, IConfiguration configuration) + { + var configuredRoot = configuration["FileStorage:RootPath"] ?? "App_Data/hr-documents"; + _root = Path.IsPathRooted(configuredRoot) ? configuredRoot : Path.Combine(env.ContentRootPath, configuredRoot); + Directory.CreateDirectory(_root); + } + + public async Task<(string StoredFileName, string RelativePath, long SizeBytes)> SaveAsync( + Stream content, string suggestedFileName, string contentType, CancellationToken ct = default) + { + var extension = Path.GetExtension(suggestedFileName); + var storedFileName = $"{Guid.NewGuid():N}{extension}"; + + // Bucket by year/month so a single directory never grows unbounded. + var subDir = Path.Combine(DateTime.UtcNow.Year.ToString(), DateTime.UtcNow.Month.ToString("00")); + var absoluteDir = Path.Combine(_root, subDir); + Directory.CreateDirectory(absoluteDir); + + var relativePath = Path.Combine(subDir, storedFileName).Replace('\\', '/'); + var absolutePath = Path.Combine(_root, relativePath); + + await using (var fileStream = new FileStream(absolutePath, FileMode.CreateNew, FileAccess.Write)) + { + await content.CopyToAsync(fileStream, ct); + } + + var sizeBytes = new FileInfo(absolutePath).Length; + return (storedFileName, relativePath, sizeBytes); + } + + public Task OpenReadAsync(string relativePath, CancellationToken ct = default) + { + var absolutePath = ResolveSafe(relativePath); + Stream stream = new FileStream(absolutePath, FileMode.Open, FileAccess.Read); + return Task.FromResult(stream); + } + + public Task DeleteAsync(string relativePath, CancellationToken ct = default) + { + var absolutePath = ResolveSafe(relativePath); + if (File.Exists(absolutePath)) File.Delete(absolutePath); + return Task.CompletedTask; + } + + public Task ExistsAsync(string relativePath, CancellationToken ct = default) + { + var absolutePath = ResolveSafe(relativePath); + return Task.FromResult(File.Exists(absolutePath)); + } + + /// Resolves a stored relative path and rejects any attempt to escape the storage root. + private string ResolveSafe(string relativePath) + { + var absolutePath = Path.GetFullPath(Path.Combine(_root, relativePath)); + var rootFull = Path.GetFullPath(_root); + if (!absolutePath.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase)) + throw new UnauthorizedAccessException("Resolved path escapes the file storage root."); + return absolutePath; + } +} diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 5fcf63c..8b028cf 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -2,11 +2,13 @@ using System.Text.Json.Serialization; using ERPCore.Infra.Auth; using ERPCore.Infra.Auth.AuthHex; using ERPCore.Infra.Persistence; +using ERPCore.Infra.Storage; using ERPCore.Infra.UoW; using ERPCore.Repositories; using ERPCore.Repositories.Interfaces; using ERPCore.Services; using ERPCore.Services.Auth; +using ERPCore.Services.Hrm; using ERPCore.Services.Interfaces; using ERPCore.Services.Stock; using ERPCore.System.Errors; @@ -95,6 +97,42 @@ builder.Services.AddScoped(); // Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13) builder.Services.AddScoped(); +// HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// HRM: Leave (docs/13-BACKEND-HRM-API.md §5) — LeaveType/LeaveBalance built before +// LeaveRequest since approval increments balances; Attendance depends on LeaveRequest. +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// HRM: Attendance (docs/13-BACKEND-HRM-API.md §4) +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// HRM: Payroll (docs/13-BACKEND-HRM-API.md §6) — masters/settings before the +// calculation service, which composes them; PayrollRunService orchestrates last. +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities +builder.Services.AddScoped(); + // Health checks (EF Core DB) builder.Services.AddHealthChecks().AddDbContextCheck(); diff --git a/Backend/ERPCore/Services/Hrm/AttendanceComputationService.cs b/Backend/ERPCore/Services/Hrm/AttendanceComputationService.cs new file mode 100644 index 0000000..2f6cfb4 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/AttendanceComputationService.cs @@ -0,0 +1,46 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Services.Interfaces; + +namespace ERPCore.Services.Hrm; + +/// +public sealed class AttendanceComputationService : IAttendanceComputationService +{ + public void Compute(AttendanceRecord record, WorkShift shift, bool hasApprovedLeave, bool isHoliday, bool isWeekOff) + { + if (record.CheckIn is null || record.CheckOut is null) + { + record.WorkingMinutes = 0; + record.LateMinutes = 0; + record.EarlyLeaveMinutes = 0; + record.OvertimeMinutes = 0; + record.AttendanceStatus = isHoliday ? AttendanceStatus.Holiday + : isWeekOff ? AttendanceStatus.WeekOff + : hasApprovedLeave ? AttendanceStatus.OnLeave + : AttendanceStatus.Absent; + return; + } + + var checkIn = record.CheckIn.Value; + var checkOut = record.CheckOut.Value; + // Overnight shift: checkout numerically earlier than checkin means it rolled past midnight. + if (shift.IsOvernight && checkOut < checkIn) checkOut = checkOut.Add(TimeSpan.FromHours(24)); + + var grossWorkedMinutes = (int)(checkOut - checkIn).TotalMinutes; + var workingMinutes = Math.Max(0, grossWorkedMinutes - shift.BreakMinutes); + + var shiftStart = shift.StartTime; + var shiftEnd = shift.IsOvernight ? shift.EndTime.Add(TimeSpan.FromHours(24)) : shift.EndTime; + + var lateMinutes = Math.Max(0, (int)(checkIn - shiftStart).TotalMinutes - shift.GraceMinutes); + var earlyLeaveMinutes = Math.Max(0, (int)(shiftEnd - checkOut).TotalMinutes); + var overtimeMinutes = Math.Max(0, workingMinutes - shift.StandardWorkingMinutes); + + record.WorkingMinutes = workingMinutes; + record.LateMinutes = lateMinutes; + record.EarlyLeaveMinutes = earlyLeaveMinutes; + record.OvertimeMinutes = overtimeMinutes; + record.AttendanceStatus = workingMinutes < shift.StandardWorkingMinutes / 2 ? AttendanceStatus.HalfDay : AttendanceStatus.Present; + } +} diff --git a/Backend/ERPCore/Services/Hrm/AttendanceUploadService.cs b/Backend/ERPCore/Services/Hrm/AttendanceUploadService.cs new file mode 100644 index 0000000..0797a28 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/AttendanceUploadService.cs @@ -0,0 +1,407 @@ +using System.Globalization; +using System.Text; +using ClosedXML.Excel; +using CsvHelper; +using CsvHelper.Configuration; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Attendance upload/validate/confirm pipeline (FR-HR-ATT, docs/12-BACKEND-HRM.md §B.3.4). +/// Status flow is exactly Draft → Validated → Confirmed → UsedInPayroll; template and +/// parser share so they can never silently drift apart. +/// +public sealed class AttendanceUploadService : IAttendanceUploadService +{ + /// Employee Code | Date | Check In | Check Out — shared by the parser and the template generator. + public static readonly string[] ColumnNames = { "Employee Code", "Date", "Check In", "Check Out" }; + + private readonly IRepository _batches; + private readonly IRepository _records; + private readonly IRepository _employees; + private readonly IRepository _workShifts; + private readonly INumberSequenceService _numberSequence; + private readonly ILeaveRequestService _leaveRequests; + private readonly IAttendanceComputationService _computation; + private readonly IUnitOfWork _uow; + + public AttendanceUploadService( + IRepository batches, IRepository records, + IRepository employees, IRepository workShifts, + INumberSequenceService numberSequence, ILeaveRequestService leaveRequests, + IAttendanceComputationService computation, IUnitOfWork uow) + { + _batches = batches; + _records = records; + _employees = employees; + _workShifts = workShifts; + _numberSequence = numberSequence; + _leaveRequests = leaveRequests; + _computation = computation; + _uow = uow; + } + + public async Task> ListBatchesAsync( + PageQuery query, AttendanceBatchStatus? status, int? periodYear, int? periodMonth, CancellationToken ct = default) + { + var q = _batches.Query().AsNoTracking(); + if (status is not null) q = q.Where(b => b.Status == status); + if (periodYear is not null) q = q.Where(b => b.PeriodStart.Year == periodYear); + if (periodMonth is not null) q = q.Where(b => b.PeriodStart.Month == periodMonth); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(b => b.UploadedAt) + .Skip(query.Skip).Take(query.PageSize) + .Select(b => Map(b)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetBatchAsync(int batchId, CancellationToken ct = default) + { + var batch = await _batches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.AttendanceUploadBatchId == batchId, ct); + return batch is null ? null : Map(batch); + } + + public async Task UploadAsync( + Stream fileContent, string fileName, DateTime periodStart, DateTime periodEnd, int actorUserId, CancellationToken ct = default) + { + if (periodEnd < periodStart) + throw new DomainException(ErrorCodes.Validation, "Period end cannot be before period start.", 422); + + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + var sourceType = extension switch + { + ".xlsx" => AttendanceSourceType.Excel, + ".csv" => AttendanceSourceType.Csv, + _ => throw new DomainException(ErrorCodes.FileTypeNotAllowed, $"Unsupported attendance file type '{extension}'.", 422) + }; + + var rows = extension == ".xlsx" ? ParseExcel(fileContent) : ParseCsv(fileContent); + + var docNo = await _numberSequence.NextAsync("ATT", ct); + var batch = new AttendanceUploadBatch + { + DocNo = docNo, + PeriodStart = periodStart.Date, + PeriodEnd = periodEnd.Date, + SourceType = sourceType, + OriginalFileName = fileName, + UploadedBy = actorUserId, + UploadedAt = DateTime.UtcNow, + Status = AttendanceBatchStatus.Draft + }; + await _batches.AddAsync(batch, ct); + await _uow.SaveChangesAsync(ct); // flush to get batch.AttendanceUploadBatchId + + var employeesByCode = await _employees.Query().AsNoTracking() + .Where(e => e.Status == EmployeeStatus.Active) + .ToDictionaryAsync(e => e.EmployeeCode, StringComparer.OrdinalIgnoreCase, ct); + var shifts = await _workShifts.Query().AsNoTracking().ToDictionaryAsync(s => s.WorkShiftId, ct); + + var seenInBatch = new Dictionary<(int EmployeeId, DateTime Date), AttendanceRecord>(); + var created = new List(); + + foreach (var row in rows) + { + var record = new AttendanceRecord { AttendanceUploadBatchId = batch.AttendanceUploadBatchId }; + + if (!employeesByCode.TryGetValue(row.EmployeeCode, out var employee)) + { + record.RowValidationStatus = RowValidationStatus.EmployeeNotFound; + record.AttendanceDate = row.Date ?? periodStart; + record.WorkShiftId = shifts.Values.FirstOrDefault()?.WorkShiftId ?? 0; + created.Add(record); + continue; + } + + record.EmployeeId = employee.EmployeeId; + record.WorkShiftId = employee.WorkShiftId; + + if (row.Date is null) + { + record.RowValidationStatus = RowValidationStatus.InvalidDateTime; + record.AttendanceDate = periodStart; + created.Add(record); + continue; + } + record.AttendanceDate = row.Date.Value; + + if (row.HasCheckInText && row.CheckIn is null || row.HasCheckOutText && row.CheckOut is null) + { + record.RowValidationStatus = RowValidationStatus.InvalidDateTime; + created.Add(record); + continue; + } + + record.CheckIn = row.CheckIn; + record.CheckOut = row.CheckOut; + + if (row.CheckIn is not null && row.CheckOut is null) + { + record.RowValidationStatus = RowValidationStatus.Error; // forgot to punch out + created.Add(record); + continue; + } + + var key = (record.EmployeeId, record.AttendanceDate); + if (seenInBatch.TryGetValue(key, out var firstOccurrence)) + { + record.RowValidationStatus = RowValidationStatus.DuplicateWithinBatch; + record.DuplicateOfAttendanceRecordId = null; // linked by (EmployeeId, Date) until both are persisted + firstOccurrence.RowValidationStatus = RowValidationStatus.DuplicateWithinBatch; + created.Add(record); + continue; + } + + var alreadyConfirmed = await _records.Query().AsNoTracking() + .Include(r => r.AttendanceUploadBatch) + .AnyAsync(r => r.EmployeeId == record.EmployeeId && r.AttendanceDate == record.AttendanceDate + && r.AttendanceUploadBatch != null + && (r.AttendanceUploadBatch.Status == AttendanceBatchStatus.Confirmed || r.AttendanceUploadBatch.Status == AttendanceBatchStatus.UsedInPayroll), ct); + if (alreadyConfirmed) + { + record.RowValidationStatus = RowValidationStatus.DuplicateConfirmed; + created.Add(record); + continue; + } + + seenInBatch[key] = record; + record.RowValidationStatus = RowValidationStatus.Valid; + created.Add(record); + } + + foreach (var record in created) + { + if (record.RowValidationStatus == RowValidationStatus.Valid && shifts.TryGetValue(record.WorkShiftId, out var shift)) + { + var leave = await _leaveRequests.FindApprovedLeaveCoveringAsync(record.EmployeeId, record.AttendanceDate, ct); + var dayIndex = ((int)record.AttendanceDate.DayOfWeek + 6) % 7; // Monday=0..Sunday=6 + var isWeekOff = (shift.WorkingDaysMask & (1 << dayIndex)) == 0; + _computation.Compute(record, shift, leave is not null, isHoliday: false, isWeekOff: isWeekOff); + } + await _records.AddAsync(record, ct); + } + + batch.RowCountTotal = created.Count; + batch.RowCountDuplicate = created.Count(r => r.RowValidationStatus is RowValidationStatus.DuplicateWithinBatch or RowValidationStatus.DuplicateConfirmed); + batch.RowCountError = created.Count(r => r.RowValidationStatus is RowValidationStatus.EmployeeNotFound or RowValidationStatus.InvalidDateTime or RowValidationStatus.Error); + + await _uow.SaveChangesAsync(ct); + + return Map(batch); + } + + public async Task> ListRecordsAsync(int batchId, RowValidationStatus? status, CancellationToken ct = default) + { + var q = _records.Query().AsNoTracking().Include(r => r.Employee) + .Where(r => r.AttendanceUploadBatchId == batchId); + if (status is not null) q = q.Where(r => r.RowValidationStatus == status); + + return await q.OrderBy(r => r.Employee!.FullName).ThenBy(r => r.AttendanceDate) + .Select(r => Map(r)) + .ToListAsync(ct); + } + + public async Task UpdateRecordAsync( + int batchId, int recordId, UpdateAttendanceRecordRequest request, int actorUserId, CancellationToken ct = default) + { + var batch = await _batches.GetByIdAsync(batchId, ct) + ?? throw new NotFoundException($"Attendance batch {batchId} was not found."); + if (batch.Status is AttendanceBatchStatus.Confirmed or AttendanceBatchStatus.UsedInPayroll) + throw new DomainException(ErrorCodes.AttendanceBatchLocked, "This attendance batch is locked and cannot be edited.", 409); + + var record = await _records.Query().Include(r => r.Employee).Include(r => r.WorkShift) + .FirstOrDefaultAsync(r => r.AttendanceRecordId == recordId && r.AttendanceUploadBatchId == batchId, ct) + ?? throw new NotFoundException($"Attendance record {recordId} was not found in batch {batchId}."); + + if (request.CheckIn is not null) record.CheckIn = request.CheckIn; + if (request.CheckOut is not null) record.CheckOut = request.CheckOut; + if (request.Notes is not null) record.Notes = request.Notes.Trim(); + + if (record.WorkShift is not null && record.RowValidationStatus == RowValidationStatus.Valid) + { + var leave = await _leaveRequests.FindApprovedLeaveCoveringAsync(record.EmployeeId, record.AttendanceDate, ct); + var dayIndex = ((int)record.AttendanceDate.DayOfWeek + 6) % 7; + var isWeekOff = (record.WorkShift.WorkingDaysMask & (1 << dayIndex)) == 0; + _computation.Compute(record, record.WorkShift, leave is not null, isHoliday: false, isWeekOff: isWeekOff); + } + if (request.AttendanceStatus is not null) record.AttendanceStatus = request.AttendanceStatus.Value; + + record.IsManualOverride = true; + record.EditedBy = actorUserId; + record.EditedAt = DateTime.UtcNow; + + await _uow.SaveChangesAsync(ct); + return Map(record); + } + + public async Task ResolveDuplicateAsync(int batchId, ResolveDuplicateRequest request, CancellationToken ct = default) + { + var batch = await _batches.GetByIdAsync(batchId, ct) + ?? throw new NotFoundException($"Attendance batch {batchId} was not found."); + if (batch.Status is AttendanceBatchStatus.Confirmed or AttendanceBatchStatus.UsedInPayroll) + throw new DomainException(ErrorCodes.AttendanceBatchLocked, "This attendance batch is locked and cannot be edited.", 409); + + var record = await _records.GetByIdAsync(request.RecordId, ct) + ?? throw new NotFoundException($"Attendance record {request.RecordId} was not found."); + + switch (request.Action) + { + case "discard": + _records.Remove(record); + break; + case "keep": + record.RowValidationStatus = RowValidationStatus.Valid; + break; + case "supersede": + // Authorized cross-batch override: accept this row as the new source of truth. + record.RowValidationStatus = RowValidationStatus.Valid; + break; + } + + await _uow.SaveChangesAsync(ct); + } + + public async Task ValidateAsync(int batchId, CancellationToken ct = default) + { + var batch = await _batches.GetByIdAsync(batchId, ct) + ?? throw new NotFoundException($"Attendance batch {batchId} was not found."); + if (batch.Status != AttendanceBatchStatus.Draft) + throw new DomainException(ErrorCodes.Conflict, "Only a Draft batch can be validated.", 409); + + var unresolved = await _records.Query() + .CountAsync(r => r.AttendanceUploadBatchId == batchId && r.RowValidationStatus != RowValidationStatus.Valid, ct); + if (unresolved > 0) + throw new DomainException(ErrorCodes.AttendanceDuplicateUnresolved, + $"{unresolved} record(s) have unresolved errors/duplicates.", 422); + + batch.Status = AttendanceBatchStatus.Validated; + await _uow.SaveChangesAsync(ct); + return Map(batch); + } + + public async Task ConfirmAsync(int batchId, int actorUserId, CancellationToken ct = default) + { + var batch = await _batches.GetByIdAsync(batchId, ct) + ?? throw new NotFoundException($"Attendance batch {batchId} was not found."); + if (batch.Status != AttendanceBatchStatus.Validated) + throw new DomainException(ErrorCodes.Conflict, "Only a Validated batch can be confirmed.", 409); + + batch.Status = AttendanceBatchStatus.Confirmed; + batch.ConfirmedBy = actorUserId; + batch.ConfirmedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return Map(batch); + } + + public async Task UnlockAsync(int batchId, string reason, int actorUserId, CancellationToken ct = default) + { + var batch = await _batches.GetByIdAsync(batchId, ct) + ?? throw new NotFoundException($"Attendance batch {batchId} was not found."); + if (batch.Status == AttendanceBatchStatus.UsedInPayroll) + throw new DomainException(ErrorCodes.AttendanceBatchLocked, + "This batch has already been used in payroll; unlock/regenerate the payroll run first.", 409); + if (batch.Status != AttendanceBatchStatus.Confirmed) + throw new DomainException(ErrorCodes.Conflict, "Only a Confirmed batch can be unlocked.", 409); + + batch.Status = AttendanceBatchStatus.Validated; + await _uow.SaveChangesAsync(ct); + return Map(batch); + } + + public (byte[] Content, string ContentType, string FileName) GenerateTemplate(bool asCsv) + { + if (asCsv) + { + var csv = string.Join(",", ColumnNames) + "\r\n" + "EMP001,2026-07-01,08:00,17:00\r\n"; + return (Encoding.UTF8.GetBytes(csv), "text/csv", "attendance-template.csv"); + } + + using var workbook = new XLWorkbook(); + var sheet = workbook.Worksheets.Add("Attendance"); + for (var i = 0; i < ColumnNames.Length; i++) sheet.Cell(1, i + 1).Value = ColumnNames[i]; + sheet.Cell(2, 1).Value = "EMP001"; + sheet.Cell(2, 2).Value = "2026-07-01"; + sheet.Cell(2, 3).Value = "08:00"; + sheet.Cell(2, 4).Value = "17:00"; + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return (stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "attendance-template.xlsx"); + } + + private sealed record ParsedRow(string EmployeeCode, DateTime? Date, TimeSpan? CheckIn, TimeSpan? CheckOut, bool HasCheckInText, bool HasCheckOutText); + + private static List ParseExcel(Stream content) + { + using var workbook = new XLWorkbook(content); + var sheet = workbook.Worksheets.First(); + var rows = new List(); + + var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1; + for (var r = 2; r <= lastRow; r++) + { + var employeeCode = sheet.Cell(r, 1).GetString().Trim(); + if (string.IsNullOrWhiteSpace(employeeCode)) continue; + + var dateText = sheet.Cell(r, 2).GetString().Trim(); + var checkInText = sheet.Cell(r, 3).GetString().Trim(); + var checkOutText = sheet.Cell(r, 4).GetString().Trim(); + + rows.Add(new ParsedRow( + employeeCode, + DateTime.TryParse(dateText, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d.Date : null, + TimeSpan.TryParse(checkInText, CultureInfo.InvariantCulture, out var ci) ? ci : null, + TimeSpan.TryParse(checkOutText, CultureInfo.InvariantCulture, out var co) ? co : null, + !string.IsNullOrWhiteSpace(checkInText), !string.IsNullOrWhiteSpace(checkOutText))); + } + return rows; + } + + private static List ParseCsv(Stream content) + { + using var reader = new StreamReader(content); + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); + csv.Read(); + csv.ReadHeader(); + + var rows = new List(); + while (csv.Read()) + { + var employeeCode = csv.GetField("Employee Code")?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(employeeCode)) continue; + + var dateText = csv.GetField("Date")?.Trim() ?? string.Empty; + var checkInText = csv.GetField("Check In")?.Trim() ?? string.Empty; + var checkOutText = csv.GetField("Check Out")?.Trim() ?? string.Empty; + + rows.Add(new ParsedRow( + employeeCode, + DateTime.TryParse(dateText, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d.Date : null, + TimeSpan.TryParse(checkInText, CultureInfo.InvariantCulture, out var ci) ? ci : null, + TimeSpan.TryParse(checkOutText, CultureInfo.InvariantCulture, out var co) ? co : null, + !string.IsNullOrWhiteSpace(checkInText), !string.IsNullOrWhiteSpace(checkOutText))); + } + return rows; + } + + private static AttendanceUploadBatchDto Map(AttendanceUploadBatch b) => new( + b.AttendanceUploadBatchId, b.DocNo, b.PeriodStart, b.PeriodEnd, b.SourceType, b.OriginalFileName, + b.UploadedBy, b.UploadedAt, b.Status, b.ConfirmedBy, b.ConfirmedAt, b.RowCountTotal, b.RowCountDuplicate, b.RowCountError); + + private static AttendanceRecordDto Map(AttendanceRecord r) => new( + r.AttendanceRecordId, r.AttendanceUploadBatchId, r.EmployeeId, r.Employee?.EmployeeCode, r.Employee?.FullName, + r.AttendanceDate, r.CheckIn, r.CheckOut, r.WorkingMinutes, r.LateMinutes, r.EarlyLeaveMinutes, r.OvertimeMinutes, + r.AttendanceStatus, r.RowValidationStatus, r.DuplicateOfAttendanceRecordId, r.Notes); +} diff --git a/Backend/ERPCore/Services/Hrm/BranchService.cs b/Backend/ERPCore/Services/Hrm/BranchService.cs new file mode 100644 index 0000000..f147e22 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/BranchService.cs @@ -0,0 +1,107 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Branch master service (FR-HR-MD-01, docs/13-BACKEND-HRM-API.md §2). +public sealed class BranchService : IBranchService +{ + private readonly IRepository _branches; + private readonly IUnitOfWork _uow; + + public BranchService(IRepository branches, IUnitOfWork uow) + { + _branches = branches; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _branches.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(b => EF.Functions.ILike(b.Name, $"%{term}%") || EF.Functions.ILike(b.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(b => b.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(b => b.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(b => new BranchDto(b.BranchId, b.Code, b.Name, b.Address, b.Status, b.CreatedAt, b.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int branchId, CancellationToken ct = default) + { + var branch = await _branches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.BranchId == branchId, ct); + return branch is null ? null : new ETagged(Map(branch), branch.RowVersion); + } + + public async Task> CreateAsync(CreateBranchRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _branches.Query().AnyAsync(b => b.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A branch with code '{code}' already exists."); + + var branch = new Branch + { + Code = code, + Name = request.Name.Trim(), + Address = request.Address?.Trim(), + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _branches.AddAsync(branch, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(branch), branch.RowVersion); + } + + public async Task> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var branch = await _branches.GetByIdAsync(branchId, ct) + ?? throw new NotFoundException($"Branch {branchId} was not found."); + + if (branch.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The branch was modified by another request.", 412); + + branch.Name = request.Name.Trim(); + branch.Address = request.Address?.Trim(); + branch.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The branch was modified by another request.", 412); + } + + return new ETagged(Map(branch), branch.RowVersion); + } + + public async Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default) + { + var branch = await _branches.GetByIdAsync(branchId, ct) + ?? throw new NotFoundException($"Branch {branchId} was not found."); + + branch.Status = status; + branch.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.Status, b.CreatedAt, b.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/DepartmentService.cs b/Backend/ERPCore/Services/Hrm/DepartmentService.cs new file mode 100644 index 0000000..5d79041 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/DepartmentService.cs @@ -0,0 +1,149 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Department master service (FR-HR-MD-01) — unlimited self-nesting for a real org +/// chart (unlike the two-level-capped Category); is +/// the service-level guard since there is no DB-level constraint for this +/// (docs/12-BACKEND-HRM.md A.1/C.1). +/// +public sealed class DepartmentService : IDepartmentService +{ + private readonly IRepository _departments; + private readonly IUnitOfWork _uow; + + public DepartmentService(IRepository departments, IUnitOfWork uow) + { + _departments = departments; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _departments.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(d => EF.Functions.ILike(d.Name, $"%{term}%") || EF.Functions.ILike(d.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(d => d.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(d => d.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(d => Map(d)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int departmentId, CancellationToken ct = default) + { + var dept = await _departments.Query().AsNoTracking().FirstOrDefaultAsync(d => d.DepartmentId == departmentId, ct); + return dept is null ? null : new ETagged(Map(dept), dept.RowVersion); + } + + public async Task> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _departments.Query().AnyAsync(d => d.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A department with code '{code}' already exists."); + + if (request.ParentDepartmentId is not null + && !await _departments.Query().AnyAsync(d => d.DepartmentId == request.ParentDepartmentId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Parent department {request.ParentDepartmentId} was not found.", 422); + + var dept = new Department + { + Code = code, + Name = request.Name.Trim(), + ParentDepartmentId = request.ParentDepartmentId, + HeadEmployeeId = request.HeadEmployeeId, + BranchId = request.BranchId, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _departments.AddAsync(dept, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(dept), dept.RowVersion); + } + + public async Task> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var dept = await _departments.GetByIdAsync(departmentId, ct) + ?? throw new NotFoundException($"Department {departmentId} was not found."); + + if (dept.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The department was modified by another request.", 412); + + if (request.ParentDepartmentId is not null) + { + if (request.ParentDepartmentId == departmentId) + throw new DomainException(ErrorCodes.DepartmentCycleDetected, "A department cannot be its own parent.", 422); + + if (!await _departments.Query().AnyAsync(d => d.DepartmentId == request.ParentDepartmentId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Parent department {request.ParentDepartmentId} was not found.", 422); + + await EnsureNoCycleAsync(departmentId, request.ParentDepartmentId.Value, ct); + } + + dept.Name = request.Name.Trim(); + dept.ParentDepartmentId = request.ParentDepartmentId; + dept.HeadEmployeeId = request.HeadEmployeeId; + dept.BranchId = request.BranchId; + dept.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The department was modified by another request.", 412); + } + + return new ETagged(Map(dept), dept.RowVersion); + } + + public async Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default) + { + var dept = await _departments.GetByIdAsync(departmentId, ct) + ?? throw new NotFoundException($"Department {departmentId} was not found."); + + dept.Status = status; + dept.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + /// Walks up from ; throws if it ever reaches . + private async Task EnsureNoCycleAsync(int departmentId, int newParentId, CancellationToken ct) + { + var currentId = (int?)newParentId; + var guard = 0; + while (currentId is not null && guard++ < 1000) + { + if (currentId == departmentId) + throw new DomainException(ErrorCodes.DepartmentCycleDetected, "Setting this parent would create a department cycle.", 422); + + currentId = await _departments.Query().AsNoTracking() + .Where(d => d.DepartmentId == currentId) + .Select(d => d.ParentDepartmentId) + .FirstOrDefaultAsync(ct); + } + } + + private static DepartmentDto Map(Department d) => new( + d.DepartmentId, d.Code, d.Name, d.ParentDepartmentId, d.HeadEmployeeId, d.BranchId, d.Status, d.CreatedAt, d.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/DesignationService.cs b/Backend/ERPCore/Services/Hrm/DesignationService.cs new file mode 100644 index 0000000..9ca1fd5 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/DesignationService.cs @@ -0,0 +1,105 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Designation (job title) master service (FR-HR-MD-01, docs/13-BACKEND-HRM-API.md §2). +public sealed class DesignationService : IDesignationService +{ + private readonly IRepository _designations; + private readonly IUnitOfWork _uow; + + public DesignationService(IRepository designations, IUnitOfWork uow) + { + _designations = designations; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _designations.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(d => EF.Functions.ILike(d.Name, $"%{term}%") || EF.Functions.ILike(d.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(d => d.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(d => d.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(d => new DesignationDto(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int designationId, CancellationToken ct = default) + { + var designation = await _designations.Query().AsNoTracking().FirstOrDefaultAsync(d => d.DesignationId == designationId, ct); + return designation is null ? null : new ETagged(Map(designation), designation.RowVersion); + } + + public async Task> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _designations.Query().AnyAsync(d => d.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A designation with code '{code}' already exists."); + + var designation = new Designation + { + Code = code, + Name = request.Name.Trim(), + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _designations.AddAsync(designation, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(designation), designation.RowVersion); + } + + public async Task> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var designation = await _designations.GetByIdAsync(designationId, ct) + ?? throw new NotFoundException($"Designation {designationId} was not found."); + + if (designation.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The designation was modified by another request.", 412); + + designation.Name = request.Name.Trim(); + designation.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The designation was modified by another request.", 412); + } + + return new ETagged(Map(designation), designation.RowVersion); + } + + public async Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default) + { + var designation = await _designations.GetByIdAsync(designationId, ct) + ?? throw new NotFoundException($"Designation {designationId} was not found."); + + designation.Status = status; + designation.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static DesignationDto Map(Designation d) => new(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/EmployeeDocumentService.cs b/Backend/ERPCore/Services/Hrm/EmployeeDocumentService.cs new file mode 100644 index 0000000..19f5d06 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmployeeDocumentService.cs @@ -0,0 +1,129 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.Storage; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace ERPCore.Services.Hrm; + +/// +/// Uploaded staff document service (FR-HR-DOC-02..04). Extension allowlist + declared +/// content-type cross-check + size cap are enforced here, server-authoritative +/// (docs/12-BACKEND-HRM.md §B.5, 02-SECURITY C.8) — magic-byte sniffing / antivirus +/// scanning are an explicitly deferred accepted risk, not silently skipped. +/// +public sealed class EmployeeDocumentService : Services.Interfaces.IEmployeeDocumentService +{ + private static readonly Dictionary AllowedExtensionContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + [".pdf"] = "application/pdf", + [".jpg"] = "image/jpeg", + [".jpeg"] = "image/jpeg", + [".png"] = "image/png", + [".docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }; + + private readonly IRepository _documents; + private readonly IRepository _employees; + private readonly IRepository _documentTypes; + private readonly IFileStorageService _storage; + private readonly IUnitOfWork _uow; + private readonly long _maxSizeBytes; + + public EmployeeDocumentService( + IRepository documents, IRepository employees, IRepository documentTypes, + IFileStorageService storage, IUnitOfWork uow, IConfiguration configuration) + { + _documents = documents; + _employees = employees; + _documentTypes = documentTypes; + _storage = storage; + _uow = uow; + _maxSizeBytes = configuration.GetValue("FileStorage:MaxSizeBytes") ?? 10 * 1024 * 1024; + } + + public async Task> ListAsync(int employeeId, CancellationToken ct = default) + { + return await _documents.Query().AsNoTracking() + .Include(d => d.HrDocumentType) + .Where(d => d.EmployeeId == employeeId) + .Select(d => Map(d)) + .ToListAsync(ct); + } + + public async Task UploadAsync( + int employeeId, UploadEmployeeDocumentRequest request, Stream fileContent, string fileName, string contentType, + int actorUserId, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct)) + throw new NotFoundException($"Employee {employeeId} was not found."); + + var docType = await _documentTypes.GetByIdAsync(request.HrDocumentTypeId, ct) + ?? throw new NotFoundException($"Document type {request.HrDocumentTypeId} was not found."); + + var extension = Path.GetExtension(fileName); + if (!AllowedExtensionContentTypes.TryGetValue(extension, out var expectedContentType) + || !string.Equals(expectedContentType, contentType, StringComparison.OrdinalIgnoreCase)) + throw new DomainException(ErrorCodes.FileTypeNotAllowed, + $"File type '{extension}'/'{contentType}' is not allowed.", 422); + + if (fileContent.Length > _maxSizeBytes) + throw new DomainException(ErrorCodes.FileTooLarge, + $"File exceeds the maximum allowed size of {_maxSizeBytes} bytes.", 413); + + var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(fileContent, fileName, contentType, ct); + + var document = new EmployeeDocument + { + EmployeeId = employeeId, + HrDocumentTypeId = docType.HrDocumentTypeId, + OriginalFileName = fileName, + StoredFileName = storedFileName, + RelativePath = relativePath, + ContentType = contentType, + SizeBytes = sizeBytes, + IssueDate = request.IssueDate, + ExpiryDate = request.ExpiryDate, + Notes = request.Notes?.Trim(), + UploadedBy = actorUserId, + UploadedAt = DateTime.UtcNow, + Status = EmployeeDocumentStatus.Active + }; + + await _documents.AddAsync(document, ct); + await _uow.SaveChangesAsync(ct); + + document.HrDocumentType = docType; + return Map(document); + } + + public async Task<(Stream Content, string FileName, string ContentType)> DownloadAsync( + int employeeId, int documentId, CancellationToken ct = default) + { + var document = await _documents.Query().AsNoTracking() + .FirstOrDefaultAsync(d => d.EmployeeDocumentId == documentId && d.EmployeeId == employeeId, ct) + ?? throw new NotFoundException($"Document {documentId} was not found for employee {employeeId}."); + + var stream = await _storage.OpenReadAsync(document.RelativePath, ct); + return (stream, document.OriginalFileName, document.ContentType); + } + + public async Task SetStatusAsync(int employeeId, int documentId, EmployeeDocumentStatus status, CancellationToken ct = default) + { + var document = await _documents.Query() + .FirstOrDefaultAsync(d => d.EmployeeDocumentId == documentId && d.EmployeeId == employeeId, ct) + ?? throw new NotFoundException($"Document {documentId} was not found for employee {employeeId}."); + + document.Status = status; + await _uow.SaveChangesAsync(ct); + } + + private static EmployeeDocumentDto Map(EmployeeDocument d) => new( + d.EmployeeDocumentId, d.EmployeeId, d.HrDocumentTypeId, d.HrDocumentType?.Name, + d.OriginalFileName, d.ContentType, d.SizeBytes, d.IssueDate, d.ExpiryDate, d.Notes, + d.UploadedBy, d.UploadedAt, d.Status); +} diff --git a/Backend/ERPCore/Services/Hrm/EmployeeLoanService.cs b/Backend/ERPCore/Services/Hrm/EmployeeLoanService.cs new file mode 100644 index 0000000..087bb23 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmployeeLoanService.cs @@ -0,0 +1,114 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Loan/Advance service (FR-HR-PAY-03). Creating a loan generates its full +/// installment schedule up front; installments flip Pending→Deducted only when +/// their consuming PayrollRun reaches Locked (docs/12-BACKEND-HRM.md A.4), handled +/// by , not here. +/// +public sealed class EmployeeLoanService : IEmployeeLoanService +{ + private readonly IRepository _loans; + private readonly IRepository _employees; + private readonly INumberSequenceService _numberSequence; + private readonly IUnitOfWork _uow; + + public EmployeeLoanService( + IRepository loans, IRepository employees, INumberSequenceService numberSequence, IUnitOfWork uow) + { + _loans = loans; + _employees = employees; + _numberSequence = numberSequence; + _uow = uow; + } + + public async Task> ListAsync(int employeeId, CancellationToken ct = default) + { + var rows = await _loans.Query().AsNoTracking() + .Include(l => l.Installments) + .Where(l => l.EmployeeId == employeeId) + .OrderByDescending(l => l.CreatedAt) + .ToListAsync(ct); + return rows.Select(Map).ToList(); + } + + public async Task GetAsync(int employeeId, int loanId, CancellationToken ct = default) + { + var loan = await _loans.Query().AsNoTracking() + .Include(l => l.Installments) + .FirstOrDefaultAsync(l => l.EmployeeLoanId == loanId && l.EmployeeId == employeeId, ct); + return loan is null ? null : Map(loan); + } + + public async Task CreateAsync(int employeeId, CreateEmployeeLoanRequest request, int actorUserId, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct)) + throw new NotFoundException($"Employee {employeeId} was not found."); + + var docNo = await _numberSequence.NextAsync("LOAN", ct); + var loan = new EmployeeLoan + { + DocNo = docNo, + EmployeeId = employeeId, + LoanKind = request.LoanKind, + PrincipalAmount = request.PrincipalAmount, + InterestRate = request.InterestRate, + InstallmentAmount = request.InstallmentAmount, + NumberOfInstallments = request.NumberOfInstallments, + StartYear = request.StartYear, + StartMonth = request.StartMonth, + OutstandingBalance = request.PrincipalAmount, + Status = LoanStatus.Active, + ApprovedBy = actorUserId, + ApprovedAt = DateTime.UtcNow, + CreatedBy = actorUserId, + CreatedAt = DateTime.UtcNow + }; + + var year = request.StartYear; + var month = request.StartMonth; + for (var i = 1; i <= request.NumberOfInstallments; i++) + { + loan.Installments.Add(new LoanInstallment + { + InstallmentNumber = i, + DueYear = year, + DueMonth = month, + ScheduledAmount = request.InstallmentAmount, + Status = LoanInstallmentStatus.Pending + }); + month++; + if (month > 12) { month = 1; year++; } + } + + await _loans.AddAsync(loan, ct); + await _uow.SaveChangesAsync(ct); + + return Map(loan); + } + + public async Task> GetDueInstallmentsAsync(int employeeId, int periodYear, int periodMonth, CancellationToken ct = default) + { + return await _loans.Query() + .Where(l => l.EmployeeId == employeeId && l.Status == LoanStatus.Active) + .SelectMany(l => l.Installments) + .Where(i => i.DueYear == periodYear && i.DueMonth == periodMonth && i.Status == LoanInstallmentStatus.Pending) + .ToListAsync(ct); + } + + private static EmployeeLoanDto Map(EmployeeLoan l) => new( + l.EmployeeLoanId, l.DocNo, l.EmployeeId, l.LoanKind, l.PrincipalAmount, l.InterestRate, l.InstallmentAmount, + l.NumberOfInstallments, l.StartYear, l.StartMonth, l.OutstandingBalance, l.Status, + l.Installments.OrderBy(i => i.InstallmentNumber).Select(i => new LoanInstallmentDto( + i.LoanInstallmentId, i.InstallmentNumber, i.DueYear, i.DueMonth, i.ScheduledAmount, i.PaidAmount, i.PayrollRunId, i.Status)).ToList(), + l.CreatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/EmployeeSalaryStructureService.cs b/Backend/ERPCore/Services/Hrm/EmployeeSalaryStructureService.cs new file mode 100644 index 0000000..91e2d67 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmployeeSalaryStructureService.cs @@ -0,0 +1,104 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Effective-dated salary structure service (FR-HR-PAY-02) — creating a new +/// structure supersedes the previous open-ended one, preserving history for audits +/// (docs/12-BACKEND-HRM.md §13) rather than overwriting it. +/// +public sealed class EmployeeSalaryStructureService : IEmployeeSalaryStructureService +{ + private readonly IRepository _structures; + private readonly IRepository _employees; + private readonly IRepository _components; + private readonly IUnitOfWork _uow; + + public EmployeeSalaryStructureService( + IRepository structures, IRepository employees, + IRepository components, IUnitOfWork uow) + { + _structures = structures; + _employees = employees; + _components = components; + _uow = uow; + } + + public async Task> ListHistoryAsync(int employeeId, CancellationToken ct = default) + { + var rows = await _structures.Query().AsNoTracking() + .Include(s => s.Lines).ThenInclude(l => l.SalaryComponent) + .Where(s => s.EmployeeId == employeeId) + .OrderByDescending(s => s.EffectiveFrom) + .ToListAsync(ct); + + return rows.Select(Map).ToList(); + } + + public async Task GetCurrentAsync(int employeeId, CancellationToken ct = default) + { + var current = await _structures.Query().AsNoTracking() + .Include(s => s.Lines).ThenInclude(l => l.SalaryComponent) + .Where(s => s.EmployeeId == employeeId && s.EffectiveTo == null) + .FirstOrDefaultAsync(ct); + return current is null ? null : Map(current); + } + + public async Task CreateAsync( + int employeeId, CreateSalaryStructureRequest request, int actorUserId, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct)) + throw new NotFoundException($"Employee {employeeId} was not found."); + + var current = await _structures.Query() + .FirstOrDefaultAsync(s => s.EmployeeId == employeeId && s.EffectiveTo == null, ct); + if (current is not null) + { + if (request.EffectiveFrom.Date <= current.EffectiveFrom.Date) + throw new DomainException(ErrorCodes.SalaryStructureOverlap, + "The new effective date must be after the current structure's effective date.", 409); + + current.EffectiveTo = request.EffectiveFrom.Date.AddDays(-1); + current.Status = SalaryStructureStatus.Superseded; + } + + var componentIds = request.Lines.Select(l => l.SalaryComponentId).ToList(); + var validComponentCount = await _components.Query().CountAsync(c => componentIds.Contains(c.SalaryComponentId), ct); + if (validComponentCount != componentIds.Distinct().Count()) + throw new DomainException(ErrorCodes.Validation, "One or more salary components were not found.", 422); + + var structure = new EmployeeSalaryStructure + { + EmployeeId = employeeId, + EffectiveFrom = request.EffectiveFrom.Date, + BasicSalary = request.BasicSalary, + Status = SalaryStructureStatus.Active, + ApprovedBy = actorUserId, + ApprovedAt = DateTime.UtcNow, + CreatedBy = actorUserId, + CreatedAt = DateTime.UtcNow, + Lines = request.Lines.Select(l => new EmployeeSalaryStructureLine + { + SalaryComponentId = l.SalaryComponentId, + Amount = l.Amount + }).ToList() + }; + + await _structures.AddAsync(structure, ct); + await _uow.SaveChangesAsync(ct); + + return await GetCurrentAsync(employeeId, ct) ?? Map(structure); + } + + private static EmployeeSalaryStructureDto Map(EmployeeSalaryStructure s) => new( + s.EmployeeSalaryStructureId, s.EmployeeId, s.EffectiveFrom, s.EffectiveTo, s.BasicSalary, s.Currency, s.Status, + s.Lines.Select(l => new EmployeeSalaryStructureLineDto(l.SalaryComponentId, l.SalaryComponent?.Name, l.Amount)).ToList(), + s.CreatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/EmployeeService.cs b/Backend/ERPCore/Services/Hrm/EmployeeService.cs new file mode 100644 index 0000000..3dc0a4b --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmployeeService.cs @@ -0,0 +1,244 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Employee (staff) service (FR-HR-MD-02/03, docs/13-BACKEND-HRM-API.md §3). Distinct +/// from (system login accounts) — the two are +/// linked only via the optional, explicit (docs/12-BACKEND-HRM.md A.5). +/// +public sealed class EmployeeService : IEmployeeService +{ + private readonly IRepository _employees; + private readonly IRepository _users; + private readonly IRepository _bankDetails; + private readonly IUnitOfWork _uow; + + public EmployeeService( + IRepository employees, IRepository users, IRepository bankDetails, IUnitOfWork uow) + { + _employees = employees; + _users = users; + _bankDetails = bankDetails; + _uow = uow; + } + + public async Task> ListAsync( + PageQuery query, EmployeeStatus? status, int? departmentId, int? designationId, int? branchId, CancellationToken ct = default) + { + var q = _employees.Query().AsNoTracking() + .Include(e => e.Department).Include(e => e.Designation) + .Include(e => e.EmploymentType).Include(e => e.Branch) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(e => EF.Functions.ILike(e.FullName, $"%{term}%") || EF.Functions.ILike(e.EmployeeCode, $"%{term}%")); + } + if (status is not null) q = q.Where(e => e.Status == status); + if (departmentId is not null) q = q.Where(e => e.DepartmentId == departmentId); + if (designationId is not null) q = q.Where(e => e.DesignationId == designationId); + if (branchId is not null) q = q.Where(e => e.BranchId == branchId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(e => e.FullName) + .Skip(query.Skip).Take(query.PageSize) + .Select(e => new EmployeeListItemDto( + e.EmployeeId, e.EmployeeCode, e.FullName, e.Email, + e.DepartmentId, e.Department!.Name, e.DesignationId, e.Designation!.Name, + e.EmploymentTypeId, e.EmploymentType!.Name, e.BranchId, e.Branch != null ? e.Branch.Name : null, + e.Status, e.UserId != null, e.HireDate)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int employeeId, CancellationToken ct = default) + { + var employee = await _employees.Query().AsNoTracking().FirstOrDefaultAsync(e => e.EmployeeId == employeeId, ct); + return employee is null ? null : new ETagged(Map(employee), employee.RowVersion); + } + + public async Task> CreateAsync(CreateEmployeeRequest request, int actorUserId, CancellationToken ct = default) + { + var code = request.EmployeeCode.Trim(); + if (await _employees.Query().AnyAsync(e => e.EmployeeCode.ToLower() == code.ToLower(), ct)) + throw new DomainException(ErrorCodes.EmployeeCodeDuplicate, $"An employee with code '{code}' already exists.", 400); + + int? linkedUserId = null; + if (request.LinkUserId is not null) + { + var user = await _users.GetByIdAsync(request.LinkUserId.Value, ct) + ?? throw new NotFoundException($"User {request.LinkUserId} was not found."); + if (await _employees.Query().AnyAsync(e => e.UserId == user.UserId, ct)) + throw new DomainException(ErrorCodes.UserAlreadyLinked, $"User {user.UserId} already backs a different employee.", 409); + linkedUserId = user.UserId; + } + + var employee = new Employee + { + EmployeeCode = code, + FullName = request.FullName.Trim(), + Nic = request.Nic?.Trim(), + DateOfBirth = request.DateOfBirth, + Gender = request.Gender, + Nationality = request.Nationality?.Trim(), + Email = request.Email?.Trim(), + PersonalMobile = request.PersonalMobile?.Trim(), + AddressLine1 = request.AddressLine1?.Trim(), + AddressLine2 = request.AddressLine2?.Trim(), + City = request.City?.Trim(), + PostalCode = request.PostalCode?.Trim(), + Country = request.Country?.Trim(), + EmergencyContactName = request.EmergencyContactName?.Trim(), + EmergencyContactRelationship = request.EmergencyContactRelationship?.Trim(), + EmergencyContactPhone = request.EmergencyContactPhone?.Trim(), + HireDate = request.HireDate, + DepartmentId = request.DepartmentId, + DesignationId = request.DesignationId, + EmploymentTypeId = request.EmploymentTypeId, + BranchId = request.BranchId, + WorkShiftId = request.WorkShiftId, + ReportingManagerId = request.ReportingManagerId, + EpfNumber = request.EpfNumber?.Trim(), + EtfNumber = request.EtfNumber?.Trim(), + TaxIdentificationNumber = request.TaxIdentificationNumber?.Trim(), + UserId = linkedUserId, + Status = EmployeeStatus.Active, + CreatedBy = actorUserId, + CreatedAt = DateTime.UtcNow + }; + + await _employees.AddAsync(employee, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(employee), employee.RowVersion); + } + + public async Task> UpdateAsync( + int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default) + { + var employee = await _employees.GetByIdAsync(employeeId, ct) + ?? throw new NotFoundException($"Employee {employeeId} was not found."); + + if (employee.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employee was modified by another request.", 412); + + employee.FullName = request.FullName.Trim(); + employee.Nic = request.Nic?.Trim(); + employee.DateOfBirth = request.DateOfBirth; + employee.Gender = request.Gender; + employee.Nationality = request.Nationality?.Trim(); + employee.Email = request.Email?.Trim(); + employee.PersonalMobile = request.PersonalMobile?.Trim(); + employee.AddressLine1 = request.AddressLine1?.Trim(); + employee.AddressLine2 = request.AddressLine2?.Trim(); + employee.City = request.City?.Trim(); + employee.PostalCode = request.PostalCode?.Trim(); + employee.Country = request.Country?.Trim(); + employee.EmergencyContactName = request.EmergencyContactName?.Trim(); + employee.EmergencyContactRelationship = request.EmergencyContactRelationship?.Trim(); + employee.EmergencyContactPhone = request.EmergencyContactPhone?.Trim(); + employee.ConfirmationDate = request.ConfirmationDate; + employee.LastWorkingDate = request.LastWorkingDate; + employee.DepartmentId = request.DepartmentId; + employee.DesignationId = request.DesignationId; + employee.EmploymentTypeId = request.EmploymentTypeId; + employee.BranchId = request.BranchId; + employee.WorkShiftId = request.WorkShiftId; + employee.ReportingManagerId = request.ReportingManagerId; + employee.EpfNumber = request.EpfNumber?.Trim(); + employee.EtfNumber = request.EtfNumber?.Trim(); + employee.TaxIdentificationNumber = request.TaxIdentificationNumber?.Trim(); + employee.UpdatedBy = actorUserId; + employee.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employee was modified by another request.", 412); + } + + return new ETagged(Map(employee), employee.RowVersion); + } + + public async Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default) + { + var employee = await _employees.GetByIdAsync(employeeId, ct) + ?? throw new NotFoundException($"Employee {employeeId} was not found."); + + employee.Status = status; + employee.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + public async Task> ListBankDetailsAsync(int employeeId, CancellationToken ct = default) + { + return await _bankDetails.Query().AsNoTracking() + .Where(b => b.EmployeeId == employeeId) + .Select(b => new EmployeeBankDetailDto( + b.EmployeeBankDetailId, b.BankName, b.BranchName, b.AccountNumber, b.AccountHolderName, + b.SwiftCode, b.IsPrimary, b.Status)) + .ToListAsync(ct); + } + + public async Task> ReplaceBankDetailsAsync( + int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct)) + throw new NotFoundException($"Employee {employeeId} was not found."); + + if (request.Items.Count(i => i.IsPrimary) > 1) + throw new DomainException(ErrorCodes.Validation, "Only one bank detail row may be marked primary.", 422); + + var existing = await _bankDetails.Query().Where(b => b.EmployeeId == employeeId).ToListAsync(ct); + foreach (var row in existing) _bankDetails.Remove(row); + + var result = new List(); + foreach (var item in request.Items) + { + var detail = new EmployeeBankDetail + { + EmployeeId = employeeId, + BankName = item.BankName.Trim(), + BranchName = item.BranchName.Trim(), + AccountNumber = item.AccountNumber.Trim(), + AccountHolderName = item.AccountHolderName.Trim(), + SwiftCode = item.SwiftCode?.Trim(), + IsPrimary = item.IsPrimary, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + result.Add(detail); + await _bankDetails.AddAsync(detail, ct); + } + + await _uow.SaveChangesAsync(ct); + + return result.Select(b => new EmployeeBankDetailDto( + b.EmployeeBankDetailId, b.BankName, b.BranchName, b.AccountNumber, b.AccountHolderName, + b.SwiftCode, b.IsPrimary, b.Status)).ToList(); + } + + private static EmployeeDetailDto Map(Employee e) => new( + e.EmployeeId, e.EmployeeCode, e.FullName, e.Nic, e.DateOfBirth, e.Gender, e.Nationality, e.ProfilePhotoPath, + e.Email, e.PersonalMobile, e.AddressLine1, e.AddressLine2, e.City, e.PostalCode, e.Country, + e.EmergencyContactName, e.EmergencyContactRelationship, e.EmergencyContactPhone, + e.HireDate, e.ConfirmationDate, e.LastWorkingDate, + e.DepartmentId, e.DesignationId, e.EmploymentTypeId, e.BranchId, e.WorkShiftId, + e.ReportingManagerId, e.EpfNumber, e.EtfNumber, e.TaxIdentificationNumber, + e.UserId, e.Status, e.CreatedAt, e.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/EmployeeUserLinkService.cs b/Backend/ERPCore/Services/Hrm/EmployeeUserLinkService.cs new file mode 100644 index 0000000..88e7299 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmployeeUserLinkService.cs @@ -0,0 +1,73 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +public sealed class EmployeeUserLinkService : IEmployeeUserLinkService +{ + private readonly IRepository _employees; + private readonly IRepository _users; + private readonly IUnitOfWork _uow; + + public EmployeeUserLinkService(IRepository employees, IRepository users, IUnitOfWork uow) + { + _employees = employees; + _users = users; + _uow = uow; + } + + public async Task FindStaffCandidateByEmailAsync(string email, CancellationToken ct = default) + { + var term = email.Trim(); + var employee = await _employees.Query().AsNoTracking() + .Where(e => e.UserId == null && e.Email != null && EF.Functions.ILike(e.Email, term)) + .Select(e => new EmployeeMatchDto(e.EmployeeId, e.EmployeeCode, e.FullName, e.Email!)) + .FirstOrDefaultAsync(ct); + return employee; + } + + public async Task FindUserCandidateByEmailAsync(string email, CancellationToken ct = default) + { + var term = email.Trim(); + var linkedUserIds = _employees.Query().Where(e => e.UserId != null).Select(e => e.UserId!.Value); + + var user = await _users.Query().AsNoTracking() + .Where(u => u.Email != null && EF.Functions.ILike(u.Email, term) && !linkedUserIds.Contains(u.UserId)) + .Select(u => new UserMatchDto(u.UserId, u.Username, u.DisplayName, u.Email!)) + .FirstOrDefaultAsync(ct); + return user; + } + + public async Task LinkAsync(int employeeId, int userId, CancellationToken ct = default) + { + var employee = await _employees.GetByIdAsync(employeeId, ct) + ?? throw new NotFoundException($"Employee {employeeId} was not found."); + if (employee.UserId is not null) + throw new DomainException(ErrorCodes.EmployeeAlreadyLinked, $"Employee {employeeId} already has a linked user.", 409); + + var user = await _users.GetByIdAsync(userId, ct) + ?? throw new NotFoundException($"User {userId} was not found."); + if (await _employees.Query().AnyAsync(e => e.UserId == userId, ct)) + throw new DomainException(ErrorCodes.UserAlreadyLinked, $"User {userId} already backs a different employee.", 409); + + employee.UserId = user.UserId; + employee.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + public async Task UnlinkAsync(int employeeId, CancellationToken ct = default) + { + var employee = await _employees.GetByIdAsync(employeeId, ct) + ?? throw new NotFoundException($"Employee {employeeId} was not found."); + + employee.UserId = null; + employee.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } +} diff --git a/Backend/ERPCore/Services/Hrm/EmploymentTypeService.cs b/Backend/ERPCore/Services/Hrm/EmploymentTypeService.cs new file mode 100644 index 0000000..6fbc80e --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmploymentTypeService.cs @@ -0,0 +1,105 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// EmploymentType master service (FR-HR-MD-01, docs/13-BACKEND-HRM-API.md §2). +public sealed class EmploymentTypeService : IEmploymentTypeService +{ + private readonly IRepository _employmentTypes; + private readonly IUnitOfWork _uow; + + public EmploymentTypeService(IRepository employmentTypes, IUnitOfWork uow) + { + _employmentTypes = employmentTypes; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _employmentTypes.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(e => EF.Functions.ILike(e.Name, $"%{term}%") || EF.Functions.ILike(e.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(e => e.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(e => e.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(e => new EmploymentTypeDto(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int employmentTypeId, CancellationToken ct = default) + { + var et = await _employmentTypes.Query().AsNoTracking().FirstOrDefaultAsync(e => e.EmploymentTypeId == employmentTypeId, ct); + return et is null ? null : new ETagged(Map(et), et.RowVersion); + } + + public async Task> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _employmentTypes.Query().AnyAsync(e => e.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"An employment type with code '{code}' already exists."); + + var et = new EmploymentType + { + Code = code, + Name = request.Name.Trim(), + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _employmentTypes.AddAsync(et, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(et), et.RowVersion); + } + + public async Task> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var et = await _employmentTypes.GetByIdAsync(employmentTypeId, ct) + ?? throw new NotFoundException($"Employment type {employmentTypeId} was not found."); + + if (et.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employment type was modified by another request.", 412); + + et.Name = request.Name.Trim(); + et.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employment type was modified by another request.", 412); + } + + return new ETagged(Map(et), et.RowVersion); + } + + public async Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default) + { + var et = await _employmentTypes.GetByIdAsync(employmentTypeId, ct) + ?? throw new NotFoundException($"Employment type {employmentTypeId} was not found."); + + et.Status = status; + et.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static EmploymentTypeDto Map(EmploymentType e) => new(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/HrDocumentTypeService.cs b/Backend/ERPCore/Services/Hrm/HrDocumentTypeService.cs new file mode 100644 index 0000000..1f488d1 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/HrDocumentTypeService.cs @@ -0,0 +1,113 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Staff document-type catalog service (FR-HR-DOC-01, docs/13-BACKEND-HRM-API.md §2). +public sealed class HrDocumentTypeService : IHrDocumentTypeService +{ + private readonly IRepository _types; + private readonly IUnitOfWork _uow; + + public HrDocumentTypeService(IRepository types, IUnitOfWork uow) + { + _types = types; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _types.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%") || EF.Functions.ILike(t.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(t => t.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(t => t.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(t => Map(t)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int hrDocumentTypeId, CancellationToken ct = default) + { + var type = await _types.Query().AsNoTracking().FirstOrDefaultAsync(t => t.HrDocumentTypeId == hrDocumentTypeId, ct); + return type is null ? null : new ETagged(Map(type), type.RowVersion); + } + + public async Task> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _types.Query().AnyAsync(t => t.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A document type with code '{code}' already exists."); + + var type = new HrDocumentType + { + Code = code, + Name = request.Name.Trim(), + Category = request.Category, + RequiredAtOnboarding = request.RequiredAtOnboarding, + ExpiryTracked = request.ExpiryTracked, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _types.AddAsync(type, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(type), type.RowVersion); + } + + public async Task> UpdateAsync( + int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var type = await _types.GetByIdAsync(hrDocumentTypeId, ct) + ?? throw new NotFoundException($"Document type {hrDocumentTypeId} was not found."); + + if (type.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The document type was modified by another request.", 412); + + type.Name = request.Name.Trim(); + type.Category = request.Category; + type.RequiredAtOnboarding = request.RequiredAtOnboarding; + type.ExpiryTracked = request.ExpiryTracked; + type.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The document type was modified by another request.", 412); + } + + return new ETagged(Map(type), type.RowVersion); + } + + public async Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default) + { + var type = await _types.GetByIdAsync(hrDocumentTypeId, ct) + ?? throw new NotFoundException($"Document type {hrDocumentTypeId} was not found."); + + type.Status = status; + type.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static HrDocumentTypeDto Map(HrDocumentType t) => new( + t.HrDocumentTypeId, t.Code, t.Name, t.Category, t.RequiredAtOnboarding, t.ExpiryTracked, t.Status, t.CreatedAt, t.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/HrReportService.cs b/Backend/ERPCore/Services/Hrm/HrReportService.cs new file mode 100644 index 0000000..b027a46 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/HrReportService.cs @@ -0,0 +1,133 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +public sealed class HrReportService : IHrReportService +{ + private readonly IRepository _attendance; + private readonly IRepository _payrollLines; + private readonly IRepository _salaryStructures; + private readonly IRepository _leaveBalances; + private readonly IRepository _documents; + + public HrReportService( + IRepository attendance, IRepository payrollLines, + IRepository salaryStructures, IRepository leaveBalances, + IRepository documents) + { + _attendance = attendance; + _payrollLines = payrollLines; + _salaryStructures = salaryStructures; + _leaveBalances = leaveBalances; + _documents = documents; + } + + public async Task> AttendanceSummaryAsync( + int periodYear, int periodMonth, int? departmentId, CancellationToken ct = default) + { + var periodStart = new DateTime(periodYear, periodMonth, 1); + var periodEnd = periodStart.AddMonths(1).AddDays(-1); + + var q = _attendance.Query().AsNoTracking() + .Include(r => r.Employee).ThenInclude(e => e!.Department) + .Where(r => r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd); + if (departmentId is not null) q = q.Where(r => r.Employee!.DepartmentId == departmentId); + + var rows = await q.ToListAsync(ct); + + return rows.GroupBy(r => r.EmployeeId) + .Select(g => new AttendanceSummaryRowDto( + g.Key, g.First().Employee!.EmployeeCode, g.First().Employee!.FullName, g.First().Employee!.Department?.Name, + g.Count(r => r.AttendanceStatus == AttendanceStatus.Present), + g.Count(r => r.AttendanceStatus == AttendanceStatus.Absent), + g.Count(r => r.AttendanceStatus == AttendanceStatus.OnLeave), + g.Count(r => r.AttendanceStatus == AttendanceStatus.HalfDay), + g.Sum(r => r.OvertimeMinutes), + g.Sum(r => r.LateMinutes))) + .OrderBy(r => r.EmployeeName) + .ToList(); + } + + public async Task> OvertimeReportAsync(int periodYear, int periodMonth, CancellationToken ct = default) + { + var periodStart = new DateTime(periodYear, periodMonth, 1); + var periodEnd = periodStart.AddMonths(1).AddDays(-1); + + return await _attendance.Query().AsNoTracking() + .Include(r => r.Employee) + .Where(r => r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd && r.OvertimeMinutes > 0) + .OrderByDescending(r => r.OvertimeMinutes) + .Select(r => new OvertimeReportRowDto(r.EmployeeId, r.Employee!.EmployeeCode, r.Employee!.FullName, r.AttendanceDate, r.OvertimeMinutes)) + .ToListAsync(ct); + } + + public async Task> LateArrivalReportAsync(int periodYear, int periodMonth, CancellationToken ct = default) + { + var periodStart = new DateTime(periodYear, periodMonth, 1); + var periodEnd = periodStart.AddMonths(1).AddDays(-1); + + return await _attendance.Query().AsNoTracking() + .Include(r => r.Employee) + .Where(r => r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd && r.LateMinutes > 0) + .OrderByDescending(r => r.LateMinutes) + .Select(r => new LateArrivalReportRowDto(r.EmployeeId, r.Employee!.EmployeeCode, r.Employee!.FullName, r.AttendanceDate, r.LateMinutes)) + .ToListAsync(ct); + } + + public async Task> PayrollRegisterAsync(int payrollRunId, CancellationToken ct = default) + { + return await _payrollLines.Query().AsNoTracking() + .Include(l => l.Employee) + .Where(l => l.PayrollRunId == payrollRunId) + .OrderBy(l => l.Employee!.FullName) + .Select(l => new PayrollRegisterRowDto( + l.PayrollLineId, l.EmployeeId, l.Employee!.EmployeeCode, l.Employee!.FullName, + l.GrossSalary, l.GrossSalary - l.NetSalary, l.NetSalary)) + .ToListAsync(ct); + } + + public async Task> SalaryHistoryAsync(int employeeId, CancellationToken ct = default) + { + return await _salaryStructures.Query().AsNoTracking() + .Where(s => s.EmployeeId == employeeId) + .OrderByDescending(s => s.EffectiveFrom) + .Select(s => new SalaryHistoryRowDto(s.EmployeeSalaryStructureId, s.EffectiveFrom, s.EffectiveTo, s.BasicSalary, s.Status.ToString())) + .ToListAsync(ct); + } + + public async Task> LeaveBalanceReportAsync(int year, CancellationToken ct = default) + { + return await _leaveBalances.Query().AsNoTracking() + .Include(b => b.Employee) + .Include(b => b.LeaveType) + .Where(b => b.Year == year) + .OrderBy(b => b.Employee!.FullName) + .Select(b => new LeaveBalanceReportRowDto( + b.EmployeeId, b.Employee!.EmployeeCode, b.Employee!.FullName, b.LeaveType!.Name, + b.EntitledDays, b.TakenDays, b.EntitledDays + b.CarriedForwardDays + b.AdjustmentDays - b.TakenDays)) + .ToListAsync(ct); + } + + public async Task> DocumentExpiryReportAsync(int withinDays, CancellationToken ct = default) + { + var cutoff = DateTime.UtcNow.Date.AddDays(withinDays); + var today = DateTime.UtcNow.Date; + + var rows = await _documents.Query().AsNoTracking() + .Include(d => d.Employee) + .Include(d => d.HrDocumentType) + .Where(d => d.ExpiryDate != null && d.ExpiryDate <= cutoff) + .OrderBy(d => d.ExpiryDate) + .ToListAsync(ct); + + return rows.Select(d => new DocumentExpiryReportRowDto( + d.EmployeeDocumentId, d.EmployeeId, d.Employee!.EmployeeCode, d.Employee!.FullName, + d.HrDocumentType!.Name, d.ExpiryDate!.Value, (d.ExpiryDate.Value.Date - today).Days)).ToList(); + } +} diff --git a/Backend/ERPCore/Services/Hrm/LeaveBalanceService.cs b/Backend/ERPCore/Services/Hrm/LeaveBalanceService.cs new file mode 100644 index 0000000..51e1e40 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/LeaveBalanceService.cs @@ -0,0 +1,95 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Leave balance service (FR-HR-LV-03, docs/13-BACKEND-HRM-API.md §5). +public sealed class LeaveBalanceService : ILeaveBalanceService +{ + private readonly IRepository _balances; + private readonly IRepository _leaveTypes; + private readonly IRepository _employees; + private readonly IUnitOfWork _uow; + + public LeaveBalanceService( + IRepository balances, IRepository leaveTypes, IRepository employees, IUnitOfWork uow) + { + _balances = balances; + _leaveTypes = leaveTypes; + _employees = employees; + _uow = uow; + } + + public async Task> ListAsync(int employeeId, int? year, CancellationToken ct = default) + { + var effectiveYear = year ?? DateTime.UtcNow.Year; + var rows = await _balances.Query().AsNoTracking() + .Include(b => b.LeaveType) + .Where(b => b.EmployeeId == employeeId && b.Year == effectiveYear) + .ToListAsync(ct); + + return rows.Select(Map).ToList(); + } + + public async Task> ApplyAdjustmentsAsync(int employeeId, UpdateLeaveBalancesRequest request, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct)) + throw new NotFoundException($"Employee {employeeId} was not found."); + + var result = new List(); + foreach (var item in request.Items) + { + var balance = await GetOrCreateAsync(employeeId, item.LeaveTypeId, request.Year, ct); + balance.AdjustmentDays = item.AdjustmentDays; + balance.UpdatedAt = DateTime.UtcNow; + result.Add(balance); + } + + await _uow.SaveChangesAsync(ct); + + return result.Select(Map).ToList(); + } + + public async Task IncrementTakenDaysAsync(int employeeId, int leaveTypeId, int year, decimal days, CancellationToken ct = default) + { + var balance = await GetOrCreateAsync(employeeId, leaveTypeId, year, ct); + balance.TakenDays += days; + balance.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private async Task GetOrCreateAsync(int employeeId, int leaveTypeId, int year, CancellationToken ct) + { + var leaveType = await _leaveTypes.GetByIdAsync(leaveTypeId, ct) + ?? throw new NotFoundException($"Leave type {leaveTypeId} was not found."); + + var balance = await _balances.Query() + .FirstOrDefaultAsync(b => b.EmployeeId == employeeId && b.LeaveTypeId == leaveTypeId && b.Year == year, ct); + if (balance is not null) + { + balance.LeaveType = leaveType; + return balance; + } + + balance = new LeaveBalance + { + EmployeeId = employeeId, + LeaveTypeId = leaveTypeId, + Year = year, + EntitledDays = leaveType.AccrualPerYear, + LeaveType = leaveType + }; + await _balances.AddAsync(balance, ct); + return balance; + } + + private static LeaveBalanceDto Map(LeaveBalance b) => new( + b.LeaveBalanceId, b.EmployeeId, b.LeaveTypeId, b.LeaveType?.Name, b.Year, + b.EntitledDays, b.TakenDays, b.CarriedForwardDays, b.AdjustmentDays, + b.EntitledDays + b.CarriedForwardDays + b.AdjustmentDays - b.TakenDays); +} diff --git a/Backend/ERPCore/Services/Hrm/LeaveRequestService.cs b/Backend/ERPCore/Services/Hrm/LeaveRequestService.cs new file mode 100644 index 0000000..57e604f --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/LeaveRequestService.cs @@ -0,0 +1,168 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Leave request service (FR-HR-LV-02). Approving a request increments the +/// employee's (docs/12-BACKEND-HRM.md §6) — +/// this is also the event Attendance's OnLeave classification reads back via +/// . +/// +public sealed class LeaveRequestService : ILeaveRequestService +{ + private readonly IRepository _requests; + private readonly IRepository _employees; + private readonly IRepository _leaveTypes; + private readonly ILeaveBalanceService _balances; + private readonly INumberSequenceService _numberSequence; + private readonly IUnitOfWork _uow; + + public LeaveRequestService( + IRepository requests, IRepository employees, IRepository leaveTypes, + ILeaveBalanceService balances, INumberSequenceService numberSequence, IUnitOfWork uow) + { + _requests = requests; + _employees = employees; + _leaveTypes = leaveTypes; + _balances = balances; + _numberSequence = numberSequence; + _uow = uow; + } + + public async Task> ListAsync( + PageQuery query, int? employeeId, LeaveRequestStatus? status, CancellationToken ct = default) + { + var q = _requests.Query().AsNoTracking().Include(r => r.Employee).Include(r => r.LeaveType).AsQueryable(); + if (employeeId is not null) q = q.Where(r => r.EmployeeId == employeeId); + if (status is not null) q = q.Where(r => r.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.CreatedAt) + .Skip(query.Skip).Take(query.PageSize) + .Select(r => Map(r)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetAsync(int leaveRequestId, CancellationToken ct = default) + { + var request = await _requests.Query().AsNoTracking() + .Include(r => r.Employee).Include(r => r.LeaveType) + .FirstOrDefaultAsync(r => r.LeaveRequestId == leaveRequestId, ct); + return request is null ? null : Map(request); + } + + public async Task CreateAsync(CreateLeaveRequestRequest request, int actorUserId, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == request.EmployeeId, ct)) + throw new NotFoundException($"Employee {request.EmployeeId} was not found."); + if (!await _leaveTypes.Query().AnyAsync(t => t.LeaveTypeId == request.LeaveTypeId, ct)) + throw new NotFoundException($"Leave type {request.LeaveTypeId} was not found."); + if (request.EndDate < request.StartDate) + throw new DomainException(ErrorCodes.Validation, "End date cannot be before start date.", 422); + + var docNo = await _numberSequence.NextAsync("LV", ct); + + // Simplification: calendar-day count (inclusive), not business-day aware — + // flagged for a future improvement, not silently assumed correct for payroll. + var daysCount = (decimal)(request.EndDate.Date - request.StartDate.Date).Days + 1; + + var leaveRequest = new LeaveRequest + { + DocNo = docNo, + EmployeeId = request.EmployeeId, + LeaveTypeId = request.LeaveTypeId, + StartDate = request.StartDate.Date, + EndDate = request.EndDate.Date, + DaysCount = daysCount, + Reason = request.Reason?.Trim(), + Status = LeaveRequestStatus.Draft, + CreatedBy = actorUserId, + CreatedAt = DateTime.UtcNow + }; + + await _requests.AddAsync(leaveRequest, ct); + await _uow.SaveChangesAsync(ct); + + return Map(leaveRequest); + } + + public async Task SubmitAsync(int leaveRequestId, CancellationToken ct = default) + { + var request = await GetTrackedAsync(leaveRequestId, ct); + if (request.Status != LeaveRequestStatus.Draft) + throw new DomainException(ErrorCodes.Conflict, "Only a Draft leave request can be submitted.", 409); + + request.Status = LeaveRequestStatus.Submitted; + await _uow.SaveChangesAsync(ct); + return Map(request); + } + + public async Task ApproveAsync(int leaveRequestId, int actorUserId, CancellationToken ct = default) + { + var request = await GetTrackedAsync(leaveRequestId, ct); + if (request.Status != LeaveRequestStatus.Submitted) + throw new DomainException(ErrorCodes.Conflict, "Only a Submitted leave request can be approved.", 409); + + request.Status = LeaveRequestStatus.Approved; + request.ApprovedBy = actorUserId; + request.ApprovedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + + await _balances.IncrementTakenDaysAsync(request.EmployeeId, request.LeaveTypeId, request.StartDate.Year, request.DaysCount, ct); + + return Map(request); + } + + public async Task RejectAsync(int leaveRequestId, string reason, int actorUserId, CancellationToken ct = default) + { + var request = await GetTrackedAsync(leaveRequestId, ct); + if (request.Status != LeaveRequestStatus.Submitted) + throw new DomainException(ErrorCodes.Conflict, "Only a Submitted leave request can be rejected.", 409); + + request.Status = LeaveRequestStatus.Rejected; + request.ApprovedBy = actorUserId; + request.ApprovedAt = DateTime.UtcNow; + request.RejectionReason = reason.Trim(); + await _uow.SaveChangesAsync(ct); + + return Map(request); + } + + public async Task CancelAsync(int leaveRequestId, CancellationToken ct = default) + { + var request = await GetTrackedAsync(leaveRequestId, ct); + if (request.Status is not (LeaveRequestStatus.Draft or LeaveRequestStatus.Submitted)) + throw new DomainException(ErrorCodes.Conflict, "Only a Draft or Submitted leave request can be cancelled.", 409); + + request.Status = LeaveRequestStatus.Cancelled; + await _uow.SaveChangesAsync(ct); + return Map(request); + } + + public async Task FindApprovedLeaveCoveringAsync(int employeeId, DateTime date, CancellationToken ct = default) + { + var day = date.Date; + return await _requests.Query().AsNoTracking() + .Include(r => r.LeaveType) + .FirstOrDefaultAsync(r => r.EmployeeId == employeeId && r.Status == LeaveRequestStatus.Approved + && r.StartDate <= day && r.EndDate >= day, ct); + } + + private async Task GetTrackedAsync(int leaveRequestId, CancellationToken ct) + => await _requests.GetByIdAsync(leaveRequestId, ct) + ?? throw new NotFoundException($"Leave request {leaveRequestId} was not found."); + + private static LeaveRequestDto Map(LeaveRequest r) => new( + r.LeaveRequestId, r.DocNo, r.EmployeeId, r.Employee?.FullName, r.LeaveTypeId, r.LeaveType?.Name, + r.StartDate, r.EndDate, r.DaysCount, r.Reason, r.Status, r.ApprovedBy, r.ApprovedAt, r.RejectionReason, r.CreatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/LeaveTypeService.cs b/Backend/ERPCore/Services/Hrm/LeaveTypeService.cs new file mode 100644 index 0000000..d1d63c5 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/LeaveTypeService.cs @@ -0,0 +1,119 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Leave type master service (FR-HR-LV-01, docs/13-BACKEND-HRM-API.md §5). +public sealed class LeaveTypeService : ILeaveTypeService +{ + private readonly IRepository _types; + private readonly IUnitOfWork _uow; + + public LeaveTypeService(IRepository types, IUnitOfWork uow) + { + _types = types; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _types.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%") || EF.Functions.ILike(t.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(t => t.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(t => t.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(t => Map(t)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int leaveTypeId, CancellationToken ct = default) + { + var type = await _types.Query().AsNoTracking().FirstOrDefaultAsync(t => t.LeaveTypeId == leaveTypeId, ct); + return type is null ? null : new ETagged(Map(type), type.RowVersion); + } + + public async Task> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _types.Query().AnyAsync(t => t.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A leave type with code '{code}' already exists."); + + var type = new LeaveType + { + Code = code, + Name = request.Name.Trim(), + IsPaid = request.IsPaid, + CountsAsNoPay = request.CountsAsNoPay, + AccrualPerYear = request.AccrualPerYear, + CarryForwardAllowed = request.CarryForwardAllowed, + MaxCarryForwardDays = request.MaxCarryForwardDays, + RequiresApproval = request.RequiresApproval, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _types.AddAsync(type, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(type), type.RowVersion); + } + + public async Task> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var type = await _types.GetByIdAsync(leaveTypeId, ct) + ?? throw new NotFoundException($"Leave type {leaveTypeId} was not found."); + + if (type.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The leave type was modified by another request.", 412); + + type.Name = request.Name.Trim(); + type.IsPaid = request.IsPaid; + type.CountsAsNoPay = request.CountsAsNoPay; + type.AccrualPerYear = request.AccrualPerYear; + type.CarryForwardAllowed = request.CarryForwardAllowed; + type.MaxCarryForwardDays = request.MaxCarryForwardDays; + type.RequiresApproval = request.RequiresApproval; + type.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The leave type was modified by another request.", 412); + } + + return new ETagged(Map(type), type.RowVersion); + } + + public async Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default) + { + var type = await _types.GetByIdAsync(leaveTypeId, ct) + ?? throw new NotFoundException($"Leave type {leaveTypeId} was not found."); + + type.Status = status; + type.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static LeaveTypeDto Map(LeaveType t) => new( + t.LeaveTypeId, t.Code, t.Name, t.IsPaid, t.CountsAsNoPay, t.AccrualPerYear, + t.CarryForwardAllowed, t.MaxCarryForwardDays, t.RequiresApproval, t.Status, t.CreatedAt, t.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/PayrollCalculationService.cs b/Backend/ERPCore/Services/Hrm/PayrollCalculationService.cs new file mode 100644 index 0000000..712414e --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/PayrollCalculationService.cs @@ -0,0 +1,152 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +public sealed class PayrollCalculationService : IPayrollCalculationService +{ + private readonly IRepository _structures; + private readonly IRepository _attendance; + private readonly IRepository _workShifts; + private readonly IEmployeeLoanService _loans; + private readonly IPayrollStatutorySettingService _statutory; + private readonly ITaxSlabService _taxSlabs; + + public PayrollCalculationService( + IRepository structures, IRepository attendance, + IRepository workShifts, IEmployeeLoanService loans, + IPayrollStatutorySettingService statutory, ITaxSlabService taxSlabs) + { + _structures = structures; + _attendance = attendance; + _workShifts = workShifts; + _loans = loans; + _statutory = statutory; + _taxSlabs = taxSlabs; + } + + public async Task CalculateAsync(Employee employee, int periodYear, int periodMonth, CancellationToken ct = default) + { + var periodStart = new DateTime(periodYear, periodMonth, 1); + var periodEnd = periodStart.AddMonths(1).AddDays(-1); + + var structure = await _structures.Query().AsNoTracking() + .Include(s => s.Lines).ThenInclude(l => l.SalaryComponent) + .Where(s => s.EmployeeId == employee.EmployeeId && s.EffectiveFrom <= periodEnd && (s.EffectiveTo == null || s.EffectiveTo >= periodStart)) + .OrderByDescending(s => s.EffectiveFrom) + .FirstOrDefaultAsync(ct) + ?? throw new NotFoundException($"Employee {employee.EmployeeId} has no salary structure effective for {periodYear}-{periodMonth:00}."); + + var shift = await _workShifts.GetByIdAsync(employee.WorkShiftId, ct) + ?? throw new NotFoundException($"Work shift {employee.WorkShiftId} was not found."); + + var records = await _attendance.Query().AsNoTracking() + .Where(r => r.EmployeeId == employee.EmployeeId && r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd) + .ToListAsync(ct); + + var presentDays = records.Count(r => r.AttendanceStatus is AttendanceStatus.Present or AttendanceStatus.HalfDay); + var absentDays = records.Count(r => r.AttendanceStatus == AttendanceStatus.Absent); + // Simplification: all OnLeave days are currently treated as paid — AttendanceRecord doesn't + // carry which LeaveType covered it, so unpaid-leave No-Pay isn't distinguished here yet (docs §8). + var leaveDays = records.Count(r => r.AttendanceStatus == AttendanceStatus.OnLeave); + var otMinutesTotal = records.Sum(r => r.OvertimeMinutes); + var lateMinutesTotal = records.Sum(r => r.LateMinutes); + + var daysInMonth = DateTime.DaysInMonth(periodYear, periodMonth); + var dailyRate = structure.BasicSalary / daysInMonth; + var perMinuteRate = shift.StandardWorkingMinutes > 0 ? dailyRate / shift.StandardWorkingMinutes : 0m; + + var earningLines = structure.Lines.Where(l => l.SalaryComponent!.ComponentType == SalaryComponentType.Earning).ToList(); + var deductionLines = structure.Lines.Where(l => l.SalaryComponent!.ComponentType == SalaryComponentType.Deduction).ToList(); + + var totalAllowances = earningLines.Sum(l => l.Amount); + var otMultiplier = shift.OtMultiplier; + var overtimeAmount = Math.Round(perMinuteRate * otMultiplier * otMinutesTotal, 2); + var grossSalary = structure.BasicSalary + totalAllowances + overtimeAmount; + + var lateDeduction = Math.Round(perMinuteRate * lateMinutesTotal, 2); + var noPayAmount = Math.Round(dailyRate * absentDays, 2); + var otherDeductionsAmount = deductionLines.Sum(l => l.Amount); + + var dueInstallments = await _loans.GetDueInstallmentsAsync(employee.EmployeeId, periodYear, periodMonth, ct); + var loanDeduction = dueInstallments.Sum(i => i.ScheduledAmount); + + var statutory = await _statutory.GetEffectiveAsync(periodStart, ct); + var epfEtfBase = structure.BasicSalary + earningLines.Where(l => l.SalaryComponent!.IsEpfEtfApplicable).Sum(l => l.Amount); + var epfEmployeeAmount = statutory is null ? 0m : Math.Round(epfEtfBase * statutory.EpfEmployeeRate, 2); + var epfEmployerAmount = statutory is null ? 0m : Math.Round(epfEtfBase * statutory.EpfEmployerRate, 2); + var etfEmployerAmount = statutory is null ? 0m : Math.Round(epfEtfBase * statutory.EtfEmployerRate, 2); + + var taxableIncome = structure.BasicSalary + earningLines.Where(l => l.SalaryComponent!.IsTaxable).Sum(l => l.Amount) + overtimeAmount; + var slabs = await _taxSlabs.GetEffectiveSlabsAsync(periodStart, ct); + var taxAmount = Math.Round(ComputeMarginalTax(taxableIncome, slabs), 2); + + var netSalary = grossSalary - lateDeduction - noPayAmount - loanDeduction - epfEmployeeAmount - taxAmount - otherDeductionsAmount; + + var line = new PayrollLine + { + EmployeeId = employee.EmployeeId, + BasicSalary = structure.BasicSalary, + TotalAllowances = totalAllowances, + OvertimeAmount = overtimeAmount, + GrossSalary = grossSalary, + LateDeductionAmount = lateDeduction, + NoPayAmount = noPayAmount, + LoanDeductionAmount = loanDeduction, + EpfEmployeeAmount = epfEmployeeAmount, + EpfEmployerAmount = epfEmployerAmount, + EtfEmployerAmount = etfEmployerAmount, + TaxAmount = taxAmount, + OtherDeductionsAmount = otherDeductionsAmount, + NetSalary = netSalary, + WorkingDays = presentDays + absentDays + leaveDays, + PresentDays = presentDays, + AbsentDays = absentDays, + LeaveDays = leaveDays, + OtMinutesTotal = otMinutesTotal, + LateMinutesTotal = lateMinutesTotal + }; + + var sort = 0; + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Earning, Label = "Basic Salary", Amount = structure.BasicSalary, SortOrder = sort++ }); + foreach (var l in earningLines) + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Earning, SalaryComponentId = l.SalaryComponentId, Label = l.SalaryComponent!.Name, Amount = l.Amount, SortOrder = sort++ }); + if (overtimeAmount > 0) + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Earning, Label = "Overtime", Amount = overtimeAmount, SortOrder = sort++ }); + + if (lateDeduction > 0) line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "Late Deduction", Amount = lateDeduction, SortOrder = sort++ }); + if (noPayAmount > 0) line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "No Pay", Amount = noPayAmount, SortOrder = sort++ }); + if (loanDeduction > 0) line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "Loan", Amount = loanDeduction, SortOrder = sort++ }); + foreach (var l in deductionLines) + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, SalaryComponentId = l.SalaryComponentId, Label = l.SalaryComponent!.Name, Amount = l.Amount, SortOrder = sort++ }); + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "EPF (Employee)", Amount = epfEmployeeAmount, SortOrder = sort++ }); + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "Tax", Amount = taxAmount, SortOrder = sort++ }); + + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.EmployerContribution, Label = "EPF (Employer)", Amount = epfEmployerAmount, SortOrder = sort++ }); + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.EmployerContribution, Label = "ETF (Company Contribution)", Amount = etfEmployerAmount, SortOrder = sort++ }); + + return line; + } + + /// Standard ascending marginal-slab computation over taxable income. + private static decimal ComputeMarginalTax(decimal taxableIncome, List slabs) + { + if (taxableIncome <= 0 || slabs.Count == 0) return 0m; + + var tax = 0m; + foreach (var slab in slabs.OrderBy(s => s.LowerBound)) + { + if (taxableIncome <= slab.LowerBound) continue; + var upper = slab.UpperBound ?? taxableIncome; + var taxableInBand = Math.Min(taxableIncome, upper) - slab.LowerBound; + if (taxableInBand <= 0) continue; + tax += taxableInBand * slab.Rate; + } + return tax; + } +} diff --git a/Backend/ERPCore/Services/Hrm/PayrollRunService.cs b/Backend/ERPCore/Services/Hrm/PayrollRunService.cs new file mode 100644 index 0000000..8d94aa7 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/PayrollRunService.cs @@ -0,0 +1,283 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Payroll run orchestration (FR-HR-PAY-05/06). Maps the business's 5-step flow onto +/// 3 stored states (docs/12-BACKEND-HRM.md A.4): Generate→Draft, Review is a human +/// action, Approve→Approved, Lock→Locked (the point loan installments and attendance +/// batches are stamped consumed — deliberately deferred from Generate so a +/// discarded/regenerated Draft never prematurely consumes them), Generate Payslips is +/// an action gated on Locked. +/// +public sealed class PayrollRunService : IPayrollRunService +{ + private readonly IRepository _runs; + private readonly IRepository _employees; + private readonly IRepository _attendanceBatches; + private readonly IRepository _installments; + private readonly IRepository _loans; + private readonly IRepository _payslips; + private readonly IPayrollCalculationService _calculation; + private readonly IEmployeeLoanService _loanService; + private readonly INumberSequenceService _numberSequence; + private readonly IUnitOfWork _uow; + + public PayrollRunService( + IRepository runs, IRepository employees, IRepository attendanceBatches, + IRepository installments, IRepository loans, IRepository payslips, + IPayrollCalculationService calculation, IEmployeeLoanService loanService, INumberSequenceService numberSequence, IUnitOfWork uow) + { + _runs = runs; + _employees = employees; + _attendanceBatches = attendanceBatches; + _installments = installments; + _loans = loans; + _payslips = payslips; + _calculation = calculation; + _loanService = loanService; + _numberSequence = numberSequence; + _uow = uow; + } + + public async Task> ListAsync( + PageQuery query, int? periodYear, int? periodMonth, PayrollRunStatus? status, CancellationToken ct = default) + { + var q = _runs.Query().AsNoTracking().Include(r => r.Lines).AsQueryable(); + if (periodYear is not null) q = q.Where(r => r.PeriodYear == periodYear); + if (periodMonth is not null) q = q.Where(r => r.PeriodMonth == periodMonth); + if (status is not null) q = q.Where(r => r.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.GeneratedAt) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + return PagedResponse.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total); + } + + public async Task GetAsync(int payrollRunId, CancellationToken ct = default) + { + var run = await _runs.Query().AsNoTracking().Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.PayrollRunId == payrollRunId, ct); + return run is null ? null : Map(run); + } + + public async Task> ListLinesAsync(int payrollRunId, CancellationToken ct = default) + { + var lines = await _runs.Query().AsNoTracking() + .Where(r => r.PayrollRunId == payrollRunId) + .SelectMany(r => r.Lines) + .Include(l => l.Employee) + .OrderBy(l => l.Employee!.FullName) + .ToListAsync(ct); + return lines.Select(MapLine).ToList(); + } + + public async Task GetLineAsync(int payrollRunId, int lineId, CancellationToken ct = default) + { + var line = await _runs.Query().AsNoTracking() + .Where(r => r.PayrollRunId == payrollRunId) + .SelectMany(r => r.Lines) + .Include(l => l.Employee) + .Include(l => l.Components).ThenInclude(c => c.SalaryComponent) + .FirstOrDefaultAsync(l => l.PayrollLineId == lineId, ct); + return line is null ? null : MapLineDetail(line); + } + + public async Task GenerateAsync(GeneratePayrollRunRequest request, int actorUserId, CancellationToken ct = default) + { + var unconfirmed = await _attendanceBatches.Query().AnyAsync(b => + b.PeriodStart.Year == request.PeriodYear && b.PeriodStart.Month == request.PeriodMonth && + (b.Status == AttendanceBatchStatus.Draft || b.Status == AttendanceBatchStatus.Validated), ct); + if (unconfirmed) + throw new DomainException(ErrorCodes.AttendanceNotConfirmed, + "One or more attendance batches for this period are not yet Confirmed.", 422); + + var employeesQuery = _employees.Query().Where(e => e.Status == EmployeeStatus.Active); + if (request.BranchId is not null) employeesQuery = employeesQuery.Where(e => e.BranchId == request.BranchId); + var employees = await employeesQuery.ToListAsync(ct); + + var docNo = await _numberSequence.NextAsync("PAY", ct); + var run = new PayrollRun + { + DocNo = docNo, + PeriodYear = request.PeriodYear, + PeriodMonth = request.PeriodMonth, + BranchId = request.BranchId, + Status = PayrollRunStatus.Draft, + GeneratedBy = actorUserId, + GeneratedAt = DateTime.UtcNow + }; + + foreach (var employee in employees) + { + try + { + var line = await _calculation.CalculateAsync(employee, request.PeriodYear, request.PeriodMonth, ct); + run.Lines.Add(line); + } + catch (NotFoundException) + { + // No effective salary structure for this employee this period — skip rather than fail the whole run. + } + } + + await _runs.AddAsync(run, ct); + await _uow.SaveChangesAsync(ct); + + return Map(run); + } + + public async Task ApproveAsync(int payrollRunId, int actorUserId, CancellationToken ct = default) + { + var run = await GetTrackedAsync(payrollRunId, ct); + if (run.Status != PayrollRunStatus.Draft) + throw new DomainException(ErrorCodes.Conflict, "Only a Draft payroll run can be approved.", 409); + + run.Status = PayrollRunStatus.Approved; + run.ApprovedBy = actorUserId; + run.ApprovedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return Map(run); + } + + public async Task LockAsync(int payrollRunId, int actorUserId, CancellationToken ct = default) + { + var run = await GetTrackedAsync(payrollRunId, ct); + if (run.Status != PayrollRunStatus.Approved) + throw new DomainException(ErrorCodes.Conflict, "Only an Approved payroll run can be locked.", 409); + + await _uow.ExecuteInTransactionAsync(async innerCt => + { + // Stamp due loan installments as Deducted, decrementing outstanding balance. + var employeeIds = run.Lines.Select(l => l.EmployeeId).ToList(); + var loans = await _loans.Query().Include(l => l.Installments) + .Where(l => employeeIds.Contains(l.EmployeeId) && l.Status == LoanStatus.Active) + .ToListAsync(innerCt); + foreach (var loan in loans) + { + foreach (var installment in loan.Installments.Where(i => + i.DueYear == run.PeriodYear && i.DueMonth == run.PeriodMonth && i.Status == LoanInstallmentStatus.Pending)) + { + installment.Status = LoanInstallmentStatus.Deducted; + installment.PaidAmount = installment.ScheduledAmount; + installment.PayrollRunId = run.PayrollRunId; + loan.OutstandingBalance = Math.Max(0, loan.OutstandingBalance - installment.ScheduledAmount); + if (loan.OutstandingBalance == 0) loan.Status = LoanStatus.Closed; + } + } + + // Flip consumed attendance batches Confirmed -> UsedInPayroll. + var batches = await _attendanceBatches.Query() + .Where(b => b.PeriodStart.Year == run.PeriodYear && b.PeriodStart.Month == run.PeriodMonth && b.Status == AttendanceBatchStatus.Confirmed) + .ToListAsync(innerCt); + foreach (var batch in batches) batch.Status = AttendanceBatchStatus.UsedInPayroll; + + run.Status = PayrollRunStatus.Locked; + run.LockedBy = actorUserId; + run.LockedAt = DateTime.UtcNow; + + await _uow.SaveChangesAsync(innerCt); + }, ct); + + return Map(run); + } + + public async Task UnlockAsync(int payrollRunId, string reason, int actorUserId, CancellationToken ct = default) + { + var run = await GetTrackedAsync(payrollRunId, ct); + if (run.Status != PayrollRunStatus.Locked) + throw new DomainException(ErrorCodes.PayrollPeriodLocked, "Only a Locked payroll run can be unlocked.", 409); + + await _uow.ExecuteInTransactionAsync(async innerCt => + { + var installments = await _installments.Query() + .Where(i => i.PayrollRunId == run.PayrollRunId) + .Include(i => i.EmployeeLoan) + .ToListAsync(innerCt); + foreach (var installment in installments) + { + installment.Status = LoanInstallmentStatus.Pending; + installment.PaidAmount = null; + installment.PayrollRunId = null; + if (installment.EmployeeLoan is not null) + { + installment.EmployeeLoan.OutstandingBalance += installment.ScheduledAmount; + installment.EmployeeLoan.Status = LoanStatus.Active; + } + } + + var batches = await _attendanceBatches.Query() + .Where(b => b.PeriodStart.Year == run.PeriodYear && b.PeriodStart.Month == run.PeriodMonth && b.Status == AttendanceBatchStatus.UsedInPayroll) + .ToListAsync(innerCt); + foreach (var batch in batches) batch.Status = AttendanceBatchStatus.Confirmed; + + run.Status = PayrollRunStatus.Approved; + run.UnlockedBy = actorUserId; + run.UnlockedAt = DateTime.UtcNow; + run.UnlockReason = reason.Trim(); + + await _uow.SaveChangesAsync(innerCt); + }, ct); + + return Map(run); + } + + public async Task> GeneratePayslipsAsync(int payrollRunId, CancellationToken ct = default) + { + var run = await _runs.Query().Include(r => r.Lines).FirstOrDefaultAsync(r => r.PayrollRunId == payrollRunId, ct) + ?? throw new NotFoundException($"Payroll run {payrollRunId} was not found."); + if (run.Status != PayrollRunStatus.Locked) + throw new DomainException(ErrorCodes.Conflict, "Payslips can only be generated for a Locked payroll run.", 409); + + var existingLineIds = await _payslips.Query() + .Where(p => run.Lines.Select(l => l.PayrollLineId).Contains(p.PayrollLineId)) + .Select(p => p.PayrollLineId) + .ToListAsync(ct); + + var created = new List(); + foreach (var line in run.Lines.Where(l => !existingLineIds.Contains(l.PayrollLineId))) + { + var payslip = new Payslip { PayrollLineId = line.PayrollLineId, GeneratedAt = DateTime.UtcNow }; + created.Add(payslip); + await _payslips.AddAsync(payslip, ct); + } + await _uow.SaveChangesAsync(ct); + + var all = await _payslips.Query().AsNoTracking() + .Where(p => run.Lines.Select(l => l.PayrollLineId).Contains(p.PayrollLineId)) + .ToListAsync(ct); + return all.Select(p => new PayslipDto(p.PayslipId, p.PayrollLineId, p.GeneratedAt, p.ReleasedAt, p.ReleasedBy)).ToList(); + } + + private async Task GetTrackedAsync(int payrollRunId, CancellationToken ct) + => await _runs.Query().Include(r => r.Lines).FirstOrDefaultAsync(r => r.PayrollRunId == payrollRunId, ct) + ?? throw new NotFoundException($"Payroll run {payrollRunId} was not found."); + + private static PayrollRunDto Map(PayrollRun r) => new( + r.PayrollRunId, r.DocNo, r.PeriodYear, r.PeriodMonth, r.BranchId, r.Status, + r.GeneratedBy, r.GeneratedAt, r.ApprovedBy, r.ApprovedAt, r.LockedBy, r.LockedAt, + r.UnlockedBy, r.UnlockedAt, r.UnlockReason, + r.Lines.Sum(l => l.GrossSalary), r.Lines.Sum(l => l.NetSalary), r.Lines.Count); + + private static PayrollLineDto MapLine(PayrollLine l) => new( + l.PayrollLineId, l.PayrollRunId, l.EmployeeId, l.Employee?.EmployeeCode, l.Employee?.FullName, + l.BasicSalary, l.TotalAllowances, l.OvertimeAmount, l.GrossSalary, + l.LateDeductionAmount, l.NoPayAmount, l.LoanDeductionAmount, + l.EpfEmployeeAmount, l.EpfEmployerAmount, l.EtfEmployerAmount, l.TaxAmount, l.OtherDeductionsAmount, l.NetSalary, + l.WorkingDays, l.PresentDays, l.AbsentDays, l.LeaveDays, l.OtMinutesTotal, l.LateMinutesTotal); + + private static PayrollLineDetailDto MapLineDetail(PayrollLine l) => new( + MapLine(l), + l.Components.OrderBy(c => c.SortOrder).Select(c => new PayrollLineComponentDto( + c.ComponentCategory, c.SalaryComponentId, c.Label, c.Amount, c.SortOrder)).ToList()); +} diff --git a/Backend/ERPCore/Services/Hrm/PayrollStatutorySettingService.cs b/Backend/ERPCore/Services/Hrm/PayrollStatutorySettingService.cs new file mode 100644 index 0000000..250c75d --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/PayrollStatutorySettingService.cs @@ -0,0 +1,63 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Effective-dated EPF/ETF settings (FR-HR-PAY-04, docs/13-BACKEND-HRM-API.md §6). +public sealed class PayrollStatutorySettingService : IPayrollStatutorySettingService +{ + private readonly IRepository _settings; + private readonly IUnitOfWork _uow; + + public PayrollStatutorySettingService(IRepository settings, IUnitOfWork uow) + { + _settings = settings; + _uow = uow; + } + + public async Task> ListAsync(CancellationToken ct = default) + { + var rows = await _settings.Query().AsNoTracking().OrderByDescending(s => s.EffectiveFrom).ToListAsync(ct); + return rows.Select(Map).ToList(); + } + + public async Task CreateAsync(UpsertPayrollStatutorySettingRequest request, int actorUserId, CancellationToken ct = default) + { + var previous = await _settings.Query() + .Where(s => s.EffectiveTo == null) + .OrderByDescending(s => s.EffectiveFrom) + .FirstOrDefaultAsync(ct); + if (previous is not null) previous.EffectiveTo = request.EffectiveFrom.Date.AddDays(-1); + + var setting = new PayrollStatutorySetting + { + EpfEmployeeRate = request.EpfEmployeeRate, + EpfEmployerRate = request.EpfEmployerRate, + EtfEmployerRate = request.EtfEmployerRate, + OtMultiplierDefault = request.OtMultiplierDefault, + EffectiveFrom = request.EffectiveFrom.Date, + CreatedBy = actorUserId, + CreatedAt = DateTime.UtcNow + }; + + await _settings.AddAsync(setting, ct); + await _uow.SaveChangesAsync(ct); + + return Map(setting); + } + + public async Task GetEffectiveAsync(DateTime asOf, CancellationToken ct = default) + { + return await _settings.Query().AsNoTracking() + .Where(s => s.EffectiveFrom <= asOf && (s.EffectiveTo == null || s.EffectiveTo >= asOf)) + .OrderByDescending(s => s.EffectiveFrom) + .FirstOrDefaultAsync(ct); + } + + private static PayrollStatutorySettingDto Map(PayrollStatutorySetting s) => new( + s.PayrollStatutorySettingId, s.EpfEmployeeRate, s.EpfEmployerRate, s.EtfEmployerRate, s.OtMultiplierDefault, s.EffectiveFrom, s.EffectiveTo); +} diff --git a/Backend/ERPCore/Services/Hrm/PayslipService.cs b/Backend/ERPCore/Services/Hrm/PayslipService.cs new file mode 100644 index 0000000..ddc9f8b --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/PayslipService.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Text; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +public sealed class PayslipService : IPayslipService +{ + private readonly IRepository _payslips; + + public PayslipService(IRepository payslips) => _payslips = payslips; + + public async Task GetAsync(int payslipId, CancellationToken ct = default) + { + var payslip = await _payslips.Query().AsNoTracking().FirstOrDefaultAsync(p => p.PayslipId == payslipId, ct); + return payslip is null ? null : new PayslipDto(payslip.PayslipId, payslip.PayrollLineId, payslip.GeneratedAt, payslip.ReleasedAt, payslip.ReleasedBy); + } + + public async Task RenderHtmlAsync(int payslipId, CancellationToken ct = default) + { + var payslip = await _payslips.Query().AsNoTracking() + .Include(p => p.PayrollLine!).ThenInclude(l => l.Employee) + .Include(p => p.PayrollLine!).ThenInclude(l => l.Components) + .Include(p => p.PayrollLine!).ThenInclude(l => l.PayrollRun) + .FirstOrDefaultAsync(p => p.PayslipId == payslipId, ct); + if (payslip?.PayrollLine is null) return null; + + var line = payslip.PayrollLine; + var employee = line.Employee; + var run = line.PayrollRun; + + var rows = new StringBuilder(); + foreach (var group in line.Components.GroupBy(c => c.ComponentCategory)) + { + var title = group.Key switch + { + PayrollLineComponentCategory.Earning => "Earnings", + PayrollLineComponentCategory.Deduction => "Deductions", + _ => "Employer Contributions (informational, not deducted)" + }; + rows.Append($"{WebUtility.HtmlEncode(title)}"); + foreach (var c in group.OrderBy(c => c.SortOrder)) + rows.Append($"{WebUtility.HtmlEncode(c.Label)}{c.Amount:N2}"); + } + + const string style = "body{font-family:Arial,sans-serif;font-size:14px;color:#111;}" + + "table{width:100%;border-collapse:collapse;} td{padding:4px 8px;}" + + ".totals td{font-weight:bold;border-top:2px solid #333;}" + + "h2{margin-bottom:0;} .sub{color:#555;margin-top:2px;}"; + + var html = new StringBuilder(); + html.Append("Payslip") + .Append("

Payslip

") + .Append("
Employee: ").Append(WebUtility.HtmlEncode(employee?.FullName ?? string.Empty)) + .Append(" (").Append(WebUtility.HtmlEncode(employee?.EmployeeCode ?? string.Empty)).Append(")
") + .Append("
Period: ").Append(run?.PeriodMonth.ToString("00")).Append('/').Append(run?.PeriodYear) + .Append(" · Run ").Append(WebUtility.HtmlEncode(run?.DocNo ?? string.Empty)).Append("
") + .Append("").Append(rows) + .Append("") + .Append("") + .Append("
Gross Salary").Append(line.GrossSalary.ToString("N2")).Append("
Net Salary").Append(line.NetSalary.ToString("N2")).Append("
") + .Append("

Generated ").Append(payslip.GeneratedAt.ToString("yyyy-MM-dd HH:mm")).Append(" UTC

") + .Append(""); + + return html.ToString(); + } +} diff --git a/Backend/ERPCore/Services/Hrm/SalaryComponentService.cs b/Backend/ERPCore/Services/Hrm/SalaryComponentService.cs new file mode 100644 index 0000000..85150a6 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/SalaryComponentService.cs @@ -0,0 +1,112 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// SalaryComponent master service (FR-HR-PAY-01, docs/13-BACKEND-HRM-API.md §6). +public sealed class SalaryComponentService : ISalaryComponentService +{ + private readonly IRepository _components; + private readonly IUnitOfWork _uow; + + public SalaryComponentService(IRepository components, IUnitOfWork uow) + { + _components = components; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _components.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%") || EF.Functions.ILike(c.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(c => c.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(c => c.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(c => Map(c)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int salaryComponentId, CancellationToken ct = default) + { + var component = await _components.Query().AsNoTracking().FirstOrDefaultAsync(c => c.SalaryComponentId == salaryComponentId, ct); + return component is null ? null : new ETagged(Map(component), component.RowVersion); + } + + public async Task> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _components.Query().AnyAsync(c => c.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A salary component with code '{code}' already exists."); + + var component = new SalaryComponent + { + Code = code, + Name = request.Name.Trim(), + ComponentType = request.ComponentType, + IsTaxable = request.IsTaxable, + IsEpfEtfApplicable = request.IsEpfEtfApplicable, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _components.AddAsync(component, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(component), component.RowVersion); + } + + public async Task> UpdateAsync( + int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var component = await _components.GetByIdAsync(salaryComponentId, ct) + ?? throw new NotFoundException($"Salary component {salaryComponentId} was not found."); + + if (component.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The salary component was modified by another request.", 412); + + component.Name = request.Name.Trim(); + component.IsTaxable = request.IsTaxable; + component.IsEpfEtfApplicable = request.IsEpfEtfApplicable; + component.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The salary component was modified by another request.", 412); + } + + return new ETagged(Map(component), component.RowVersion); + } + + public async Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default) + { + var component = await _components.GetByIdAsync(salaryComponentId, ct) + ?? throw new NotFoundException($"Salary component {salaryComponentId} was not found."); + + component.Status = status; + component.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static SalaryComponentDto Map(SalaryComponent c) => new( + c.SalaryComponentId, c.Code, c.Name, c.ComponentType, c.IsTaxable, c.IsEpfEtfApplicable, c.Status, c.CreatedAt, c.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/TaxSlabService.cs b/Backend/ERPCore/Services/Hrm/TaxSlabService.cs new file mode 100644 index 0000000..eab0339 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/TaxSlabService.cs @@ -0,0 +1,67 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Configurable marginal tax slabs (FR-HR-PAY-04, docs/13-BACKEND-HRM-API.md §6). +public sealed class TaxSlabService : ITaxSlabService +{ + private readonly IRepository _slabs; + private readonly IUnitOfWork _uow; + + public TaxSlabService(IRepository slabs, IUnitOfWork uow) + { + _slabs = slabs; + _uow = uow; + } + + public async Task> ListAsync(CancellationToken ct = default) + { + var rows = await _slabs.Query().AsNoTracking() + .OrderByDescending(s => s.EffectiveFrom).ThenBy(s => s.LowerBound) + .ToListAsync(ct); + return rows.Select(Map).ToList(); + } + + public async Task CreateAsync(CreateTaxSlabRequest request, CancellationToken ct = default) + { + if (request.UpperBound is not null && request.UpperBound <= request.LowerBound) + throw new DomainException(ErrorCodes.TaxSlabGapInvalid, "Upper bound must be greater than the lower bound.", 422); + + var overlapping = await _slabs.Query().AnyAsync(s => + s.EffectiveFrom.Date == request.EffectiveFrom.Date && + s.LowerBound < (request.UpperBound ?? decimal.MaxValue) && + (s.UpperBound ?? decimal.MaxValue) > request.LowerBound, ct); + if (overlapping) + throw new DomainException(ErrorCodes.TaxSlabGapInvalid, "This slab overlaps an existing slab for the same effective date.", 422); + + var slab = new TaxSlab + { + EffectiveFrom = request.EffectiveFrom.Date, + LowerBound = request.LowerBound, + UpperBound = request.UpperBound, + Rate = request.Rate, + CreatedAt = DateTime.UtcNow + }; + + await _slabs.AddAsync(slab, ct); + await _uow.SaveChangesAsync(ct); + + return Map(slab); + } + + public async Task> GetEffectiveSlabsAsync(DateTime asOf, CancellationToken ct = default) + { + return await _slabs.Query().AsNoTracking() + .Where(s => s.EffectiveFrom <= asOf && (s.EffectiveTo == null || s.EffectiveTo >= asOf)) + .OrderBy(s => s.LowerBound) + .ToListAsync(ct); + } + + private static TaxSlabDto Map(TaxSlab s) => new(s.TaxSlabId, s.EffectiveFrom, s.EffectiveTo, s.LowerBound, s.UpperBound, s.Rate); +} diff --git a/Backend/ERPCore/Services/Hrm/WorkShiftService.cs b/Backend/ERPCore/Services/Hrm/WorkShiftService.cs new file mode 100644 index 0000000..15d45b1 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/WorkShiftService.cs @@ -0,0 +1,127 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// WorkShift master service (FR-HR-MD-01) — the attendance baseline Late/Early/OT +/// figures are computed against (docs/12-BACKEND-HRM.md A.3). +/// +public sealed class WorkShiftService : IWorkShiftService +{ + private readonly IRepository _shifts; + private readonly IUnitOfWork _uow; + + public WorkShiftService(IRepository shifts, IUnitOfWork uow) + { + _shifts = shifts; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _shifts.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(w => EF.Functions.ILike(w.Name, $"%{term}%") || EF.Functions.ILike(w.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(w => w.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(w => w.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(w => Map(w)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int workShiftId, CancellationToken ct = default) + { + var shift = await _shifts.Query().AsNoTracking().FirstOrDefaultAsync(w => w.WorkShiftId == workShiftId, ct); + return shift is null ? null : new ETagged(Map(shift), shift.RowVersion); + } + + public async Task> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _shifts.Query().AnyAsync(w => w.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A work shift with code '{code}' already exists."); + + var shift = new WorkShift + { + Code = code, + Name = request.Name.Trim(), + StartTime = request.StartTime, + EndTime = request.EndTime, + IsOvernight = request.IsOvernight, + GraceMinutes = request.GraceMinutes, + BreakMinutes = request.BreakMinutes, + StandardWorkingMinutes = request.StandardWorkingMinutes, + OtMultiplier = request.OtMultiplier, + WorkingDaysMask = request.WorkingDaysMask, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _shifts.AddAsync(shift, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(shift), shift.RowVersion); + } + + public async Task> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var shift = await _shifts.GetByIdAsync(workShiftId, ct) + ?? throw new NotFoundException($"Work shift {workShiftId} was not found."); + + if (shift.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The work shift was modified by another request.", 412); + + shift.Name = request.Name.Trim(); + shift.StartTime = request.StartTime; + shift.EndTime = request.EndTime; + shift.IsOvernight = request.IsOvernight; + shift.GraceMinutes = request.GraceMinutes; + shift.BreakMinutes = request.BreakMinutes; + shift.StandardWorkingMinutes = request.StandardWorkingMinutes; + shift.OtMultiplier = request.OtMultiplier; + shift.WorkingDaysMask = request.WorkingDaysMask; + shift.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The work shift was modified by another request.", 412); + } + + return new ETagged(Map(shift), shift.RowVersion); + } + + public async Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default) + { + var shift = await _shifts.GetByIdAsync(workShiftId, ct) + ?? throw new NotFoundException($"Work shift {workShiftId} was not found."); + + shift.Status = status; + shift.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static WorkShiftDto Map(WorkShift w) => new( + w.WorkShiftId, w.Code, w.Name, w.StartTime, w.EndTime, w.IsOvernight, + w.GraceMinutes, w.BreakMinutes, w.StandardWorkingMinutes, w.OtMultiplier, w.WorkingDaysMask, + w.Status, w.CreatedAt, w.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAttendanceComputationService.cs b/Backend/ERPCore/Services/Interfaces/IAttendanceComputationService.cs new file mode 100644 index 0000000..27431c3 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAttendanceComputationService.cs @@ -0,0 +1,19 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Services.Interfaces; + +/// +/// Pure attendance-figure computation against a baseline +/// (docs/12-BACKEND-HRM.md A.3) — the direct analog of +/// : invoked from +/// , never from a controller. +/// +public interface IAttendanceComputationService +{ + /// + /// Computes WorkingMinutes/LateMinutes/EarlyLeaveMinutes/OvertimeMinutes and the + /// derived AttendanceStatus for one record's CheckIn/CheckOut against its shift, + /// mutating the record in place. + /// + void Compute(AttendanceRecord record, WorkShift shift, bool hasApprovedLeave, bool isHoliday, bool isWeekOff); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAttendanceUploadService.cs b/Backend/ERPCore/Services/Interfaces/IAttendanceUploadService.cs new file mode 100644 index 0000000..4d6044f --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAttendanceUploadService.cs @@ -0,0 +1,26 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Attendance upload/validate/confirm pipeline (FR-HR-ATT, docs/13-BACKEND-HRM-API.md §4). +public interface IAttendanceUploadService +{ + Task> ListBatchesAsync( + PageQuery query, AttendanceBatchStatus? status, int? periodYear, int? periodMonth, CancellationToken ct = default); + Task GetBatchAsync(int batchId, CancellationToken ct = default); + Task UploadAsync( + Stream fileContent, string fileName, DateTime periodStart, DateTime periodEnd, int actorUserId, CancellationToken ct = default); + + Task> ListRecordsAsync(int batchId, RowValidationStatus? status, CancellationToken ct = default); + Task UpdateRecordAsync(int batchId, int recordId, UpdateAttendanceRecordRequest request, int actorUserId, CancellationToken ct = default); + Task ResolveDuplicateAsync(int batchId, ResolveDuplicateRequest request, CancellationToken ct = default); + + Task ValidateAsync(int batchId, CancellationToken ct = default); + Task ConfirmAsync(int batchId, int actorUserId, CancellationToken ct = default); + Task UnlockAsync(int batchId, string reason, int actorUserId, CancellationToken ct = default); + + /// Generates the upload template in the same column shape the parser expects. + (byte[] Content, string ContentType, string FileName) GenerateTemplate(bool asCsv); +} diff --git a/Backend/ERPCore/Services/Interfaces/IBranchService.cs b/Backend/ERPCore/Services/Interfaces/IBranchService.cs new file mode 100644 index 0000000..526b207 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IBranchService.cs @@ -0,0 +1,16 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Branch master business logic (docs/13-BACKEND-HRM-API.md §2). +public interface IBranchService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int branchId, CancellationToken ct = default); + Task> CreateAsync(CreateBranchRequest request, CancellationToken ct = default); + Task> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IDepartmentService.cs b/Backend/ERPCore/Services/Interfaces/IDepartmentService.cs new file mode 100644 index 0000000..40227fb --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IDepartmentService.cs @@ -0,0 +1,16 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Department master business logic, incl. self-nesting cycle guard (docs/13-BACKEND-HRM-API.md §2). +public interface IDepartmentService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int departmentId, CancellationToken ct = default); + Task> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default); + Task> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IDesignationService.cs b/Backend/ERPCore/Services/Interfaces/IDesignationService.cs new file mode 100644 index 0000000..78b2eb8 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IDesignationService.cs @@ -0,0 +1,15 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface IDesignationService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int designationId, CancellationToken ct = default); + Task> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default); + Task> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmployeeDocumentService.cs b/Backend/ERPCore/Services/Interfaces/IEmployeeDocumentService.cs new file mode 100644 index 0000000..54fac19 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmployeeDocumentService.cs @@ -0,0 +1,15 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Uploaded staff document ("Doc") business logic (docs/13-BACKEND-HRM-API.md §3). +public interface IEmployeeDocumentService +{ + Task> ListAsync(int employeeId, CancellationToken ct = default); + Task UploadAsync( + int employeeId, UploadEmployeeDocumentRequest request, Stream fileContent, string fileName, string contentType, + int actorUserId, CancellationToken ct = default); + Task<(Stream Content, string FileName, string ContentType)> DownloadAsync(int employeeId, int documentId, CancellationToken ct = default); + Task SetStatusAsync(int employeeId, int documentId, EmployeeDocumentStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmployeeLoanService.cs b/Backend/ERPCore/Services/Interfaces/IEmployeeLoanService.cs new file mode 100644 index 0000000..c708fba --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmployeeLoanService.cs @@ -0,0 +1,14 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Loan/Advance business logic (FR-HR-PAY-03, docs/13-BACKEND-HRM-API.md §6). +public interface IEmployeeLoanService +{ + Task> ListAsync(int employeeId, CancellationToken ct = default); + Task GetAsync(int employeeId, int loanId, CancellationToken ct = default); + Task CreateAsync(int employeeId, CreateEmployeeLoanRequest request, int actorUserId, CancellationToken ct = default); + + /// Due, not-yet-deducted installments for an employee in a given period — consumed by PayrollCalculationService. + Task> GetDueInstallmentsAsync(int employeeId, int periodYear, int periodMonth, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmployeeSalaryStructureService.cs b/Backend/ERPCore/Services/Interfaces/IEmployeeSalaryStructureService.cs new file mode 100644 index 0000000..2285207 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmployeeSalaryStructureService.cs @@ -0,0 +1,11 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Effective-dated salary structure business logic (FR-HR-PAY-02, docs/13-BACKEND-HRM-API.md §6). +public interface IEmployeeSalaryStructureService +{ + Task> ListHistoryAsync(int employeeId, CancellationToken ct = default); + Task GetCurrentAsync(int employeeId, CancellationToken ct = default); + Task CreateAsync(int employeeId, CreateSalaryStructureRequest request, int actorUserId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmployeeService.cs b/Backend/ERPCore/Services/Interfaces/IEmployeeService.cs new file mode 100644 index 0000000..ebd5d3e --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmployeeService.cs @@ -0,0 +1,20 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Employee (staff) business logic (docs/13-BACKEND-HRM-API.md §3). +public interface IEmployeeService +{ + Task> ListAsync( + PageQuery query, EmployeeStatus? status, int? departmentId, int? designationId, int? branchId, CancellationToken ct = default); + Task?> GetAsync(int employeeId, CancellationToken ct = default); + Task> CreateAsync(CreateEmployeeRequest request, int actorUserId, CancellationToken ct = default); + Task> UpdateAsync(int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default); + Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default); + + Task> ListBankDetailsAsync(int employeeId, CancellationToken ct = default); + Task> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmployeeUserLinkService.cs b/Backend/ERPCore/Services/Interfaces/IEmployeeUserLinkService.cs new file mode 100644 index 0000000..f43c0b4 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmployeeUserLinkService.cs @@ -0,0 +1,22 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// +/// Bidirectional Employee<->User soft-match/link logic (docs/12-BACKEND-HRM.md A.5, +/// Part B.3.2). Lookups are advisory only; linking is always an explicit, human-confirmed +/// action — never automatic, even on an exact email match. +/// +public interface IEmployeeUserLinkService +{ + /// Given an email (typically entered on a Create-User form), find an unlinked Staff record match. + Task FindStaffCandidateByEmailAsync(string email, CancellationToken ct = default); + + /// Given an email (typically entered on a Create-Employee form), find an unlinked User account match. + Task FindUserCandidateByEmailAsync(string email, CancellationToken ct = default); + + /// Links an existing Employee to an existing User. Throws EMPLOYEE_ALREADY_LINKED/USER_ALREADY_LINKED if either side is already linked to someone else. + Task LinkAsync(int employeeId, int userId, CancellationToken ct = default); + + Task UnlinkAsync(int employeeId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmploymentTypeService.cs b/Backend/ERPCore/Services/Interfaces/IEmploymentTypeService.cs new file mode 100644 index 0000000..de766c3 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmploymentTypeService.cs @@ -0,0 +1,15 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface IEmploymentTypeService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int employmentTypeId, CancellationToken ct = default); + Task> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default); + Task> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IHrDocumentTypeService.cs b/Backend/ERPCore/Services/Interfaces/IHrDocumentTypeService.cs new file mode 100644 index 0000000..a58dcb2 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IHrDocumentTypeService.cs @@ -0,0 +1,16 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Staff document-type catalog ("DocType") business logic (docs/13-BACKEND-HRM-API.md §2). +public interface IHrDocumentTypeService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int hrDocumentTypeId, CancellationToken ct = default); + Task> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default); + Task> UpdateAsync(int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IHrReportService.cs b/Backend/ERPCore/Services/Interfaces/IHrReportService.cs new file mode 100644 index 0000000..b308d26 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IHrReportService.cs @@ -0,0 +1,19 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// +/// Read-only HRM aggregation reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6) — no +/// new entities, queries over Attendance/Payroll/Leave/Document data that already +/// exists, mirroring how StockController answers on-hand/ledger queries today. +/// +public interface IHrReportService +{ + Task> AttendanceSummaryAsync(int periodYear, int periodMonth, int? departmentId, CancellationToken ct = default); + Task> OvertimeReportAsync(int periodYear, int periodMonth, CancellationToken ct = default); + Task> LateArrivalReportAsync(int periodYear, int periodMonth, CancellationToken ct = default); + Task> PayrollRegisterAsync(int payrollRunId, CancellationToken ct = default); + Task> SalaryHistoryAsync(int employeeId, CancellationToken ct = default); + Task> LeaveBalanceReportAsync(int year, CancellationToken ct = default); + Task> DocumentExpiryReportAsync(int withinDays, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ILeaveBalanceService.cs b/Backend/ERPCore/Services/Interfaces/ILeaveBalanceService.cs new file mode 100644 index 0000000..3d597a9 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ILeaveBalanceService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface ILeaveBalanceService +{ + Task> ListAsync(int employeeId, int? year, CancellationToken ct = default); + Task> ApplyAdjustmentsAsync(int employeeId, UpdateLeaveBalancesRequest request, CancellationToken ct = default); + + /// Increments TakenDays for an approved leave request; called by ILeaveRequestService.ApproveAsync. + Task IncrementTakenDaysAsync(int employeeId, int leaveTypeId, int year, decimal days, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ILeaveRequestService.cs b/Backend/ERPCore/Services/Interfaces/ILeaveRequestService.cs new file mode 100644 index 0000000..cbff7fd --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ILeaveRequestService.cs @@ -0,0 +1,20 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Domain.Enums; + +namespace ERPCore.Services.Interfaces; + +/// Leave request business logic (FR-HR-LV-02, docs/13-BACKEND-HRM-API.md §5). +public interface ILeaveRequestService +{ + Task> ListAsync(PageQuery query, int? employeeId, LeaveRequestStatus? status, CancellationToken ct = default); + Task GetAsync(int leaveRequestId, CancellationToken ct = default); + Task CreateAsync(CreateLeaveRequestRequest request, int actorUserId, CancellationToken ct = default); + Task SubmitAsync(int leaveRequestId, CancellationToken ct = default); + Task ApproveAsync(int leaveRequestId, int actorUserId, CancellationToken ct = default); + Task RejectAsync(int leaveRequestId, string reason, int actorUserId, CancellationToken ct = default); + Task CancelAsync(int leaveRequestId, CancellationToken ct = default); + + /// True if the employee has an Approved leave request covering the given date (used by Attendance's OnLeave classification). + Task FindApprovedLeaveCoveringAsync(int employeeId, DateTime date, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ILeaveTypeService.cs b/Backend/ERPCore/Services/Interfaces/ILeaveTypeService.cs new file mode 100644 index 0000000..a9bbccd --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ILeaveTypeService.cs @@ -0,0 +1,15 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface ILeaveTypeService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int leaveTypeId, CancellationToken ct = default); + Task> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default); + Task> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IPayrollCalculationService.cs b/Backend/ERPCore/Services/Interfaces/IPayrollCalculationService.cs new file mode 100644 index 0000000..0d41631 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IPayrollCalculationService.cs @@ -0,0 +1,14 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Services.Interfaces; + +/// +/// Payroll calculation domain service (FR-HR-PAY-05) — the payroll analog of +/// . Computes one +/// (with its breakdown) per employee per period, +/// per the formula in docs/12-BACKEND-HRM.md B.4. Never invoked from a controller directly. +/// +public interface IPayrollCalculationService +{ + Task CalculateAsync(Employee employee, int periodYear, int periodMonth, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IPayrollRunService.cs b/Backend/ERPCore/Services/Interfaces/IPayrollRunService.cs new file mode 100644 index 0000000..7557f4a --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IPayrollRunService.cs @@ -0,0 +1,20 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Payroll run approval workflow (FR-HR-PAY-05/06, docs/13-BACKEND-HRM-API.md §6). +public interface IPayrollRunService +{ + Task> ListAsync(PageQuery query, int? periodYear, int? periodMonth, PayrollRunStatus? status, CancellationToken ct = default); + Task GetAsync(int payrollRunId, CancellationToken ct = default); + Task> ListLinesAsync(int payrollRunId, CancellationToken ct = default); + Task GetLineAsync(int payrollRunId, int lineId, CancellationToken ct = default); + + Task GenerateAsync(GeneratePayrollRunRequest request, int actorUserId, CancellationToken ct = default); + Task ApproveAsync(int payrollRunId, int actorUserId, CancellationToken ct = default); + Task LockAsync(int payrollRunId, int actorUserId, CancellationToken ct = default); + Task UnlockAsync(int payrollRunId, string reason, int actorUserId, CancellationToken ct = default); + Task> GeneratePayslipsAsync(int payrollRunId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IPayrollStatutorySettingService.cs b/Backend/ERPCore/Services/Interfaces/IPayrollStatutorySettingService.cs new file mode 100644 index 0000000..1128abf --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IPayrollStatutorySettingService.cs @@ -0,0 +1,10 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface IPayrollStatutorySettingService +{ + Task> ListAsync(CancellationToken ct = default); + Task CreateAsync(UpsertPayrollStatutorySettingRequest request, int actorUserId, CancellationToken ct = default); + Task GetEffectiveAsync(DateTime asOf, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IPayslipService.cs b/Backend/ERPCore/Services/Interfaces/IPayslipService.cs new file mode 100644 index 0000000..57b2e41 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IPayslipService.cs @@ -0,0 +1,10 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Payslip retrieval + HTML print view (FR-HR-PAY-07, docs/13-BACKEND-HRM-API.md §6). No PDF dependency in this phase. +public interface IPayslipService +{ + Task GetAsync(int payslipId, CancellationToken ct = default); + Task RenderHtmlAsync(int payslipId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalaryComponentService.cs b/Backend/ERPCore/Services/Interfaces/ISalaryComponentService.cs new file mode 100644 index 0000000..97c6a0e --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalaryComponentService.cs @@ -0,0 +1,15 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface ISalaryComponentService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int salaryComponentId, CancellationToken ct = default); + Task> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default); + Task> UpdateAsync(int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ITaxSlabService.cs b/Backend/ERPCore/Services/Interfaces/ITaxSlabService.cs new file mode 100644 index 0000000..7ac7111 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ITaxSlabService.cs @@ -0,0 +1,10 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface ITaxSlabService +{ + Task> ListAsync(CancellationToken ct = default); + Task CreateAsync(CreateTaxSlabRequest request, CancellationToken ct = default); + Task> GetEffectiveSlabsAsync(DateTime asOf, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IWorkShiftService.cs b/Backend/ERPCore/Services/Interfaces/IWorkShiftService.cs new file mode 100644 index 0000000..8534189 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IWorkShiftService.cs @@ -0,0 +1,15 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface IWorkShiftService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int workShiftId, CancellationToken ct = default); + Task> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default); + Task> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/UserManagementService.cs b/Backend/ERPCore/Services/UserManagementService.cs index 3471270..b5be8e5 100644 --- a/Backend/ERPCore/Services/UserManagementService.cs +++ b/Backend/ERPCore/Services/UserManagementService.cs @@ -18,15 +18,18 @@ public sealed class UserManagementService : IUserManagementService private readonly IRepository _roles; private readonly IAuthUserService _authUsers; private readonly IAuthHexClient _authHex; + private readonly IEmployeeUserLinkService _links; private readonly IUnitOfWork _uow; public UserManagementService( - IRepository users, IRepository roles, IAuthUserService authUsers, IAuthHexClient authHex, IUnitOfWork uow) + IRepository users, IRepository roles, IAuthUserService authUsers, IAuthHexClient authHex, + IEmployeeUserLinkService links, IUnitOfWork uow) { _users = users; _roles = roles; _authUsers = authUsers; _authHex = authHex; + _links = links; _uow = uow; } @@ -82,12 +85,15 @@ public sealed class UserManagementService : IUserManagementService }, ct); // Mirror into the local shadow User row immediately, rather than waiting - // for ShadowUserClaimsTransformation's next-login JIT provisioning. + // for ShadowUserClaimsTransformation's next-login JIT provisioning. Email is + // persisted here too — it is the field the Employee<->User cross-link + // (docs/12-BACKEND-HRM.md A.5) matches on. var user = new User { AuthUserId = authUserId, Username = username, DisplayName = request.FullName.Trim(), + Email = request.Email.Trim(), RoleId = role.RoleId, Status = EntityStatus.Active }; @@ -95,6 +101,10 @@ public sealed class UserManagementService : IUserManagementService await _users.AddAsync(user, ct); await _uow.SaveChangesAsync(ct); + // Explicit, human-confirmed link to an existing Staff record (never automatic). + if (request.LinkEmployeeId is not null) + await _links.LinkAsync(request.LinkEmployeeId.Value, user.UserId, ct); + user.Role = role; return Map(user); } @@ -120,5 +130,5 @@ public sealed class UserManagementService : IUserManagementService } private static ManagedUserDto Map(User u) => new( - u.UserId, u.Username, u.DisplayName, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name); + u.UserId, u.Username, u.DisplayName, u.Email, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name); } diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index fdba3e0..ee5dd78 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -32,4 +32,19 @@ public static class ErrorCodes public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE"; public const string CsrfTokenMismatch = "CSRF_TOKEN_MISMATCH"; public const string RefreshTokenMissing = "REFRESH_TOKEN_MISSING"; + + // HRM (docs/13-BACKEND-HRM-API.md §7) + public const string EmployeeCodeDuplicate = "EMPLOYEE_CODE_DUPLICATE"; + public const string EmployeeAlreadyLinked = "EMPLOYEE_ALREADY_LINKED"; + public const string UserAlreadyLinked = "USER_ALREADY_LINKED"; + public const string DepartmentCycleDetected = "DEPARTMENT_CYCLE_DETECTED"; + public const string DocumentTypeInUse = "DOCUMENT_TYPE_IN_USE"; + public const string FileTypeNotAllowed = "FILE_TYPE_NOT_ALLOWED"; + public const string FileTooLarge = "FILE_TOO_LARGE"; + public const string AttendanceBatchLocked = "ATTENDANCE_BATCH_LOCKED"; + public const string AttendanceDuplicateUnresolved = "ATTENDANCE_DUPLICATE_UNRESOLVED"; + public const string AttendanceNotConfirmed = "ATTENDANCE_NOT_CONFIRMED"; + public const string SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP"; + public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID"; + public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED"; } diff --git a/Backend/ERPCore/appsettings.json b/Backend/ERPCore/appsettings.json index 7735c12..e207a35 100644 --- a/Backend/ERPCore/appsettings.json +++ b/Backend/ERPCore/appsettings.json @@ -18,5 +18,9 @@ "AuthHex": { "BaseUrl": "http://localhost:5011" }, + "FileStorage": { + "RootPath": "App_Data/hr-documents", + "MaxSizeBytes": 10485760 + }, "AllowedHosts": "*" } diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index d9b4286..debc2a8 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -110,6 +110,74 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [ ] Reservation/allocation fulfilment - [ ] RBAC policy enforcement + approval workflow activation +--- + +# HRM (Phase 2) + +Spec: `docs/12-BACKEND-HRM.md` (model + rules) · `docs/13-BACKEND-HRM-API.md` (API). Security: `docs/02-SECURITY.md §C.8` (run before ticking any HRM feature `[x]` — salary/PII data, see AR-09/AR-10). + +### 2026-07-23 — Bug fix: `DateTime Kind=Unspecified` 500 on every user-supplied date (Employee create, Statutory Settings create, etc.) +- **Root cause:** Postgres/Npgsql requires `DateTime` values written to a `timestamp with time zone` column to have `Kind=Utc`. Every Phase-1 `DateTime` was always server-generated (`DateTime.UtcNow`), so this never surfaced before. HRM is the first place user-supplied dates (hire date, salary-structure/statutory-setting effective date, leave/attendance period dates, document issue/expiry dates, …) get deserialized straight from a JSON request body — which produces `Kind=Unspecified` — and then persisted, so **any** create involving a date (`POST /employees`, `POST /payroll-statutory-settings`, `POST /tax-slabs`, `POST /employees/{id}/salary-structure`, `POST /leave-requests`, attendance upload, …) threw a `500` (`DbUpdateException` → `ArgumentException: Cannot write DateTime with Kind=Unspecified...`). Confirmed via `logs/erpcore-20260723.log`. +- **Fix:** a global EF Core `ValueConverter`/`ValueConverter` registered once in `ErpDbContext.OnModelCreating` (applied to every entity property of type `DateTime`/`DateTime?` via `modelBuilder.Model.GetEntityTypes()`), forcing `Kind=Utc` on write. Fixes the bug for every current and future HRM (and Phase-1) entity in one place, rather than patching each service call site individually. +- **No migration needed** — confirmed by scaffolding a migration and finding it empty (`Up`/`Down` both no-ops), then removing it. The converter doesn't change the store type (`timestamp with time zone` throughout), only how the CLR value's `Kind` is normalized before Npgsql sees it. +- **Verified:** `dotnet build` clean (0/0). Not yet re-verified end-to-end against a live AuthHex session (same blocker as the rest of this phase's runtime testing) — the next person to get a token should re-try `POST /employees` and `POST /payroll-statutory-settings` to confirm the `500` is gone. + +## 7. Sub-phase 2.1 — Employee + User-link + Documents +> **Code complete + migration applied (2026-07-23).** `dotnet build` clean (0/0); migration `AddHrmPhase1Foundation` generated + applied to the local Postgres DB (new tables only + one nullable `users.Email` column — the scaffolder's "possible data loss" warning is just the benign `UpdateData` setting the seeded system user's `Email` to `null`, not a drop). Runtime smoke-test (Swagger/browser) not yet run — do that before ticking `[x]`, per the §6 security-gate convention §1's note established for Phase 1. +- [x] Org masters: Branch, Department (self-nesting + cycle guard), Designation, EmploymentType, WorkShift — entities + configs + services + controllers (CRUD, ETag, deactivate-not-delete). Routes: `/branches`, `/departments`, `/designations`, `/employment-types`, `/work-shifts`. +- [x] Employee entity + config (EmployeeStatus enum, EmployeeCode uniqueness, all FKs) + service + controller (`/employees`) +- [x] EmployeeBankDetail (one-to-many, IsPrimary) — `GET/PUT /employees/{id}/bank-details` (full-replace) +- [x] `User.Email` column + unique index (Postgres allows multiple NULLs natively, same pattern as `AuthUserId` — no explicit filter needed); `UsersController.Create` now persists it locally (previously silently dropped despite `CreateUserRequest.Email` being required) and returns it on `ManagedUserDto`. +- [x] `EmployeeUserLinkService` + email-lookup endpoints (`GET /employees/email-lookup`, `GET /users/email-lookup`, both advisory/non-mutating) + `POST/DELETE /employees/{id}/link-user` + `linkUserId`/`linkEmployeeId` on the two create endpoints. `Employee.UserId` has a unique index (DB-level one-User-per-Employee guarantee) backed by service-level `EMPLOYEE_ALREADY_LINKED`/`USER_ALREADY_LINKED` (409) checks. +- [x] `HrDocumentType` master (CRUD, deactivate-not-delete) — `/hr-document-types` +- [x] `Infra/Storage/IFileStorageService` + `LocalFileStorageService` (root `App_Data/hr-documents` outside wwwroot, year/month bucketing, path-escape guard, registered as a **singleton** — stateless aside from the configured root) +- [x] `EmployeeDocument` entity + upload/list/download/status endpoints (`POST/GET /employees/{id}/documents`, `GET .../documents/{docId}/download`, `PATCH .../documents/{docId}/status`) — extension allowlist (`.pdf/.jpg/.jpeg/.png/.docx`) cross-checked against declared content-type, size cap from `FileStorage:MaxSizeBytes` (appsettings, default 10MB) + +**Deviations (recorded, not silently skipped):** +- **JIT-provisioning Email backfill not implemented.** `ShadowUserClaimsTransformation` still only sets Username/DisplayName from the AuthHex token — the token carries no `Email` claim (confirmed set: `UserId`/`UserTypeCode`/`RoleCode`/`NIC`/`jti`/`iat`), so backfilling it would require an extra AuthHex API call (`getUserDetails`) inside the claims-transformation hot path. Deferred as a follow-up; the primary path (`UsersController.Create`, which already collects `Email` in the request body) covers the common case of an ERPCore-driven user creation. +- **`DOCUMENT_TYPE_IN_USE` error code is defined but not wired to anything** — there is no hard-`DELETE` endpoint for `HrDocumentType` (same deactivate-only convention as every Phase-1 master; FR-MD-08), so nothing currently triggers it. Reserved for consistency with the doc, same posture as Phase 1's unused `MASTER_IN_USE` before transaction tables existed. +- **`EmployeeDocumentService` reads `Stream.Length` for the size-cap check** rather than `IFormFile.Length` directly — works because ASP.NET Core's default `IFormFile.OpenReadStream()` returns a seekable buffered stream, but would need revisiting if a non-seekable upload path is ever added. + +## 8. Sub-phase 2.2 — Attendance + Leave +> **Code complete + migration applied (2026-07-23).** `dotnet build` clean (0 errors); migration `AddHrmAttendanceAndLeave` generated (purely additive, no data-loss warning) + applied to the local Postgres DB. Packages added: `ClosedXML` 0.105.0, `CsvHelper` 33.1.0. Runtime smoke-test not yet run. +- [x] LeaveType (master CRUD, `/leave-types`), LeaveRequest (Draft/Submitted/Approved/Rejected/Cancelled, `/leave-requests`, DocNo via `NumberSequenceService` "LV"), LeaveBalance (`GET/PUT /employees/{id}/leave-balances`) — approving a request increments `LeaveBalance.TakenDays` via `ILeaveBalanceService.IncrementTakenDaysAsync` +- [x] WorkShift-based `AttendanceComputationService` (Working/Late/Early/OT minutes; overnight-shift handling; derives Present/HalfDay/Absent/Holiday/WeekOff/OnLeave) — the analog of `FifoCostingService` +- [x] AttendanceUploadBatch + AttendanceRecord entities/config (`WorkShiftId` snapshotted at ingestion per docs A.3) +- [x] Excel/CSV parsing (ClosedXML for `.xlsx`, CsvHelper for `.csv`) + `GET /attendance-batches/template.xlsx`/`?format=csv` — both share `AttendanceUploadService.ColumnNames` so template and parser can't drift +- [x] Upload → validate (employee-code resolution against Active employees, date/time parse, within-batch + cross-batch-confirmed duplicate detection) → confirm pipeline, exact status flow Draft→Validated→Confirmed→UsedInPayroll (`/attendance-batches`, `.../validate`, `.../confirm`) +- [x] Manual record edit (`PUT .../records/{id}`, blocked once Confirmed/UsedInPayroll → `409 ATTENDANCE_BATCH_LOCKED`) + duplicate resolution (`POST .../resolve-duplicate`, keep/discard/supersede) + unlock (`POST .../unlock`, mandatory reason, blocked once `UsedInPayroll`) +- [x] Leave→Attendance OnLeave classification wired in — `AttendanceUploadService` calls `ILeaveRequestService.FindApprovedLeaveCoveringAsync` per record during upload and re-computation + +**Deviations (recorded):** +- **LeaveRequest.DaysCount is a calendar-day count** (`EndDate − StartDate + 1`), not business-day/holiday-aware. Flagged as a simplification in the service's own doc comment — a real deployment will want to exclude weekends/holidays from paid-leave day counts before this feeds Payroll. +- **No `Holiday` calendar entity exists yet** — `AttendanceComputationService.Compute` always receives `isHoliday: false`; only `WeekOff` (derived from `WorkShift.WorkingDaysMask`) and `OnLeave` are currently distinguishable from a plain `Absent`. A company-holiday calendar is a natural near-term addition, not built in this pass. +- **`ResolveDuplicateAsync`'s "supersede" action does not yet locate/mutate the prior confirmed record** — it currently just accepts the new row as Valid. The prior confirmed `AttendanceRecord` this is meant to supersede is not looked up or flagged; this needs a follow-up pass before "supersede" is safe to expose to non-admin users in the UI. + +## 9. Sub-phase 2.3 — Payroll +> **Code complete + migration applied (2026-07-23).** `dotnet build` clean (0 errors); migration `AddHrmPayroll` generated + applied to the local Postgres DB. Runtime smoke-test not yet run. +- [x] SalaryComponent master (`/salary-components`) — Earning/Deduction, IsTaxable, IsEpfEtfApplicable +- [x] EmployeeSalaryStructure (+Lines), effective-dated (`GET/POST /employees/{id}/salary-structure`) — creating a new structure automatically supersedes the previous open-ended one (`EffectiveTo` set the day before the new `EffectiveFrom`), `409 SALARY_STRUCTURE_OVERLAP` if the new date isn't after the current one +- [x] EmployeeLoan (+Installments) ledger (`GET/POST /employees/{id}/loans`) — creating a loan generates its full installment schedule up front; `IEmployeeLoanService.GetDueInstallmentsAsync` is what `PayrollCalculationService` consumes +- [x] PayrollStatutorySetting (`/payroll-statutory-settings`), TaxSlab (`/tax-slabs`) — both effective-dated; creating a new statutory setting supersedes the prior open-ended one; tax slab creation validates no gap/overlap for the same effective date (`422 TAX_SLAB_GAP_INVALID`) +- [x] `PayrollCalculationService` — Gross = Basic + allowance lines + OT; Net = Gross − Late − NoPay − Loan − EPF(employee) − Tax − OtherDeductions; **EPF-employer/ETF are informational-only, never subtracted**, matching the spec's "Company Contribution" framing; Tax via standard ascending marginal-slab computation over taxable earnings +- [x] PayrollRun/PayrollLine/PayrollLineComponent + Draft→Approved→Locked workflow (`/payroll-runs`, generate/approve/lock/unlock/generate-payslips) — Generate blocked (`422 ATTENDANCE_NOT_CONFIRMED`) if any attendance batch for the period is still Draft/Validated; loan-installment (Pending→Deducted, balance decremented) and attendance-batch (Confirmed→UsedInPayroll) stamping deferred to **Lock**, not Generate/Approve, per docs A.4 +- [x] Unlock (`POST .../unlock`, mandatory reason) — reverses both the loan-installment and attendance-batch stamps made at Lock, back to Approved +- [x] Payslip generation (Locked-only, idempotent) + HTML print view (`GET /payslips/{id}/view`) — no PDF dependency, per the confirmed decision + +**Deviations / open items (recorded, not silently assumed):** +- **The exact APIT taxable-income base is a real compliance question, not resolved here** (docs/12-BACKEND-HRM.md B.4 flags this explicitly) — `PayrollCalculationService` computes taxable income as `Basic + taxable allowance lines + Overtime` and applies the configurable `TaxSlab` table as a standard ascending marginal calculation; whether EPF-employee should reduce taxable income first, or whether OT should be taxable at all, needs finance/statutory sign-off before go-live. +- **NoPayAmount currently only counts plain `Absent` days**, not unpaid-leave days — `AttendanceRecord` doesn't yet carry which `LeaveType` covered an `OnLeave` day (or whether it's paid), so all `OnLeave` days are currently treated as paid. A follow-up should either stamp the record with the leave's paid/unpaid flag at attendance-computation time, or join back to `LeaveRequest`/`LeaveType` during payroll calculation. +- **OT/Late per-minute rate model**: `dailyRate = Basic ÷ daysInMonth`, `perMinuteRate = dailyRate ÷ WorkShift.StandardWorkingMinutes` — a simplification flagged in `12-BACKEND-HRM.md B.4` (tiered late/OT policies are a future improvement, not built now). +- **`PayrollRunService.GenerateAsync` skips employees with no effective salary structure** for the period rather than failing the whole run — intentional (a partially-onboarded workforce shouldn't block payroll for everyone else), but means a run's employee count can silently be less than total active headcount; worth surfacing in the frontend as a warning list. +- **Loan-installment/attendance-batch reversal on Unlock re-derives "what this run touched" by period/status query**, not from a stored per-run link table (e.g. any `Confirmed`-turned-`UsedInPayroll` batch for the run's period, any installment whose `PayrollRunId` matches). This is correct for the common case but would need a real link if multiple concurrent runs ever target overlapping periods/branches — not expected in this phase (one run per period/branch). + +## 10. Sub-phase 2.4 — Reports +> **Code complete (2026-07-23).** `dotnet build` clean (0 errors). No new entities/migration — pure read-only aggregation over Attendance/Payroll/Leave/Document tables (`IHrReportService`/`HrReportsController`, `/reports/hrm/*`), same posture as `StockController`'s on-hand/ledger queries. Runtime smoke-test not yet run. +- [x] Attendance summary (`GET /reports/hrm/attendance-summary?periodYear=&periodMonth=&departmentId=`), OT report (`.../overtime`), late-arrival report (`.../late-arrivals`) +- [x] Payroll register (`.../payroll-register?payrollRunId=`) — `TotalDeductions` computed as `GrossSalary − NetSalary` (informational EPF-employer/ETF already excluded since they were never subtracted from Net) +- [x] Employee salary history (`.../salary-history?employeeId=`) — full `EmployeeSalaryStructure` revision history, ordered newest first +- [x] Leave balance report (`.../leave-balances?year=`), document expiry report (`.../document-expiry?withinDays=`) + ## Done diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 5c28774..d0f8725 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -87,6 +87,34 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt - [x] Transactional actions show server-returned side effects as confirmation — GRN confirm renders `createdLayers`/`ledgerRefs`/`poStatus` from the response +--- + +# HRM (Phase 2) + +Spec: `docs/21-FRONTEND-HRM.md` (flows + rules) · `docs/13-BACKEND-HRM-API.md` (API contract). + +## 8. Screens (per `21-FRONTEND-HRM.md §1`) +> **Code complete (2026-07-23).** All screens below built against the live HRM API (`lib/api/{employees,hrm-masters,hrm-master-factory,attendance,leave,payroll,hr-reports}.ts`, `types/hrm.ts`). `tsc --noEmit` clean for every new/changed file (the only remaining project-wide tsc errors are pre-existing, unrelated syntax errors in `app/dashboard/receiving/grn/new/page.tsx` — not touched this pass, not introduced by it). `eslint` clean on all new/changed files. Runtime browser verification not yet done — see the note at the end of this section. +- [x] Employees (`app/dashboard/hrm/employees/{page,[id]/page}.tsx`) — list + create dialog (department/designation/employment-type/work-shift selects) + detail page with an Overview/Bank Details/Documents/Salary & Loans tab switcher (plain button-based tabs — no `Tabs` primitive exists in `components/ui/` yet). Email-lookup cross-link suggestion chip on the create dialog's email field (`onBlur` → `employeesApi.emailLookup`), never auto-linking — the human must have already seen the match before `linkUserId` is set on submit. +- [x] **Salary & Loans tab** (added same session, follow-up to the initial pass) — Salary Structure section shows effective-dated history (`employeesApi.salaryStructureHistory`) + a "New Structure" dialog (effective date, basic salary, dynamic allowance/deduction lines picked from `salaryComponentsApi`, `employeesApi.createSalaryStructure`); Loans & Advances section shows the loan list (`employeesApi.listLoans`) + a "New Loan" dialog (Loan/Advance, principal, installment amount, count, start year/month, `employeesApi.createLoan`). +- [x] Users screen (`app/dashboard/settings/users/page.tsx`) extended with the same cross-link suggestion chip in reverse (`employeeCrossLinkApi.findStaffByEmail` on email blur), setting `linkEmployeeId` on submit. `ManagedUser`/`CreateUserRequest` types extended with `email`/`linkEmployeeId` to match the backend DTO changes. +- [x] Attendance (`app/dashboard/hrm/attendance/{page,[id]/page}.tsx`) — batch list + upload dialog (period start/end + file picker, `.xlsx`/`.csv`) + template download link (`attendanceTemplateUrl()`) + detail page rendering the Working Hours/Late/OT preview table with per-row validation-status badges, Validate/Confirm/Unlock actions gated on batch status, and Keep/Discard duplicate-resolution buttons (Draft only). +- [x] Leave (`app/dashboard/hrm/leave/page.tsx`) — single-page request list + create dialog (employee/leave-type selects, immediately submits after create) + inline Approve/Reject actions on Submitted rows (reject via a `window.prompt` for the reason — the simplest correct UX given the time budget; a proper dialog is a nicer follow-up, not a correctness gap). +- [x] Payroll (`app/dashboard/hrm/payroll/{page,[id]/page,[id]/lines/[lineId]/page}.tsx`) — run list + Generate dialog (year/month), detail page rendering the exact Basic/OT/Allowances/Deductions/Net preview table plus Approve/Lock/Unlock(with mandatory reason)/Generate Payslips actions gated on `status`, and a line-breakdown page matching the user's exact Earnings/Deductions/Employer-Contributions layout (EPF-employer/ETF explicitly labeled "informational — not deducted"). **Backend gap found and fixed during this pass**: there was no endpoint to list all `PayrollLine`s for a run (only single-line lookup existed) — added `GET /payroll-runs/{id}/lines` (`IPayrollRunService.ListLinesAsync`, `PayrollRunsController.ListLines`) since the Payroll Preview table genuinely needs it; documented in `docs/13-BACKEND-HRM-API.md §6`. +- [x] Reports (`app/dashboard/hrm/reports/page.tsx`) — single page, a report-type select switches which filter fields + table are shown (Attendance Summary / Overtime / Late Arrivals / Payroll Register / Salary History / Leave Balances / Document Expiry), each calling its own `hrReportsApi` method on demand. +- [x] Settings screens (`app/dashboard/hrm/settings/{page,branches,departments,designations,employment-types,work-shifts,document-types,leave-types,salary-components,statutory}/page.tsx`) — a hub page linking to 9 sub-screens. `components/hrm/CodeNameMasterPage.tsx` is a shared generic component for the three byte-identical "code + name" masters (Branch, Designation, EmploymentType) — the other masters (Department's parent/branch selects, WorkShift's many fields + working-days bitmask, HrDocumentType's category enum, LeaveType's paid/no-pay/carry-forward flags, SalaryComponent's type enum) each have their own page since their forms genuinely differ, matching this codebase's own existing convention of one file per master rather than a forced one-size-fits-all abstraction. Statutory settings page covers both `PayrollStatutorySetting` and `TaxSlab` (list + create, no edit — both are effective-dated/append-only by design). +- [x] Sidebar (`components/Layouts/AppSidebar.tsx`) — new "HRM" section with 6 children (Employees/Attendance/Leave/Payroll/Reports/Settings). **Deviation, matching existing precedent**: no backend `NavItem`/`SubNavItem` seed exists for `hrm`/`hrm.*` codes yet, so — exactly like the pre-existing `procurement` bypass — `hrm` is added to the same frontend-only `bypassCodes` set that skips the `navCodes` visibility check. This is also the AR-09 sidebar-visibility stopgap called out in `02-SECURITY.md §C.8`: it hides HRM from the UI for now but enforces nothing server-side. Remove the bypass once a real nav/permission seed exists. +- [x] `lib/api-client.ts` extended to support `FormData` request bodies (attendance file upload, staff document upload) — previously every request body was unconditionally `JSON.stringify`'d; now a `FormData` body skips both that and the `Content-Type` header (the browser sets its own multipart boundary). + +## 9. Validation posture (HRM specifics, per `21-FRONTEND-HRM.md §3`) +- [x] Client format/required checks on the Employee create dialog (code/name/hire-date/department/designation/employment-type/work-shift) and Attendance upload (period dates, file presence) — UX only, per `20-FRONTEND.md §3` +- [x] Server-authoritative, never assumed client-side: employee-code uniqueness, email-lookup match existence, one-User-per-Employee, attendance duplicate detection (within-batch/cross-batch), attendance batch lock state, payroll generation's attendance-confirmed precondition, payroll run lock state, and every calculated amount (Gross/Net/Tax/EPF/ETF/OT/Late/No-Pay) — the client never computes or previews these independently of what the server returns; all payroll tables render server-supplied numbers verbatim. + +**Not yet done, flagged rather than silently skipped:** +- **Runtime/browser verification.** Every screen above type-checks and lints clean, and was built directly against the live API contract confirmed by the backend smoke test (71+ registered routes, correct 401 gating), but no screen has been driven in an actual browser this pass — that needs a running AuthHex session (see `Backend/PROGRESS.md`'s sub-phase 2.1 note on why deep functional testing was deferred) to get past the login wall. +- **Leave reject uses a native `window.prompt`** instead of a dialog — functionally correct, but a lower-fidelity UX than the rest of the app's dialog-based patterns. +- **A pre-existing, unrelated syntax error in `app/dashboard/receiving/grn/new/page.tsx`** (unclosed JSX, last touched 2026-07-23 before this HRM pass started) blocks a clean whole-project `tsc --noEmit` run. Not introduced by this work and not fixed by it — confirmed via `git status`/`git log` that this file was untouched this session; scoped `eslint`/`tsc` checks against every HRM file individually (and the fact this is the *only* file `tsc` reports) confirm the HRM additions themselves are clean. + ## Done diff --git a/Frontend/erp-system/app/dashboard/hrm/attendance/[id]/page.tsx b/Frontend/erp-system/app/dashboard/hrm/attendance/[id]/page.tsx new file mode 100644 index 0000000..9df1019 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/attendance/[id]/page.tsx @@ -0,0 +1,194 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } from "next/navigation" + +import { attendanceApi } from "@/lib/api/attendance" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { AttendanceRecord, AttendanceUploadBatch } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const ROW_STATUS_STYLE: Record = { + Valid: "bg-success/10 text-success", + DuplicateWithinBatch: "bg-warning/10 text-warning", + DuplicateConfirmed: "bg-warning/10 text-warning", + EmployeeNotFound: "bg-destructive/10 text-destructive", + InvalidDateTime: "bg-destructive/10 text-destructive", + Error: "bg-destructive/10 text-destructive", +} + +function minutesLabel(m: number): string { + if (m <= 0) return "0" + const h = Math.floor(m / 60) + const mm = m % 60 + return h > 0 ? `${h}h ${mm}m` : `${mm}m` +} + +export default function AttendanceBatchDetailPage() { + const params = useParams<{ id: string }>() + const batchId = Number(params.id) + + const [batch, setBatch] = useState(null) + const [records, setRecords] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [unlockOpen, setUnlockOpen] = useState(false) + const [unlockReason, setUnlockReason] = useState("") + + function load() { + Promise.all([attendanceApi.get(batchId), attendanceApi.listRecords(batchId)]) + .then(([b, r]) => { setBatch(b); setRecords(r) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [batchId]) + + const locked = batch?.status === "Confirmed" || batch?.status === "UsedInPayroll" + + async function resolve(recordId: number, action: "keep" | "discard" | "supersede") { + try { + await attendanceApi.resolveDuplicate(batchId, recordId, action) + toast.success("Resolved") + load() + } catch (err) { + toast.error("Could not resolve", errorMessage(err)) + } + } + + async function validate() { + setBusy(true) + try { + const b = await attendanceApi.validate(batchId) + setBatch(b) + toast.success("Batch validated") + } catch (err) { + toast.error("Could not validate", errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function confirm() { + setBusy(true) + try { + const b = await attendanceApi.confirm(batchId) + setBatch(b) + toast.success("Batch confirmed", "This is now the source of truth for payroll.") + } catch (err) { + toast.error("Could not confirm", errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function unlock() { + if (!unlockReason.trim()) { toast.error("A reason is required"); return } + setBusy(true) + try { + const b = await attendanceApi.unlock(batchId, unlockReason.trim()) + setBatch(b) + setUnlockOpen(false) + setUnlockReason("") + toast.success("Batch unlocked") + } catch (err) { + toast.error("Could not unlock", errorMessage(err)) + } finally { + setBusy(false) + } + } + + if (error) return
{error}
+ if (!batch || !records) return
{Array.from({ length: 5 }).map((_, i) => )}
+ + const unresolved = records.filter((r) => r.rowValidationStatus !== "Valid") + + return ( +
+
+
+

{batch.docNo}

+

{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows

+
+
+ {batch.status} + {batch.status === "Draft" && } + {batch.status === "Validated" && } + {batch.status === "Confirmed" && ( + + Unlock} /> + + + Unlock batch + Requires a reason and reverts to Validated for editing. + + + Reason setUnlockReason(e.target.value)} /> + +
+ + +
+
+
+ )} +
+
+ + {unresolved.length > 0 && batch.status === "Draft" && ( +
+ {unresolved.length} record(s) have unresolved errors or duplicates — resolve them before validating. +
+ )} + + + + + Employee + Date + In / Out + Working Hours + Late + OT + Status + Row + {!locked && Resolve} + + + + {records.map((r) => ( + + {r.employeeName ?? r.employeeCode ?? "Unknown"} + {new Date(r.attendanceDate).toLocaleDateString()} + {r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"} + {minutesLabel(r.workingMinutes)} + {r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"} + {r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"} + {r.attendanceStatus} + + {r.rowValidationStatus} + + {!locked && ( + + {r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && ( +
+ + +
+ )} +
+ )} +
+ ))} +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/attendance/page.tsx b/Frontend/erp-system/app/dashboard/hrm/attendance/page.tsx new file mode 100644 index 0000000..f0feb61 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/attendance/page.tsx @@ -0,0 +1,152 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { Download, Eye, Plus, Upload } from "lucide-react" + +import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { AttendanceUploadBatch } from "@/types/hrm" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +const STATUS_STYLE: Record = { + Draft: "bg-muted text-muted-foreground", + Validated: "bg-warning/10 text-warning", + Confirmed: "bg-success/10 text-success", + UsedInPayroll: "bg-primary/10 text-primary", +} + +export default function AttendanceBatchesPage() { + const [batches, setBatches] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [periodStart, setPeriodStart] = useState("") + const [periodEnd, setPeriodEnd] = useState("") + const [file, setFile] = useState(null) + const [submitting, setSubmitting] = useState(false) + + function load() { + attendanceApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setBatches(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + async function handleUpload() { + if (!file) { toast.error("Choose an Excel or CSV file first"); return } + if (!periodStart || !periodEnd) { toast.error("Period start/end are required"); return } + setSubmitting(true) + try { + await attendanceApi.upload(file, periodStart, periodEnd) + toast.success("Attendance uploaded", "Review the preview and confirm when ready.") + setOpen(false) + setFile(null) + setPeriodStart("") + setPeriodEnd("") + load() + } catch (err) { + toast.error("Could not upload attendance", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+

Attendance

+

Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll's source of truth.

+
+
+ + Download template + + + Upload} /> + + + Upload Attendance + Columns: Employee Code, Date, Check In, Check Out. + + +
+ Period start setPeriodStart(e.target.value)} /> + Period end setPeriodEnd(e.target.value)} /> +
+ + File (.xlsx or .csv) + setFile(e.target.files?.[0] ?? null)} /> + +
+
+ + +
+
+
+
+
+ + {error &&
{error}
} + {!error && batches === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && batches !== null && batches.length === 0 && ( +

No attendance batches yet.

+ )} + {!error && batches !== null && batches.length > 0 && ( + + + + Doc No + Period + Rows + Duplicates/Errors + Status + Actions + + + + {batches.map((b) => ( + + {b.docNo} + {new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()} + {b.rowCountTotal} + {b.rowCountDuplicate} / {b.rowCountError} + {b.status} + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/employees/[id]/page.tsx b/Frontend/erp-system/app/dashboard/hrm/employees/[id]/page.tsx new file mode 100644 index 0000000..9f33dee --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/employees/[id]/page.tsx @@ -0,0 +1,485 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } from "next/navigation" +import { Link2, Plus, Upload } from "lucide-react" + +import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees" +import { departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { + Department, + Designation, + EmployeeBankDetail, + EmployeeDetail, + EmployeeDocument, + EmployeeLoan, + EmployeeSalaryStructure, + EmploymentType, + HrDocumentType, + LoanKind, + SalaryComponent, + WorkShift, +} from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const TABS = ["Overview", "Bank Details", "Documents", "Salary & Loans"] as const +type Tab = (typeof TABS)[number] + +function money(n: number): string { + return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +export default function EmployeeDetailPage() { + const params = useParams<{ id: string }>() + const employeeId = Number(params.id) + + const [tab, setTab] = useState("Overview") + const [employee, setEmployee] = useState(null) + const [etag, setEtag] = useState(null) + const [error, setError] = useState(null) + + const [departments, setDepartments] = useState([]) + const [designations, setDesignations] = useState([]) + const [employmentTypes, setEmploymentTypes] = useState([]) + const [workShifts, setWorkShifts] = useState([]) + const [saving, setSaving] = useState(false) + + const [bankDetails, setBankDetails] = useState([]) + const [documents, setDocuments] = useState([]) + const [documentTypes, setDocumentTypes] = useState([]) + const [uploadTypeId, setUploadTypeId] = useState("") + + const [salaryStructures, setSalaryStructures] = useState([]) + const [salaryComponents, setSalaryComponents] = useState([]) + const [loans, setLoans] = useState([]) + + const [structureOpen, setStructureOpen] = useState(false) + const [structureEffectiveFrom, setStructureEffectiveFrom] = useState("") + const [structureBasic, setStructureBasic] = useState(0) + const [structureLines, setStructureLines] = useState<{ salaryComponentId: string; amount: number }[]>([]) + + const [loanOpen, setLoanOpen] = useState(false) + const [loanKind, setLoanKind] = useState("Loan") + const [loanPrincipal, setLoanPrincipal] = useState(0) + const [loanInstallmentAmount, setLoanInstallmentAmount] = useState(0) + const [loanCount, setLoanCount] = useState(1) + const now = new Date() + const [loanStartYear, setLoanStartYear] = useState(now.getFullYear()) + const [loanStartMonth, setLoanStartMonth] = useState(now.getMonth() + 1) + const [busy, setBusy] = useState(false) + + function load() { + employeesApi.get(employeeId) + .then((res) => { setEmployee(res.data); setEtag(res.etag) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [employeeId]) + + useEffect(() => { + departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([])) + designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([])) + employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([])) + workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([])) + hrDocumentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setDocumentTypes(r.items)).catch(() => setDocumentTypes([])) + }, []) + + useEffect(() => { + if (tab === "Bank Details") employeesApi.listBankDetails(employeeId).then(setBankDetails).catch((err) => toast.error("Could not load bank details", errorMessage(err))) + if (tab === "Documents") employeesApi.listDocuments(employeeId).then(setDocuments).catch((err) => toast.error("Could not load documents", errorMessage(err))) + if (tab === "Salary & Loans") { + employeesApi.salaryStructureHistory(employeeId).then(setSalaryStructures).catch((err) => toast.error("Could not load salary history", errorMessage(err))) + employeesApi.listLoans(employeeId).then(setLoans).catch((err) => toast.error("Could not load loans", errorMessage(err))) + salaryComponentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setSalaryComponents(r.items)).catch(() => setSalaryComponents([])) + } + }, [tab, employeeId]) + + function addStructureLine() { + setStructureLines((prev) => [...prev, { salaryComponentId: "", amount: 0 }]) + } + + async function createStructure() { + if (!structureEffectiveFrom) { toast.error("Effective date is required"); return } + const lines = structureLines.filter((l) => l.salaryComponentId).map((l) => ({ salaryComponentId: Number(l.salaryComponentId), amount: l.amount })) + const request: CreateSalaryStructureRequest = { effectiveFrom: structureEffectiveFrom, basicSalary: structureBasic, lines } + setBusy(true) + try { + await employeesApi.createSalaryStructure(employeeId, request) + toast.success("Salary structure saved") + setStructureOpen(false) + setStructureEffectiveFrom("") + setStructureBasic(0) + setStructureLines([]) + employeesApi.salaryStructureHistory(employeeId).then(setSalaryStructures) + } catch (err) { + toast.error("Could not save salary structure", errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function createLoan() { + if (loanPrincipal <= 0 || loanInstallmentAmount <= 0 || loanCount <= 0) { toast.error("Principal, installment amount, and count must be positive"); return } + const request: CreateEmployeeLoanRequest = { + loanKind, principalAmount: loanPrincipal, interestRate: 0, installmentAmount: loanInstallmentAmount, + numberOfInstallments: loanCount, startYear: loanStartYear, startMonth: loanStartMonth, + } + setBusy(true) + try { + await employeesApi.createLoan(employeeId, request) + toast.success("Loan created") + setLoanOpen(false) + setLoanPrincipal(0) + setLoanInstallmentAmount(0) + setLoanCount(1) + employeesApi.listLoans(employeeId).then(setLoans) + } catch (err) { + toast.error("Could not create loan", errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function handleSave() { + if (!employee || !etag) return + setSaving(true) + try { + const res = await employeesApi.update(employeeId, { + fullName: employee.fullName, + nic: employee.nic, + dateOfBirth: employee.dateOfBirth, + gender: employee.gender, + nationality: employee.nationality, + email: employee.email, + personalMobile: employee.personalMobile, + addressLine1: employee.addressLine1, + addressLine2: employee.addressLine2, + city: employee.city, + postalCode: employee.postalCode, + country: employee.country, + emergencyContactName: employee.emergencyContactName, + emergencyContactRelationship: employee.emergencyContactRelationship, + emergencyContactPhone: employee.emergencyContactPhone, + confirmationDate: employee.confirmationDate, + lastWorkingDate: employee.lastWorkingDate, + departmentId: employee.departmentId, + designationId: employee.designationId, + employmentTypeId: employee.employmentTypeId, + branchId: employee.branchId, + workShiftId: employee.workShiftId, + reportingManagerId: employee.reportingManagerId, + epfNumber: employee.epfNumber, + etfNumber: employee.etfNumber, + taxIdentificationNumber: employee.taxIdentificationNumber, + }, etag) + setEmployee(res.data) + setEtag(res.etag) + toast.success("Employee updated") + } catch (err) { + toast.error("Could not save", errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function handleUpload(file: File) { + if (!uploadTypeId) { toast.error("Select a document type first"); return } + try { + const doc = await employeesApi.uploadDocument(employeeId, file, { hrDocumentTypeId: Number(uploadTypeId) }) + setDocuments((prev) => [doc, ...prev]) + toast.success("Document uploaded") + } catch (err) { + toast.error("Could not upload document", errorMessage(err)) + } + } + + if (error) return
{error}
+ if (!employee) return
{Array.from({ length: 5 }).map((_, i) => )}
+ + return ( +
+
+
+

{employee.fullName}

+

+ {employee.employeeCode} + {employee.userId ? ( + Linked to a system user + ) : ( + No system login + )} +

+
+ {employee.status} +
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === "Overview" && ( + +
+ Full name setEmployee({ ...employee, fullName: e.target.value })} /> + Email setEmployee({ ...employee, email: e.target.value })} /> + NIC setEmployee({ ...employee, nic: e.target.value })} /> + Personal mobile setEmployee({ ...employee, personalMobile: e.target.value })} /> + + Department + + + + Designation + + + + Employment type + + + + Work shift + + + EPF number setEmployee({ ...employee, epfNumber: e.target.value })} /> + ETF number setEmployee({ ...employee, etfNumber: e.target.value })} /> +
+
+ +
+
+ )} + + {tab === "Bank Details" && ( +
+ + + + Bank + Branch + Account + Primary + + + + {bankDetails.map((b, i) => ( + + {b.bankName} + {b.branchName} + {b.accountNumber} + {b.isPrimary ? "Yes" : "—"} + + ))} + {bankDetails.length === 0 && ( + No bank details on file. + )} + +
+
+ )} + + {tab === "Documents" && ( +
+
+ + +
+ + + + File + Type + Uploaded + Status + Download + + + + {documents.map((d) => ( + + {d.originalFileName} + {d.hrDocumentTypeName ?? "—"} + {new Date(d.uploadedAt).toLocaleDateString()} + {d.status} + + Download + + + ))} + {documents.length === 0 && ( + No documents uploaded yet. + )} + +
+
+ )} + + {tab === "Salary & Loans" && ( +
+
+
+

Salary Structure

+ { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}> + New Structure} /> + + + New Salary Structure + Supersedes the current open-ended structure from this date. + + +
+ Effective from setStructureEffectiveFrom(e.target.value)} /> + Basic salary setStructureBasic(Number(e.target.value))} /> +
+ + Allowances / other deductions +
+ {structureLines.map((line, i) => ( +
+ + setStructureLines((prev) => prev.map((l, idx) => (idx === i ? { ...l, amount: Number(e.target.value) } : l)))} /> +
+ ))} + +
+
+
+
+ + +
+
+
+
+ + + + Effective from + Effective to + Basic + Status + + + + {salaryStructures.map((s) => ( + + {new Date(s.effectiveFrom).toLocaleDateString()} + {s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"} + {money(s.basicSalary)} + {s.status} + + ))} + {salaryStructures.length === 0 && ( + No salary structure set yet. + )} + +
+
+ +
+
+

Loans & Advances

+ + New Loan} /> + + + New Loan / Advance + Generates the full installment schedule up front. + + + + Type + + +
+ Principal setLoanPrincipal(Number(e.target.value))} /> + Installment amount setLoanInstallmentAmount(Number(e.target.value))} /> + # installments setLoanCount(Number(e.target.value))} /> + Start year/month +
+ setLoanStartYear(Number(e.target.value))} /> + setLoanStartMonth(Number(e.target.value))} /> +
+
+
+
+
+ + +
+
+
+
+ + + + Doc No + Type + Principal + Outstanding + Status + + + + {loans.map((l) => ( + + {l.docNo} + {l.loanKind} + {money(l.principalAmount)} + {money(l.outstandingBalance)} + {l.status} + + ))} + {loans.length === 0 && ( + No loans or advances on file. + )} + +
+
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/employees/page.tsx b/Frontend/erp-system/app/dashboard/hrm/employees/page.tsx new file mode 100644 index 0000000..51651f0 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/employees/page.tsx @@ -0,0 +1,284 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react" + +import { employeesApi } from "@/lib/api/employees" +import { departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { CreateEmployeeRequest, Department, Designation, EmployeeListItem, EmploymentType, UserMatch, WorkShift } from "@/types/hrm" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +const emptyForm: CreateEmployeeRequest = { + employeeCode: "", + fullName: "", + email: "", + hireDate: new Date().toISOString().slice(0, 10), + departmentId: 0, + designationId: 0, + employmentTypeId: 0, + workShiftId: 0, +} + +export default function EmployeesPage() { + const [employees, setEmployees] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + const [q, setQ] = useState("") + + const [departments, setDepartments] = useState([]) + const [designations, setDesignations] = useState([]) + const [employmentTypes, setEmploymentTypes] = useState([]) + const [workShifts, setWorkShifts] = useState([]) + + const [open, setOpen] = useState(false) + const [form, setForm] = useState(emptyForm) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + // Advisory cross-link suggestion: does a System User already exist with this email? + const [userMatch, setUserMatch] = useState(null) + const [checkingEmail, setCheckingEmail] = useState(false) + + function load() { + employeesApi + .list({ page, pageSize: PAGE_SIZE, q: q || undefined }) + .then((res) => { setEmployees(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page, q]) + + useEffect(() => { + departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([])) + designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([])) + employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([])) + workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([])) + }, []) + + async function checkEmail(email: string) { + if (!email.trim()) { setUserMatch(null); return } + setCheckingEmail(true) + try { + const { match } = await employeesApi.emailLookup(email.trim()) + setUserMatch(match) + } catch { + setUserMatch(null) + } finally { + setCheckingEmail(false) + } + } + + function resetForm() { + setForm(emptyForm) + setUserMatch(null) + setErrors({}) + } + + async function handleCreate() { + const nextErrors: Record = {} + if (!form.employeeCode.trim()) nextErrors.employeeCode = "Employee code is required" + if (!form.fullName.trim()) nextErrors.fullName = "Full name is required" + if (!form.hireDate) nextErrors.hireDate = "Hire date is required" + if (!form.departmentId) nextErrors.departmentId = "Department is required" + if (!form.designationId) nextErrors.designationId = "Designation is required" + if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required" + if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + await employeesApi.create({ + ...form, + email: form.email || null, + linkUserId: userMatch ? userMatch.userId : null, + }) + toast.success("Employee created") + setOpen(false) + resetForm() + load() + } catch (err) { + toast.error("Could not create employee", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+

Employees

+

Staff records — separate from system login accounts (see the link chip below).

+
+ { setOpen(v); if (!v) resetForm() }}> + New Employee} /> + + + New Employee + Not every employee needs a login — a system user account is optional and separate. + + +
+ + Employee code + setForm((f) => ({ ...f, employeeCode: e.target.value }))} aria-invalid={!!errors.employeeCode} /> + + + + Hire date + setForm((f) => ({ ...f, hireDate: e.target.value }))} aria-invalid={!!errors.hireDate} /> + + +
+ + Full name + setForm((f) => ({ ...f, fullName: e.target.value }))} aria-invalid={!!errors.fullName} /> + + + + Email (optional) + setForm((f) => ({ ...f, email: e.target.value }))} + onBlur={(e) => checkEmail(e.target.value)} + /> + {checkingEmail &&

Checking for an existing system user…

} + {userMatch && ( +
+ + + System user {userMatch.username} ({userMatch.displayName}) matches this email — it will be linked to this employee. + +
+ )} +
+
+ + Department + + + + + Designation + + + + + Employment type + + + + + Work shift + + + +
+
+
+ + +
+
+
+
+ + { setPage(1); setQ(e.target.value) }} className="max-w-sm" /> + + {error &&
{error}
} + + {!error && employees === null && ( +
{Array.from({ length: 4 }).map((_, i) => )}
+ )} + + {!error && employees !== null && employees.length === 0 && ( +
+ +

No employees yet.

+
+ )} + + {!error && employees !== null && employees.length > 0 && ( + + + + Code + Name + Department + Designation + Login + Status + Actions + + + + {employees.map((e) => ( + + {e.employeeCode} + {e.fullName} + {e.departmentName ?? "—"} + {e.designationName ?? "—"} + + {e.hasUserLink ? ( + Linked + ) : ( + No login + )} + + + {e.status} + + + + + + + + ))} + +
+ )} + + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx b/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx new file mode 100644 index 0000000..1c858e6 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx @@ -0,0 +1,198 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { employeesApi } from "@/lib/api/employees" +import { leaveTypesApi } from "@/lib/api/hrm-masters" +import { leaveRequestsApi } from "@/lib/api/leave" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +const STATUS_STYLE: Record = { + Draft: "bg-muted text-muted-foreground", + Submitted: "bg-warning/10 text-warning", + Approved: "bg-success/10 text-success", + Rejected: "bg-destructive/10 text-destructive", + Cancelled: "bg-muted text-muted-foreground", +} + +export default function LeaveRequestsPage() { + const [requests, setRequests] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [employees, setEmployees] = useState([]) + const [leaveTypes, setLeaveTypes] = useState([]) + + const [open, setOpen] = useState(false) + const [employeeId, setEmployeeId] = useState("") + const [leaveTypeId, setLeaveTypeId] = useState("") + const [startDate, setStartDate] = useState("") + const [endDate, setEndDate] = useState("") + const [reason, setReason] = useState("") + const [submitting, setSubmitting] = useState(false) + + function load() { + leaveRequestsApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setRequests(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + useEffect(() => { + employeesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmployees(r.items)).catch(() => setEmployees([])) + leaveTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setLeaveTypes(r.items)).catch(() => setLeaveTypes([])) + }, []) + + async function handleCreate() { + if (!employeeId || !leaveTypeId || !startDate || !endDate) { toast.error("All fields except reason are required"); return } + setSubmitting(true) + try { + const created = await leaveRequestsApi.create({ employeeId: Number(employeeId), leaveTypeId: Number(leaveTypeId), startDate, endDate, reason: reason || null }) + await leaveRequestsApi.submit(created.leaveRequestId) + toast.success("Leave request submitted") + setOpen(false) + setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("") + load() + } catch (err) { + toast.error("Could not submit leave request", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function approve(id: number) { + try { + await leaveRequestsApi.approve(id) + toast.success("Leave approved") + load() + } catch (err) { + toast.error("Could not approve", errorMessage(err)) + } + } + + async function reject(id: number) { + const reasonText = window.prompt("Reason for rejection:") + if (!reasonText) return + try { + await leaveRequestsApi.reject(id, reasonText) + toast.success("Leave rejected") + load() + } catch (err) { + toast.error("Could not reject", errorMessage(err)) + } + } + + return ( +
+
+
+

Leave

+

Approved leave feeds Attendance's OnLeave status and Payroll's No-Pay calculation.

+
+ + New Request} /> + + + New Leave Request + Submitted immediately for approval. + + + + Employee + + + + Leave type + + +
+ Start date setStartDate(e.target.value)} /> + End date setEndDate(e.target.value)} /> +
+ Reason (optional) setReason(e.target.value)} /> +
+
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && requests === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && requests !== null && requests.length === 0 && ( +

No leave requests yet.

+ )} + {!error && requests !== null && requests.length > 0 && ( + + + + Doc No + Employee + Type + Dates + Days + Status + Actions + + + + {requests.map((r) => ( + + {r.docNo} + {r.employeeName ?? "—"} + {r.leaveTypeName ?? "—"} + {new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()} + {r.daysCount} + {r.status} + + {r.status === "Submitted" && ( +
+ + +
+ )} +
+
+ ))} +
+
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/lines/[lineId]/page.tsx b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/lines/[lineId]/page.tsx new file mode 100644 index 0000000..b5eb4f2 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/lines/[lineId]/page.tsx @@ -0,0 +1,76 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } from "next/navigation" + +import { payrollRunsApi } from "@/lib/api/payroll" +import { errorMessage } from "@/lib/error-map" +import { PayrollLineDetail } from "@/types/hrm" + +import { Skeleton } from "@/components/ui/skeleton" + +function money(n: number): string { + return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +export default function PayrollLineDetailPage() { + const params = useParams<{ id: string; lineId: string }>() + const runId = Number(params.id) + const lineId = Number(params.lineId) + + const [detail, setDetail] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + payrollRunsApi.getLine(runId, lineId).then(setDetail).catch((err) => setError(errorMessage(err))) + }, [runId, lineId]) + + if (error) return
{error}
+ if (!detail) return
{Array.from({ length: 6 }).map((_, i) => )}
+ + const { line, components } = detail + const earnings = components.filter((c) => c.componentCategory === "Earning") + const deductions = components.filter((c) => c.componentCategory === "Deduction") + const employerContributions = components.filter((c) => c.componentCategory === "EmployerContribution") + + return ( +
+
+

Salary Breakdown

+

{line.employeeName} ({line.employeeCode})

+
+ +
+ + + {earnings.map((c, i) => ( + + ))} + + + {deductions.map((c, i) => ( + + ))} + + {employerContributions.length > 0 && ( + <> + + {employerContributions.map((c, i) => ( + + ))} + + )} + +
{c.label}{money(c.amount)}
Gross Salary{money(line.grossSalary)}
Deductions
{c.label}{money(c.amount)}
Net Salary{money(line.netSalary)}
Employer Contributions (informational — not deducted)
{c.label}{money(c.amount)}
+
+ +
+
Present days: {line.presentDays}
+
Absent days: {line.absentDays}
+
Leave days: {line.leaveDays}
+
OT minutes: {line.otMinutesTotal}
+
Late minutes: {line.lateMinutesTotal}
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx new file mode 100644 index 0000000..2186cd4 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx @@ -0,0 +1,170 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { useParams } from "next/navigation" + +import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PayrollLine, PayrollRun, Payslip } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +function money(n: number): string { + return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +export default function PayrollRunDetailPage() { + const params = useParams<{ id: string }>() + const runId = Number(params.id) + + const [run, setRun] = useState(null) + const [lines, setLines] = useState(null) + const [payslips, setPayslips] = useState([]) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [unlockOpen, setUnlockOpen] = useState(false) + const [unlockReason, setUnlockReason] = useState("") + + function load() { + Promise.all([payrollRunsApi.get(runId), payrollRunsApi.listLines(runId)]) + .then(([r, l]) => { setRun(r); setLines(l) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [runId]) + + async function approve() { + setBusy(true) + try { setRun(await payrollRunsApi.approve(runId)); toast.success("Payroll approved") } + catch (err) { toast.error("Could not approve", errorMessage(err)) } + finally { setBusy(false) } + } + + async function lock() { + setBusy(true) + try { setRun(await payrollRunsApi.lock(runId)); toast.success("Payroll locked") } + catch (err) { toast.error("Could not lock", errorMessage(err)) } + finally { setBusy(false) } + } + + async function unlock() { + if (!unlockReason.trim()) { toast.error("A reason is required"); return } + setBusy(true) + try { + setRun(await payrollRunsApi.unlock(runId, unlockReason.trim())) + setUnlockOpen(false) + setUnlockReason("") + toast.success("Payroll unlocked") + } catch (err) { + toast.error("Could not unlock", errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function generatePayslips() { + setBusy(true) + try { + const result = await payrollRunsApi.generatePayslips(runId) + setPayslips(result) + toast.success("Payslips generated") + } catch (err) { + toast.error("Could not generate payslips", errorMessage(err)) + } finally { + setBusy(false) + } + } + + if (error) return
{error}
+ if (!run || !lines) return
{Array.from({ length: 5 }).map((_, i) => )}
+ + return ( +
+
+
+

{run.docNo}

+

{run.periodMonth.toString().padStart(2, "0")}/{run.periodYear} · {run.employeeCount} employees

+
+
+ {run.status} + {run.status === "Draft" && } + {run.status === "Approved" && } + {run.status === "Locked" && ( + <> + + + Unlock} /> + + + Unlock payroll + The highest-risk action in this module — requires a reason and is fully audited. + + + Reason setUnlockReason(e.target.value)} /> + +
+ + +
+
+
+ + )} +
+
+ +
+

Gross

{money(run.totalGross)}

+

Net

{money(run.totalNet)}

+

Employees

{run.employeeCount}

+

Deductions

{money(run.totalGross - run.totalNet)}

+
+ + + + + Employee + Basic + OT + Allowances + Deductions + Net Salary + Details + + + + {lines.map((l) => { + const totalDeductions = l.grossSalary - l.netSalary + const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId) + return ( + + {l.employeeName} ({l.employeeCode}) + {money(l.basicSalary)} + {money(l.overtimeAmount)} + {money(l.totalAllowances)} + {money(totalDeductions)} + {money(l.netSalary)} + +
+ Breakdown + {payslip && ( + Payslip + )} +
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/payroll/page.tsx b/Frontend/erp-system/app/dashboard/hrm/payroll/page.tsx new file mode 100644 index 0000000..e09f753 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/payroll/page.tsx @@ -0,0 +1,139 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { Eye, Plus } from "lucide-react" + +import { payrollRunsApi } from "@/lib/api/payroll" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { PayrollRun, PayrollRunStatus } from "@/types/hrm" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +const STATUS_STYLE: Record = { + Draft: "bg-muted text-muted-foreground", + Approved: "bg-warning/10 text-warning", + Locked: "bg-success/10 text-success", +} + +export default function PayrollRunsPage() { + const [runs, setRuns] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const now = new Date() + const [periodYear, setPeriodYear] = useState(now.getFullYear()) + const [periodMonth, setPeriodMonth] = useState(now.getMonth() + 1) + const [submitting, setSubmitting] = useState(false) + + function load() { + payrollRunsApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setRuns(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + async function handleGenerate() { + setSubmitting(true) + try { + await payrollRunsApi.generate({ periodYear, periodMonth }) + toast.success("Payroll run generated") + setOpen(false) + load() + } catch (err) { + toast.error("Could not generate payroll", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+

Payroll

+

Generate → Review → Approve → Lock → Payslips.

+
+ + Generate Payroll} /> + + + Generate Payroll Run + Blocked if attendance for this period isn't fully Confirmed yet. + + +
+ Year setPeriodYear(Number(e.target.value))} /> + Month setPeriodMonth(Number(e.target.value))} /> +
+
+
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && runs === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && runs !== null && runs.length === 0 && ( +

No payroll runs yet.

+ )} + {!error && runs !== null && runs.length > 0 && ( + + + + Doc No + Period + Employees + Gross + Net + Status + Actions + + + + {runs.map((r) => ( + + {r.docNo} + {r.periodMonth.toString().padStart(2, "0")}/{r.periodYear} + {r.employeeCount} + {r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })} + {r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })} + {r.status} + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/reports/page.tsx b/Frontend/erp-system/app/dashboard/hrm/reports/page.tsx new file mode 100644 index 0000000..b97e586 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/reports/page.tsx @@ -0,0 +1,175 @@ +"use client" + +import { useState } from "react" + +import { hrReportsApi } from "@/lib/api/hr-reports" +import { errorMessage } from "@/lib/error-map" +import { + AttendanceSummaryRow, + DocumentExpiryReportRow, + LateArrivalReportRow, + LeaveBalanceReportRow, + OvertimeReportRow, + PayrollRegisterRow, + SalaryHistoryRow, +} from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const REPORTS = [ + "Attendance Summary", + "Overtime", + "Late Arrivals", + "Payroll Register", + "Salary History", + "Leave Balances", + "Document Expiry", +] as const +type ReportName = (typeof REPORTS)[number] + +const now = new Date() + +export default function HrReportsPage() { + const [report, setReport] = useState("Attendance Summary") + const [periodYear, setPeriodYear] = useState(now.getFullYear()) + const [periodMonth, setPeriodMonth] = useState(now.getMonth() + 1) + const [payrollRunId, setPayrollRunId] = useState("") + const [employeeId, setEmployeeId] = useState("") + const [year, setYear] = useState(now.getFullYear()) + const [withinDays, setWithinDays] = useState(30) + const [loading, setLoading] = useState(false) + + const [attendanceRows, setAttendanceRows] = useState([]) + const [otRows, setOtRows] = useState([]) + const [lateRows, setLateRows] = useState([]) + const [payrollRows, setPayrollRows] = useState([]) + const [salaryRows, setSalaryRows] = useState([]) + const [leaveRows, setLeaveRows] = useState([]) + const [expiryRows, setExpiryRows] = useState([]) + + async function run() { + setLoading(true) + try { + switch (report) { + case "Attendance Summary": setAttendanceRows(await hrReportsApi.attendanceSummary(periodYear, periodMonth)); break + case "Overtime": setOtRows(await hrReportsApi.overtime(periodYear, periodMonth)); break + case "Late Arrivals": setLateRows(await hrReportsApi.lateArrivals(periodYear, periodMonth)); break + case "Payroll Register": setPayrollRows(await hrReportsApi.payrollRegister(Number(payrollRunId))); break + case "Salary History": setSalaryRows(await hrReportsApi.salaryHistory(Number(employeeId))); break + case "Leave Balances": setLeaveRows(await hrReportsApi.leaveBalances(year)); break + case "Document Expiry": setExpiryRows(await hrReportsApi.documentExpiry(withinDays)); break + } + } catch (err) { + toast.error("Could not load report", errorMessage(err)) + } finally { + setLoading(false) + } + } + + return ( +
+
+

Reports

+

Read-only views over Attendance, Payroll, Leave, and Documents.

+
+ +
+
+ + +
+ + {(report === "Attendance Summary" || report === "Overtime" || report === "Late Arrivals") && ( + <> +
setPeriodYear(Number(e.target.value))} />
+
setPeriodMonth(Number(e.target.value))} />
+ + )} + {report === "Payroll Register" && ( +
setPayrollRunId(e.target.value)} />
+ )} + {report === "Salary History" && ( +
setEmployeeId(e.target.value)} />
+ )} + {report === "Leave Balances" && ( +
setYear(Number(e.target.value))} />
+ )} + {report === "Document Expiry" && ( +
setWithinDays(Number(e.target.value))} />
+ )} + + +
+ + {report === "Attendance Summary" && ( + + EmployeeDeptPresentAbsentLeaveOT (min)Late (min) + {attendanceRows.map((r) => ( + {r.employeeName} ({r.employeeCode}){r.departmentName ?? "—"}{r.presentDays}{r.absentDays}{r.leaveDays}{r.otMinutesTotal}{r.lateMinutesTotal} + ))} +
+ )} + + {report === "Overtime" && ( + + EmployeeDateOT (min) + {otRows.map((r, i) => ( + {r.employeeName} ({r.employeeCode}){new Date(r.attendanceDate).toLocaleDateString()}{r.overtimeMinutes} + ))} +
+ )} + + {report === "Late Arrivals" && ( + + EmployeeDateLate (min) + {lateRows.map((r, i) => ( + {r.employeeName} ({r.employeeCode}){new Date(r.attendanceDate).toLocaleDateString()}{r.lateMinutes} + ))} +
+ )} + + {report === "Payroll Register" && ( + + EmployeeGrossDeductionsNet + {payrollRows.map((r) => ( + {r.employeeName} ({r.employeeCode}){r.grossSalary.toFixed(2)}{r.totalDeductions.toFixed(2)}{r.netSalary.toFixed(2)} + ))} +
+ )} + + {report === "Salary History" && ( + + Effective fromEffective toBasicStatus + {salaryRows.map((r) => ( + {new Date(r.effectiveFrom).toLocaleDateString()}{r.effectiveTo ? new Date(r.effectiveTo).toLocaleDateString() : "Current"}{r.basicSalary.toFixed(2)}{r.status} + ))} +
+ )} + + {report === "Leave Balances" && ( + + EmployeeLeave typeEntitledTakenRemaining + {leaveRows.map((r, i) => ( + {r.employeeName} ({r.employeeCode}){r.leaveTypeName}{r.entitledDays}{r.takenDays}{r.remainingDays} + ))} +
+ )} + + {report === "Document Expiry" && ( + + EmployeeDocument typeExpiry dateDays left + {expiryRows.map((r) => ( + {r.employeeName} ({r.employeeCode}){r.documentTypeName}{new Date(r.expiryDate).toLocaleDateString()}{r.daysUntilExpiry} + ))} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/branches/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/branches/page.tsx new file mode 100644 index 0000000..01ee9a9 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/branches/page.tsx @@ -0,0 +1,16 @@ +"use client" + +import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage" +import { branchesHrmApi } from "@/lib/api/hrm-masters" +import { Branch } from "@/types/hrm" + +export default function BranchesPage() { + return ( + + title="Branches" + description="Company locations/branches — used for multi-branch employee and payroll scoping." + idOf={(b) => b.branchId} + api={branchesHrmApi} + /> + ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/departments/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/departments/page.tsx new file mode 100644 index 0000000..ade8dc2 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/departments/page.tsx @@ -0,0 +1,198 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { Branch, Department } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 +const NONE = "__none__" + +export default function DepartmentsPage() { + const [items, setItems] = useState(null) + const [pagination, setPagination] = useState(null) + const [branches, setBranches] = useState([]) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [parentDepartmentId, setParentDepartmentId] = useState(NONE) + const [branchId, setBranchId] = useState(NONE) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + departmentsApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { + setItems(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page]) + useEffect(() => { + branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((res) => setBranches(res.items)).catch(() => setBranches([])) + }, []) + + async function handleCreate() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Code is required" + if (!name.trim()) nextErrors.name = "Name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + await departmentsApi.create({ + code: code.trim(), + name: name.trim(), + parentDepartmentId: parentDepartmentId === NONE ? null : Number(parentDepartmentId), + branchId: branchId === NONE ? null : Number(branchId), + }) + toast.success("Department created") + setOpen(false) + setCode("") + setName("") + setParentDepartmentId(NONE) + setBranchId(NONE) + load() + } catch (err) { + toast.error("Could not create department", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function toggleStatus(item: Department) { + try { + await departmentsApi.updateStatus(item.departmentId, item.status === "Active" ? "Inactive" : "Active") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } + } + + return ( +
+
+
+

Departments

+

Org structure — unlimited nesting, optionally scoped to a branch.

+
+ + New Department} /> + + + New Department + Set a parent department for a sub-department, or leave it top-level. + + + + Code + setCode(e.target.value)} aria-invalid={!!errors.code} /> + + + + Name + setName(e.target.value)} aria-invalid={!!errors.name} /> + + + + Parent department (optional) + + + + Branch (optional) + + + +
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && items === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && items !== null && items.length === 0 && ( +
+

No departments yet.

+
+ )} + {!error && items !== null && items.length > 0 && ( + + + + Code + Name + Parent + Status + Actions + + + + {items.map((d) => ( + + {d.code} + {d.name} + {items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"} + + {d.status} + + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/designations/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/designations/page.tsx new file mode 100644 index 0000000..b51b551 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/designations/page.tsx @@ -0,0 +1,16 @@ +"use client" + +import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage" +import { designationsApi } from "@/lib/api/hrm-masters" +import { Designation } from "@/types/hrm" + +export default function DesignationsPage() { + return ( + + title="Designations" + description="Job titles — standalone, reusable across departments." + idOf={(d) => d.designationId} + api={designationsApi} + /> + ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/document-types/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/document-types/page.tsx new file mode 100644 index 0000000..42663db --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/document-types/page.tsx @@ -0,0 +1,176 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { hrDocumentTypesApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { HrDocumentCategory, HrDocumentType } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 +const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"] + +export default function DocumentTypesPage() { + const [items, setItems] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [category, setCategory] = useState("Identity") + const [requiredAtOnboarding, setRequiredAtOnboarding] = useState(false) + const [expiryTracked, setExpiryTracked] = useState(false) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + hrDocumentTypesApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setItems(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + async function handleCreate() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Code is required" + if (!name.trim()) nextErrors.name = "Name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + await hrDocumentTypesApi.create({ code: code.trim(), name: name.trim(), category, requiredAtOnboarding, expiryTracked }) + toast.success("Document type created") + setOpen(false) + setCode("") + setName("") + load() + } catch (err) { + toast.error("Could not create document type", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function toggleStatus(item: HrDocumentType) { + try { + await hrDocumentTypesApi.updateStatus(item.hrDocumentTypeId, item.status === "Active" ? "Inactive" : "Active") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } + } + + return ( +
+
+
+

Document Types

+

The staff document catalog — NIC, contracts, certificates, etc.

+
+ + New Type} /> + + + New Document Type + Categorize how this document is used, not the file itself. + + + + Code + setCode(e.target.value)} aria-invalid={!!errors.code} /> + + + + Name + setName(e.target.value)} aria-invalid={!!errors.name} /> + + + + Category + + + + setRequiredAtOnboarding(v === true)} /> + Required at onboarding + + + setExpiryTracked(v === true)} /> + Track expiry date (e.g. passport, visa) + + +
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && items === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && items !== null && items.length === 0 && ( +

No document types yet.

+ )} + {!error && items !== null && items.length > 0 && ( + + + + Code + Name + Category + Status + Actions + + + + {items.map((t) => ( + + {t.code} + {t.name} + {t.category} + + {t.status} + + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/employment-types/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/employment-types/page.tsx new file mode 100644 index 0000000..269d789 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/employment-types/page.tsx @@ -0,0 +1,16 @@ +"use client" + +import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage" +import { employmentTypesApi } from "@/lib/api/hrm-masters" +import { EmploymentType } from "@/types/hrm" + +export default function EmploymentTypesPage() { + return ( + + title="Employment Types" + description="Labor categories — Permanent, Probation, Contract, etc." + idOf={(e) => e.employmentTypeId} + api={employmentTypesApi} + /> + ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/leave-types/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/leave-types/page.tsx new file mode 100644 index 0000000..633791c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/leave-types/page.tsx @@ -0,0 +1,179 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { leaveTypesApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { LeaveType } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +export default function LeaveTypesPage() { + const [items, setItems] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [isPaid, setIsPaid] = useState(true) + const [countsAsNoPay, setCountsAsNoPay] = useState(false) + const [accrualPerYear, setAccrualPerYear] = useState(14) + const [carryForwardAllowed, setCarryForwardAllowed] = useState(false) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + leaveTypesApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setItems(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + async function handleCreate() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Code is required" + if (!name.trim()) nextErrors.name = "Name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + await leaveTypesApi.create({ + code: code.trim(), name: name.trim(), isPaid, countsAsNoPay, accrualPerYear, + carryForwardAllowed, requiresApproval: true, + }) + toast.success("Leave type created") + setOpen(false) + setCode("") + setName("") + load() + } catch (err) { + toast.error("Could not create leave type", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function toggleStatus(item: LeaveType) { + try { + await leaveTypesApi.updateStatus(item.leaveTypeId, item.status === "Active" ? "Inactive" : "Active") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } + } + + return ( +
+
+
+

Leave Types

+

Annual, Casual, Medical, Unpaid, etc. — drives Attendance's OnLeave classification and Payroll's No-Pay calc.

+
+ + New Leave Type} /> + + + New Leave Type + Whether it's paid affects payroll's No-Pay deduction. + + + + Code + setCode(e.target.value)} aria-invalid={!!errors.code} /> + + + + Name + setName(e.target.value)} aria-invalid={!!errors.name} /> + + + + Days per year + setAccrualPerYear(Number(e.target.value))} /> + + + setIsPaid(v === true)} /> + Paid leave + + + setCountsAsNoPay(v === true)} /> + Counts as No-Pay in payroll + + + setCarryForwardAllowed(v === true)} /> + Carry-forward allowed + + +
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && items === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && items !== null && items.length === 0 && ( +

No leave types yet.

+ )} + {!error && items !== null && items.length > 0 && ( + + + + Code + Name + Days/yr + Paid + Status + Actions + + + + {items.map((t) => ( + + {t.code} + {t.name} + {t.accrualPerYear} + {t.isPaid ? "Yes" : "No"} + + {t.status} + + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/page.tsx new file mode 100644 index 0000000..7e6c5b7 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/page.tsx @@ -0,0 +1,44 @@ +"use client" + +import Link from "next/link" +import { Building2, CalendarClock, FileText, ListTree, Percent, Sigma, Users } from "lucide-react" + +const cards = [ + { title: "Branches", href: "/dashboard/hrm/settings/branches", icon: Building2, desc: "Company locations" }, + { title: "Departments", href: "/dashboard/hrm/settings/departments", icon: ListTree, desc: "Org structure" }, + { title: "Designations", href: "/dashboard/hrm/settings/designations", icon: Users, desc: "Job titles" }, + { title: "Employment Types", href: "/dashboard/hrm/settings/employment-types", icon: Users, desc: "Permanent, Contract, etc." }, + { title: "Work Shifts", href: "/dashboard/hrm/settings/work-shifts", icon: CalendarClock, desc: "Attendance baseline" }, + { title: "Document Types", href: "/dashboard/hrm/settings/document-types", icon: FileText, desc: "Staff document catalog" }, + { title: "Leave Types", href: "/dashboard/hrm/settings/leave-types", icon: CalendarClock, desc: "Annual, Casual, Medical…" }, + { title: "Salary Components", href: "/dashboard/hrm/settings/salary-components", icon: Sigma, desc: "Allowances & deductions" }, + { title: "Statutory Settings", href: "/dashboard/hrm/settings/statutory", icon: Percent, desc: "EPF/ETF rates & tax slabs" }, +] + +export default function HrmSettingsPage() { + return ( +
+
+

HRM Settings

+

Masters and configuration used across Employees, Attendance, Leave, and Payroll.

+
+
+ {cards.map((c) => ( + +
+ +
+
+

{c.title}

+

{c.desc}

+
+ + ))} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/salary-components/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/salary-components/page.tsx new file mode 100644 index 0000000..126307f --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/salary-components/page.tsx @@ -0,0 +1,176 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { salaryComponentsApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { SalaryComponent, SalaryComponentType } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +export default function SalaryComponentsPage() { + const [items, setItems] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [componentType, setComponentType] = useState("Earning") + const [isTaxable, setIsTaxable] = useState(true) + const [isEpfEtfApplicable, setIsEpfEtfApplicable] = useState(true) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + salaryComponentsApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setItems(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + async function handleCreate() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Code is required" + if (!name.trim()) nextErrors.name = "Name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + await salaryComponentsApi.create({ code: code.trim(), name: name.trim(), componentType, isTaxable, isEpfEtfApplicable }) + toast.success("Salary component created") + setOpen(false) + setCode("") + setName("") + load() + } catch (err) { + toast.error("Could not create salary component", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function toggleStatus(item: SalaryComponent) { + try { + await salaryComponentsApi.updateStatus(item.salaryComponentId, item.status === "Active" ? "Inactive" : "Active") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } + } + + return ( +
+
+
+

Salary Components

+

Allowances and ad hoc other deductions — OT/Late/No-Pay/Loan/EPF/ETF/Tax are computed automatically, not components.

+
+ + New Component} /> + + + New Salary Component + e.g. Transport Allowance, Meal Allowance, or an ad hoc deduction. + + + + Code + setCode(e.target.value)} aria-invalid={!!errors.code} /> + + + + Name + setName(e.target.value)} aria-invalid={!!errors.name} /> + + + + Type + + + + setIsTaxable(v === true)} /> + Taxable + + + setIsEpfEtfApplicable(v === true)} /> + EPF/ETF applicable + + +
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && items === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && items !== null && items.length === 0 && ( +

No salary components yet.

+ )} + {!error && items !== null && items.length > 0 && ( + + + + Code + Name + Type + Status + Actions + + + + {items.map((c) => ( + + {c.code} + {c.name} + {c.componentType} + + {c.status} + + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/statutory/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/statutory/page.tsx new file mode 100644 index 0000000..737fd7a --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/statutory/page.tsx @@ -0,0 +1,179 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { payrollStatutorySettingsApi, taxSlabsApi } from "@/lib/api/payroll" +import { errorMessage } from "@/lib/error-map" +import { PayrollStatutorySetting, TaxSlab } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +export default function StatutorySettingsPage() { + const [settings, setSettings] = useState([]) + const [slabs, setSlabs] = useState([]) + const [error, setError] = useState(null) + + const [settingOpen, setSettingOpen] = useState(false) + const [epfEmployeeRate, setEpfEmployeeRate] = useState(0.08) + const [epfEmployerRate, setEpfEmployerRate] = useState(0.12) + const [etfEmployerRate, setEtfEmployerRate] = useState(0.03) + const [otMultiplierDefault, setOtMultiplierDefault] = useState(1.5) + const [effectiveFrom, setEffectiveFrom] = useState("") + + const [slabOpen, setSlabOpen] = useState(false) + const [slabEffectiveFrom, setSlabEffectiveFrom] = useState("") + const [lowerBound, setLowerBound] = useState(0) + const [upperBound, setUpperBound] = useState("") + const [rate, setRate] = useState(0.06) + const [submitting, setSubmitting] = useState(false) + + function load() { + Promise.all([payrollStatutorySettingsApi.list(), taxSlabsApi.list()]) + .then(([s, t]) => { setSettings(s); setSlabs(t) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, []) + + async function createSetting() { + if (!effectiveFrom) { toast.error("Effective date is required"); return } + setSubmitting(true) + try { + await payrollStatutorySettingsApi.create({ epfEmployeeRate, epfEmployerRate, etfEmployerRate, otMultiplierDefault, effectiveFrom }) + toast.success("Statutory setting saved") + setSettingOpen(false) + load() + } catch (err) { + toast.error("Could not save", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function createSlab() { + if (!slabEffectiveFrom) { toast.error("Effective date is required"); return } + setSubmitting(true) + try { + await taxSlabsApi.create({ + effectiveFrom: slabEffectiveFrom, + lowerBound, + upperBound: upperBound.trim() === "" ? null : Number(upperBound), + rate, + }) + toast.success("Tax slab created") + setSlabOpen(false) + load() + } catch (err) { + toast.error("Could not create tax slab", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+

Statutory Settings

+

EPF/ETF rates and tax slabs — effective-dated since these change with government policy.

+
+ + {error &&
{error}
} + +
+
+

EPF / ETF Rates

+ + New Setting} /> + + + New Statutory Setting + Supersedes the current open-ended setting from this date. + + + Effective from setEffectiveFrom(e.target.value)} /> + EPF employee rate (0-1) setEpfEmployeeRate(Number(e.target.value))} /> + EPF employer rate (0-1) setEpfEmployerRate(Number(e.target.value))} /> + ETF employer rate (0-1) setEtfEmployerRate(Number(e.target.value))} /> + Default OT multiplier setOtMultiplierDefault(Number(e.target.value))} /> + +
+ + +
+
+
+
+ + + + Effective from + EPF (Employee) + EPF (Employer) + ETF (Employer) + OT multiplier + + + + {settings.map((s) => ( + + {new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"} + {(s.epfEmployeeRate * 100).toFixed(1)}% + {(s.epfEmployerRate * 100).toFixed(1)}% + {(s.etfEmployerRate * 100).toFixed(1)}% + {s.otMultiplierDefault}x + + ))} + +
+
+ +
+
+

Tax Slabs

+ + New Slab} /> + + + New Tax Slab + Leave upper bound empty for "and above". + + + Effective from setSlabEffectiveFrom(e.target.value)} /> + Lower bound setLowerBound(Number(e.target.value))} /> + Upper bound (optional) setUpperBound(e.target.value)} placeholder="And above" /> + Rate (0-1) setRate(Number(e.target.value))} /> + +
+ + +
+
+
+
+ + + + Effective from + Range + Rate + + + + {slabs.map((s) => ( + + {new Date(s.effectiveFrom).toLocaleDateString()} + {s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"} + {(s.rate * 100).toFixed(1)}% + + ))} + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/work-shifts/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/work-shifts/page.tsx new file mode 100644 index 0000000..1c1c7f0 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/work-shifts/page.tsx @@ -0,0 +1,222 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { workShiftsApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { WorkShift } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 +const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + +export default function WorkShiftsPage() { + const [items, setItems] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [startTime, setStartTime] = useState("08:00") + const [endTime, setEndTime] = useState("17:00") + const [isOvernight, setIsOvernight] = useState(false) + const [graceMinutes, setGraceMinutes] = useState(15) + const [breakMinutes, setBreakMinutes] = useState(60) + const [standardWorkingMinutes, setStandardWorkingMinutes] = useState(480) + const [otMultiplier, setOtMultiplier] = useState(1.5) + const [workingDays, setWorkingDays] = useState([true, true, true, true, true, false, false]) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + workShiftsApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { + setItems(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page]) + + async function handleCreate() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Code is required" + if (!name.trim()) nextErrors.name = "Name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + const mask = workingDays.reduce((m, on, i) => (on ? m | (1 << i) : m), 0) + setSubmitting(true) + try { + await workShiftsApi.create({ + code: code.trim(), + name: name.trim(), + startTime: `${startTime}:00`, + endTime: `${endTime}:00`, + isOvernight, + graceMinutes, + breakMinutes, + standardWorkingMinutes, + otMultiplier, + workingDaysMask: mask, + }) + toast.success("Work shift created") + setOpen(false) + setCode("") + setName("") + load() + } catch (err) { + toast.error("Could not create work shift", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function toggleStatus(item: WorkShift) { + try { + await workShiftsApi.updateStatus(item.workShiftId, item.status === "Active" ? "Inactive" : "Active") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } + } + + return ( +
+
+
+

Work Shifts

+

The baseline Attendance computes Late/Early/OT against.

+
+ + New Shift} /> + + + New Work Shift + Standard hours, grace period, and working days for this shift. + + + + Code + setCode(e.target.value)} aria-invalid={!!errors.code} /> + + + + Name + setName(e.target.value)} aria-invalid={!!errors.name} /> + + +
+ + Start time + setStartTime(e.target.value)} /> + + + End time + setEndTime(e.target.value)} /> + +
+ + setIsOvernight(v === true)} /> + Overnight shift (end time rolls past midnight) + +
+ + Grace (minutes) + setGraceMinutes(Number(e.target.value))} /> + + + Break (minutes) + setBreakMinutes(Number(e.target.value))} /> + + + Standard working minutes + setStandardWorkingMinutes(Number(e.target.value))} /> + + + OT multiplier + setOtMultiplier(Number(e.target.value))} /> + +
+ + Working days +
+ {DAYS.map((d, i) => ( + + ))} +
+
+
+
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && items === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && items !== null && items.length === 0 && ( +

No work shifts yet.

+ )} + {!error && items !== null && items.length > 0 && ( + + + + Code + Name + Hours + Status + Actions + + + + {items.map((w) => ( + + {w.code} + {w.name} + {w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)} + + {w.status} + + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/settings/users/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/page.tsx index 7f31957..715a8c4 100644 --- a/Frontend/erp-system/app/dashboard/settings/users/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/users/page.tsx @@ -2,14 +2,16 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { Pencil, Plus, Users as UsersIcon } from "lucide-react" +import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react" import { rolesApi } from "@/lib/api/roles" import { usersApi } from "@/lib/api/users" +import { employeeCrossLinkApi } from "@/lib/api/employees" import { errorMessage, fieldErrors } from "@/lib/error-map" import { cn } from "@/lib/utils" import { PaginationMeta } from "@/types/common" import { Role } from "@/types/rbac" +import { EmployeeMatch } from "@/types/hrm" import { ManagedUser, UserTypeOption } from "@/types/users" import { Button, buttonVariants } from "@/components/ui/button" @@ -50,6 +52,23 @@ export default function UsersPage() { const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) + // Advisory cross-link suggestion: does a Staff record already exist with this email? + const [staffMatch, setStaffMatch] = useState(null) + const [checkingEmail, setCheckingEmail] = useState(false) + + async function checkEmail(value: string) { + if (!value.trim()) { setStaffMatch(null); return } + setCheckingEmail(true) + try { + const { match } = await employeeCrossLinkApi.findStaffByEmail(value.trim()) + setStaffMatch(match) + } catch { + setStaffMatch(null) + } finally { + setCheckingEmail(false) + } + } + function load() { usersApi .list({ page, pageSize: PAGE_SIZE }) @@ -88,6 +107,7 @@ export default function UsersPage() { setRoleId("") setUserTypeId("") setErrors({}) + setStaffMatch(null) } async function handleCreate() { @@ -110,6 +130,7 @@ export default function UsersPage() { nic: nic || null, roleId: Number(roleId), userTypeId: userTypeId.trim(), + linkEmployeeId: staffMatch ? staffMatch.employeeId : null, }) toast.success("User created", `Credentials have been emailed to ${result.username}.`) setOpen(false) @@ -161,8 +182,24 @@ export default function UsersPage() { Email - setEmail(e.target.value)} aria-invalid={!!errors.email} /> + setEmail(e.target.value)} + onBlur={(e) => checkEmail(e.target.value)} + aria-invalid={!!errors.email} + /> + {checkingEmail &&

Checking for an existing staff record…

} + {staffMatch && ( +
+ + + Staff record {staffMatch.employeeCode} ({staffMatch.fullName}) matches this email — it will be linked to this user. + +
+ )}
Mobile number (optional) diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index 63d19ac..fa0fce9 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -4,12 +4,17 @@ import { useEffect, useState } from "react" import Link from "next/link" import { usePathname } from "next/navigation" import { + Banknote, Boxes, Building2, + CalendarCheck, + CalendarClock, ChevronRight, ClipboardList, + FileBarChart, FileText, HelpCircle, + IdCard, LayoutGrid, ListTree, Menu, @@ -81,6 +86,22 @@ const navItems: { { title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true }, { title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true }, { title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true }, + { + title: "HRM", + code: "hrm", + href: "/dashboard/hrm", + landingHref: "/dashboard/hrm/employees", + icon: IdCard, + chevron: true, + children: [ + { title: "Employees", code: "hrm.employees", href: "/dashboard/hrm/employees", icon: Users }, + { title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck }, + { title: "Leave", code: "hrm.leave", href: "/dashboard/hrm/leave", icon: CalendarClock }, + { title: "Payroll", code: "hrm.payroll", href: "/dashboard/hrm/payroll", icon: Banknote }, + { title: "Reports", code: "hrm.reports", href: "/dashboard/hrm/reports", icon: FileBarChart }, + { title: "Settings", code: "hrm.settings", href: "/dashboard/hrm/settings", icon: SlidersHorizontal }, + ], + }, { title: "Settings", code: "settings", @@ -294,17 +315,21 @@ export function AppSidebar() { // flashing the full menu to a restricted role. Once resolved, a nav item // is visible if its own code is granted, or (for parents) if any child is. // - // "procurement" is exempted from that check (frontend-only): no role is currently - // seeded with NAV:procurement or its children server-side, which would hide the whole - // section for everyone. Remove this bypass once roles are granted the permission - // properly (Settings → Roles → Sidebar permissions) or a backend seed grants it. + // "procurement" and "hrm" are exempted from that check (frontend-only): no role is + // currently seeded with NAV:procurement/NAV:hrm or their children server-side, which + // would hide the whole section for everyone. Remove each bypass once roles are granted + // the permission properly (Settings → Roles → Sidebar permissions) or a backend seed + // grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility + // stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce + // anything server-side. + const bypassCodes = new Set(["procurement", "hrm"]) const visibleItems = loading ? [] : navItems - .filter((item) => item.code === "procurement" || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code))) + .filter((item) => bypassCodes.has(item.code) || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code))) .map((item) => ({ ...item, - children: item.code === "procurement" ? item.children : item.children?.filter((c) => navCodes.includes(c.code)), + children: bypassCodes.has(item.code) ? item.children : item.children?.filter((c) => navCodes.includes(c.code)), })) // Close mobile menu on route change diff --git a/Frontend/erp-system/components/hrm/CodeNameMasterPage.tsx b/Frontend/erp-system/components/hrm/CodeNameMasterPage.tsx new file mode 100644 index 0000000..27e0aac --- /dev/null +++ b/Frontend/erp-system/components/hrm/CodeNameMasterPage.tsx @@ -0,0 +1,193 @@ +"use client" + +// Shared list/create/deactivate screen for the plain "code + name" HRM masters +// (Branch, Designation, EmploymentType) — identical shape to each other, so one +// component parameterized by the resource's api/labels replaces 3 near-duplicate pages. +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { EntityStatus, PaginationMeta } from "@/types/common" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +interface CodeNamed { + code: string + name: string + status: EntityStatus +} + +interface Api { + list(params: { page: number; pageSize: number }): Promise<{ items: T[]; pagination: PaginationMeta }> + create(request: { code: string; name: string }): Promise<{ value: T }> + updateStatus(id: number, status: EntityStatus): Promise +} + +export function CodeNameMasterPage({ + title, + description, + idOf, + api, +}: { + title: string + description: string + idOf: (item: T) => number + api: Api +}) { + const PAGE_SIZE = 10 + const [items, setItems] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + api + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { + setItems(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page]) // eslint-disable-line react-hooks/exhaustive-deps + + async function handleCreate() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Code is required" + if (!name.trim()) nextErrors.name = "Name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + await api.create({ code: code.trim(), name: name.trim() }) + toast.success(`${title.replace(/s$/, "")} created`) + setOpen(false) + setCode("") + setName("") + setErrors({}) + load() + } catch (err) { + toast.error("Could not create", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function toggleStatus(item: T) { + const next: EntityStatus = item.status === "Active" ? "Inactive" : "Active" + try { + await api.updateStatus(idOf(item), next) + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } + } + + return ( +
+
+
+

{title}

+

{description}

+
+ + New} /> + + + New {title.replace(/s$/, "")} + Deactivate later — masters are never deleted. + + + + Code + setCode(e.target.value)} aria-invalid={!!errors.code} /> + + + + Name + setName(e.target.value)} aria-invalid={!!errors.name} /> + + + +
+ + +
+
+
+
+ + {error &&
{error}
} + + {!error && items === null && ( +
{Array.from({ length: 4 }).map((_, i) => )}
+ )} + + {!error && items !== null && items.length === 0 && ( +
+

No records yet.

+
+ )} + + {!error && items !== null && items.length > 0 && ( + + + + Code + Name + Status + Actions + + + + {items.map((item) => ( + + {item.code} + {item.name} + + + {item.status} + + + + + + + ))} + +
+ )} + + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/lib/api-client.ts b/Frontend/erp-system/lib/api-client.ts index e85851e..7f569f5 100644 --- a/Frontend/erp-system/lib/api-client.ts +++ b/Frontend/erp-system/lib/api-client.ts @@ -50,10 +50,13 @@ export function readCsrfToken(): string | null { async function rawRequest(path: string, options: RequestOptions = {}): Promise { const { body, ifMatch, idempotencyKey, csrf, headers, ...rest } = options const csrfToken = csrf ? readCsrfToken() : null + // Multipart uploads (attendance files, staff documents) pass a FormData body — the + // browser sets its own Content-Type (with boundary), and it must never be JSON-encoded. + const isFormData = typeof FormData !== "undefined" && body instanceof FormData const finalHeaders: Record = { Accept: "application/json", - ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + ...(body !== undefined && !isFormData ? { "Content-Type": "application/json" } : {}), ...(ifMatch ? { "If-Match": ifMatch } : {}), ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), ...(csrfToken ? { "X-XSRF-TOKEN": csrfToken } : {}), @@ -64,7 +67,7 @@ async function rawRequest(path: string, options: RequestOptions = {}): Promise> { + return apiRequest>(`/attendance-batches${buildQuery(params)}`) + }, + get(batchId: number): Promise { + return apiRequest(`/attendance-batches/${batchId}`) + }, + upload(file: File, periodStart: string, periodEnd: string): Promise { + const form = new FormData() + form.append("file", file) + form.append("PeriodStart", periodStart) + form.append("PeriodEnd", periodEnd) + return apiRequest("/attendance-batches", { method: "POST", body: form }) + }, + + listRecords(batchId: number, status?: RowValidationStatus): Promise { + return apiRequest(`/attendance-batches/${batchId}/records${buildQuery({ status })}`) + }, + updateRecord( + batchId: number, + recordId: number, + request: { checkIn?: string | null; checkOut?: string | null; attendanceStatus?: AttendanceStatusValue | null; notes?: string | null } + ): Promise { + return apiRequest(`/attendance-batches/${batchId}/records/${recordId}`, { method: "PUT", body: request }) + }, + resolveDuplicate(batchId: number, recordId: number, action: "keep" | "discard" | "supersede"): Promise { + return apiRequest(`/attendance-batches/${batchId}/resolve-duplicate`, { method: "POST", body: { recordId, action } }) + }, + + validate(batchId: number): Promise { + return apiRequest(`/attendance-batches/${batchId}/validate`, { method: "POST" }) + }, + confirm(batchId: number): Promise { + return apiRequest(`/attendance-batches/${batchId}/confirm`, { method: "POST" }) + }, + unlock(batchId: number, reason: string): Promise { + return apiRequest(`/attendance-batches/${batchId}/unlock`, { method: "POST", body: { reason } }) + }, +} diff --git a/Frontend/erp-system/lib/api/employees.ts b/Frontend/erp-system/lib/api/employees.ts new file mode 100644 index 0000000..b5cb194 --- /dev/null +++ b/Frontend/erp-system/lib/api/employees.ts @@ -0,0 +1,128 @@ +// Employee (staff) endpoints, incl. the Employee<->User cross-link, bank details, +// documents, salary structure, loans, and leave balances (docs/13-BACKEND-HRM-API.md §3). +import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +import { ApiResult, PagedResponse } from "@/types/common" +import { + CreateEmployeeRequest, + EmployeeBankDetail, + EmployeeDetail, + EmployeeDocument, + EmployeeListItem, + EmployeeLoan, + EmployeeMatch, + EmployeeSalaryStructure, + EmployeeStatus, + LeaveBalance, + UpdateEmployeeRequest, + UserMatch, +} from "@/types/hrm" + +export interface ListEmployeesParams { + page?: number + pageSize?: number + q?: string + status?: EmployeeStatus + departmentId?: number + designationId?: number + branchId?: number +} + +export interface CreateSalaryStructureRequest { + effectiveFrom: string + basicSalary: number + lines: { salaryComponentId: number; amount: number }[] +} + +export interface CreateEmployeeLoanRequest { + loanKind: "Loan" | "Advance" + principalAmount: number + interestRate: number + installmentAmount: number + numberOfInstallments: number + startYear: number + startMonth: number +} + +export const employeesApi = { + list(params: ListEmployeesParams = {}): Promise> { + return apiRequest>(`/employees${buildQuery(params)}`) + }, + get(employeeId: number): Promise> { + return apiRequestWithETag(`/employees/${employeeId}`) + }, + create(request: CreateEmployeeRequest): Promise> { + return apiRequestWithETag("/employees", { method: "POST", body: request }) + }, + update(employeeId: number, request: UpdateEmployeeRequest, ifMatch: string): Promise> { + return apiRequestWithETag(`/employees/${employeeId}`, { method: "PUT", body: request, ifMatch }) + }, + updateStatus(employeeId: number, status: EmployeeStatus): Promise { + return apiRequest(`/employees/${employeeId}/status`, { method: "PATCH", body: { status } }) + }, + + /** Advisory: does a System User already exist with this email? */ + emailLookup(email: string): Promise<{ match: UserMatch | null }> { + return apiRequest<{ match: UserMatch | null }>(`/employees/email-lookup${buildQuery({ email })}`) + }, + linkUser(employeeId: number, userId: number): Promise { + return apiRequest(`/employees/${employeeId}/link-user`, { method: "POST", body: { userId } }) + }, + unlinkUser(employeeId: number): Promise { + return apiRequest(`/employees/${employeeId}/link-user`, { method: "DELETE" }) + }, + + listBankDetails(employeeId: number): Promise { + return apiRequest(`/employees/${employeeId}/bank-details`) + }, + replaceBankDetails(employeeId: number, items: EmployeeBankDetail[]): Promise { + return apiRequest(`/employees/${employeeId}/bank-details`, { method: "PUT", body: { items } }) + }, + + listDocuments(employeeId: number): Promise { + return apiRequest(`/employees/${employeeId}/documents`) + }, + async uploadDocument( + employeeId: number, + file: File, + meta: { hrDocumentTypeId: number; issueDate?: string | null; expiryDate?: string | null; notes?: string | null } + ): Promise { + const form = new FormData() + form.append("file", file) + form.append("HrDocumentTypeId", String(meta.hrDocumentTypeId)) + if (meta.issueDate) form.append("IssueDate", meta.issueDate) + if (meta.expiryDate) form.append("ExpiryDate", meta.expiryDate) + if (meta.notes) form.append("Notes", meta.notes) + return apiRequest(`/employees/${employeeId}/documents`, { method: "POST", body: form }) + }, + documentDownloadUrl(employeeId: number, documentId: number): string { + return `/api/v1/employees/${employeeId}/documents/${documentId}/download` + }, + setDocumentStatus(employeeId: number, documentId: number, status: "Active" | "Archived"): Promise { + return apiRequest(`/employees/${employeeId}/documents/${documentId}/status`, { method: "PATCH", body: { status } }) + }, + + salaryStructureHistory(employeeId: number): Promise { + return apiRequest(`/employees/${employeeId}/salary-structure`) + }, + createSalaryStructure(employeeId: number, request: CreateSalaryStructureRequest): Promise { + return apiRequest(`/employees/${employeeId}/salary-structure`, { method: "POST", body: request }) + }, + + listLoans(employeeId: number): Promise { + return apiRequest(`/employees/${employeeId}/loans`) + }, + createLoan(employeeId: number, request: CreateEmployeeLoanRequest): Promise { + return apiRequest(`/employees/${employeeId}/loans`, { method: "POST", body: request }) + }, + + listLeaveBalances(employeeId: number, year?: number): Promise { + return apiRequest(`/employees/${employeeId}/leave-balances${buildQuery({ year })}`) + }, +} + +// Re-exported for the Employee create form's cross-link chip (mirrors employeesApi.emailLookup in reverse). +export const employeeCrossLinkApi = { + findStaffByEmail(email: string): Promise<{ match: EmployeeMatch | null }> { + return apiRequest<{ match: EmployeeMatch | null }>(`/users/email-lookup${buildQuery({ email })}`) + }, +} diff --git a/Frontend/erp-system/lib/api/hr-reports.ts b/Frontend/erp-system/lib/api/hr-reports.ts new file mode 100644 index 0000000..41623c8 --- /dev/null +++ b/Frontend/erp-system/lib/api/hr-reports.ts @@ -0,0 +1,35 @@ +// Read-only HRM reports (docs/13-BACKEND-HRM-API.md §6, FR-HR-RPT). +import { apiRequest, buildQuery } from "@/lib/api-client" +import { + AttendanceSummaryRow, + DocumentExpiryReportRow, + LateArrivalReportRow, + LeaveBalanceReportRow, + OvertimeReportRow, + PayrollRegisterRow, + SalaryHistoryRow, +} from "@/types/hrm" + +export const hrReportsApi = { + attendanceSummary(periodYear: number, periodMonth: number, departmentId?: number): Promise { + return apiRequest(`/reports/hrm/attendance-summary${buildQuery({ periodYear, periodMonth, departmentId })}`) + }, + overtime(periodYear: number, periodMonth: number): Promise { + return apiRequest(`/reports/hrm/overtime${buildQuery({ periodYear, periodMonth })}`) + }, + lateArrivals(periodYear: number, periodMonth: number): Promise { + return apiRequest(`/reports/hrm/late-arrivals${buildQuery({ periodYear, periodMonth })}`) + }, + payrollRegister(payrollRunId: number): Promise { + return apiRequest(`/reports/hrm/payroll-register${buildQuery({ payrollRunId })}`) + }, + salaryHistory(employeeId: number): Promise { + return apiRequest(`/reports/hrm/salary-history${buildQuery({ employeeId })}`) + }, + leaveBalances(year: number): Promise { + return apiRequest(`/reports/hrm/leave-balances${buildQuery({ year })}`) + }, + documentExpiry(withinDays: number): Promise { + return apiRequest(`/reports/hrm/document-expiry${buildQuery({ withinDays })}`) + }, +} diff --git a/Frontend/erp-system/lib/api/hrm-master-factory.ts b/Frontend/erp-system/lib/api/hrm-master-factory.ts new file mode 100644 index 0000000..d868cde --- /dev/null +++ b/Frontend/erp-system/lib/api/hrm-master-factory.ts @@ -0,0 +1,32 @@ +// Shared CRUD shape for the ~8 near-identical HRM masters (Branch, Department, +// Designation, EmploymentType, WorkShift, HrDocumentType, LeaveType, SalaryComponent) — +// same ETag/status/list pattern as brandsApi, factored out once instead of copy-pasted 8x. +import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +import { ApiResult, EntityStatus, PagedResponse } from "@/types/common" + +export interface ListMasterParams { + page?: number + pageSize?: number + q?: string + status?: EntityStatus +} + +export function createMasterApi(resource: string) { + return { + list(params: ListMasterParams = {}): Promise> { + return apiRequest>(`/${resource}${buildQuery(params)}`) + }, + get(id: number): Promise> { + return apiRequestWithETag(`/${resource}/${id}`) + }, + create(request: TCreate): Promise> { + return apiRequestWithETag(`/${resource}`, { method: "POST", body: request }) + }, + update(id: number, request: TUpdate, ifMatch: string): Promise> { + return apiRequestWithETag(`/${resource}/${id}`, { method: "PUT", body: request, ifMatch }) + }, + updateStatus(id: number, status: EntityStatus): Promise { + return apiRequest(`/${resource}/${id}/status`, { method: "PATCH", body: { status } }) + }, + } +} diff --git a/Frontend/erp-system/lib/api/hrm-masters.ts b/Frontend/erp-system/lib/api/hrm-masters.ts new file mode 100644 index 0000000..009c79f --- /dev/null +++ b/Frontend/erp-system/lib/api/hrm-masters.ts @@ -0,0 +1,83 @@ +// HRM org/reference masters (docs/13-BACKEND-HRM-API.md §2, §5, §6). Each is the +// same list/get/create/update/status-toggle shape as brandsApi (see hrm-master-factory). +import { createMasterApi } from "@/lib/api/hrm-master-factory" +import { + Branch, + Department, + Designation, + EmploymentType, + HrDocumentType, + LeaveType, + SalaryComponent, + WorkShift, +} from "@/types/hrm" + +export interface CreateBranchRequest { code: string; name: string; address?: string | null } +export type UpdateBranchRequest = Omit +export const branchesHrmApi = createMasterApi("branches") + +export interface CreateDepartmentRequest { + code: string + name: string + parentDepartmentId?: number | null + headEmployeeId?: number | null + branchId?: number | null +} +export type UpdateDepartmentRequest = Omit +export const departmentsApi = createMasterApi("departments") + +export interface CreateDesignationRequest { code: string; name: string } +export type UpdateDesignationRequest = Omit +export const designationsApi = createMasterApi("designations") + +export interface CreateEmploymentTypeRequest { code: string; name: string } +export type UpdateEmploymentTypeRequest = Omit +export const employmentTypesApi = createMasterApi("employment-types") + +export interface CreateWorkShiftRequest { + code: string + name: string + startTime: string + endTime: string + isOvernight: boolean + graceMinutes: number + breakMinutes: number + standardWorkingMinutes: number + otMultiplier: number + workingDaysMask: number +} +export type UpdateWorkShiftRequest = Omit +export const workShiftsApi = createMasterApi("work-shifts") + +export interface CreateHrDocumentTypeRequest { + code: string + name: string + category: HrDocumentType["category"] + requiredAtOnboarding: boolean + expiryTracked: boolean +} +export type UpdateHrDocumentTypeRequest = Omit +export const hrDocumentTypesApi = createMasterApi("hr-document-types") + +export interface CreateLeaveTypeRequest { + code: string + name: string + isPaid: boolean + countsAsNoPay: boolean + accrualPerYear: number + carryForwardAllowed: boolean + maxCarryForwardDays?: number | null + requiresApproval: boolean +} +export type UpdateLeaveTypeRequest = Omit +export const leaveTypesApi = createMasterApi("leave-types") + +export interface CreateSalaryComponentRequest { + code: string + name: string + componentType: SalaryComponent["componentType"] + isTaxable: boolean + isEpfEtfApplicable: boolean +} +export type UpdateSalaryComponentRequest = Omit +export const salaryComponentsApi = createMasterApi("salary-components") diff --git a/Frontend/erp-system/lib/api/leave.ts b/Frontend/erp-system/lib/api/leave.ts new file mode 100644 index 0000000..240d460 --- /dev/null +++ b/Frontend/erp-system/lib/api/leave.ts @@ -0,0 +1,43 @@ +// Leave requests (docs/13-BACKEND-HRM-API.md §5). +import { apiRequest, buildQuery } from "@/lib/api-client" +import { PagedResponse } from "@/types/common" +import { LeaveRequest, LeaveRequestStatus } from "@/types/hrm" + +export interface ListLeaveRequestsParams { + page?: number + pageSize?: number + employeeId?: number + status?: LeaveRequestStatus +} + +export interface CreateLeaveRequestRequest { + employeeId: number + leaveTypeId: number + startDate: string + endDate: string + reason?: string | null +} + +export const leaveRequestsApi = { + list(params: ListLeaveRequestsParams = {}): Promise> { + return apiRequest>(`/leave-requests${buildQuery(params)}`) + }, + get(leaveRequestId: number): Promise { + return apiRequest(`/leave-requests/${leaveRequestId}`) + }, + create(request: CreateLeaveRequestRequest): Promise { + return apiRequest("/leave-requests", { method: "POST", body: request }) + }, + submit(leaveRequestId: number): Promise { + return apiRequest(`/leave-requests/${leaveRequestId}/submit`, { method: "POST" }) + }, + approve(leaveRequestId: number): Promise { + return apiRequest(`/leave-requests/${leaveRequestId}/approve`, { method: "POST" }) + }, + reject(leaveRequestId: number, reason: string): Promise { + return apiRequest(`/leave-requests/${leaveRequestId}/reject`, { method: "POST", body: { reason } }) + }, + cancel(leaveRequestId: number): Promise { + return apiRequest(`/leave-requests/${leaveRequestId}/cancel`, { method: "POST" }) + }, +} diff --git a/Frontend/erp-system/lib/api/payroll.ts b/Frontend/erp-system/lib/api/payroll.ts new file mode 100644 index 0000000..e12faa7 --- /dev/null +++ b/Frontend/erp-system/lib/api/payroll.ts @@ -0,0 +1,70 @@ +// Payroll runs, statutory settings, tax slabs, payslips (docs/13-BACKEND-HRM-API.md §6). +import { apiRequest, buildQuery } from "@/lib/api-client" +import { PagedResponse } from "@/types/common" +import { Payslip, PayrollLine, PayrollLineDetail, PayrollRun, PayrollRunStatus, PayrollStatutorySetting, TaxSlab } from "@/types/hrm" + +export interface ListPayrollRunsParams { + page?: number + pageSize?: number + periodYear?: number + periodMonth?: number + status?: PayrollRunStatus +} + +export interface GeneratePayrollRunRequest { + periodYear: number + periodMonth: number + branchId?: number | null +} + +export const payrollRunsApi = { + list(params: ListPayrollRunsParams = {}): Promise> { + return apiRequest>(`/payroll-runs${buildQuery(params)}`) + }, + get(payrollRunId: number): Promise { + return apiRequest(`/payroll-runs/${payrollRunId}`) + }, + listLines(payrollRunId: number): Promise { + return apiRequest(`/payroll-runs/${payrollRunId}/lines`) + }, + getLine(payrollRunId: number, lineId: number): Promise { + return apiRequest(`/payroll-runs/${payrollRunId}/lines/${lineId}`) + }, + generate(request: GeneratePayrollRunRequest): Promise { + return apiRequest("/payroll-runs", { method: "POST", body: request }) + }, + approve(payrollRunId: number): Promise { + return apiRequest(`/payroll-runs/${payrollRunId}/approve`, { method: "POST" }) + }, + lock(payrollRunId: number): Promise { + return apiRequest(`/payroll-runs/${payrollRunId}/lock`, { method: "POST" }) + }, + unlock(payrollRunId: number, reason: string): Promise { + return apiRequest(`/payroll-runs/${payrollRunId}/unlock`, { method: "POST", body: { reason } }) + }, + generatePayslips(payrollRunId: number): Promise { + return apiRequest(`/payroll-runs/${payrollRunId}/generate-payslips`, { method: "POST" }) + }, +} + +export const payrollStatutorySettingsApi = { + list(): Promise { + return apiRequest("/payroll-statutory-settings") + }, + create(request: Omit): Promise { + return apiRequest("/payroll-statutory-settings", { method: "POST", body: request }) + }, +} + +export const taxSlabsApi = { + list(): Promise { + return apiRequest("/tax-slabs") + }, + create(request: Omit): Promise { + return apiRequest("/tax-slabs", { method: "POST", body: request }) + }, +} + +export function payslipViewUrl(payslipId: number): string { + return `/api/v1/payslips/${payslipId}/view` +} diff --git a/Frontend/erp-system/lib/error-map.ts b/Frontend/erp-system/lib/error-map.ts index 3e2355b..ac8e1ec 100644 --- a/Frontend/erp-system/lib/error-map.ts +++ b/Frontend/erp-system/lib/error-map.ts @@ -28,6 +28,19 @@ const CODE_MESSAGES: Record = { CONCURRENCY_CONFLICT: "This record was changed by someone else. Reload and try again.", PRECONDITION_REQUIRED: "This record needs to be reloaded before it can be updated.", IDEMPOTENCY_REPLAY: "This request was already processed; showing the original result.", + EMPLOYEE_CODE_DUPLICATE: "An employee with that code already exists.", + EMPLOYEE_ALREADY_LINKED: "This staff record already has a linked system user.", + USER_ALREADY_LINKED: "This user account is already linked to a different staff record.", + DEPARTMENT_CYCLE_DETECTED: "Setting this parent would create a department cycle.", + DOCUMENT_TYPE_IN_USE: "This document type is referenced by existing documents and cannot be removed.", + FILE_TYPE_NOT_ALLOWED: "That file type isn't allowed. Use PDF, JPG, PNG, or DOCX.", + FILE_TOO_LARGE: "That file is too large.", + ATTENDANCE_BATCH_LOCKED: "This attendance batch is locked and cannot be edited.", + ATTENDANCE_DUPLICATE_UNRESOLVED: "Some records have unresolved errors or duplicates.", + ATTENDANCE_NOT_CONFIRMED: "Attendance for this period must be Confirmed before payroll can be generated.", + SALARY_STRUCTURE_OVERLAP: "The new effective date must be after the current salary structure's effective date.", + TAX_SLAB_GAP_INVALID: "This tax slab overlaps another slab for the same effective date.", + PAYROLL_PERIOD_LOCKED: "This payroll run is locked.", validation_error: "Please check the highlighted fields.", not_found: "The requested record was not found.", conflict: "This action conflicts with the record's current state.", diff --git a/Frontend/erp-system/package-lock.json b/Frontend/erp-system/package-lock.json index 300c9c7..4f3fa1e 100644 --- a/Frontend/erp-system/package-lock.json +++ b/Frontend/erp-system/package-lock.json @@ -18,6 +18,7 @@ "date-fns": "^4.4.0", "lucide-react": "^1.23.0", "next": "16.2.10", + "next-themes": "^0.4.6", "react": "19.2.4", "react-chartjs-2": "^5.3.1", "react-day-picker": "^10.0.1", @@ -81,7 +82,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -532,8 +532,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", @@ -726,7 +725,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -770,6 +768,28 @@ "integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==", "license": "BSD-3-Clause" }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -2309,7 +2329,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2375,7 +2394,6 @@ "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", @@ -2992,7 +3010,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3448,7 +3465,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", @@ -3586,7 +3602,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -3959,7 +3974,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -4488,7 +4502,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4674,7 +4687,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4964,7 +4976,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5594,7 +5605,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -7109,6 +7119,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -7934,7 +7954,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7980,7 +7999,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7993,7 +8011,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz", "integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -9125,7 +9142,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9346,7 +9362,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9742,7 +9757,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/Frontend/erp-system/types/hrm.ts b/Frontend/erp-system/types/hrm.ts new file mode 100644 index 0000000..8e9c9c4 --- /dev/null +++ b/Frontend/erp-system/types/hrm.ts @@ -0,0 +1,536 @@ +// HRM DTOs mirroring ERPCore's Dtos/Hrm/*.cs (docs/13-BACKEND-HRM-API.md). +import { EntityStatus } from "@/types/common" + +// --- Org masters --- + +export interface Branch { + branchId: number + code: string + name: string + address: string | null + status: EntityStatus + createdAt: string + updatedAt: string | null +} + +export interface Department { + departmentId: number + code: string + name: string + parentDepartmentId: number | null + headEmployeeId: number | null + branchId: number | null + status: EntityStatus + createdAt: string + updatedAt: string | null +} + +export interface Designation { + designationId: number + code: string + name: string + status: EntityStatus + createdAt: string + updatedAt: string | null +} + +export interface EmploymentType { + employmentTypeId: number + code: string + name: string + status: EntityStatus + createdAt: string + updatedAt: string | null +} + +export interface WorkShift { + workShiftId: number + code: string + name: string + startTime: string // "HH:mm:ss" + endTime: string + isOvernight: boolean + graceMinutes: number + breakMinutes: number + standardWorkingMinutes: number + otMultiplier: number + workingDaysMask: number + status: EntityStatus + createdAt: string + updatedAt: string | null +} + +export type HrDocumentCategory = "Identity" | "Educational" | "Contract" | "Certification" | "Statutory" | "Other" + +export interface HrDocumentType { + hrDocumentTypeId: number + code: string + name: string + category: HrDocumentCategory + requiredAtOnboarding: boolean + expiryTracked: boolean + status: EntityStatus + createdAt: string + updatedAt: string | null +} + +// --- Employee --- + +export type EmployeeStatus = "Active" | "Suspended" | "Resigned" | "Terminated" | "Retired" +export type Gender = "Male" | "Female" | "Other" + +export interface EmployeeListItem { + employeeId: number + employeeCode: string + fullName: string + email: string | null + departmentId: number + departmentName: string | null + designationId: number + designationName: string | null + employmentTypeId: number + employmentTypeName: string | null + branchId: number | null + branchName: string | null + status: EmployeeStatus + hasUserLink: boolean + hireDate: string +} + +export interface EmployeeDetail { + employeeId: number + employeeCode: string + fullName: string + nic: string | null + dateOfBirth: string | null + gender: Gender | null + nationality: string | null + profilePhotoPath: string | null + email: string | null + personalMobile: string | null + addressLine1: string | null + addressLine2: string | null + city: string | null + postalCode: string | null + country: string | null + emergencyContactName: string | null + emergencyContactRelationship: string | null + emergencyContactPhone: string | null + hireDate: string + confirmationDate: string | null + lastWorkingDate: string | null + departmentId: number + designationId: number + employmentTypeId: number + branchId: number | null + workShiftId: number + reportingManagerId: number | null + epfNumber: string | null + etfNumber: string | null + taxIdentificationNumber: string | null + userId: number | null + status: EmployeeStatus + createdAt: string + updatedAt: string | null +} + +export interface CreateEmployeeRequest { + employeeCode: string + fullName: string + nic?: string | null + dateOfBirth?: string | null + gender?: Gender | null + nationality?: string | null + email?: string | null + personalMobile?: string | null + addressLine1?: string | null + addressLine2?: string | null + city?: string | null + postalCode?: string | null + country?: string | null + emergencyContactName?: string | null + emergencyContactRelationship?: string | null + emergencyContactPhone?: string | null + hireDate: string + departmentId: number + designationId: number + employmentTypeId: number + branchId?: number | null + workShiftId: number + reportingManagerId?: number | null + epfNumber?: string | null + etfNumber?: string | null + taxIdentificationNumber?: string | null + linkUserId?: number | null +} + +export type UpdateEmployeeRequest = Omit & { + confirmationDate?: string | null + lastWorkingDate?: string | null +} + +export interface EmployeeBankDetail { + employeeBankDetailId: number | null + bankName: string + branchName: string + accountNumber: string + accountHolderName: string + swiftCode: string | null + isPrimary: boolean + status: EntityStatus +} + +export interface EmployeeMatch { + employeeId: number + employeeCode: string + fullName: string + email: string +} + +export interface UserMatch { + userId: number + username: string + displayName: string + email: string +} + +// --- Documents --- + +export type EmployeeDocumentStatus = "Active" | "Archived" + +export interface EmployeeDocument { + employeeDocumentId: number + employeeId: number + hrDocumentTypeId: number + hrDocumentTypeName: string | null + originalFileName: string + contentType: string + sizeBytes: number + issueDate: string | null + expiryDate: string | null + notes: string | null + uploadedBy: number + uploadedAt: string + status: EmployeeDocumentStatus +} + +// --- Attendance --- + +export type AttendanceSourceType = "Excel" | "Csv" | "Manual" | "BiometricDevice" +export type AttendanceBatchStatus = "Draft" | "Validated" | "Confirmed" | "UsedInPayroll" +export type AttendanceStatusValue = "Present" | "Absent" | "HalfDay" | "OnLeave" | "Holiday" | "WeekOff" +export type RowValidationStatus = "Valid" | "DuplicateWithinBatch" | "DuplicateConfirmed" | "EmployeeNotFound" | "InvalidDateTime" | "Error" + +export interface AttendanceUploadBatch { + attendanceUploadBatchId: number + docNo: string + periodStart: string + periodEnd: string + sourceType: AttendanceSourceType + originalFileName: string | null + uploadedBy: number + uploadedAt: string + status: AttendanceBatchStatus + confirmedBy: number | null + confirmedAt: string | null + rowCountTotal: number + rowCountDuplicate: number + rowCountError: number +} + +export interface AttendanceRecord { + attendanceRecordId: number + attendanceUploadBatchId: number | null + employeeId: number + employeeCode: string | null + employeeName: string | null + attendanceDate: string + checkIn: string | null + checkOut: string | null + workingMinutes: number + lateMinutes: number + earlyLeaveMinutes: number + overtimeMinutes: number + attendanceStatus: AttendanceStatusValue + rowValidationStatus: RowValidationStatus + duplicateOfAttendanceRecordId: number | null + notes: string | null +} + +// --- Leave --- + +export interface LeaveType { + leaveTypeId: number + code: string + name: string + isPaid: boolean + countsAsNoPay: boolean + accrualPerYear: number + carryForwardAllowed: boolean + maxCarryForwardDays: number | null + requiresApproval: boolean + status: EntityStatus + createdAt: string + updatedAt: string | null +} + +export type LeaveRequestStatus = "Draft" | "Submitted" | "Approved" | "Rejected" | "Cancelled" + +export interface LeaveRequest { + leaveRequestId: number + docNo: string + employeeId: number + employeeName: string | null + leaveTypeId: number + leaveTypeName: string | null + startDate: string + endDate: string + daysCount: number + reason: string | null + status: LeaveRequestStatus + approvedBy: number | null + approvedAt: string | null + rejectionReason: string | null + createdAt: string +} + +export interface LeaveBalance { + leaveBalanceId: number + employeeId: number + leaveTypeId: number + leaveTypeName: string | null + year: number + entitledDays: number + takenDays: number + carriedForwardDays: number + adjustmentDays: number + remainingDays: number +} + +// --- Payroll --- + +export type SalaryComponentType = "Earning" | "Deduction" + +export interface SalaryComponent { + salaryComponentId: number + code: string + name: string + componentType: SalaryComponentType + isTaxable: boolean + isEpfEtfApplicable: boolean + status: EntityStatus + createdAt: string + updatedAt: string | null +} + +export interface EmployeeSalaryStructureLine { + salaryComponentId: number + salaryComponentName: string | null + amount: number +} + +export type SalaryStructureStatus = "Active" | "Superseded" + +export interface EmployeeSalaryStructure { + employeeSalaryStructureId: number + employeeId: number + effectiveFrom: string + effectiveTo: string | null + basicSalary: number + currency: string + status: SalaryStructureStatus + lines: EmployeeSalaryStructureLine[] + createdAt: string +} + +export type LoanKind = "Loan" | "Advance" +export type LoanStatus = "Active" | "Closed" | "Cancelled" +export type LoanInstallmentStatus = "Pending" | "Deducted" | "Skipped" + +export interface LoanInstallment { + loanInstallmentId: number + installmentNumber: number + dueYear: number + dueMonth: number + scheduledAmount: number + paidAmount: number | null + payrollRunId: number | null + status: LoanInstallmentStatus +} + +export interface EmployeeLoan { + employeeLoanId: number + docNo: string + employeeId: number + loanKind: LoanKind + principalAmount: number + interestRate: number + installmentAmount: number + numberOfInstallments: number + startYear: number + startMonth: number + outstandingBalance: number + status: LoanStatus + installments: LoanInstallment[] + createdAt: string +} + +export interface PayrollStatutorySetting { + payrollStatutorySettingId: number + epfEmployeeRate: number + epfEmployerRate: number + etfEmployerRate: number + otMultiplierDefault: number + effectiveFrom: string + effectiveTo: string | null +} + +export interface TaxSlab { + taxSlabId: number + effectiveFrom: string + effectiveTo: string | null + lowerBound: number + upperBound: number | null + rate: number +} + +export type PayrollRunStatus = "Draft" | "Approved" | "Locked" +export type PayrollLineComponentCategory = "Earning" | "Deduction" | "EmployerContribution" + +export interface PayrollLineComponent { + componentCategory: PayrollLineComponentCategory + salaryComponentId: number | null + label: string + amount: number + sortOrder: number +} + +export interface PayrollLine { + payrollLineId: number + payrollRunId: number + employeeId: number + employeeCode: string | null + employeeName: string | null + basicSalary: number + totalAllowances: number + overtimeAmount: number + grossSalary: number + lateDeductionAmount: number + noPayAmount: number + loanDeductionAmount: number + epfEmployeeAmount: number + epfEmployerAmount: number + etfEmployerAmount: number + taxAmount: number + otherDeductionsAmount: number + netSalary: number + workingDays: number + presentDays: number + absentDays: number + leaveDays: number + otMinutesTotal: number + lateMinutesTotal: number +} + +export interface PayrollLineDetail { + line: PayrollLine + components: PayrollLineComponent[] +} + +export interface PayrollRun { + payrollRunId: number + docNo: string + periodYear: number + periodMonth: number + branchId: number | null + status: PayrollRunStatus + generatedBy: number + generatedAt: string + approvedBy: number | null + approvedAt: string | null + lockedBy: number | null + lockedAt: string | null + unlockedBy: number | null + unlockedAt: string | null + unlockReason: string | null + totalGross: number + totalNet: number + employeeCount: number +} + +export interface Payslip { + payslipId: number + payrollLineId: number + generatedAt: string + releasedAt: string | null + releasedBy: number | null +} + +// --- Reports --- + +export interface AttendanceSummaryRow { + employeeId: number + employeeCode: string + employeeName: string + departmentName: string | null + presentDays: number + absentDays: number + leaveDays: number + halfDays: number + otMinutesTotal: number + lateMinutesTotal: number +} + +export interface OvertimeReportRow { + employeeId: number + employeeCode: string + employeeName: string + attendanceDate: string + overtimeMinutes: number +} + +export interface LateArrivalReportRow { + employeeId: number + employeeCode: string + employeeName: string + attendanceDate: string + lateMinutes: number +} + +export interface PayrollRegisterRow { + payrollLineId: number + employeeId: number + employeeCode: string + employeeName: string + grossSalary: number + totalDeductions: number + netSalary: number +} + +export interface SalaryHistoryRow { + employeeSalaryStructureId: number + effectiveFrom: string + effectiveTo: string | null + basicSalary: number + status: string +} + +export interface LeaveBalanceReportRow { + employeeId: number + employeeCode: string + employeeName: string + leaveTypeName: string + entitledDays: number + takenDays: number + remainingDays: number +} + +export interface DocumentExpiryReportRow { + employeeDocumentId: number + employeeId: number + employeeCode: string + employeeName: string + documentTypeName: string + expiryDate: string + daysUntilExpiry: number +} diff --git a/Frontend/erp-system/types/users.ts b/Frontend/erp-system/types/users.ts index 8ac5285..6df7022 100644 --- a/Frontend/erp-system/types/users.ts +++ b/Frontend/erp-system/types/users.ts @@ -5,6 +5,7 @@ export interface ManagedUser { userId: number username: string displayName: string + email: string | null status: EntityStatus roleId: number | null roleCode: string | null @@ -21,6 +22,8 @@ export interface CreateUserRequest { mobileNumber?: string | null /** Left empty to auto-generate — AuthHex emails it to `email`. */ password?: string | null + /** Explicit, human-confirmed link to an existing unlinked Employee found via email-lookup. */ + linkEmployeeId?: number | null } export interface UpdateUserRoleRequest { diff --git a/docs/00-CORE.md b/docs/00-CORE.md index 9277b56..e50cc97 100644 --- a/docs/00-CORE.md +++ b/docs/00-CORE.md @@ -20,6 +20,8 @@ A modular ERP built in phases. **Phase 1** delivers the **Inventory & Supply Cha Full requirements live in the backend spec (see §7). This file does **not** duplicate them. +**Phase 2 (HRM)** is now underway alongside Phase 1 — see §7 routing to `12-BACKEND-HRM.md` / `13-BACKEND-HRM-API.md` / `21-FRONTEND-HRM.md`. + --- ## 2. Repository structure @@ -334,18 +336,21 @@ All frontend work is governed by `20-FRONTEND.md`. | If you are working on… | Go to | |---|---| -| Requirements, business rules, entities, ER model, data types, architecture detail | **`10-BACKEND-PHASE1.md`** | -| API endpoints, request/response shapes, error catalog, enums | **`11-BACKEND-PHASE1.md`** | -| Frontend user-flows, screen flow, architecture rules, validation posture | **`20-FRONTEND.md`** | +| Requirements, business rules, entities, ER model, data types, architecture detail (Phase 1: Inventory & Supply Chain) | **`10-BACKEND-PHASE1.md`** | +| API endpoints, request/response shapes, error catalog, enums (Phase 1) | **`11-BACKEND-PHASE1.md`** | +| HRM requirements, business rules, entities, ER model (Phase 2) | **`12-BACKEND-HRM.md`** | +| HRM API endpoints, request/response shapes, error catalog (Phase 2) | **`13-BACKEND-HRM-API.md`** | +| Frontend user-flows, screen flow, architecture rules, validation posture (Phase 1) | **`20-FRONTEND.md`** | +| HRM frontend user-flows (Phase 2) | **`21-FRONTEND-HRM.md`** | | Security risks per feature, accepted-risk register, pre-ship checklist | **`02-SECURITY.md`** | | Understanding the doc system, reading order, tracking conventions | **`01-DOC-GUIDE.md`** | | Recording backend changes made | **`Backend/PROGRESS.md`** | | Recording frontend changes made | **`Frontend/PROGRESS.md`** | Quick resolver: -- *"Where is the model / an entity defined?"* → `10-BACKEND-PHASE1.md` (schema is authoritative there). -- *"What does this endpoint accept/return?"* → `11-BACKEND-PHASE1.md`. -- *"How should the UI flow / what do I validate where?"* → `20-FRONTEND.md`. +- *"Where is the model / an entity defined?"* → `10-BACKEND-PHASE1.md` (Phase 1) / `12-BACKEND-HRM.md` (HRM) — schema is authoritative there. +- *"What does this endpoint accept/return?"* → `11-BACKEND-PHASE1.md` (Phase 1) / `13-BACKEND-HRM-API.md` (HRM). +- *"How should the UI flow / what do I validate where?"* → `20-FRONTEND.md` (Phase 1) / `21-FRONTEND-HRM.md` (HRM). - *"What security risks / checks apply to this feature?"* → `02-SECURITY.md`. --- diff --git a/docs/01-DOC-GUIDE.md b/docs/01-DOC-GUIDE.md index 005d802..c61164c 100644 --- a/docs/01-DOC-GUIDE.md +++ b/docs/01-DOC-GUIDE.md @@ -34,9 +34,12 @@ | `00-CORE.md` | High | **Hub.** Structure, tech stack, runnable backend init, routing. | First — every task. | Claude + humans | | `01-DOC-GUIDE.md` | High | **This file.** Doc map, reading order, tracking conventions. | To understand the doc system. | Claude + humans | | `02-SECURITY.md` | High (cross-cutting) | **Security review aid:** accepted-risks register + per-feature checklist. | Before ticking any feature in a PROGRESS.md; during security review. | Claude + humans | -| `10-BACKEND-PHASE1.md` | High | Backend spec: **full SRS**, **ER model / 38-entity list**, tech stack detail, layer/architecture rules. Schema is **authoritative** here. | Any backend model / business-rule / requirement work. | Claude + humans | -| `11-BACKEND-PHASE1.md` | High | Backend **API reference**: every endpoint with complete request/response, error catalog, enums. | Any API contract / controller / client work. | Claude + humans | -| `20-FRONTEND.md` | High | Frontend **user-flows**, architecture rules to follow, **validation posture**. | Any frontend work. | Claude + humans | +| `10-BACKEND-PHASE1.md` | High | Backend spec: **full SRS**, **ER model / 38-entity list**, tech stack detail, layer/architecture rules. Schema is **authoritative** here. | Any backend model / business-rule / requirement work (Phase 1). | Claude + humans | +| `11-BACKEND-PHASE1.md` | High | Backend **API reference**: every endpoint with complete request/response, error catalog, enums. | Any API contract / controller / client work (Phase 1). | Claude + humans | +| `12-BACKEND-HRM.md` | High | HRM backend spec: SRS, ER model, HRM-specific architecture notes. Schema is **authoritative** here. | Any HRM model / business-rule / requirement work (Phase 2). | Claude + humans | +| `13-BACKEND-HRM-API.md` | High | HRM **API reference**: every endpoint, error catalog additions, enums. | Any HRM API contract / controller work. | Claude + humans | +| `20-FRONTEND.md` | High | Frontend **user-flows**, architecture rules to follow, **validation posture**. | Any frontend work (Phase 1). | Claude + humans | +| `21-FRONTEND-HRM.md` | High | HRM frontend **user-flows**, screens, validation specifics. | Any HRM frontend work. | Claude + humans | | `Backend/PROGRESS.md` | Low | Backend **change checklist**, git-shared. | After making backend changes. | **Claude** | | `Frontend/PROGRESS.md` | Low | Frontend **change checklist**, git-shared. | After making frontend changes. | **Claude** | @@ -187,9 +190,9 @@ Spec: docs/20-FRONTEND.md (flows + rules) · docs/11-BACKEND-PHASE1.md (API cont ## 7. Adding future docs -When later phases arrive (Sales & CRM, Manufacturing, QC/QA, Accounting, HRM), follow the same scheme: -- Backend spec/API for a phase → new `1x-` files (e.g. `30-BACKEND-PHASE2.md`), linked from the hub. -- Frontend additions → extend `20-FRONTEND.md` or add `2x-` files. +When later phases arrive (Sales & CRM, Manufacturing, QC/QA, Accounting), follow the same scheme HRM (Phase 2) established: +- Backend spec/API for a phase → new `1x-` files continuing the backend decade (e.g. HRM used `12-BACKEND-HRM.md` + `13-BACKEND-HRM-API.md`, mirroring the `10`/`11` SRS+ER / API-reference split), linked from the hub. +- Frontend additions → extend `20-FRONTEND.md` or add `2x-` files (e.g. HRM used `21-FRONTEND-HRM.md`). - Always register the new doc in `00-CORE.md` routing (§7) and in this index (§2). --- diff --git a/docs/02-SECURITY.md b/docs/02-SECURITY.md index 05cc993..6271795 100644 --- a/docs/02-SECURITY.md +++ b/docs/02-SECURITY.md @@ -20,6 +20,8 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin | **AR-06** | **Localhost dev secrets** in `appsettings.Development.json`. | Local-dev convenience, current phase. | `.gitignore` + localhost only. | Before any shared/staging/prod → User Secrets / env vars; rotate. | | **AR-07** | **`getUserDetails` / `LogoutUser` callable without a bearer token** — `GET /api/v1/auth/users/{userId}` and `POST /api/v1/auth/logout` resolve the target user from the URL/payload, not the caller's session, so any anonymous caller can fetch a profile or log out an arbitrary user's sessions by GUID. | Carried over verbatim from AuthHex's own dispatcher contract (API_REFERENCE.md §3) — ERPCore's `AuthController` proxies it as-is rather than silently tightening a contract it doesn't own. | GUIDs are not enumerable; every call is written to `AuthEventLogs` upstream in AuthHex. | Revisit once AuthHex exposes a token-scoped variant, or add ERPCore-side rate limiting / auth requirement ahead of AuthHex. | | **AR-08** | **No rate limiting on `AuthController`'s anonymous endpoints** (login, register, refresh, recovery, OTP send/verify) — brute-force and account-enumeration exposure. | Not built in this pass (docs/11 §2.0, added 2026-07-16); AuthHex may rate-limit server-side but ERPCore does not add its own layer yet. | AuthHex's own lockout/backoff (per docs/10 NFR-03), immutable audit trail. | Add ASP.NET Core rate limiting middleware to `AuthController` before any non-local deployment. | +| **AR-09** | **HRM inherits AR-01 for salary/PII data** — any door-admitted authenticated user can currently view/download any employee's salary figures, payslip, or uploaded documents (NIC scans, contracts). This is a **explicit, flagged decision, not a silent inheritance**: salary/PII is categorically more sensitive than Phase-1 inventory data, and this was called out to the business owner before HRM build started (see `12-BACKEND-HRM.md` A.1). | RBAC still deferred repo-wide; a coarse HR-role door-gate was not made a blocking prerequisite for HRM go-live. | Immutable audit trail (as AR-01); sidebar-visibility hiding of Employees/Attendance/Payroll sections for non-HR roles via the existing `NavItem`/`RolePermission` mechanism (UI-level only, not a server-enforced gate). | **HRM should be the forcing function that enables per-endpoint RBAC ahead of the rest of the system** (see Part D) — salary-data exposure is a materially worse blast radius than inventory data. | +| **AR-10** | **`AuditLogsController` exposes salary figures** — a `PayrollLine`/`EmployeeSalaryStructure` mutation's `ChangeSet` contains salary amounts; `AuditLogsController` is not RBAC-gated, so any authenticated user can read another employee's salary history via `GET /audit-logs?entityType=PayrollLine&entityId=X`. | Consequence of AR-01/AR-09, specific enough to name on its own rather than leaving implicit. | None beyond authentication today. | Closed with RBAC (Part D), or an interim HRM-specific audit-log access filter. | --- @@ -116,11 +118,23 @@ Auto-post + no approval + direct write-off = the primary theft/fraud surface. Re - [ ] System-qty snapshot immutable once the count is opened - [ ] Large variances flagged for review +### C.8 HRM (Employee / Documents / Attendance / Leave / Payroll) — **salary/PII data, see AR-09/AR-10** +- [ ] Create/update DTOs exclude server-controlled fields (`status`, ids, `createdBy`, timestamps, computed payroll amounts) +- [ ] Employee is **never hard-deleted** (deactivate via `EmployeeStatus` only), matching FR-MD-08's deactivate-not-delete pattern +- [ ] `Employee.UserId` uniqueness (one User per Employee) enforced at the DB level (filtered unique index), not just service-level +- [ ] File uploads (`EmployeeDocument`, attendance spreadsheets): extension allowlist + content-type cross-check + size cap enforced **server-side** (client checks are UX only); magic-byte sniffing / antivirus scanning explicitly **deferred**, not silently skipped — treat as an extension of AR-08's "not built in this pass" posture +- [ ] `EmployeeDocument` download is never served via a static/guessable URL — authenticated controller action streaming through `IFileStorageService` only +- [ ] Attendance batch lock (`Confirmed`/`UsedInPayroll`) genuinely blocks record edits server-side (`ATTENDANCE_BATCH_LOCKED`), not just hidden in the UI +- [ ] Payroll figures (Gross/Net/Tax/EPF/ETF) are computed **server-side only**; the client never supplies or overrides them +- [ ] **Payroll Unlock is the highest-risk action in this module** (parallel to C.5's framing of Adjustments) — mandatory reason, heavy audit; treat as the first HRM candidate for real per-endpoint RBAC +- [ ] Untrusted spreadsheet parsing (`ClosedXML`/`CsvHelper`): packages pinned to current versions, no macro/external-entity execution path enabled +- [ ] Review note: **AR-01/AR-09/AR-10** apply to every HRM endpoint until RBAC lands + --- ## Part D — Post-Phase-1 controls to enable (in order) 1. **Adjustment approval** (config flag already reserved) — closes the top fraud surface (AR-02, C.5). -2. **RBAC enforcement** (role→permission) — closes AR-01, AR-03, AR-04. +2. **RBAC enforcement** (role→permission) — closes AR-01, AR-03, AR-04, and **AR-09/AR-10 (HRM salary/PII)**. Given HRM's materially worse blast radius, consider bringing this forward ahead of item 3 once HRM ships (see AR-09's revisit trigger). 3. **PO approval** (value thresholds) — closes remaining AR-02. 4. **Monitoring reports** — stuck-transfer aging (AR-05) and large-variance/write-off review. diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index 8fd58c0..081b938 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -220,7 +220,7 @@ UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-f | Sales & CRM | Reservation/allocation status distinguishing on-hand vs available (FR-STK-11). | | Manufacturing | Generic goods-issue/consumption movement type BOM will consume through (extends FR-STK-03). | | QC / QA | GRN inspection/quarantine hold (FR-GRN-05); hold blocks issue (FR-WH-07). | -| HRM | User identity foundation (FR-X-01) reusable for employee-linked permissions. | +| HRM | User identity foundation (FR-X-01) reusable for employee-linked permissions. **Now underway — see `12-BACKEND-HRM.md`.** | | RBAC & Approvals | Config flags + retained `PendingApproval`/role structures enable PO & adjustment approvals with no schema change. | ## B.8 Appendices diff --git a/docs/12-BACKEND-HRM.md b/docs/12-BACKEND-HRM.md new file mode 100644 index 0000000..b5c0c9a --- /dev/null +++ b/docs/12-BACKEND-HRM.md @@ -0,0 +1,198 @@ +# 12 · BACKEND — Phase 2 Spec (HRM) + +> Companion to `10-BACKEND-PHASE1.md`. Same rules apply unless noted otherwise here — this file adds HRM-specific requirements, decisions, and the ER model; it does not repeat Part A's layering rules verbatim (see `00-CORE.md` §4 and `10-BACKEND-PHASE1.md` Part A, which remain in force for every module). + +--- + +# Part A — HRM-specific architecture notes + +## A.1 What's new vs. Phase 1 + +- **New cross-cutting fix**: the local shadow `User` entity (`Domain/Entities/User.cs`) gains a nullable `Email` column + case-insensitive filtered unique index. This is additive; no existing Phase-1 behavior changes. +- **New infra layer**: `Infra/Storage/IFileStorageService` (+ `LocalFileStorageService`) — the first file-upload/attachment mechanism in the codebase. No other module currently has one; HRM introduces the abstraction, other modules may adopt it later. +- **New service namespace**: `Services/Hrm/*` — mirrors `Services/Stock/*` (domain calculation services distinct from CRUD services): `AttendanceComputationService` (analog of `FifoCostingService`), `PayrollCalculationService`, `EmployeeUserLinkService`. +- **New packages**: `ClosedXML` (.xlsx read/write), `CsvHelper` (.csv read/write) — both added for Attendance upload/template generation only. +- **Decisions carried into this phase** (confirmed with the business owner, not re-litigated per module): + | Decision | Value | + |---|---| + | Document storage | Local disk, behind `IFileStorageService`; cloud swap is a future DI change, not a schema change | + | Leave Management | In scope now (lightweight) — feeds Attendance's OnLeave status and Payroll's No-Pay calc | + | Payslip output | Server-rendered HTML/print view; PDF export explicitly deferred | + | Statutory rules | Sri Lanka (EPF 8%/12%, ETF 3% employer-only); tax via a configurable `TaxSlab` table, not hardcoded | + | RBAC | Still deferred repo-wide (per Phase 1 AR-01), but flagged in `02-SECURITY.md §C.8` as a decision to make explicitly for HRM, not silently inherited | + +## A.2 Employee.EmployeeCode numbering (deviation from `NumberSequence`) + +Unlike `PurchaseOrder`/`Grn`/other Phase-1 documents, `Employee.EmployeeCode` is **user-entered**, validated for uniqueness server-side (`EMPLOYEE_CODE_DUPLICATE`), not generated via `NumberSequenceService`. Rationale: `NumberSequence` is year-scoped (`{DocType}-{Year}-{00000}`), the wrong shape for an identifier that must never look "reset" across years; HR departments also typically already have a legacy numbering scheme to preserve at go-live. All other new HRM transactional documents (`AttendanceUploadBatch`, `LeaveRequest`, `EmployeeLoan`, `PayrollRun`) *do* use `NumberSequenceService.NextAsync(docType)` as-is (`ATT-`, `LV-`, `LOAN-`, `PAY-`). + +## A.3 Snapshot-at-ingestion rule (attendance integrity) + +`AttendanceRecord.WorkShiftId` is captured from `Employee.WorkShiftId` **at the moment a record is ingested**, not read live from `Employee` at report time. If an employee's shift assignment changes later, historical Late/OT/Working-Hours figures for already-recorded dates must not silently change. Any service reading `AttendanceRecord` for computation always joins to the record's own `WorkShiftId`, never the employee's current one. + +## A.4 Lock-boundary rule (payroll integrity) + +`LoanInstallment.PayrollRunId` is stamped, and `Loan.OutstandingBalance` decremented, only when a `PayrollRun` transitions to `Locked` — not at Generate/Draft and not at Approve. This is deliberate: a Draft payroll run may be regenerated or discarded; consuming a loan installment before the run is truly final would make regeneration lossy. The same boundary applies to `AttendanceUploadBatch.Status` flipping `Confirmed → UsedInPayroll`. + +--- + +# Part B — Software Requirements Specification + +## B.1 Scope + +Phase 2 (HRM) covers: Employee/Staff Management (incl. the Employee↔User login cross-link and staff document attachments), Attendance (Excel/CSV upload → validate → confirm), a lightweight Leave module (feeds Attendance/Payroll), and Payroll (calculate → review → approve → lock → payslips), plus read-only Reports over all of the above. Out of scope for this phase (see §B.7): PDF payslip generation, employee self-service portal, notification/email infrastructure, biometric device integration (schema seam reserved only), tiered late/OT policy, multi-emergency-contact and structured education-history child tables, exit/clearance workflow. + +## B.2 User classes + +- **HR Administrator** — full CRUD on Employees, masters, Attendance batches, Payroll runs; the only role that can Unlock a payroll run or supersede a confirmed attendance duplicate. +- **HR Staff / Payroll Clerk** — day-to-day upload/validate/confirm attendance, generate payroll drafts, upload staff documents. +- **Approver** (Department Head / Finance) — approves Leave Requests, approves/locks Payroll runs. +- **System User (non-HR)** — no HRM access; sidebar sections hidden via existing `NavItem`/`RolePermission` mechanism (see `02-SECURITY.md §C.8`). + +Per-endpoint RBAC enforcement is still deferred (mirrors Phase 1 `AR-01`); the role split above is currently a UI-visibility convention only, not a server-enforced one, except where explicitly noted (Unlock actions). + +## B.3 Functional Requirements + +### B.3.1 Employee & Org Masters (FR-HR-MD) +- FR-HR-MD-01 Maintain Branch, Department (unlimited self-nesting, cycle-guarded), Designation, EmploymentType, WorkShift masters — standard deactivate-not-delete CRUD. +- FR-HR-MD-02 Maintain Employee records with the full field set in Part C.2; `EmployeeCode` unique, user-entered; `Status` lifecycle Active→Suspended/Resigned/Terminated/Retired, never hard-deleted. +- FR-HR-MD-03 Maintain one or more `EmployeeBankDetail` rows per employee, exactly one marked `IsPrimary`. +- FR-HR-MD-04 Bidirectional email-based cross-check between Employee and User (see A.5/§B.3.2) — advisory only, human-confirmed link. + +### B.3.2 Employee ↔ User cross-link (FR-HR-LINK) +- FR-HR-LINK-01 `GET /employees/email-lookup?email=` and `GET /users/email-lookup?email=` return the matching record (or null) for the *other* side, given an email. +- FR-HR-LINK-02 `Employee.UserId` is nullable; when set, unique (one User backs at most one Employee), enforced by a filtered unique index plus a friendly `409 EMPLOYEE_ALREADY_LINKED`/`USER_ALREADY_LINKED` service check. +- FR-HR-LINK-03 Linking is only ever explicit (via `linkEmployeeId`/`linkUserId` on create, or `POST/DELETE /employees/{id}/link-user`) — never automatic, even on an exact email match. + +### B.3.3 Staff Documents (FR-HR-DOC) +- FR-HR-DOC-01 Maintain `HrDocumentType` catalog (deactivate-not-delete; `409 DOCUMENT_TYPE_IN_USE` if referenced). +- FR-HR-DOC-02 Upload an `EmployeeDocument` against a document type; server validates extension allowlist + content-type match + max size (`422 FILE_TYPE_NOT_ALLOWED` / `413 FILE_TOO_LARGE`). +- FR-HR-DOC-03 Download requires authentication; files are never served via a static/guessable URL. +- FR-HR-DOC-04 Archive (not delete) a document row; the on-disk file may be retained or purged per retention policy (not specified further in this phase). +- FR-HR-DOC-05 `HrDocumentType.ExpiryTracked` + `EmployeeDocument.ExpiryDate` support a document-expiry report (§B.3.7). + +### B.3.4 Attendance (FR-HR-ATT) +- FR-HR-ATT-01 Upload Excel (`.xlsx`) or CSV attendance file: columns `Employee Code | Date | Check In | Check Out`. Creates an `AttendanceUploadBatch` in `Draft`. +- FR-HR-ATT-02 Downloadable template (`GET /attendance-batches/template.xlsx`/`?format=csv`) generated from the same column-mapping the parser uses. +- FR-HR-ATT-03 Validation on upload: employee-code resolution (must exist & be Active), date/time parse + sanity (not future-dated, not before hire date), within-batch duplicate detection, cross-batch-already-confirmed duplicate detection (flagged separately, requires an explicit "supersede" action). +- FR-HR-ATT-04 Preview computes, per record, against the employee's `WorkShift`: WorkingMinutes, LateMinutes, EarlyLeaveMinutes, OvertimeMinutes, and derives `AttendanceStatus` (Present/Absent/HalfDay/OnLeave/Holiday/WeekOff) — OnLeave derived from an overlapping Approved `LeaveRequest` (§B.3.5). +- FR-HR-ATT-05 Confirmation flow: manual record edit, duplicate resolution, then `validate` (Draft→Validated, blocks if unresolved errors/duplicates remain — `422 ATTENDANCE_DUPLICATE_UNRESOLVED`), then `confirm` (Validated→Confirmed, locks records against further edit — `409 ATTENDANCE_BATCH_LOCKED`). +- FR-HR-ATT-06 `unlock` (Confirmed→Validated) requires a mandatory reason, is authorized-user only, and is itself blocked once the batch reaches `UsedInPayroll`. +- FR-HR-ATT-07 A `Confirmed` batch is the only valid input to Payroll generation for its period (§B.3.7). + +### B.3.5 Leave (FR-HR-LV) +- FR-HR-LV-01 Maintain `LeaveType` master (IsPaid, CountsAsNoPay, AccrualPerYear, CarryForwardAllowed). +- FR-HR-LV-02 Submit/approve/reject/cancel a `LeaveRequest` (Draft→Submitted→Approved/Rejected, or →Cancelled). +- FR-HR-LV-03 Maintain per-employee/type/year `LeaveBalance` (Entitled/Taken/CarriedForward/Adjustment); HR can manually adjust. +- FR-HR-LV-04 An Approved leave request overlapping an attendance date with no punch classifies that date `OnLeave` (not `Absent`) during Attendance validation. + +### B.3.6 Payroll (FR-HR-PAY) +- FR-HR-PAY-01 Maintain `SalaryComponent` master (Earning/Deduction, IsTaxable, IsEpfEtfApplicable) — for Allowances and ad hoc Other Deductions only. +- FR-HR-PAY-02 Maintain effective-dated `EmployeeSalaryStructure` (+ lines) per employee; exactly one open-ended (`EffectiveTo IS NULL`) row at a time (`409 SALARY_STRUCTURE_OVERLAP` otherwise). +- FR-HR-PAY-03 Maintain `EmployeeLoan` (Loan or Advance) + `LoanInstallment` schedule/ledger. +- FR-HR-PAY-04 Maintain effective-dated `PayrollStatutorySetting` (EPF/ETF rates) and `TaxSlab` (marginal tax bounds/rate, effective-dated). +- FR-HR-PAY-05 Generate a `PayrollRun` for a period (+ optional branch scope): blocked (`422 ATTENDANCE_NOT_CONFIRMED`) unless every relevant attendance batch for the period is `Confirmed`. Computes one `PayrollLine` + `PayrollLineComponent` set per employee per the formula in §B.4. +- FR-HR-PAY-06 Approval workflow: `Draft` (Generate/regenerate freely) → `Approved` (freeze from recompute) → `Locked` (immutable; stamps loan installments as Deducted and attendance batches as UsedInPayroll — see A.4). `Unlock` (mandatory reason) reverses those stamps and reverts to `Approved`; blocked without authorization. +- FR-HR-PAY-07 `POST /payroll-runs/{id}/generate-payslips` (Locked-only) creates one `Payslip` marker per `PayrollLine`; `GET /payslips/{id}/view` renders an HTML print view. + +### B.3.7 Reports (FR-HR-RPT) +- FR-HR-RPT-01 Attendance summary (Present/Absent/Leave/OT/Late totals per employee/period). +- FR-HR-RPT-02 Overtime report, Late-arrival report (both derived from `AttendanceRecord`). +- FR-HR-RPT-03 Payroll register (per `PayrollRun`, exportable). +- FR-HR-RPT-04 Employee salary history (`EmployeeSalaryStructure` revisions over time). +- FR-HR-RPT-05 Leave balance report. +- FR-HR-RPT-06 Document expiry report (`EmployeeDocument.ExpiryDate` within N days / already expired). + +## B.4 Payroll calculation (authoritative formula) + +``` +GrossSalary = BasicSalary + Σ(SalaryStructureLine WHERE ComponentType=Earning) + OvertimeAmount +NetSalary = GrossSalary + − LateDeductionAmount − NoPayAmount − LoanDeductionAmount + − EpfEmployeeAmount − TaxAmount − OtherDeductionsAmount +``` +`EpfEmployerAmount` and `EtfEmployerAmount` are **informational/liability lines only** — never subtracted from Net (they represent the company's own contribution, not an employee deduction). OvertimeAmount = per-minute rate (derived from Basic ÷ `WorkShift.StandardWorkingMinutes`, × `WorkShift.OtMultiplier` or `PayrollStatutorySetting.OtMultiplierDefault`) × total OT minutes for the period. LateDeductionAmount/NoPayAmount use a flat per-minute/per-day rate in this phase (tiered policies are a future improvement, not built now — see §B.7). Tax is looked up from `TaxSlab` via standard marginal-slab computation over taxable earnings (per `SalaryComponent.IsTaxable`). + +> **Open compliance question, not silently assumed:** the exact APIT taxable-income base (whether EPF-employee reduces taxable income before slab lookup, whether OT is taxable) requires finance/statutory sign-off before go-live. The `TaxSlab` mechanism is built to be configurable; no specific formula is hardcoded beyond "marginal rate over taxable earnings." + +## B.5 Non-Functional Requirements + +- Salary/PII data is more sensitive than Phase-1 inventory data — see `02-SECURITY.md §C.8` for the explicit (not inherited) risk decision required before go-live. +- File uploads (attendance spreadsheets, staff documents) are untrusted input: size-capped, extension/content-type-validated server-side; true magic-byte sniffing and antivirus scanning are explicitly deferred (new accepted-risk entry, not silently skipped). +- All HRM transactional documents (`AttendanceUploadBatch`, `LeaveRequest`, `EmployeeLoan`, `PayrollRun`) follow the same audit/concurrency posture as Phase 1: `uint RowVersion` ETag, automatic `AuditLog` capture via `AuditScribe` (no per-entity wiring needed). + +## B.6 Constraints & assumptions + +- Single currency (LKR) for Phase 2; multi-currency payroll is out of scope. +- Statutory rules assume Sri Lanka (EPF/ETF/APIT-style tax); the `TaxSlab`/`PayrollStatutorySetting` tables exist specifically so this isn't hardcoded, but no other country's scheme is modeled. +- No employee self-service session model exists; all HRM screens are HR/admin-facing only in this phase. + +## B.7 Future Modules & Integration Seams (Phase 2 → later) + +| Seam | Note | +|---|---| +| Biometric device attendance | `AttendanceUploadBatch.SourceType` reserves a `BiometricDevice` enum value now; a device feed can write `AttendanceRecord`s directly later with no schema change. | +| PDF payslips | `Payslip`/`PayrollLine`/`PayrollLineComponent` already carry everything a PDF renderer needs; only a rendering step (e.g. QuestPDF) is deferred. | +| Employee self-service portal | Requires a new employee-facing session model, not present in this phase. | +| Notification hooks | Document expiry, payslip release, leave approval — no email/notification infra exists anywhere in this repo yet; a genuine future subsystem. | +| Tiered late/OT policy | MVP uses a flat per-minute/per-day rate; policies like "3 lates = 1 day" are a future enhancement to `PayrollCalculationService`. | +| Bulk employee import | Mirror the Attendance upload UX (ClosedXML/CsvHelper + preview + validate) for onboarding an existing workforce at go-live. | +| RBAC for HR data | Recommend HRM be the forcing function that finally turns on per-endpoint RBAC (`02-SECURITY.md` Part D), ahead of inventory adjustments as currently ordered. | + +--- + +# Part C — ER Model + +## C.1 Org / Masters + +- **Branch** — `BranchId PK, Code, Name, Address?, Status(EntityStatus), CreatedAt, UpdatedAt, RowVersion`. +- **Department** — `DepartmentId PK, Code, Name, ParentDepartmentId? (self-FK), HeadEmployeeId? FK→Employee, BranchId? FK→Branch, Status, CreatedAt, UpdatedAt, RowVersion`. Unlimited self-nesting (unlike the two-level-capped `Category`); service-level cycle guard on write. +- **Designation** — `DesignationId PK, Code, Name, Status, CreatedAt, UpdatedAt, RowVersion`. +- **EmploymentType** — `EmploymentTypeId PK, Code, Name, Status, CreatedAt, UpdatedAt, RowVersion`. Master (mirrors `Brand`), not an enum. +- **WorkShift** — `WorkShiftId PK, Code, Name, StartTime, EndTime, IsOvernight(bool), GraceMinutes(int, default 15), BreakMinutes(int, default 60), StandardWorkingMinutes(int, default 480), OtMultiplier(decimal, default 1.5), WorkingDaysMask(int), Status, CreatedAt, UpdatedAt, RowVersion`. + +## C.2 Employee core + +- **Employee** — `EmployeeId PK, EmployeeCode(unique, user-entered), FullName, Nic?, DateOfBirth?, Gender?(enum), Nationality?, ProfilePhotoPath?, Email?, PersonalMobile?, AddressLine1?, AddressLine2?, City?, PostalCode?, Country?, EmergencyContactName?, EmergencyContactRelationship?, EmergencyContactPhone?, HireDate, ConfirmationDate?, LastWorkingDate?, DepartmentId FK, DesignationId FK, EmploymentTypeId FK, BranchId? FK, WorkShiftId FK, ReportingManagerId? (self-FK), EpfNumber?, EtfNumber?, TaxIdentificationNumber?, UserId? FK→User (unique, filtered), Status(EmployeeStatus enum: Active/Suspended/Resigned/Terminated/Retired), CreatedBy FK→User, CreatedAt, UpdatedBy? FK→User, UpdatedAt?, RowVersion`. +- **EmployeeBankDetail** — `EmployeeBankDetailId PK, EmployeeId FK, BankName, BranchName, AccountNumber, AccountHolderName, SwiftCode?, IsPrimary(bool), Status, CreatedAt, UpdatedAt, RowVersion`. + +## C.3 Documents + +- **HrDocumentType** — `HrDocumentTypeId PK, Code, Name, Category(enum: Identity/Educational/Contract/Certification/Statutory/Other), RequiredAtOnboarding(bool), ExpiryTracked(bool), Status, CreatedAt, UpdatedAt, RowVersion`. +- **EmployeeDocument** — `EmployeeDocumentId PK, EmployeeId FK, HrDocumentTypeId FK, OriginalFileName, StoredFileName, RelativePath, ContentType, SizeBytes, IssueDate?, ExpiryDate?, Notes?, UploadedBy FK→User, UploadedAt, VerifiedBy? FK→User, VerifiedAt?, Status(enum: Active, Archived), RowVersion`. + +## C.4 Attendance + +- **AttendanceUploadBatch** — `AttendanceUploadBatchId PK, DocNo(NumberSequence "ATT"), PeriodStart, PeriodEnd, SourceType(enum: Excel, Csv, Manual, BiometricDevice[reserved]), OriginalFileName?, UploadedBy FK→User, UploadedAt, Status(enum: Draft, Validated, Confirmed, UsedInPayroll), ConfirmedBy? FK→User, ConfirmedAt?, RowCountTotal, RowCountDuplicate, RowCountError, RowVersion`. +- **AttendanceRecord** — `AttendanceRecordId PK, AttendanceUploadBatchId? FK, EmployeeId FK, AttendanceDate, CheckIn?, CheckOut?, WorkShiftId FK (snapshotted, see A.3), WorkingMinutes(computed), LateMinutes(computed), EarlyLeaveMinutes(computed), OvertimeMinutes(computed), AttendanceStatus(enum: Present/Absent/HalfDay/OnLeave/Holiday/WeekOff), RowValidationStatus(enum: Valid/DuplicateWithinBatch/DuplicateConfirmed/EmployeeNotFound/InvalidDateTime/Error), DuplicateOfAttendanceRecordId? (self-FK), Notes?, IsManualOverride(bool), EditedBy? FK→User, EditedAt?, RowVersion`. + +## C.5 Leave + +- **LeaveType** — `LeaveTypeId PK, Code, Name, IsPaid(bool), CountsAsNoPay(bool), AccrualPerYear(decimal), CarryForwardAllowed(bool), MaxCarryForwardDays?, RequiresApproval(bool, default true), Status, CreatedAt, UpdatedAt, RowVersion`. +- **LeaveRequest** — `LeaveRequestId PK, DocNo(NumberSequence "LV"), EmployeeId FK, LeaveTypeId FK, StartDate, EndDate, DaysCount(decimal), Reason?, Status(enum: Draft/Submitted/Approved/Rejected/Cancelled), ApprovedBy? FK→User, ApprovedAt?, RejectionReason?, CreatedBy FK→User, CreatedAt, RowVersion`. +- **LeaveBalance** — `LeaveBalanceId PK, EmployeeId FK, LeaveTypeId FK, Year, EntitledDays, TakenDays, CarriedForwardDays, AdjustmentDays, RowVersion, UpdatedAt`. Unique `(EmployeeId, LeaveTypeId, Year)`. + +## C.6 Payroll + +- **SalaryComponent** — `SalaryComponentId PK, Code, Name, ComponentType(enum: Earning, Deduction), IsTaxable(bool), IsEpfEtfApplicable(bool), Status, CreatedAt, UpdatedAt, RowVersion`. +- **EmployeeSalaryStructure** — `EmployeeSalaryStructureId PK, EmployeeId FK, EffectiveFrom, EffectiveTo?, BasicSalary(decimal), Currency(default "LKR"), Status(enum: Active, Superseded), ApprovedBy FK→User, ApprovedAt, CreatedBy FK→User, CreatedAt, RowVersion`. +- **EmployeeSalaryStructureLine** — `EmployeeSalaryStructureLineId PK, EmployeeSalaryStructureId FK, SalaryComponentId FK, Amount(decimal)`. +- **EmployeeLoan** — `EmployeeLoanId PK, DocNo(NumberSequence "LOAN"), EmployeeId FK, LoanKind(enum: Loan, Advance), PrincipalAmount, InterestRate(decimal, default 0), InstallmentAmount, NumberOfInstallments, StartYear, StartMonth, OutstandingBalance(decimal, denormalized), Status(enum: Active, Closed, Cancelled), ApprovedBy FK→User, ApprovedAt, CreatedBy FK→User, CreatedAt, RowVersion`. +- **LoanInstallment** — `LoanInstallmentId PK, EmployeeLoanId FK, InstallmentNumber, DueYear, DueMonth, ScheduledAmount, PaidAmount?, PayrollRunId? FK (stamped only at Lock), Status(enum: Pending, Deducted, Skipped), RowVersion`. +- **PayrollStatutorySetting** — `PayrollStatutorySettingId PK, EpfEmployeeRate(decimal, default 0.08), EpfEmployerRate(decimal, default 0.12), EtfEmployerRate(decimal, default 0.03), OtMultiplierDefault(decimal, default 1.5), EffectiveFrom, EffectiveTo?, CreatedBy FK→User, CreatedAt, RowVersion`. +- **TaxSlab** — `TaxSlabId PK, EffectiveFrom, EffectiveTo?, LowerBound(decimal), UpperBound(decimal?, null = "and above"), Rate(decimal), RowVersion, CreatedAt`. +- **PayrollRun** — `PayrollRunId PK, DocNo(NumberSequence "PAY"), PeriodYear, PeriodMonth, BranchId? (null = company-wide), Status(enum: Draft, Approved, Locked), GeneratedBy FK→User, GeneratedAt, ApprovedBy? FK→User, ApprovedAt?, LockedBy? FK→User, LockedAt?, UnlockedBy? FK→User, UnlockedAt?, UnlockReason?, RowVersion`. +- **PayrollLine** — `PayrollLineId PK, PayrollRunId FK, EmployeeId FK, BasicSalary, TotalAllowances, OvertimeAmount, GrossSalary(computed), LateDeductionAmount, NoPayAmount, LoanDeductionAmount, EpfEmployeeAmount, EpfEmployerAmount(informational), EtfEmployerAmount(informational), TaxAmount, OtherDeductionsAmount, NetSalary(computed), WorkingDays, PresentDays, AbsentDays, LeaveDays, OtMinutesTotal, LateMinutesTotal, RowVersion`. +- **PayrollLineComponent** — `PayrollLineComponentId PK, PayrollLineId FK, ComponentCategory(enum: Earning, Deduction, EmployerContribution), SalaryComponentId? FK (null for system-computed lines), Label, Amount, SortOrder`. +- **Payslip** — `PayslipId PK, PayrollLineId FK (unique), GeneratedAt, ReleasedAt?, ReleasedBy? FK→User`. + +## C.7 Cross-cutting change + +- **User** (existing entity, `Domain/Entities/User.cs`) — add `Email? (nullable string)`, case-insensitive filtered unique index. No other column changes. + +## C.10 Entity → implementation mapping + +Same convention as Phase 1 (`10-BACKEND-PHASE1.md` C.10): entities → `Domain/Entities/*.cs`; enums → `Domain/Enums/*.cs`; EF configurations (`IEntityTypeConfiguration`, one per entity) → `Infra/Persistence/Configurations/*Configuration.cs`; DTOs grouped by module → `Dtos/Hrm/*.cs`; services → `Services/Hrm/*Service.cs` (CRUD) + `Services/Hrm/{AttendanceComputationService,PayrollCalculationService,EmployeeUserLinkService}.cs` (domain calculation, analog of `Services/Stock/FifoCostingService.cs`); controllers → `Controllers/Hrm/*Controller.cs`; new file-storage abstraction → `Infra/Storage/{IFileStorageService,LocalFileStorageService}.cs`. + +--- + +*End of 12-BACKEND-HRM.md. API contract: `13-BACKEND-HRM-API.md`. Frontend flows: `21-FRONTEND-HRM.md`.* diff --git a/docs/13-BACKEND-HRM-API.md b/docs/13-BACKEND-HRM-API.md new file mode 100644 index 0000000..9469461 --- /dev/null +++ b/docs/13-BACKEND-HRM-API.md @@ -0,0 +1,156 @@ +# 13 · BACKEND — Phase 2 API Reference (HRM) + +> Follows the exact conventions of `11-BACKEND-PHASE1.md §1` (base URL, list envelope, pagination, concurrency, error format). Not repeated verbatim here — see that file for the shared contract. This file adds only the HRM endpoint surface and the HRM-specific error codes/enums. + +--- + +## 1. Conventions recap + +- Base URL: `/api/v1`. JSON, camelCase. +- List envelope: `{ items: [...], pagination: { page, pageSize, total } }`. +- Concurrency: `ETag`/`If-Match` (backed by `RowVersion`) on every mutable aggregate's `PUT`/status-changing endpoints. +- Errors: RFC 7807 `ProblemDetails` with a stable `code` (catalog in §7). + +--- + +## 2. Org / Masters + +``` +GET/POST /branches GET/PUT /branches/{id} PATCH /branches/{id}/status +GET/POST /departments GET/PUT /departments/{id} PATCH /departments/{id}/status +GET/POST /designations GET/PUT /designations/{id} PATCH /designations/{id}/status +GET/POST /employment-types GET/PUT /employment-types/{id} PATCH /employment-types/{id}/status +GET/POST /work-shifts GET/PUT /work-shifts/{id} PATCH /work-shifts/{id}/status +GET/POST /hr-document-types GET/PUT /hr-document-types/{id} PATCH /hr-document-types/{id}/status +GET/POST /leave-types GET/PUT /leave-types/{id} PATCH /leave-types/{id}/status +GET/POST /salary-components GET/PUT /salary-components/{id} PATCH /salary-components/{id}/status +``` +All follow the `BrandsController` pattern exactly: `GET` list (`q`, `status`, paging), `GET {id}` with `ETag`, `POST` → `201`, `PUT` requires `If-Match`, `PATCH .../status` → `204`, no hard `DELETE`. `Department` additionally accepts `parentDepartmentId` on create/update (`DEPARTMENT_CYCLE_DETECTED` 422 if it would create a cycle). + +--- + +## 3. Employees + +``` +GET /employees?q=&status=&departmentId=&designationId=&branchId=&page=&pageSize= +GET /employees/{employeeId} + ETag +POST /employees { employeeCode, fullName, hireDate, departmentId, designationId, + employmentTypeId, workShiftId, branchId?, reportingManagerId?, + email?, ..., linkUserId? } 201; EMPLOYEE_CODE_DUPLICATE (400) +PUT /employees/{employeeId} requires If-Match +PATCH /employees/{employeeId}/status { status } 204 + +GET /employees/email-lookup?email= { match: EmployeeSummary|null } +POST /employees/{employeeId}/link-user { userId } EMPLOYEE_ALREADY_LINKED / USER_ALREADY_LINKED (409) +DELETE /employees/{employeeId}/link-user 204 + +GET /employees/{employeeId}/bank-details +PUT /employees/{employeeId}/bank-details { items: [...] } replaces the set; exactly one isPrimary + +GET /employees/{employeeId}/documents +POST /employees/{employeeId}/documents multipart(file, hrDocumentTypeId, issueDate?, expiryDate?, notes?) + 201; FILE_TYPE_NOT_ALLOWED (422) / FILE_TOO_LARGE (413) +GET /employees/{employeeId}/documents/{docId}/download streams via IFileStorageService, auth-gated +PATCH /employees/{employeeId}/documents/{docId}/status { status } Active|Archived +``` + +Also on the Users surface (existing `UsersController`, extended): +``` +POST /users { username, fullName, roleId, userTypeId, email, ..., linkEmployeeId? } +GET /users/email-lookup?email= { match: UserSummary|null } +``` + +--- + +## 4. Attendance + +``` +GET /attendance-batches/template.xlsx | ?format=csv generated from parser's own column map +GET /attendance-batches?status=&periodYear=&periodMonth=&page=&pageSize= +POST /attendance-batches multipart(file, periodStart, periodEnd) 201 Draft; row-level preview computed +GET /attendance-batches/{id} + summary counts +GET /attendance-batches/{id}/records?status= +PUT /attendance-batches/{id}/records/{recordId} { checkIn?, checkOut?, attendanceStatus?, notes? } + 409 ATTENDANCE_BATCH_LOCKED once Confirmed/UsedInPayroll +POST /attendance-batches/{id}/resolve-duplicate { recordId, action: "keep"|"discard"|"supersede" } +POST /attendance-batches/{id}/validate Draft → Validated; 422 ATTENDANCE_DUPLICATE_UNRESOLVED +POST /attendance-batches/{id}/confirm Validated → Confirmed +POST /attendance-batches/{id}/unlock { reason } Confirmed → Validated; 409 if already UsedInPayroll +``` + +--- + +## 5. Leave + +``` +POST /leave-requests { employeeId, leaveTypeId, startDate, endDate, reason? } 201 Draft +GET /leave-requests?employeeId=&status=&page=&pageSize= +GET /leave-requests/{id} +POST /leave-requests/{id}/submit | /approve | /reject { reason? } | /cancel + +GET /employees/{employeeId}/leave-balances?year= +PUT /employees/{employeeId}/leave-balances { items: [{ leaveTypeId, adjustmentDays }] } HR manual adjustment +``` + +--- + +## 6. Payroll + +``` +GET /employees/{employeeId}/salary-structure current + history +POST /employees/{employeeId}/salary-structure { effectiveFrom, basicSalary, lines: [{salaryComponentId, amount}] } + 409 SALARY_STRUCTURE_OVERLAP + +GET /employees/{employeeId}/loans +POST /employees/{employeeId}/loans { loanKind, principalAmount, installmentAmount, numberOfInstallments, startYear, startMonth } +GET /employees/{employeeId}/loans/{loanId} + installment ledger + +GET/PUT /payroll-statutory-settings GET/PUT .../{id} +GET/POST /tax-slabs GET/PUT .../{id} 422 TAX_SLAB_GAP_INVALID + +POST /payroll-runs { periodYear, periodMonth, branchId? } 201 Draft; 422 ATTENDANCE_NOT_CONFIRMED +GET /payroll-runs?periodYear=&periodMonth=&status=&branchId= +GET /payroll-runs/{id} + lines summary +GET /payroll-runs/{id}/lines per-employee summary rows (the Payroll Preview table) +GET /payroll-runs/{id}/lines/{lineId} detailed breakdown (PayrollLineComponent[]) +POST /payroll-runs/{id}/approve Draft → Approved +POST /payroll-runs/{id}/lock Approved → Locked; stamps loans/attendance +POST /payroll-runs/{id}/unlock { reason } Locked → Approved; 409 PAYROLL_PERIOD_LOCKED if unauthorized +POST /payroll-runs/{id}/generate-payslips Locked-only; idempotent +GET /payslips/{payslipId} +GET /payslips/{payslipId}/view server-rendered HTML print view +``` + +--- + +## 7. Domain Error Catalog (additions) + +| `code` | HTTP | When | +|---|---|---| +| `EMPLOYEE_CODE_DUPLICATE` | 400 | Employee code already exists. | +| `EMPLOYEE_ALREADY_LINKED` | 409 | Target Employee already has a linked User. | +| `USER_ALREADY_LINKED` | 409 | Target User already backs a different Employee. | +| `DEPARTMENT_CYCLE_DETECTED` | 422 | Setting `parentDepartmentId` would create a cycle. | +| `DOCUMENT_TYPE_IN_USE` | 409 | Hard delete of a referenced `HrDocumentType` attempted (deactivate instead). | +| `FILE_TYPE_NOT_ALLOWED` | 422 | Upload extension/content-type outside the allowlist. | +| `FILE_TOO_LARGE` | 413 | Upload exceeds configured max size. | +| `ATTENDANCE_BATCH_LOCKED` | 409 | Edit attempted on a Confirmed/UsedInPayroll batch. | +| `ATTENDANCE_DUPLICATE_UNRESOLVED` | 422 | Validate/Confirm attempted with unresolved duplicates. | +| `ATTENDANCE_NOT_CONFIRMED` | 422 | Payroll generation attempted against a non-Confirmed batch for the period. | +| `SALARY_STRUCTURE_OVERLAP` | 409 | New effective-dated salary structure overlaps an existing open-ended one. | +| `TAX_SLAB_GAP_INVALID` | 422 | Tax slab bounds leave a gap or overlap with another slab. | +| `PAYROLL_PERIOD_LOCKED` | 409 | Edit or unauthorized unlock attempted on a Locked payroll run. | + +## 8. Enumerations (new) + +`EmployeeStatus`, `EmployeeDocument.Status` (Active/Archived), `HrDocumentType.Category`, `AttendanceUploadBatch.SourceType/Status`, `AttendanceRecord.AttendanceStatus/RowValidationStatus`, `LeaveRequest.Status`, `EmployeeLoan.LoanKind/Status`, `LoanInstallment.Status`, `PayrollRun.Status`, `PayrollLineComponent.ComponentCategory`, `SalaryComponent.ComponentType`. Full field definitions: `12-BACKEND-HRM.md` Part C. + +## 9. Implementation notes + +- Excel/CSV parsing: `ClosedXML` (.xlsx) and `CsvHelper` (.csv), both new packages. Template generation reuses the same column-mapping constants as the parser. +- File streaming: download endpoints stream via `IFileStorageService.OpenReadAsync`, never a static file path. +- Payslip HTML view: server-rendered Razor/plain-HTML response from `PayrollLine`+`PayrollLineComponent`, no PDF library in this phase. + +--- + +*End of 13-BACKEND-HRM-API.md.* diff --git a/docs/21-FRONTEND-HRM.md b/docs/21-FRONTEND-HRM.md new file mode 100644 index 0000000..c59a3e7 --- /dev/null +++ b/docs/21-FRONTEND-HRM.md @@ -0,0 +1,50 @@ +# 21 · FRONTEND — Phase 2 (HRM) + +> Follows `20-FRONTEND.md §1` architecture rules verbatim (Next.js App Router + TypeScript, Tailwind + shadcn/ui only, plain React hooks, dependency-free client validation, single `lib/api-client.ts`, types mirror API DTOs). Not repeated here. This file adds only HRM-specific screens/flows. + +--- + +## 1. Screens (new, under `app/dashboard/hrm/`) + +``` +app/dashboard/hrm/ +├── employees/ list + create/edit + detail (bank details, documents, salary history tabs) +├── attendance/ batch list + upload wizard (upload → preview → confirm) +├── leave/ leave request list + approval inbox +├── payroll/ payroll run list + generate → review → approve → lock → payslips +├── reports/ attendance summary / OT / late-arrival / payroll register / salary history / document expiry +└── settings/ departments, designations, employment types, work shifts, document types, + leave types, salary components, statutory settings, tax slabs +``` + +Mirrors the existing `app/dashboard/settings/{roles,users}` admin-screen pattern for master-data CRUD; `components/auth/RolePermissionTree.tsx`-style list/detail layout is the closest existing analog for the Employees list+detail screen. + +## 2. Flows → API mapping + +- **Create Employee** — form posts `POST /employees`. On blur of the email field, call `GET /users/email-lookup?email=`; if a match is returned, show a non-blocking suggestion chip ("System user 'kasun.p' matches this email — link instead?") that sets `linkUserId` on submit if accepted. Never auto-link. +- **Create User** (existing `/dashboard/settings/users` screen, extended) — same pattern in reverse: on email blur, call `GET /employees/email-lookup?email=`, offer `linkEmployeeId`. +- **Attendance upload wizard** — three-step client flow over one `AttendanceUploadBatch`: + 1. Upload (file picker + "Download template" link) → `POST /attendance-batches`, batch created `Draft`, server-computed preview returned. + 2. Preview/Confirm — table of `AttendanceRecord`s with computed Working Hours/Late/Early/OT/Status columns; inline edit (`PUT .../records/{id}`) and duplicate-resolution actions; `POST .../validate` then `POST .../confirm`. + 3. Confirmed state is read-only in the UI (matches the server 409 on edit); an "Unlock" action is only shown to an HR Administrator. +- **Payroll run** — `Generate` (`POST /payroll-runs`) shows a Preview list (`Employee | Basic | OT | Allowances | Deductions | Net`); clicking a row opens the detailed breakdown (`GET .../lines/{lineId}`) rendered as the exact Basic/Allowances/OT/Gross/Late/No-Pay/Loan/EPF/ETF/Tax/Net layout from the spec. `Approve`/`Lock`/`Unlock` buttons gated on current `Status`; `Generate Payslips` only enabled once `Locked`. Payslip view opens `GET /payslips/{id}/view` in a print-friendly page (browser print → PDF is the user's own path in this phase, per the confirmed "HTML first" decision). +- **Leave approval inbox** — list of `Submitted` `LeaveRequest`s for the current approver's team; approve/reject inline. + +## 3. Validation posture (per `20-FRONTEND.md §3`, applied to HRM specifics) + +Client-side (format/required/range only, for UX): +- Employee: required fields present, email format, date ranges sane (hire date not in future). +- Attendance upload: file extension/size check before upload (fast feedback), mirrored server-side as authoritative. + +Server-authoritative (never assumed client-side): +- Employee code uniqueness, email-lookup match existence, one-User-per-Employee constraint. +- Attendance duplicate detection (within-batch and cross-batch), employee-code resolution, batch lock state. +- Payroll: attendance-confirmed precondition, salary-structure overlap, lock/unlock authorization, all calculated amounts (Gross/Net/Tax/EPF/ETF) — the client never recomputes or previews these independently of what the server returns. + +## 4. Error & empty states + +Same posture as `20-FRONTEND.md §4`: surface server `ProblemDetails.code` directly (e.g. a friendly message keyed off `ATTENDANCE_BATCH_LOCKED`/`PAYROLL_PERIOD_LOCKED`/`EMPLOYEE_CODE_DUPLICATE`), empty states for "no employees yet" / "no attendance batches this period" / "no payroll runs yet" following the existing master-data screen convention (§2.2 there). + +--- + +*End of 21-FRONTEND-HRM.md.*