Refactor. Add data layer
This commit is contained in:
1 parent
3b7e4e6ec5
commit
b6b794f0b4
82 files changed
+2930
-2811
No files matched your search
@@ -1,4 +1,4 @@
|
||||
@inject AccountService AccountService
|
||||
@inject IAccountRepository AccountService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject AppState AppState
|
||||
@inject NavigationManager Navigation
|
||||
@@ -70,11 +70,11 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@if (accounts is not null && accounts.Count > 0)
|
||||
@if (_accounts is not null && _accounts.Count > 0)
|
||||
{
|
||||
<div class="nav-balances px-3">
|
||||
<div class="balances-header">Balances</div>
|
||||
@foreach (var account in accounts)
|
||||
@foreach (var account in _accounts)
|
||||
{
|
||||
<div class="balance-item" style="cursor: pointer;" title="Click to manage accounts" @onclick="NavigateToAccounts">
|
||||
<div class="balance-color" style="background-color: @account.Color;"></div>
|
||||
@@ -88,44 +88,4 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<Account>? accounts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AppState.OnAccountsChanged += HandleAccountsChanged;
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (userId is not null)
|
||||
{
|
||||
accounts = await AccountService.GetAccountsAsync(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async void HandleAccountsChanged()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (userId is not null)
|
||||
{
|
||||
accounts = await AccountService.GetAccountsAsync(userId);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AppState.OnAccountsChanged -= HandleAccountsChanged;
|
||||
}
|
||||
|
||||
private void NavigateToAccounts()
|
||||
{
|
||||
Navigation.NavigateTo("accounts");
|
||||
}
|
||||
}
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
using BudgetApp.Data.Entities;
|
||||
|
||||
namespace BudgetApp.Components.Layout;
|
||||
|
||||
public partial class NavMenu
|
||||
{
|
||||
private List<Account>? _accounts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AppState.OnAccountsChanged += HandleAccountsChanged;
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (userId is not null)
|
||||
{
|
||||
_accounts = await AccountService.GetAccountsAsync(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async void HandleAccountsChanged()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (userId is not null)
|
||||
{
|
||||
_accounts = await AccountService.GetAccountsAsync(userId);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AppState.OnAccountsChanged -= HandleAccountsChanged;
|
||||
}
|
||||
|
||||
private void NavigateToAccounts()
|
||||
{
|
||||
Navigation.NavigateTo("accounts");
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,10 @@
|
||||
A personal finance dashboard built with Blazor Server.
|
||||
</p>
|
||||
|
||||
@if (links.Count > 0)
|
||||
@if (_links.Count > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-center gap-3 mb-4">
|
||||
@foreach (var link in links)
|
||||
@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
|
||||
@@ -24,34 +24,4 @@
|
||||
|
||||
<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>"));
|
||||
}
|
||||
}
|
||||
</p>
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace BudgetApp.Components.Pages;
|
||||
|
||||
public partial class About
|
||||
{
|
||||
private record ContactLink(string Label, string Url, string Icon);
|
||||
|
||||
private readonly 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>"));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/accounts"
|
||||
@using BudgetApp.Data.Entities
|
||||
@attribute [Authorize]
|
||||
@inject AccountService AccountService
|
||||
@inject IAccountRepository AccountService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject AppState AppState
|
||||
@rendermode InteractiveServer
|
||||
@@ -15,20 +16,20 @@
|
||||
<h5 class="mb-0">Add Account</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<EditForm Model="formModel" OnValidSubmit="AddAccount" FormName="addAccount">
|
||||
<EditForm Model="_formModel" OnValidSubmit="AddAccount" FormName="addAccount">
|
||||
<ToastValidator />
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Account Name</label>
|
||||
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Current Account" title="A friendly name for this account" />
|
||||
<InputText @bind-Value="_formModel.Name" class="form-control" placeholder="e.g. Current Account" title="A friendly name for this account" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Current Balance</label>
|
||||
<InputNumber @bind-Value="formModel.CurrentBalance" class="form-control numeric-input" title="The opening balance for this account" />
|
||||
<InputNumber @bind-Value="_formModel.CurrentBalance" class="form-control numeric-input" title="The opening balance for this account" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Currency</label>
|
||||
<InputSelect @bind-Value="formModel.CurrencyCode" class="form-select" title="Currency used for this account">
|
||||
<InputSelect @bind-Value="_formModel.CurrencyCode" class="form-select" title="Currency used for this account">
|
||||
@foreach (var kvp in Account.CurrencySymbols)
|
||||
{
|
||||
<option value="@kvp.Key">@kvp.Key (@kvp.Value)</option>
|
||||
@@ -37,7 +38,7 @@
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Colour</label>
|
||||
<InputText @bind-Value="formModel.Color" type="color" class="form-control form-control-color" title="Colour used to identify this account in charts and lists" />
|
||||
<InputText @bind-Value="_formModel.Color" type="color" class="form-control form-control-color" title="Colour used to identify this account in charts and lists" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">Add</button>
|
||||
@@ -47,31 +48,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (accounts is null)
|
||||
@if (_accounts is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (accounts.Count == 0)
|
||||
else if (_accounts.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No accounts yet. Add one above to get started.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
@foreach (var account in accounts)
|
||||
@foreach (var account in _accounts)
|
||||
{
|
||||
<div class="col-md-4 col-sm-6 mb-3">
|
||||
<div class="card h-100" style="border-left: 4px solid @account.Color;">
|
||||
<div class="card-body">
|
||||
@if (editingAccountId == account.Id)
|
||||
@if (_editingAccountId == account.Id)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm mb-1">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="editName" />
|
||||
<input type="text" class="form-control form-control-sm" @bind="_editName" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm mb-1">Currency</label>
|
||||
<select class="form-select form-select-sm" @bind="editCurrencyCode">
|
||||
<select class="form-select form-select-sm" @bind="_editCurrencyCode">
|
||||
@foreach (var kvp in Account.CurrencySymbols)
|
||||
{
|
||||
<option value="@kvp.Key">@kvp.Key (@kvp.Value)</option>
|
||||
@@ -80,7 +81,7 @@ else
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm mb-1">Colour</label>
|
||||
<input type="color" class="form-control form-control-color form-control-sm" @bind="editColor" />
|
||||
<input type="color" class="form-control form-control-color form-control-sm" @bind="_editColor" />
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-success btn-sm" @onclick="() => SaveAccountDetails(account.Id)">Save</button>
|
||||
@@ -100,13 +101,13 @@ else
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (editingBalanceAccountId == account.Id)
|
||||
@if (_editingBalanceAccountId == account.Id)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">@account.GetSymbol()</span>
|
||||
<input type="number" class="form-control numeric-input" step="0.01"
|
||||
@bind="editingBalanceValue" />
|
||||
@bind="_editingBalanceValue" />
|
||||
</div>
|
||||
<div class="d-flex gap-1 mt-2">
|
||||
<button class="btn btn-success btn-sm flex-fill" @onclick="() => SaveBalance(account.Id)">Save</button>
|
||||
@@ -131,7 +132,7 @@ else
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showDeleteModal && pendingDeleteAccount is not null)
|
||||
@if (_showDeleteModal && _pendingDeleteAccount is not null)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||
<div class="modal-dialog">
|
||||
@@ -141,7 +142,7 @@ else
|
||||
<button type="button" class="btn-close" @onclick="CancelDeleteAccount"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>@pendingDeleteAccount.Name</strong>?</p>
|
||||
<p>Are you sure you want to delete <strong>@_pendingDeleteAccount.Name</strong>?</p>
|
||||
<p class="text-danger mb-0">This will permanently remove the account and all its transactions.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -154,138 +155,11 @@ else
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Account>? accounts;
|
||||
private AccountForm formModel = new();
|
||||
private string? userId;
|
||||
|
||||
// Balance editing state
|
||||
private int? editingBalanceAccountId;
|
||||
private decimal editingBalanceValue;
|
||||
|
||||
// Account details editing state
|
||||
private int? editingAccountId;
|
||||
private string editName = string.Empty;
|
||||
private string editColor = "#1b6ec2";
|
||||
private string editCurrencyCode = "GBP";
|
||||
|
||||
// Delete confirmation state
|
||||
private bool showDeleteModal;
|
||||
private Account? pendingDeleteAccount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private async Task LoadAccounts()
|
||||
{
|
||||
if (userId is not null)
|
||||
{
|
||||
accounts = await AccountService.GetAccountsAsync(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddAccount()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
var account = new Account
|
||||
{
|
||||
Name = formModel.Name,
|
||||
CurrentBalance = formModel.CurrentBalance,
|
||||
Color = formModel.Color,
|
||||
CurrencyCode = formModel.CurrencyCode,
|
||||
UserId = userId
|
||||
};
|
||||
await AccountService.CreateAccountAsync(account);
|
||||
formModel = new AccountForm();
|
||||
await LoadAccounts();
|
||||
AppState.NotifyAccountsChanged();
|
||||
}
|
||||
|
||||
private void ConfirmDeleteAccount(Account account)
|
||||
{
|
||||
pendingDeleteAccount = account;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
private async Task DeleteAccountConfirmed()
|
||||
{
|
||||
if (userId is null || pendingDeleteAccount is null) return;
|
||||
|
||||
showDeleteModal = false;
|
||||
await AccountService.DeleteAccountAsync(pendingDeleteAccount.Id, userId);
|
||||
pendingDeleteAccount = null;
|
||||
await LoadAccounts();
|
||||
AppState.NotifyAccountsChanged();
|
||||
}
|
||||
|
||||
private void CancelDeleteAccount()
|
||||
{
|
||||
showDeleteModal = false;
|
||||
pendingDeleteAccount = null;
|
||||
}
|
||||
|
||||
private void StartBalanceEdit(Account account)
|
||||
{
|
||||
editingBalanceAccountId = account.Id;
|
||||
editingBalanceValue = account.CurrentBalance;
|
||||
}
|
||||
|
||||
private void CancelBalanceEdit()
|
||||
{
|
||||
editingBalanceAccountId = null;
|
||||
}
|
||||
|
||||
private async Task SaveBalance(int accountId)
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await AccountService.UpdateBalanceAsync(accountId, userId, editingBalanceValue);
|
||||
editingBalanceAccountId = null;
|
||||
await LoadAccounts();
|
||||
AppState.NotifyAccountsChanged();
|
||||
}
|
||||
|
||||
private void StartAccountEdit(Account account)
|
||||
{
|
||||
editingAccountId = account.Id;
|
||||
editName = account.Name;
|
||||
editColor = account.Color;
|
||||
editCurrencyCode = account.CurrencyCode;
|
||||
}
|
||||
|
||||
private void CancelAccountEdit()
|
||||
{
|
||||
editingAccountId = null;
|
||||
}
|
||||
|
||||
private async Task SaveAccountDetails(int accountId)
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await AccountService.UpdateAccountDetailsAsync(accountId, userId, editName, editColor, editCurrencyCode);
|
||||
editingAccountId = null;
|
||||
await LoadAccounts();
|
||||
AppState.NotifyAccountsChanged();
|
||||
}
|
||||
|
||||
private sealed class AccountForm
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Account name is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public decimal CurrentBalance { get; set; }
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(7)]
|
||||
public string Color { get; set; } = "#1b6ec2";
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(3)]
|
||||
public string CurrencyCode { get; set; } = "GBP";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
|
||||
using BudgetApp.Data.Entities;
|
||||
|
||||
namespace BudgetApp.Components.Pages;
|
||||
|
||||
public partial class Accounts
|
||||
{
|
||||
private List<Account>? _accounts;
|
||||
private AccountForm _formModel = new();
|
||||
private string? _userId;
|
||||
private int? _editingBalanceAccountId;
|
||||
private decimal _editingBalanceValue;
|
||||
private int? _editingAccountId;
|
||||
private string _editName = string.Empty;
|
||||
private string _editColor = "#1b6ec2";
|
||||
private string _editCurrencyCode = "GBP";
|
||||
private bool _showDeleteModal;
|
||||
private Account? _pendingDeleteAccount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private async Task LoadAccounts()
|
||||
{
|
||||
if (_userId is not null)
|
||||
{
|
||||
_accounts = await AccountService.GetAccountsAsync(_userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddAccount()
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
var account = new Account
|
||||
{
|
||||
Name = _formModel.Name,
|
||||
CurrentBalance = _formModel.CurrentBalance,
|
||||
Color = _formModel.Color,
|
||||
CurrencyCode = _formModel.CurrencyCode,
|
||||
UserId = _userId
|
||||
};
|
||||
await AccountService.CreateAccountAsync(account);
|
||||
_formModel = new AccountForm();
|
||||
await LoadAccounts();
|
||||
AppState.NotifyAccountsChanged();
|
||||
}
|
||||
|
||||
private void ConfirmDeleteAccount(Account account)
|
||||
{
|
||||
_pendingDeleteAccount = account;
|
||||
_showDeleteModal = true;
|
||||
}
|
||||
|
||||
private async Task DeleteAccountConfirmed()
|
||||
{
|
||||
if (_userId is null || _pendingDeleteAccount is null) return;
|
||||
|
||||
_showDeleteModal = false;
|
||||
await AccountService.DeleteAccountAsync(_pendingDeleteAccount.Id, _userId);
|
||||
_pendingDeleteAccount = null;
|
||||
await LoadAccounts();
|
||||
AppState.NotifyAccountsChanged();
|
||||
}
|
||||
|
||||
private void CancelDeleteAccount()
|
||||
{
|
||||
_showDeleteModal = false;
|
||||
_pendingDeleteAccount = null;
|
||||
}
|
||||
|
||||
private void StartBalanceEdit(Account account)
|
||||
{
|
||||
_editingBalanceAccountId = account.Id;
|
||||
_editingBalanceValue = account.CurrentBalance;
|
||||
}
|
||||
|
||||
private void CancelBalanceEdit()
|
||||
{
|
||||
_editingBalanceAccountId = null;
|
||||
}
|
||||
|
||||
private async Task SaveBalance(int accountId)
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
await AccountService.UpdateBalanceAsync(accountId, _userId, _editingBalanceValue);
|
||||
_editingBalanceAccountId = null;
|
||||
await LoadAccounts();
|
||||
AppState.NotifyAccountsChanged();
|
||||
}
|
||||
|
||||
private void StartAccountEdit(Account account)
|
||||
{
|
||||
_editingAccountId = account.Id;
|
||||
_editName = account.Name;
|
||||
_editColor = account.Color;
|
||||
_editCurrencyCode = account.CurrencyCode;
|
||||
}
|
||||
|
||||
private void CancelAccountEdit()
|
||||
{
|
||||
_editingAccountId = null;
|
||||
}
|
||||
|
||||
private async Task SaveAccountDetails(int accountId)
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
await AccountService.UpdateAccountDetailsAsync(accountId, _userId, _editName, _editColor, _editCurrencyCode);
|
||||
_editingAccountId = null;
|
||||
await LoadAccounts();
|
||||
AppState.NotifyAccountsChanged();
|
||||
}
|
||||
|
||||
private sealed class AccountForm
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Account name is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public decimal CurrentBalance { get; set; }
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(7)]
|
||||
public string Color { get; set; } = "#1b6ec2";
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(3)]
|
||||
public string CurrencyCode { get; set; } = "GBP";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@page "/categories"
|
||||
@attribute [Authorize]
|
||||
@inject CategoryService CategoryService
|
||||
@inject ICategoryRepository CategoryService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@@ -14,17 +14,17 @@
|
||||
<h5 class="mb-0">Add Category</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<EditForm Model="formModel" OnValidSubmit="AddCategory" FormName="addCategory">
|
||||
<EditForm Model="_formModel" OnValidSubmit="AddCategory" FormName="addCategory">
|
||||
<ToastValidator />
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Category Name</label>
|
||||
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Groceries, Rent, Salary" title="A name to group related transactions" />
|
||||
<InputText @bind-Value="_formModel.Name" class="form-control" placeholder="e.g. Groceries, Rent, Salary" title="A name to group related transactions" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Icon</label>
|
||||
<button type="button" class="btn btn-outline-secondary w-100" style="font-size: 1.4rem; height: 38px; padding: 0;"
|
||||
title="Click to choose an emoji icon" @onclick="OpenAddIconPicker">@(string.IsNullOrEmpty(formModel.Icon) ? "📁" : formModel.Icon)</button>
|
||||
title="Click to choose an emoji icon" @onclick="OpenAddIconPicker">@(string.IsNullOrEmpty(_formModel.Icon) ? "📁" : _formModel.Icon)</button>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn btn-primary w-100">Add</button>
|
||||
@@ -34,29 +34,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (categories is null)
|
||||
@if (_categories is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (categories.Count == 0)
|
||||
else if (_categories.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No categories yet. Add one above to organise your transactions.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 g-3">
|
||||
@foreach (var category in categories)
|
||||
@foreach (var category in _categories)
|
||||
{
|
||||
<div class="col">
|
||||
<div class="card h-100 text-center">
|
||||
<div class="card-body d-flex flex-column align-items-center justify-content-center">
|
||||
@if (editingCategoryId == category.Id)
|
||||
@if (_editingCategoryId == category.Id)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary" style="font-size: 2rem; width: 52px; height: 52px; padding: 0;"
|
||||
@onclick="OpenEditIconPicker">@(string.IsNullOrEmpty(editIcon) ? "📁" : editIcon)</button>
|
||||
@onclick="OpenEditIconPicker">@(string.IsNullOrEmpty(_editIcon) ? "📁" : _editIcon)</button>
|
||||
<div class="mt-2" style="height: 31px; width: 100%;">
|
||||
<input type="text" class="form-control form-control-sm text-center"
|
||||
style="font-size: 1rem; font-weight: 500;" @bind="editCategoryName" />
|
||||
style="font-size: 1rem; font-weight: 500;" @bind="_editCategoryName" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -73,7 +73,7 @@ else
|
||||
{
|
||||
<span class="badge bg-secondary" title="Default categories cannot be edited or deleted">Default</span>
|
||||
}
|
||||
else if (editingCategoryId == category.Id)
|
||||
else if (_editingCategoryId == category.Id)
|
||||
{
|
||||
<button class="btn btn-success btn-sm" @onclick="() => SaveCategoryEdit(category.Id)">Save</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="CancelCategoryEdit">Cancel</button>
|
||||
@@ -91,7 +91,7 @@ else
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showIconModal)
|
||||
@if (_showIconModal)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);" @onclick="CloseIconModal">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm" @onclick:stopPropagation="true">
|
||||
@@ -115,118 +115,9 @@ else
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Category>? categories;
|
||||
private CategoryForm formModel = new();
|
||||
private string? userId;
|
||||
|
||||
// Edit state
|
||||
private int? editingCategoryId;
|
||||
private string editCategoryName = string.Empty;
|
||||
private string editIcon = string.Empty;
|
||||
|
||||
// Icon modal state
|
||||
private bool showIconModal;
|
||||
private string iconPickerTarget = "add"; // "add" or "edit"
|
||||
|
||||
private static readonly string[] AvailableIcons =
|
||||
[
|
||||
"🛒", "🏠", "💡", "🚗", "🍽️", "🎬", "🏥", "🛡️", "💰", "🏦",
|
||||
"📱", "🛍️", "📚", "✈️", "🎁", "🏋️", "🎮", "🎵", "🐾", "☕",
|
||||
"🍕", "👶", "💼", "🔧", "📦", "💊", "🎨", "🌿", "🏢", "📰",
|
||||
"💳", "🧾", "📋", "🔌", "🚌", "🚀", "🎂", "🧹", "📸", "💎"
|
||||
];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private async Task LoadCategories()
|
||||
{
|
||||
if (userId is not null)
|
||||
{
|
||||
await CategoryService.EnsureDefaultCategoriesAsync(userId);
|
||||
categories = await CategoryService.GetCategoriesAsync(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddCategory()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
var category = new Category
|
||||
{
|
||||
Name = formModel.Name,
|
||||
Icon = formModel.Icon,
|
||||
UserId = userId
|
||||
};
|
||||
await CategoryService.CreateCategoryAsync(category);
|
||||
formModel = new CategoryForm();
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private void StartCategoryEdit(Category category)
|
||||
{
|
||||
editingCategoryId = category.Id;
|
||||
editCategoryName = category.Name;
|
||||
editIcon = category.Icon;
|
||||
}
|
||||
|
||||
private void CancelCategoryEdit()
|
||||
{
|
||||
editingCategoryId = null;
|
||||
}
|
||||
|
||||
private async Task SaveCategoryEdit(int id)
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await CategoryService.UpdateCategoryAsync(id, userId, editCategoryName, editIcon);
|
||||
editingCategoryId = null;
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private async Task DeleteCategory(int id)
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await CategoryService.DeleteCategoryAsync(id, userId);
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private void OpenAddIconPicker()
|
||||
{
|
||||
iconPickerTarget = "add";
|
||||
showIconModal = true;
|
||||
}
|
||||
|
||||
private void OpenEditIconPicker()
|
||||
{
|
||||
iconPickerTarget = "edit";
|
||||
showIconModal = true;
|
||||
}
|
||||
|
||||
private void CloseIconModal() => showIconModal = false;
|
||||
|
||||
private void SelectIcon(string emoji)
|
||||
{
|
||||
if (iconPickerTarget == "add")
|
||||
formModel.Icon = emoji;
|
||||
else
|
||||
editIcon = emoji;
|
||||
|
||||
showIconModal = false;
|
||||
}
|
||||
|
||||
private sealed class CategoryForm
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Category name is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(10)]
|
||||
public string Icon { get; set; } = "📁";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
|
||||
using BudgetApp.Data.Entities;
|
||||
|
||||
namespace BudgetApp.Components.Pages;
|
||||
|
||||
public partial class Categories
|
||||
{
|
||||
private List<Category>? _categories;
|
||||
private CategoryForm _formModel = new();
|
||||
private string? _userId;
|
||||
private int? _editingCategoryId;
|
||||
private string _editCategoryName = string.Empty;
|
||||
private string _editIcon = string.Empty;
|
||||
private bool _showIconModal;
|
||||
private string _iconPickerTarget = "add"; // "add" or "edit"
|
||||
|
||||
private static readonly string[] AvailableIcons =
|
||||
[
|
||||
"??", "??", "??", "??", "???", "??", "??", "???", "??", "??",
|
||||
"??", "???", "??", "??", "??", "???", "??", "??", "??", "?",
|
||||
"??", "??", "??", "??", "??", "??", "??", "??", "??", "??",
|
||||
"??", "??", "??", "??", "??", "??", "??", "??", "??", "??"
|
||||
];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private async Task LoadCategories()
|
||||
{
|
||||
if (_userId is not null)
|
||||
{
|
||||
await CategoryService.EnsureDefaultCategoriesAsync(_userId);
|
||||
_categories = await CategoryService.GetCategoriesAsync(_userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddCategory()
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
var category = new Category
|
||||
{
|
||||
Name = _formModel.Name,
|
||||
Icon = _formModel.Icon,
|
||||
UserId = _userId
|
||||
};
|
||||
await CategoryService.CreateCategoryAsync(category);
|
||||
_formModel = new CategoryForm();
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private void StartCategoryEdit(Category category)
|
||||
{
|
||||
_editingCategoryId = category.Id;
|
||||
_editCategoryName = category.Name;
|
||||
_editIcon = category.Icon;
|
||||
}
|
||||
|
||||
private void CancelCategoryEdit()
|
||||
{
|
||||
_editingCategoryId = null;
|
||||
}
|
||||
|
||||
private async Task SaveCategoryEdit(int id)
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
await CategoryService.UpdateCategoryAsync(id, _userId, _editCategoryName, _editIcon);
|
||||
_editingCategoryId = null;
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private async Task DeleteCategory(int id)
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
await CategoryService.DeleteCategoryAsync(id, _userId);
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private void OpenAddIconPicker()
|
||||
{
|
||||
_iconPickerTarget = "add";
|
||||
_showIconModal = true;
|
||||
}
|
||||
|
||||
private void OpenEditIconPicker()
|
||||
{
|
||||
_iconPickerTarget = "edit";
|
||||
_showIconModal = true;
|
||||
}
|
||||
|
||||
private void CloseIconModal() => _showIconModal = false;
|
||||
|
||||
private void SelectIcon(string emoji)
|
||||
{
|
||||
if (_iconPickerTarget == "add")
|
||||
_formModel.Icon = emoji;
|
||||
else
|
||||
_editIcon = emoji;
|
||||
|
||||
_showIconModal = false;
|
||||
}
|
||||
|
||||
private sealed class CategoryForm
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Category name is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(10)]
|
||||
public string Icon { get; set; } = "??";
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
@page "/"
|
||||
@attribute [Authorize]
|
||||
@inject DashboardService DashboardService
|
||||
@inject TransactionService TransactionService
|
||||
@inject SalaryService SalaryService
|
||||
@inject ITransactionRepository TransactionService
|
||||
@inject ISalaryRepository SalaryService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime JS
|
||||
@inject IJSRuntime Js
|
||||
@using BudgetApp.Data.Entities
|
||||
@implements IAsyncDisposable
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@@ -13,11 +14,11 @@
|
||||
<h3 class="mb-2">Dashboard</h3>
|
||||
<p class="text-muted mb-4">An overview of your balances, recent and upcoming transactions.</p>
|
||||
|
||||
@if (data is null)
|
||||
@if (_data is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (data.Accounts.Count == 0)
|
||||
else if (_data.Accounts.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<h5>Welcome!</h5>
|
||||
@@ -31,16 +32,16 @@ else
|
||||
<h5 class="mb-0">Balance Trend</h5>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn @(windowPreset == "7d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 7 days" @onclick="() => SetWindow(7)">7d</button>
|
||||
<button class="btn @(windowPreset == "30d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 30 days" @onclick="() => SetWindow(30)">30d</button>
|
||||
<button class="btn @(windowPreset == "90d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 90 days" @onclick="() => SetWindow(90)">90d</button>
|
||||
<button class="btn @(windowPreset == "1y" ? "btn-primary" : "btn-outline-secondary")" title="Show next year" @onclick="() => SetWindow(365)">1y</button>
|
||||
<button class="btn @(_windowPreset == "7d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 7 days" @onclick="() => SetWindow(7)">7d</button>
|
||||
<button class="btn @(_windowPreset == "30d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 30 days" @onclick="() => SetWindow(30)">30d</button>
|
||||
<button class="btn @(_windowPreset == "90d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 90 days" @onclick="() => SetWindow(90)">90d</button>
|
||||
<button class="btn @(_windowPreset == "1y" ? "btn-primary" : "btn-outline-secondary")" title="Show next year" @onclick="() => SetWindow(365)">1y</button>
|
||||
</div>
|
||||
<input type="date" class="form-control form-control-sm date-picker"
|
||||
title="Custom start date" @bind="windowStart" @bind:after="OnWindowDateChanged" />
|
||||
title="Custom start date" @bind="_windowStart" @bind:after="OnWindowDateChanged" />
|
||||
<span class="text-muted">to</span>
|
||||
<input type="date" class="form-control form-control-sm date-picker"
|
||||
title="Custom end date" @bind="windowEnd" @bind:after="OnWindowDateChanged" />
|
||||
title="Custom end date" @bind="_windowEnd" @bind:after="OnWindowDateChanged" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -51,41 +52,41 @@ else
|
||||
</div>
|
||||
|
||||
@* ── Edit / Add Modal ── *@
|
||||
@if (showModal)
|
||||
@if (_showModal)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@(editingTransactionId is null ? "Add Transaction" : "Edit Transaction")</h5>
|
||||
<h5 class="modal-title">@(_editingTransactionId is null ? "Add Transaction" : "Edit Transaction")</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseModal"></button>
|
||||
</div>
|
||||
<EditForm Model="modalForm" OnValidSubmit="SaveModal" FormName="dashboardModal">
|
||||
<EditForm Model="_modalForm" OnValidSubmit="SaveModal" FormName="dashboardModal">
|
||||
<ToastValidator />
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<InputText @bind-Value="modalForm.Description" class="form-control" />
|
||||
<InputText @bind-Value="_modalForm.Description" class="form-control" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Amount</label>
|
||||
<InputNumber @bind-Value="modalForm.Amount" class="form-control numeric-input" />
|
||||
<InputNumber @bind-Value="_modalForm.Amount" class="form-control numeric-input" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Date</label>
|
||||
<InputDate @bind-Value="modalForm.Date" class="form-control" />
|
||||
<InputDate @bind-Value="_modalForm.Date" class="form-control" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Type</label>
|
||||
<InputSelect @bind-Value="modalForm.Type" class="form-select">
|
||||
<InputSelect @bind-Value="_modalForm.Type" class="form-select">
|
||||
<option value="@TransactionType.Expense">Expense</option>
|
||||
<option value="@TransactionType.Income">Income</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Account</label>
|
||||
<InputSelect @bind-Value="modalForm.AccountId" class="form-select">
|
||||
@foreach (var a in data.Accounts)
|
||||
<InputSelect @bind-Value="_modalForm.AccountId" class="form-select">
|
||||
@foreach (var a in _data.Accounts)
|
||||
{
|
||||
<option value="@a.Id">@a.Name</option>
|
||||
}
|
||||
@@ -93,19 +94,19 @@ else
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Category</label>
|
||||
<InputSelect @bind-Value="modalForm.CategoryId" class="form-select">
|
||||
<InputSelect @bind-Value="_modalForm.CategoryId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
@foreach (var c in data.Categories)
|
||||
@foreach (var c in _data.Categories)
|
||||
{
|
||||
<option value="@c.Id">@(string.IsNullOrEmpty(c.Icon) ? "" : c.Icon + " ")@c.Name</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
@if (editingTransactionId is null)
|
||||
@if (_editingTransactionId is null)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Recurrence</label>
|
||||
<InputSelect @bind-Value="modalForm.RecurrenceType" class="form-select">
|
||||
<InputSelect @bind-Value="_modalForm.RecurrenceType" class="form-select">
|
||||
<option value="@RecurrenceType.None">None</option>
|
||||
<option value="@RecurrenceType.Daily">Daily</option>
|
||||
<option value="@RecurrenceType.Weekly">Weekly</option>
|
||||
@@ -113,15 +114,15 @@ else
|
||||
<option value="@RecurrenceType.Yearly">Yearly</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
@if (modalForm.RecurrenceType != RecurrenceType.None)
|
||||
@if (_modalForm.RecurrenceType != RecurrenceType.None)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Recurrence End Date <small class="text-muted">(max 1 year, defaults to 1 year if blank)</small></label>
|
||||
<InputDate @bind-Value="modalForm.RecurrenceEndDate" class="form-control" max="@modalForm.Date.AddYears(1).ToString("yyyy-MM-dd")" />
|
||||
<InputDate @bind-Value="_modalForm.RecurrenceEndDate" class="form-control" max="@_modalForm.Date.AddYears(1).ToString("yyyy-MM-dd")" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (editIsPartOfSeries)
|
||||
else if (_editIsPartOfSeries)
|
||||
{
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle"></i> Changes will apply to this and all future occurrences in the series.
|
||||
@@ -142,31 +143,31 @@ else
|
||||
<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" : "")" title="Transactions that have already occurred" @onclick='() => { activeTab = "recent"; recentPage = 1; }'>
|
||||
Recent <span class="badge bg-secondary ms-1">@filteredRecent.Count</span>
|
||||
<button class="nav-link @(_activeTab == "recent" ? "active" : "")" title="Transactions that have already occurred" @onclick='() => { _activeTab = "recent"; _recentPage = 1; }'>
|
||||
Recent <span class="badge bg-secondary ms-1">@FilteredRecent.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "upcoming" ? "active" : "")" title="Scheduled future transactions" @onclick='() => { activeTab = "upcoming"; upcomingPage = 1; }'>
|
||||
Upcoming <span class="badge bg-secondary ms-1">@filteredUpcoming.Count</span>
|
||||
<button class="nav-link @(_activeTab == "upcoming" ? "active" : "")" title="Scheduled future transactions" @onclick='() => { _activeTab = "upcoming"; _upcomingPage = 1; }'>
|
||||
Upcoming <span class="badge bg-secondary ms-1">@FilteredUpcoming.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input type="search" class="form-control form-control-sm" style="max-width: 180px;"
|
||||
title="Filter by description, account, category or amount" placeholder="Search..." @bind="dashboardSearchQuery" @bind:event="oninput" @bind:after="OnDashboardSearchChanged" />
|
||||
title="Filter by description, account, category or amount" placeholder="Search..." @bind="_dashboardSearchQuery" @bind:event="oninput" @bind:after="OnDashboardSearchChanged" />
|
||||
<button class="btn btn-sm btn-primary" title="Quickly add a new transaction" @onclick="OpenAddModal">+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (data.Accounts.Count > 1)
|
||||
@if (_data.Accounts.Count > 1)
|
||||
{
|
||||
<div class="px-3 py-2 border-bottom d-flex flex-wrap align-items-center gap-2">
|
||||
<small class="text-muted me-1">Filter:</small>
|
||||
@foreach (var a in data.Accounts)
|
||||
@foreach (var a in _data.Accounts)
|
||||
{
|
||||
<button class="btn btn-sm px-2 py-0 @(hiddenAccountIds.Contains(a.Id) ? "btn-outline-secondary" : "")"
|
||||
style="font-size: 0.75rem; @(hiddenAccountIds.Contains(a.Id) ? "opacity: 0.5;" : $"background-color: {a.Color}; color: white;")"
|
||||
title="@(hiddenAccountIds.Contains(a.Id) ? $"Show {a.Name} transactions" : $"Hide {a.Name} transactions")"
|
||||
<button class="btn btn-sm px-2 py-0 @(_hiddenAccountIds.Contains(a.Id) ? "btn-outline-secondary" : "")"
|
||||
style="font-size: 0.75rem; @(_hiddenAccountIds.Contains(a.Id) ? "opacity: 0.5;" : $"background-color: {a.Color}; color: white;")"
|
||||
title="@(_hiddenAccountIds.Contains(a.Id) ? $"Show {a.Name} transactions" : $"Hide {a.Name} transactions")"
|
||||
@onclick="() => ToggleAccountFilter(a.Id)">
|
||||
@a.Name
|
||||
</button>
|
||||
@@ -174,18 +175,18 @@ else
|
||||
</div>
|
||||
}
|
||||
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
|
||||
@if (activeTab == "recent")
|
||||
@if (_activeTab == "recent")
|
||||
{
|
||||
@if (filteredRecent.Count == 0)
|
||||
@if (FilteredRecent.Count == 0)
|
||||
{
|
||||
<p class="p-3 text-muted mb-0">No recent transactions.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach (var t in filteredRecent.Skip((recentPage - 1) * PageSize).Take(PageSize))
|
||||
@foreach (var t in FilteredRecent.Skip((_recentPage - 1) * PageSize).Take(PageSize))
|
||||
{
|
||||
<div class="list-group-item list-group-item-action txn-row @(selectedTransactionId == t.Id ? "txn-row-selected" : "")"
|
||||
<div class="list-group-item list-group-item-action txn-row @(_selectedTransactionId == t.Id ? "txn-row-selected" : "")"
|
||||
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
|
||||
@onclick="() => SelectTransaction(t.Id)">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
@@ -217,7 +218,7 @@ else
|
||||
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (selectedTransactionId == t.Id)
|
||||
@if (_selectedTransactionId == t.Id)
|
||||
{
|
||||
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center gap-2">
|
||||
@if (t.IsPaid)
|
||||
@@ -240,25 +241,25 @@ else
|
||||
@if (RecentTotalPages > 1)
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(recentPage <= 1 ? true : (bool?)null)" @onclick="() => recentPage--">← Prev</button>
|
||||
<small class="text-muted">Page @recentPage of @RecentTotalPages</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(recentPage >= RecentTotalPages ? true : (bool?)null)" @onclick="() => recentPage++">Next →</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(_recentPage <= 1 ? true : (bool?)null)" @onclick="() => _recentPage--">← Prev</button>
|
||||
<small class="text-muted">Page @_recentPage of @RecentTotalPages</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(_recentPage >= RecentTotalPages ? true : (bool?)null)" @onclick="() => _recentPage++">Next →</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (filteredUpcoming.Count == 0)
|
||||
@if (FilteredUpcoming.Count == 0)
|
||||
{
|
||||
<p class="p-3 text-muted mb-0">No upcoming transactions.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach (var t in filteredUpcoming.Skip((upcomingPage - 1) * PageSize).Take(PageSize))
|
||||
@foreach (var t in FilteredUpcoming.Skip((_upcomingPage - 1) * PageSize).Take(PageSize))
|
||||
{
|
||||
<div class="list-group-item list-group-item-action txn-row @(selectedTransactionId == t.Id ? "txn-row-selected" : "")"
|
||||
<div class="list-group-item list-group-item-action txn-row @(_selectedTransactionId == t.Id ? "txn-row-selected" : "")"
|
||||
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
|
||||
@onclick="() => SelectTransaction(t.Id)">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
@@ -283,7 +284,7 @@ else
|
||||
<div class="@(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold" style="font-size: 1.05rem;">
|
||||
@(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
|
||||
</div>
|
||||
@if (projectedBalances.TryGetValue(t.Id, out var projected))
|
||||
@if (_projectedBalances.TryGetValue(t.Id, out var projected))
|
||||
{
|
||||
<small class="@(projected >= 0 ? "text-success" : "text-danger")" style="font-size: 0.7rem;">
|
||||
Projected: @t.Account.FormatAmount(projected)
|
||||
@@ -295,7 +296,7 @@ else
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (selectedTransactionId == t.Id)
|
||||
@if (_selectedTransactionId == t.Id)
|
||||
{
|
||||
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center gap-2">
|
||||
<button class="btn btn-outline-success btn-sm" title="Mark as paid and move to Recent" @onclick="() => MarkAsPaid(t.Id, true)" @onclick:stopPropagation="true">✅ Mark as Paid</button>
|
||||
@@ -311,9 +312,9 @@ else
|
||||
@if (UpcomingTotalPages > 1)
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(upcomingPage <= 1 ? true : (bool?)null)" @onclick="() => upcomingPage--">← Prev</button>
|
||||
<small class="text-muted">Page @upcomingPage of @UpcomingTotalPages</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(upcomingPage >= UpcomingTotalPages ? true : (bool?)null)" @onclick="() => upcomingPage++">Next →</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(_upcomingPage <= 1 ? true : (bool?)null)" @onclick="() => _upcomingPage--">← Prev</button>
|
||||
<small class="text-muted">Page @_upcomingPage of @UpcomingTotalPages</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(_upcomingPage >= UpcomingTotalPages ? true : (bool?)null)" @onclick="() => _upcomingPage++">Next →</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -321,18 +322,18 @@ else
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showDeleteModal && pendingDeleteTransaction is not null)
|
||||
@if (_showDeleteModal && _pendingDeleteTransaction is not null)
|
||||
{
|
||||
<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">@(pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete 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>Are you sure you want to delete <strong>@pendingDeleteTransaction.Description</strong>?</p>
|
||||
@if (pendingDeleteIsRecurring)
|
||||
<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>
|
||||
}
|
||||
@@ -343,7 +344,7 @@ else
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||
@if (pendingDeleteIsRecurring)
|
||||
@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>
|
||||
@@ -360,371 +361,17 @@ else
|
||||
}
|
||||
|
||||
@code {
|
||||
private DashboardData? data;
|
||||
private string? userId;
|
||||
private int? selectedTransactionId;
|
||||
private string activeTab = "recent";
|
||||
private Dictionary<int, decimal> projectedBalances = new();
|
||||
|
||||
// Modal state
|
||||
private bool showModal;
|
||||
private int? editingTransactionId;
|
||||
private bool editIsPartOfSeries;
|
||||
private ModalTransactionForm modalForm = new();
|
||||
|
||||
// Chart redraw tracking
|
||||
private int chartDataVersion;
|
||||
private int lastRenderedChartVersion;
|
||||
|
||||
// Time window
|
||||
private DateTime windowStart = DateTime.Today;
|
||||
private DateTime windowEnd = DateTime.Today.AddDays(30);
|
||||
private string windowPreset = "30d";
|
||||
|
||||
// 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))
|
||||
.Where(t => MatchesSearch(t))
|
||||
.ToList();
|
||||
|
||||
private List<Transaction> filteredUpcoming => data is null ? []
|
||||
: 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)
|
||||
{
|
||||
if (!hiddenAccountIds.Remove(accountId))
|
||||
hiddenAccountIds.Add(accountId);
|
||||
recentPage = 1;
|
||||
upcomingPage = 1;
|
||||
}
|
||||
|
||||
private void OnDashboardSearchChanged()
|
||||
{
|
||||
recentPage = 1;
|
||||
upcomingPage = 1;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
private int recentPage = 1;
|
||||
private int upcomingPage = 1;
|
||||
private const int PageSize = 10;
|
||||
private int RecentTotalPages => (int)Math.Ceiling(filteredRecent.Count / (double)PageSize);
|
||||
private int UpcomingTotalPages => (int)Math.Ceiling(filteredUpcoming.Count / (double)PageSize);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (userId is not null)
|
||||
{
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (data?.ChartData.Datasets.Count > 0 && chartDataVersion != lastRenderedChartVersion)
|
||||
{
|
||||
lastRenderedChartVersion = chartDataVersion;
|
||||
|
||||
var datasets = data.ChartData.Datasets.Select(ds => new
|
||||
{
|
||||
label = ds.Label,
|
||||
color = ds.Color,
|
||||
data = ds.Data.Select(d => (double)d).ToArray(),
|
||||
currencySymbol = ds.CurrencySymbol
|
||||
}).ToArray();
|
||||
|
||||
var dayLabels = data.ChartData.DayTransactionLabels
|
||||
.Select(d => d.ToArray()).ToArray();
|
||||
|
||||
int highlightIndex = GetHighlightIndex();
|
||||
|
||||
await JS.InvokeVoidAsync("chartInterop.createOrUpdateChart",
|
||||
"balanceChart",
|
||||
data.ChartData.Labels.ToArray(),
|
||||
datasets,
|
||||
dayLabels,
|
||||
highlightIndex,
|
||||
GetSalaryDayIndices(),
|
||||
data.CurrencySymbol);
|
||||
}
|
||||
}
|
||||
|
||||
private int GetHighlightIndex()
|
||||
{
|
||||
if (selectedTransactionId is null || data is null) return -1;
|
||||
|
||||
var txn = data.RecentTransactions.FirstOrDefault(t => t.Id == selectedTransactionId)
|
||||
?? data.UpcomingTransactions.FirstOrDefault(t => t.Id == selectedTransactionId);
|
||||
if (txn is null) return -1;
|
||||
|
||||
var label = txn.Date.ToString("MMM dd");
|
||||
return data.ChartData.Labels.IndexOf(label);
|
||||
}
|
||||
|
||||
private int[] GetSalaryDayIndices()
|
||||
{
|
||||
if (data is null) return [];
|
||||
|
||||
var salaryDates = data.RecentTransactions
|
||||
.Concat(data.UpcomingTransactions)
|
||||
.Where(t => t.SalaryId is not null)
|
||||
.Select(t => t.Date.ToString("MMM dd"))
|
||||
.ToHashSet();
|
||||
|
||||
return data.ChartData.Labels
|
||||
.Select((label, index) => (label, index))
|
||||
.Where(x => salaryDates.Contains(x.label))
|
||||
.Select(x => x.index)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task SelectTransaction(int id)
|
||||
{
|
||||
selectedTransactionId = selectedTransactionId == id ? null : id;
|
||||
|
||||
if (data?.ChartData.Datasets.Count > 0)
|
||||
{
|
||||
int highlightIndex = GetHighlightIndex();
|
||||
await JS.InvokeVoidAsync("chartInterop.highlightDate", "balanceChart", highlightIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenAddModal()
|
||||
{
|
||||
editingTransactionId = null;
|
||||
modalForm = new ModalTransactionForm
|
||||
{
|
||||
AccountId = data?.Accounts.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
private void OpenEditModal(Transaction t)
|
||||
{
|
||||
editingTransactionId = t.Id;
|
||||
editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||
modalForm = new ModalTransactionForm
|
||||
{
|
||||
Description = t.Description,
|
||||
Amount = t.Amount,
|
||||
Date = t.Date,
|
||||
Type = t.Type,
|
||||
AccountId = t.AccountId,
|
||||
CategoryId = t.CategoryId,
|
||||
RecurrenceType = t.RecurrenceType,
|
||||
RecurrenceEndDate = t.RecurrenceEndDate
|
||||
};
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
private void CloseModal() => showModal = false;
|
||||
|
||||
private async Task SaveModal()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
if (editingTransactionId is null)
|
||||
{
|
||||
var transaction = new Transaction
|
||||
{
|
||||
Description = modalForm.Description,
|
||||
Amount = modalForm.Amount,
|
||||
Date = modalForm.Date,
|
||||
Type = modalForm.Type,
|
||||
AccountId = modalForm.AccountId,
|
||||
CategoryId = modalForm.CategoryId,
|
||||
RecurrenceType = modalForm.RecurrenceType,
|
||||
RecurrenceEndDate = modalForm.RecurrenceEndDate
|
||||
};
|
||||
await TransactionService.CreateTransactionAsync(transaction);
|
||||
await RefreshData();
|
||||
|
||||
// Keep modal open and reset form for the next entry
|
||||
var previousAccountId = modalForm.AccountId;
|
||||
var previousCategoryId = modalForm.CategoryId;
|
||||
var previousType = modalForm.Type;
|
||||
modalForm = new ModalTransactionForm
|
||||
{
|
||||
AccountId = previousAccountId,
|
||||
CategoryId = previousCategoryId,
|
||||
Type = previousType
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
await TransactionService.UpdateTransactionAsync(
|
||||
editingTransactionId.Value, userId,
|
||||
modalForm.Description, modalForm.Amount, modalForm.Date,
|
||||
modalForm.Type, modalForm.AccountId, modalForm.CategoryId,
|
||||
modalForm.RecurrenceType, modalForm.RecurrenceEndDate);
|
||||
|
||||
showModal = false;
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation state
|
||||
private bool showDeleteModal;
|
||||
private Transaction? pendingDeleteTransaction;
|
||||
private bool pendingDeleteIsRecurring;
|
||||
|
||||
private void ConfirmDelete(Transaction t)
|
||||
{
|
||||
pendingDeleteTransaction = t;
|
||||
pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
private async Task DeleteSingle()
|
||||
{
|
||||
if (userId is null || pendingDeleteTransaction is null) return;
|
||||
showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionAsync(pendingDeleteTransaction.Id, userId);
|
||||
pendingDeleteTransaction = null;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private async Task DeleteSeries()
|
||||
{
|
||||
if (userId is null || pendingDeleteTransaction is null) return;
|
||||
showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionSeriesAsync(pendingDeleteTransaction.Id, userId);
|
||||
pendingDeleteTransaction = null;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
showDeleteModal = false;
|
||||
pendingDeleteTransaction = null;
|
||||
}
|
||||
|
||||
private async Task MarkAsPaid(int id, bool isPaid)
|
||||
{
|
||||
if (userId is null) return;
|
||||
await TransactionService.MarkAsPaidAsync(id, userId, isPaid);
|
||||
selectedTransactionId = null;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private static string GetRecurrenceLabel(Transaction t)
|
||||
{
|
||||
if (t.RecurrenceType != RecurrenceType.None)
|
||||
return t.RecurrenceType.ToString();
|
||||
return t.ParentTransaction?.RecurrenceType.ToString() ?? "Recurring";
|
||||
}
|
||||
|
||||
private void ComputeProjectedBalances()
|
||||
{
|
||||
projectedBalances.Clear();
|
||||
if (data is null) return;
|
||||
|
||||
var accountBalances = data.Accounts.ToDictionary(a => a.Id, a => a.CurrentBalance);
|
||||
|
||||
foreach (var t in data.UpcomingTransactions)
|
||||
{
|
||||
if (!accountBalances.ContainsKey(t.AccountId)) continue;
|
||||
|
||||
accountBalances[t.AccountId] += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
|
||||
projectedBalances[t.Id] = accountBalances[t.AccountId];
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
if (userId is null) return;
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd);
|
||||
data = await DashboardService.GetDashboardDataAsync(userId, windowStart, windowEnd);
|
||||
chartDataVersion++;
|
||||
ComputeProjectedBalances();
|
||||
recentPage = 1;
|
||||
upcomingPage = 1;
|
||||
}
|
||||
|
||||
private async Task SetWindow(int days)
|
||||
{
|
||||
windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => "custom" };
|
||||
windowStart = DateTime.Today;
|
||||
windowEnd = DateTime.Today.AddDays(days);
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private async Task OnWindowDateChanged()
|
||||
{
|
||||
windowPreset = "custom";
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("chartInterop.destroyChart", "balanceChart");
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ModalTransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(0.01, (double)decimal.MaxValue, ErrorMessage = "Amount must be greater than zero")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public DateTime Date { get; set; } = DateTime.Today;
|
||||
|
||||
public TransactionType Type { get; set; } = TransactionType.Expense;
|
||||
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public int? CategoryId { get; set; }
|
||||
|
||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.None;
|
||||
|
||||
public DateTime? RecurrenceEndDate { get; set; }
|
||||
|
||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||
{
|
||||
if (RecurrenceType != RecurrenceType.None && RecurrenceEndDate.HasValue)
|
||||
{
|
||||
var maxEnd = Date.AddYears(1);
|
||||
if (RecurrenceEndDate.Value > maxEnd)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date cannot be more than 1 year from the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
if (RecurrenceEndDate.Value <= Date)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date must be after the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using BudgetApp.Data.Entities;
|
||||
using BudgetApp.Services.Models;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace BudgetApp.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
private DashboardData? _data;
|
||||
private string? _userId;
|
||||
private int? _selectedTransactionId;
|
||||
private string _activeTab = "recent";
|
||||
private readonly Dictionary<int, decimal> _projectedBalances = new();
|
||||
private bool _showModal;
|
||||
private int? _editingTransactionId;
|
||||
private bool _editIsPartOfSeries;
|
||||
private ModalTransactionForm _modalForm = new();
|
||||
private int _chartDataVersion;
|
||||
private int _lastRenderedChartVersion;
|
||||
private DateTime _windowStart = DateTime.Today;
|
||||
private DateTime _windowEnd = DateTime.Today.AddDays(30);
|
||||
private string _windowPreset = "30d";
|
||||
private readonly HashSet<int> _hiddenAccountIds = [];
|
||||
private string _dashboardSearchQuery = string.Empty;
|
||||
|
||||
private List<Transaction> FilteredRecent => _data is null
|
||||
? []
|
||||
: _data.RecentTransactions
|
||||
.Where(t => !_hiddenAccountIds.Contains(t.AccountId))
|
||||
.Where(MatchesSearch)
|
||||
.ToList();
|
||||
|
||||
private List<Transaction> FilteredUpcoming => _data is null
|
||||
? []
|
||||
: _data.UpcomingTransactions
|
||||
.Where(t => !_hiddenAccountIds.Contains(t.AccountId))
|
||||
.Where(MatchesSearch)
|
||||
.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)
|
||||
{
|
||||
if (!_hiddenAccountIds.Remove(accountId))
|
||||
_hiddenAccountIds.Add(accountId);
|
||||
_recentPage = 1;
|
||||
_upcomingPage = 1;
|
||||
}
|
||||
|
||||
private void OnDashboardSearchChanged()
|
||||
{
|
||||
_recentPage = 1;
|
||||
_upcomingPage = 1;
|
||||
}
|
||||
|
||||
private int _recentPage = 1;
|
||||
private int _upcomingPage = 1;
|
||||
private const int PageSize = 10;
|
||||
private int RecentTotalPages => (int)Math.Ceiling(FilteredRecent.Count / (double)PageSize);
|
||||
private int UpcomingTotalPages => (int)Math.Ceiling(FilteredUpcoming.Count / (double)PageSize);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (_userId is not null)
|
||||
{
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (_data?.ChartData.Datasets.Count > 0 && _chartDataVersion != _lastRenderedChartVersion)
|
||||
{
|
||||
_lastRenderedChartVersion = _chartDataVersion;
|
||||
|
||||
var datasets = _data.ChartData.Datasets.Select(ds => new
|
||||
{
|
||||
label = ds.Label,
|
||||
color = ds.Color,
|
||||
data = ds.Data.Select(d => (double)d).ToArray(),
|
||||
currencySymbol = ds.CurrencySymbol
|
||||
}).ToArray();
|
||||
|
||||
var dayLabels = _data.ChartData.DayTransactionLabels
|
||||
.Select(d => d.ToArray()).ToArray();
|
||||
|
||||
int highlightIndex = GetHighlightIndex();
|
||||
|
||||
await Js.InvokeVoidAsync("chartInterop.createOrUpdateChart",
|
||||
"balanceChart",
|
||||
_data.ChartData.Labels.ToArray(),
|
||||
datasets,
|
||||
dayLabels,
|
||||
highlightIndex,
|
||||
GetSalaryDayIndices(),
|
||||
_data.CurrencySymbol);
|
||||
}
|
||||
}
|
||||
|
||||
private int GetHighlightIndex()
|
||||
{
|
||||
if (_selectedTransactionId is null || _data is null) return -1;
|
||||
|
||||
var txn = _data.RecentTransactions.FirstOrDefault(t => t.Id == _selectedTransactionId)
|
||||
?? _data.UpcomingTransactions.FirstOrDefault(t => t.Id == _selectedTransactionId);
|
||||
if (txn is null) return -1;
|
||||
|
||||
var label = txn.Date.ToString("MMM dd");
|
||||
return _data.ChartData.Labels.IndexOf(label);
|
||||
}
|
||||
|
||||
private int[] GetSalaryDayIndices()
|
||||
{
|
||||
if (_data is null) return [];
|
||||
|
||||
var salaryDates = _data.RecentTransactions
|
||||
.Concat(_data.UpcomingTransactions)
|
||||
.Where(t => t.SalaryId is not null)
|
||||
.Select(t => t.Date.ToString("MMM dd"))
|
||||
.ToHashSet();
|
||||
|
||||
return _data.ChartData.Labels
|
||||
.Select((label, index) => (label, index))
|
||||
.Where(x => salaryDates.Contains(x.label))
|
||||
.Select(x => x.index)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task SelectTransaction(int id)
|
||||
{
|
||||
_selectedTransactionId = _selectedTransactionId == id ? null : id;
|
||||
|
||||
if (_data?.ChartData.Datasets.Count > 0)
|
||||
{
|
||||
int highlightIndex = GetHighlightIndex();
|
||||
await Js.InvokeVoidAsync("chartInterop.highlightDate", "balanceChart", highlightIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenAddModal()
|
||||
{
|
||||
_editingTransactionId = null;
|
||||
_modalForm = new ModalTransactionForm
|
||||
{
|
||||
AccountId = _data?.Accounts.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
_showModal = true;
|
||||
}
|
||||
|
||||
private void OpenEditModal(Transaction t)
|
||||
{
|
||||
_editingTransactionId = t.Id;
|
||||
_editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||
_modalForm = new ModalTransactionForm
|
||||
{
|
||||
Description = t.Description,
|
||||
Amount = t.Amount,
|
||||
Date = t.Date,
|
||||
Type = t.Type,
|
||||
AccountId = t.AccountId,
|
||||
CategoryId = t.CategoryId,
|
||||
RecurrenceType = t.RecurrenceType,
|
||||
RecurrenceEndDate = t.RecurrenceEndDate
|
||||
};
|
||||
_showModal = true;
|
||||
}
|
||||
|
||||
private void CloseModal() => _showModal = false;
|
||||
|
||||
private async Task SaveModal()
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
if (_editingTransactionId is null)
|
||||
{
|
||||
var transaction = new Transaction
|
||||
{
|
||||
Description = _modalForm.Description,
|
||||
Amount = _modalForm.Amount,
|
||||
Date = _modalForm.Date,
|
||||
Type = _modalForm.Type,
|
||||
AccountId = _modalForm.AccountId,
|
||||
CategoryId = _modalForm.CategoryId,
|
||||
RecurrenceType = _modalForm.RecurrenceType,
|
||||
RecurrenceEndDate = _modalForm.RecurrenceEndDate
|
||||
};
|
||||
await TransactionService.CreateTransactionAsync(transaction);
|
||||
await RefreshData();
|
||||
|
||||
// Keep modal open and reset form for the next entry
|
||||
var previousAccountId = _modalForm.AccountId;
|
||||
var previousCategoryId = _modalForm.CategoryId;
|
||||
var previousType = _modalForm.Type;
|
||||
_modalForm = new ModalTransactionForm
|
||||
{
|
||||
AccountId = previousAccountId,
|
||||
CategoryId = previousCategoryId,
|
||||
Type = previousType
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
await TransactionService.UpdateTransactionAsync(
|
||||
_editingTransactionId.Value, _userId,
|
||||
_modalForm.Description, _modalForm.Amount, _modalForm.Date,
|
||||
_modalForm.Type, _modalForm.AccountId, _modalForm.CategoryId,
|
||||
_modalForm.RecurrenceType, _modalForm.RecurrenceEndDate);
|
||||
|
||||
_showModal = false;
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
private bool _showDeleteModal;
|
||||
private Transaction? _pendingDeleteTransaction;
|
||||
private bool _pendingDeleteIsRecurring;
|
||||
|
||||
private void ConfirmDelete(Transaction t)
|
||||
{
|
||||
_pendingDeleteTransaction = t;
|
||||
_pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||
_showDeleteModal = true;
|
||||
}
|
||||
|
||||
private async Task DeleteSingle()
|
||||
{
|
||||
if (_userId is null || _pendingDeleteTransaction is null) return;
|
||||
_showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionAsync(_pendingDeleteTransaction.Id, _userId);
|
||||
_pendingDeleteTransaction = null;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private async Task DeleteSeries()
|
||||
{
|
||||
if (_userId is null || _pendingDeleteTransaction is null) return;
|
||||
_showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionSeriesAsync(_pendingDeleteTransaction.Id, _userId);
|
||||
_pendingDeleteTransaction = null;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
_showDeleteModal = false;
|
||||
_pendingDeleteTransaction = null;
|
||||
}
|
||||
|
||||
private async Task MarkAsPaid(int id, bool isPaid)
|
||||
{
|
||||
if (_userId is null) return;
|
||||
await TransactionService.MarkAsPaidAsync(id, _userId, isPaid);
|
||||
_selectedTransactionId = null;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private static string GetRecurrenceLabel(Transaction t)
|
||||
{
|
||||
if (t.RecurrenceType != RecurrenceType.None)
|
||||
return t.RecurrenceType.ToString();
|
||||
return t.ParentTransaction?.RecurrenceType.ToString() ?? "Recurring";
|
||||
}
|
||||
|
||||
private void ComputeProjectedBalances()
|
||||
{
|
||||
_projectedBalances.Clear();
|
||||
if (_data is null) return;
|
||||
|
||||
var accountBalances = _data.Accounts.ToDictionary(a => a.Id, a => a.CurrentBalance);
|
||||
|
||||
foreach (var t in _data.UpcomingTransactions)
|
||||
{
|
||||
if (!accountBalances.ContainsKey(t.AccountId)) continue;
|
||||
|
||||
accountBalances[t.AccountId] += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
|
||||
_projectedBalances[t.Id] = accountBalances[t.AccountId];
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
if (_userId is null) return;
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(_userId, _windowEnd);
|
||||
_data = await DashboardService.GetDashboardDataAsync(_userId, _windowStart, _windowEnd);
|
||||
_chartDataVersion++;
|
||||
ComputeProjectedBalances();
|
||||
_recentPage = 1;
|
||||
_upcomingPage = 1;
|
||||
}
|
||||
|
||||
private async Task SetWindow(int days)
|
||||
{
|
||||
_windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => "custom" };
|
||||
_windowStart = DateTime.Today;
|
||||
_windowEnd = DateTime.Today.AddDays(days);
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private async Task OnWindowDateChanged()
|
||||
{
|
||||
_windowPreset = "custom";
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Js.InvokeVoidAsync("chartInterop.destroyChart", "balanceChart");
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ModalTransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(0.01, (double)decimal.MaxValue,
|
||||
ErrorMessage = "Amount must be greater than zero")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public DateTime Date { get; set; } = DateTime.Today;
|
||||
|
||||
public TransactionType Type { get; set; } = TransactionType.Expense;
|
||||
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public int? CategoryId { get; set; }
|
||||
|
||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.None;
|
||||
|
||||
public DateTime? RecurrenceEndDate { get; set; }
|
||||
|
||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(
|
||||
System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||
{
|
||||
if (RecurrenceType != RecurrenceType.None && RecurrenceEndDate.HasValue)
|
||||
{
|
||||
var maxEnd = Date.AddYears(1);
|
||||
if (RecurrenceEndDate.Value > maxEnd)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date cannot be more than 1 year from the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
|
||||
if (RecurrenceEndDate.Value <= Date)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date must be after the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/login"
|
||||
@layout AuthLayout
|
||||
@using BudgetApp.Data.Entities
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@@ -17,9 +18,9 @@
|
||||
<EditForm Model="Model" OnValidSubmit="HandleLogin" FormName="login">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -37,17 +38,17 @@
|
||||
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
||||
</EditForm>
|
||||
|
||||
@if (externalProviders.Count > 0)
|
||||
@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)
|
||||
@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" />
|
||||
<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>
|
||||
@@ -59,71 +60,4 @@
|
||||
|
||||
<p class="text-center mt-3">
|
||||
Don't have an account? <a href="/register">Register</a>
|
||||
</p>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromForm]
|
||||
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()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
var result = await SignInManager.PasswordSignInAsync(
|
||||
Model.Email, Model.Password, isPersistent: false, lockoutOnFailure: false);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = "Invalid email or password.";
|
||||
}
|
||||
}
|
||||
|
||||
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>"),
|
||||
"Facebook" => new MarkupString("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\"><path fill=\"#1877F2\" d=\"M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.413c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z\"/></svg>"),
|
||||
_ => new MarkupString("")
|
||||
};
|
||||
|
||||
private sealed class LoginModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||
[System.ComponentModel.DataAnnotations.EmailAddress]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
</p>
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace BudgetApp.Components.Pages;
|
||||
|
||||
public partial class Login
|
||||
{
|
||||
[SupplyParameterFromForm] 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()
|
||||
{
|
||||
_errorMessage = null;
|
||||
|
||||
var result = await SignInManager.PasswordSignInAsync(
|
||||
Model.Email, Model.Password, isPersistent: false, lockoutOnFailure: false);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_errorMessage = "Invalid email or password.";
|
||||
}
|
||||
}
|
||||
|
||||
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>"),
|
||||
"Facebook" => new MarkupString(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\"><path fill=\"#1877F2\" d=\"M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.413c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z\"/></svg>"),
|
||||
_ => new MarkupString("")
|
||||
};
|
||||
|
||||
private sealed class LoginModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||
[System.ComponentModel.DataAnnotations.EmailAddress]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@page "/logout"
|
||||
@using BudgetApp.Data.Entities
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/register"
|
||||
@layout AuthLayout
|
||||
@using BudgetApp.Data.Entities
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@@ -12,9 +13,9 @@
|
||||
<EditForm Model="Model" OnValidSubmit="HandleRegister" FormName="register">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -40,44 +41,4 @@
|
||||
|
||||
<p class="text-center mt-3">
|
||||
Already have an account? <a href="/login">Sign in</a>
|
||||
</p>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromForm]
|
||||
private RegisterModel Model { get; set; } = new();
|
||||
|
||||
private string? errorMessage;
|
||||
|
||||
private async Task HandleRegister()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
var user = new ApplicationUser { UserName = Model.Email, Email = Model.Email };
|
||||
var result = await UserManager.CreateAsync(user, Model.Password);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SignInManager.SignInAsync(user, isPersistent: false);
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RegisterModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Invalid email address")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
|
||||
[System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
|
||||
public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
</p>
|
||||
@@ -0,0 +1,44 @@
|
||||
using BudgetApp.Data.Entities;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace BudgetApp.Components.Pages;
|
||||
|
||||
public partial class Register
|
||||
{
|
||||
[SupplyParameterFromForm] private RegisterModel Model { get; set; } = new();
|
||||
|
||||
private string? _errorMessage;
|
||||
|
||||
private async Task HandleRegister()
|
||||
{
|
||||
_errorMessage = null;
|
||||
|
||||
var user = new ApplicationUser { UserName = Model.Email, Email = Model.Email };
|
||||
var result = await UserManager.CreateAsync(user, Model.Password);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SignInManager.SignInAsync(user, isPersistent: false);
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RegisterModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Invalid email address")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
|
||||
[System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
|
||||
public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
@page "/reports"
|
||||
@attribute [Authorize]
|
||||
@inject TransactionService TransactionService
|
||||
@inject AccountService AccountService
|
||||
@inject CategoryService CategoryService
|
||||
@inject SalaryService SalaryService
|
||||
@inject ITransactionRepository TransactionService
|
||||
@inject IAccountRepository AccountService
|
||||
@inject ICategoryRepository CategoryService
|
||||
@inject ISalaryRepository SalaryService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime JS
|
||||
@inject IJSRuntime Js
|
||||
@implements IAsyncDisposable
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
<h3 class="mb-2">Reports</h3>
|
||||
<p class="text-muted mb-4">Visual breakdown of your spending and income by category for a specified time period.</p>
|
||||
|
||||
@if (loading)
|
||||
@if (_loading)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (accounts.Count == 0)
|
||||
else if (_accounts.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<h5>No data yet</h5>
|
||||
@@ -33,23 +33,23 @@ else
|
||||
<h5 class="mb-0">Filters</h5>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn @(preset == "7d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(7)">7d</button>
|
||||
<button class="btn @(preset == "30d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(30)">30d</button>
|
||||
<button class="btn @(preset == "90d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(90)">90d</button>
|
||||
<button class="btn @(preset == "1y" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(365)">1y</button>
|
||||
<button class="btn @(_preset == "7d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(7)">7d</button>
|
||||
<button class="btn @(_preset == "30d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(30)">30d</button>
|
||||
<button class="btn @(_preset == "90d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(90)">90d</button>
|
||||
<button class="btn @(_preset == "1y" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(365)">1y</button>
|
||||
</div>
|
||||
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
||||
title="Start date" @bind="dateFrom" @bind:after="OnDateChanged" />
|
||||
title="Start date" @bind="_dateFrom" @bind:after="OnDateChanged" />
|
||||
<span class="text-muted">to</span>
|
||||
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
||||
title="End date" @bind="dateTo" @bind:after="OnDateChanged" />
|
||||
title="End date" @bind="_dateTo" @bind:after="OnDateChanged" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@foreach (var account in accounts)
|
||||
@foreach (var account in _accounts)
|
||||
{
|
||||
<button class="btn btn-sm @(selectedAccountIds.Contains(account.Id) ? "btn-primary" : "btn-outline-secondary")"
|
||||
<button class="btn btn-sm @(_selectedAccountIds.Contains(account.Id) ? "btn-primary" : "btn-outline-secondary")"
|
||||
title="Toggle @account.Name" @onclick="() => ToggleAccount(account.Id)">
|
||||
<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background-color:@account.Color;"></span>
|
||||
@account.Name
|
||||
@@ -65,7 +65,7 @@ else
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><h5 class="mb-0">Expenses by Category</h5></div>
|
||||
<div class="card-body">
|
||||
@if (categoryBreakdown.Count == 0)
|
||||
@if (_categoryBreakdown.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No expenses in this period.</p>
|
||||
}
|
||||
@@ -84,7 +84,7 @@ else
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><h5 class="mb-0">Income by Category</h5></div>
|
||||
<div class="card-body">
|
||||
@if (incomeBreakdown.Count == 0)
|
||||
@if (_incomeBreakdown.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No income in this period.</p>
|
||||
}
|
||||
@@ -103,7 +103,7 @@ else
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0">Monthly Income vs Expenses</h5></div>
|
||||
<div class="card-body">
|
||||
@if (monthlyLabels.Count == 0)
|
||||
@if (_monthlyLabels.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No transactions in this period.</p>
|
||||
}
|
||||
@@ -120,7 +120,7 @@ else
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0">Category Summary</h5></div>
|
||||
<div class="card-body">
|
||||
@if (categoryBreakdown.Count == 0 && incomeBreakdown.Count == 0)
|
||||
@if (_categoryBreakdown.Count == 0 && _incomeBreakdown.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No transactions in this period.</p>
|
||||
}
|
||||
@@ -137,14 +137,14 @@ else
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in categorySummary)
|
||||
@foreach (var row in _categorySummary)
|
||||
{
|
||||
<tr>
|
||||
<td>@row.Icon @row.Name</td>
|
||||
<td class="text-end text-success">@currencySymbol@row.Income.ToString("N2")</td>
|
||||
<td class="text-end text-danger">@currencySymbol@row.Expense.ToString("N2")</td>
|
||||
<td class="text-end text-success">@_currencySymbol@row.Income.ToString("N2")</td>
|
||||
<td class="text-end text-danger">@_currencySymbol@row.Expense.ToString("N2")</td>
|
||||
<td class="text-end @(row.Net >= 0 ? "text-success" : "text-danger")">
|
||||
@currencySymbol@row.Net.ToString("N2")
|
||||
@_currencySymbol@row.Net.ToString("N2")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -152,11 +152,11 @@ else
|
||||
<tfoot>
|
||||
<tr class="fw-bold">
|
||||
<td>Total</td>
|
||||
<td class="text-end text-success">@currencySymbol@categorySummary.Sum(r => r.Income).ToString("N2")</td>
|
||||
<td class="text-end text-danger">@currencySymbol@categorySummary.Sum(r => r.Expense).ToString("N2")</td>
|
||||
@{ var totalNet = categorySummary.Sum(r => r.Net); }
|
||||
<td class="text-end text-success">@_currencySymbol@_categorySummary.Sum(r => r.Income).ToString("N2")</td>
|
||||
<td class="text-end text-danger">@_currencySymbol@_categorySummary.Sum(r => r.Expense).ToString("N2")</td>
|
||||
@{ var totalNet = _categorySummary.Sum(r => r.Net); }
|
||||
<td class="text-end @(totalNet >= 0 ? "text-success" : "text-danger")">
|
||||
@currencySymbol@totalNet.ToString("N2")
|
||||
@_currencySymbol@totalNet.ToString("N2")
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -165,210 +165,4 @@ else
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool loading = true;
|
||||
private string? userId;
|
||||
|
||||
private DateTime dateFrom = DateTime.Today;
|
||||
private DateTime dateTo = DateTime.Today.AddDays(30);
|
||||
private string? preset = "30d";
|
||||
|
||||
private List<Account> accounts = [];
|
||||
private HashSet<int> selectedAccountIds = [];
|
||||
private List<Category> categories = [];
|
||||
private string currencySymbol = "£";
|
||||
|
||||
private List<CategoryAmount> categoryBreakdown = [];
|
||||
private List<CategoryAmount> incomeBreakdown = [];
|
||||
private List<CategorySummaryRow> categorySummary = [];
|
||||
|
||||
private List<string> monthlyLabels = [];
|
||||
private List<decimal> monthlyIncome = [];
|
||||
private List<decimal> monthlyExpenses = [];
|
||||
|
||||
private static readonly string[] chartColors =
|
||||
[
|
||||
"#4dc9f6", "#f67019", "#f53794", "#537bc4", "#acc236",
|
||||
"#166a8f", "#00a950", "#58595b", "#8549ba", "#e6194b",
|
||||
"#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4",
|
||||
"#42d4f4", "#f032e6", "#bfef45", "#fabed4", "#469990"
|
||||
];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null) return;
|
||||
|
||||
accounts = await AccountService.GetAccountsAsync(userId);
|
||||
categories = await CategoryService.GetCategoriesAsync(userId);
|
||||
selectedAccountIds = accounts.Select(a => a.Id).ToHashSet();
|
||||
currencySymbol = accounts.FirstOrDefault()?.GetSymbol() ?? "£";
|
||||
|
||||
await LoadReport();
|
||||
loading = false;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || loading) return;
|
||||
await RenderCharts();
|
||||
}
|
||||
|
||||
private async Task LoadReport()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, dateTo);
|
||||
var transactions = await TransactionService.GetTransactionsInRangeAsync(userId, dateFrom, dateTo);
|
||||
|
||||
var filtered = transactions
|
||||
.Where(t => selectedAccountIds.Contains(t.AccountId))
|
||||
.ToList();
|
||||
|
||||
categoryBreakdown = filtered
|
||||
.Where(t => t.Type == TransactionType.Expense)
|
||||
.GroupBy(t => t.CategoryId)
|
||||
.Select(g =>
|
||||
{
|
||||
var cat = categories.FirstOrDefault(c => c.Id == g.Key);
|
||||
return new CategoryAmount
|
||||
{
|
||||
Name = cat?.Name ?? "Uncategorised",
|
||||
Icon = cat?.Icon ?? "",
|
||||
Total = g.Sum(t => t.Amount)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(c => c.Total)
|
||||
.ToList();
|
||||
|
||||
incomeBreakdown = filtered
|
||||
.Where(t => t.Type == TransactionType.Income)
|
||||
.GroupBy(t => t.CategoryId)
|
||||
.Select(g =>
|
||||
{
|
||||
var cat = categories.FirstOrDefault(c => c.Id == g.Key);
|
||||
return new CategoryAmount
|
||||
{
|
||||
Name = cat?.Name ?? "Uncategorised",
|
||||
Icon = cat?.Icon ?? "",
|
||||
Total = g.Sum(t => t.Amount)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(c => c.Total)
|
||||
.ToList();
|
||||
|
||||
var allCategoryKeys = filtered
|
||||
.Select(t => t.CategoryId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
categorySummary = allCategoryKeys.Select(key =>
|
||||
{
|
||||
var cat = categories.FirstOrDefault(c => c.Id == key);
|
||||
var income = filtered.Where(t => t.CategoryId == key && t.Type == TransactionType.Income).Sum(t => t.Amount);
|
||||
var expense = filtered.Where(t => t.CategoryId == key && t.Type == TransactionType.Expense).Sum(t => t.Amount);
|
||||
return new CategorySummaryRow
|
||||
{
|
||||
Name = cat?.Name ?? "Uncategorised",
|
||||
Icon = cat?.Icon ?? "",
|
||||
Income = income,
|
||||
Expense = expense
|
||||
};
|
||||
})
|
||||
.OrderByDescending(r => r.Expense)
|
||||
.ToList();
|
||||
|
||||
// Monthly data
|
||||
var monthly = filtered
|
||||
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
||||
.OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
|
||||
.ToList();
|
||||
|
||||
monthlyLabels = monthly.Select(g => $"{g.Key.Year}-{g.Key.Month:D2}").ToList();
|
||||
monthlyIncome = monthly.Select(g => g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount)).ToList();
|
||||
monthlyExpenses = monthly.Select(g => g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount)).ToList();
|
||||
}
|
||||
|
||||
private async Task RenderCharts()
|
||||
{
|
||||
if (categoryBreakdown.Count > 0)
|
||||
{
|
||||
var labels = categoryBreakdown.Select(c => $"{c.Icon} {c.Name}".Trim()).ToArray();
|
||||
var data = categoryBreakdown.Select(c => c.Total).ToArray();
|
||||
var colors = Enumerable.Range(0, categoryBreakdown.Count).Select(i => chartColors[i % chartColors.Length]).ToArray();
|
||||
await JS.InvokeVoidAsync("chartInterop.createOrUpdatePieChart", "expensePieChart", labels, data, colors, currencySymbol, "doughnut");
|
||||
}
|
||||
|
||||
if (incomeBreakdown.Count > 0)
|
||||
{
|
||||
var labels = incomeBreakdown.Select(c => $"{c.Icon} {c.Name}".Trim()).ToArray();
|
||||
var data = incomeBreakdown.Select(c => c.Total).ToArray();
|
||||
var colors = Enumerable.Range(0, incomeBreakdown.Count).Select(i => chartColors[i % chartColors.Length]).ToArray();
|
||||
await JS.InvokeVoidAsync("chartInterop.createOrUpdatePieChart", "incomePieChart", labels, data, colors, currencySymbol, "doughnut");
|
||||
}
|
||||
|
||||
if (monthlyLabels.Count > 0)
|
||||
{
|
||||
await JS.InvokeVoidAsync("chartInterop.createOrUpdateBarChart", "monthlyBarChart", monthlyLabels.ToArray(), monthlyIncome.ToArray(), monthlyExpenses.ToArray(), currencySymbol);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetPreset(int days)
|
||||
{
|
||||
preset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null };
|
||||
dateFrom = DateTime.Today;
|
||||
dateTo = DateTime.Today.AddDays(days);
|
||||
await Refresh();
|
||||
}
|
||||
|
||||
private async Task OnDateChanged()
|
||||
{
|
||||
preset = null;
|
||||
await Refresh();
|
||||
}
|
||||
|
||||
private async Task ToggleAccount(int accountId)
|
||||
{
|
||||
if (!selectedAccountIds.Remove(accountId))
|
||||
selectedAccountIds.Add(accountId);
|
||||
await Refresh();
|
||||
}
|
||||
|
||||
private async Task Refresh()
|
||||
{
|
||||
await LoadReport();
|
||||
StateHasChanged();
|
||||
await Task.Yield();
|
||||
await RenderCharts();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("chartInterop.destroyChart", "expensePieChart");
|
||||
await JS.InvokeVoidAsync("chartInterop.destroyChart", "incomePieChart");
|
||||
await JS.InvokeVoidAsync("chartInterop.destroyChart", "monthlyBarChart");
|
||||
}
|
||||
catch (JSDisconnectedException) { }
|
||||
}
|
||||
|
||||
private sealed class CategoryAmount
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public decimal Total { get; set; }
|
||||
}
|
||||
|
||||
private sealed class CategorySummaryRow
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public decimal Income { get; set; }
|
||||
public decimal Expense { get; set; }
|
||||
public decimal Net => Income - Expense;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using BudgetApp.Data.Entities;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace BudgetApp.Components.Pages;
|
||||
|
||||
public partial class Reports
|
||||
{
|
||||
private bool _loading = true;
|
||||
private string? _userId;
|
||||
private DateTime _dateFrom = DateTime.Today;
|
||||
private DateTime _dateTo = DateTime.Today.AddDays(30);
|
||||
private string? _preset = "30d";
|
||||
private List<Account> _accounts = [];
|
||||
private HashSet<int> _selectedAccountIds = [];
|
||||
private List<Category> _categories = [];
|
||||
private string _currencySymbol = "£";
|
||||
private List<CategoryAmount> _categoryBreakdown = [];
|
||||
private List<CategoryAmount> _incomeBreakdown = [];
|
||||
private List<CategorySummaryRow> _categorySummary = [];
|
||||
private List<string> _monthlyLabels = [];
|
||||
private List<decimal> _monthlyIncome = [];
|
||||
private List<decimal> _monthlyExpenses = [];
|
||||
|
||||
private static readonly string[] ChartColors =
|
||||
[
|
||||
"#4dc9f6", "#f67019", "#f53794", "#537bc4", "#acc236",
|
||||
"#166a8f", "#00a950", "#58595b", "#8549ba", "#e6194b",
|
||||
"#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4",
|
||||
"#42d4f4", "#f032e6", "#bfef45", "#fabed4", "#469990"
|
||||
];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (_userId is null) return;
|
||||
|
||||
_accounts = await AccountService.GetAccountsAsync(_userId);
|
||||
_categories = await CategoryService.GetCategoriesAsync(_userId);
|
||||
_selectedAccountIds = _accounts.Select(a => a.Id).ToHashSet();
|
||||
_currencySymbol = _accounts.FirstOrDefault()?.GetSymbol() ?? "£";
|
||||
|
||||
await LoadReport();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || _loading) return;
|
||||
await RenderCharts();
|
||||
}
|
||||
|
||||
private async Task LoadReport()
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(_userId, _dateTo);
|
||||
var transactions = await TransactionService.GetTransactionsInRangeAsync(_userId, _dateFrom, _dateTo);
|
||||
|
||||
var filtered = transactions
|
||||
.Where(t => _selectedAccountIds.Contains(t.AccountId))
|
||||
.ToList();
|
||||
|
||||
_categoryBreakdown = filtered
|
||||
.Where(t => t.Type == TransactionType.Expense)
|
||||
.GroupBy(t => t.CategoryId)
|
||||
.Select(g =>
|
||||
{
|
||||
var cat = _categories.FirstOrDefault(c => c.Id == g.Key);
|
||||
return new CategoryAmount
|
||||
{
|
||||
Name = cat?.Name ?? "Uncategorised",
|
||||
Icon = cat?.Icon ?? "",
|
||||
Total = g.Sum(t => t.Amount)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(c => c.Total)
|
||||
.ToList();
|
||||
|
||||
_incomeBreakdown = filtered
|
||||
.Where(t => t.Type == TransactionType.Income)
|
||||
.GroupBy(t => t.CategoryId)
|
||||
.Select(g =>
|
||||
{
|
||||
var cat = _categories.FirstOrDefault(c => c.Id == g.Key);
|
||||
return new CategoryAmount
|
||||
{
|
||||
Name = cat?.Name ?? "Uncategorised",
|
||||
Icon = cat?.Icon ?? "",
|
||||
Total = g.Sum(t => t.Amount)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(c => c.Total)
|
||||
.ToList();
|
||||
|
||||
var allCategoryKeys = filtered
|
||||
.Select(t => t.CategoryId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
_categorySummary = allCategoryKeys.Select(key =>
|
||||
{
|
||||
var cat = _categories.FirstOrDefault(c => c.Id == key);
|
||||
var income = filtered.Where(t => t.CategoryId == key && t.Type == TransactionType.Income)
|
||||
.Sum(t => t.Amount);
|
||||
var expense = filtered.Where(t => t.CategoryId == key && t.Type == TransactionType.Expense)
|
||||
.Sum(t => t.Amount);
|
||||
return new CategorySummaryRow
|
||||
{
|
||||
Name = cat?.Name ?? "Uncategorised",
|
||||
Icon = cat?.Icon ?? "",
|
||||
Income = income,
|
||||
Expense = expense
|
||||
};
|
||||
})
|
||||
.OrderByDescending(r => r.Expense)
|
||||
.ToList();
|
||||
|
||||
// Monthly data
|
||||
var monthly = filtered
|
||||
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
||||
.OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
|
||||
.ToList();
|
||||
|
||||
_monthlyLabels = monthly.Select(g => $"{g.Key.Year}-{g.Key.Month:D2}").ToList();
|
||||
_monthlyIncome = monthly.Select(g => g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount))
|
||||
.ToList();
|
||||
_monthlyExpenses = monthly.Select(g => g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task RenderCharts()
|
||||
{
|
||||
if (_categoryBreakdown.Count > 0)
|
||||
{
|
||||
var labels = _categoryBreakdown.Select(c => $"{c.Icon} {c.Name}".Trim()).ToArray();
|
||||
var data = _categoryBreakdown.Select(c => c.Total).ToArray();
|
||||
var colors = Enumerable.Range(0, _categoryBreakdown.Count).Select(i => ChartColors[i % ChartColors.Length])
|
||||
.ToArray();
|
||||
await Js.InvokeVoidAsync("chartInterop.createOrUpdatePieChart", "expensePieChart", labels, data, colors,
|
||||
_currencySymbol, "doughnut");
|
||||
}
|
||||
|
||||
if (_incomeBreakdown.Count > 0)
|
||||
{
|
||||
var labels = _incomeBreakdown.Select(c => $"{c.Icon} {c.Name}".Trim()).ToArray();
|
||||
var data = _incomeBreakdown.Select(c => c.Total).ToArray();
|
||||
var colors = Enumerable.Range(0, _incomeBreakdown.Count).Select(i => ChartColors[i % ChartColors.Length])
|
||||
.ToArray();
|
||||
await Js.InvokeVoidAsync("chartInterop.createOrUpdatePieChart", "incomePieChart", labels, data, colors,
|
||||
_currencySymbol, "doughnut");
|
||||
}
|
||||
|
||||
if (_monthlyLabels.Count > 0)
|
||||
{
|
||||
await Js.InvokeVoidAsync("chartInterop.createOrUpdateBarChart", "monthlyBarChart", _monthlyLabels.ToArray(),
|
||||
_monthlyIncome.ToArray(), _monthlyExpenses.ToArray(), _currencySymbol);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetPreset(int days)
|
||||
{
|
||||
_preset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null };
|
||||
_dateFrom = DateTime.Today;
|
||||
_dateTo = DateTime.Today.AddDays(days);
|
||||
await Refresh();
|
||||
}
|
||||
|
||||
private async Task OnDateChanged()
|
||||
{
|
||||
_preset = null;
|
||||
await Refresh();
|
||||
}
|
||||
|
||||
private async Task ToggleAccount(int accountId)
|
||||
{
|
||||
if (!_selectedAccountIds.Remove(accountId))
|
||||
_selectedAccountIds.Add(accountId);
|
||||
await Refresh();
|
||||
}
|
||||
|
||||
private async Task Refresh()
|
||||
{
|
||||
await LoadReport();
|
||||
StateHasChanged();
|
||||
await Task.Yield();
|
||||
await RenderCharts();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Js.InvokeVoidAsync("chartInterop.destroyChart", "expensePieChart");
|
||||
await Js.InvokeVoidAsync("chartInterop.destroyChart", "incomePieChart");
|
||||
await Js.InvokeVoidAsync("chartInterop.destroyChart", "monthlyBarChart");
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CategoryAmount
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public decimal Total { get; set; }
|
||||
}
|
||||
|
||||
private sealed class CategorySummaryRow
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public decimal Income { get; set; }
|
||||
public decimal Expense { get; set; }
|
||||
public decimal Net => Income - Expense;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
@page "/salary"
|
||||
@using BudgetApp.Data.Entities
|
||||
@attribute [Authorize]
|
||||
@inject SalaryService SalaryService
|
||||
@inject AccountService AccountService
|
||||
@inject ISalaryRepository SalaryService
|
||||
@inject IAccountRepository AccountService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
<h5 class="mb-0">Add Income Source</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (accountsList is not null && accountsList.Count == 0)
|
||||
@if (_accountsList is not null && _accountsList.Count == 0)
|
||||
{
|
||||
<div class="alert alert-warning mb-0">
|
||||
You need to <a href="/accounts">create an account</a> before adding a salary.
|
||||
@@ -23,23 +24,23 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="formModel" OnValidSubmit="AddSalary" FormName="addSalary">
|
||||
<EditForm Model="_formModel" OnValidSubmit="AddSalary" FormName="addSalary">
|
||||
<ToastValidator />
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Name</label>
|
||||
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Main Job" />
|
||||
<InputText @bind-Value="_formModel.Name" class="form-control" placeholder="e.g. Main Job" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Amount</label>
|
||||
<InputNumber @bind-Value="formModel.Amount" class="form-control numeric-input" />
|
||||
<InputNumber @bind-Value="_formModel.Amount" class="form-control numeric-input" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Account</label>
|
||||
<InputSelect @bind-Value="formModel.AccountId" class="form-select">
|
||||
@if (accountsList is not null)
|
||||
<InputSelect @bind-Value="_formModel.AccountId" class="form-select">
|
||||
@if (_accountsList is not null)
|
||||
{
|
||||
@foreach (var account in accountsList)
|
||||
@foreach (var account in _accountsList)
|
||||
{
|
||||
<option value="@account.Id">@account.Name</option>
|
||||
}
|
||||
@@ -48,7 +49,7 @@
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Recurrence</label>
|
||||
<InputSelect @bind-Value="formModel.RecurrenceType" class="form-select">
|
||||
<InputSelect @bind-Value="_formModel.RecurrenceType" class="form-select">
|
||||
<option value="@RecurrenceType.Weekly">Weekly</option>
|
||||
<option value="@RecurrenceType.Monthly">Monthly</option>
|
||||
<option value="@RecurrenceType.Yearly">Yearly</option>
|
||||
@@ -56,7 +57,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">First Payday</label>
|
||||
<InputDate @bind-Value="formModel.StartDate" class="form-control" title="The date of the first payment — the interval repeats from here indefinitely" />
|
||||
<InputDate @bind-Value="_formModel.StartDate" class="form-control" title="The date of the first payment — the interval repeats from here indefinitely" />
|
||||
</div>
|
||||
<div class="col-md-12 col-lg-auto d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">Add</button>
|
||||
@@ -67,11 +68,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (salaries is null)
|
||||
@if (_salaries is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (salaries.Count == 0)
|
||||
else if (_salaries.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No income sources yet. Add one above to start tracking your salary.</div>
|
||||
}
|
||||
@@ -94,7 +95,7 @@ else
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var s in salaries)
|
||||
@foreach (var s in _salaries)
|
||||
{
|
||||
<tr>
|
||||
<td class="fw-semibold">💰 @s.Name</td>
|
||||
@@ -108,7 +109,7 @@ else
|
||||
<span class="badge bg-info text-nowrap">🔁 @s.GetPaydaySummary()</span>
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
@if (nextPaydates.TryGetValue(s.Id, out var next) && next.HasValue)
|
||||
@if (_nextPaydates.TryGetValue(s.Id, out var next) && next.HasValue)
|
||||
{
|
||||
<span>@next.Value.ToString("MMM dd, yyyy")</span>
|
||||
}
|
||||
@@ -126,12 +127,12 @@ else
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small class="text-muted">@salaries.Count income source@(salaries.Count == 1 ? "" : "s") · Transactions are generated automatically and appear on the <a href="/transactions">Transactions</a> page.</small>
|
||||
<small class="text-muted">@_salaries.Count income source@(_salaries.Count == 1 ? "" : "s") · Transactions are generated automatically and appear on the <a href="/transactions">Transactions</a> page.</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showDeleteModal && pendingDeleteSalary is not null)
|
||||
@if (_showDeleteModal && _pendingDeleteSalary is not null)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||
<div class="modal-dialog">
|
||||
@@ -141,7 +142,7 @@ else
|
||||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>@pendingDeleteSalary.Name</strong>?</p>
|
||||
<p>Are you sure you want to delete <strong>@_pendingDeleteSalary.Name</strong>?</p>
|
||||
<p>All future unpaid salary transactions will be removed. Past transactions remain in your history.</p>
|
||||
<p class="text-danger mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
@@ -155,110 +156,7 @@ else
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<BudgetApp.Data.Salary>? salaries;
|
||||
private List<Account>? accountsList;
|
||||
private Dictionary<int, DateTime?> nextPaydates = [];
|
||||
private string? userId;
|
||||
|
||||
private SalaryForm formModel = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
var windowEnd = DateTime.Today.AddDays(90);
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd);
|
||||
|
||||
salaries = await SalaryService.GetSalariesAsync(userId);
|
||||
accountsList = await AccountService.GetAccountsAsync(userId);
|
||||
|
||||
nextPaydates.Clear();
|
||||
foreach (var sal in salaries)
|
||||
{
|
||||
nextPaydates[sal.Id] = await SalaryService.GetNextPaydateAsync(sal.Id);
|
||||
}
|
||||
|
||||
if (accountsList.Count > 0 && formModel.AccountId == 0)
|
||||
{
|
||||
formModel.AccountId = accountsList[0].Id;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddSalary()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
var entry = new BudgetApp.Data.Salary
|
||||
{
|
||||
Name = formModel.Name,
|
||||
Amount = formModel.Amount,
|
||||
AccountId = formModel.AccountId,
|
||||
RecurrenceType = formModel.RecurrenceType,
|
||||
StartDate = formModel.StartDate,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
await SalaryService.CreateSalaryAsync(entry, DateTime.Today.AddDays(90));
|
||||
|
||||
formModel = new SalaryForm
|
||||
{
|
||||
AccountId = accountsList?.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
// Delete state
|
||||
private bool showDeleteModal;
|
||||
private BudgetApp.Data.Salary? pendingDeleteSalary;
|
||||
|
||||
private void ConfirmDelete(BudgetApp.Data.Salary s)
|
||||
{
|
||||
pendingDeleteSalary = s;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
showDeleteModal = false;
|
||||
pendingDeleteSalary = null;
|
||||
}
|
||||
|
||||
private async Task ExecuteDelete()
|
||||
{
|
||||
if (userId is null || pendingDeleteSalary is null) return;
|
||||
showDeleteModal = false;
|
||||
await SalaryService.DeleteSalaryAsync(pendingDeleteSalary.Id, userId);
|
||||
pendingDeleteSalary = null;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private sealed class SalaryForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Name is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than zero")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.Monthly;
|
||||
|
||||
public DateTime StartDate { get; set; } = DateTime.Today;
|
||||
|
||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(
|
||||
System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
|
||||
using BudgetApp.Data.Entities;
|
||||
|
||||
namespace BudgetApp.Components.Pages;
|
||||
|
||||
public partial class Salary
|
||||
{
|
||||
private List<Data.Entities.Salary>? _salaries;
|
||||
private List<Account>? _accountsList;
|
||||
private readonly Dictionary<int, DateTime?> _nextPaydates = [];
|
||||
private string? _userId;
|
||||
private SalaryForm _formModel = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
var windowEnd = DateTime.Today.AddDays(90);
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(_userId, windowEnd);
|
||||
|
||||
_salaries = await SalaryService.GetSalariesAsync(_userId);
|
||||
_accountsList = await AccountService.GetAccountsAsync(_userId);
|
||||
|
||||
_nextPaydates.Clear();
|
||||
foreach (var sal in _salaries)
|
||||
{
|
||||
_nextPaydates[sal.Id] = await SalaryService.GetNextPaydateAsync(sal.Id);
|
||||
}
|
||||
|
||||
if (_accountsList.Count > 0 && _formModel.AccountId == 0)
|
||||
{
|
||||
_formModel.AccountId = _accountsList[0].Id;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddSalary()
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
var entry = new Data.Entities.Salary
|
||||
{
|
||||
Name = _formModel.Name,
|
||||
Amount = _formModel.Amount,
|
||||
AccountId = _formModel.AccountId,
|
||||
RecurrenceType = _formModel.RecurrenceType,
|
||||
StartDate = _formModel.StartDate,
|
||||
UserId = _userId
|
||||
};
|
||||
|
||||
await SalaryService.CreateSalaryAsync(entry, DateTime.Today.AddDays(90));
|
||||
|
||||
_formModel = new SalaryForm
|
||||
{
|
||||
AccountId = _accountsList?.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private bool _showDeleteModal;
|
||||
private Data.Entities.Salary? _pendingDeleteSalary;
|
||||
|
||||
private void ConfirmDelete(Data.Entities.Salary s)
|
||||
{
|
||||
_pendingDeleteSalary = s;
|
||||
_showDeleteModal = true;
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
_showDeleteModal = false;
|
||||
_pendingDeleteSalary = null;
|
||||
}
|
||||
|
||||
private async Task ExecuteDelete()
|
||||
{
|
||||
if (_userId is null || _pendingDeleteSalary is null) return;
|
||||
_showDeleteModal = false;
|
||||
await SalaryService.DeleteSalaryAsync(_pendingDeleteSalary.Id, _userId);
|
||||
_pendingDeleteSalary = null;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private sealed class SalaryForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Name is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(0.01, double.MaxValue,
|
||||
ErrorMessage = "Amount must be greater than zero")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.Monthly;
|
||||
|
||||
public DateTime StartDate { get; set; } = DateTime.Today;
|
||||
|
||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(
|
||||
System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
@page "/settings"
|
||||
@attribute [Authorize]
|
||||
@using BudgetApp.Data.Entities
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@@ -24,30 +24,30 @@
|
||||
<h5 class="mb-0">Change Password</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (passwordSuccess)
|
||||
@if (_passwordSuccess)
|
||||
{
|
||||
<div class="alert alert-success">Password changed successfully.</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(passwordError))
|
||||
@if (!string.IsNullOrEmpty(_passwordError))
|
||||
{
|
||||
<div class="alert alert-danger">@passwordError</div>
|
||||
<div class="alert alert-danger">@_passwordError</div>
|
||||
}
|
||||
|
||||
<EditForm Model="passwordForm" OnValidSubmit="ChangePassword" FormName="changePassword">
|
||||
<EditForm Model="_passwordForm" OnValidSubmit="ChangePassword" FormName="changePassword">
|
||||
<ToastValidator />
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Current Password</label>
|
||||
<InputText @bind-Value="passwordForm.CurrentPassword" type="password" class="form-control" />
|
||||
<InputText @bind-Value="_passwordForm.CurrentPassword" type="password" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">New Password</label>
|
||||
<InputText @bind-Value="passwordForm.NewPassword" type="password" class="form-control" />
|
||||
<InputText @bind-Value="_passwordForm.NewPassword" type="password" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<InputText @bind-Value="passwordForm.ConfirmNewPassword" type="password" class="form-control" />
|
||||
<InputText @bind-Value="_passwordForm.ConfirmNewPassword" type="password" class="form-control" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Update Password</button>
|
||||
@@ -67,19 +67,19 @@
|
||||
This will permanently delete all your accounts, transactions and categories, giving you a clean slate. Your user account and password will be kept.
|
||||
</p>
|
||||
|
||||
@if (resetSuccess)
|
||||
@if (_resetSuccess)
|
||||
{
|
||||
<div class="alert alert-success">All data has been reset successfully.</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(resetError))
|
||||
@if (!string.IsNullOrEmpty(_resetError))
|
||||
{
|
||||
<div class="alert alert-danger">@resetError</div>
|
||||
<div class="alert alert-danger">@_resetError</div>
|
||||
}
|
||||
|
||||
@if (!confirmingReset)
|
||||
@if (!_confirmingReset)
|
||||
{
|
||||
<button class="btn btn-outline-warning" @onclick="() => confirmingReset = true">
|
||||
<button class="btn btn-outline-warning" @onclick="() => _confirmingReset = true">
|
||||
Reset All Data
|
||||
</button>
|
||||
}
|
||||
@@ -89,8 +89,8 @@
|
||||
<strong>Are you sure?</strong> This cannot be undone. Type <code>I UNDERSTAND</code> and enter your password to confirm.
|
||||
</div>
|
||||
<div class="mb-3" style="max-width: 400px;">
|
||||
<input type="text" class="form-control mb-2" placeholder="Type I UNDERSTAND to confirm" @bind="resetConfirmation" />
|
||||
<input type="password" class="form-control mb-2" placeholder="Enter your password" @bind="resetPassword" />
|
||||
<input type="text" class="form-control mb-2" placeholder="Type I UNDERSTAND to confirm" @bind="_resetConfirmation" />
|
||||
<input type="password" class="form-control mb-2" placeholder="Enter your password" @bind="_resetPassword" />
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-warning" @onclick="ResetData">Confirm Reset</button>
|
||||
<button class="btn btn-secondary" @onclick="CancelReset">Cancel</button>
|
||||
@@ -110,14 +110,14 @@
|
||||
This will permanently delete your account and all associated data (accounts, transactions, and categories). This action cannot be undone.
|
||||
</p>
|
||||
|
||||
@if (!string.IsNullOrEmpty(deleteError))
|
||||
@if (!string.IsNullOrEmpty(_deleteError))
|
||||
{
|
||||
<div class="alert alert-danger">@deleteError</div>
|
||||
<div class="alert alert-danger">@_deleteError</div>
|
||||
}
|
||||
|
||||
@if (!confirmingDelete)
|
||||
@if (!_confirmingDelete)
|
||||
{
|
||||
<button class="btn btn-outline-danger" @onclick="() => confirmingDelete = true">
|
||||
<button class="btn btn-outline-danger" @onclick="() => _confirmingDelete = true">
|
||||
Delete My Account
|
||||
</button>
|
||||
}
|
||||
@@ -127,166 +127,10 @@
|
||||
<strong>Are you sure?</strong> Type your password to confirm.
|
||||
</div>
|
||||
<div class="input-group mb-3" style="max-width: 400px;">
|
||||
<input type="password" class="form-control" placeholder="Enter your password" @bind="deletePassword" />
|
||||
<input type="password" class="form-control" placeholder="Enter your password" @bind="_deletePassword" />
|
||||
<button class="btn btn-danger" @onclick="DeleteAccount">Confirm Delete</button>
|
||||
<button class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private static readonly string appVersion =
|
||||
typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
|
||||
private ChangePasswordForm passwordForm = new();
|
||||
private bool passwordSuccess;
|
||||
private string? passwordError;
|
||||
|
||||
private bool confirmingDelete;
|
||||
private string deletePassword = string.Empty;
|
||||
private string? deleteError;
|
||||
|
||||
private bool confirmingReset;
|
||||
private string resetConfirmation = string.Empty;
|
||||
private string resetPassword = string.Empty;
|
||||
private string? resetError;
|
||||
private bool resetSuccess;
|
||||
|
||||
private async Task ChangePassword()
|
||||
{
|
||||
passwordSuccess = false;
|
||||
passwordError = null;
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim is null) return;
|
||||
|
||||
var user = await UserManager.FindByIdAsync(userIdClaim);
|
||||
if (user is null) return;
|
||||
|
||||
var result = await UserManager.ChangePasswordAsync(user, passwordForm.CurrentPassword, passwordForm.NewPassword);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
passwordSuccess = true;
|
||||
passwordForm = new ChangePasswordForm();
|
||||
}
|
||||
else
|
||||
{
|
||||
passwordError = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAccount()
|
||||
{
|
||||
deleteError = null;
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim is null) return;
|
||||
|
||||
var user = await UserManager.FindByIdAsync(userIdClaim);
|
||||
if (user is null) return;
|
||||
|
||||
var passwordValid = await UserManager.CheckPasswordAsync(user, deletePassword);
|
||||
if (!passwordValid)
|
||||
{
|
||||
deleteError = "Incorrect password.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete all user data explicitly in correct dependency order
|
||||
await DbContext.Transactions
|
||||
.Where(t => t.Account.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await DbContext.Accounts
|
||||
.Where(a => a.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await DbContext.Categories
|
||||
.Where(c => c.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// Delete identity user
|
||||
await UserManager.DeleteAsync(user);
|
||||
await SignInManager.SignOutAsync();
|
||||
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
confirmingDelete = false;
|
||||
deletePassword = string.Empty;
|
||||
deleteError = null;
|
||||
}
|
||||
|
||||
private async Task ResetData()
|
||||
{
|
||||
resetError = null;
|
||||
resetSuccess = false;
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim is null) return;
|
||||
|
||||
var user = await UserManager.FindByIdAsync(userIdClaim);
|
||||
if (user is null) return;
|
||||
|
||||
if (!string.Equals(resetConfirmation?.Trim(), "I UNDERSTAND", StringComparison.Ordinal))
|
||||
{
|
||||
resetError = "Please type 'I UNDERSTAND' exactly to confirm.";
|
||||
return;
|
||||
}
|
||||
|
||||
var passwordValid = await UserManager.CheckPasswordAsync(user, resetPassword);
|
||||
if (!passwordValid)
|
||||
{
|
||||
resetError = "Incorrect password.";
|
||||
return;
|
||||
}
|
||||
|
||||
await DbContext.Transactions
|
||||
.Where(t => t.Account.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await DbContext.Accounts
|
||||
.Where(a => a.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await DbContext.Categories
|
||||
.Where(c => c.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
confirmingReset = false;
|
||||
resetConfirmation = string.Empty;
|
||||
resetPassword = string.Empty;
|
||||
resetSuccess = true;
|
||||
|
||||
AppState.NotifyAccountsChanged();
|
||||
}
|
||||
|
||||
private void CancelReset()
|
||||
{
|
||||
confirmingReset = false;
|
||||
resetConfirmation = string.Empty;
|
||||
resetPassword = string.Empty;
|
||||
resetError = null;
|
||||
}
|
||||
|
||||
private sealed class ChangePasswordForm
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Current password is required")]
|
||||
public string CurrentPassword { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "New password is required")]
|
||||
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your new password")]
|
||||
[System.ComponentModel.DataAnnotations.Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")]
|
||||
public string ConfirmNewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,159 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BudgetApp.Components.Pages;
|
||||
|
||||
public partial class Settings
|
||||
{
|
||||
private static readonly string AppVersion =
|
||||
typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
|
||||
private ChangePasswordForm _passwordForm = new();
|
||||
private bool _passwordSuccess;
|
||||
private string? _passwordError;
|
||||
private bool _confirmingDelete;
|
||||
private string _deletePassword = string.Empty;
|
||||
private string? _deleteError;
|
||||
private bool _confirmingReset;
|
||||
private string _resetConfirmation = string.Empty;
|
||||
private string _resetPassword = string.Empty;
|
||||
private string? _resetError;
|
||||
private bool _resetSuccess;
|
||||
|
||||
private async Task ChangePassword()
|
||||
{
|
||||
_passwordSuccess = false;
|
||||
_passwordError = null;
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim is null) return;
|
||||
|
||||
var user = await UserManager.FindByIdAsync(userIdClaim);
|
||||
if (user is null) return;
|
||||
|
||||
var result =
|
||||
await UserManager.ChangePasswordAsync(user, _passwordForm.CurrentPassword, _passwordForm.NewPassword);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
_passwordSuccess = true;
|
||||
_passwordForm = new ChangePasswordForm();
|
||||
}
|
||||
else
|
||||
{
|
||||
_passwordError = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAccount()
|
||||
{
|
||||
_deleteError = null;
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim is null) return;
|
||||
|
||||
var user = await UserManager.FindByIdAsync(userIdClaim);
|
||||
if (user is null) return;
|
||||
|
||||
var passwordValid = await UserManager.CheckPasswordAsync(user, _deletePassword);
|
||||
if (!passwordValid)
|
||||
{
|
||||
_deleteError = "Incorrect password.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete all user data explicitly in correct dependency order
|
||||
await DbContext.Transactions
|
||||
.Where(t => t.Account.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await DbContext.Accounts
|
||||
.Where(a => a.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await DbContext.Categories
|
||||
.Where(c => c.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// Delete identity user
|
||||
await UserManager.DeleteAsync(user);
|
||||
await SignInManager.SignOutAsync();
|
||||
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
_confirmingDelete = false;
|
||||
_deletePassword = string.Empty;
|
||||
_deleteError = null;
|
||||
}
|
||||
|
||||
private async Task ResetData()
|
||||
{
|
||||
_resetError = null;
|
||||
_resetSuccess = false;
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim is null) return;
|
||||
|
||||
var user = await UserManager.FindByIdAsync(userIdClaim);
|
||||
if (user is null) return;
|
||||
|
||||
if (!string.Equals(_resetConfirmation?.Trim(), "I UNDERSTAND", StringComparison.Ordinal))
|
||||
{
|
||||
_resetError = "Please type 'I UNDERSTAND' exactly to confirm.";
|
||||
return;
|
||||
}
|
||||
|
||||
var passwordValid = await UserManager.CheckPasswordAsync(user, _resetPassword);
|
||||
if (!passwordValid)
|
||||
{
|
||||
_resetError = "Incorrect password.";
|
||||
return;
|
||||
}
|
||||
|
||||
await DbContext.Transactions
|
||||
.Where(t => t.Account.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await DbContext.Accounts
|
||||
.Where(a => a.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await DbContext.Categories
|
||||
.Where(c => c.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
_confirmingReset = false;
|
||||
_resetConfirmation = string.Empty;
|
||||
_resetPassword = string.Empty;
|
||||
_resetSuccess = true;
|
||||
|
||||
AppState.NotifyAccountsChanged();
|
||||
}
|
||||
|
||||
private void CancelReset()
|
||||
{
|
||||
_confirmingReset = false;
|
||||
_resetConfirmation = string.Empty;
|
||||
_resetPassword = string.Empty;
|
||||
_resetError = null;
|
||||
}
|
||||
|
||||
private sealed class ChangePasswordForm
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Current password is required")]
|
||||
public string CurrentPassword { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "New password is required")]
|
||||
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your new password")]
|
||||
[System.ComponentModel.DataAnnotations.Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")]
|
||||
public string ConfirmNewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
@page "/transactions"
|
||||
@using BudgetApp.Data.Entities
|
||||
@attribute [Authorize]
|
||||
@inject TransactionService TransactionService
|
||||
@inject AccountService AccountService
|
||||
@inject CategoryService CategoryService
|
||||
@inject SalaryService SalaryService
|
||||
@inject ITransactionRepository TransactionService
|
||||
@inject IAccountRepository AccountService
|
||||
@inject ICategoryRepository CategoryService
|
||||
@inject ISalaryRepository SalaryService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@@ -17,7 +18,7 @@
|
||||
<h5 class="mb-0">Add Transaction</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (accountsList is not null && accountsList.Count == 0)
|
||||
@if (_accountsList is not null && _accountsList.Count == 0)
|
||||
{
|
||||
<div class="alert alert-warning mb-0">
|
||||
You need to <a href="/accounts">create an account</a> before adding transactions.
|
||||
@@ -25,34 +26,34 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="formModel" OnValidSubmit="AddTransaction" FormName="addTransaction">
|
||||
<EditForm Model="_formModel" OnValidSubmit="AddTransaction" FormName="addTransaction">
|
||||
<ToastValidator />
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Description</label>
|
||||
<InputText @bind-Value="formModel.Description" class="form-control" placeholder="e.g. Food shopping" title="A short description of the transaction" />
|
||||
<InputText @bind-Value="_formModel.Description" class="form-control" placeholder="e.g. Food shopping" title="A short description of the transaction" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Amount</label>
|
||||
<InputNumber @bind-Value="formModel.Amount" class="form-control numeric-input" title="Enter a positive amount — the type determines income or expense" />
|
||||
<InputNumber @bind-Value="_formModel.Amount" class="form-control numeric-input" title="Enter a positive amount — the type determines income or expense" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Date</label>
|
||||
<InputDate @bind-Value="formModel.Date" class="form-control" />
|
||||
<InputDate @bind-Value="_formModel.Date" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Type</label>
|
||||
<InputSelect @bind-Value="formModel.Type" class="form-select">
|
||||
<InputSelect @bind-Value="_formModel.Type" class="form-select">
|
||||
<option value="@TransactionType.Expense">Expense</option>
|
||||
<option value="@TransactionType.Income">Income</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Account</label>
|
||||
<InputSelect @bind-Value="formModel.AccountId" class="form-select">
|
||||
@if (accountsList is not null)
|
||||
<InputSelect @bind-Value="_formModel.AccountId" class="form-select">
|
||||
@if (_accountsList is not null)
|
||||
{
|
||||
@foreach (var account in accountsList)
|
||||
@foreach (var account in _accountsList)
|
||||
{
|
||||
<option value="@account.Id">@account.Name</option>
|
||||
}
|
||||
@@ -61,11 +62,11 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Category</label>
|
||||
<InputSelect @bind-Value="formModel.CategoryId" class="form-select">
|
||||
<InputSelect @bind-Value="_formModel.CategoryId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
@if (categoriesList is not null)
|
||||
@if (_categoriesList is not null)
|
||||
{
|
||||
@foreach (var category in categoriesList)
|
||||
@foreach (var category in _categoriesList)
|
||||
{
|
||||
<option value="@category.Id">@(string.IsNullOrEmpty(category.Icon) ? "" : category.Icon + " ")@category.Name</option>
|
||||
}
|
||||
@@ -74,7 +75,7 @@
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Recurrence</label>
|
||||
<InputSelect @bind-Value="formModel.RecurrenceType" class="form-select" title="Set a recurring schedule — future occurrences are generated automatically">
|
||||
<InputSelect @bind-Value="_formModel.RecurrenceType" class="form-select" title="Set a recurring schedule — future occurrences are generated automatically">
|
||||
<option value="@RecurrenceType.None">None</option>
|
||||
<option value="@RecurrenceType.Daily">Daily</option>
|
||||
<option value="@RecurrenceType.Weekly">Weekly</option>
|
||||
@@ -82,11 +83,11 @@
|
||||
<option value="@RecurrenceType.Yearly">Yearly</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
@if (formModel.RecurrenceType != RecurrenceType.None)
|
||||
@if (_formModel.RecurrenceType != RecurrenceType.None)
|
||||
{
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">End Date <small class="text-muted">(max 1yr)</small></label>
|
||||
<InputDate @bind-Value="formModel.RecurrenceEndDate" class="form-control" max="@formModel.Date.AddYears(1).ToString("yyyy-MM-dd")" />
|
||||
<InputDate @bind-Value="_formModel.RecurrenceEndDate" class="form-control" max="@_formModel.Date.AddYears(1).ToString("yyyy-MM-dd")" />
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
@@ -98,11 +99,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (transactions is null)
|
||||
@if (_transactions is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (transactions.Count == 0)
|
||||
else if (_transactions.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No transactions yet.</div>
|
||||
}
|
||||
@@ -113,16 +114,16 @@ else
|
||||
<h5 class="mb-0">Filters</h5>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn @(windowPreset == "7d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 7 days" @onclick="() => SetWindow(7)">7d</button>
|
||||
<button class="btn @(windowPreset == "30d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 30 days" @onclick="() => SetWindow(30)">30d</button>
|
||||
<button class="btn @(windowPreset == "90d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 90 days" @onclick="() => SetWindow(90)">90d</button>
|
||||
<button class="btn @(windowPreset == "1y" ? "btn-primary" : "btn-outline-secondary")" title="Show next year" @onclick="() => SetWindow(365)">1y</button>
|
||||
<button class="btn @(_windowPreset == "7d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 7 days" @onclick="() => SetWindow(7)">7d</button>
|
||||
<button class="btn @(_windowPreset == "30d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 30 days" @onclick="() => SetWindow(30)">30d</button>
|
||||
<button class="btn @(_windowPreset == "90d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 90 days" @onclick="() => SetWindow(90)">90d</button>
|
||||
<button class="btn @(_windowPreset == "1y" ? "btn-primary" : "btn-outline-secondary")" title="Show next year" @onclick="() => SetWindow(365)">1y</button>
|
||||
</div>
|
||||
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
||||
title="Custom start date" @bind="windowStart" @bind:after="OnWindowDateChanged" />
|
||||
title="Custom start date" @bind="_windowStart" @bind:after="OnWindowDateChanged" />
|
||||
<span class="text-muted">to</span>
|
||||
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
||||
title="Custom end date" @bind="windowEnd" @bind:after="OnWindowDateChanged" />
|
||||
title="Custom end date" @bind="_windowEnd" @bind:after="OnWindowDateChanged" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
@@ -130,25 +131,25 @@ else
|
||||
<div class="col-12 col-sm-auto">
|
||||
<select class="form-select form-select-sm" title="Filter transactions by account" @onchange="OnAccountFilterChanged">
|
||||
<option value="">All Accounts</option>
|
||||
@if (accountsList is not null)
|
||||
@if (_accountsList is not null)
|
||||
{
|
||||
@foreach (var a in accountsList)
|
||||
@foreach (var a in _accountsList)
|
||||
{
|
||||
<option value="@a.Id" selected="@(filterAccountId == a.Id)">@a.Name</option>
|
||||
<option value="@a.Id" selected="@(_filterAccountId == a.Id)">@a.Name</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-sm">
|
||||
<input type="search" class="form-control form-control-sm"
|
||||
title="Search by description, account, category or amount" placeholder="Search..." @bind="searchQuery" @bind:event="oninput" @bind:after="OnSearchChanged" />
|
||||
title="Search by description, account, category or amount" placeholder="Search..." @bind="_searchQuery" @bind:event="oninput" @bind:after="OnSearchChanged" />
|
||||
</div>
|
||||
<div class="col-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="width:auto;" @onchange="OnPageSizeChanged">
|
||||
@foreach (var size in pageSizeOptions)
|
||||
@foreach (var size in PageSizeOptions)
|
||||
{
|
||||
<option value="@size" selected="@(pageSize == size)">@size</option>
|
||||
<option value="@size" selected="@(_pageSize == size)">@size</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@@ -174,13 +175,13 @@ else
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var t in pagedTransactions)
|
||||
@foreach (var t in PagedTransactions)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@if (t.SalaryId is null)
|
||||
{
|
||||
<input type="checkbox" class="form-check-input" checked="@selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" />
|
||||
<input type="checkbox" class="form-check-input" checked="@_selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -255,55 +256,55 @@ else
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (totalPages > 1)
|
||||
@if (TotalPages > 1)
|
||||
{
|
||||
<div class="card-footer d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(currentPage <= 1 ? true : (bool?)null)" @onclick="() => currentPage--">← Prev</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(_currentPage <= 1 ? true : (bool?)null)" @onclick="() => _currentPage--">← Prev</button>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
@if (selectedIds.Count > 0)
|
||||
@if (_selectedIds.Count > 0)
|
||||
{
|
||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
|
||||
Delete @selectedIds.Count selected
|
||||
Delete @_selectedIds.Count selected
|
||||
</button>
|
||||
}
|
||||
<small class="text-muted">Page @currentPage of @totalPages (@filteredTransactions.Count transactions)</small>
|
||||
<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>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(_currentPage >= TotalPages ? true : (bool?)null)" @onclick="() => _currentPage++">Next →</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card-footer d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<small class="text-muted">@filteredTransactions.Count transactions</small>
|
||||
@if (selectedIds.Count > 0)
|
||||
<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
|
||||
Delete @_selectedIds.Count selected
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showDeleteModal)
|
||||
@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">@deleteModalTitle</h5>
|
||||
<h5 class="modal-title">@_deleteModalTitle</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (bulkDeleteCount > 0)
|
||||
@if (_bulkDeleteCount > 0)
|
||||
{
|
||||
<p>Are you sure you want to delete <strong>@bulkDeleteCount</strong> selected transactions?</p>
|
||||
<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)
|
||||
else if (_pendingDeleteTransaction is not null)
|
||||
{
|
||||
<p>Are you sure you want to delete <strong>@pendingDeleteTransaction.Description</strong>?</p>
|
||||
@if (pendingDeleteIsRecurring)
|
||||
<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>
|
||||
}
|
||||
@@ -315,11 +316,11 @@ else
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||
@if (bulkDeleteCount > 0)
|
||||
@if (_bulkDeleteCount > 0)
|
||||
{
|
||||
<button type="button" class="btn btn-danger" @onclick="ExecuteBulkDelete">Delete All Selected</button>
|
||||
}
|
||||
else if (pendingDeleteIsRecurring)
|
||||
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>
|
||||
@@ -334,7 +335,7 @@ else
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showEditModal)
|
||||
@if (_showEditModal)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
@@ -343,34 +344,34 @@ else
|
||||
<h5 class="modal-title">Edit Transaction</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseEditModal"></button>
|
||||
</div>
|
||||
<EditForm Model="editModel" OnValidSubmit="SaveEdit" FormName="editTransaction">
|
||||
<EditForm Model="_editModel" OnValidSubmit="SaveEdit" FormName="editTransaction">
|
||||
<ToastValidator />
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<InputText @bind-Value="editModel.Description" class="form-control" />
|
||||
<InputText @bind-Value="_editModel.Description" class="form-control" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Amount</label>
|
||||
<InputNumber @bind-Value="editModel.Amount" class="form-control numeric-input" />
|
||||
<InputNumber @bind-Value="_editModel.Amount" class="form-control numeric-input" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Date</label>
|
||||
<InputDate @bind-Value="editModel.Date" class="form-control" />
|
||||
<InputDate @bind-Value="_editModel.Date" class="form-control" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Type</label>
|
||||
<InputSelect @bind-Value="editModel.Type" class="form-select">
|
||||
<InputSelect @bind-Value="_editModel.Type" class="form-select">
|
||||
<option value="@TransactionType.Expense">Expense</option>
|
||||
<option value="@TransactionType.Income">Income</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Account</label>
|
||||
<InputSelect @bind-Value="editModel.AccountId" class="form-select">
|
||||
@if (accountsList is not null)
|
||||
<InputSelect @bind-Value="_editModel.AccountId" class="form-select">
|
||||
@if (_accountsList is not null)
|
||||
{
|
||||
@foreach (var account in accountsList)
|
||||
@foreach (var account in _accountsList)
|
||||
{
|
||||
<option value="@account.Id">@account.Name</option>
|
||||
}
|
||||
@@ -379,18 +380,18 @@ else
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Category</label>
|
||||
<InputSelect @bind-Value="editModel.CategoryId" class="form-select">
|
||||
<InputSelect @bind-Value="_editModel.CategoryId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
@if (categoriesList is not null)
|
||||
@if (_categoriesList is not null)
|
||||
{
|
||||
@foreach (var category in categoriesList)
|
||||
@foreach (var category in _categoriesList)
|
||||
{
|
||||
<option value="@category.Id">@(string.IsNullOrEmpty(category.Icon) ? "" : category.Icon + " ")@category.Name</option>
|
||||
}
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
@if (editIsPartOfSeries)
|
||||
@if (_editIsPartOfSeries)
|
||||
{
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle"></i> Changes will apply to this and all future occurrences in the series.
|
||||
@@ -409,350 +410,17 @@ else
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Transaction>? transactions;
|
||||
private List<Account>? accountsList;
|
||||
private List<Category>? categoriesList;
|
||||
private TransactionForm formModel = new();
|
||||
private string? userId;
|
||||
private int? filterAccountId;
|
||||
private string searchQuery = string.Empty;
|
||||
|
||||
// Time window state
|
||||
private DateTime windowStart = DateTime.Today;
|
||||
private DateTime windowEnd = DateTime.Today.AddDays(30);
|
||||
private string? windowPreset = "30d";
|
||||
|
||||
// Pagination state
|
||||
private int currentPage = 1;
|
||||
private int pageSize = 25;
|
||||
private static readonly int[] pageSizeOptions = [10, 25, 50, 100];
|
||||
private int totalPages => (int)Math.Ceiling(filteredTransactions.Count / (double)pageSize);
|
||||
private List<Transaction> pagedTransactions => filteredTransactions
|
||||
.Skip((currentPage - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
// Sort state
|
||||
private string sortColumn = "date";
|
||||
private bool sortAscending = true;
|
||||
|
||||
private List<Transaction> filteredTransactions
|
||||
{
|
||||
get
|
||||
{
|
||||
var list = (transactions ?? []).Where(t => t.Date.Date >= windowStart.Date && t.Date.Date <= windowEnd.Date);
|
||||
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
|
||||
? list.OrderBy(t => t.Description, StringComparer.OrdinalIgnoreCase)
|
||||
: list.OrderByDescending(t => t.Description, StringComparer.OrdinalIgnoreCase),
|
||||
"amount" => sortAscending
|
||||
? list.OrderBy(t => t.Amount)
|
||||
: list.OrderByDescending(t => t.Amount),
|
||||
_ => sortAscending
|
||||
? list.OrderBy(t => t.Date)
|
||||
: list.OrderByDescending(t => t.Date),
|
||||
};
|
||||
|
||||
return list.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAccountFilterChanged(ChangeEventArgs e)
|
||||
{
|
||||
filterAccountId = int.TryParse(e.Value?.ToString(), out var id) ? id : null;
|
||||
currentPage = 1;
|
||||
}
|
||||
|
||||
private void OnSearchChanged() => currentPage = 1;
|
||||
|
||||
private async Task SetWindow(int days)
|
||||
{
|
||||
windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null };
|
||||
windowStart = DateTime.Today;
|
||||
windowEnd = DateTime.Today.AddDays(days);
|
||||
currentPage = 1;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task OnWindowDateChanged()
|
||||
{
|
||||
windowPreset = null;
|
||||
currentPage = 1;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private void OnPageSizeChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (int.TryParse(e.Value?.ToString(), out var size))
|
||||
{
|
||||
pageSize = size;
|
||||
currentPage = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetSort(string column)
|
||||
{
|
||||
if (sortColumn == column)
|
||||
sortAscending = !sortAscending;
|
||||
else
|
||||
{
|
||||
sortColumn = column;
|
||||
sortAscending = true;
|
||||
}
|
||||
currentPage = 1;
|
||||
}
|
||||
|
||||
private MarkupString SortIndicator(string column)
|
||||
{
|
||||
if (sortColumn != column)
|
||||
return new MarkupString("<span class=\"text-muted opacity-50\">⇅</span>");
|
||||
return new MarkupString(sortAscending ? "▲" : "▼");
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd);
|
||||
transactions = await TransactionService.GetTransactionsAsync(userId);
|
||||
accountsList = await AccountService.GetAccountsAsync(userId);
|
||||
categoriesList = await CategoryService.GetCategoriesAsync(userId);
|
||||
|
||||
if (accountsList.Count > 0 && formModel.AccountId == 0)
|
||||
{
|
||||
formModel.AccountId = accountsList[0].Id;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddTransaction()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
var transaction = new Transaction
|
||||
{
|
||||
Description = formModel.Description,
|
||||
Amount = formModel.Amount,
|
||||
Date = formModel.Date,
|
||||
Type = formModel.Type,
|
||||
AccountId = formModel.AccountId,
|
||||
CategoryId = formModel.CategoryId,
|
||||
RecurrenceType = formModel.RecurrenceType,
|
||||
RecurrenceEndDate = formModel.RecurrenceEndDate
|
||||
};
|
||||
await TransactionService.CreateTransactionAsync(transaction);
|
||||
formModel = new TransactionForm
|
||||
{
|
||||
AccountId = accountsList?.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
// Selection state
|
||||
private HashSet<int> selectedIds = [];
|
||||
|
||||
private void ToggleSelect(int id)
|
||||
{
|
||||
var t = transactions?.FirstOrDefault(x => x.Id == id);
|
||||
if (t?.SalaryId is not null) return;
|
||||
if (!selectedIds.Remove(id))
|
||||
selectedIds.Add(id);
|
||||
}
|
||||
|
||||
private bool IsAllPageSelected()
|
||||
{
|
||||
var selectable = pagedTransactions.Where(t => t.SalaryId is null).ToList();
|
||||
return selectable.Count > 0 && selectable.All(t => selectedIds.Contains(t.Id));
|
||||
}
|
||||
|
||||
private void ToggleSelectAll(ChangeEventArgs e)
|
||||
{
|
||||
var selectable = pagedTransactions.Where(t => t.SalaryId is null).ToList();
|
||||
if (e.Value is true)
|
||||
{
|
||||
foreach (var t in selectable)
|
||||
selectedIds.Add(t.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var t in selectable)
|
||||
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;
|
||||
private int? editingTransactionId;
|
||||
private bool editIsPartOfSeries;
|
||||
private TransactionForm editModel = new();
|
||||
|
||||
private void OpenEditModal(Transaction t)
|
||||
{
|
||||
editingTransactionId = t.Id;
|
||||
editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||
editModel = new TransactionForm
|
||||
{
|
||||
Description = t.Description,
|
||||
Amount = t.Amount,
|
||||
Date = t.Date,
|
||||
Type = t.Type,
|
||||
AccountId = t.AccountId,
|
||||
CategoryId = t.CategoryId,
|
||||
RecurrenceType = t.RecurrenceType,
|
||||
RecurrenceEndDate = t.RecurrenceEndDate
|
||||
};
|
||||
showEditModal = true;
|
||||
}
|
||||
|
||||
private void CloseEditModal() => showEditModal = false;
|
||||
|
||||
private async Task SaveEdit()
|
||||
{
|
||||
if (userId is null || editingTransactionId is null) return;
|
||||
|
||||
await TransactionService.UpdateTransactionAsync(
|
||||
editingTransactionId.Value, userId,
|
||||
editModel.Description, editModel.Amount, editModel.Date,
|
||||
editModel.Type, editModel.AccountId, editModel.CategoryId,
|
||||
editModel.RecurrenceType, editModel.RecurrenceEndDate);
|
||||
|
||||
showEditModal = false;
|
||||
editingTransactionId = null;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private void ConfirmDelete(Transaction t)
|
||||
{
|
||||
pendingDeleteTransaction = t;
|
||||
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()
|
||||
{
|
||||
if (userId is null || pendingDeleteTransaction is null) return;
|
||||
showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionAsync(pendingDeleteTransaction.Id, userId);
|
||||
selectedIds.Remove(pendingDeleteTransaction.Id);
|
||||
pendingDeleteTransaction = null;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task DeleteSeries()
|
||||
{
|
||||
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.ToList())
|
||||
{
|
||||
var t = transactions?.FirstOrDefault(x => x.Id == id);
|
||||
if (t?.SalaryId is not null) continue;
|
||||
await TransactionService.DeleteTransactionAsync(id, userId);
|
||||
}
|
||||
selectedIds.Clear();
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
showDeleteModal = false;
|
||||
pendingDeleteTransaction = null;
|
||||
bulkDeleteCount = 0;
|
||||
}
|
||||
|
||||
private async Task TogglePaid(Transaction t)
|
||||
{
|
||||
if (userId is null) return;
|
||||
await TransactionService.MarkAsPaidAsync(t.Id, userId, !t.IsPaid);
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private sealed class TransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than zero")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public DateTime Date { get; set; } = DateTime.Today;
|
||||
|
||||
public TransactionType Type { get; set; } = TransactionType.Expense;
|
||||
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public int? CategoryId { get; set; }
|
||||
|
||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.None;
|
||||
|
||||
public DateTime? RecurrenceEndDate { get; set; }
|
||||
|
||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||
{
|
||||
if (RecurrenceType != RecurrenceType.None && RecurrenceEndDate.HasValue)
|
||||
{
|
||||
var maxEnd = Date.AddYears(1);
|
||||
if (RecurrenceEndDate.Value > maxEnd)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date cannot be more than 1 year from the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
if (RecurrenceEndDate.Value <= Date)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date must be after the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using BudgetApp.Data.Entities;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace BudgetApp.Components.Pages;
|
||||
|
||||
public partial class Transactions
|
||||
{
|
||||
private List<Transaction>? _transactions;
|
||||
private List<Account>? _accountsList;
|
||||
private List<Category>? _categoriesList;
|
||||
private TransactionForm _formModel = new();
|
||||
private string? _userId;
|
||||
private int? _filterAccountId;
|
||||
private string _searchQuery = string.Empty;
|
||||
private DateTime _windowStart = DateTime.Today;
|
||||
private DateTime _windowEnd = DateTime.Today.AddDays(30);
|
||||
private string? _windowPreset = "30d";
|
||||
private int _currentPage = 1;
|
||||
private int _pageSize = 25;
|
||||
private static readonly int[] PageSizeOptions = [10, 25, 50, 100];
|
||||
private int TotalPages => (int)Math.Ceiling(FilteredTransactions.Count / (double)_pageSize);
|
||||
|
||||
private List<Transaction> PagedTransactions => FilteredTransactions
|
||||
.Skip((_currentPage - 1) * _pageSize)
|
||||
.Take(_pageSize)
|
||||
.ToList();
|
||||
|
||||
private string _sortColumn = "date";
|
||||
private bool _sortAscending = true;
|
||||
|
||||
private List<Transaction> FilteredTransactions
|
||||
{
|
||||
get
|
||||
{
|
||||
var list = (_transactions ?? []).Where(t =>
|
||||
t.Date.Date >= _windowStart.Date && t.Date.Date <= _windowEnd.Date);
|
||||
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
|
||||
? list.OrderBy(t => t.Description, StringComparer.OrdinalIgnoreCase)
|
||||
: list.OrderByDescending(t => t.Description, StringComparer.OrdinalIgnoreCase),
|
||||
"amount" => _sortAscending
|
||||
? list.OrderBy(t => t.Amount)
|
||||
: list.OrderByDescending(t => t.Amount),
|
||||
_ => _sortAscending
|
||||
? list.OrderBy(t => t.Date)
|
||||
: list.OrderByDescending(t => t.Date),
|
||||
};
|
||||
|
||||
return list.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAccountFilterChanged(ChangeEventArgs e)
|
||||
{
|
||||
_filterAccountId = int.TryParse(e.Value?.ToString(), out var id) ? id : null;
|
||||
_currentPage = 1;
|
||||
}
|
||||
|
||||
private void OnSearchChanged() => _currentPage = 1;
|
||||
|
||||
private async Task SetWindow(int days)
|
||||
{
|
||||
_windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null };
|
||||
_windowStart = DateTime.Today;
|
||||
_windowEnd = DateTime.Today.AddDays(days);
|
||||
_currentPage = 1;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task OnWindowDateChanged()
|
||||
{
|
||||
_windowPreset = null;
|
||||
_currentPage = 1;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private void OnPageSizeChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (int.TryParse(e.Value?.ToString(), out var size))
|
||||
{
|
||||
_pageSize = size;
|
||||
_currentPage = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetSort(string column)
|
||||
{
|
||||
if (_sortColumn == column)
|
||||
_sortAscending = !_sortAscending;
|
||||
else
|
||||
{
|
||||
_sortColumn = column;
|
||||
_sortAscending = true;
|
||||
}
|
||||
|
||||
_currentPage = 1;
|
||||
}
|
||||
|
||||
private MarkupString SortIndicator(string column)
|
||||
{
|
||||
if (_sortColumn != column)
|
||||
return new MarkupString("<span class=\"text-muted opacity-50\">⇅</span>");
|
||||
return new MarkupString(_sortAscending ? "▲" : "▼");
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(_userId, _windowEnd);
|
||||
_transactions = await TransactionService.GetTransactionsAsync(_userId);
|
||||
_accountsList = await AccountService.GetAccountsAsync(_userId);
|
||||
_categoriesList = await CategoryService.GetCategoriesAsync(_userId);
|
||||
|
||||
if (_accountsList.Count > 0 && _formModel.AccountId == 0)
|
||||
{
|
||||
_formModel.AccountId = _accountsList[0].Id;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddTransaction()
|
||||
{
|
||||
if (_userId is null) return;
|
||||
|
||||
var transaction = new Transaction
|
||||
{
|
||||
Description = _formModel.Description,
|
||||
Amount = _formModel.Amount,
|
||||
Date = _formModel.Date,
|
||||
Type = _formModel.Type,
|
||||
AccountId = _formModel.AccountId,
|
||||
CategoryId = _formModel.CategoryId,
|
||||
RecurrenceType = _formModel.RecurrenceType,
|
||||
RecurrenceEndDate = _formModel.RecurrenceEndDate
|
||||
};
|
||||
await TransactionService.CreateTransactionAsync(transaction);
|
||||
_formModel = new TransactionForm
|
||||
{
|
||||
AccountId = _accountsList?.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private readonly HashSet<int> _selectedIds = [];
|
||||
|
||||
private void ToggleSelect(int id)
|
||||
{
|
||||
var t = _transactions?.FirstOrDefault(x => x.Id == id);
|
||||
if (t?.SalaryId is not null) return;
|
||||
if (!_selectedIds.Remove(id))
|
||||
_selectedIds.Add(id);
|
||||
}
|
||||
|
||||
private bool IsAllPageSelected()
|
||||
{
|
||||
var selectable = PagedTransactions.Where(t => t.SalaryId is null).ToList();
|
||||
return selectable.Count > 0 && selectable.All(t => _selectedIds.Contains(t.Id));
|
||||
}
|
||||
|
||||
private void ToggleSelectAll(ChangeEventArgs e)
|
||||
{
|
||||
var selectable = PagedTransactions.Where(t => t.SalaryId is null).ToList();
|
||||
if (e.Value is true)
|
||||
{
|
||||
foreach (var t in selectable)
|
||||
_selectedIds.Add(t.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var t in selectable)
|
||||
_selectedIds.Remove(t.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private bool _showDeleteModal;
|
||||
private Transaction? _pendingDeleteTransaction;
|
||||
private bool _pendingDeleteIsRecurring;
|
||||
private int _bulkDeleteCount;
|
||||
private string _deleteModalTitle = "Delete Transaction";
|
||||
private bool _showEditModal;
|
||||
private int? _editingTransactionId;
|
||||
private bool _editIsPartOfSeries;
|
||||
private TransactionForm _editModel = new();
|
||||
|
||||
private void OpenEditModal(Transaction t)
|
||||
{
|
||||
_editingTransactionId = t.Id;
|
||||
_editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||
_editModel = new TransactionForm
|
||||
{
|
||||
Description = t.Description,
|
||||
Amount = t.Amount,
|
||||
Date = t.Date,
|
||||
Type = t.Type,
|
||||
AccountId = t.AccountId,
|
||||
CategoryId = t.CategoryId,
|
||||
RecurrenceType = t.RecurrenceType,
|
||||
RecurrenceEndDate = t.RecurrenceEndDate
|
||||
};
|
||||
_showEditModal = true;
|
||||
}
|
||||
|
||||
private void CloseEditModal() => _showEditModal = false;
|
||||
|
||||
private async Task SaveEdit()
|
||||
{
|
||||
if (_userId is null || _editingTransactionId is null) return;
|
||||
|
||||
await TransactionService.UpdateTransactionAsync(
|
||||
_editingTransactionId.Value, _userId,
|
||||
_editModel.Description, _editModel.Amount, _editModel.Date,
|
||||
_editModel.Type, _editModel.AccountId, _editModel.CategoryId,
|
||||
_editModel.RecurrenceType, _editModel.RecurrenceEndDate);
|
||||
|
||||
_showEditModal = false;
|
||||
_editingTransactionId = null;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private void ConfirmDelete(Transaction t)
|
||||
{
|
||||
_pendingDeleteTransaction = t;
|
||||
_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()
|
||||
{
|
||||
if (_userId is null || _pendingDeleteTransaction is null) return;
|
||||
_showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionAsync(_pendingDeleteTransaction.Id, _userId);
|
||||
_selectedIds.Remove(_pendingDeleteTransaction.Id);
|
||||
_pendingDeleteTransaction = null;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task DeleteSeries()
|
||||
{
|
||||
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.ToList())
|
||||
{
|
||||
var t = _transactions?.FirstOrDefault(x => x.Id == id);
|
||||
if (t?.SalaryId is not null) continue;
|
||||
await TransactionService.DeleteTransactionAsync(id, _userId);
|
||||
}
|
||||
|
||||
_selectedIds.Clear();
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
_showDeleteModal = false;
|
||||
_pendingDeleteTransaction = null;
|
||||
_bulkDeleteCount = 0;
|
||||
}
|
||||
|
||||
private async Task TogglePaid(Transaction t)
|
||||
{
|
||||
if (_userId is null) return;
|
||||
await TransactionService.MarkAsPaidAsync(t.Id, _userId, !t.IsPaid);
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private sealed class TransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(0.01, double.MaxValue,
|
||||
ErrorMessage = "Amount must be greater than zero")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public DateTime Date { get; set; } = DateTime.Today;
|
||||
|
||||
public TransactionType Type { get; set; } = TransactionType.Expense;
|
||||
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public int? CategoryId { get; set; }
|
||||
|
||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.None;
|
||||
|
||||
public DateTime? RecurrenceEndDate { get; set; }
|
||||
|
||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(
|
||||
System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||
{
|
||||
if (RecurrenceType != RecurrenceType.None && RecurrenceEndDate.HasValue)
|
||||
{
|
||||
var maxEnd = Date.AddYears(1);
|
||||
if (RecurrenceEndDate.Value > maxEnd)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date cannot be more than 1 year from the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
|
||||
if (RecurrenceEndDate.Value <= Date)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date must be after the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
@page "/weather"
|
||||
@attribute [StreamRendering]
|
||||
|
||||
<PageTitle>Weather</PageTitle>
|
||||
|
||||
<h1>Weather</h1>
|
||||
|
||||
<p>This component demonstrates showing data.</p>
|
||||
|
||||
@if (forecasts == null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th aria-label="Temperature in Celsius">Temp. (C)</th>
|
||||
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
|
||||
<th>Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var forecast in forecasts)
|
||||
{
|
||||
<tr>
|
||||
<td>@forecast.Date.ToShortDateString()</td>
|
||||
<td>@forecast.TemperatureC</td>
|
||||
<td>@forecast.TemperatureF</td>
|
||||
<td>@forecast.Summary</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private WeatherForecast[]? forecasts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Simulate asynchronous loading to demonstrate streaming rendering
|
||||
await Task.Delay(500);
|
||||
|
||||
var startDate = DateOnly.FromDateTime(DateTime.Now);
|
||||
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
|
||||
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = startDate.AddDays(index),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = summaries[Random.Shared.Next(summaries.Length)]
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private class WeatherForecast
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public int TemperatureC { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
}
|
||||
@@ -3,49 +3,11 @@
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<div class="toast-stack">
|
||||
@foreach (var toast in toasts)
|
||||
@foreach (var toast in _toasts)
|
||||
{
|
||||
<div class="toast-item @(toast.Dismissing ? "toast-dismiss" : "")">
|
||||
<span class="toast-icon">⚠</span>
|
||||
<span class="toast-message">@toast.Message</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private readonly List<ToastItem> toasts = [];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
ToastService.OnShow += ShowToast;
|
||||
}
|
||||
|
||||
private async void ShowToast(string message)
|
||||
{
|
||||
var toast = new ToastItem { Message = message };
|
||||
toasts.Add(toast);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
_ = DismissAfterDelay(toast);
|
||||
}
|
||||
|
||||
private async Task DismissAfterDelay(ToastItem toast)
|
||||
{
|
||||
await Task.Delay(3500);
|
||||
toast.Dismissing = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
await Task.Delay(300);
|
||||
toasts.Remove(toast);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ToastService.OnShow -= ShowToast;
|
||||
}
|
||||
|
||||
private sealed class ToastItem
|
||||
{
|
||||
public string Message { get; set; } = "";
|
||||
public bool Dismissing { get; set; }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace BudgetApp.Components;
|
||||
|
||||
public partial class ToastContainer
|
||||
{
|
||||
private readonly List<ToastItem> _toasts = [];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
ToastService.OnShow += ShowToast;
|
||||
}
|
||||
|
||||
private async void ShowToast(string message)
|
||||
{
|
||||
var toast = new ToastItem { Message = message };
|
||||
_toasts.Add(toast);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
_ = DismissAfterDelay(toast);
|
||||
}
|
||||
|
||||
private async Task DismissAfterDelay(ToastItem toast)
|
||||
{
|
||||
await Task.Delay(3500);
|
||||
toast.Dismissing = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
await Task.Delay(300);
|
||||
_toasts.Remove(toast);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ToastService.OnShow -= ShowToast;
|
||||
}
|
||||
|
||||
private sealed class ToastItem
|
||||
{
|
||||
public string Message { get; set; } = "";
|
||||
public bool Dismissing { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,3 @@
|
||||
@implements IDisposable
|
||||
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private EditContext? CurrentEditContext { get; set; }
|
||||
|
||||
[Inject]
|
||||
private ToastService ToastService { get; set; } = default!;
|
||||
|
||||
private bool validationRequested;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (CurrentEditContext is not null)
|
||||
{
|
||||
CurrentEditContext.OnValidationRequested += OnValidationRequested;
|
||||
CurrentEditContext.OnValidationStateChanged += OnValidationStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
|
||||
{
|
||||
validationRequested = true;
|
||||
}
|
||||
|
||||
private void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e)
|
||||
{
|
||||
if (!validationRequested || CurrentEditContext is null) return;
|
||||
validationRequested = false;
|
||||
|
||||
foreach (var message in CurrentEditContext.GetValidationMessages())
|
||||
{
|
||||
ToastService.Show(message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (CurrentEditContext is not null)
|
||||
{
|
||||
CurrentEditContext.OnValidationRequested -= OnValidationRequested;
|
||||
CurrentEditContext.OnValidationStateChanged -= OnValidationStateChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
<DataAnnotationsValidator />
|
||||
@@ -0,0 +1,48 @@
|
||||
using BudgetApp.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
|
||||
namespace BudgetApp.Components;
|
||||
|
||||
public partial class ToastValidator
|
||||
{
|
||||
[CascadingParameter] private EditContext? CurrentEditContext { get; set; }
|
||||
|
||||
[Inject] private ToastService ToastService { get; set; } = default!;
|
||||
|
||||
private bool _validationRequested;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (CurrentEditContext is not null)
|
||||
{
|
||||
CurrentEditContext.OnValidationRequested += OnValidationRequested;
|
||||
CurrentEditContext.OnValidationStateChanged += OnValidationStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
|
||||
{
|
||||
_validationRequested = true;
|
||||
}
|
||||
|
||||
private void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e)
|
||||
{
|
||||
if (!_validationRequested || CurrentEditContext is null) return;
|
||||
_validationRequested = false;
|
||||
|
||||
foreach (var message in CurrentEditContext.GetValidationMessages())
|
||||
{
|
||||
ToastService.Show(message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (CurrentEditContext is not null)
|
||||
{
|
||||
CurrentEditContext.OnValidationRequested -= OnValidationRequested;
|
||||
CurrentEditContext.OnValidationStateChanged -= OnValidationStateChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,6 @@
|
||||
@using BudgetApp.Components
|
||||
@using BudgetApp.Components.Layout
|
||||
@using BudgetApp.Data
|
||||
@using BudgetApp.Data.Interfaces
|
||||
@using BudgetApp.Data.Repositories
|
||||
@using BudgetApp.Services
|
||||
Reference in new issue
Block a user