From 9c5df63134fad44bebd951a5a4b15a5935ffd8eb Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Wed, 10 Dec 2025 10:42:05 +0530 Subject: [PATCH] Added an API to get the attendance overview project-wise --- .../DashBoard/ProjectAttendanceOverviewVM.cs | 10 ++ .../Controllers/DashboardController.cs | 117 ++++++++++++++++++ Marco.Pms.Services/Service/ExpensesService.cs | 5 + Marco.Pms.Services/Service/ProjectServices.cs | 6 +- 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 Marco.Pms.Model/ViewModels/DashBoard/ProjectAttendanceOverviewVM.cs diff --git a/Marco.Pms.Model/ViewModels/DashBoard/ProjectAttendanceOverviewVM.cs b/Marco.Pms.Model/ViewModels/DashBoard/ProjectAttendanceOverviewVM.cs new file mode 100644 index 0000000..3a9d65a --- /dev/null +++ b/Marco.Pms.Model/ViewModels/DashBoard/ProjectAttendanceOverviewVM.cs @@ -0,0 +1,10 @@ +namespace Marco.Pms.Model.ViewModels.DashBoard +{ + public class ProjectAttendanceOverviewVM + { + public Guid ProjectId { get; set; } + public string? ProjectName { get; set; } + public int TeamCount { get; set; } + public int AttendanceCount { get; set; } + } +} diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index 905059d..e6932aa 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -1707,5 +1707,122 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(response, "job progression fetched successfully", 200)); } + + [HttpGet("project/attendance-overview")] + public async Task GetProjectAttendanceOverViewAsync([FromQuery] DateTime? date, CancellationToken cancellationToken) + { + // 1. Validation and Setup + if (tenantId == Guid.Empty) + { + _logger.LogWarning("GetProjectAttendanceOverView: Invalid request - TenantId is empty."); + return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); + } + + // Default to UTC Today if null, ensuring only Date component is used + var targetDate = date?.Date ?? DateTime.UtcNow.Date; + + _logger.LogInfo("GetProjectAttendanceOverView: Starting fetch for Tenant {TenantId} on Date {Date}", tenantId, targetDate); + + try + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + if (loggedInEmployee == null) + { + _logger.LogWarning("GetProjectAttendanceOverView: Employee not found for current user."); + return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "Employee profile not found.", 401)); + } + + // 2. Permission Check + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, loggedInEmployee.Id); + + // 3. Determine Scope of Projects (Filtering Project IDs) + // We select only the IDs first to keep the memory footprint low before aggregation + var projectQuery = _context.ProjectAllocations + .AsNoTracking() + .Where(pa => pa.TenantId == tenantId && pa.IsActive); + + if (!hasPermission) + { + // If no admin permission, restrict to projects the employee is allocated to + projectQuery = projectQuery.Where(pa => pa.EmployeeId == loggedInEmployee.Id); + } + + var visibleProjectIds = await projectQuery + .Select(pa => pa.ProjectId) + .Distinct() + .ToListAsync(cancellationToken); + + if (!visibleProjectIds.Any()) + { + return Ok(ApiResponse>.SuccessResponse(new List(), "No projects found.", 200)); + } + + // 4. Parallel Data Fetching (Optimization) + // We fetch Project Details/Allocations AND Attendance counts separately to avoid complex Cartesian products in SQL + + // Query A: Get Project Details and Total Allocation Counts + var projectsTask = _context.ProjectAllocations + .AsNoTracking() + .Where(pa => pa.TenantId == tenantId && + pa.IsActive && + visibleProjectIds.Contains(pa.ProjectId) && + pa.Project != null) + .GroupBy(pa => new { pa.ProjectId, pa.Project!.Name }) + .Select(g => new + { + ProjectId = g.Key.ProjectId, + Name = g.Key.Name, + TeamCount = g.Count() + }) + .ToListAsync(cancellationToken); + + // Query B: Get Attendance Counts for the specific date + await using var context = await _dbContextFactory.CreateDbContextAsync(); + var attendanceTask = context.Attendes + .AsNoTracking() + .Where(a => a.TenantId == tenantId && + visibleProjectIds.Contains(a.ProjectID) && + a.AttendanceDate.Date == targetDate) + .GroupBy(a => a.ProjectID) + .Select(g => new + { + ProjectId = g.Key, + Count = g.Count() + }) + .ToDictionaryAsync(k => k.ProjectId, v => v.Count, cancellationToken); + + await Task.WhenAll(projectsTask, attendanceTask); + + var projects = await projectsTask; + var attendanceMap = await attendanceTask; + + // 5. In-Memory Projection + // Merging the two datasets efficiently + var response = projects.Select(p => new ProjectAttendanceOverviewVM + { + ProjectId = p.ProjectId, + ProjectName = p.Name, + TeamCount = p.TeamCount, + // O(1) Lookup from the dictionary + AttendanceCount = attendanceMap.ContainsKey(p.ProjectId) ? attendanceMap[p.ProjectId] : 0 + }) + .OrderBy(p => p.ProjectName) + .ToList(); + + _logger.LogInfo("GetProjectAttendanceOverView: Successfully fetched {Count} projects for Tenant {TenantId}", response.Count, tenantId); + + return Ok(ApiResponse>.SuccessResponse(response, "Attendance overview fetched successfully", 200)); + } + catch (Exception ex) + { + _logger.LogError(ex, "GetProjectAttendanceOverView: An unexpected error occurred for Tenant {TenantId}", tenantId); + // Do not expose raw Exception details to client in production + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while processing your request.", 500)); + } + } + } } diff --git a/Marco.Pms.Services/Service/ExpensesService.cs b/Marco.Pms.Services/Service/ExpensesService.cs index 3f988f8..0242c9b 100644 --- a/Marco.Pms.Services/Service/ExpensesService.cs +++ b/Marco.Pms.Services/Service/ExpensesService.cs @@ -153,6 +153,11 @@ namespace Marco.Pms.Services.Service .Include(e => e.Currency) .Where(e => e.TenantId == tenantId); // Always filter by TenantId first. + //using var scope = _serviceScopeFactory.CreateScope(); + //var _projectServices = scope.ServiceProvider.GetRequiredService(); + + //var allprojectIds = await _projectServices.GetBothProjectIdsAsync(loggedInEmployee.Id, tenantId); + if (cacheList == null) { //await _cache.AddExpensesListToCache(expenses: await expensesQuery.ToListAsync(), tenantId); diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 48e2cc1..760cc6b 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -304,7 +304,9 @@ namespace Marco.Pms.Services.Service responseVms = responseVms .OrderBy(p => p.Name) .Skip((pageNumber - 1) * pageSize) - .Take(pageSize).ToList(); + .Take(pageSize) + .OrderBy(p => p.ShortName) + .ToList(); // --- Step 4: Return the combined result --- @@ -3267,7 +3269,7 @@ namespace Marco.Pms.Services.Service } } - return finalViewModels; + return finalViewModels.OrderBy(p => p.Name).ToList(); } private async Task GetProjectViewModel(Guid? id, Project project) {