Files
Budgeteer/BudgetApp/Components/Pages/Transactions.razor
T
2026-03-06 11:05:01 +00:00

427 lines
23 KiB
Plaintext

@page "/transactions"
@using BudgetApp.Data.Entities
@attribute [Authorize]
@inject ITransactionRepository TransactionService
@inject IAccountRepository AccountService
@inject ICategoryRepository CategoryService
@inject ISalaryRepository SalaryService
@inject AuthenticationStateProvider AuthStateProvider
@rendermode InteractiveServer
<PageTitle>Transactions</PageTitle>
<h3 class="mb-2">Transactions</h3>
<p class="text-muted mb-4">Record, edit and manage your income and expenses.</p>
<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">
<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" />
</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" />
</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" 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>
<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")" />
</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 mb-3">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<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>
</div>
<input type="date" class="form-control form-control-sm" style="width:auto;"
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" />
</div>
</div>
<div class="card-body py-2">
<div class="row g-2 align-items-center">
<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)
{
@foreach (var a in _accountsList)
{
<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" />
</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)
{
<option value="@size" selected="@(_pageSize == size)">@size</option>
}
</select>
</div>
</div>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 2rem;">
<input type="checkbox" class="form-check-input" title="Select all transactions on this page" checked="@IsAllPageSelected()" @onchange="ToggleSelectAll" />
</th>
<th role="button" class="user-select-none" title="Sort by date" @onclick='() => SetSort("date")'>Date @SortIndicator("date")</th>
<th role="button" class="user-select-none" title="Sort by description" @onclick='() => SetSort("description")'>Description @SortIndicator("description")</th>
<th>Account</th>
<th class="d-none d-md-table-cell">Category</th>
<th class="d-none d-md-table-cell">Recurrence</th>
<th role="button" class="user-select-none text-end" title="Sort by amount" @onclick='() => SetSort("amount")'>Amount @SortIndicator("amount")</th>
<th></th>
</tr>
</thead>
<tbody>
@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)" />
}
else
{
<span class="text-muted" style="font-size: 0.85rem;" title="Salary transactions cannot be bulk deleted — manage them on the Salary page">🔒</span>
}
</td>
<td class="text-nowrap">@t.Date.ToString("MMM dd, yyyy")</td>
<td>
@t.Description
@if (t.SalaryId is not null)
{
<span class="badge bg-primary ms-1" style="font-size: 0.7rem;">💰 Salary</span>
}
@if (t.IsPaid && t.Date.Date > DateTime.Today)
{
<span class="badge bg-success ms-1" style="font-size: 0.7rem;">✓ Paid</span>
}
</td>
<td>
<span class="badge" style="background-color: @t.Account.Color;">
@t.Account.Name
</span>
</td>
<td class="d-none d-md-table-cell">
@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 class="d-none d-md-table-cell">
@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 text-nowrap @(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold">
@(t.Type == TransactionType.Income ? "+" : "-")@t.Account.FormatAmount(t.Amount)
</td>
<td class="text-end text-nowrap">
<div class="btn-group btn-group-sm">
@if (t.Date.Date > DateTime.Today)
{
<button class="btn @(t.IsPaid ? "btn-success" : "btn-outline-success")"
title="@(t.IsPaid ? "Mark as unpaid" : "Mark as paid")"
@onclick="() => TogglePaid(t)">✓</button>
}
<button class="btn btn-outline-primary" @onclick="() => OpenEditModal(t)">Edit</button>
@if (t.SalaryId is null)
{
<button class="btn btn-outline-danger" @onclick="() => ConfirmDelete(t)">Delete</button>
}
else
{
<a href="/salary" class="btn btn-outline-secondary" title="Salary transactions are managed on the Salary page">🔒</a>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@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>
<div class="d-flex align-items-center gap-3">
@if (_selectedIds.Count > 0)
{
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
Delete @_selectedIds.Count selected
</button>
}
<small class="text-muted">Page @_currentPage of @TotalPages (@FilteredTransactions.Count transactions)</small>
</div>
<button class="btn btn-sm btn-outline-secondary" disabled="@(_currentPage >= TotalPages ? true : (bool?)null)" @onclick="() => _currentPage++">Next →</button>
</div>
}
else
{
<div class="card-footer d-flex flex-wrap justify-content-between align-items-center gap-2">
<small class="text-muted">@FilteredTransactions.Count transactions</small>
@if (_selectedIds.Count > 0)
{
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
Delete @_selectedIds.Count selected
</button>
}
</div>
}
</div>
@if (_showDeleteModal)
{
<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>
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
</div>
<div class="modal-body">
@if (_bulkDeleteCount > 0)
{
<p>Are you sure you want to delete <strong>@_bulkDeleteCount</strong> selected transactions?</p>
<p class="text-danger mb-0">This action cannot be undone.</p>
}
else if (_pendingDeleteTransaction is not null)
{
<p>Are you sure you want to delete <strong>@_pendingDeleteTransaction.Description</strong>?</p>
@if (_pendingDeleteIsRecurring)
{
<p>This transaction is part of a recurring series. Would you like to delete just this one, or all future transactions in the series?</p>
}
else
{
<p class="text-danger mb-0">This action cannot be undone.</p>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
@if (_bulkDeleteCount > 0)
{
<button type="button" class="btn btn-danger" @onclick="ExecuteBulkDelete">Delete All Selected</button>
}
else if (_pendingDeleteIsRecurring)
{
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
}
else
{
<button type="button" class="btn btn-danger" @onclick="DeleteSingle">Delete</button>
}
</div>
</div>
</div>
</div>
}
@if (_showEditModal)
{
<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">Edit Transaction</h5>
<button type="button" class="btn-close" @onclick="CloseEditModal"></button>
</div>
<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" />
</div>
<div class="mb-3">
<label class="form-label">Amount</label>
<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" />
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<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)
{
@foreach (var account in _accountsList)
{
<option value="@account.Id">@account.Name</option>
}
}
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Category</label>
<InputSelect @bind-Value="_editModel.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>
@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.
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseEditModal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</EditForm>
</div>
</div>
</div>
}
}
@code {
// Time window state
// Pagination state
// Sort state
// Selection state
// Delete confirmation state
// Edit modal state
}