WIP
This commit is contained in:
1 parent
2cf6128a85
commit
45a5e3fc5e
11 files changed
+428
-40
No files matched your search
@@ -0,0 +1,57 @@
|
||||
@page "/about"
|
||||
@layout AuthLayout
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
<PageTitle>Budgeteer — About</PageTitle>
|
||||
|
||||
<h3 class="text-center mb-4">💰 Budgeteer</h3>
|
||||
|
||||
<p class="text-center text-muted mb-4">
|
||||
A personal finance dashboard built with Blazor Server.
|
||||
</p>
|
||||
|
||||
@if (links.Count > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-center gap-3 mb-4">
|
||||
@foreach (var link in links)
|
||||
{
|
||||
<a href="@link.Url" target="_blank" rel="noopener noreferrer" class="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1" title="@link.Label">
|
||||
@((MarkupString)link.Icon) @link.Label
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="text-center">
|
||||
<a href="/login">Sign in</a> or <a href="/register">Register</a>
|
||||
</p>
|
||||
|
||||
@code {
|
||||
private record ContactLink(string Label, string Url, string Icon);
|
||||
private List<ContactLink> links = [];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var about = Configuration.GetSection("About");
|
||||
|
||||
var email = about["Email"];
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
links.Add(new("Email", $"mailto:{email}", "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414.05 3.555ZM0 4.697v7.104l5.803-3.558L0 4.697ZM6.761 8.83l-6.57 4.027A2 2 0 0 0 2 14h12a2 2 0 0 0 1.808-1.144l-6.57-4.027L8 9.586l-1.239-.757ZM16 11.801V4.697l-5.803 3.546L16 11.801Z'/></svg>"));
|
||||
|
||||
var discord = about["Discord"];
|
||||
if (!string.IsNullOrWhiteSpace(discord))
|
||||
links.Add(new("Discord", discord, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M13.545 2.907a13.227 13.227 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 0 0-3.658 0 8.258 8.258 0 0 0-.412-.833.051.051 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019c.308-.42.582-.863.818-1.329a.05.05 0 0 0-.01-.059.051.051 0 0 0-.018-.011 8.875 8.875 0 0 1-1.248-.595.05.05 0 0 1-.02-.066.051.051 0 0 1 .015-.019c.084-.063.168-.129.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 0 1 .053.007c.08.066.164.132.248.195a.051.051 0 0 1-.004.085 8.254 8.254 0 0 1-1.249.594.05.05 0 0 0-.03.03.052.052 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.235 13.235 0 0 0 4.001-2.02.049.049 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 0 0-.02-.019Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612Zm5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612Z'/></svg>"));
|
||||
|
||||
var github = about["GitHub"];
|
||||
if (!string.IsNullOrWhiteSpace(github))
|
||||
links.Add(new("GitHub", github, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z'/></svg>"));
|
||||
|
||||
var twitter = about["Twitter"];
|
||||
if (!string.IsNullOrWhiteSpace(twitter))
|
||||
links.Add(new("Twitter", twitter, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865l8.875 11.633Z'/></svg>"));
|
||||
|
||||
var mastodon = about["Mastodon"];
|
||||
if (!string.IsNullOrWhiteSpace(mastodon))
|
||||
links.Add(new("Mastodon", mastodon, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765 2.79.765.504 1.783.504 5.253c-.005.76-.01 1.666.013 2.63.073 3.225.552 6.405 3.335 7.196 1.282.364 2.387.44 3.275.388 1.61-.094 2.515-.613 2.515-.613l-.053-1.154s-1.152.362-2.445.318c-1.28-.044-2.631-.139-2.838-1.723a3.2 3.2 0 0 1-.028-.462s1.255.309 2.846.382c.973.044 1.883-.058 2.808-.174Zm1.563-2.2v-4.14c0-.846-.215-1.518-.648-2.011-.447-.493-1.031-.745-1.758-.745-.842 0-1.48.324-1.9.974L8 4.66l-.447-.586c-.42-.65-1.058-.974-1.9-.974-.727 0-1.311.252-1.758.745-.434.493-.648 1.165-.648 2.012v4.139h1.686V5.12c0-.846.358-1.277 1.073-1.277.79 0 1.187.51 1.187 1.519V7.77h1.686V5.362c0-1.008.396-1.519 1.187-1.519.715 0 1.073.431 1.073 1.277v4.875h1.614Z'/></svg>"));
|
||||
}
|
||||
}
|
||||
@@ -137,8 +137,8 @@ else
|
||||
}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<ul class="nav nav-tabs card-header-tabs">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<ul class="nav nav-tabs card-header-tabs" style="flex-grow: 0;">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "recent" ? "active" : "")" @onclick='() => { activeTab = "recent"; recentPage = 1; }'>
|
||||
Recent <span class="badge bg-secondary ms-1">@filteredRecent.Count</span>
|
||||
@@ -150,7 +150,11 @@ else
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="btn btn-sm btn-primary" @onclick="OpenAddModal">+ Add</button>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input type="search" class="form-control form-control-sm" style="max-width: 180px;"
|
||||
placeholder="Search..." @bind="dashboardSearchQuery" @bind:event="oninput" @bind:after="OnDashboardSearchChanged" />
|
||||
<button class="btn btn-sm btn-primary" @onclick="OpenAddModal">+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (data.Accounts.Count > 1)
|
||||
{
|
||||
@@ -303,19 +307,31 @@ else
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Recurring Transaction</h5>
|
||||
<h5 class="modal-title">@(pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction")</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
<strong>@pendingDeleteTransaction.Description</strong> is part of a recurring series.
|
||||
</p>
|
||||
<p>Would you like to delete just this transaction, or all future transactions in the series?</p>
|
||||
<p>Are you sure you want to delete <strong>@pendingDeleteTransaction.Description</strong>?</p>
|
||||
@if (pendingDeleteIsRecurring)
|
||||
{
|
||||
<p>This transaction is part of a recurring series. Would you like to delete just this one, or all future transactions in the series?</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-danger mb-0">This action cannot be undone.</p>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
|
||||
@if (pendingDeleteIsRecurring)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-danger" @onclick="DeleteSingle">Delete</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,12 +363,29 @@ else
|
||||
|
||||
// Account filter
|
||||
private HashSet<int> hiddenAccountIds = [];
|
||||
private string dashboardSearchQuery = string.Empty;
|
||||
|
||||
private List<Transaction> filteredRecent => data is null ? []
|
||||
: data.RecentTransactions.Where(t => !hiddenAccountIds.Contains(t.AccountId)).ToList();
|
||||
: data.RecentTransactions
|
||||
.Where(t => !hiddenAccountIds.Contains(t.AccountId))
|
||||
.Where(t => MatchesSearch(t))
|
||||
.ToList();
|
||||
|
||||
private List<Transaction> filteredUpcoming => data is null ? []
|
||||
: data.UpcomingTransactions.Where(t => !hiddenAccountIds.Contains(t.AccountId)).ToList();
|
||||
: data.UpcomingTransactions
|
||||
.Where(t => !hiddenAccountIds.Contains(t.AccountId))
|
||||
.Where(t => MatchesSearch(t))
|
||||
.ToList();
|
||||
|
||||
private bool MatchesSearch(Transaction t)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dashboardSearchQuery)) return true;
|
||||
var q = dashboardSearchQuery.Trim();
|
||||
return t.Description.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| t.Account.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.Category?.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
|
||||
|| t.Amount.ToString("F2").Contains(q, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void ToggleAccountFilter(int accountId)
|
||||
{
|
||||
@@ -362,6 +395,12 @@ else
|
||||
upcomingPage = 1;
|
||||
}
|
||||
|
||||
private void OnDashboardSearchChanged()
|
||||
{
|
||||
recentPage = 1;
|
||||
upcomingPage = 1;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
private int recentPage = 1;
|
||||
private int upcomingPage = 1;
|
||||
@@ -509,18 +548,13 @@ else
|
||||
// Delete confirmation state
|
||||
private bool showDeleteModal;
|
||||
private Transaction? pendingDeleteTransaction;
|
||||
private bool pendingDeleteIsRecurring;
|
||||
|
||||
private void ConfirmDelete(Transaction t)
|
||||
{
|
||||
pendingDeleteTransaction = t;
|
||||
if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
|
||||
{
|
||||
showDeleteModal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = DeleteSingle();
|
||||
}
|
||||
pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
private async Task DeleteSingle()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@page "/login"
|
||||
@layout AuthLayout
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject NavigationManager Navigation
|
||||
@inject IAuthenticationSchemeProvider SchemeProvider
|
||||
|
||||
<PageTitle>Budgeteer — Sign In</PageTitle>
|
||||
|
||||
@@ -31,6 +33,26 @@
|
||||
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
||||
</EditForm>
|
||||
|
||||
@if (externalProviders.Count > 0)
|
||||
{
|
||||
<hr class="my-4" />
|
||||
<p class="text-center text-muted mb-3">Or sign in with</p>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
@foreach (var provider in externalProviders)
|
||||
{
|
||||
<form method="post" action="/api/auth/external-login">
|
||||
<input type="hidden" name="provider" value="@provider.Name" />
|
||||
<input type="hidden" name="returnUrl" value="/" />
|
||||
<input type="hidden" name="__RequestVerificationToken" value="@antiforgeryToken" />
|
||||
<button type="submit" class="btn btn-outline-secondary w-100 d-flex align-items-center justify-content-center gap-2">
|
||||
@GetProviderIcon(provider.DisplayName ?? provider.Name)
|
||||
<span>@(provider.DisplayName ?? provider.Name)</span>
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="text-center mt-3">
|
||||
Don't have an account? <a href="/register">Register</a>
|
||||
</p>
|
||||
@@ -40,6 +62,31 @@
|
||||
private LoginModel Model { get; set; } = new();
|
||||
|
||||
private string? errorMessage;
|
||||
private List<AuthenticationScheme> externalProviders = [];
|
||||
private string antiforgeryToken = string.Empty;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var schemes = await SchemeProvider.GetAllSchemesAsync();
|
||||
externalProviders = schemes
|
||||
.Where(s => !string.IsNullOrEmpty(s.DisplayName)
|
||||
&& s.Name != IdentityConstants.ApplicationScheme
|
||||
&& s.Name != IdentityConstants.ExternalScheme
|
||||
&& s.Name != IdentityConstants.TwoFactorUserIdScheme
|
||||
&& s.Name != IdentityConstants.TwoFactorRememberMeScheme)
|
||||
.ToList();
|
||||
|
||||
if (HttpContext is not null)
|
||||
{
|
||||
var tokens = HttpContext.RequestServices
|
||||
.GetRequiredService<Microsoft.AspNetCore.Antiforgery.IAntiforgery>()
|
||||
.GetAndStoreTokens(HttpContext);
|
||||
antiforgeryToken = tokens.RequestToken ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleLogin()
|
||||
{
|
||||
@@ -58,6 +105,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
private static MarkupString GetProviderIcon(string provider) => provider switch
|
||||
{
|
||||
"Google" => new MarkupString("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\"><path fill=\"#4285F4\" d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z\"/><path fill=\"#34A853\" d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\"/><path fill=\"#FBBC05\" d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\"/><path fill=\"#EA4335\" d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\"/></svg>"),
|
||||
"Microsoft" => new MarkupString("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 23 23\"><rect fill=\"#f25022\" x=\"1\" y=\"1\" width=\"10\" height=\"10\"/><rect fill=\"#00a4ef\" x=\"1\" y=\"12\" width=\"10\" height=\"10\"/><rect fill=\"#7fba00\" x=\"12\" y=\"1\" width=\"10\" height=\"10\"/><rect fill=\"#ffb900\" x=\"12\" y=\"12\" width=\"10\" height=\"10\"/></svg>"),
|
||||
"GitHub" => new MarkupString("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\"><path d=\"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\"/></svg>"),
|
||||
_ => new MarkupString("")
|
||||
};
|
||||
|
||||
private sealed class LoginModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||
|
||||
@@ -119,6 +119,8 @@ else
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<input type="search" class="form-control form-control-sm" style="max-width: 220px;"
|
||||
placeholder="Search..." @bind="searchQuery" @bind:event="oninput" @bind:after="OnSearchChanged" />
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<label class="form-label mb-0 text-nowrap">Show:</label>
|
||||
<select class="form-select form-select-sm" style="max-width: 90px;" @onchange="OnPageSizeChanged">
|
||||
@@ -133,6 +135,9 @@ else
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 2rem;">
|
||||
<input type="checkbox" class="form-check-input" checked="@IsAllPageSelected()" @onchange="ToggleSelectAll" />
|
||||
</th>
|
||||
<th role="button" class="user-select-none" @onclick='() => SetSort("date")'>Date @SortIndicator("date")</th>
|
||||
<th role="button" class="user-select-none" @onclick='() => SetSort("description")'>Description @SortIndicator("description")</th>
|
||||
<th>Account</th>
|
||||
@@ -146,6 +151,9 @@ else
|
||||
@foreach (var t in pagedTransactions)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input" checked="@selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" />
|
||||
</td>
|
||||
<td>@t.Date.ToString("MMM dd, yyyy")</td>
|
||||
<td>@t.Description</td>
|
||||
<td>
|
||||
@@ -197,37 +205,75 @@ else
|
||||
{
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(currentPage <= 1 ? true : (bool?)null)" @onclick="() => currentPage--">← Prev</button>
|
||||
<small class="text-muted">Page @currentPage of @totalPages (@filteredTransactions.Count transactions)</small>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
@if (selectedIds.Count > 0)
|
||||
{
|
||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
|
||||
Delete @selectedIds.Count selected
|
||||
</button>
|
||||
}
|
||||
<small class="text-muted">Page @currentPage of @totalPages (@filteredTransactions.Count transactions)</small>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(currentPage >= totalPages ? true : (bool?)null)" @onclick="() => currentPage++">Next →</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card-footer">
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">@filteredTransactions.Count transactions</small>
|
||||
@if (selectedIds.Count > 0)
|
||||
{
|
||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
|
||||
Delete @selectedIds.Count selected
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showDeleteModal && pendingDeleteTransaction is not null)
|
||||
@if (showDeleteModal)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Recurring Transaction</h5>
|
||||
<h5 class="modal-title">@deleteModalTitle</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
<strong>@pendingDeleteTransaction.Description</strong> is part of a recurring series.
|
||||
</p>
|
||||
<p>Would you like to delete just this transaction, or all future transactions in the series?</p>
|
||||
@if (bulkDeleteCount > 0)
|
||||
{
|
||||
<p>Are you sure you want to delete <strong>@bulkDeleteCount</strong> selected transactions?</p>
|
||||
<p class="text-danger mb-0">This action cannot be undone.</p>
|
||||
}
|
||||
else if (pendingDeleteTransaction is not null)
|
||||
{
|
||||
<p>Are you sure you want to delete <strong>@pendingDeleteTransaction.Description</strong>?</p>
|
||||
@if (pendingDeleteIsRecurring)
|
||||
{
|
||||
<p>This transaction is part of a recurring series. Would you like to delete just this one, or all future transactions in the series?</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-danger mb-0">This action cannot be undone.</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
|
||||
@if (bulkDeleteCount > 0)
|
||||
{
|
||||
<button type="button" class="btn btn-danger" @onclick="ExecuteBulkDelete">Delete All Selected</button>
|
||||
}
|
||||
else if (pendingDeleteIsRecurring)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-danger" @onclick="DeleteSingle">Delete</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,6 +361,7 @@ else
|
||||
private TransactionForm formModel = new();
|
||||
private string? userId;
|
||||
private int? filterAccountId;
|
||||
private string searchQuery = string.Empty;
|
||||
|
||||
// Pagination state
|
||||
private int currentPage = 1;
|
||||
@@ -338,6 +385,16 @@ else
|
||||
if (filterAccountId is not null)
|
||||
list = list.Where(t => t.AccountId == filterAccountId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchQuery))
|
||||
{
|
||||
var q = searchQuery.Trim();
|
||||
list = list.Where(t =>
|
||||
t.Description.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| t.Account.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| (t.Category?.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
|
||||
|| t.Amount.ToString("F2").Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
list = sortColumn switch
|
||||
{
|
||||
"description" => sortAscending
|
||||
@@ -361,6 +418,8 @@ else
|
||||
currentPage = 1;
|
||||
}
|
||||
|
||||
private void OnSearchChanged() => currentPage = 1;
|
||||
|
||||
private void OnPageSizeChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (int.TryParse(e.Value?.ToString(), out var size))
|
||||
@@ -433,9 +492,37 @@ else
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
// Selection state
|
||||
private HashSet<int> selectedIds = [];
|
||||
|
||||
private void ToggleSelect(int id)
|
||||
{
|
||||
if (!selectedIds.Remove(id))
|
||||
selectedIds.Add(id);
|
||||
}
|
||||
|
||||
private bool IsAllPageSelected() => pagedTransactions.Count > 0 && pagedTransactions.All(t => selectedIds.Contains(t.Id));
|
||||
|
||||
private void ToggleSelectAll(ChangeEventArgs e)
|
||||
{
|
||||
if (e.Value is true)
|
||||
{
|
||||
foreach (var t in pagedTransactions)
|
||||
selectedIds.Add(t.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var t in pagedTransactions)
|
||||
selectedIds.Remove(t.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation state
|
||||
private bool showDeleteModal;
|
||||
private Transaction? pendingDeleteTransaction;
|
||||
private bool pendingDeleteIsRecurring;
|
||||
private int bulkDeleteCount;
|
||||
private string deleteModalTitle = "Delete Transaction";
|
||||
|
||||
// Edit modal state
|
||||
private bool showEditModal;
|
||||
@@ -481,14 +568,19 @@ else
|
||||
private void ConfirmDelete(Transaction t)
|
||||
{
|
||||
pendingDeleteTransaction = t;
|
||||
if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
|
||||
{
|
||||
showDeleteModal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = DeleteSingle();
|
||||
}
|
||||
pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||
bulkDeleteCount = 0;
|
||||
deleteModalTitle = pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction";
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
private void ConfirmBulkDelete()
|
||||
{
|
||||
pendingDeleteTransaction = null;
|
||||
pendingDeleteIsRecurring = false;
|
||||
bulkDeleteCount = selectedIds.Count;
|
||||
deleteModalTitle = $"Delete {bulkDeleteCount} Transactions";
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
private async Task DeleteSingle()
|
||||
@@ -496,6 +588,7 @@ else
|
||||
if (userId is null || pendingDeleteTransaction is null) return;
|
||||
showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionAsync(pendingDeleteTransaction.Id, userId);
|
||||
selectedIds.Remove(pendingDeleteTransaction.Id);
|
||||
pendingDeleteTransaction = null;
|
||||
await LoadData();
|
||||
}
|
||||
@@ -505,14 +598,28 @@ else
|
||||
if (userId is null || pendingDeleteTransaction is null) return;
|
||||
showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionSeriesAsync(pendingDeleteTransaction.Id, userId);
|
||||
selectedIds.Remove(pendingDeleteTransaction.Id);
|
||||
pendingDeleteTransaction = null;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task ExecuteBulkDelete()
|
||||
{
|
||||
if (userId is null) return;
|
||||
showDeleteModal = false;
|
||||
foreach (var id in selectedIds)
|
||||
{
|
||||
await TransactionService.DeleteTransactionAsync(id, userId);
|
||||
}
|
||||
selectedIds.Clear();
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
showDeleteModal = false;
|
||||
pendingDeleteTransaction = null;
|
||||
bulkDeleteCount = 0;
|
||||
}
|
||||
|
||||
private sealed class TransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||
|
||||
Reference in new issue
Block a user