9f3ae1051c
Updated currency handling to use dynamic symbols, defaulting to "€". Adjusted UI components to reflect these changes. Introduced `GetCurrencySymbol` and `NormalizeCurrencySymbol` methods for consistency. Reintroduced QR code parsing/serialization methods. Updated models and services to align with the new currency symbol logic.
495 lines
27 KiB
Plaintext
495 lines
27 KiB
Plaintext
@page "/registry/{RegistryId:guid}/admin"
|
|
@rendermode InteractiveServer
|
|
|
|
@using Blazored.TextEditor
|
|
|
|
<PageTitle>Registry Admin</PageTitle>
|
|
|
|
@if (!IsAuthorized)
|
|
{
|
|
<p>Access denied.</p>
|
|
}
|
|
else
|
|
{
|
|
<h1>Registry Admin</h1>
|
|
|
|
@if (!IsSmtpConfigured)
|
|
{
|
|
<div class="alert alert-warning" role="alert">
|
|
SMTP is not configured. Email features (identity emails and admin invite emails) are disabled. Configure the Smtp section in appsettings or user secrets.
|
|
</div>
|
|
}
|
|
|
|
<ul class="nav nav-tabs mb-4" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link @(GetTabClass("items"))"
|
|
id="items-tab"
|
|
@onclick='() => SetActiveTab("items")'
|
|
type="button"
|
|
role="tab"
|
|
aria-controls="items-content"
|
|
aria-selected="@(ActiveTab == "items" ? "true" : "false")">
|
|
<span class="bi bi-box-seam"></span> Items
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link @(GetTabClass("settings"))"
|
|
id="settings-tab"
|
|
@onclick='() => SetActiveTab("settings")'
|
|
type="button"
|
|
role="tab"
|
|
aria-controls="settings-content"
|
|
aria-selected="@(ActiveTab == "settings" ? "true" : "false")">
|
|
<span class="bi bi-gear"></span> Settings
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link @(GetTabClass("admins"))"
|
|
id="admins-tab"
|
|
@onclick='() => SetActiveTab("admins")'
|
|
type="button"
|
|
role="tab"
|
|
aria-controls="admins-content"
|
|
aria-selected="@(ActiveTab == "admins" ? "true" : "false")">
|
|
<span class="bi bi-people"></span> Administrators
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link @(GetTabClass("addresses"))"
|
|
id="addresses-tab"
|
|
@onclick='() => SetActiveTab("addresses")'
|
|
type="button"
|
|
role="tab"
|
|
aria-controls="addresses-content"
|
|
aria-selected="@(ActiveTab == "addresses" ? "true" : "false")">
|
|
<span class="bi bi-house"></span> Addresses
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<a href="/registry/@RegistryId/admin/action-log" class="nav-link">
|
|
<span class="bi bi-clock-history"></span> Action Log
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
|
|
<div class="tab-content">
|
|
<!-- Items Tab -->
|
|
<div class="tab-pane fade @(GetTabPaneClass("items"))" id="items-content" role="tabpanel" aria-labelledby="items-tab">
|
|
<section class="mb-4">
|
|
<h2>Add or edit item</h2>
|
|
<EditForm Model="ItemModel" OnValidSubmit="SaveItemAsync" FormName="registry-item-form">
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label">Name</label>
|
|
<InputText class="form-control" @bind-Value="ItemModel.Name" />
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Product URL</label>
|
|
<div class="input-group">
|
|
<InputText class="form-control" @bind-Value="ItemModel.ProductUrl" />
|
|
<button type="button" class="btn btn-outline-secondary" @onclick="FetchMetadataAsync">Auto fetch</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Picture URL</label>
|
|
<InputText class="form-control" @bind-Value="ItemModel.PictureUrl" />
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Description</label>
|
|
<InputTextArea class="form-control" @bind-Value="ItemModel.Description" rows="2" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Price</label>
|
|
<InputNumber class="form-control" @bind-Value="ItemModel.PriceAmount" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Currency symbol</label>
|
|
<InputText class="form-control" @bind-Value="ItemModel.CurrencyCode" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Desired qty</label>
|
|
<InputNumber class="form-control" @bind-Value="ItemModel.DesiredQuantity" />
|
|
</div>
|
|
<div class="col-md-3 d-flex align-items-center gap-2">
|
|
<InputCheckbox @bind-Value="ItemModel.ParticipationAllowed" />
|
|
<label>Participation</label>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Participation target</label>
|
|
<InputNumber class="form-control" @bind-Value="ItemModel.ParticipationTargetAmount" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Second hand preference</label>
|
|
<InputSelect class="form-select" @bind-Value="ItemModel.PreferSecondHand">
|
|
<option value="">Second hand optional</option>
|
|
<option value="true">Prefer second hand</option>
|
|
<option value="false">New only</option>
|
|
</InputSelect>
|
|
</div>
|
|
<div class="col-md-3 d-flex align-items-center gap-2">
|
|
<InputCheckbox @bind-Value="ItemModel.IsGiven" />
|
|
<label>Given</label>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Category</label>
|
|
<InputSelect class="form-select" @bind-Value="ItemModel.CategoryId">
|
|
@foreach (var category in ItemCategories)
|
|
{
|
|
<option value="@category.Id">@category.Name</option>
|
|
}
|
|
</InputSelect>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-primary mt-3" type="submit">Save item</button>
|
|
</EditForm>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="d-flex justify-content-between align-items-end flex-wrap gap-3">
|
|
<h2 class="mb-0">Categories and items</h2>
|
|
<div class="d-flex gap-2">
|
|
<InputText class="form-control" @bind-Value="NewCategoryName" placeholder="New category" />
|
|
<button type="button" class="btn btn-outline-primary" @onclick="AddCategoryAsync">Add category</button>
|
|
</div>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(ItemManagementMessage))
|
|
{
|
|
<div class="alert alert-warning mt-3 mb-0" role="alert">@ItemManagementMessage</div>
|
|
}
|
|
|
|
<p class="text-muted mt-3 mb-2">Drag categories or items to reorder. Drop items into another category to regroup them.</p>
|
|
|
|
<div class="category-groups mt-3">
|
|
@foreach (var category in ItemCategories)
|
|
{
|
|
<section id="category-card-@category.Id"
|
|
class="card category-group @GetCategoryDropClass(category.Id)"
|
|
draggable="true"
|
|
@ondragstart="() => OnCategoryDragStart(category.Id)"
|
|
@ondragend="OnCategoryDragEnd"
|
|
@ondragover="@(args => OnCategoryDragOverAsync(category.Id, args))"
|
|
@ondragover:preventDefault="true"
|
|
@ondrop:stopPropagation="true"
|
|
@ondrop:preventDefault="true"
|
|
@ondrop="() => OnCategoryDropAsync(category.Id)">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
@if (EditingCategoryId == category.Id)
|
|
{
|
|
<div class="d-flex gap-2 align-items-center w-100">
|
|
<InputText class="form-control form-control-sm" @bind-Value="CategoryRenameName" />
|
|
<button type="button" class="btn btn-sm btn-primary" @onclick="() => SaveCategoryRenameAsync(category.Id)">Save</button>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CancelCategoryRename">Cancel</button>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<strong>@category.Name</strong>
|
|
<div class="d-flex gap-2">
|
|
<button type="button"
|
|
class="btn btn-sm btn-outline-secondary"
|
|
@onclick="() => StartCategoryRename(category)">
|
|
Rename
|
|
</button>
|
|
<button type="button"
|
|
class="btn btn-sm btn-outline-danger"
|
|
@onclick="() => RemoveCategoryAsync(category.Id)"
|
|
disabled="@(ItemCategories.Count <= 1)">
|
|
Remove
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="card-body p-0 @GetCategoryItemsDropClass(category.Id)"
|
|
@ondragover="() => OnCategoryItemsDragOver(category.Id)"
|
|
@ondragover:preventDefault="true"
|
|
@ondrop:stopPropagation="true"
|
|
@ondrop:preventDefault="true"
|
|
@ondrop="() => OnCategoryItemsDropAsync(category.Id)">
|
|
@if (category.Items.Count == 0)
|
|
{
|
|
<div class="p-3 text-muted">Drop items here.</div>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-striped mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Desired Qty</th>
|
|
<th>Participation</th>
|
|
<th>Purchased by / Contributed by</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var item in category.Items)
|
|
{
|
|
<tr id="item-row-@item.Id"
|
|
class="@GetItemDropClass(category.Id, item.Id!.Value)"
|
|
draggable="true"
|
|
@ondragstart:stopPropagation="true"
|
|
@ondragstart="() => OnItemDragStart(item.Id!.Value)"
|
|
@ondragend="OnItemDragEnd"
|
|
@ondragover:stopPropagation="true"
|
|
@ondragover="@(args => OnItemDragOverAsync(category.Id, item.Id!.Value, args))"
|
|
@ondragover:preventDefault="true"
|
|
@ondrop:stopPropagation="true"
|
|
@ondrop:preventDefault="true"
|
|
@ondrop="() => OnItemDropAsync(category.Id, item.Id!.Value)">
|
|
<td>@item.Name</td>
|
|
<td>@item.DesiredQuantity</td>
|
|
<td>@(item.ParticipationAllowed ? "Yes" : "No")</td>
|
|
<td>
|
|
@if (item.Purchasers.Count > 0)
|
|
{
|
|
<div class="mb-1">
|
|
<small><strong>Purchased:</strong></small>
|
|
<div class="small">
|
|
@foreach (var purchaser in item.Purchasers)
|
|
{
|
|
<div>@purchaser.DisplayName (@purchaser.Quantity)</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
@if (item.Contributors.Count > 0)
|
|
{
|
|
<div>
|
|
<small><strong>Contributed:</strong></small>
|
|
<div class="small">
|
|
@foreach (var contributor in item.Contributors)
|
|
{
|
|
<div>@contributor.DisplayName (@contributor.Amount.ToString("0.00"))</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button>
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
<div class="category-list-end-drop-zone @GetCategoryListEndDropClass()"
|
|
@ondragover="OnCategoryListEndDragOver"
|
|
@ondragover:preventDefault="true"
|
|
@ondrop:preventDefault="true"
|
|
@ondrop="OnCategoryListEndDropAsync">
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Settings Tab -->
|
|
<div class="tab-pane fade @(GetTabPaneClass("settings"))" id="settings-content" role="tabpanel" aria-labelledby="settings-tab">
|
|
<section class="mb-4">
|
|
<h2>Registry Settings</h2>
|
|
<EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form">
|
|
<div class="row g-3">
|
|
<div class="col-md-4">
|
|
<label class="form-label">Baby name</label>
|
|
<InputText class="form-control" @bind-Value="SettingsModel.BabyName" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Birth date</label>
|
|
<InputDate class="form-control" @bind-Value="SettingsModel.BirthDate" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Currency symbol</label>
|
|
<InputText class="form-control" @bind-Value="SettingsModel.CurrencyCode" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Theme</label>
|
|
<InputSelect class="form-select" @bind-Value="SettingsModel.ThemeKey">
|
|
<option value="default">Default</option>
|
|
<option value="soft">Soft</option>
|
|
<option value="modern">Modern</option>
|
|
</InputSelect>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<label class="form-label">Shipping address</label>
|
|
<InputTextArea class="form-control" @bind-Value="SettingsModel.ShippingAddress" rows="4" />
|
|
<small class="form-text text-muted">Line breaks will be preserved</small>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<label class="form-label">Top content</label>
|
|
<div class="editor-wrapper">
|
|
<BlazoredTextEditor @ref="TextEditor" Placeholder="Welcome text" Theme="snow" ToolbarContent="@ToolbarContent" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<h3>Bank Account Settings</h3>
|
|
<div class="row g-3 mt-2">
|
|
<div class="col-md-4">
|
|
<label class="form-label">Bank account name</label>
|
|
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountDisplayName" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">IBAN</label>
|
|
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountIban" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">BIC</label>
|
|
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountBic" />
|
|
</div>
|
|
</div>
|
|
<div class="mt-2">
|
|
<div class="form-check">
|
|
<InputCheckbox class="form-check-input" @bind-Value="SettingsModel.ShowBankAccountName" id="showBankName" />
|
|
<label class="form-check-label" for="showBankName">
|
|
Display bank account name
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<h3>Contribution Payment Options</h3>
|
|
<div class="row g-3 mt-2">
|
|
<div class="col-md-12">
|
|
<label class="form-label">Single QR code URL</label>
|
|
<InputText class="form-control" @bind-Value="SettingsModel.ContributionQrCodeUrl" />
|
|
<small class="form-text text-muted">Optional: one QR code that donors can scan for any amount.</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<h4 class="mb-0">Amount-specific QR codes</h4>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="AddContributionAmountQrCode">Add QR amount</button>
|
|
</div>
|
|
|
|
@if (SettingsModel.ContributionAmountQrCodes.Count == 0)
|
|
{
|
|
<p class="text-muted mt-2 mb-0">No amount-specific QR codes configured.</p>
|
|
}
|
|
else
|
|
{
|
|
<div class="mt-2 d-flex flex-column gap-2">
|
|
@foreach (var amountQr in SettingsModel.ContributionAmountQrCodes)
|
|
{
|
|
<div class="row g-2 align-items-end">
|
|
<div class="col-md-3">
|
|
<label class="form-label">Amount</label>
|
|
<InputNumber class="form-control" @bind-Value="amountQr.Amount" />
|
|
</div>
|
|
<div class="col-md-7">
|
|
<label class="form-label">QR code URL</label>
|
|
<InputText class="form-control" @bind-Value="amountQr.QrCodeUrl" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<button type="button" class="btn btn-outline-danger w-100" @onclick="() => RemoveContributionAmountQrCode(amountQr)">Remove</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn btn-primary mt-4" type="submit">Save settings</button>
|
|
</EditForm>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Addresses Tab -->
|
|
<div class="tab-pane fade @(GetTabPaneClass("addresses"))" id="addresses-content" role="tabpanel" aria-labelledby="addresses-tab">
|
|
<section class="mb-4">
|
|
<h2>User addresses</h2>
|
|
@if (AccessibleUserAddresses.Count == 0)
|
|
{
|
|
<p class="text-muted">No users found yet.</p>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Address</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var user in AccessibleUserAddresses)
|
|
{
|
|
<tr>
|
|
<td>@user.DisplayName</td>
|
|
<td>@(string.IsNullOrWhiteSpace(user.Email) ? "-" : user.Email)</td>
|
|
<td>@(string.IsNullOrWhiteSpace(user.Address) ? "-" : user.Address)</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Admins Tab -->
|
|
<div class="tab-pane fade @(GetTabPaneClass("admins"))" id="admins-content" role="tabpanel" aria-labelledby="admins-tab">
|
|
<section class="mb-4">
|
|
<h2>Current administrators</h2>
|
|
@if (Admins.Count == 0)
|
|
{
|
|
<p class="text-muted">No admins assigned yet.</p>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>Email / Name</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var admin in Admins)
|
|
{
|
|
<tr>
|
|
<td>@admin.DisplayName</td>
|
|
<td class="text-end">
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAdminAsync(admin.UserId)">Remove</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Invite administrator</h2>
|
|
<div class="row g-2">
|
|
<div class="col-md-8">
|
|
<InputText class="form-control" @bind-Value="InviteEmail" placeholder="optional email" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<button class="btn btn-outline-primary w-100" @onclick="CreateInviteAsync">Create invite</button>
|
|
</div>
|
|
</div>
|
|
@if (!string.IsNullOrWhiteSpace(InviteLink))
|
|
{
|
|
<p class="mt-3">
|
|
<strong>Invite link:</strong>
|
|
<br />
|
|
<a href="@InviteLink" target="_blank">@InviteLink</a>
|
|
</p>
|
|
}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
}
|