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,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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user