This commit is contained in:
rossmill76 committed 2026-03-05 18:57:12 +00:00
1 parent 38067df8c3
commit 492d86db9e
30 files changed
+2473 -40

No files matched your search

+5 -1
View File
@@ -69,7 +69,11 @@ else
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<div class="d-flex gap-1 justify-content-center">
@if (editingCategoryId == category.Id)
@if (category.IsDefault)
{
<span class="badge bg-secondary" title="Default categories cannot be edited or deleted">Default</span>
}
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>
+38 -2
View File
@@ -21,7 +21,10 @@
<strong>Create an account</strong> — Head to <a href="/accounts">Accounts</a> and add at least one financial account (e.g. "Current Account", "Savings"). Set the opening balance and choose a currency.
</li>
<li class="mb-2">
<strong>Set up categories</strong> — Visit <a href="/categories">Categories</a> to organise your spending. Pick an emoji icon for each category to make them easy to spot.
<strong>Set up categories</strong> — Visit <a href="/categories">Categories</a> to organise your spending. A set of default categories (including Salary) are provided automatically.
</li>
<li class="mb-2">
<strong>Configure your salary</strong> — Visit <a href="/salary">Salary</a> to add your regular income sources. Each salary generates recurring Income transactions automatically and highlights paydays on the chart.
</li>
<li class="mb-2">
<strong>Record transactions</strong> — Go to <a href="/transactions">Transactions</a> or use the <strong>+ Add</strong> button on the <a href="/">Dashboard</a> to log income and expenses.
@@ -42,7 +45,7 @@
<p>The <a href="/">Dashboard</a> is your home screen and gives you an at-a-glance view of your finances.</p>
<h6>Balance Trend Chart</h6>
<p>The chart shows projected balances for each account over a time window starting from today. Use the preset buttons (<strong>7d</strong>, <strong>30d</strong>, <strong>90d</strong>, <strong>1y</strong>) to look ahead, or pick custom dates with the date pickers.</p>
<p>The chart shows projected balances for each account over a time window starting from today. Use the preset buttons (<strong>7d</strong>, <strong>30d</strong>, <strong>90d</strong>, <strong>1y</strong>) to look ahead, or pick custom dates with the date pickers. Payday dates from your <a href="/salary">Salary</a> sources are highlighted with a 💰 marker and a subtle green band.</p>
<h6>Recent &amp; Upcoming Tabs</h6>
<ul>
@@ -109,6 +112,36 @@
</div>
</div>
@* ── Salary ── *@
<div class="card mb-4" id="salary">
<div class="card-header">
<h5 class="mb-0">💰 Salary</h5>
</div>
<div class="card-body">
<p>The <a href="/salary">Salary</a> page lets you define regular income sources that generate recurring transactions automatically.</p>
<h6>Adding a Salary</h6>
<ul>
<li><strong>Name</strong> — A label for this income source (e.g. "Main Job", "Freelance").</li>
<li><strong>Amount</strong> — The net amount credited each payday.</li>
<li><strong>Account</strong> — Which account receives the payment.</li>
<li><strong>Recurrence</strong> — How often you are paid: <strong>Weekly</strong>, <strong>Monthly</strong> or <strong>Yearly</strong>.</li>
<li><strong>First Payday</strong> — The date of the first payment. The day of month (or week) is derived from this and repeats indefinitely.</li>
</ul>
<h6>How Salary Transactions Work</h6>
<ul>
<li>Salary transactions are generated automatically as Income entries up to the end of the current time window, and refreshed whenever you visit or change the date range on any page.</li>
<li>They appear on the <a href="/transactions">Transactions</a> page with a 💰 badge and cannot be deleted from there — manage them on the Salary page instead.</li>
<li>Payday dates are highlighted on the <a href="/">Dashboard</a> balance chart with a 💰 marker and subtle green band so you can see upcoming income at a glance.</li>
<li>The category is automatically set to <strong>Salary</strong>.</li>
</ul>
<h6>Deleting a Salary</h6>
<p class="mb-0">Deleting a salary removes all future unpaid transactions for that income source. Past transactions remain in your history for accurate record-keeping.</p>
</div>
</div>
@* ── Categories ── *@
<div class="card mb-4" id="categories">
<div class="card-header">
@@ -120,6 +153,7 @@
<li><strong>Add a category</strong> — Enter a name and pick an emoji icon from the icon picker.</li>
<li><strong>Edit</strong> — Click the Edit button on a category card to change its name or icon.</li>
<li><strong>Delete</strong> — Removes the category. Transactions using it will become uncategorised.</li>
<li><strong>Default categories</strong> — A set of built-in categories (Groceries, Salary, Rent / Mortgage, etc.) are created automatically and shown with a <strong>Default</strong> badge. These cannot be edited or deleted.</li>
</ul>
<p class="mb-0">Categories are optional — you can assign them to transactions to get better insight into your spending patterns.</p>
</div>
@@ -175,6 +209,7 @@
<ul class="mb-0">
<li>Use the Dashboard <strong>+ Add</strong> button for rapid entry — the modal stays open after each save so you can batch-add transactions.</li>
<li>Set up recurring transactions for regular bills and income to keep your upcoming view accurate.</li>
<li>Use the <a href="/salary">Salary</a> page for regular pay — it is protected from accidental deletion and highlights paydays on the chart. Use the <strong>90d</strong> or <strong>1y</strong> window to plan ahead.</li>
<li>Assign colours to your accounts to easily distinguish them on the chart and in transaction lists.</li>
<li>Hover over elements for tooltips with additional context throughout the app.</li>
<li>Click your account balances in the sidebar to quickly navigate to account management.</li>
@@ -193,6 +228,7 @@
<a href="/help#dashboard" class="list-group-item list-group-item-action">📊 Dashboard</a>
<a href="/help#accounts" class="list-group-item list-group-item-action">🏦 Accounts</a>
<a href="/help#transactions" class="list-group-item list-group-item-action">💸 Transactions</a>
<a href="/help#salary" class="list-group-item list-group-item-action">💰 Salary</a>
<a href="/help#categories" class="list-group-item list-group-item-action">🏷️ Categories</a>
<a href="/help#reports" class="list-group-item list-group-item-action">📈 Reports</a>
<a href="/help#settings" class="list-group-item list-group-item-action">⚙️ Settings</a>
+62 -17
View File
@@ -2,6 +2,7 @@
@attribute [Authorize]
@inject DashboardService DashboardService
@inject TransactionService TransactionService
@inject SalaryService SalaryService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@implements IAsyncDisposable
@@ -195,6 +196,10 @@ else
{
<span class="badge bg-info bg-opacity-10 text-info" style="font-size: 0.7rem;">🔁 @GetRecurrenceLabel(t)</span>
}
@if (t.IsPaid)
{
<span class="badge bg-success bg-opacity-10 text-success" style="font-size: 0.7rem;">✓ Paid</span>
}
</div>
<div class="d-flex align-items-center gap-2 mt-1 flex-wrap" style="font-size: 0.8rem;">
<span class="text-muted">@t.Date.ToString("MMM dd, yyyy")</span>
@@ -209,20 +214,30 @@ 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>
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
</div>
</div>
@if (selectedTransactionId == t.Id)
{
<div class="mt-2 pt-2 border-top d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
</div>
}
</div>
}
</div>
@if (RecentTotalPages > 1)
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
</div>
</div>
@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)
{
<button class="btn btn-outline-secondary btn-sm" title="Revert to upcoming" @onclick="() => MarkAsPaid(t.Id, false)" @onclick:stopPropagation="true">↩ Unpaid</button>
}
else
{
<span></span>
}
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
</div>
</div>
}
</div>
}
</div>
@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>
@@ -282,9 +297,12 @@ else
</div>
@if (selectedTransactionId == t.Id)
{
<div class="mt-2 pt-2 border-top d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
<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>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
</div>
</div>
}
</div>
@@ -446,6 +464,7 @@ else
datasets,
dayLabels,
highlightIndex,
GetSalaryDayIndices(),
data.CurrencySymbol);
}
}
@@ -462,6 +481,23 @@ else
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;
@@ -583,6 +619,14 @@ else
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)
@@ -609,6 +653,7 @@ else
private async Task RefreshData()
{
if (userId is null) return;
await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd);
data = await DashboardService.GetDashboardDataAsync(userId, windowStart, windowEnd);
chartDataVersion++;
ComputeProjectedBalances();
+2
View File
@@ -3,6 +3,7 @@
@inject TransactionService TransactionService
@inject AccountService AccountService
@inject CategoryService CategoryService
@inject SalaryService SalaryService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@implements IAsyncDisposable
@@ -220,6 +221,7 @@ else
{
if (userId is null) return;
await SalaryService.MaterializeSalaryTransactionsAsync(userId, dateTo);
var transactions = await TransactionService.GetTransactionsInRangeAsync(userId, dateFrom, dateTo);
var filtered = transactions
+264
View File
@@ -0,0 +1,264 @@
@page "/salary"
@attribute [Authorize]
@inject SalaryService SalaryService
@inject AccountService AccountService
@inject AuthenticationStateProvider AuthStateProvider
@rendermode InteractiveServer
<PageTitle>Salary</PageTitle>
<h3 class="mb-2">Salary</h3>
<p class="text-muted mb-4">Define your regular income sources. Each salary generates recurring transactions at the set interval, forever.</p>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Add Income Source</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 a salary.
</div>
}
else
{
<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" />
</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">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-2">
<label class="form-label">Recurrence</label>
<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>
</InputSelect>
</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" />
</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>
</div>
</div>
</EditForm>
}
</div>
</div>
@if (salaries is null)
{
<p><em>Loading...</em></p>
}
else if (salaries.Count == 0)
{
<div class="alert alert-info">No income sources yet. Add one above to start tracking your salary.</div>
}
else
{
<div class="card">
<div class="card-header">
<h5 class="mb-0">Income Sources</h5>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Amount</th>
<th>Account</th>
<th class="d-none d-md-table-cell">Schedule</th>
<th>Next Payday</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var s in salaries)
{
<tr>
<td class="fw-semibold">💰 @s.Name</td>
<td class="text-success fw-semibold text-nowrap">+@s.Account.FormatAmount(s.Amount)</td>
<td>
<span class="badge" style="background-color: @s.Account.Color;">
@s.Account.Name
</span>
</td>
<td class="d-none d-md-table-cell">
<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)
{
<span>@next.Value.ToString("MMM dd, yyyy")</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="text-end text-nowrap">
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(s)">Delete</button>
</td>
</tr>
}
</tbody>
</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>
</div>
</div>
}
@if (showDeleteModal && pendingDeleteSalary 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 Income Source</h5>
<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>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>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
<button type="button" class="btn btn-danger" @onclick="ExecuteDelete">Delete</button>
</div>
</div>
</div>
</div>
}
@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,17 @@
.card {
border-radius: 1rem;
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.07);
border: none;
}
.card-header {
border-radius: 1rem 1rem 0 0 !important;
padding: 1rem 1.25rem;
}
@media (max-width: 767.98px) {
::deep .table > :not(caption) > * > * {
padding: 0.3rem 0.4rem;
font-size: 0.8125rem;
}
}
+61 -11
View File
@@ -3,6 +3,7 @@
@inject TransactionService TransactionService
@inject AccountService AccountService
@inject CategoryService CategoryService
@inject SalaryService SalaryService
@inject AuthenticationStateProvider AuthStateProvider
@rendermode InteractiveServer
@@ -176,11 +177,28 @@ else
@foreach (var t in pagedTransactions)
{
<tr>
<td>
<input type="checkbox" class="form-check-input" checked="@selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" />
</td>
<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</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
@@ -215,8 +233,21 @@ else
</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>
<button class="btn btn-outline-danger" @onclick="() => ConfirmDelete(t)">Delete</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>
@@ -448,18 +479,20 @@ else
private void OnSearchChanged() => currentPage = 1;
private void SetWindow(int days)
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 void OnWindowDateChanged()
private async Task OnWindowDateChanged()
{
windowPreset = null;
currentPage = 1;
await LoadData();
}
private void OnPageSizeChanged(ChangeEventArgs e)
@@ -501,6 +534,7 @@ else
{
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);
@@ -539,22 +573,29 @@ else
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() => pagedTransactions.Count > 0 && pagedTransactions.All(t => selectedIds.Contains(t.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 pagedTransactions)
foreach (var t in selectable)
selectedIds.Add(t.Id);
}
else
{
foreach (var t in pagedTransactions)
foreach (var t in selectable)
selectedIds.Remove(t.Id);
}
}
@@ -649,8 +690,10 @@ else
{
if (userId is null) return;
showDeleteModal = false;
foreach (var id in selectedIds)
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();
@@ -664,6 +707,13 @@ else
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")]