Improve invoice filtering UI (#4914)

* Improve invoice filtering UI

Closes #3664.

* UI updates

* Add app filter

* Add indicator for active filters

* updates text

* Improve selected filter display

* Apply suggestions from code review

---------

Co-authored-by: dstrukt <gfxdsign@gmail.com>
This commit is contained in:
d11n 2023-05-19 03:42:09 +02:00 committed by GitHub
parent 3d57b944ca
commit 6c6544bf9b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 347 additions and 125 deletions

View file

@ -1042,14 +1042,13 @@ namespace BTCPayServer.Tests
[Fact]
public void CanParseFilter()
{
var storeId = "6DehZnc9S7qC6TUTNWuzJ1pFsHTHvES6An21r3MjvLey";
var filter = "storeid:abc, status:abed, blabhbalh ";
var search = new SearchString(filter);
Assert.Equal("storeid:abc, status:abed, blabhbalh", search.ToString());
Assert.Equal("blabhbalh", search.TextSearch);
Assert.Single(search.Filters["storeid"]);
Assert.Single(search.Filters["status"]);
Assert.Equal("abc", search.Filters["storeid"].First());
Assert.Equal("abed", search.Filters["status"].First());
Assert.Single(search.Filters["storeid"], "abc");
Assert.Single(search.Filters["status"], "abed");
filter = "status:abed, status:abed2";
search = new SearchString(filter);
@ -1064,6 +1063,48 @@ namespace BTCPayServer.Tests
search = new SearchString(filter);
Assert.Equal("2019-04-25 01:00 AM", search.Filters["startdate"].First());
Assert.Equal("hekki", search.TextSearch);
// modify search
filter = $"status:settled,exceptionstatus:paidLate,unusual:true, fulltext searchterm, storeid:{storeId},startdate:2019-04-25 01:00:00";
search = new SearchString(filter);
Assert.Equal(filter, search.ToString());
Assert.Equal("fulltext searchterm", search.TextSearch);
Assert.Single(search.Filters["storeid"], storeId);
Assert.Single(search.Filters["status"], "settled");
Assert.Single(search.Filters["exceptionstatus"], "paidLate");
Assert.Single(search.Filters["unusual"], "true");
// toggle off bool with same value
var modified = new SearchString(search.Toggle("unusual", "true"));
Assert.Null(modified.GetFilterBool("unusual"));
// add to array
modified = new SearchString(modified.Toggle("status", "processing"));
var statusArray = modified.GetFilterArray("status");
Assert.Equal(2, statusArray.Length);
Assert.Contains("processing", statusArray);
Assert.Contains("settled", statusArray);
// toggle off array with same value
modified = new SearchString(modified.Toggle("status", "settled"));
statusArray = modified.GetFilterArray("status");
Assert.Single(statusArray, "processing");
// toggle off array with null value
modified = new SearchString(modified.Toggle("status", null));
Assert.Null(modified.GetFilterArray("status"));
// toggle off date with null value
modified = new SearchString(modified.Toggle("startdate", "-7d"));
Assert.Single(modified.GetFilterArray("startdate"), "-7d");
modified = new SearchString(modified.Toggle("startdate", null));
Assert.Null(modified.GetFilterArray("startdate"));
// toggle off date with same value
modified = new SearchString(modified.Toggle("enddate", "-7d"));
Assert.Single(modified.GetFilterArray("enddate"), "-7d");
modified = new SearchString(modified.Toggle("enddate", "-7d"));
Assert.Null(modified.GetFilterArray("enddate"));
}
[Fact]

View file

@ -720,8 +720,8 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain(invoiceId, s.Driver.PageSource);
// unarchive via list
s.Driver.FindElement(By.Id("SearchOptionsToggle")).Click();
s.Driver.FindElement(By.Id("SearchOptionsIncludeArchived")).Click();
s.Driver.FindElement(By.Id("StatusOptionsToggle")).Click();
s.Driver.FindElement(By.Id("StatusOptionsIncludeArchived")).Click();
Assert.Contains(invoiceId, s.Driver.PageSource);
s.Driver.FindElement(By.CssSelector($".selector[value=\"{invoiceId}\"]")).Click();
s.Driver.FindElement(By.Id("ActionsDropdownToggle")).Click();

View file

@ -1073,34 +1073,44 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> ListInvoices(InvoicesModel? model = null)
{
model = this.ParseListQuery(model ?? new InvoicesModel());
var fs = new SearchString(model.SearchTerm);
var timezoneOffset = model.TimezoneOffset ?? 0;
var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}";
var fs = new SearchString(searchTerm, timezoneOffset);
string? storeId = model.StoreId;
var storeIds = new HashSet<string>();
if (fs.GetFilterArray("storeid") is string[] l)
if (storeId is not null)
{
storeIds.Add(storeId);
}
if (fs.GetFilterArray("storeid") is { } l)
{
foreach (var i in l)
storeIds.Add(i);
}
if (storeId is not null)
{
storeIds.Add(storeId);
model.StoreId = storeId;
}
model.StoreIds = storeIds.ToArray();
InvoiceQuery invoiceQuery = GetInvoiceQuery(model.SearchTerm, model.TimezoneOffset ?? 0);
invoiceQuery.StoreId = model.StoreIds;
model.Search = fs;
model.SearchText = fs.TextSearch;
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, timezoneOffset);
invoiceQuery.StoreId = storeIds.ToArray();
invoiceQuery.Take = model.Count;
invoiceQuery.Skip = model.Skip;
invoiceQuery.IncludeRefunds = true;
var list = await _InvoiceRepository.GetInvoices(invoiceQuery);
model.IncludeArchived = invoiceQuery.IncludeArchived;
// Apps
var apps = await _appService.GetAllApps(GetUserId(), false, storeId);
model.Apps = apps.Select(a => new InvoiceAppModel
{
Id = a.Id,
AppName = a.AppName,
AppType = a.AppType,
AppOrderId = AppService.GetAppOrderId(a.AppType, a.Id)
}).ToList();
foreach (var invoice in list)
{
var state = invoice.GetInvoiceState();
model.Invoices.Add(new InvoiceModel()
model.Invoices.Add(new InvoiceModel
{
Status = state,
ShowCheckout = invoice.Status == InvoiceStatusLegacy.New,
@ -1119,10 +1129,9 @@ namespace BTCPayServer.Controllers
return View(model);
}
private InvoiceQuery GetInvoiceQuery(string? searchTerm = null, int timezoneOffset = 0)
private InvoiceQuery GetInvoiceQuery(SearchString fs, int timezoneOffset = 0)
{
var fs = new SearchString(searchTerm);
var invoiceQuery = new InvoiceQuery()
return new InvoiceQuery
{
TextSearch = fs.TextSearch,
UserId = GetUserId(),
@ -1136,7 +1145,6 @@ namespace BTCPayServer.Controllers
StartDate = fs.GetFilterDate("startdate", timezoneOffset),
EndDate = fs.GetFilterDate("enddate", timezoneOffset)
};
return invoiceQuery;
}
[HttpGet]
@ -1147,17 +1155,17 @@ namespace BTCPayServer.Controllers
var model = new InvoiceExport(_CurrencyNameTable);
var fs = new SearchString(searchTerm);
var storeIds = new HashSet<string>();
if (fs.GetFilterArray("storeid") is string[] l)
{
foreach (var i in l)
storeIds.Add(i);
}
if (storeId is not null)
{
storeIds.Add(storeId);
}
if (fs.GetFilterArray("storeid") is { } l)
{
foreach (var i in l)
storeIds.Add(i);
}
InvoiceQuery invoiceQuery = GetInvoiceQuery(searchTerm, timezoneOffset);
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, timezoneOffset);
invoiceQuery.StoreId = storeIds.ToArray();
invoiceQuery.Skip = 0;
invoiceQuery.Take = int.MaxValue;

View file

@ -58,6 +58,7 @@ namespace BTCPayServer.Controllers
private readonly InvoiceActivator _invoiceActivator;
private readonly LinkGenerator _linkGenerator;
private readonly IAuthorizationService _authorizationService;
private readonly AppService _appService;
public WebhookSender WebhookNotificationManager { get; }
@ -81,6 +82,7 @@ namespace BTCPayServer.Controllers
UIWalletsController walletsController,
InvoiceActivator invoiceActivator,
LinkGenerator linkGenerator,
AppService appService,
IAuthorizationService authorizationService)
{
_displayFormatter = displayFormatter;
@ -102,6 +104,7 @@ namespace BTCPayServer.Controllers
_invoiceActivator = invoiceActivator;
_linkGenerator = linkGenerator;
_authorizationService = authorizationService;
_appService = appService;
}

View file

@ -78,7 +78,7 @@ namespace BTCPayServer.Controllers
model = this.ParseListQuery(model ?? new ListPaymentRequestsViewModel());
var store = GetCurrentStore();
var includeArchived = new SearchString(model.SearchTerm).GetFilterBool("includearchived") == true;
var includeArchived = new SearchString(model.SearchTerm, model.TimezoneOffset ?? 0).GetFilterBool("includearchived") == true;
var result = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery
{
UserId = GetUserId(),

View file

@ -1,17 +1,18 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Models.InvoicingModels
{
public class InvoicesModel : BasePagingViewModel
{
public List<InvoiceModel> Invoices { get; set; } = new List<InvoiceModel>();
public List<InvoiceModel> Invoices { get; set; } = new ();
public override int CurrentPageCount => Invoices.Count;
public string[] StoreIds { get; set; }
public string StoreId { get; set; }
public bool IncludeArchived { get; set; }
public string SearchText { get; set; }
public SearchString Search { get; set; }
public List<InvoiceAppModel> Apps { get; set; }
}
public class InvoiceModel
@ -34,4 +35,12 @@ namespace BTCPayServer.Models.InvoicingModels
public InvoiceDetailsModel Details { get; set; }
public bool HasRefund { get; set; }
}
public class InvoiceAppModel
{
public string Id { get; set; }
public string AppName { get; set; }
public string AppType { get; set; }
public string AppOrderId { get; set; }
}
}

View file

@ -2,73 +2,129 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using ExchangeSharp;
namespace BTCPayServer
{
public class SearchString
{
readonly string _OriginalString;
public SearchString(string str)
private const char FilterSeparator = ',';
private const char ValueSeparator = ':';
private readonly string _originalString;
private readonly int _timezoneOffset;
public SearchString(string str, int timezoneOffset = 0)
{
str = str ?? string.Empty;
str ??= string.Empty;
str = str.Trim();
_OriginalString = str.Trim();
TextSearch = _OriginalString;
var splitted = str.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
_originalString = str;
_timezoneOffset = timezoneOffset;
TextSearch = _originalString;
var splitted = str.Split(new [] { FilterSeparator }, StringSplitOptions.RemoveEmptyEntries);
Filters
= splitted
.Select(t => t.Split(new char[] { ':' }, 2, StringSplitOptions.RemoveEmptyEntries))
.Select(t => t.Split(new [] { ValueSeparator }, 2, StringSplitOptions.RemoveEmptyEntries))
.Where(kv => kv.Length == 2)
.Select(kv => new KeyValuePair<string, string>(kv[0].ToLowerInvariant().Trim(), kv[1]))
.Select(kv => new KeyValuePair<string, string>(UnifyKey(kv[0]), kv[1]))
.ToMultiValueDictionary(o => o.Key, o => o.Value);
var val = splitted.FirstOrDefault(a => a?.IndexOf(':', StringComparison.OrdinalIgnoreCase) == -1);
if (val != null)
TextSearch = val.Trim();
else
TextSearch = "";
var val = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1);
TextSearch = val != null ? val.Trim() : string.Empty;
}
public string TextSearch { get; private set; }
public MultiValueDictionary<string, string> Filters { get; private set; }
public MultiValueDictionary<string, string> Filters { get; }
public override string ToString()
{
return _OriginalString;
return _originalString;
}
internal string[] GetFilterArray(string key)
public string Toggle(string key, string value)
{
key = UnifyKey(key);
var keyValue = $"{key}{ValueSeparator}{value}";
var prependOnInsert = string.IsNullOrEmpty(ToString()) ? string.Empty : $"{ToString()}{FilterSeparator}";
if (!ContainsFilter(key)) return Finalize($"{prependOnInsert}{keyValue}");
var boolFilter = GetFilterBool(key);
if (boolFilter != null)
{
return Finalize(ToString().Replace(keyValue, string.Empty));
}
var dateFilter = GetFilterDate(key, _timezoneOffset);
if (dateFilter != null)
{
var current = GetFilterArray(key).First();
var oldValue = $"{key}{ValueSeparator}{current}";
var newValue = string.IsNullOrEmpty(value) || current == value ? string.Empty : keyValue;
return Finalize(_originalString.Replace(oldValue, newValue));
}
var arrayFilter = GetFilterArray(key);
if (arrayFilter != null)
{
if (string.IsNullOrEmpty(value))
{
return Finalize(arrayFilter.Aggregate(ToString(), (current, filter) =>
current.Replace($"{key}{ValueSeparator}{filter}", string.Empty)));
}
return Finalize(arrayFilter.Contains(value)
? ToString().Replace(keyValue, string.Empty)
: $"{prependOnInsert}{keyValue}"
);
}
return Finalize(ToString());
}
public string WithoutSearchText()
{
return string.IsNullOrEmpty(TextSearch)
? Finalize(ToString())
: Finalize(ToString()).Replace(TextSearch, string.Empty);
}
public string[] GetFilterArray(string key)
{
key = UnifyKey(key);
return Filters.ContainsKey(key) ? Filters[key].ToArray() : null;
}
internal bool? GetFilterBool(string key)
public bool? GetFilterBool(string key)
{
key = UnifyKey(key);
if (!Filters.ContainsKey(key))
return null;
return bool.TryParse(Filters[key].First(), out var r) ?
r : (bool?)null;
return bool.TryParse(Filters[key].First(), out var r) ? r : null;
}
internal DateTimeOffset? GetFilterDate(string key, int timezoneOffset)
public DateTimeOffset? GetFilterDate(string key, int timezoneOffset)
{
key = UnifyKey(key);
if (!Filters.ContainsKey(key))
return null;
var val = Filters[key].First();
// handle special string values
if (val == "-24h")
return DateTimeOffset.UtcNow.AddHours(-24).AddMinutes(timezoneOffset);
else if (val == "-3d")
return DateTimeOffset.UtcNow.AddDays(-3).AddMinutes(timezoneOffset);
else if (val == "-7d")
return DateTimeOffset.UtcNow.AddDays(-7).AddMinutes(timezoneOffset);
switch (val)
{
// handle special string values
case "-24h":
case "-1d":
return DateTimeOffset.UtcNow.AddDays(-1).AddMinutes(timezoneOffset);
case "-3d":
return DateTimeOffset.UtcNow.AddDays(-3).AddMinutes(timezoneOffset);
case "-7d":
return DateTimeOffset.UtcNow.AddDays(-7).AddMinutes(timezoneOffset);
}
// default parsing logic
var success = DateTimeOffset.TryParse(val, null as IFormatProvider, DateTimeStyles.AssumeUniversal, out var r);
var success = DateTimeOffset.TryParse(val, null, DateTimeStyles.AssumeUniversal, out var r);
if (success)
{
r = r.AddMinutes(timezoneOffset);
@ -78,6 +134,20 @@ namespace BTCPayServer
return null;
}
internal bool ContainsFilter(string key) => Filters.ContainsKey(key);
public bool ContainsFilter(string key)
{
return Filters.ContainsKey(UnifyKey(key));
}
private string UnifyKey(string key)
{
return key.ToLowerInvariant().Trim();
}
private static string Finalize(string str)
{
var value = str.TrimStart(FilterSeparator).TrimEnd(FilterSeparator);
return string.IsNullOrEmpty(value) ? " " : value;
}
}
}

View file

@ -5,9 +5,25 @@
@model InvoicesModel
@{
ViewData.SetActivePage(InvoiceNavPages.Index, "Invoices");
var storeIds = string.Join("", Model.StoreIds.Select(storeId => $",storeid:{storeId}"));
if (this.Context.GetRouteValue("storeId") is string)
storeIds = string.Empty;
var statusFilterCount = CountArrayFilter("status") + CountArrayFilter("exceptionstatus") + (HasBooleanFilter("includearchived") ? 1 : 0) + (HasBooleanFilter("unusual") ? 1 : 0);
var hasDateFilter = HasArrayFilter("startdate") || HasArrayFilter("enddate");
var appFilterCount = Model.Apps.Count(app => HasArrayFilter("orderid", app.AppOrderId));
}
@functions
{
private int CountArrayFilter(string type) =>
Model.Search.ContainsFilter(type) ? Model.Search.GetFilterArray(type).Length : 0;
private bool HasArrayFilter(string type, string key = null) =>
Model.Search.ContainsFilter(type) && (key is null || Model.Search.GetFilterArray(type).Contains(key));
private bool HasBooleanFilter(string key) =>
Model.Search.ContainsFilter(key) && Model.Search.GetFilterBool(key) is true;
private bool HasCustomDateFilter() =>
Model.Search.ContainsFilter("startdate") && Model.Search.ContainsFilter("enddate");
}
@section PageHeadContent
@ -16,6 +32,16 @@
.invoice-payments {
padding-left: var(--btcpay-space-l);
}
.dropdown > .btn {
min-width: 7rem;
padding-left: 1rem;
text-align: left;
}
@@media (max-width: 568px) {
#SearchText {
width: 100%;
}
}
</style>
}
@ -59,7 +85,7 @@
var dtpStartDate = $("#dtpStartDate").val();
if (dtpStartDate !== null && dtpStartDate !== "") {
filterString = "startDate%3A" + dtpStartDate;
filterString = "startdate%3A" + dtpStartDate;
}
var dtpEndDate = $("#dtpEndDate").val();
@ -67,7 +93,7 @@
if (filterString !== "") {
filterString += ",";
}
filterString += "endDate%3A" + dtpEndDate;
filterString += "enddate%3A" + dtpEndDate;
}
if (filterString !== "") {
@ -143,6 +169,14 @@
<div class="flex-fill">
<p class="mb-2">Invoices are documents issued by the seller to a buyer to collect payment.</p>
<p class="mb-3">An invoice must be paid within a defined time interval at a fixed exchange rate to protect the issuer from price fluctuations.</p>
<p class="mb-3">
You can also apply filters to your search by searching for <code>filtername:value</code>.
Be sure to split your search parameters with comma. Supported filters are:
</p>
<ul>
<li><code>orderid:id</code> for filtering a specific order</li>
<li><code>itemcode:code</code> for filtering a specific type of item purchased through the pos or crowdfund apps</li>
</ul>
<a href="https://docs.btcpayserver.org/Invoices/" target="_blank" rel="noreferrer noopener">Learn More</a>
</div>
<button type="button" class="btn-close ms-auto" data-bs-toggle="collapse" data-bs-target="#descriptor" aria-expanded="false" aria-label="Close">
@ -200,75 +234,103 @@
</div>
</div>
<div id="help" class="row collapse">
<div class="col-xl-8 pb-3">
<p>
You can search for invoice Id, deposit address, price, order id, store id, any buyer information and any product information.
Be sure to split your search parameters with comma, for example:<br />
<code>startdate:2019-04-25 13:00:00, status:paid</code>
</p>
<p class="mb-2">
You can also apply filters to your search by searching for <code>filtername:value</code>, supported filters are:
</p>
<ul>
<li><code>storeid:id</code> for filtering a specific store</li>
<li><code>orderid:id</code> for filtering a specific order</li>
<li><code>itemcode:code</code> for filtering a specific type of item purchased through the pos or crowdfund apps</li>
<li><code>status:(expired|invalid|complete|confirmed|paid|new)</code> for filtering a specific status</li>
<li><code>exceptionstatus:(paidover|paidlate|paidpartial)</code> for filtering a specific exception state</li>
<li><code>unusual:(true|false)</code> for filtering invoices which might requires merchant attention (those invalid or with an exceptionstatus)</li>
<li><code>startdate:yyyy-MM-dd HH:mm:ss</code> getting invoices that were created after certain date</li>
<li><code>enddate:yyyy-MM-dd HH:mm:ss</code> getting invoices that were created before certain date</li>
</ul>
</div>
</div>
<form class="@(Model.Invoices.Count > 0 ? "col-xl-7 col-xxl-8 " : "")mb-4" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" method="get">
<input type="hidden" asp-for="Count" />
<form class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 @(Model.Invoices.Any() ? "col-xl-7 col-xxl-8" : null)" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" method="get">
<input asp-for="Count" type="hidden" />
<input asp-for="TimezoneOffset" type="hidden" />
<div class="input-group">
<a href="#help" class="input-group-text text-secondary text-decoration-none" data-bs-toggle="collapse">
<span class="fa fa-filter"></span>
</a>
<input asp-for="SearchTerm" class="form-control" />
<button type="submit" class="btn btn-secondary" title="Search invoice">
<span class="fa fa-search"></span> Search
<input asp-for="SearchTerm" type="hidden" value="@Model.Search.WithoutSearchText()"/>
<input asp-for="SearchText" class="form-control" placeholder="Search…" />
<div class="dropdown">
<button id="StatusOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (statusFilterCount > 0)
{
<span>@statusFilterCount Status</span>
}
else
{
<span>All Status</span>
}
</button>
<button type="button" id="SearchOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<div class="dropdown-menu dropdown-menu-end" aria-labelledby="SearchOptionsToggle">
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="status:invalid@{@storeIds}">Invalid Invoices</a>
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="status:processing,status:settled@{@storeIds}">Settled Invoices</a>
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="exceptionstatus:paidLate@{@storeIds}">Settled Late Invoices</a>
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="exceptionstatus:paidPartial@{@storeIds}">Settled Partial Invoices</a>
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="exceptionstatus:paidOver@{@storeIds}">Settled Over Invoices</a>
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="unusual:true@{@storeIds}">Unusual Invoices</a>
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="includearchived:true@{@storeIds}" id="SearchOptionsIncludeArchived">Archived Invoices</a>
<div role="separator" class="dropdown-divider"></div>
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-timezoneoffset="0" asp-route-searchTerm="startDate:-24h@{@storeIds}">Last 24 hours</a>
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-timezoneoffset="0" asp-route-searchTerm="startDate:-3d@{@storeIds}">Last 3 days</a>
<a class="dropdown-item" asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-timezoneoffset="0" asp-route-searchTerm="startDate:-7d@{@storeIds}">Last 7 days</a>
<button type="button" class="dropdown-item" data-bs-toggle="modal" data-bs-target="#customRangeModal">Custom Range</button>
<div role="separator" class="dropdown-divider"></div>
<a class="dropdown-item" href="?searchTerm=">Unfiltered</a>
<div class="dropdown-menu" aria-labelledby="StatusOptionsToggle">
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "invalid")" class="dropdown-item @(HasArrayFilter("status", "invalid") ? "custom-active" : "")">Invalid</a>
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "processing")" class="dropdown-item @(HasArrayFilter("status", "processing") ? "custom-active" : "")">Processing</a>
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("status", "settled")" class="dropdown-item @(HasArrayFilter("status", "settled") ? "custom-active" : "")">Settled</a>
<hr class="dropdown-divider">
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("exceptionstatus", "paidLate")" class="dropdown-item @(HasArrayFilter("exceptionstatus", "paidLate") ? "custom-active" : "")">Settled Late</a>
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("exceptionstatus", "paidPartial")" class="dropdown-item @(HasArrayFilter("exceptionstatus", "paidPartial") ? "custom-active" : "")">Settled Partial</a>
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("exceptionstatus", "paidOver")" class="dropdown-item @(HasArrayFilter("exceptionstatus", "paidOver") ? "custom-active" : "")">Settled Over</a>
<hr class="dropdown-divider">
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("unusual", "true")" class="dropdown-item @(HasBooleanFilter("unusual") ? "custom-active" : "")">Unusual</a>
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("includearchived", "true")" class="dropdown-item @(HasBooleanFilter("includearchived") ? "custom-active" : "")" id="StatusOptionsIncludeArchived">Archived</a>
</div>
</div>
@if (Model.Apps.Any())
{
<div class="dropdown">
<button id="AppOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (appFilterCount > 0)
{
<span>@appFilterCount Plugin@(appFilterCount > 1 ? "s" : "")</span>
}
else
{
<span>All Plugins</span>
}
</button>
<div class="dropdown-menu" aria-labelledby="AppOptionsToggle">
@foreach (var app in Model.Apps)
{
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("orderid", app.AppOrderId)" class="dropdown-item @(HasArrayFilter("orderid", app.AppOrderId) ? "custom-active" : "")">@app.AppName</a>
}
</div>
</div>
}
<div class="dropdown">
<button id="DateOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
@if (hasDateFilter)
{
if (HasArrayFilter("startdate", "-1d"))
{
<span>24 Hours</span>
}
else if (HasArrayFilter("startdate", "-3d"))
{
<span>3 Days</span>
}
else if (HasArrayFilter("startdate", "-7d"))
{
<span>7 Days</span>
}
else
{
<span>Custom</span>
}
}
else
{
<span>All Time</span>
}
</button>
<div class="dropdown-menu" aria-labelledby="DateOptionsToggle">
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("startdate", "-1d")" class="dropdown-item @(HasArrayFilter("startdate", "-1d") ? "custom-active" : "")">Last 24 hours</a>
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("startdate", "-3d")" class="dropdown-item @(HasArrayFilter("startdate", "-3d") ? "custom-active" : "")">Last 3 days</a>
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("startdate", "-7d")" class="dropdown-item @(HasArrayFilter("startdate", "-7d") ? "custom-active" : "")">Last 7 days</a>
<button type="button" class="dropdown-item @(HasCustomDateFilter() ? "custom-active" : "")" data-bs-toggle="modal" data-bs-target="#customRangeModal">Custom Range</button>
</div>
</div>
<span asp-validation-for="SearchTerm" class="text-danger"></span>
</form>
@if (Model.Invoices.Count > 0)
@if (Model.Invoices.Any())
{
<form method="post" id="MassAction" asp-action="MassAction" class="">
<div class="d-inline-flex align-items-center pb-2 float-xl-end mb-2 gap-3">
<input type="hidden" name="storeId" value="@Model.StoreId" />
<div class="dropdown order-xl-1">
<button class="btn btn-secondary dropdown-toggle" type="button" id="ActionsDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<button class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" id="ActionsDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Actions
</button>
<div class="dropdown-menu dropdown-menu-xl-end" aria-labelledby="ActionsDropdownToggle">
<button type="submit" class="dropdown-item" name="command" value="archive" id="ActionsDropdownArchive">Archive</button>
@if (Model.IncludeArchived)
@if (HasBooleanFilter("includearchived"))
{
<button type="submit" asp-action="MassAction" class="dropdown-item" name="command" value="unarchive" id="ActionsDropdownUnarchive">Unarchive</button>
}
@ -276,7 +338,7 @@
</div>
</div>
<div class="dropdown d-inline-flex align-items-center gap-3">
<button class="btn btn-secondary dropdown-toggle order-xl-1" type="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<button class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret order-xl-1" type="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Export
</button>
<div class="dropdown-menu" aria-labelledby="ExportDropdownToggle">

View file

@ -11028,6 +11028,19 @@ fieldset:disabled .btn {
[list]::-webkit-list-button {
content: none;
}
.dropdown-toggle.dropdown-toggle-custom-caret {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%236E7681' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 16px 12px;
padding-right: 2.25rem;
}
.dropdown-toggle.dropdown-toggle-custom-caret::after {
border: none;
margin-left: var(--btcpay-space-s);
}
/* Scrollbar - first works on Firefox, rest on WebKit-based browsers */
* {
--btcpay-scrollbar-width: 0.375rem;
@ -11235,6 +11248,22 @@ ul:not([class]) li {
padding-left: 1rem;
}
.dropdown .custom-active {
display: inline-flex;
align-items: center;
gap: var(--btcpay-space-s);
}
.dropdown .custom-active::after {
display: inline-block;
margin-left: auto;
content: '';
width: 0.5em;
height: 0.5em;
background-color: var(--btcpay-body-bg-active);
border-radius: 50%;
}
.font-monospace {
font-size: 0.95rem;
}

View file

@ -1,5 +1,5 @@
:root {
--btcpay-neutral-50: #0D1117;
--btcpay-neutral-50: #0d1117;
--btcpay-neutral-100: var(--btcpay-neutral-dark-900);
--btcpay-neutral-200: var(--btcpay-neutral-dark-800);
--btcpay-neutral-300: var(--btcpay-neutral-dark-700);