Introduce Server paging for Payouts List (#2564)

* Introduce Server paging for Payouts List

* Add paging params

* Minor code and formatting improvements

* View updates

* Apply suggestions from code review

Co-authored-by: Zaxounette <51208677+Zaxounette@users.noreply.github.com>

* fix tests

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
Co-authored-by: Zaxounette <51208677+Zaxounette@users.noreply.github.com>
This commit is contained in:
Andrew Camilleri
2021-06-30 08:59:01 +01:00
committed by GitHub
parent 33de4cccfc
commit 6c856aba48
5 changed files with 267 additions and 270 deletions

View File

@@ -912,113 +912,113 @@ namespace BTCPayServer.Tests
[Trait("Selenium", "Selenium")] [Trait("Selenium", "Selenium")]
public async Task CanUsePullPaymentsViaUI() public async Task CanUsePullPaymentsViaUI()
{ {
using (var s = SeleniumTester.Create()) using var s = SeleniumTester.Create();
{ await s.StartAsync();
await s.StartAsync(); s.RegisterNewUser(true);
s.RegisterNewUser(true); s.CreateNewStore();
s.CreateNewStore(); s.GenerateWallet("BTC", "", true, true);
s.GenerateWallet("BTC", "", true, true);
await s.Server.ExplorerNode.GenerateAsync(1); await s.Server.ExplorerNode.GenerateAsync(1);
await s.FundStoreWallet(denomination: 50.0m); await s.FundStoreWallet(denomination: 50.0m);
s.GoToWallet(navPages: WalletsNavPages.PullPayments); s.GoToWallet(navPages: WalletsNavPages.PullPayments);
s.Driver.FindElement(By.Id("NewPullPayment")).Click(); s.Driver.FindElement(By.Id("NewPullPayment")).Click();
s.Driver.FindElement(By.Id("Name")).SendKeys("PP1"); s.Driver.FindElement(By.Id("Name")).SendKeys("PP1");
s.Driver.FindElement(By.Id("Amount")).Clear(); s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys("99.0");; s.Driver.FindElement(By.Id("Amount")).SendKeys("99.0");;
s.Driver.FindElement(By.Id("Create")).Click(); s.Driver.FindElement(By.Id("Create")).Click();
s.Driver.FindElement(By.LinkText("View")).Click(); s.Driver.FindElement(By.LinkText("View")).Click();
s.GoToWallet(navPages: WalletsNavPages.PullPayments); s.GoToWallet(navPages: WalletsNavPages.PullPayments);
s.Driver.FindElement(By.Id("NewPullPayment")).Click(); s.Driver.FindElement(By.Id("NewPullPayment")).Click();
s.Driver.FindElement(By.Id("Name")).SendKeys("PP2"); s.Driver.FindElement(By.Id("Name")).SendKeys("PP2");
s.Driver.FindElement(By.Id("Amount")).Clear(); s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys("100.0"); s.Driver.FindElement(By.Id("Amount")).SendKeys("100.0");
s.Driver.FindElement(By.Id("Create")).Click(); s.Driver.FindElement(By.Id("Create")).Click();
// This should select the first View, ie, the last one PP2 // This should select the first View, ie, the last one PP2
s.Driver.FindElement(By.LinkText("View")).Click(); s.Driver.FindElement(By.LinkText("View")).Click();
var address = await s.Server.ExplorerNode.GetNewAddressAsync(); var address = await s.Server.ExplorerNode.GetNewAddressAsync();
s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString()); s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString());
s.Driver.FindElement(By.Id("ClaimedAmount")).Clear(); s.Driver.FindElement(By.Id("ClaimedAmount")).Clear();
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("15" + Keys.Enter); s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("15" + Keys.Enter);
s.FindAlertMessage(); s.FindAlertMessage();
// We should not be able to use an address already used // We should not be able to use an address already used
s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString()); s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString());
s.Driver.FindElement(By.Id("ClaimedAmount")).Clear(); s.Driver.FindElement(By.Id("ClaimedAmount")).Clear();
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("20" + Keys.Enter); s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("20" + Keys.Enter);
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error); s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error);
address = await s.Server.ExplorerNode.GetNewAddressAsync(); address = await s.Server.ExplorerNode.GetNewAddressAsync();
s.Driver.FindElement(By.Id("Destination")).Clear(); s.Driver.FindElement(By.Id("Destination")).Clear();
s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString()); s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString());
s.Driver.FindElement(By.Id("ClaimedAmount")).Clear(); s.Driver.FindElement(By.Id("ClaimedAmount")).Clear();
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("20" + Keys.Enter); s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("20" + Keys.Enter);
s.FindAlertMessage(); s.FindAlertMessage();
Assert.Contains("Awaiting Approval", s.Driver.PageSource); Assert.Contains("Awaiting Approval", s.Driver.PageSource);
var viewPullPaymentUrl = s.Driver.Url; var viewPullPaymentUrl = s.Driver.Url;
// This one should have nothing // This one should have nothing
s.GoToWallet(navPages: WalletsNavPages.PullPayments); s.GoToWallet(navPages: WalletsNavPages.PullPayments);
var payouts = s.Driver.FindElements(By.ClassName("pp-payout")); var payouts = s.Driver.FindElements(By.ClassName("pp-payout"));
Assert.Equal(2, payouts.Count); Assert.Equal(2, payouts.Count);
payouts[1].Click(); payouts[1].Click();
Assert.Empty(s.Driver.FindElements(By.ClassName("payout"))); Assert.Empty(s.Driver.FindElements(By.ClassName("payout")));
// PP2 should have payouts // PP2 should have payouts
s.GoToWallet(navPages: WalletsNavPages.PullPayments); s.GoToWallet(navPages: WalletsNavPages.PullPayments);
payouts = s.Driver.FindElements(By.ClassName("pp-payout")); payouts = s.Driver.FindElements(By.ClassName("pp-payout"));
payouts[0].Click(); payouts[0].Click();
Assert.NotEmpty(s.Driver.FindElements(By.ClassName("payout"))); Assert.NotEmpty(s.Driver.FindElements(By.ClassName("payout")));
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-selectAllCheckbox")).Click(); s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-selectAllCheckbox")).Click();
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-actions")).Click(); s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-actions")).Click();
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-approve-pay")).Click(); s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-approve-pay")).Click();
s.Driver.FindElement(By.Id("SignTransaction")).Click(); s.Driver.FindElement(By.Id("SignTransaction")).Click();
s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).Click(); s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).Click();
s.FindAlertMessage(); s.FindAlertMessage();
TestUtils.Eventually(() => TestUtils.Eventually(() =>
{ {
s.Driver.Navigate().Refresh(); s.Driver.Navigate().Refresh();
Assert.Contains("badge transactionLabel", s.Driver.PageSource); Assert.Contains("badge transactionLabel", s.Driver.PageSource);
}); });
Assert.Equal("payout", s.Driver.FindElement(By.ClassName("transactionLabel")).Text); Assert.Equal("payout", s.Driver.FindElement(By.ClassName("transactionLabel")).Text);
s.GoToWallet(navPages: WalletsNavPages.Payouts); s.GoToWallet(navPages: WalletsNavPages.Payouts);
ReadOnlyCollection<IWebElement> txs; var x = s.Driver.PageSource;
TestUtils.Eventually(() => s.Driver.FindElement(By.Id($"{PayoutState.InProgress}-view")).Click();
{ ReadOnlyCollection<IWebElement> txs;
s.Driver.Navigate().Refresh(); TestUtils.Eventually(() =>
{
s.Driver.Navigate().Refresh();
txs = s.Driver.FindElements(By.ClassName("transaction-link"));
Assert.Equal(2, txs.Count);
});
s.Driver.Navigate().GoToUrl(viewPullPaymentUrl);
txs = s.Driver.FindElements(By.ClassName("transaction-link")); txs = s.Driver.FindElements(By.ClassName("transaction-link"));
Assert.Equal(2, txs.Count); Assert.Equal(2, txs.Count);
Assert.Contains("In Progress", s.Driver.PageSource); });
await s.Server.ExplorerNode.GenerateAsync(1); s.Driver.Navigate().GoToUrl(viewPullPaymentUrl);
txs = s.Driver.FindElements(By.ClassName("transaction-link"));
Assert.Equal(2, txs.Count);
Assert.Contains(PayoutState.InProgress.GetStateString(), s.Driver.PageSource);
TestUtils.Eventually(() => await s.Server.ExplorerNode.GenerateAsync(1);
{
s.Driver.Navigate().Refresh();
Assert.Contains("Completed", s.Driver.PageSource);
});
await s.Server.ExplorerNode.GenerateAsync(10);
var pullPaymentId = viewPullPaymentUrl.Split('/').Last();
await TestUtils.EventuallyAsync(async () => TestUtils.Eventually(() =>
{ {
using var ctx = s.Server.PayTester.GetService<ApplicationDbContextFactory>().CreateContext(); s.Driver.Navigate().Refresh();
var payoutsData = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId).ToListAsync(); Assert.Contains(PayoutState.Completed.GetStateString(), s.Driver.PageSource);
Assert.True(payoutsData.All(p => p.State == PayoutState.Completed)); });
}); await s.Server.ExplorerNode.GenerateAsync(10);
} var pullPaymentId = viewPullPaymentUrl.Split('/').Last();
await TestUtils.EventuallyAsync(async () =>
{
using var ctx = s.Server.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
var payoutsData = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId).ToListAsync();
Assert.True(payoutsData.All(p => p.State == PayoutState.Completed));
});
} }
private static void CanBrowseContent(SeleniumTester s) private static void CanBrowseContent(SeleniumTester s)

