Added dynamic sortting, grouping, cloumn searching in collection controller
This commit is contained in:
parent
f1f5fc263f
commit
8f5a49deed
11
Marco.Pms.Model/Filters/AdvanceFilter.cs
Normal file
11
Marco.Pms.Model/Filters/AdvanceFilter.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace Marco.Pms.Model.Filters
|
||||
{
|
||||
public class AdvanceFilter
|
||||
{
|
||||
// The dynamic filters from your JSON
|
||||
public List<SortItem>? SortFilters { get; set; }
|
||||
public List<SearchItem>? SearchFilters { get; set; }
|
||||
public List<AdvanceItem>? AdvanceFilters { get; set; }
|
||||
public string GroupByColumn { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
9
Marco.Pms.Model/Filters/AdvanceItem.cs
Normal file
9
Marco.Pms.Model/Filters/AdvanceItem.cs
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
8
Marco.Pms.Model/Filters/SearchItem.cs
Normal file
8
Marco.Pms.Model/Filters/SearchItem.cs
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
8
Marco.Pms.Model/Filters/SortItem.cs
Normal file
8
Marco.Pms.Model/Filters/SortItem.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace Marco.Pms.Model.Filters
|
||||
{
|
||||
public class SortItem
|
||||
{
|
||||
public string Column { get; set; } = string.Empty;
|
||||
public bool SortDescending { get; set; }
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
/// </summary>
|
||||
|
||||
[HttpGet("invoice/list")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<BasicProjectVM>(p))
|
||||
.ToListAsync();
|
||||
var serviceProjectsTask = serviceProjectsQuery
|
||||
.Select(sp => _mapper.Map<BasicProjectVM>(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<InvoiceListVM>();
|
||||
|
||||
@ -185,8 +234,7 @@ namespace Marco.Pms.Services.Controllers
|
||||
var vm = _mapper.Map<InvoiceListVM>(invoice);
|
||||
|
||||
// Project mapping logic - minimize nested object allocations
|
||||
vm.Project = serviceProjects.Where(sp => sp.Id == invoice.ProjectId).Select(p => _mapper.Map<BasicProjectVM>(p)).FirstOrDefault()
|
||||
?? infraProjects.Where(ip => ip.Id == invoice.ProjectId).Select(sp => _mapper.Map<BasicProjectVM>(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<AdvanceFilter>(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<string>(filter, options) ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
|
||||
{
|
||||
advanceFilter = JsonSerializer.Deserialize<AdvanceFilter>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Async permission check helper with scoped DI lifetime
|
||||
/// </summary>
|
||||
|
||||
84
Marco.Pms.Services/Extensions/QueryableExtensions.cs
Normal file
84
Marco.Pms.Services/Extensions/QueryableExtensions.cs
Normal file
@ -0,0 +1,84 @@
|
||||
using Marco.Pms.Model.Filters;
|
||||
using System.Linq.Dynamic.Core;
|
||||
|
||||
namespace Marco.Pms.Services.Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enterprise-grade extension methods for applying dynamic filters and sorting to IQueryable sources.
|
||||
/// </summary>
|
||||
public static class QueryableExtensions
|
||||
{
|
||||
public static IQueryable<T> ApplyCustomFilters<T>(this IQueryable<T> 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<string>();
|
||||
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<T> ApplySearchFilters<T>(this IQueryable<T> query, List<SearchItem> 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<T> ApplyGroupByFilters<T>(this IQueryable<T> query, string groupByColumn)
|
||||
{
|
||||
query.GroupBy(groupByColumn).Select("new (Key, ToList() as Items)"); // Reshape to { Key: "Value", Items: [...] }
|
||||
return query;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@ -42,6 +42,7 @@
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.MongoDB" Version="7.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.7.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user