Refactor. Add data layer

This commit is contained in:
rossmill76 committed 2026-03-06 11:05:01 +00:00
1 parent 3b7e4e6ec5
commit b6b794f0b4
82 files changed
+2930 -2811

No files matched your search

+59 -412
View File
@@ -1,10 +1,11 @@
@page "/"
@attribute [Authorize]
@inject DashboardService DashboardService
@inject TransactionService TransactionService
@inject SalaryService SalaryService
@inject ITransactionRepository TransactionService
@inject ISalaryRepository SalaryService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@inject IJSRuntime Js
@using BudgetApp.Data.Entities
@implements IAsyncDisposable
@rendermode InteractiveServer
@@ -13,11 +14,11 @@
<h3 class="mb-2">Dashboard</h3>
<p class="text-muted mb-4">An overview of your balances, recent and upcoming transactions.</p>
@if (data is null)
@if (_data is null)
{
<p><em>Loading...</em></p>
}
else if (data.Accounts.Count == 0)
else if (_data.Accounts.Count == 0)
{
<div class="alert alert-info">
<h5>Welcome!</h5>
@@ -31,16 +32,16 @@ else
<h5 class="mb-0">Balance Trend</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 date-picker"
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 date-picker"
title="Custom end date" @bind="windowEnd" @bind:after="OnWindowDateChanged" />
title="Custom end date" @bind="_windowEnd" @bind:after="OnWindowDateChanged" />
</div>
</div>
<div class="card-body">
@@ -51,41 +52,41 @@ else
</div>
@* ── Edit / Add Modal ── *@
@if (showModal)
@if (_showModal)
{
<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">@(editingTransactionId is null ? "Add Transaction" : "Edit Transaction")</h5>
<h5 class="modal-title">@(_editingTransactionId is null ? "Add Transaction" : "Edit Transaction")</h5>
<button type="button" class="btn-close" @onclick="CloseModal"></button>
</div>
<EditForm Model="modalForm" OnValidSubmit="SaveModal" FormName="dashboardModal">
<EditForm Model="_modalForm" OnValidSubmit="SaveModal" FormName="dashboardModal">
<ToastValidator />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Description</label>
<InputText @bind-Value="modalForm.Description" class="form-control" />
<InputText @bind-Value="_modalForm.Description" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">Amount</label>
<InputNumber @bind-Value="modalForm.Amount" class="form-control numeric-input" />
<InputNumber @bind-Value="_modalForm.Amount" class="form-control numeric-input" />
</div>
<div class="mb-3">
<label class="form-label">Date</label>
<InputDate @bind-Value="modalForm.Date" class="form-control" />
<InputDate @bind-Value="_modalForm.Date" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<InputSelect @bind-Value="modalForm.Type" class="form-select">
<InputSelect @bind-Value="_modalForm.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="modalForm.AccountId" class="form-select">
@foreach (var a in data.Accounts)
<InputSelect @bind-Value="_modalForm.AccountId" class="form-select">
@foreach (var a in _data.Accounts)
{
<option value="@a.Id">@a.Name</option>
}
@@ -93,19 +94,19 @@ else
</div>
<div class="mb-3">
<label class="form-label">Category</label>
<InputSelect @bind-Value="modalForm.CategoryId" class="form-select">
<InputSelect @bind-Value="_modalForm.CategoryId" class="form-select">
<option value="">— None —</option>
@foreach (var c in data.Categories)
@foreach (var c in _data.Categories)
{
<option value="@c.Id">@(string.IsNullOrEmpty(c.Icon) ? "" : c.Icon + " ")@c.Name</option>
}
</InputSelect>
</div>
@if (editingTransactionId is null)
@if (_editingTransactionId is null)
{
<div class="mb-3">
<label class="form-label">Recurrence</label>
<InputSelect @bind-Value="modalForm.RecurrenceType" class="form-select">
<InputSelect @bind-Value="_modalForm.RecurrenceType" class="form-select">
<option value="@RecurrenceType.None">None</option>
<option value="@RecurrenceType.Daily">Daily</option>
<option value="@RecurrenceType.Weekly">Weekly</option>
@@ -113,15 +114,15 @@ else
<option value="@RecurrenceType.Yearly">Yearly</option>
</InputSelect>
</div>
@if (modalForm.RecurrenceType != RecurrenceType.None)
@if (_modalForm.RecurrenceType != RecurrenceType.None)
{
<div class="mb-3">
<label class="form-label">Recurrence End Date <small class="text-muted">(max 1 year, defaults to 1 year if blank)</small></label>
<InputDate @bind-Value="modalForm.RecurrenceEndDate" class="form-control" max="@modalForm.Date.AddYears(1).ToString("yyyy-MM-dd")" />
<InputDate @bind-Value="_modalForm.RecurrenceEndDate" class="form-control" max="@_modalForm.Date.AddYears(1).ToString("yyyy-MM-dd")" />
</div>
}
}
else if (editIsPartOfSeries)
else 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.
@@ -142,31 +143,31 @@ else
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<ul class="nav nav-tabs card-header-tabs" style="flex-grow: 0;">
<li class="nav-item">
<button class="nav-link @(activeTab == "recent" ? "active" : "")" title="Transactions that have already occurred" @onclick='() => { activeTab = "recent"; recentPage = 1; }'>
Recent <span class="badge bg-secondary ms-1">@filteredRecent.Count</span>
<button class="nav-link @(_activeTab == "recent" ? "active" : "")" title="Transactions that have already occurred" @onclick='() => { _activeTab = "recent"; _recentPage = 1; }'>
Recent <span class="badge bg-secondary ms-1">@FilteredRecent.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "upcoming" ? "active" : "")" title="Scheduled future transactions" @onclick='() => { activeTab = "upcoming"; upcomingPage = 1; }'>
Upcoming <span class="badge bg-secondary ms-1">@filteredUpcoming.Count</span>
<button class="nav-link @(_activeTab == "upcoming" ? "active" : "")" title="Scheduled future transactions" @onclick='() => { _activeTab = "upcoming"; _upcomingPage = 1; }'>
Upcoming <span class="badge bg-secondary ms-1">@FilteredUpcoming.Count</span>
</button>
</li>
</ul>
<div class="d-flex align-items-center gap-2">
<input type="search" class="form-control form-control-sm" style="max-width: 180px;"
title="Filter by description, account, category or amount" placeholder="Search..." @bind="dashboardSearchQuery" @bind:event="oninput" @bind:after="OnDashboardSearchChanged" />
title="Filter by description, account, category or amount" placeholder="Search..." @bind="_dashboardSearchQuery" @bind:event="oninput" @bind:after="OnDashboardSearchChanged" />
<button class="btn btn-sm btn-primary" title="Quickly add a new transaction" @onclick="OpenAddModal">+ Add</button>
</div>
</div>
@if (data.Accounts.Count > 1)
@if (_data.Accounts.Count > 1)
{
<div class="px-3 py-2 border-bottom d-flex flex-wrap align-items-center gap-2">
<small class="text-muted me-1">Filter:</small>
@foreach (var a in data.Accounts)
@foreach (var a in _data.Accounts)
{
<button class="btn btn-sm px-2 py-0 @(hiddenAccountIds.Contains(a.Id) ? "btn-outline-secondary" : "")"
style="font-size: 0.75rem; @(hiddenAccountIds.Contains(a.Id) ? "opacity: 0.5;" : $"background-color: {a.Color}; color: white;")"
title="@(hiddenAccountIds.Contains(a.Id) ? $"Show {a.Name} transactions" : $"Hide {a.Name} transactions")"
<button class="btn btn-sm px-2 py-0 @(_hiddenAccountIds.Contains(a.Id) ? "btn-outline-secondary" : "")"
style="font-size: 0.75rem; @(_hiddenAccountIds.Contains(a.Id) ? "opacity: 0.5;" : $"background-color: {a.Color}; color: white;")"
title="@(_hiddenAccountIds.Contains(a.Id) ? $"Show {a.Name} transactions" : $"Hide {a.Name} transactions")"
@onclick="() => ToggleAccountFilter(a.Id)">
@a.Name
</button>
@@ -174,18 +175,18 @@ else
</div>
}
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
@if (activeTab == "recent")
@if (_activeTab == "recent")
{
@if (filteredRecent.Count == 0)
@if (FilteredRecent.Count == 0)
{
<p class="p-3 text-muted mb-0">No recent transactions.</p>
}
else
{
<div class="list-group list-group-flush">
@foreach (var t in filteredRecent.Skip((recentPage - 1) * PageSize).Take(PageSize))
@foreach (var t in FilteredRecent.Skip((_recentPage - 1) * PageSize).Take(PageSize))
{
<div class="list-group-item list-group-item-action txn-row @(selectedTransactionId == t.Id ? "txn-row-selected" : "")"
<div class="list-group-item list-group-item-action txn-row @(_selectedTransactionId == t.Id ? "txn-row-selected" : "")"
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
@onclick="() => SelectTransaction(t.Id)">
<div class="d-flex justify-content-between align-items-start">
@@ -217,7 +218,7 @@ else
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
</div>
</div>
@if (selectedTransactionId == t.Id)
@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)
@@ -240,25 +241,25 @@ else
@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>
<small class="text-muted">Page @recentPage of @RecentTotalPages</small>
<button class="btn btn-sm btn-outline-secondary" disabled="@(recentPage >= RecentTotalPages ? true : (bool?)null)" @onclick="() => recentPage++">Next →</button>
<button class="btn btn-sm btn-outline-secondary" disabled="@(_recentPage <= 1 ? true : (bool?)null)" @onclick="() => _recentPage--">← Prev</button>
<small class="text-muted">Page @_recentPage of @RecentTotalPages</small>
<button class="btn btn-sm btn-outline-secondary" disabled="@(_recentPage >= RecentTotalPages ? true : (bool?)null)" @onclick="() => _recentPage++">Next →</button>
</div>
}
}
}
else
{
@if (filteredUpcoming.Count == 0)
@if (FilteredUpcoming.Count == 0)
{
<p class="p-3 text-muted mb-0">No upcoming transactions.</p>
}
else
{
<div class="list-group list-group-flush">
@foreach (var t in filteredUpcoming.Skip((upcomingPage - 1) * PageSize).Take(PageSize))
@foreach (var t in FilteredUpcoming.Skip((_upcomingPage - 1) * PageSize).Take(PageSize))
{
<div class="list-group-item list-group-item-action txn-row @(selectedTransactionId == t.Id ? "txn-row-selected" : "")"
<div class="list-group-item list-group-item-action txn-row @(_selectedTransactionId == t.Id ? "txn-row-selected" : "")"
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
@onclick="() => SelectTransaction(t.Id)">
<div class="d-flex justify-content-between align-items-start">
@@ -283,7 +284,7 @@ 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>
@if (projectedBalances.TryGetValue(t.Id, out var projected))
@if (_projectedBalances.TryGetValue(t.Id, out var projected))
{
<small class="@(projected >= 0 ? "text-success" : "text-danger")" style="font-size: 0.7rem;">
Projected: @t.Account.FormatAmount(projected)
@@ -295,7 +296,7 @@ else
}
</div>
</div>
@if (selectedTransactionId == t.Id)
@if (_selectedTransactionId == t.Id)
{
<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>
@@ -311,9 +312,9 @@ else
@if (UpcomingTotalPages > 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="@(upcomingPage <= 1 ? true : (bool?)null)" @onclick="() => upcomingPage--">← Prev</button>
<small class="text-muted">Page @upcomingPage of @UpcomingTotalPages</small>
<button class="btn btn-sm btn-outline-secondary" disabled="@(upcomingPage >= UpcomingTotalPages ? true : (bool?)null)" @onclick="() => upcomingPage++">Next →</button>
<button class="btn btn-sm btn-outline-secondary" disabled="@(_upcomingPage <= 1 ? true : (bool?)null)" @onclick="() => _upcomingPage--">← Prev</button>
<small class="text-muted">Page @_upcomingPage of @UpcomingTotalPages</small>
<button class="btn btn-sm btn-outline-secondary" disabled="@(_upcomingPage >= UpcomingTotalPages ? true : (bool?)null)" @onclick="() => _upcomingPage++">Next →</button>
</div>
}
}
@@ -321,18 +322,18 @@ else
</div>
</div>
@if (showDeleteModal && pendingDeleteTransaction is not null)
@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">@(pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction")</h5>
<h5 class="modal-title">@(_pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction")</h5>
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
</div>
<div class="modal-body">
<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>
}
@@ -343,7 +344,7 @@ else
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
@if (pendingDeleteIsRecurring)
@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>
@@ -360,371 +361,17 @@ else
}
@code {
private DashboardData? data;
private string? userId;
private int? selectedTransactionId;
private string activeTab = "recent";
private Dictionary<int, decimal> projectedBalances = new();
// Modal state
private bool showModal;
private int? editingTransactionId;
private bool editIsPartOfSeries;
private ModalTransactionForm modalForm = new();
// Chart redraw tracking
private int chartDataVersion;
private int lastRenderedChartVersion;
// Time window
private DateTime windowStart = DateTime.Today;
private DateTime windowEnd = DateTime.Today.AddDays(30);
private string windowPreset = "30d";
// Account filter
private HashSet<int> hiddenAccountIds = [];
private string dashboardSearchQuery = string.Empty;
private List<Transaction> filteredRecent => data is null ? []
: data.RecentTransactions
.Where(t => !hiddenAccountIds.Contains(t.AccountId))
.Where(t => MatchesSearch(t))
.ToList();
private List<Transaction> filteredUpcoming => data is null ? []
: data.UpcomingTransactions
.Where(t => !hiddenAccountIds.Contains(t.AccountId))
.Where(t => MatchesSearch(t))
.ToList();
private bool MatchesSearch(Transaction t)
{
if (string.IsNullOrWhiteSpace(dashboardSearchQuery)) return true;
var q = dashboardSearchQuery.Trim();
return 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);
}
private void ToggleAccountFilter(int accountId)
{
if (!hiddenAccountIds.Remove(accountId))
hiddenAccountIds.Add(accountId);
recentPage = 1;
upcomingPage = 1;
}
private void OnDashboardSearchChanged()
{
recentPage = 1;
upcomingPage = 1;
}
// Pagination
private int recentPage = 1;
private int upcomingPage = 1;
private const int PageSize = 10;
private int RecentTotalPages => (int)Math.Ceiling(filteredRecent.Count / (double)PageSize);
private int UpcomingTotalPages => (int)Math.Ceiling(filteredUpcoming.Count / (double)PageSize);
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (userId is not null)
{
await RefreshData();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (data?.ChartData.Datasets.Count > 0 && chartDataVersion != lastRenderedChartVersion)
{
lastRenderedChartVersion = chartDataVersion;
var datasets = data.ChartData.Datasets.Select(ds => new
{
label = ds.Label,
color = ds.Color,
data = ds.Data.Select(d => (double)d).ToArray(),
currencySymbol = ds.CurrencySymbol
}).ToArray();
var dayLabels = data.ChartData.DayTransactionLabels
.Select(d => d.ToArray()).ToArray();
int highlightIndex = GetHighlightIndex();
await JS.InvokeVoidAsync("chartInterop.createOrUpdateChart",
"balanceChart",
data.ChartData.Labels.ToArray(),
datasets,
dayLabels,
highlightIndex,
GetSalaryDayIndices(),
data.CurrencySymbol);
}
}
private int GetHighlightIndex()
{
if (selectedTransactionId is null || data is null) return -1;
var txn = data.RecentTransactions.FirstOrDefault(t => t.Id == selectedTransactionId)
?? data.UpcomingTransactions.FirstOrDefault(t => t.Id == selectedTransactionId);
if (txn is null) return -1;
var label = txn.Date.ToString("MMM dd");
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;
if (data?.ChartData.Datasets.Count > 0)
{
int highlightIndex = GetHighlightIndex();
await JS.InvokeVoidAsync("chartInterop.highlightDate", "balanceChart", highlightIndex);
}
}
private void OpenAddModal()
{
editingTransactionId = null;
modalForm = new ModalTransactionForm
{
AccountId = data?.Accounts.FirstOrDefault()?.Id ?? 0
};
showModal = true;
}
private void OpenEditModal(Transaction t)
{
editingTransactionId = t.Id;
editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
modalForm = new ModalTransactionForm
{
Description = t.Description,
Amount = t.Amount,
Date = t.Date,
Type = t.Type,
AccountId = t.AccountId,
CategoryId = t.CategoryId,
RecurrenceType = t.RecurrenceType,
RecurrenceEndDate = t.RecurrenceEndDate
};
showModal = true;
}
private void CloseModal() => showModal = false;
private async Task SaveModal()
{
if (userId is null) return;
if (editingTransactionId is null)
{
var transaction = new Transaction
{
Description = modalForm.Description,
Amount = modalForm.Amount,
Date = modalForm.Date,
Type = modalForm.Type,
AccountId = modalForm.AccountId,
CategoryId = modalForm.CategoryId,
RecurrenceType = modalForm.RecurrenceType,
RecurrenceEndDate = modalForm.RecurrenceEndDate
};
await TransactionService.CreateTransactionAsync(transaction);
await RefreshData();
// Keep modal open and reset form for the next entry
var previousAccountId = modalForm.AccountId;
var previousCategoryId = modalForm.CategoryId;
var previousType = modalForm.Type;
modalForm = new ModalTransactionForm
{
AccountId = previousAccountId,
CategoryId = previousCategoryId,
Type = previousType
};
}
else
{
await TransactionService.UpdateTransactionAsync(
editingTransactionId.Value, userId,
modalForm.Description, modalForm.Amount, modalForm.Date,
modalForm.Type, modalForm.AccountId, modalForm.CategoryId,
modalForm.RecurrenceType, modalForm.RecurrenceEndDate);
showModal = false;
await RefreshData();
}
}
// Delete confirmation state
private bool showDeleteModal;
private Transaction? pendingDeleteTransaction;
private bool pendingDeleteIsRecurring;
private void ConfirmDelete(Transaction t)
{
pendingDeleteTransaction = t;
pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
showDeleteModal = true;
}
private async Task DeleteSingle()
{
if (userId is null || pendingDeleteTransaction is null) return;
showDeleteModal = false;
await TransactionService.DeleteTransactionAsync(pendingDeleteTransaction.Id, userId);
pendingDeleteTransaction = null;
await RefreshData();
}
private async Task DeleteSeries()
{
if (userId is null || pendingDeleteTransaction is null) return;
showDeleteModal = false;
await TransactionService.DeleteTransactionSeriesAsync(pendingDeleteTransaction.Id, userId);
pendingDeleteTransaction = null;
await RefreshData();
}
private void CancelDelete()
{
showDeleteModal = false;
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)
return t.RecurrenceType.ToString();
return t.ParentTransaction?.RecurrenceType.ToString() ?? "Recurring";
}
private void ComputeProjectedBalances()
{
projectedBalances.Clear();
if (data is null) return;
var accountBalances = data.Accounts.ToDictionary(a => a.Id, a => a.CurrentBalance);
foreach (var t in data.UpcomingTransactions)
{
if (!accountBalances.ContainsKey(t.AccountId)) continue;
accountBalances[t.AccountId] += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
projectedBalances[t.Id] = accountBalances[t.AccountId];
}
}
private async Task RefreshData()
{
if (userId is null) return;
await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd);
data = await DashboardService.GetDashboardDataAsync(userId, windowStart, windowEnd);
chartDataVersion++;
ComputeProjectedBalances();
recentPage = 1;
upcomingPage = 1;
}
private async Task SetWindow(int days)
{
windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => "custom" };
windowStart = DateTime.Today;
windowEnd = DateTime.Today.AddDays(days);
await RefreshData();
}
private async Task OnWindowDateChanged()
{
windowPreset = "custom";
await RefreshData();
}
public async ValueTask DisposeAsync()
{
try
{
await JS.InvokeVoidAsync("chartInterop.destroyChart", "balanceChart");
}
catch (JSDisconnectedException)
{
}
}
private sealed class ModalTransactionForm : 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)decimal.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)]);
}
}
}
}
}