427 lines
23 KiB
Plaintext
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
|
|
|
|
}
|