Files
Budgeteer/BudgetApp/Components/Pages/Transactions.razor
T
2026-03-01 18:17:42 +00:00

401 lines
17 KiB
Plaintext

@page "/transactions"
@attribute [Authorize]
@inject TransactionService TransactionService
@inject AccountService AccountService
@inject CategoryService CategoryService
@inject AuthenticationStateProvider AuthStateProvider
@rendermode InteractiveServer
<PageTitle>Transactions</PageTitle>
<h3 class="mb-4">Transactions</h3>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Add Transaction</h5>
</div>
<div class="card-body">
@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.
</div>
}
else
{
<EditForm Model="formModel" OnValidSubmit="AddTransaction" FormName="addTransaction">
<DataAnnotationsValidator />
<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" />
<ValidationMessage For="() => formModel.Description" />
</div>
<div class="col-md-2">
<label class="form-label">Amount</label>
<InputNumber @bind-Value="formModel.Amount" class="form-control numeric-input" />
</div>
<div class="col-md-2">
<label class="form-label">Date</label>
<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">
<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)
{
@foreach (var account in accountsList)
{
<option value="@account.Id">@account.Name</option>
}
}
</InputSelect>
</div>
<div class="col-md-4">
<label class="form-label">Category</label>
<InputSelect @bind-Value="formModel.CategoryId" class="form-select">
<option value="">— None —</option>
@if (categoriesList is not null)
{
@foreach (var category in categoriesList)
{
<option value="@category.Id">@(string.IsNullOrEmpty(category.Icon) ? "" : category.Icon + " ")@category.Name</option>
}
}
</InputSelect>
</div>
<div class="col-md-2">
<label class="form-label">Recurrence</label>
<InputSelect @bind-Value="formModel.RecurrenceType" class="form-select">
<option value="@RecurrenceType.None">None</option>
<option value="@RecurrenceType.Daily">Daily</option>
<option value="@RecurrenceType.Weekly">Weekly</option>
<option value="@RecurrenceType.Monthly">Monthly</option>
<option value="@RecurrenceType.Yearly">Yearly</option>
</InputSelect>
</div>
@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")" />
<ValidationMessage For="() => formModel.RecurrenceEndDate" />
</div>
}
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Add</button>
</div>
</div>
</EditForm>
}
</div>
</div>
@if (transactions is null)
{
<p><em>Loading...</em></p>
}
else if (transactions.Count == 0)
{
<div class="alert alert-info">No transactions yet.</div>
}
else
{
<div class="card">
<div class="card-header d-flex align-items-center gap-2">
<label class="form-label mb-0 text-nowrap">Account:</label>
<select class="form-select form-select-sm" style="max-width: 200px;" @onchange="OnAccountFilterChanged">
<option value="">All Accounts</option>
@if (accountsList is not null)
{
@foreach (var a in accountsList)
{
<option value="@a.Id" selected="@(filterAccountId == a.Id)">@a.Name</option>
}
}
</select>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th role="button" class="user-select-none" @onclick='() => SetSort("date")'>Date @SortIndicator("date")</th>
<th role="button" class="user-select-none" @onclick='() => SetSort("description")'>Description @SortIndicator("description")</th>
<th>Account</th>
<th>Category</th>
<th>Recurrence</th>
<th role="button" class="user-select-none text-end" @onclick='() => SetSort("amount")'>Amount @SortIndicator("amount")</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var t in filteredTransactions)
{
<tr>
<td>@t.Date.ToString("MMM dd, yyyy")</td>
<td>@t.Description</td>
<td>
<span class="badge" style="background-color: @t.Account.Color;">
@t.Account.Name
</span>
</td>
<td>
@if (t.Category is not null)
{
@if (!string.IsNullOrEmpty(t.Category.Icon))
{
<span class="me-1">@t.Category.Icon</span>
}
@t.Category.Name
}
else
{
<span>—</span>
}
</td>
<td>
@if (t.RecurrenceType != RecurrenceType.None)
{
<span class="badge bg-info">🔁 @t.RecurrenceType</span>
}
else if (t.ParentTransactionId is not null)
{
<span class="badge bg-info">🔁 @(t.ParentTransaction?.RecurrenceType.ToString() ?? "Recurring")</span>
}
</td>
<td class="text-end @(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold">
@(t.Type == TransactionType.Income ? "+" : "-")@t.Account.FormatAmount(t.Amount)
</td>
<td class="text-end">
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)">
Delete
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@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">Delete Recurring Transaction</h5>
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
</div>
<div class="modal-body">
<p>
<strong>@pendingDeleteTransaction.Description</strong> is part of a recurring series.
</p>
<p>Would you like to delete just this transaction, or all future transactions in the series?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
</div>
</div>
</div>
</div>
}
}
@code {
private List<Transaction>? transactions;
private List<Account>? accountsList;
private List<Category>? categoriesList;
private TransactionForm formModel = new();
private string? userId;
private int? filterAccountId;
// Sort state
private string sortColumn = "date";
private bool sortAscending = true;
private List<Transaction> filteredTransactions
{
get
{
var list = (transactions ?? []).Where(t => t.Date.Date >= DateTime.Today);
if (filterAccountId is not null)
list = list.Where(t => t.AccountId == filterAccountId);
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;
}
private void SetSort(string column)
{
if (sortColumn == column)
sortAscending = !sortAscending;
else
{
sortColumn = column;
sortAscending = true;
}
}
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;
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();
}
// Delete confirmation state
private bool showDeleteModal;
private Transaction? pendingDeleteTransaction;
private void ConfirmDelete(Transaction t)
{
pendingDeleteTransaction = t;
if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
{
showDeleteModal = true;
}
else
{
_ = DeleteSingle();
}
}
private async Task DeleteSingle()
{
if (userId is null || pendingDeleteTransaction is null) return;
showDeleteModal = false;
await TransactionService.DeleteTransactionAsync(pendingDeleteTransaction.Id, userId);
pendingDeleteTransaction = null;
await LoadData();
}
private async Task DeleteSeries()
{
if (userId is null || pendingDeleteTransaction is null) return;
showDeleteModal = false;
await TransactionService.DeleteTransactionSeriesAsync(pendingDeleteTransaction.Id, userId);
pendingDeleteTransaction = null;
await LoadData();
}
private void CancelDelete()
{
showDeleteModal = false;
pendingDeleteTransaction = null;
}
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)]);
}
}
}
}
}