Leave entry admin and uploader fix

This commit is contained in:
mashfiq 2026-11-11 14:13:46 +06:00
parent 102c90ea12
commit 53210c0ac5
8 changed files with 218 additions and 14 deletions

54
CLAUDE.md Normal file
View File

@ -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_<name>` 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/<Domain>/*DA.cs`: static internal classes that build raw SQL strings with `SQLParser.MakeSQL(...)` and run them against a `TransactionContext`.
- `Service/<Domain>/*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/<Area>/`: `[ApiController]` with `[Route("api/<Area>")]`. 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/<Domain>`.
2. Add the SQL in `HRM.DA/DA/<Domain>/XxxDA.cs`.
3. Add the service in `HRM.DA/Service/<Domain>`.
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/<domain>/`. 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/`.

View File

@ -2236,6 +2236,12 @@ namespace HRM.DA
return tc.ExecuteReader("Select * From Employee");
}
/// <param name="quotedEmployeeNos">Comma separated, already quoted/escaped list, e.g. 'E001','E002'</param>
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);

View File

@ -484,6 +484,22 @@ namespace HRM.DA
return tc.ExecuteReader("SELECT * from LeaveEntry where LeaveYear=%n", nLeaveYear);
}
/// <summary>
/// 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.
/// </summary>
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)
{

View File

@ -3563,11 +3563,44 @@ namespace HRM.DA
try
{
List<LeaveEntry> 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<string> uploadedEmpNos = new List<string>();
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<string, Employee> 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<int, List<LeaveEntry>> leaveEntriesByEmp = new Dictionary<int, List<LeaveEntry>>();
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<LeaveEntry> leaveEntrys = new List<LeaveEntry>();
List<Employee> employees = new EmployeeService().GetAllEmps();
List<Leave> oLeaves = new LeaveService().GetAll();
List<LeaveParameter> oLeaveParameters = new List<LeaveParameter>();
List<LeaveParameter> 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<LeaveEntry> 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<LeaveEntry> 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;

View File

@ -1194,6 +1194,51 @@ namespace HRM.DA
return employees;
}
/// <summary>
/// Loads only the employees whose EmployeeNo is in the given list (queried in batches),
/// instead of reading the whole Employee table.
/// </summary>
public List<Employee> GetByEmployeeNos(IEnumerable<string> employeeNos)
{
List<Employee> employees = new List<Employee>();
List<string> 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<Employee>(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<Employee> GetAllEmps(int payrollTypeid)
{
List<Employee> employees = new List<Employee>();

View File

@ -713,6 +713,52 @@ namespace HRM.DA
return oLeaveEntrys;
}
/// <summary>
/// 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.
/// </summary>
public List<LeaveEntry> GetForOverlapCheck(int nLeaveYear, IEnumerable<int> empIDs, DateTime fromDate,
DateTime toDate, params EnumLeaveStatus[] excludedStatuses)
{
List<LeaveEntry> oLeaveEntrys = new List<LeaveEntry>();
List<int> 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<LeaveEntry>(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<LeaveEntry> Get(string empIds, DateTime fromDate, DateTime toDate, string leaveStatuss)
{
List<LeaveEntry> oLeaveEntrys = null;

View File

@ -47,15 +47,15 @@
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="appliedFromDate" title="From Date" width="120">
<kendo-grid-column field="approvedFromDate" title="From Date" width="120">
<ng-template kendoGridCellTemplate let-dataItem let-rowIndex="rowIndex">
{{dataItem.appliedFromDate | date: 'dd-MMM-yyyy'}}
{{dataItem.approvedFromDate | date: 'dd-MMM-yyyy'}}
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="appliedToDate" title="To Date" width="120">
<kendo-grid-column field="approvedToDate" title="To Date" width="120">
<ng-template kendoGridCellTemplate let-dataItem let-rowIndex="rowIndex">
{{dataItem.appliedToDate | date: 'dd-MMM-yyyy'}}
{{dataItem.approvedToDate | date: 'dd-MMM-yyyy'}}
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="isLFA" title="LFA" width="80">

View File

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