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 _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 _hiddenAccountIds = []; private string _dashboardSearchQuery = string.Empty; private List FilteredRecent => _data is null ? [] : _data.RecentTransactions .Where(t => !_hiddenAccountIds.Contains(t.AccountId)) .Where(MatchesSearch) .ToList(); private List 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); private DateTime? _nextPayday; private Dictionary _predictedPaydayBalances = new(); private IEnumerable> GroupedRecentPage => FilteredRecent .Skip((_recentPage - 1) * PageSize) .Take(PageSize) .GroupBy(t => t.Date.Date) .OrderByDescending(g => g.Key); private IEnumerable> GroupedUpcomingPage => FilteredUpcoming .Skip((_upcomingPage - 1) * PageSize) .Take(PageSize) .GroupBy(t => t.Date.Date) .OrderBy(g => g.Key); private static IOrderedEnumerable OrderGroupByIncomeFirst(IGrouping group) => group.OrderByDescending(t => t.Type == TransactionType.Income).ThenBy(t => t.Description); 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) { var type = t.RecurrenceType != RecurrenceType.None ? t.RecurrenceType : (t.ParentTransaction?.RecurrenceType ?? RecurrenceType.None); return type switch { RecurrenceType.FourWeekly => "4 Weekly", _ => type.ToString() }; } 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; var materializeEnd = _windowEnd > DateTime.Today.AddDays(90) ? _windowEnd : DateTime.Today.AddDays(90); await SalaryService.MaterializeSalaryTransactionsAsync(_userId, materializeEnd); _data = await DashboardService.GetDashboardDataAsync(_userId, _windowStart, _windowEnd); _chartDataVersion++; ComputeProjectedBalances(); await ComputeNextPaydayBalance(); _recentPage = 1; _upcomingPage = 1; } private async Task ComputeNextPaydayBalance() { _nextPayday = null; _predictedPaydayBalances.Clear(); if (_data is null || _userId is null) return; var salaries = await SalaryService.GetSalariesAsync(_userId); DateTime? soonest = null; foreach (var s in salaries) { var next = await SalaryService.GetNextPaydateAsync(s.Id); if (next.HasValue && next.Value.Date > DateTime.Today) { if (soonest is null || next.Value.Date < soonest.Value.Date) soonest = next.Value.Date; } } _nextPayday = soonest; if (_nextPayday is null) return; var txnsToPayday = await TransactionService.GetTransactionsInRangeAsync( _userId, DateTime.Today.AddDays(1), _nextPayday.Value.AddDays(-1)); foreach (var account in _data.Accounts) { var balance = account.CurrentBalance; foreach (var t in txnsToPayday.Where(t => t.AccountId == account.Id && !t.IsPaid)) balance += t.Type == TransactionType.Income ? t.Amount : -t.Amount; _predictedPaydayBalances[account.Id] = balance; } } 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 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)]); } } } } }