View File

@@ -24,8 +24,7 @@ namespace BTCPayServer.Controllers
{ {
public partial class WalletsController public partial class WalletsController
{ {
[HttpGet] [HttpGet("{walletId}/pull-payments/new")]
[Route("{walletId}/pull-payments/new")]
public IActionResult NewPullPayment([ModelBinder(typeof(WalletIdModelBinder))] public IActionResult NewPullPayment([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId) WalletId walletId)
{ {
@@ -40,9 +39,8 @@ namespace BTCPayServer.Controllers
EmbeddedCSS = "", EmbeddedCSS = "",
}); });
} }
[HttpPost] [HttpPost("{walletId}/pull-payments/new")]
[Route("{walletId}/pull-payments/new")]
public async Task<IActionResult> NewPullPayment([ModelBinder(typeof(WalletIdModelBinder))] public async Task<IActionResult> NewPullPayment([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, NewPullPaymentModel model) WalletId walletId, NewPullPaymentModel model)
{ {
@@ -86,9 +84,8 @@ namespace BTCPayServer.Controllers
}); });
return RedirectToAction(nameof(PullPayments), new { walletId = walletId.ToString() }); return RedirectToAction(nameof(PullPayments), new { walletId = walletId.ToString() });
} }
[HttpGet] [HttpGet("{walletId}/pull-payments")]
[Route("{walletId}/pull-payments")]
public async Task<IActionResult> PullPayments( public async Task<IActionResult> PullPayments(
[ModelBinder(typeof(WalletIdModelBinder))] [ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId) WalletId walletId)
@@ -149,8 +146,7 @@ namespace BTCPayServer.Controllers
return time; return time;
} }
[HttpGet] [HttpGet("{walletId}/pull-payments/{pullPaymentId}/archive")]
[Route("{walletId}/pull-payments/{pullPaymentId}/archive")]
public IActionResult ArchivePullPayment( public IActionResult ArchivePullPayment(
[ModelBinder(typeof(WalletIdModelBinder))] [ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletId walletId,
@@ -164,8 +160,8 @@ namespace BTCPayServer.Controllers
Action = "Archive" Action = "Archive"
}); });
} }
[HttpPost]
[Route("{walletId}/pull-payments/{pullPaymentId}/archive")] [HttpPost("{walletId}/pull-payments/{pullPaymentId}/archive")]
public async Task<IActionResult> ArchivePullPaymentPost( public async Task<IActionResult> ArchivePullPaymentPost(
[ModelBinder(typeof(WalletIdModelBinder))] [ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletId walletId,
@@ -180,8 +176,7 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(PullPayments), new { walletId = walletId.ToString() }); return RedirectToAction(nameof(PullPayments), new { walletId = walletId.ToString() });
} }
[HttpPost] [HttpPost("{walletId}/payouts")]
[Route("{walletId}/payouts")]
public async Task<IActionResult> PayoutsPost( public async Task<IActionResult> PayoutsPost(
[ModelBinder(typeof(WalletIdModelBinder))] [ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, PayoutsModel vm, CancellationToken cancellationToken) WalletId walletId, PayoutsModel vm, CancellationToken cancellationToken)
@@ -312,7 +307,7 @@ namespace BTCPayServer.Controllers
}); });
if (result != PayoutPaidRequest.PayoutPaidResult.Ok) if (result != PayoutPaidRequest.PayoutPaidResult.Ok)
{ {
this.TempData.SetStatusMessageModel(new StatusMessageModel() TempData.SetStatusMessageModel(new StatusMessageModel()
{ {
Message = PayoutPaidRequest.GetErrorMessage(result), Message = PayoutPaidRequest.GetErrorMessage(result),
Severity = StatusMessageModel.StatusSeverity.Error Severity = StatusMessageModel.StatusSeverity.Error
@@ -362,65 +357,77 @@ namespace BTCPayServer.Controllers
return payouts; return payouts;
} }
[HttpGet] [HttpGet("{walletId}/payouts")]
[Route("{walletId}/payouts")]
public async Task<IActionResult> Payouts( public async Task<IActionResult> Payouts(
[ModelBinder(typeof(WalletIdModelBinder))] [ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, PayoutsModel vm = null) WalletId walletId, string pullPaymentId, PayoutState payoutState,
int skip = 0, int count = 50)
{ {
vm ??= new PayoutsModel(); var vm = this.ParseListQuery(new PayoutsModel
vm.PayoutStateSets ??= ((PayoutState[]) Enum.GetValues(typeof(PayoutState))).Select(state => {
new PayoutsModel.PayoutStateSet() {State = state, Payouts = new List<PayoutsModel.PayoutModel>()}).ToList(); PaymentMethodId = new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike),
using var ctx = this._dbContextFactory.CreateContext(); PullPaymentId = pullPaymentId,
PayoutState = payoutState,
Skip = skip,
Count = count
});
vm.Payouts = new List<PayoutsModel.PayoutModel>();
await using var ctx = _dbContextFactory.CreateContext();
var storeId = walletId.StoreId; var storeId = walletId.StoreId;
vm.PaymentMethodId = new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike);
var payoutRequest = ctx.Payouts.Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived); var payoutRequest = ctx.Payouts.Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived);
if (vm.PullPaymentId != null) if (vm.PullPaymentId != null)
{ {
payoutRequest = payoutRequest.Where(p => p.PullPaymentDataId == vm.PullPaymentId); payoutRequest = payoutRequest.Where(p => p.PullPaymentDataId == vm.PullPaymentId);
vm.PullPaymentName = (await ctx.PullPayments.FindAsync(pullPaymentId)).GetBlob().Name;
} }
if (vm.PaymentMethodId != null)
{
var pmiStr = vm.PaymentMethodId.ToString();
payoutRequest = payoutRequest.Where(p => p.PaymentMethodId == pmiStr);
}
vm.PayoutStateCount = payoutRequest.GroupBy(data => data.State)
.Select(e => new {e.Key, Count = e.Count()})
.ToDictionary(arg => arg.Key, arg => arg.Count);
foreach (PayoutState value in Enum.GetValues(typeof(PayoutState)))
{
if(vm.PayoutStateCount.ContainsKey(value))
continue;
vm.PayoutStateCount.Add(value, 0);
}
vm.PayoutStateCount = vm.PayoutStateCount.OrderBy(pair => pair.Key)
.ToDictionary(pair => pair.Key, pair => pair.Value);
payoutRequest = payoutRequest.Where(p => p.State == vm.PayoutState);
vm.Total = await payoutRequest.CountAsync();
payoutRequest = payoutRequest.Skip(vm.Skip).Take(vm.Count);
var payouts = await payoutRequest.OrderByDescending(p => p.Date) var payouts = await payoutRequest.OrderByDescending(p => p.Date)
.Select(o => new .Select(o => new
{ {
Payout = o, Payout = o,
PullPayment = o.PullPaymentData PullPayment = o.PullPaymentData
}).ToListAsync(); }).ToListAsync();
foreach (var stateSet in payouts.GroupBy(arg => arg.Payout.State)) foreach (var item in payouts)
{ {
var state = vm.PayoutStateSets.SingleOrDefault(set => set.State == stateSet.Key); var ppBlob = item.PullPayment.GetBlob();
if (state == null) var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings);
var m = new PayoutsModel.PayoutModel
{ {
state = new PayoutsModel.PayoutStateSet() PullPaymentId = item.PullPayment.Id,
{ PullPaymentName = ppBlob.Name ?? item.PullPayment.Id,
Payouts = new List<PayoutsModel.PayoutModel>(), State = stateSet.Key Date = item.Payout.Date,
}; PayoutId = item.Payout.Id,
vm.PayoutStateSets.Add(state); Amount = _currencyTable.DisplayFormatCurrency(payoutBlob.Amount, ppBlob.Currency),
} Destination = payoutBlob.Destination
};
foreach (var item in stateSet) var handler = _payoutHandlers
{ .FirstOrDefault(handler => handler.CanHandle(item.Payout.GetPaymentMethodId()));
var proofBlob = handler?.ParseProof(item.Payout);
if (item.Payout.GetPaymentMethodId() != vm.PaymentMethodId) m.ProofLink = proofBlob?.Link;
continue; vm.Payouts.Add(m);
var ppBlob = item.PullPayment.GetBlob();
var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings);
var m = new PayoutsModel.PayoutModel();
m.PullPaymentId = item.PullPayment.Id;
m.PullPaymentName = ppBlob.Name ?? item.PullPayment.Id;
m.Date = item.Payout.Date;
m.PayoutId = item.Payout.Id;
m.Amount = _currencyTable.DisplayFormatCurrency(payoutBlob.Amount, ppBlob.Currency);
m.Destination = payoutBlob.Destination;
var handler = _payoutHandlers
.FirstOrDefault(handler => handler.CanHandle(item.Payout.GetPaymentMethodId()));
var proofBlob = handler?.ParseProof(item.Payout);
m.ProofLink = proofBlob?.Link;
state.Payouts.Add(m);
}
} }
vm.PayoutStateSets = vm.PayoutStateSets.Where(set => set.Payouts?.Any() is true).ToList();
return View(vm); return View(vm);
} }
} }

