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

+371
View File
@@ -0,0 +1,371 @@
using BudgetApp.Data.Entities;
using BudgetApp.Services.Models;
using Microsoft.JSInterop;
namespace BudgetApp.Components.Pages;
public partial class Home
{
private DashboardData? _data;
private string? _userId;
private int? _selectedTransactionId;
private string _activeTab = "recent";
private readonly Dictionary<int, decimal> _projectedBalances = new();
private bool _showModal;
private int? _editingTransactionId;
private bool _editIsPartOfSeries;
private ModalTransactionForm _modalForm = new();
private int _chartDataVersion;
private int _lastRenderedChartVersion;
private DateTime _windowStart = DateTime.Today;
private DateTime _windowEnd = DateTime.Today.AddDays(30);
private string _windowPreset = "30d";
private readonly HashSet<int> _hiddenAccountIds = [];
private string _dashboardSearchQuery = string.Empty;
private List<Transaction> FilteredRecent => _data is null
? []
: _data.RecentTransactions
.Where(t => !_hiddenAccountIds.Contains(t.AccountId))
.Where(MatchesSearch)
.ToList();
private List<Transaction> FilteredUpcoming => _data is null
? []
: _data.UpcomingTransactions
.Where(t => !_hiddenAccountIds.Contains(t.AccountId))
.Where(MatchesSearch)
.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;
}
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();
}
}
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)]);
}
}
}
}
}