diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c43115e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +EchoTex HR & Payroll: an ASP.NET Core (.NET 8) Web API that hosts an Angular 13 SPA, backed by SQL Server through a hand-written ADO.NET data layer (no EF). Solution file: `EchoTex.sln`. Main branch for PRs is `devqc`; developers work on `dev_` branches. + +## Commands + +Backend (from repo root): +- Build everything: `dotnet build EchoTex.sln`. `DatabaseComparison` is a legacy .NET Framework 4.6.1 WinForms project, so it may need Visual Studio/MSBuild. Build `HRM.UI/HRM.UI.csproj` directly to skip it. +- Run API + SPA: `dotnet run --project HRM.UI`. In Development, `Startup` calls `spa.UseAngularCliServer(npmScript: "start")`, which runs `ng serve` from `HRM.UI/ClientApp` automatically. + +Frontend (from `HRM.UI/ClientApp`): +- `npm install` (Kendo UI license file: `kendo-ui-license.txt`) +- `npm start` (ng serve), `npm run build`, `npm run lint` (tslint), `npm test` (Karma) + +The solution has no .NET test projects. + +## Architecture + +Layered projects, with dependencies flowing downward: + +- **Ease.Core**: framework code. `DataAccess/` (`TransactionContext`, `DataReader`, `SQLParser.MakeSQL`, `DbConfig`/`ConnectionFactory` for SQL/Oracle) and `Model/` (`ServiceTemplate`, `ObjectTemplate`). +- **HRM.BO**: business objects grouped by domain folder (Leave, Salary, Tax, PF, Attendance…). Each domain file holds the entity classes (usually derived from `AuditTrailBase` / `BasicBaseObject`) **and** its service interface, e.g. `ILeaveEntryService` is defined at the bottom of `HRM.BO/Leave/LeaveEntry.cs`. +- **HRM.DA**: two halves. + - `DA//*DA.cs`: static internal classes that build raw SQL strings with `SQLParser.MakeSQL(...)` and run them against a `TransactionContext`. + - `Service//*Service.cs`: implement the BO interfaces and inherit `ServiceTemplate`. Each public method follows the same pattern: `tc = TransactionContext.Begin(...)`, call the DA, map rows with `DataReader` in a private `MapObject`, `tc.End()`, and `tc.HandleError()` in the catch block. Overloads that take an existing `TransactionContext tc` let one transaction span several services. +- **HRM.Report**: RDLC report generation (`RDLC/`, typed DataSets, `ReportProcessor.cs`). +- **HRM.UI**: the API host. + - `Controllers//`: `[ApiController]` with `[Route("api/")]`. Each action wraps its body in try/catch and returns `StatusCode(500, e.Message)` on failure. Get the logged-in user from the JWT with `CurrentUser.GetCurrentUser(HttpContext.User)`. + - `Configurations/DI/ServiceCollectionExtensions.cs`: every service interface→implementation pair must be registered here (`AddTransient`). A new service will not resolve until it is added. + - `Configurations/AutoMapper`: mappings, registered by `services.ConfigureMappings()`. + - `appsettings.json` → `AppSettings`: `DefaultDB` names the connection entry in `DbConfig`, whose key is encrypted. The same file also holds JWT, email, and OpenAI settings. +- **HRM.AttendanceAutoProcess** (console) and **HRM.MailNotificationProcess** (WinForms, net8.0-windows): standalone background jobs that reuse BO/DA. +- **Erecruitment.UI**: a separate web app for e-recruitment. + +### Adding a feature end-to-end + +1. Add the entity and interface in `HRM.BO/`. +2. Add the SQL in `HRM.DA/DA//XxxDA.cs`. +3. Add the service in `HRM.DA/Service/`. +4. Register it in `ServiceCollectionExtensions`. +5. Add the controller action. +6. Add the Angular service method and component. + +### Angular client (`HRM.UI/ClientApp/src/app`) + +- Feature modules are lazy-loaded from `app.routes.ts` (e.g. `leave/leave.module`). +- HTTP calls go through per-domain services in `_services//`. These wrap `ApiService` (`app.api.service.ts`) and call relative paths such as `this.apiService.httpGet('/Leave/getWeeklyHoliday')`. +- `ApiService` holds `base_url`, `versionNumber`, and the current payroll type/user type. The commented-out `base_url` values are per-client deployment paths. +- UI components come from Kendo UI for Angular and PrimeNG-style layout components (`app.main`, `app.menu`, `app.topbar`). Models live in `_models/`. +- Auth uses JWT (`@auth0/angular-jwt`), with optional Azure AD via MSAL. Route guards are in `_guards/`. diff --git a/HRM.DA/DA/Employee/EmployeeDA.cs b/HRM.DA/DA/Employee/EmployeeDA.cs index 466c1d7..6e76457 100644 --- a/HRM.DA/DA/Employee/EmployeeDA.cs +++ b/HRM.DA/DA/Employee/EmployeeDA.cs @@ -2236,6 +2236,12 @@ namespace HRM.DA return tc.ExecuteReader("Select * From Employee"); } + /// Comma separated, already quoted/escaped list, e.g. 'E001','E002' + internal static IDataReader GetByEmployeeNos(TransactionContext tc, string quotedEmployeeNos) + { + return tc.ExecuteReader("SELECT * FROM Employee WHERE EmployeeNo IN (%q)", quotedEmployeeNos); + } + internal static IDataReader GetAllEmps(TransactionContext tc, int payrollTypeid) { return tc.ExecuteReader("Select * From Employee where payrolltypeid=%n", payrollTypeid); diff --git a/HRM.DA/DA/Leave/LeaveEntryDA.cs b/HRM.DA/DA/Leave/LeaveEntryDA.cs index 094bfe9..0a2a54a 100644 --- a/HRM.DA/DA/Leave/LeaveEntryDA.cs +++ b/HRM.DA/DA/Leave/LeaveEntryDA.cs @@ -484,6 +484,22 @@ namespace HRM.DA return tc.ExecuteReader("SELECT * from LeaveEntry where LeaveYear=%n", nLeaveYear); } + /// + /// Leave entries of the given employees whose applied-from date falls in [fromDate, toDate], + /// excluding the given statuses. Used for overlap checks during bulk upload, so the + /// LeaveEntry table is filtered in SQL instead of loading the whole leave year. + /// + public static IDataReader GetForOverlapCheck(TransactionContext tc, int nLeaveYear, string empIDs, + DateTime fromDate, DateTime toDate, string excludedStatuses) + { + // AppFromDate < (toDate + 1 day) so entries on the last day are included regardless of time part + return tc.ExecuteReader(@"SELECT * FROM LeaveEntry + WHERE LeaveYear=%n AND EmpId IN (%q) + AND AppFromDate >= %d AND AppFromDate < %d + AND LeaveStatus NOT IN (%q)", + nLeaveYear, empIDs, fromDate.Date, toDate.Date.AddDays(1), excludedStatuses); + } + public static IDataReader Get(TransactionContext tc, string empIds, DateTime fromDate, DateTime toDate, string LeaveStatus) { diff --git a/HRM.DA/Service/Common/RegularDataUploadService.cs b/HRM.DA/Service/Common/RegularDataUploadService.cs index e4555b1..74080d4 100644 --- a/HRM.DA/Service/Common/RegularDataUploadService.cs +++ b/HRM.DA/Service/Common/RegularDataUploadService.cs @@ -3563,11 +3563,44 @@ namespace HRM.DA try { - List leaveentries = new LeaveEntryService().GetByLeaveYear(currLeaveYear.ID); + // Pre-scan the uploaded rows so we only load the employees / leave entries + // that are actually referenced, instead of the full Employee and LeaveEntry tables. + List uploadedEmpNos = new List(); + DateTime? minFromDate = null; + DateTime? maxToDate = null; + foreach (DataRow row in _uplodedData.Rows) + { + uploadedEmpNos.Add(row[0].ToString().Trim()); + DateTime f, t; + if (DateTime.TryParse(row[2].ToString().Trim(), out f) + && DateTime.TryParse(row[3].ToString().Trim(), out t)) + { + if (minFromDate == null || f < minFromDate) minFromDate = f; + if (maxToDate == null || t > maxToDate) maxToDate = t; + } + } + + // EmployeeNo -> Employee (ordinal, same exact-match semantics as the previous List.Find) + Dictionary employeesByNo = new EmployeeService() + .GetByEmployeeNos(uploadedEmpNos) + .Where(e => e.EmployeeNo != null) + .GroupBy(e => e.EmployeeNo, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + + // EmpID -> existing (non declined / cancelled) leave entries within the uploaded date window + Dictionary> leaveEntriesByEmp = new Dictionary>(); + if (minFromDate != null && employeesByNo.Count > 0) + { + leaveEntriesByEmp = new LeaveEntryService() + .GetForOverlapCheck(currLeaveYear.ID, employeesByNo.Values.Select(e => e.ID), + minFromDate.Value, maxToDate.Value, EnumLeaveStatus.Declined, EnumLeaveStatus.Cancel) + .GroupBy(x => x.EmpID) + .ToDictionary(g => g.Key, g => g.ToList()); + } + int nRowCount = 1; LeaveEntry leaveEntry = new LeaveEntry(); List leaveEntrys = new List(); - List employees = new EmployeeService().GetAllEmps(); List oLeaves = new LeaveService().GetAll(); List oLeaveParameters = new List(); List leaveParamss = new LeaveParameterService().Get(EnumStatus.Regardless, _columnDefinition.PayrollTypeID, null, true); @@ -3592,8 +3625,8 @@ namespace HRM.DA string remarks = dr[5].ToString().Trim(); //Leave leave = leaves.Find(delegate(Payroll.BO.Leave le) { return le.Code == leaveCode; }); - Employee employee = employees.Find(delegate (Employee em) { return em.EmployeeNo == Empcode; }); - if (employee == null) + Employee employee; + if (!employeesByNo.TryGetValue(Empcode, out employee)) { _errorOrSuccessList.Add(UploadErrorOrSuccess.Create("Employee No", nRow, "(" + dr["Employee No"].ToString() + ")" + "Employee Not found")); continue; @@ -3610,12 +3643,13 @@ namespace HRM.DA continue; } - List tempLS = leaveentries.Where(x => x.AppliedFromDate >= fromDate - && x.AppliedFromDate <= toDate - && x.EmpID == employee.ID - && x.LeaveStatus != EnumLeaveStatus.Declined - && x.LeaveStatus != EnumLeaveStatus.Cancel).ToList(); - if (tempLS.Count > 0) + List empLeaveEntries; + bool hasOverlap = leaveEntriesByEmp.TryGetValue(employee.ID, out empLeaveEntries) + && empLeaveEntries.Any(x => x.AppliedFromDate >= fromDate + && x.AppliedFromDate <= toDate + && x.LeaveStatus != EnumLeaveStatus.Declined + && x.LeaveStatus != EnumLeaveStatus.Cancel); + if (hasOverlap) { _errorOrSuccessList.Add(UploadErrorOrSuccess.Create("Employee No", nRow, "(" + dr["Employee No"].ToString() + ")" + " Overlap with previously applied leaves.")); continue; diff --git a/HRM.DA/Service/Employee/EmployeeService.cs b/HRM.DA/Service/Employee/EmployeeService.cs index 2f611e2..87f7a6d 100644 --- a/HRM.DA/Service/Employee/EmployeeService.cs +++ b/HRM.DA/Service/Employee/EmployeeService.cs @@ -1194,6 +1194,51 @@ namespace HRM.DA return employees; } + /// + /// Loads only the employees whose EmployeeNo is in the given list (queried in batches), + /// instead of reading the whole Employee table. + /// + public List GetByEmployeeNos(IEnumerable employeeNos) + { + List employees = new List(); + List distinctNos = employeeNos + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (distinctNos.Count == 0) return employees; + + const int batchSize = 500; + TransactionContext tc = null; + try + { + tc = TransactionContext.Begin(); + for (int i = 0; i < distinctNos.Count; i += batchSize) + { + string inList = string.Join(",", distinctNos.Skip(i).Take(batchSize) + .Select(x => "'" + x.Replace("'", "''") + "'")); + DataReader dr = new DataReader(EmployeeDA.GetByEmployeeNos(tc, inList)); + employees.AddRange(this.CreateObjects(dr)); + dr.Close(); + } + tc.End(); + } + catch (Exception e) + { + #region Handle Exception + + if (tc != null) + tc.HandleError(); + ExceptionLog.Write(e); + + throw new ServiceException(e.Message, e); + + #endregion + } + + return employees; + } + public List GetAllEmps(int payrollTypeid) { List employees = new List(); diff --git a/HRM.DA/Service/Leave/LeaveEntryService.cs b/HRM.DA/Service/Leave/LeaveEntryService.cs index ae44bcb..9cfbb51 100644 --- a/HRM.DA/Service/Leave/LeaveEntryService.cs +++ b/HRM.DA/Service/Leave/LeaveEntryService.cs @@ -713,6 +713,52 @@ namespace HRM.DA return oLeaveEntrys; } + /// + /// Loads leave entries only for the given employees and applied-from date window + /// (queried in batches), excluding the given statuses. Replacement for + /// GetByLeaveYear when only a few employees need an overlap check. + /// + public List GetForOverlapCheck(int nLeaveYear, IEnumerable empIDs, DateTime fromDate, + DateTime toDate, params EnumLeaveStatus[] excludedStatuses) + { + List oLeaveEntrys = new List(); + List ids = empIDs.Distinct().ToList(); + if (ids.Count == 0) return oLeaveEntrys; + + string statuses = excludedStatuses != null && excludedStatuses.Length > 0 + ? string.Join(",", excludedStatuses.Select(x => ((int)x).ToString())) + : "-1"; + + const int batchSize = 500; + TransactionContext tc = null; + try + { + tc = TransactionContext.Begin(); + for (int i = 0; i < ids.Count; i += batchSize) + { + string inList = string.Join(",", ids.Skip(i).Take(batchSize)); + DataReader dr = new DataReader(LeaveEntryDA.GetForOverlapCheck(tc, nLeaveYear, inList, + fromDate, toDate, statuses)); + oLeaveEntrys.AddRange(this.CreateObjects(dr)); + dr.Close(); + } + tc.End(); + } + catch (Exception e) + { + #region Handle Exception + + if (tc != null) + tc.HandleError(); + ExceptionLog.Write(e); + throw new ServiceException("Failed to Get Leave Information" + e.Message, e); + + #endregion + } + + return oLeaveEntrys; + } + public List Get(string empIds, DateTime fromDate, DateTime toDate, string leaveStatuss) { List oLeaveEntrys = null; diff --git a/HRM.UI/ClientApp/src/app/leave/leave-entry-by-admin-panel/leave-application-by-admin-list/leave-application-by-admin-list.component.html b/HRM.UI/ClientApp/src/app/leave/leave-entry-by-admin-panel/leave-application-by-admin-list/leave-application-by-admin-list.component.html index 97e3f8a..67e9e54 100644 --- a/HRM.UI/ClientApp/src/app/leave/leave-entry-by-admin-panel/leave-application-by-admin-list/leave-application-by-admin-list.component.html +++ b/HRM.UI/ClientApp/src/app/leave/leave-entry-by-admin-panel/leave-application-by-admin-list/leave-application-by-admin-list.component.html @@ -47,15 +47,15 @@ - + - {{dataItem.appliedFromDate | date: 'dd-MMM-yyyy'}} + {{dataItem.approvedFromDate | date: 'dd-MMM-yyyy'}} - + - {{dataItem.appliedToDate | date: 'dd-MMM-yyyy'}} + {{dataItem.approvedToDate | date: 'dd-MMM-yyyy'}} diff --git a/HRM.UI/ClientApp/src/app/leave/leave-entry-by-admin-panel/leave-application-by-admin-list/leave-application-by-admin-list.component.ts b/HRM.UI/ClientApp/src/app/leave/leave-entry-by-admin-panel/leave-application-by-admin-list/leave-application-by-admin-list.component.ts index 516f09a..4ec1e18 100644 --- a/HRM.UI/ClientApp/src/app/leave/leave-entry-by-admin-panel/leave-application-by-admin-list/leave-application-by-admin-list.component.ts +++ b/HRM.UI/ClientApp/src/app/leave/leave-entry-by-admin-panel/leave-application-by-admin-list/leave-application-by-admin-list.component.ts @@ -151,6 +151,9 @@ export class LeaveListByAdminComponent implements OnInit { this._leaveEntry.appliedFromDate = new Date(this._leaveEntry.appliedFromDate); this._leaveEntry.appliedToDate = new Date(this._leaveEntry.appliedToDate); this._leaveEntry.appliedLeaveDate = new Date(this._leaveEntry.appliedLeaveDate); + this._leaveEntry.approvedFromDate = new Date(this._leaveEntry.approvedFromDate); + this._leaveEntry.approvedToDate = new Date(this._leaveEntry.approvedToDate); + this._leaveEntry.appliedLeaveDate = new Date(this._leaveEntry.appliedLeaveDate); this.showModal = true; this.isNew = false;