430 lines
15 KiB
C#
430 lines
15 KiB
C#
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);
|
|
|
|
private DateTime? _nextPayday;
|
|
private Dictionary<int, decimal> _predictedPaydayBalances = new();
|
|
|
|
private IEnumerable<IGrouping<DateTime, Transaction>> GroupedRecentPage =>
|
|
FilteredRecent
|
|
.Skip((_recentPage - 1) * PageSize)
|
|
.Take(PageSize)
|
|
.GroupBy(t => t.Date.Date)
|
|
.OrderByDescending(g => g.Key);
|
|
|
|
private IEnumerable<IGrouping<DateTime, Transaction>> GroupedUpcomingPage =>
|
|
FilteredUpcoming
|
|
.Skip((_upcomingPage - 1) * PageSize)
|
|
.Take(PageSize)
|
|
.GroupBy(t => t.Date.Date)
|
|
.OrderBy(g => g.Key);
|
|
|
|
private static IOrderedEnumerable<Transaction> OrderGroupByIncomeFirst(IGrouping<DateTime, Transaction> 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<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)]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |