diff --git a/Marco.Pms.Model/ViewModels/ServiceProject/ServiceProjectVm.cs b/Marco.Pms.Model/ViewModels/ServiceProject/ServiceProjectVm.cs index dea5921..136bedc 100644 --- a/Marco.Pms.Model/ViewModels/ServiceProject/ServiceProjectVm.cs +++ b/Marco.Pms.Model/ViewModels/ServiceProject/ServiceProjectVm.cs @@ -15,6 +15,10 @@ namespace Marco.Pms.Model.ViewModels.ServiceProject public StatusMaster? Status { get; set; } public BasicOrganizationVm? Client { get; set; } public List? Services { get; set; } + public int TeamMemberCount { get; set; } + public int ActiveJobsCount { get; set; } + public int JobsPassedDueDateCount { get; set; } + public int JobMembersCount { get; set; } public string? ContactName { get; set; } public string? ContactPhone { get; set; } public string? ContactEmail { get; set; } diff --git a/Marco.Pms.Services/Controllers/ServiceProjectController.cs b/Marco.Pms.Services/Controllers/ServiceProjectController.cs index 2c538b5..d3f337b 100644 --- a/Marco.Pms.Services/Controllers/ServiceProjectController.cs +++ b/Marco.Pms.Services/Controllers/ServiceProjectController.cs @@ -36,10 +36,10 @@ namespace Marco.Pms.Services.Controllers #region =================================================================== Service Project Functions =================================================================== [HttpGet("list")] - public async Task GetServiceProjectList([FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20) + public async Task GetServiceProjectList([FromQuery] string? searchString, [FromQuery] int pageNumber = 1, [FromQuery] int pageSize = 20) { Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var response = await _serviceProject.GetServiceProjectListAsync(pageNumber, pageSize, loggedInEmployee, tenantId); + var response = await _serviceProject.GetServiceProjectListAsync(searchString, pageNumber, pageSize, loggedInEmployee, tenantId); return StatusCode(response.StatusCode, response); } @@ -120,6 +120,10 @@ namespace Marco.Pms.Services.Controllers } #endregion + #region =================================================================== Service Project Talking Points Functions =================================================================== + + #endregion + #region =================================================================== Job Tickets Functions =================================================================== [HttpGet("job/list")] diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs index 6e001d4..3dc53d7 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IServiceProject.cs @@ -8,7 +8,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces public interface IServiceProject { #region =================================================================== Service Project Functions =================================================================== - Task> GetServiceProjectListAsync(int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId); + Task> GetServiceProjectListAsync(string? searchString, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId); Task> GetServiceProjectDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId); Task> CreateServiceProjectAsync(ServiceProjectDto serviceProject, Employee loggedInEmployee, Guid TenantId); Task> UpdateServiceProjectAsync(Guid id, ServiceProjectDto serviceProject, Employee loggedInEmployee, Guid tenantId); @@ -20,6 +20,10 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> ManageServiceProjectAllocationAsync(List model, Employee loggedInEmployee, Guid tenantId); #endregion + #region =================================================================== Service Project Talking Points Functions =================================================================== + + #endregion + #region =================================================================== Job Tickets Functions =================================================================== Task> GetJobTicketsListAsync(Guid? projectId, int pageNumber, int pageSize, bool isActive, Employee loggedInEmployee, Guid tenantId); Task> GetJobTicketDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId); diff --git a/Marco.Pms.Services/Service/ServiceProjectService.cs b/Marco.Pms.Services/Service/ServiceProjectService.cs index 1837101..5994f2f 100644 --- a/Marco.Pms.Services/Service/ServiceProjectService.cs +++ b/Marco.Pms.Services/Service/ServiceProjectService.cs @@ -33,6 +33,11 @@ namespace Marco.Pms.Services.Service private readonly Guid NewStatus = Guid.Parse("32d76a02-8f44-4aa0-9b66-c3716c45a918"); private readonly Guid AssignedStatus = Guid.Parse("cfa1886d-055f-4ded-84c6-42a2a8a14a66"); + private readonly Guid InProgressStatus = Guid.Parse("5a6873a5-fed7-4745-a52f-8f61bf3bd72d"); + private readonly Guid ReviewStatus = Guid.Parse("aab71020-2fb8-44d9-9430-c9a7e9bf33b0"); + private readonly Guid DoneStatus = Guid.Parse("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7"); + private readonly Guid ClosedStatus = Guid.Parse("3ddeefb5-ae3c-4e10-a922-35e0a452bb69"); + private readonly Guid OnHoldStatus = Guid.Parse("75a0c8b8-9c6a-41af-80bf-b35bab722eb2"); public ServiceProjectService(IDbContextFactory dbContextFactory, IServiceScopeFactory serviceScopeFactory, @@ -51,19 +56,21 @@ namespace Marco.Pms.Services.Service #region =================================================================== Service Project Functions =================================================================== + /// - /// Retrieves a paginated list of active service projects including their clients, status, creators, and related services for a given tenant. + /// Retrieves a paginated list of active service projects for a tenant, including related services, job counts, and team member information. /// - /// Page number (1-based) for pagination. - /// Number of records per page. - /// Employee making the request (for logging). - /// Tenant identifier for multi-tenant isolation. - /// ApiResponse containing paged service projects with related data or error information. - public async Task> GetServiceProjectListAsync(int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId) + /// Optional search string to filter projects by name. + /// The page number starting from 1. + /// The number of items per page. + /// Currently authenticated employee making the request. + /// Tenant unique identifier for multi-tenant data isolation. + /// Returns an ApiResponse containing paginated projects data or error details. + public async Task> GetServiceProjectListAsync(string? searchString, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId) { if (tenantId == Guid.Empty) { - _logger.LogWarning("GetServiceProjectListAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id); + _logger.LogWarning("Invalid tenant context in GetServiceProjectListAsync invoked by EmployeeId {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Access Denied", "Invalid tenant context.", 403); } @@ -75,17 +82,26 @@ namespace Marco.Pms.Services.Service try { - // Base query for active projects scoped by tenant including necessary related entities + // Base query for active service projects with tenant isolation and necessary eager loading. var serviceProjectQuery = _context.ServiceProjects .Include(sp => sp.Client) .Include(sp => sp.Status) .Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole) .Where(sp => sp.TenantId == tenantId && sp.IsActive); + // Apply search filter if provided (case-insensitive) + if (!string.IsNullOrWhiteSpace(searchString)) + { + var normalizedSearch = searchString.Trim().ToLowerInvariant(); + serviceProjectQuery = serviceProjectQuery + .Where(sp => sp.Name.ToLower().Contains(normalizedSearch)); + } + + // Calculate total count and pages for pagination metadata var totalEntities = await serviceProjectQuery.CountAsync(); var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize); - // Fetch paged projects ordered by creation date descending + // Fetch projects for the requested page with ordering by newest var serviceProjects = await serviceProjectQuery .OrderByDescending(sp => sp.CreatedAt) .Skip((pageNumber - 1) * pageSize) @@ -94,15 +110,64 @@ namespace Marco.Pms.Services.Service var serviceProjectIds = serviceProjects.Select(sp => sp.Id).ToList(); - // Load related service mappings with services for current page projects (avoid N+1) + // Load related services in a single query to prevent N+1 issue var serviceProjectServiceMappings = await _context.ServiceProjectServiceMapping .Include(sps => sps.Service) - .Where(sps => serviceProjectIds.Contains(sps.ProjectId) && - sps.Service != null && - sps.TenantId == tenantId) + .Where(sps => serviceProjectIds.Contains(sps.ProjectId) && sps.Service != null && sps.TenantId == tenantId) .ToListAsync(); - // Map each project with its related services into the view models + // Execute related aggregate counts in parallel with separate contexts and async queries + var jobTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.JobTickets + .Where(jt => serviceProjectIds.Contains(jt.ProjectId) && jt.TenantId == tenantId && jt.IsActive) + .GroupBy(jt => jt.ProjectId) + .Select(g => new + { + ProjectId = g.Key, + JobsPassedDueDateCount = g.Count(jt => jt.DueDate.Date < DateTime.UtcNow.Date), + ActiveJobsCount = g.Count(jt => jt.StatusId == AssignedStatus || jt.StatusId == InProgressStatus || jt.StatusId == ReviewStatus) + }) + .ToListAsync(); + }); + + var teamMemberTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.ServiceProjectAllocations + .Where(spa => serviceProjectIds.Contains(spa.ProjectId) && spa.TenantId == tenantId && spa.IsActive) + .GroupBy(spa => spa.ProjectId) + .Select(g => new + { + ProjectId = g.Key, + TeamMemberCount = g.Select(spa => spa.EmployeeId).Distinct().Count() + }) + .ToListAsync(); + }); + + var jobMembersTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.JobEmployeeMappings + .Include(jem => jem.JobTicket) + .Where(jem => jem.JobTicket != null && serviceProjectIds.Contains(jem.JobTicket.ProjectId) && jem.TenantId == tenantId) + .GroupBy(jem => jem.JobTicket!.ProjectId) + .Select(g => new + { + ProjectId = g.Key, + JobMembersCount = g.Select(jem => jem.AssigneeId).Distinct().Count() + }) + .ToListAsync(); + }); + + await Task.WhenAll(jobTask, jobMembersTask, teamMemberTask); + + var jobTickets = jobTask.Result; + var jobMembers = jobMembersTask.Result; + var teamMembers = teamMemberTask.Result; + + // Map the service projects into view models including related data var serviceProjectVMs = serviceProjects.Select(sp => { var relatedServices = serviceProjectServiceMappings @@ -112,6 +177,10 @@ namespace Marco.Pms.Services.Service var projectVm = _mapper.Map(sp); projectVm.Services = _mapper.Map>(relatedServices); + projectVm.TeamMemberCount = teamMembers.FirstOrDefault(tm => tm.ProjectId == sp.Id)?.TeamMemberCount ?? 0; + projectVm.ActiveJobsCount = jobTickets.FirstOrDefault(jt => jt.ProjectId == sp.Id)?.ActiveJobsCount ?? 0; + projectVm.JobsPassedDueDateCount = jobTickets.FirstOrDefault(jt => jt.ProjectId == sp.Id)?.JobsPassedDueDateCount ?? 0; + projectVm.JobMembersCount = jobMembers.FirstOrDefault(jm => jm.ProjectId == sp.Id)?.JobMembersCount ?? 0; return projectVm; }).ToList(); @@ -120,22 +189,26 @@ namespace Marco.Pms.Services.Service CurrentPage = pageNumber, TotalPages = totalPages, TotalEntities = totalEntities, - Data = serviceProjectVMs, + Data = serviceProjectVMs }; - _logger.LogInfo("Retrieved {Count} service projects for tenant {TenantId} by employee {EmployeeId}. Page {PageNumber}/{TotalPages}", + _logger.LogInfo("Returned {Count} service projects for tenant {TenantId} requested by EmployeeId {EmployeeId} (Page {PageNumber}/{TotalPages})", serviceProjectVMs.Count, tenantId, loggedInEmployee.Id, pageNumber, totalPages); return ApiResponse.SuccessResponse(response, "Projects retrieved successfully.", 200); } catch (Exception ex) { - _logger.LogError(ex, "An unexpected error occurred in GetServiceProjectListAsync for tenant {TenantId} by employee {EmployeeId}", + _logger.LogError(ex, "Error in GetServiceProjectListAsync for tenant {TenantId} invoked by EmployeeId {EmployeeId}", tenantId, loggedInEmployee.Id); - return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); + return ApiResponse.ErrorResponse( + "Internal Server Error", + "An unexpected error occurred while retrieving projects. Please try again later.", + 500); } } + /// /// Retrieves detailed information for a specific service project, including related client, status, services, and audit information. ///