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