Merge master into dev: quote stat cards Converted fix

This commit is contained in:
2026-06-15 16:29:59 -04:00
3 changed files with 28 additions and 7 deletions
@@ -33,10 +33,17 @@ public interface IQuoteRepository : IRepository<Quote>
/// <summary>
/// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the
/// current company by the global query filter. Pass status ID sets (derived from QuoteStatusLookup)
/// to classify open vs. approved/converted quotes.
/// given <paramref name="companyId"/> (explicit predicate for defense in depth, in addition to
/// the global query filter). Pass status ID sets (derived from QuoteStatusLookup) to classify
/// open vs. approved/converted quotes.
/// <para>
/// <paramref name="excludedStatusId"/> drops quotes in that status (Converted) from every card so
/// the stat strip reflects the same population as the default Index list, which hides Converted
/// quotes. Without this, a converted quote (which cannot be deleted while a job is linked) inflates
/// TotalValue and the approved count even though it never appears in the list.
/// </para>
/// </summary>
Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> approvedConvertedStatusIds);
Task<QuoteIndexStats> GetIndexStatsAsync(int companyId, List<int> openStatusIds, List<int> approvedConvertedStatusIds, int? excludedStatusId = null);
/// <summary>
/// Loads quote items with their coat passes (InventoryItem + Vendor) and prep services for
@@ -69,10 +69,21 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
}
/// <inheritdoc/>
public async Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> approvedConvertedStatusIds)
public async Task<QuoteIndexStats> GetIndexStatsAsync(int companyId, List<int> openStatusIds, List<int> approvedConvertedStatusIds, int? excludedStatusId = null)
{
var stats = await _context.Quotes
.Where(q => !q.IsDeleted)
// Explicit CompanyId predicate (defense in depth) on top of the global tenant filter.
var query = _context.Quotes
.Where(q => q.CompanyId == companyId && !q.IsDeleted);
// Exclude the same status the default Index list hides (Converted) so the stat strip and the
// visible list summarize the identical population — a converted quote left behind by a blocked
// delete must not inflate the cards while being invisible in the list.
if (excludedStatusId.HasValue)
{
query = query.Where(q => q.QuoteStatusId != excludedStatusId.Value);
}
var stats = await query
.Select(q => new { q.QuoteStatusId, q.Total })
.ToListAsync();
@@ -248,7 +248,10 @@ public class QuotesController : Controller
var approvedConvertedIds = quoteStatuses
.Where(s => s.StatusCode == AppConstants.StatusCodes.Quote.Approved || s.StatusCode == AppConstants.StatusCodes.Quote.Converted)
.Select(s => s.Id).ToList();
var indexStats = await _unitOfWork.Quotes.GetIndexStatsAsync(draftSentIds, approvedConvertedIds);
// Exclude Converted from the stat cards so they match the default list (which hides Converted).
var convertedStatusId = quoteStatuses
.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted)?.Id;
var indexStats = await _unitOfWork.Quotes.GetIndexStatsAsync(companyId, draftSentIds, approvedConvertedIds, convertedStatusId);
ViewBag.StatOpenCount = indexStats.OpenCount;
ViewBag.StatApprovedCount = indexStats.ApprovedConvertedCount;
ViewBag.StatTotalValue = indexStats.TotalValue;