View File

@@ -4,6 +4,7 @@ using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Models.PaymentRequestViewModels; using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.Models.ServerViewModels; using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Models.WalletViewModels;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -21,6 +22,8 @@ namespace BTCPayServer
prop = typeof(UserPrefsCookie).GetProperty(nameof(UserPrefsCookie.PaymentRequestsQuery)); prop = typeof(UserPrefsCookie).GetProperty(nameof(UserPrefsCookie.PaymentRequestsQuery));
else if (model is UsersViewModel) else if (model is UsersViewModel)
prop = typeof(UserPrefsCookie).GetProperty(nameof(UserPrefsCookie.UsersQuery)); prop = typeof(UserPrefsCookie).GetProperty(nameof(UserPrefsCookie.UsersQuery));
else if (model is PayoutsModel)
prop = typeof(UserPrefsCookie).GetProperty(nameof(UserPrefsCookie.UsersQuery));
else else
throw new Exception("Unsupported BasePagingViewModel for cookie user preferences saving"); throw new Exception("Unsupported BasePagingViewModel for cookie user preferences saving");
@@ -78,6 +81,7 @@ namespace BTCPayServer
public ListQueryDataHolder PaymentRequestsQuery { get; set; } public ListQueryDataHolder PaymentRequestsQuery { get; set; }
public ListQueryDataHolder UsersQuery { get; set; } public ListQueryDataHolder UsersQuery { get; set; }
public ListQueryDataHolder PayoutsQuery { get; set; }
} }
class ListQueryDataHolder class ListQueryDataHolder

