Add status-group pills to Invoices list, default to Unpaid

Bare /Invoices now redirects to statusGroup=unpaid (Draft, Sent, Overdue)
so the list is immediately actionable. Four pills — All, Unpaid, Partial,
Paid — mirror the Jobs page pattern with live badge counts. The existing
status dropdown and outstanding/thisMonth flags are preserved for
dashboard deep-links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 16:42:43 -04:00
parent 4f039b8281
commit eb8fc8b6d0
2 changed files with 93 additions and 38 deletions
@@ -82,14 +82,15 @@ public class InvoicesController : Controller
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary> /// <summary>
/// Displays the paginated invoice list with multi-mode filtering. The filter cascade handles /// Displays the paginated invoice list with multi-mode filtering. The filter cascade handles
/// nine combinations of overdue/outstanding/thisMonth flags with status and search term so the /// statusGroup pills (unpaid/partial/paid/all) plus legacy flag combinations (overdue/outstanding/thisMonth)
/// database receives a single targeted predicate — no full-table load then in-memory LINQ. /// so the database receives a single targeted predicate — no full-table load then in-memory LINQ.
/// Balance-due sort is computed in the ORDER BY expression rather than a stored column because /// Balance-due sort is computed in the ORDER BY expression rather than a stored column because
/// balance = Total AmountPaid CreditApplied GiftCertificateRedeemed changes on every payment. /// balance = Total AmountPaid CreditApplied GiftCertificateRedeemed changes on every payment.
/// </summary> /// </summary>
public async Task<IActionResult> Index( public async Task<IActionResult> Index(
string? searchTerm, string? searchTerm,
InvoiceStatus? statusFilter, InvoiceStatus? statusFilter,
string? statusGroup,
string? sortColumn, string? sortColumn,
string sortDirection = "desc", string sortDirection = "desc",
bool outstandingOnly = false, bool outstandingOnly = false,
@@ -100,6 +101,11 @@ public class InvoicesController : Controller
{ {
try try
{ {
// Default landing: show unpaid invoices so the list is immediately actionable.
if (string.IsNullOrEmpty(statusGroup) && !statusFilter.HasValue &&
string.IsNullOrEmpty(searchTerm) && !outstandingOnly && !thisMonthOnly && !overdueOnly)
return RedirectToAction("Index", new { statusGroup = "unpaid" });
var today = DateTime.Today; var today = DateTime.Today;
var startOfMonth = new DateTime(today.Year, today.Month, 1); var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1); var endOfMonth = startOfMonth.AddMonths(1);
@@ -116,7 +122,18 @@ public class InvoicesController : Controller
System.Linq.Expressions.Expression<Func<Invoice, bool>>? filter = null; System.Linq.Expressions.Expression<Func<Invoice, bool>>? filter = null;
if (overdueOnly) // Status-group pills take priority over the dropdown and legacy flags.
if (!string.IsNullOrEmpty(statusGroup))
{
filter = statusGroup switch
{
"unpaid" => i => i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue,
"partial" => i => i.Status == InvoiceStatus.PartiallyPaid,
"paid" => i => i.Status == InvoiceStatus.Paid,
_ => null // "all" — no predicate
};
}
else if (overdueOnly)
{ {
filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue) filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue)
&& i.DueDate.HasValue && i.DueDate.Value < today; && i.DueDate.HasValue && i.DueDate.Value < today;
@@ -215,12 +232,20 @@ public class InvoicesController : Controller
ViewBag.SearchTerm = searchTerm; ViewBag.SearchTerm = searchTerm;
ViewBag.StatusFilter = statusFilter; ViewBag.StatusFilter = statusFilter;
ViewBag.StatusGroup = statusGroup;
ViewBag.OutstandingOnly = outstandingOnly; ViewBag.OutstandingOnly = outstandingOnly;
ViewBag.ThisMonthOnly = thisMonthOnly; ViewBag.ThisMonthOnly = thisMonthOnly;
ViewBag.OverdueOnly = overdueOnly; ViewBag.OverdueOnly = overdueOnly;
ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection; ViewBag.SortDirection = gridRequest.SortDirection;
// Pill badge counts — always global (not scoped to current filter/page)
ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i =>
i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue);
ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.PartiallyPaid);
ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.Paid);
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync();
return View(pagedResult); return View(pagedResult);
} }
catch (Exception ex) catch (Exception ex)
@@ -11,8 +11,13 @@
ViewData["PageHelpContent"] = "Invoices are created from completed jobs and sent to the customer for payment. Lifecycle: Draft (editable) → Sent (locked, awaiting payment) → Partially Paid / Paid. Overdue = past due date with a balance still owed. Outstanding shows the total A/R balance across all unpaid invoices currently on screen. Use Void to cancel without deleting history."; ViewData["PageHelpContent"] = "Invoices are created from completed jobs and sent to the customer for payment. Lifecycle: Draft (editable) → Sent (locked, awaiting payment) → Partially Paid / Paid. Overdue = past due date with a balance still owed. Outstanding shows the total A/R balance across all unpaid invoices currently on screen. Use Void to cancel without deleting history.";
var searchTerm = ViewBag.SearchTerm as string; var searchTerm = ViewBag.SearchTerm as string;
var statusFilter = ViewBag.StatusFilter as InvoiceStatus?; var statusFilter = ViewBag.StatusFilter as InvoiceStatus?;
var statusGroup = ViewBag.StatusGroup as string;
var outstandingOnly = (bool)(ViewBag.OutstandingOnly ?? false); var outstandingOnly = (bool)(ViewBag.OutstandingOnly ?? false);
var thisMonthOnly = (bool)(ViewBag.ThisMonthOnly ?? false); var thisMonthOnly = (bool)(ViewBag.ThisMonthOnly ?? false);
var unpaidCount = (int)(ViewBag.UnpaidCount ?? 0);
var partialCount = (int)(ViewBag.PartialCount ?? 0);
var paidCount = (int)(ViewBag.PaidCount ?? 0);
var allCount = (int)(ViewBag.AllCount ?? 0);
} }
@{ @{
@@ -52,52 +57,77 @@
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3"> <div class="card-header border-0 py-3">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3"> <div class="d-flex flex-column gap-2">
<form asp-action="Index" method="get" class="d-flex flex-nowrap gap-2 align-items-center"> <!-- Row 1: search + dropdown + actions -->
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" /> <div class="d-flex flex-wrap gap-2 justify-content-between align-items-center">
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" /> <form asp-action="Index" method="get" class="d-flex flex-nowrap gap-2 align-items-center flex-grow-1" style="max-width:560px;">
<input type="hidden" name="pageSize" value="@Model.PageSize" /> <input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
@if (outstandingOnly) { <input type="hidden" name="outstandingOnly" value="true" /> } <input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
@if (thisMonthOnly) { <input type="hidden" name="thisMonthOnly" value="true" /> } <input type="hidden" name="pageSize" value="@Model.PageSize" />
<div class="input-group" style="max-width:280px; min-width:180px;"> @if (outstandingOnly) { <input type="hidden" name="outstandingOnly" value="true" /> }
<span class="input-group-text border-end-0"><i class="bi bi-search text-muted"></i></span> @if (thisMonthOnly) { <input type="hidden" name="thisMonthOnly" value="true" /> }
<input type="text" name="searchTerm" class="form-control border-start-0" <div class="input-group" style="min-width:180px;">
placeholder="Search invoices..." value="@searchTerm"> <span class="input-group-text border-end-0"><i class="bi bi-search text-muted"></i></span>
</div> <input type="text" name="searchTerm" class="form-control border-start-0"
<select class="form-select" name="statusFilter" style="width:auto;"> placeholder="Search invoices&hellip;" value="@searchTerm">
<option value="">All Statuses</option> </div>
@foreach (InvoiceStatus s in Enum.GetValues(typeof(InvoiceStatus))) <select class="form-select" name="statusFilter" style="width:auto;" onchange="this.form.submit()">
<option value="">All Statuses</option>
@foreach (InvoiceStatus s in Enum.GetValues(typeof(InvoiceStatus)))
{
<option value="@((int)s)" selected="@(statusFilter == s)">@InvoicesController.GetStatusDisplay(s)</option>
}
</select>
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue || outstandingOnly || thisMonthOnly || !string.IsNullOrEmpty(statusGroup))
{ {
<option value="@((int)s)" selected="@(statusFilter == s)">@InvoicesController.GetStatusDisplay(s)</option> <a asp-action="Index" asp-route-statusGroup="unpaid" class="btn btn-outline-secondary text-nowrap"><i class="bi bi-x-lg"></i></a>
} }
</select> </form>
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button> <a asp-action="Create" class="btn btn-primary text-nowrap">
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue || outstandingOnly || thisMonthOnly) <i class="bi bi-plus-circle me-2"></i>New Invoice
{ </a>
<a asp-action="Index" class="btn btn-outline-secondary">Clear</a> </div>
} <!-- Row 2: status-group pills -->
@if (outstandingOnly) <div class="pcl-pill-group">
{ <a href="@Url.Action("Index", new { statusGroup = "all" })" class="pcl-pill @(statusGroup == "all" ? "active" : "")">
All <span class="pcl-pill-count">@allCount</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "unpaid" })" class="pcl-pill @(statusGroup == "unpaid" ? "active" : "")">
Unpaid <span class="pcl-pill-count">@unpaidCount</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "partial" })" class="pcl-pill @(statusGroup == "partial" ? "active" : "")">
Partial <span class="pcl-pill-count">@partialCount</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "paid" })" class="pcl-pill @(statusGroup == "paid" ? "active" : "")">
Paid <span class="pcl-pill-count">@paidCount</span>
</a>
</div>
<!-- Legacy filter badges (outstanding A/R, this-month) -->
@if (outstandingOnly)
{
<div>
<span class="badge bg-info text-dark fs-6 fw-normal"> <span class="badge bg-info text-dark fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>Outstanding A/R <i class="bi bi-funnel-fill me-1"></i>Outstanding A/R
</span> </span>
} </div>
@if (thisMonthOnly && statusFilter == InvoiceStatus.Paid) }
{ @if (thisMonthOnly && statusFilter == InvoiceStatus.Paid)
{
<div>
<span class="badge bg-success fs-6 fw-normal"> <span class="badge bg-success fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>Paid &mdash; @DateTime.Now.ToString("MMMM yyyy") <i class="bi bi-funnel-fill me-1"></i>Paid &mdash; @DateTime.Now.ToString("MMMM yyyy")
</span> </span>
} </div>
else if (thisMonthOnly) }
{ else if (thisMonthOnly)
{
<div>
<span class="badge bg-info text-dark fs-6 fw-normal"> <span class="badge bg-info text-dark fs-6 fw-normal">
<i class="bi bi-funnel-fill me-1"></i>@DateTime.Now.ToString("MMMM yyyy") <i class="bi bi-funnel-fill me-1"></i>@DateTime.Now.ToString("MMMM yyyy")
</span> </span>
} </div>
</form> }
<a asp-action="Create" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-circle me-2"></i>New Invoice
</a>
</div> </div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">