Enhance purchase and contribution management
Build and Push Docker Image / build-and-push (push) Successful in 41s

Refactored UI to consolidate purchase and contribution actions into dynamic buttons. Added modals for managing purchases and contributions with admin-specific functionality. Introduced new properties and methods in `RegistryPublic.razor.cs` for state management and user filtering.

Updated `RegistryService` with methods for handling contributions and purchases, including admin-specific logic, validation, and logging. Improved database operations for robust handling of item totals and user actions. Added utility methods for generating unique codes and managing admin invites.

Refactored user filtering and display logic for admin actions, ensuring dynamic population of selectable users. Optimized handling of user actions and logs for consistency.
This commit is contained in:
Arne Moerman
2026-05-19 18:18:02 +02:00
parent 09349cb7b7
commit 89c439f80b
4 changed files with 720 additions and 168 deletions
@@ -175,21 +175,16 @@ else
{
<button class="btn btn-primary btn-sm" @onclick="() => OpenPurchasePrompt(item.Id, openTab: true)">Purchase</button>
}
@if (item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
}
@if (item.CurrentUserPurchasedQuantity > 0 || (Registry.IsAdmin && item.Purchasers.Count > 0))
{
@if (item.CurrentUserPurchasedQuantity > 0)
{
<span class="badge bg-success me-2">You purchased @item.CurrentUserPurchasedQuantity</span>
}
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaseAsync(item.Id)">Unmark purchase</button>
}
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchaseManagementPromptAsync(item.Id)">
@(Registry.IsAdmin ? "Manage purchases" : (item.CurrentUserPurchasedQuantity > 0 ? "Edit purchase" : "Mark purchased"))
</button>
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">Partially fulfill</button>
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionActionAsync(item.Id, item.CurrentUserContributionAmount)">
@(Registry.IsAdmin ? "Manage participations" : (item.CurrentUserContributionAmount > 0 ? "Edit participation" : "Partially fulfill"))
</button>
}
</div>
}
@@ -353,3 +348,68 @@ else
</div>
</div>
}
@if (ShowPurchaseManagementPrompt)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Manage purchase</h3>
@if (Registry?.IsAdmin == true)
{
<label class="form-label">User</label>
<InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="Search user" />
<select class="form-select mb-3" value="@SelectedPurchaseUserId" @onchange="OnPurchaseManagedUserChanged">
<option value="">Select user</option>
@foreach (var user in FilteredSelectableUsers)
{
<option value="@user.UserId">@user.DisplayName</option>
}
</select>
}
<label class="form-label">Quantity</label>
<InputNumber class="form-control" @bind-Value="ManagedPurchaseQuantity" />
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="SavePurchaseManagementAsync">Save</button>
<button class="btn btn-outline-danger" @onclick="RemoveManagedPurchaseAsync">Remove</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchaseManagementPrompt">Cancel</button>
</div>
</div>
</div>
}
@if (ShowContributionManagementPrompt)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Manage participation</h3>
@if (Registry?.IsAdmin == true)
{
<label class="form-label">User</label>
<InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="Search user" />
<select class="form-select mb-3" value="@SelectedContributionUserId" @onchange="OnContributionManagedUserChanged">
<option value="">Select user</option>
@foreach (var user in FilteredSelectableUsers)
{
<option value="@user.UserId">@user.DisplayName</option>
}
</select>
}
<label class="form-label">Amount</label>
<InputNumber class="form-control" @bind-Value="ManagedContributionAmount" />
<label class="form-label mt-2">Message</label>
<InputText class="form-control" @bind-Value="ManagedContributionMessage" />
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="SaveContributionManagementAsync">Save</button>
<button class="btn btn-outline-danger" @onclick="RemoveManagedContributionAsync">Remove</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionManagementPrompt">Cancel</button>
</div>
</div>
</div>
}
@@ -32,6 +32,16 @@ public partial class RegistryPublic : ComponentBase
protected ContributionPaymentMethodType? SelectedPaymentMethod { get; set; }
protected string? SelectedPaymentQrCodeUrl { get; private set; }
protected bool ShowPurchaseManagementPrompt { get; private set; }
protected bool ShowContributionManagementPrompt { get; private set; }
protected int ManagedPurchaseQuantity { get; set; } = 1;
protected decimal ManagedContributionAmount { get; set; }
protected string ManagedContributionMessage { get; set; } = string.Empty;
protected string? SelectedPurchaseUserId { get; set; }
protected string? SelectedContributionUserId { get; set; }
protected string UserFilterText { get; set; } = string.Empty;
protected IReadOnlyList<RegistrySelectableUserViewModel> SelectableUsers { get; private set; } = [];
private readonly HashSet<Guid> _collapsedCategoryIds = [];
protected override async Task OnParametersSetAsync()
@@ -274,7 +284,7 @@ public partial class RegistryPublic : ComponentBase
return;
}
await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None).ConfigureAwait(false);
await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None). ConfigureAwait(false);
ShowPartialFulfillWizard = false;
PartialFulfillStep = 1;
SelectedPaymentMethod = null;
@@ -310,4 +320,291 @@ public partial class RegistryPublic : ComponentBase
ArgumentException.ThrowIfNullOrWhiteSpace(paymentLink);
return $"https://api.qrserver.com/v1/create-qr-code/?size=320x320&data={Uri.EscapeDataString(paymentLink)}";
}
protected IEnumerable<RegistrySelectableUserViewModel> FilteredSelectableUsers =>
string.IsNullOrWhiteSpace(UserFilterText)
? SelectableUsers
: SelectableUsers.Where(x => x.DisplayName.Contains(UserFilterText, StringComparison.OrdinalIgnoreCase));
protected async Task OpenPurchaseManagementPromptAsync(Guid itemId)
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId);
if (item is null)
{
return;
}
ActiveItemId = itemId;
ShowPurchaseManagementPrompt = true;
ManagedPurchaseQuantity = item.CurrentUserPurchasedQuantity > 0 ? item.CurrentUserPurchasedQuantity : 1;
UserFilterText = string.Empty;
if (Registry?.IsAdmin == true)
{
SelectableUsers = await RegistryService.GetRegistrySelectableUsersAsync(Registry.Id, CancellationToken.None).ConfigureAwait(false);
SelectedPurchaseUserId = item.Purchasers.FirstOrDefault()?.UserId ?? userId;
if (!SelectableUsers.Any(x => x.UserId == SelectedPurchaseUserId) && !string.IsNullOrWhiteSpace(SelectedPurchaseUserId))
{
SelectableUsers =
[
.. SelectableUsers,
new RegistrySelectableUserViewModel { UserId = SelectedPurchaseUserId, DisplayName = SelectedPurchaseUserId }
];
}
}
else
{
SelectedPurchaseUserId = userId;
}
}
protected void ClosePurchaseManagementPrompt()
{
ShowPurchaseManagementPrompt = false;
SelectedPurchaseUserId = null;
UserFilterText = string.Empty;
SelectableUsers = [];
ActiveItemId = Guid.Empty;
}
protected async Task SavePurchaseManagementAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedPurchaseUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
if (item is null)
{
return;
}
var existingPurchase = item.Purchasers.FirstOrDefault(x => x.UserId == targetUserId);
if (existingPurchase is null)
{
await RegistryService.AddPurchaseAsync(ActiveItemId, targetUserId, ManagedPurchaseQuantity, CancellationToken.None).ConfigureAwait(false);
}
else
{
await RegistryService.UpdatePurchaseAsync(ActiveItemId, targetUserId, ManagedPurchaseQuantity, actorUserId, CancellationToken.None).ConfigureAwait(false);
}
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
ClosePurchaseManagementPrompt();
}
protected async Task RemoveManagedPurchaseAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedPurchaseUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
if (Registry?.IsAdmin == true)
{
await RegistryService.UnmarkPurchaseByAdminAsync(ActiveItemId, targetUserId, actorUserId, CancellationToken.None).ConfigureAwait(false);
}
else
{
await RegistryService.UnmarkPurchaseAsync(ActiveItemId, targetUserId, CancellationToken.None).ConfigureAwait(false);
}
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
ClosePurchaseManagementPrompt();
}
protected async Task OpenContributionManagementPromptAsync(Guid itemId)
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId);
if (item is null)
{
return;
}
ActiveItemId = itemId;
ShowContributionManagementPrompt = true;
UserFilterText = string.Empty;
if (Registry?.IsAdmin == true)
{
SelectableUsers = await RegistryService.GetRegistrySelectableUsersAsync(Registry.Id, CancellationToken.None).ConfigureAwait(false);
SelectedContributionUserId = item.Contributors.FirstOrDefault()?.UserId ?? userId;
}
else
{
SelectedContributionUserId = userId;
}
var currentContribution = item.Contributors.FirstOrDefault(x => x.UserId == SelectedContributionUserId);
ManagedContributionAmount = currentContribution?.Amount ?? item.CurrentUserContributionAmount;
ManagedContributionMessage = $"Participation for item: {item.Name}";
}
protected void CloseContributionManagementPrompt()
{
ShowContributionManagementPrompt = false;
SelectedContributionUserId = null;
UserFilterText = string.Empty;
SelectableUsers = [];
ManagedContributionAmount = 0;
ManagedContributionMessage = string.Empty;
ActiveItemId = Guid.Empty;
}
protected async Task SaveContributionManagementAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty || ManagedContributionAmount <= 0)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedContributionUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
if (item is null)
{
return;
}
var existingContribution = item.Contributors.FirstOrDefault(x => x.UserId == targetUserId);
if (existingContribution is null)
{
await RegistryService.AddContributionAsync(ActiveItemId, targetUserId, ManagedContributionAmount, ManagedContributionMessage, CancellationToken.None).ConfigureAwait(false);
}
else
{
await RegistryService.UpdateContributionAsync(ActiveItemId, targetUserId, ManagedContributionAmount, ManagedContributionMessage, actorUserId, CancellationToken.None).ConfigureAwait(false);
}
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
CloseContributionManagementPrompt();
}
protected async Task RemoveManagedContributionAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedContributionUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
await RegistryService.RemoveContributionAsync(ActiveItemId, targetUserId, actorUserId, CancellationToken.None).ConfigureAwait(false);
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
CloseContributionManagementPrompt();
}
protected void SelectManagedPurchaseUser(string userId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
SelectedPurchaseUserId = userId;
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
var existing = item?.Purchasers.FirstOrDefault(x => x.UserId == userId);
ManagedPurchaseQuantity = existing?.Quantity ?? 1;
}
protected void SelectManagedContributionUser(string userId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
SelectedContributionUserId = userId;
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
var existing = item?.Contributors.FirstOrDefault(x => x.UserId == userId);
ManagedContributionAmount = existing?.Amount ?? 0;
}
protected void OnPurchaseManagedUserChanged(ChangeEventArgs args)
{
var userId = args.Value?.ToString();
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
SelectManagedPurchaseUser(userId);
}
protected void OnContributionManagedUserChanged(ChangeEventArgs args)
{
var userId = args.Value?.ToString();
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
SelectManagedContributionUser(userId);
}
private async Task ReloadRegistryAsync(string userId)
{
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
if (Registry is not null)
{
var categoryIds = Registry.Categories.Select(x => x.Id).ToHashSet();
_collapsedCategoryIds.RemoveWhere(id => !categoryIds.Contains(id));
}
}
protected Task OpenContributionActionAsync(Guid itemId, decimal currentUserContributionAmount)
{
if (Registry?.IsAdmin == true || currentUserContributionAmount > 0)
{
return OpenContributionManagementPromptAsync(itemId);
}
OpenContributionPrompt(itemId);
return Task.CompletedTask;
}
}