View File

@@ -2,18 +2,21 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Payments; using BTCPayServer.Payments;
namespace BTCPayServer.Models.WalletViewModels namespace BTCPayServer.Models.WalletViewModels
{ {
public class PayoutsModel public class PayoutsModel : BasePagingViewModel
{ {
public string PullPaymentId { get; set; } public string PullPaymentId { get; set; }
public string Command { get; set; } public string Command { get; set; }
public List<PayoutStateSet> PayoutStateSets{ get; set; } public Dictionary<PayoutState, int> PayoutStateCount { get; set; }
public PaymentMethodId PaymentMethodId { get; set; } public PaymentMethodId PaymentMethodId { get; set; }
public List<PayoutModel> Payouts { get; set; }
public PayoutState PayoutState { get; set; }
public string PullPaymentName { get; set; }
public class PayoutModel public class PayoutModel
{ {
public string PayoutId { get; set; } public string PayoutId { get; set; }
@@ -26,16 +29,9 @@ namespace BTCPayServer.Models.WalletViewModels
public string ProofLink { get; set; } public string ProofLink { get; set; }
} }
public class PayoutStateSet
{
public PayoutState State { get; set; }
public List<PayoutModel> Payouts { get; set; }
}
public string[] GetSelectedPayouts(PayoutState state) public string[] GetSelectedPayouts(PayoutState state)
{ {
return PayoutStateSets.Where(set => set.State == state) return Payouts.Where(model => model.Selected).Select(model => model.PayoutId)
.SelectMany(set => set.Payouts.Where(model => model.Selected).Select(model => model.PayoutId))
.ToArray(); .ToArray();
} }
} }

View File

@@ -4,7 +4,22 @@
@inject IEnumerable<IPayoutHandler> PayoutHandlers; @inject IEnumerable<IPayoutHandler> PayoutHandlers;
@{ @{
Layout = "../Shared/_NavLayout.cshtml"; Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePageAndTitle(WalletsNavPages.Payouts, $"Manage {Model.PaymentMethodId.ToPrettyString()} payouts", Context.GetStoreData().StoreName); ViewData.SetActivePageAndTitle(WalletsNavPages.Payouts, $"Manage {Model.PaymentMethodId.ToPrettyString()} payouts{(string.IsNullOrEmpty(Model.PullPaymentName) ? string.Empty : " for pull payment " + Model.PullPaymentName)}", Context.GetStoreData().StoreName);
var stateActions = new List<(string Action, string Text)>();
switch (Model.PayoutState)
{
case PayoutState.AwaitingApproval:
stateActions.Add(("approve", "Approve selected payouts"));
stateActions.Add(("approve-pay", "Approve & Send selected payouts"));
stateActions.Add(("cancel", "Cancel selected payouts"));
break;
case PayoutState.AwaitingPayment:
stateActions.Add(("pay", "Send selected payouts"));
stateActions.Add(("cancel", "Cancel selected payouts"));
stateActions.Add(("mark-paid", "Mark selected payouts as already paid"));
break;
}
} }
@section PageFootContent { @section PageFootContent {
@@ -20,127 +35,102 @@
} }
<form method="post"> <form method="post">
<h4 class="mb-3">@ViewData["Title"]</h4> <input type="hidden" asp-for="PaymentMethodId"/>
@if (!Model.PayoutStateSets.Any()) <input type="hidden" asp-for="PayoutState"/>
{ <h4 class="mb-4">@ViewData["Title"]</h4>
<p class="text-secondary mt-3">
There are no payouts yet.
</p>
}
<div class="row"> <div class="row">
<ul class="nav nav-pills"> <div class="col-auto">
@for (var index = 0; index < Model.PayoutStateSets.Count; index++) <ul class="nav nav-pills">
{ @foreach (var state in Model.PayoutStateCount)
var state = Model.PayoutStateSets[index]; {
<li class="nav-item py-0"> <li class="nav-item py-0">
<a class="nav-link me-1 @(index == 0 ? "active" : "")" data-bs-toggle="tab" href="#@state.State" role="tab">@state.State.GetStateString() (@state.Payouts.Count)</a> <a id="@state.Key-view" asp-action="Payouts" asp-route-walletId="@Context.GetRouteValue("walletId")" asp-route-payoutState="@state.Key" asp-route-pullPaymentId="@Model.PullPaymentId" class="nav-link me-1 @(state.Key == Model.PayoutState ? "active" : "")" role="tab">@state.Key.GetStateString() (@state.Value)</a>
</li> </li>
} }
</ul> </ul>
</div>
@if (Model.Payouts.Any() && stateActions.Any())
{
<div class="dropdown col-auto mt-4 ms-xl-auto mt-xl-0">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" id="@Model.PayoutState-actions">
Actions
</button>
<div class="dropdown-menu" aria-labelledby="@Model.PayoutState-actions">
@foreach (var action in stateActions)
{
<button type="submit" id="@Model.PayoutState-@action.Action" name="Command" class="dropdown-item" role="button" value="@Model.PayoutState-@action.Action">@action.Text</button>
}
</div>
</div>
}
</div> </div>
<div class="row"> <div class="row">
<div class="tab-content w-100">
@for (var index = 0; index < Model.PayoutStateSets.Count; index++)
{
var state = Model.PayoutStateSets[index];
var stateActions = new List<(string Action, string Text)>();
switch (state.State)
{
case PayoutState.AwaitingApproval:
stateActions.Add(("approve", "Approve selected payouts"));
stateActions.Add(("approve-pay", "Approve & Send selected payouts"));
stateActions.Add(("cancel", "Cancel selected payouts"));
break;
case PayoutState.AwaitingPayment:
stateActions.Add(("pay", "Send selected payouts"));
stateActions.Add(("cancel", "Cancel selected payouts"));
stateActions.Add(("mark-paid", "Mark selected payouts as already paid"));
break;
}
<div class="tab-pane @(index == 0 ? "active" : "") " id="@state.State" role="tabpanel">
<input type="hidden" asp-for="PayoutStateSets[index].State"/>
<input type="hidden" asp-for="PaymentMethodId"/>
@if (state.Payouts.Any() && stateActions.Any()) <div>
@if (Model.Payouts.Any())
{
<table class="table table-sm table-responsive-lg">
<thead class="thead-inverse">
<tr>
<th>
<input id="@Model.PayoutState-selectAllCheckbox" type="checkbox" class="form-check-input" onclick="selectAll(this, '@Model.PayoutState.ToString()'); return true;"/>
</th>
<th style="min-width: 90px;" class="col-md-auto">
Date
</th>
<th class="text-start">Source</th>
<th class="text-start">Destination</th>
<th class="text-end">Amount</th>
@if (Model.PayoutState != PayoutState.AwaitingApproval)
{
<th class="text-end">Transaction</th>
}
</tr>
</thead>
<tbody>
@for (int i = 0; i < Model.Payouts.Count; i++)
{ {
<div class="dropdown mt-2"> var pp = Model.Payouts[i];
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" id="@state.State-actions"> <tr class="payout">
Actions <td>
</button> <span>
<div class="dropdown-menu" aria-labelledby="@state.State-actions"> <input type="checkbox" class="selection-item-@Model.PayoutState.ToString() form-check-input" asp-for="Payouts[i].Selected"/>
@foreach (var action in stateActions) <input type="hidden" asp-for="Payouts[i].PayoutId"/>
{ </span>
<button type="submit" id="@state.State-@action.Action" name="Command" class="dropdown-item" role="button" value="@state.State-@action.Action">@action.Text</button> </td>
} <td>
</div> <span>@pp.Date.ToBrowserDate()</span>
</div> </td>
} <td class="mw-100">
<div> <span>@pp.PullPaymentName</span>
@if (state.Payouts.Any()) </td>
{ <td>
<table class="table table-sm table-responsive-lg"> <span>@pp.Destination</span>
<thead class="thead-inverse"> </td>
<tr> <td class="text-end">
<th> <span>@pp.Amount</span>
<input id="@state.State-selectAllCheckbox" type="checkbox" class="form-check-input" onclick="selectAll(this, '@state.State.ToString()'); return true;"/> </td>
</th> @if (Model.PayoutState != PayoutState.AwaitingApproval)
<th style="min-width: 90px;" class="col-md-auto"> {
Date <td class="text-end">
</th> @if (!(pp.ProofLink is null))
<th class="text-start">Source</th>
<th class="text-start">Destination</th>
<th class="text-end">Amount</th>
@if (state.State != PayoutState.AwaitingApproval)
{ {
<th class="text-end">Transaction</th> <a class="transaction-link" href="@pp.ProofLink">Link</a>
} }
</tr> </td>
</thead> }
<tbody> </tr>
@for (int i = 0; i < state.Payouts.Count; i++) }
{ </tbody>
var pp = state.Payouts[i]; </table>
<tr class="payout"> }
<td> else
<span> {
<input type="checkbox" class="selection-item-@state.State.ToString() form-check-input" asp-for="PayoutStateSets[index].Payouts[i].Selected"/> <p class="mb-0 py-4" id="@Model.PayoutState-no-payouts">There are no payouts matching this criteria.</p>
<input type="hidden" asp-for="PayoutStateSets[index].Payouts[i].PayoutId"/>
</span>
</td>
<td>
<span>@pp.Date.ToBrowserDate()</span>
</td>
<td class="mw-100">
<span>@pp.PullPaymentName</span>
</td>
<td>
<span>@pp.Destination</span>
</td>
<td class="text-end">
<span>@pp.Amount</span>
</td>
@if (state.State != PayoutState.AwaitingApproval)
{
<td class="text-end">
@if (!(pp.ProofLink is null))
{
<a class="transaction-link" href="@pp.ProofLink">Link</a>
}
</td>
}
</tr>
}
</tbody>
</table>
}
else
{
<p class="mb-0 p-4" id="@state.State-no-payouts">No payouts.</p>
}
</div>
</div>
} }
</div> </div>
<vc:pager view-model="Model"/>
</div> </div>
</form> </form>