Leave entry admin and uploader fix
This commit is contained in:
parent
102c90ea12
commit
53210c0ac5
54
CLAUDE.md
Normal file
54
CLAUDE.md
Normal 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/`.
|
||||||
|
|
@ -2236,6 +2236,12 @@ namespace HRM.DA
|
||||||
return tc.ExecuteReader("Select * From Employee");
|
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)
|
internal static IDataReader GetAllEmps(TransactionContext tc, int payrollTypeid)
|
||||||
{
|
{
|
||||||
return tc.ExecuteReader("Select * From Employee where payrolltypeid=%n", payrollTypeid);
|
return tc.ExecuteReader("Select * From Employee where payrolltypeid=%n", payrollTypeid);
|
||||||
|
|
|
||||||
|
|
@ -484,6 +484,22 @@ namespace HRM.DA
|
||||||
return tc.ExecuteReader("SELECT * from LeaveEntry where LeaveYear=%n", nLeaveYear);
|
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,
|
public static IDataReader Get(TransactionContext tc, string empIds, DateTime fromDate, DateTime toDate,
|
||||||
string LeaveStatus)
|
string LeaveStatus)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3563,11 +3563,44 @@ namespace HRM.DA
|
||||||
|
|
||||||
try
|
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;
|
int nRowCount = 1;
|
||||||
LeaveEntry leaveEntry = new LeaveEntry();
|
LeaveEntry leaveEntry = new LeaveEntry();
|
||||||
List<LeaveEntry> leaveEntrys = new List<LeaveEntry>();
|
List<LeaveEntry> leaveEntrys = new List<LeaveEntry>();
|
||||||
List<Employee> employees = new EmployeeService().GetAllEmps();
|
|
||||||
List<Leave> oLeaves = new LeaveService().GetAll();
|
List<Leave> oLeaves = new LeaveService().GetAll();
|
||||||
List<LeaveParameter> oLeaveParameters = new List<LeaveParameter>();
|
List<LeaveParameter> oLeaveParameters = new List<LeaveParameter>();
|
||||||
List<LeaveParameter> leaveParamss = new LeaveParameterService().Get(EnumStatus.Regardless, _columnDefinition.PayrollTypeID, null, true);
|
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();
|
string remarks = dr[5].ToString().Trim();
|
||||||
|
|
||||||
//Leave leave = leaves.Find(delegate(Payroll.BO.Leave le) { return le.Code == leaveCode; });
|
//Leave leave = leaves.Find(delegate(Payroll.BO.Leave le) { return le.Code == leaveCode; });
|
||||||
Employee employee = employees.Find(delegate (Employee em) { return em.EmployeeNo == Empcode; });
|
Employee employee;
|
||||||
if (employee == null)
|
if (!employeesByNo.TryGetValue(Empcode, out employee))
|
||||||
{
|
{
|
||||||
_errorOrSuccessList.Add(UploadErrorOrSuccess.Create("Employee No", nRow, "(" + dr["Employee No"].ToString() + ")" + "Employee Not found"));
|
_errorOrSuccessList.Add(UploadErrorOrSuccess.Create("Employee No", nRow, "(" + dr["Employee No"].ToString() + ")" + "Employee Not found"));
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -3610,12 +3643,13 @@ namespace HRM.DA
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<LeaveEntry> tempLS = leaveentries.Where(x => x.AppliedFromDate >= fromDate
|
List<LeaveEntry> empLeaveEntries;
|
||||||
|
bool hasOverlap = leaveEntriesByEmp.TryGetValue(employee.ID, out empLeaveEntries)
|
||||||
|
&& empLeaveEntries.Any(x => x.AppliedFromDate >= fromDate
|
||||||
&& x.AppliedFromDate <= toDate
|
&& x.AppliedFromDate <= toDate
|
||||||
&& x.EmpID == employee.ID
|
|
||||||
&& x.LeaveStatus != EnumLeaveStatus.Declined
|
&& x.LeaveStatus != EnumLeaveStatus.Declined
|
||||||
&& x.LeaveStatus != EnumLeaveStatus.Cancel).ToList();
|
&& x.LeaveStatus != EnumLeaveStatus.Cancel);
|
||||||
if (tempLS.Count > 0)
|
if (hasOverlap)
|
||||||
{
|
{
|
||||||
_errorOrSuccessList.Add(UploadErrorOrSuccess.Create("Employee No", nRow, "(" + dr["Employee No"].ToString() + ")" + " Overlap with previously applied leaves."));
|
_errorOrSuccessList.Add(UploadErrorOrSuccess.Create("Employee No", nRow, "(" + dr["Employee No"].ToString() + ")" + " Overlap with previously applied leaves."));
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -1194,6 +1194,51 @@ namespace HRM.DA
|
||||||
return employees;
|
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)
|
public List<Employee> GetAllEmps(int payrollTypeid)
|
||||||
{
|
{
|
||||||
List<Employee> employees = new List<Employee>();
|
List<Employee> employees = new List<Employee>();
|
||||||
|
|
|
||||||
|
|
@ -713,6 +713,52 @@ namespace HRM.DA
|
||||||
return oLeaveEntrys;
|
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)
|
public List<LeaveEntry> Get(string empIds, DateTime fromDate, DateTime toDate, string leaveStatuss)
|
||||||
{
|
{
|
||||||
List<LeaveEntry> oLeaveEntrys = null;
|
List<LeaveEntry> oLeaveEntrys = null;
|
||||||
|
|
|
||||||
|
|
@ -47,15 +47,15 @@
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
</kendo-grid-column>
|
</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">
|
<ng-template kendoGridCellTemplate let-dataItem let-rowIndex="rowIndex">
|
||||||
{{dataItem.appliedFromDate | date: 'dd-MMM-yyyy'}}
|
{{dataItem.approvedFromDate | date: 'dd-MMM-yyyy'}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
</kendo-grid-column>
|
</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">
|
<ng-template kendoGridCellTemplate let-dataItem let-rowIndex="rowIndex">
|
||||||
{{dataItem.appliedToDate | date: 'dd-MMM-yyyy'}}
|
{{dataItem.approvedToDate | date: 'dd-MMM-yyyy'}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
<kendo-grid-column field="isLFA" title="LFA" width="80">
|
<kendo-grid-column field="isLFA" title="LFA" width="80">
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,9 @@ export class LeaveListByAdminComponent implements OnInit {
|
||||||
this._leaveEntry.appliedFromDate = new Date(this._leaveEntry.appliedFromDate);
|
this._leaveEntry.appliedFromDate = new Date(this._leaveEntry.appliedFromDate);
|
||||||
this._leaveEntry.appliedToDate = new Date(this._leaveEntry.appliedToDate);
|
this._leaveEntry.appliedToDate = new Date(this._leaveEntry.appliedToDate);
|
||||||
this._leaveEntry.appliedLeaveDate = new Date(this._leaveEntry.appliedLeaveDate);
|
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.showModal = true;
|
||||||
this.isNew = false;
|
this.isNew = false;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user