Example: Search and Filtering System
Overview
This example demonstrates the comprehensive search and filtering system implemented in the AR Payment Reversal dashboard. The system provides powerful, real-time search capabilities across multiple data sources, including customer records, payment history, and invoice details, with advanced filtering options and intelligent search suggestions.
Business Value
The search and filtering system addresses critical user needs:
- Efficiency: Quickly locate specific payments or customers
- Accuracy: Reduce errors through precise filtering
- Productivity: Save time with intelligent search suggestions
- Flexibility: Support various search criteria and combinations
- Scalability: Handle large datasets with optimized queries
Implementation Architecture
Search Service Interface
Core contracts for search operations:
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/ISearchService.cs
public interface ISearchService
{
Task<SearchResult<T>> SearchAsync<T>(SearchRequest request);
Task<IEnumerable<SearchSuggestion>> GetSuggestionsAsync(string query, SearchContext context);
Task<SearchFilters> GetAvailableFiltersAsync(SearchContext context);
Task<SavedSearch> SaveSearchAsync(SearchRequest request, string name);
Task<IEnumerable<SavedSearch>> GetSavedSearchesAsync();
}
public class SearchRequest
{
public string Query { get; set; }
public SearchType SearchType { get; set; }
public List<FilterCriteria> Filters { get; set; } = new();
public SortOptions Sorting { get; set; }
public PagingOptions Paging { get; set; }
public bool IncludeArchived { get; set; }
public bool UseFullTextSearch { get; set; }
public SearchScope Scope { get; set; }
}
public class FilterCriteria
{
public string FieldName { get; set; }
public FilterOperator Operator { get; set; }
public object Value { get; set; }
public object Value2 { get; set; } // For range filters
public bool CaseSensitive { get; set; }
public bool Negate { get; set; }
}
public enum FilterOperator
{
Equals,
NotEquals,
Contains,
StartsWith,
EndsWith,
GreaterThan,
LessThan,
Between,
In,
IsNull,
IsNotNull,
Regex
}
public class SearchResult<T>
{
public IEnumerable<T> Results { get; set; }
public int TotalCount { get; set; }
public int FilteredCount { get; set; }
public TimeSpan SearchDuration { get; set; }
public List<SearchHighlight> Highlights { get; set; }
public SearchStatistics Statistics { get; set; }
public bool HasMore { get; set; }
}
Customer Search Implementation
Specialized search for customer records:
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/CustomerSearchService.cs
public class CustomerSearchService : ICustomerSearchService
{
private readonly ILogger<CustomerSearchService> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly ISearchIndexService _indexService;
public async Task<SearchResult<CustomerSearchResult>> SearchCustomersAsync(
CustomerSearchRequest request)
{
var stopwatch = Stopwatch.StartNew();
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
// Build base query
IQueryable<ArCustomer> query = context.ArCustomers;
// Apply search query
if (!string.IsNullOrWhiteSpace(request.Query))
{
query = ApplySearchQuery(query, request.Query, request.SearchFields);
}
// Apply filters
foreach (var filter in request.Filters)
{
query = ApplyFilter(query, filter);
}
// Get total count before paging
var totalCount = await query.CountAsync();
// Apply sorting
query = ApplySorting(query, request.Sorting);
// Apply paging
if (request.Paging != null)
{
query = query
.Skip((request.Paging.Page - 1) * request.Paging.PageSize)
.Take(request.Paging.PageSize);
}
// Execute query with projections
var results = await query
.Select(c => new CustomerSearchResult
{
Customer = c.Customer,
Name = c.Name,
ShortName = c.ShortName,
Balance = c.Balance,
CreditLimit = c.CreditLimit,
CreditStatus = c.CreditStatus,
CustomerOnHold = c.CustomerOnHold == "Y",
Branch = c.Branch,
Salesperson = c.Salesperson,
LastPaymentDate = c.DateLastPayment,
// Calculate additional metrics
PaymentCount = context.ArPayHistories
.Count(p => p.Customer == c.Customer),
OpenInvoiceCount = context.ArInvoices
.Count(i => i.Customer == c.Customer && i.InvoiceBal1 > 0),
TotalPaymentValue = context.ArPayHistories
.Where(p => p.Customer == c.Customer)
.Sum(p => (decimal?)p.PaymentValue) ?? 0,
HasRecentActivity = context.ArPayHistories
.Any(p => p.Customer == c.Customer &&
p.PayDate >= DateTime.Now.AddDays(-30))
})
.ToListAsync();
// Apply highlighting if using text search
if (!string.IsNullOrWhiteSpace(request.Query))
{
ApplyHighlighting(results, request.Query);
}
// Calculate statistics
var statistics = CalculateStatistics(results);
stopwatch.Stop();
return new SearchResult<CustomerSearchResult>
{
Results = results,
TotalCount = totalCount,
FilteredCount = results.Count,
SearchDuration = stopwatch.Elapsed,
Statistics = statistics,
HasMore = request.Paging != null &&
totalCount > request.Paging.Page * request.Paging.PageSize
};
}
}
private IQueryable<ArCustomer> ApplySearchQuery(
IQueryable<ArCustomer> query,
string searchText,
List<string> searchFields)
{
searchText = searchText.Trim().ToUpper();
// Default search fields if none specified
if (searchFields == null || !searchFields.Any())
{
searchFields = new List<string>
{
"Customer",
"Name",
"ShortName"
};
}
// Build dynamic expression for search
var parameter = Expression.Parameter(typeof(ArCustomer), "c");
Expression searchExpression = null;
foreach (var field in searchFields)
{
var property = Expression.Property(parameter, field);
var toUpperMethod = typeof(string).GetMethod("ToUpper", Type.EmptyTypes);
var upperProperty = Expression.Call(property, toUpperMethod);
var containsMethod = typeof(string).GetMethod("Contains", new[] { typeof(string) });
var searchValue = Expression.Constant(searchText);
var containsExpression = Expression.Call(upperProperty, containsMethod, searchValue);
searchExpression = searchExpression == null
? containsExpression
: Expression.OrElse(searchExpression, containsExpression);
}
if (searchExpression != null)
{
var lambda = Expression.Lambda<Func<ArCustomer, bool>>(searchExpression, parameter);
query = query.Where(lambda);
}
return query;
}
private IQueryable<ArCustomer> ApplyFilter(
IQueryable<ArCustomer> query,
FilterCriteria filter)
{
switch (filter.FieldName.ToLower())
{
case "balance":
query = ApplyNumericFilter(query, c => c.Balance, filter);
break;
case "creditlimit":
query = ApplyNumericFilter(query, c => c.CreditLimit, filter);
break;
case "onhold":
if (filter.Value is bool onHold)
{
query = query.Where(c => c.CustomerOnHold == (onHold ? "Y" : "N"));
}
break;
case "branch":
query = ApplyStringFilter(query, c => c.Branch, filter);
break;
case "salesperson":
query = ApplyStringFilter(query, c => c.Salesperson, filter);
break;
case "hasactivity":
if (filter.Value is int days)
{
var cutoffDate = DateTime.Now.AddDays(-days);
query = query.Where(c =>
c.DateLastPayment >= cutoffDate ||
c.DateLastSale >= cutoffDate);
}
break;
case "creditstatus":
query = ApplyStringFilter(query, c => c.CreditStatus, filter);
break;
}
return query;
}
private IQueryable<T> ApplyNumericFilter<T>(
IQueryable<T> query,
Expression<Func<T, decimal>> selector,
FilterCriteria filter)
{
var value = Convert.ToDecimal(filter.Value);
switch (filter.Operator)
{
case FilterOperator.Equals:
query = query.Where(Expression.Lambda<Func<T, bool>>(
Expression.Equal(selector.Body, Expression.Constant(value)),
selector.Parameters));
break;
case FilterOperator.GreaterThan:
query = query.Where(Expression.Lambda<Func<T, bool>>(
Expression.GreaterThan(selector.Body, Expression.Constant(value)),
selector.Parameters));
break;
case FilterOperator.LessThan:
query = query.Where(Expression.Lambda<Func<T, bool>>(
Expression.LessThan(selector.Body, Expression.Constant(value)),
selector.Parameters));
break;
case FilterOperator.Between:
var value2 = Convert.ToDecimal(filter.Value2);
query = query.Where(Expression.Lambda<Func<T, bool>>(
Expression.AndAlso(
Expression.GreaterThanOrEqual(selector.Body, Expression.Constant(value)),
Expression.LessThanOrEqual(selector.Body, Expression.Constant(value2))),
selector.Parameters));
break;
}
return query;
}
}
Intelligent Search Suggestions
Auto-complete and suggestion system:
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/SearchSuggestionService.cs
public class SearchSuggestionService : ISearchSuggestionService
{
private readonly IMemoryCache _cache;
private readonly ILogger<SearchSuggestionService> _logger;
public async Task<IEnumerable<SearchSuggestion>> GetSuggestionsAsync(
string query,
SearchContext context)
{
if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
{
return Enumerable.Empty<SearchSuggestion>();
}
// Check cache
var cacheKey = $"suggestions_{context.Type}_{query.ToLower()}";
if (_cache.TryGetValue<List<SearchSuggestion>>(cacheKey, out var cached))
{
return cached;
}
var suggestions = new List<SearchSuggestion>();
// Get suggestions based on context
switch (context.Type)
{
case SearchContextType.Customer:
suggestions.AddRange(await GetCustomerSuggestions(query));
break;
case SearchContextType.Payment:
suggestions.AddRange(await GetPaymentSuggestions(query));
break;
case SearchContextType.Invoice:
suggestions.AddRange(await GetInvoiceSuggestions(query));
break;
case SearchContextType.Global:
// Combine suggestions from all sources
suggestions.AddRange(await GetCustomerSuggestions(query));
suggestions.AddRange(await GetPaymentSuggestions(query));
suggestions.AddRange(await GetInvoiceSuggestions(query));
break;
}
// Add search history suggestions
suggestions.AddRange(await GetHistorySuggestions(query, context));
// Rank suggestions
suggestions = RankSuggestions(suggestions, query)
.Take(10)
.ToList();
// Cache results
_cache.Set(cacheKey, suggestions, TimeSpan.FromMinutes(5));
return suggestions;
}
private async Task<List<SearchSuggestion>> GetCustomerSuggestions(string query)
{
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
var upperQuery = query.ToUpper();
var customers = await context.ArCustomers
.Where(c => c.Customer.StartsWith(upperQuery) ||
c.Name.Contains(upperQuery) ||
c.ShortName.Contains(upperQuery))
.Take(5)
.Select(c => new SearchSuggestion
{
Type = SuggestionType.Customer,
Value = c.Customer,
DisplayText = $"{c.Customer} - {c.Name}",
Category = "Customers",
Icon = "customer-icon",
Metadata = new Dictionary<string, object>
{
["Balance"] = c.Balance,
["OnHold"] = c.CustomerOnHold == "Y"
}
})
.ToListAsync();
return customers;
}
}
private List<SearchSuggestion> RankSuggestions(
List<SearchSuggestion> suggestions,
string query)
{
foreach (var suggestion in suggestions)
{
// Calculate relevance score
var score = 0.0;
// Exact match gets highest score
if (suggestion.Value.Equals(query, StringComparison.OrdinalIgnoreCase))
{
score = 100;
}
// Starts with query
else if (suggestion.Value.StartsWith(query, StringComparison.OrdinalIgnoreCase))
{
score = 80;
}
// Contains query
else if (suggestion.Value.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0)
{
score = 60;
}
// Boost recent/frequent items
if (suggestion.Metadata?.ContainsKey("Frequency") == true)
{
score += Convert.ToDouble(suggestion.Metadata["Frequency"]) * 10;
}
if (suggestion.Metadata?.ContainsKey("LastUsed") == true)
{
var lastUsed = (DateTime)suggestion.Metadata["LastUsed"];
var daysSinceUse = (DateTime.Now - lastUsed).TotalDays;
score += Math.Max(0, 20 - daysSinceUse);
}
suggestion.Score = score;
}
return suggestions.OrderByDescending(s => s.Score).ToList();
}
}
Advanced Filter Builder
Dynamic filter construction UI:
<!-- Filter Builder Control -->
<UserControl x:Class="MepApps.Dash.Ar.Maint.PaymentReversal.Controls.FilterBuilder">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Search Box -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
x:Name="SearchBox"
Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}"
FontSize="14">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding SearchCommand}"/>
</TextBox.InputBindings>
<i:Interaction.Behaviors>
<local:SearchSuggestionBehavior
SuggestionSource="{Binding SuggestionProvider}"
MinimumLength="2"
Delay="300"/>
</i:Interaction.Behaviors>
<TextBox.Style>
<Style TargetType="TextBox">
<Style.Triggers>
<Trigger Property="IsFocused" Value="False">
<Setter Property="Text" Value="{Binding SearchPlaceholder}"/>
<Setter Property="Foreground" Value="Gray"/>
</Trigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<!-- Search Button -->
<Button Grid.Column="1"
Command="{Binding SearchCommand}"
Margin="5,0">
<StackPanel Orientation="Horizontal">
<Image Source="/Images/search.png" Width="16" Height="16"/>
<TextBlock Text="Search" Margin="5,0,0,0"/>
</StackPanel>
</Button>
<!-- Advanced Filter Toggle -->
<ToggleButton Grid.Column="2"
IsChecked="{Binding ShowAdvancedFilters}">
<StackPanel Orientation="Horizontal">
<Image Source="/Images/filter.png" Width="16" Height="16"/>
<TextBlock Text="Filters" Margin="5,0,0,0"/>
</StackPanel>
</ToggleButton>
</Grid>
<!-- Suggestion Popup -->
<Popup PlacementTarget="{Binding ElementName=SearchBox}"
Placement="Bottom"
IsOpen="{Binding ShowSuggestions}"
Width="{Binding ElementName=SearchBox, Path=ActualWidth}">
<Border BorderBrush="Gray"
BorderThickness="1"
Background="White"
MaxHeight="300">
<ListBox ItemsSource="{Binding Suggestions}"
SelectedItem="{Binding SelectedSuggestion}">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Image Grid.Column="0"
Source="{Binding Icon}"
Width="16" Height="16"
Margin="0,0,5,0"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding DisplayText}"
FontWeight="Bold"/>
<TextBlock Text="{Binding Category}"
FontSize="10"
Foreground="Gray"/>
</StackPanel>
<TextBlock Grid.Column="2"
Text="{Binding Score, StringFormat='({0:0})'}"
FontSize="10"
Foreground="Gray"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</Popup>
<!-- Advanced Filters -->
<Expander Grid.Row="1"
IsExpanded="{Binding ShowAdvancedFilters}"
Header="Advanced Filters">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="10">
<!-- Active Filters -->
<ItemsControl ItemsSource="{Binding ActiveFilters}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="LightGray"
CornerRadius="3"
Margin="0,2"
Padding="5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ComboBox Grid.Column="0"
ItemsSource="{Binding DataContext.FilterFields,
RelativeSource={RelativeSource AncestorType=UserControl}}"
SelectedItem="{Binding Field}"
DisplayMemberPath="DisplayName"
SelectedValuePath="FieldName"/>
<ComboBox Grid.Column="1"
ItemsSource="{Binding AvailableOperators}"
SelectedItem="{Binding Operator}"/>
<ContentControl Grid.Column="2"
Content="{Binding}"
Margin="5,0">
<ContentControl.Resources>
<!-- Text filter -->
<DataTemplate DataType="{x:Type local:TextFilter}">
<TextBox Text="{Binding Value}"/>
</DataTemplate>
<!-- Numeric filter -->
<DataTemplate DataType="{x:Type local:NumericFilter}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding Value}"/>
<TextBlock Grid.Column="1"
Text=" to "
Margin="5,0"
Visibility="{Binding ShowRange,
Converter={StaticResource BoolToVisibilityConverter}}"/>
<TextBox Grid.Column="2"
Text="{Binding Value2}"
Visibility="{Binding ShowRange,
Converter={StaticResource BoolToVisibilityConverter}}"/>
</Grid>
</DataTemplate>
<!-- Date filter -->
<DataTemplate DataType="{x:Type local:DateFilter}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DatePicker Grid.Column="0"
SelectedDate="{Binding DateValue}"/>
<TextBlock Grid.Column="1"
Text=" to "
Margin="5,0"
Visibility="{Binding ShowRange,
Converter={StaticResource BoolToVisibilityConverter}}"/>
<DatePicker Grid.Column="2"
SelectedDate="{Binding DateValue2}"
Visibility="{Binding ShowRange,
Converter={StaticResource BoolToVisibilityConverter}}"/>
</Grid>
</DataTemplate>
<!-- Boolean filter -->
<DataTemplate DataType="{x:Type local:BooleanFilter}">
<CheckBox IsChecked="{Binding Value}"/>
</DataTemplate>
</ContentControl.Resources>
</ContentControl>
<Button Grid.Column="3"
Content="X"
Command="{Binding DataContext.RemoveFilterCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"
Width="20" Height="20"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Add Filter Button -->
<Button Content="Add Filter"
Command="{Binding AddFilterCommand}"
HorizontalAlignment="Left"
Margin="0,5,0,0"/>
<!-- Saved Searches -->
<GroupBox Header="Saved Searches" Margin="0,10,0,0">
<ListBox ItemsSource="{Binding SavedSearches}"
SelectedItem="{Binding SelectedSavedSearch}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</GroupBox>
</StackPanel>
</ScrollViewer>
</Expander>
<!-- Search Results Info -->
<StatusBar Grid.Row="2">
<StatusBarItem>
<TextBlock>
<TextBlock.Text>
<MultiBinding StringFormat="Found {0} of {1} results in {2:F2}s">
<Binding Path="FilteredCount"/>
<Binding Path="TotalCount"/>
<Binding Path="SearchDuration.TotalSeconds"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StatusBarItem>
</StatusBar>
</Grid>
</UserControl>
Search Performance Optimization
Optimizing search queries for large datasets:
public class SearchOptimizer
{
private readonly ISearchIndexService _indexService;
public async Task<OptimizedSearchPlan> OptimizeSearchAsync(SearchRequest request)
{
var plan = new OptimizedSearchPlan();
// Analyze query complexity
var complexity = AnalyzeQueryComplexity(request);
if (complexity.Score > 100)
{
// Use indexed search for complex queries
plan.UseIndexedSearch = true;
plan.IndexName = await _indexService.GetOptimalIndex(request);
}
else if (request.Filters.Count > 3)
{
// Use materialized view for multiple filters
plan.UseMaterializedView = true;
plan.ViewName = GetMaterializedView(request);
}
else
{
// Direct query for simple searches
plan.UseDirectQuery = true;
}
// Optimize filter order
plan.FilterOrder = OptimizeFilterOrder(request.Filters);
// Determine if caching would help
plan.CacheResults = ShouldCacheResults(request);
return plan;
}
}
Benefits
- User Efficiency: Quick location of needed information
- Flexibility: Multiple search and filter options
- Performance: Optimized queries for large datasets
- Intelligence: Smart suggestions and auto-complete
- Persistence: Save and reuse complex searches
Related Documentation
Summary
The search and filtering system provides powerful, flexible data discovery capabilities for the AR Payment Reversal dashboard. Through intelligent suggestions, advanced filtering, and performance optimization, users can quickly locate and work with the exact data they need, significantly improving productivity and accuracy in payment reversal operations.