diff --git a/Marco.Pms.Model/Filters/AdvanceFilter.cs b/Marco.Pms.Model/Filters/AdvanceFilter.cs new file mode 100644 index 0000000..3b376a4 --- /dev/null +++ b/Marco.Pms.Model/Filters/AdvanceFilter.cs @@ -0,0 +1,11 @@ +namespace Marco.Pms.Model.Filters +{ + public class AdvanceFilter + { + // The dynamic filters from your JSON + public List? SortFilters { get; set; } + public List? SearchFilters { get; set; } + public List? AdvanceFilters { get; set; } + public string GroupByColumn { get; set; } = string.Empty; + } +} diff --git a/Marco.Pms.Model/Filters/AdvanceItem.cs b/Marco.Pms.Model/Filters/AdvanceItem.cs new file mode 100644 index 0000000..33d6532 --- /dev/null +++ b/Marco.Pms.Model/Filters/AdvanceItem.cs @@ -0,0 +1,9 @@ +namespace Marco.Pms.Model.Filters +{ + public class AdvanceItem + { + public string Column { get; set; } = string.Empty; + public string Opration { get; set; } = string.Empty; // "greater than", "equal to", etc. + public string Value { get; set; } = string.Empty; + } +} diff --git a/Marco.Pms.Model/Filters/SearchItem.cs b/Marco.Pms.Model/Filters/SearchItem.cs new file mode 100644 index 0000000..4aedb33 --- /dev/null +++ b/Marco.Pms.Model/Filters/SearchItem.cs @@ -0,0 +1,8 @@ +namespace Marco.Pms.Model.Filters +{ + public class SearchItem + { + public string Column { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + } +} diff --git a/Marco.Pms.Model/Filters/SortItem.cs b/Marco.Pms.Model/Filters/SortItem.cs new file mode 100644 index 0000000..9fb95cf --- /dev/null +++ b/Marco.Pms.Model/Filters/SortItem.cs @@ -0,0 +1,8 @@ +namespace Marco.Pms.Model.Filters +{ + public class SortItem + { + public string Column { get; set; } = string.Empty; + public bool SortDescending { get; set; } + } +} diff --git a/Marco.Pms.Services/Controllers/CollectionController.cs b/Marco.Pms.Services/Controllers/CollectionController.cs index 0b13983..b760ac4 100644 --- a/Marco.Pms.Services/Controllers/CollectionController.cs +++ b/Marco.Pms.Services/Controllers/CollectionController.cs @@ -4,6 +4,7 @@ using Marco.Pms.Helpers.Utility; using Marco.Pms.Model.Collection; using Marco.Pms.Model.Dtos.Collection; using Marco.Pms.Model.Entitlements; +using Marco.Pms.Model.Filters; using Marco.Pms.Model.MongoDBModels.Utility; using Marco.Pms.Model.OrganizationModel; using Marco.Pms.Model.Projects; @@ -13,6 +14,7 @@ using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Collection; using Marco.Pms.Model.ViewModels.Organization; using Marco.Pms.Model.ViewModels.Projects; +using Marco.Pms.Services.Extensions; using Marco.Pms.Services.Service; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; @@ -20,6 +22,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using MongoDB.Driver; +using System.Text.Json; using Document = Marco.Pms.Model.DocumentManager.Document; namespace Marco.Pms.Services.Controllers @@ -59,8 +62,8 @@ namespace Marco.Pms.Services.Controllers /// [HttpGet("invoice/list")] - public async Task GetInvoiceListAsync([FromQuery] Guid? projectId, [FromQuery] string? searchString, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate, - [FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false) + public async Task GetInvoiceListAsync([FromQuery] Guid? projectId, [FromQuery] string? searchString, [FromQuery] string? filter, [FromQuery] DateTime? fromDate, + [FromQuery] DateTime? toDate, [FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false) { try { @@ -94,16 +97,52 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Permissions validated for EmployeeId {EmployeeId}.", employee.Id); + var advanceFilter = TryDeserializeFilter(filter); + await using var _context = await _dbContextFactory.CreateDbContextAsync(); await using var context = await _dbContextFactory.CreateDbContextAsync(); + // Fetch related project data asynchronously and in parallel + var infraProjectsQuery = _context.Projects + .Where(p => p.TenantId == tenantId); + + var serviceProjectsQuery = context.ServiceProjects + .Where(sp => sp.TenantId == tenantId); + + if (advanceFilter?.SearchFilters != null && advanceFilter.SearchFilters.Any()) + { + var projectSearchFilter = advanceFilter.SearchFilters + .Where(f => f.Column == "ProjectName") + .Select(f => new SearchItem { Column = "Name", Value = f.Value }) + .ToList(); + if (projectSearchFilter.Any()) + { + infraProjectsQuery = infraProjectsQuery.ApplySearchFilters(projectSearchFilter); + serviceProjectsQuery = serviceProjectsQuery.ApplySearchFilters(projectSearchFilter); + } + } + + var infraProjectsTask = infraProjectsQuery + .Select(p => _mapper.Map(p)) + .ToListAsync(); + var serviceProjectsTask = serviceProjectsQuery + .Select(sp => _mapper.Map(sp)) + .ToListAsync(); + + await Task.WhenAll(infraProjectsTask, serviceProjectsTask); + + var projects = infraProjectsTask.Result; + projects.AddRange(serviceProjectsTask.Result); + + var projIds = projects.Select(p => p.Id).Distinct().ToList(); + // Build invoice query efficiently - always use AsNoTracking for reads var query = _context.Invoices .AsNoTracking() .Include(i => i.BilledTo) .Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole) .Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole) - .Where(i => i.IsActive == isActive && i.TenantId == tenantId); + .Where(i => projIds.Contains(i.ProjectId) && i.IsActive == isActive && i.TenantId == tenantId); // Filter by date, ensuring date boundaries are correct if (fromDate.HasValue && toDate.HasValue) @@ -126,11 +165,35 @@ namespace Marco.Pms.Services.Controllers _logger.LogDebug("Project filter applied: {ProjectId}", projectId.Value); } + if (advanceFilter != null) + { + query = query.ApplyCustomFilters(advanceFilter); + if (advanceFilter.SearchFilters != null) + { + var invoiceSearchFilter = advanceFilter.SearchFilters.Where(f => f.Column != "ProjectName").ToList(); + if (invoiceSearchFilter.Any()) + { + query = query.ApplySearchFilters(invoiceSearchFilter); + } + } + } + var hasSortFilters = advanceFilter?.SortFilters?.Any() ?? false; + if (!hasSortFilters) + { + query = query.OrderByDescending(i => i.InvoiceDate); + } + var totalItems = await query.CountAsync(); _logger.LogInfo("Total invoices found: {TotalItems}", totalItems); + string groupByColumn = "ProjectId"; + if (!string.IsNullOrWhiteSpace(advanceFilter?.GroupByColumn)) + { + groupByColumn = advanceFilter.GroupByColumn; + } + query = query.ApplyGroupByFilters(groupByColumn); + var pagedInvoices = await query - .OrderByDescending(i => i.InvoiceDate) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); @@ -155,20 +218,6 @@ namespace Marco.Pms.Services.Controllers _logger.LogDebug("Received payment data for {Count} invoices.", paymentGroups.Count); - // Fetch related project data asynchronously and in parallel - var projIds = pagedInvoices.Select(i => i.ProjectId).Distinct().ToList(); - var infraProjectsTask = _context.Projects - .Where(p => projIds.Contains(p.Id) && p.TenantId == tenantId) - .ToListAsync(); - var serviceProjectsTask = context.ServiceProjects - .Where(sp => projIds.Contains(sp.Id) && sp.TenantId == tenantId) - .ToListAsync(); - - await Task.WhenAll(infraProjectsTask, serviceProjectsTask); - - var infraProjects = infraProjectsTask.Result; - var serviceProjects = serviceProjectsTask.Result; - // Build results and compute balances in memory for tight control var results = new List(); @@ -185,8 +234,7 @@ namespace Marco.Pms.Services.Controllers var vm = _mapper.Map(invoice); // Project mapping logic - minimize nested object allocations - vm.Project = serviceProjects.Where(sp => sp.Id == invoice.ProjectId).Select(p => _mapper.Map(p)).FirstOrDefault() - ?? infraProjects.Where(ip => ip.Id == invoice.ProjectId).Select(sp => _mapper.Map(sp)).FirstOrDefault(); + vm.Project = projects.Where(sp => sp.Id == invoice.ProjectId).FirstOrDefault(); vm.BalanceAmount = balance; @@ -194,6 +242,13 @@ namespace Marco.Pms.Services.Controllers } var totalPages = (int)Math.Ceiling((double)totalItems / pageSize); + //string groupByColumn = "Project"; + //if (!string.IsNullOrWhiteSpace(advanceFilter?.GroupByColumn)) + //{ + // groupByColumn = advanceFilter.GroupByColumn; + //} + //var resultQuery = results.AsQueryable(); + //object finalResponseData = resultQuery.ApplyGroupByFilters(groupByColumn); _logger.LogInfo("Returning {Count} invoices (page {PageNumber} of {TotalPages}).", results.Count, pageNumber, totalPages); @@ -1155,6 +1210,45 @@ namespace Marco.Pms.Services.Controllers #region =================================================================== Helper Functions =================================================================== + private AdvanceFilter? TryDeserializeFilter(string? filter) + { + if (string.IsNullOrWhiteSpace(filter)) + { + return null; + } + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + AdvanceFilter? advanceFilter = null; + + try + { + // First, try to deserialize directly. This is the expected case (e.g., from a web client). + advanceFilter = JsonSerializer.Deserialize(filter, options); + } + catch (JsonException ex) + { + _logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter); + + // If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients). + try + { + // Unescape the string first, then deserialize the result. + string unescapedJsonString = JsonSerializer.Deserialize(filter, options) ?? ""; + if (!string.IsNullOrWhiteSpace(unescapedJsonString)) + { + advanceFilter = JsonSerializer.Deserialize(unescapedJsonString, options); + } + } + catch (JsonException ex1) + { + // If both attempts fail, log the final error and return null. + _logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter); + return null; + } + } + return advanceFilter; + } + /// /// Async permission check helper with scoped DI lifetime /// diff --git a/Marco.Pms.Services/Extensions/QueryableExtensions.cs b/Marco.Pms.Services/Extensions/QueryableExtensions.cs new file mode 100644 index 0000000..6efea99 --- /dev/null +++ b/Marco.Pms.Services/Extensions/QueryableExtensions.cs @@ -0,0 +1,84 @@ +using Marco.Pms.Model.Filters; +using System.Linq.Dynamic.Core; + +namespace Marco.Pms.Services.Extensions +{ + /// + /// Enterprise-grade extension methods for applying dynamic filters and sorting to IQueryable sources. + /// + public static class QueryableExtensions + { + public static IQueryable ApplyCustomFilters(this IQueryable query, AdvanceFilter filter) + { + // 1. Apply Advanced Filters (Arithmetic/Logic) + if (filter.AdvanceFilters != null && filter.AdvanceFilters.Any()) + { + foreach (var advanceFilter in filter.AdvanceFilters) + { + if (string.IsNullOrWhiteSpace(advanceFilter.Column)) continue; + + string op = advanceFilter.Opration.ToLower().Trim(); + string predicate = ""; + + // Map your custom strings to Dynamic LINQ operators + switch (op) + { + case "greater than": predicate = $"{advanceFilter.Column} > @0"; break; + case "less than": predicate = $"{advanceFilter.Column} < @0"; break; + case "equal to": predicate = $"{advanceFilter.Column} == @0"; break; + case "not equal": predicate = $"{advanceFilter.Column} != @0"; break; + case "greater or equal": predicate = $"{advanceFilter.Column} >= @0"; break; + case "smaller or equal": predicate = $"{advanceFilter.Column} <= @0"; break; + default: continue; + } + + if (!string.IsNullOrEmpty(predicate)) + { + // Dynamic LINQ handles type conversion (string "100" to int 100) automatically + query = query.Where(predicate, advanceFilter.Value); + } + } + } + + // 2. Apply Sorting + if (filter.SortFilters != null && filter.SortFilters.Any()) + { + // Build a comma-separated sort string: "Column1 desc, Column2 asc" + var sortExpressions = new List(); + foreach (var sort in filter.SortFilters) + { + string direction = sort.SortDescending ? "desc" : "asc"; + sortExpressions.Add($"{sort.Column} {direction}"); + } + + query = query.OrderBy(string.Join(", ", sortExpressions)); + } + + return query; + } + + public static IQueryable ApplySearchFilters(this IQueryable query, List searchFilters) + { + // 1. Apply Search Filters (Contains/Text search) + if (searchFilters.Any()) + { + foreach (var search in searchFilters) + { + if (string.IsNullOrWhiteSpace(search.Column) || string.IsNullOrWhiteSpace(search.Value)) continue; + + // Generates: x.Column.Contains("Value") + // Case insensitive logic can be handled here if needed + query = query.Where($"{search.Column}.Contains(@0)", search.Value); + } + } + return query; + } + + public static IQueryable ApplyGroupByFilters(this IQueryable query, string groupByColumn) + { + query.GroupBy(groupByColumn).Select("new (Key, ToList() as Items)"); // Reshape to { Key: "Value", Items: [...] } + return query; + } + + } +} diff --git a/Marco.Pms.Services/Marco.Pms.Services.csproj b/Marco.Pms.Services/Marco.Pms.Services.csproj index fa28c50..c4fa66e 100644 --- a/Marco.Pms.Services/Marco.Pms.Services.csproj +++ b/Marco.Pms.Services/Marco.Pms.Services.csproj @@ -42,6 +42,7 @@ +