diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index eee93bd..a02f7a8 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -2,8 +2,11 @@ using Marco.Pms.Model.Dtos.Attendance; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Expenses; +using Marco.Pms.Model.OrganizationModel; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.DashBoard; +using Marco.Pms.Model.ViewModels.Organization; +using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Service; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; @@ -20,11 +23,11 @@ namespace Marco.Pms.Services.Controllers [ApiController] public class DashboardController : ControllerBase { + private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly IProjectServices _projectServices; private readonly ILoggingService _logger; - private readonly PermissionServices _permissionServices; private readonly IServiceScopeFactory _serviceScopeFactory; public static readonly Guid ActiveId = Guid.Parse("b74da4c2-d07e-46f2-9919-e75e49b12731"); private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8"); @@ -40,16 +43,17 @@ namespace Marco.Pms.Services.Controllers IProjectServices projectServices, IServiceScopeFactory serviceScopeFactory, ILoggingService logger, - PermissionServices permissionServices) + IDbContextFactory dbContextFactory) { _context = context; _userHelper = userHelper; _projectServices = projectServices; _logger = logger; _serviceScopeFactory = serviceScopeFactory; - _permissionServices = permissionServices; tenantId = userHelper.GetTenantId(); + _dbContextFactory = dbContextFactory; } + /// /// Fetches project progression data (planned and completed tasks) in graph form for a tenant and specified (or all) projects over a date range. /// @@ -254,8 +258,10 @@ namespace Marco.Pms.Services.Controllers if (projectId.HasValue) { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); // Security Check: Ensure the requested project is in the user's accessible list. - var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value); + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId} (not active or not accessible).", loggedInEmployee.Id, projectId.Value); @@ -340,9 +346,11 @@ namespace Marco.Pms.Services.Controllers if (projectId.HasValue) { // --- Logic for a SINGLE Project --- + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); // 2a. Security Check: Verify permission for the specific project. - var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value); + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId.Value); @@ -669,7 +677,11 @@ namespace Marco.Pms.Services.Controllers // Step 3: Check if logged-in employee has permission for this project var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - bool hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee!, projectId); + + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + + bool hasPermission = await _permission.HasProjectPermission(loggedInEmployee!, projectId); if (!hasPermission) { _logger.LogWarning("Unauthorized access by EmployeeId: {EmployeeId} to ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); @@ -747,7 +759,6 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(sortedResult, $"{sortedResult.Count} records fetched for attendance overview", 200)); } - [HttpGet("expense/monthly")] public async Task GetExpenseReportByProjectsAsync([FromQuery] Guid? projectId, [FromQuery] Guid? categoryId, [FromQuery] int months) { @@ -1074,5 +1085,274 @@ namespace Marco.Pms.Services.Controllers ApiResponse.ErrorResponse("An error occurred while fetching pending expenses.", "An error occurred while fetching pending expenses.", 500)); // [Error Response] } } + + [HttpGet("collection/overview")] + + /// + /// Returns a high-level collection overview (aging buckets, due vs collected, top client) + /// for invoices of the current tenant, optionally filtered by project. + /// + /// Optional project identifier to filter invoices. + /// Standardized API response with collection KPIs. + [HttpGet("collection-overview")] + public async Task GetCollectionOverviewAsync([FromQuery] Guid? projectId) + { + // Correlation ID pattern for distributed tracing (if you use one) + var correlationId = HttpContext.TraceIdentifier; + + _logger.LogInfo("Started GetCollectionOverviewAsync. CorrelationId: {CorrelationId}, ProjectId: {ProjectId}", correlationId, projectId ?? Guid.Empty); + + try + { + // Validate and identify current employee/tenant context + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Base invoice query for this tenant; AsNoTracking for read-only performance [web:1][web:5] + var invoiceQuery = _context.Invoices + .Where(i => i.TenantId == tenantId && i.IsActive) + .Include(i => i.BilledTo) + .AsNoTracking(); + + // Fetch infra and service projects in parallel using factory-created contexts + // NOTE: Avoid Task.Run over async IO where possible. Here each uses its own context instance. [web:6][web:15] + var infraProjectTask = GetInfraProjectsAsync(tenantId); + var serviceProjectTask = GetServiceProjectsAsync(tenantId); + + await Task.WhenAll(infraProjectTask, serviceProjectTask); + + var projects = infraProjectTask.Result + .Union(serviceProjectTask.Result) + .ToList(); + + // Optional project filter: validate existence in cached list first + if (projectId.HasValue) + { + var project = projects.FirstOrDefault(p => p.Id == projectId.Value); + if (project == null) + { + _logger.LogWarning( + "Project {ProjectId} not found for tenant {TenantId} in GetCollectionOverviewAsync. CorrelationId: {CorrelationId}", + projectId, tenantId, correlationId); + + return StatusCode( + StatusCodes.Status404NotFound, + ApiResponse.ErrorResponse( + "Project Not Found", + "The requested project does not exist or is not associated with the current tenant.", + StatusCodes.Status404NotFound)); + } + + invoiceQuery = invoiceQuery.Where(i => i.ProjectId == projectId.Value); + } + + var invoices = await invoiceQuery.ToListAsync(); + + if (invoices.Count == 0) + { + _logger.LogInfo( + "No invoices found for tenant {TenantId} in GetCollectionOverviewAsync. CorrelationId: {CorrelationId}", + tenantId, correlationId); + + // Return an empty but valid overview instead of 404 – endpoint is conceptually valid + var emptyResponse = new + { + TotalDueAmount = 0d, + TotalCollectedAmount = 0d, + TotalValue = 0d, + PendingPercentage = 0d, + CollectedPercentage = 0d, + Bucket0To30Invoices = 0, + Bucket30To60Invoices = 0, + Bucket60To90Invoices = 0, + Bucket90PlusInvoices = 0, + Bucket0To30Amount = 0d, + Bucket30To60Amount = 0d, + Bucket60To90Amount = 0d, + Bucket90PlusAmount = 0d, + TopClientBalance = 0d, + TopClient = new BasicOrganizationVm() + }; + + return Ok(ApiResponse.SuccessResponse(emptyResponse, "No invoices found for the current tenant and filters; returning empty collection overview.", 200)); + } + + var invoiceIds = invoices.Select(i => i.Id).ToList(); + + // Pre-aggregate payments per invoice in the DB where possible [web:1][web:17] + var paymentGroups = await _context.ReceivedInvoicePayments + .AsNoTracking() + .Where(p => invoiceIds.Contains(p.InvoiceId) && p.TenantId == tenantId) + .GroupBy(p => p.InvoiceId) + .Select(g => new + { + InvoiceId = g.Key, + PaidAmount = g.Sum(p => p.Amount) + }) + .ToListAsync(); + + // Create a lookup to avoid repeated LINQ Where on each iteration + var paymentsLookup = paymentGroups.ToDictionary(p => p.InvoiceId, p => p.PaidAmount); + + double totalDueAmount = 0; + var today = DateTime.UtcNow.Date; // use UTC for consistency [web:17] + + var bucketOneInvoices = 0; + double bucketOneAmount = 0; + var bucketTwoInvoices = 0; + double bucketTwoAmount = 0; + var bucketThreeInvoices = 0; + double bucketThreeAmount = 0; + var bucketFourInvoices = 0; + double bucketFourAmount = 0; + + // Main aging calculation loop + foreach (var invoice in invoices) + { + var total = invoice.BasicAmount + invoice.TaxAmount; + var paid = paymentsLookup.TryGetValue(invoice.Id, out var paidAmount) + ? paidAmount + : 0d; + var balance = total - paid; + + // Skip fully paid or explicitly completed invoices + if (balance <= 0 || invoice.MarkAsCompleted) + continue; + + totalDueAmount += balance; + + // Only consider invoices with expected payment date up to today for aging + var expectedDate = invoice.ExceptedPaymentDate.Date; + if (expectedDate > today) + continue; + + var days = (today - expectedDate).Days; + + if (days <= 30 && days > 0) + { + bucketOneInvoices++; + bucketOneAmount += balance; + } + else if (days > 30 && days <= 60) + { + bucketTwoInvoices++; + bucketTwoAmount += balance; + } + else if (days > 60 && days <= 90) + { + bucketThreeInvoices++; + bucketThreeAmount += balance; + } + else if (days > 90) + { + bucketFourInvoices++; + bucketFourAmount += balance; + } + } + + var totalCollectedAmount = paymentGroups.Sum(p => p.PaidAmount); + var totalValue = totalDueAmount + totalCollectedAmount; + var pendingPercentage = totalValue > 0 ? (totalDueAmount / totalValue) * 100 : 0; + var collectedPercentage = totalValue > 0 ? (totalCollectedAmount / totalValue) * 100 : 0; + + // Determine top client by outstanding balance + double topClientBalance = 0; + Organization topClient = new Organization(); + + var groupedByClient = invoices + .Where(i => i.BilledToId.HasValue && i.BilledTo != null) + .GroupBy(i => i.BilledToId); + + foreach (var group in groupedByClient) + { + var clientInvoiceIds = group.Select(i => i.Id).ToList(); + var totalForClient = group.Sum(i => i.BasicAmount + i.TaxAmount); + var paidForClient = paymentGroups + .Where(pg => clientInvoiceIds.Contains(pg.InvoiceId)) + .Sum(pg => pg.PaidAmount); + + var clientBalance = totalForClient - paidForClient; + if (clientBalance > topClientBalance) + { + topClientBalance = clientBalance; + topClient = group.First()!.BilledTo!; + } + } + + BasicOrganizationVm topClientVm = new BasicOrganizationVm(); + if (topClient != null) + { + topClientVm = new BasicOrganizationVm + { + Id = topClient.Id, + Name = topClient.Name, + Email = topClient.Email, + ContactPerson = topClient.ContactPerson, + ContactNumber = topClient.ContactNumber, + Address = topClient.Address, + GSTNumber = topClient.GSTNumber, + SPRID = topClient.SPRID + }; + } + + var response = new + { + TotalDueAmount = totalDueAmount, + TotalCollectedAmount = totalCollectedAmount, + TotalValue = totalValue, + PendingPercentage = Math.Round(pendingPercentage, 2), + CollectedPercentage = Math.Round(collectedPercentage, 2), + Bucket0To30Invoices = bucketOneInvoices, + Bucket30To60Invoices = bucketTwoInvoices, + Bucket60To90Invoices = bucketThreeInvoices, + Bucket90PlusInvoices = bucketFourInvoices, + Bucket0To30Amount = bucketOneAmount, + Bucket30To60Amount = bucketTwoAmount, + Bucket60To90Amount = bucketThreeAmount, + Bucket90PlusAmount = bucketFourAmount, + TopClientBalance = topClientBalance, + TopClient = topClientVm + }; + + _logger.LogInfo("Successfully completed GetCollectionOverviewAsync for tenant {TenantId}. CorrelationId: {CorrelationId}, TotalInvoices: {InvoiceCount}, TotalValue: {TotalValue}", + tenantId, correlationId, invoices.Count, totalValue); + + return Ok(ApiResponse.SuccessResponse(response, "Collection overview fetched successfully.", 200)); + } + catch (Exception ex) + { + // Centralized logging for unhandled exceptions with context, no sensitive data [web:1][web:5][web:10] + _logger.LogError(ex, "Unhandled exception in GetCollectionOverviewAsync. CorrelationId: {CorrelationId}", correlationId); + + // Generic but consistent error payload; let global exception handler standardize if you use ProblemDetails [web:10][web:13][web:16] + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", + "An unexpected error occurred while generating the collection overview. Please try again or contact support with the correlation identifier.", 500)); + } + } + + /// + /// Gets infrastructure projects for a tenant as a lightweight view model. + /// + private async Task> GetInfraProjectsAsync(Guid tenantId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.Projects + .AsNoTracking() + .Where(p => p.TenantId == tenantId) + .Select(p => new BasicProjectVM { Id = p.Id, Name = p.Name }) + .ToListAsync(); + } + + /// + /// Gets service projects for a tenant as a lightweight view model. + /// + private async Task> GetServiceProjectsAsync(Guid tenantId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.ServiceProjects + .AsNoTracking() + .Where(sp => sp.TenantId == tenantId) + .Select(sp => new BasicProjectVM { Id = sp.Id, Name = sp.Name }) + .ToListAsync(); + } } }