Some checks failed
Build Linux / build (push) Failing after 24s
Features Analytics Page: Full-featured analytics dashboard with KPI cards, cash flow trend chart, net worth progression, spending patterns by day-of-week, top spending categories, and income sources breakdown. Includes PDF export via QuestPDF for selected periods. Implemented on both desktop and mobile (simplified). Auth Error Handling: Map Supabase GotrueException errors to AuthError enum with user-friendly messages for login and signup. Display errors in sign-in and sign-up panels. Dynamic Transaction/Account Counts: Replace hardcoded "46 transactions" and "4 accounts" text with FilteredTransactionCount and ActiveAccountCount properties bound to actual data. Fixes Budget Period Navigation: Fix year-aware date comparison in CanGoToPreviousPeriod and CanGoToNextPeriod. Previously only compared months, preventing navigation before January of current year. Changes AnalyticsViewModel: Period selector, KPI calculations, chart data builders (cash flow, net worth, day-of-week, top categories, income sources), PDF export PdfExportService: QuestPDF report generation with print-optimized styling AuthViewModel: Error display with GotrueException mapping BudgetViewModel: Year-aware period navigation TransactionsViewModel: FilteredTransactionCount property AccountsViewModel: ActiveAccountCount property MainViewModel: Analytics navigation and AnalyticsViewModel integration Views: Analytics button wired, error messages displayed, count bindings updated
412 lines
15 KiB
C#
412 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using Clario.Data;
|
|
using Clario.Messages;
|
|
using Clario.Models;
|
|
using Clario.Services;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
|
|
// ReSharper disable PossibleMultipleEnumeration
|
|
|
|
namespace Clario.ViewModels;
|
|
|
|
public partial class TransactionsViewModel : ViewModelBase
|
|
{
|
|
public required ViewModelBase parentViewModel;
|
|
public GeneralDataRepo AppData => DataRepo.General;
|
|
|
|
[ObservableProperty] private ObservableCollection<Category> _categories = new();
|
|
[ObservableProperty] private ObservableCollection<Account> _accounts = new();
|
|
|
|
[ObservableProperty] [NotifyPropertyChangedFor(nameof(FilteredTransactionCount))]
|
|
private List<Transaction> _filteredTransactions = new();
|
|
|
|
public int FilteredTransactionCount => _filteredTransactions.Count;
|
|
|
|
private int _pageSize = 25;
|
|
[ObservableProperty] private int _pageSizeIndex;
|
|
|
|
[ObservableProperty] [NotifyPropertyChangedFor(nameof(TotalPages))] [NotifyCanExecuteChangedFor(nameof(NextPageCommand), nameof(PreviousPageCommand))]
|
|
private int _currentPage = 1;
|
|
|
|
[ObservableProperty] private string _paginationSummaryText;
|
|
|
|
[ObservableProperty] private ObservableCollection<Transaction> _pagedTransactions = new();
|
|
|
|
[ObservableProperty] private ObservableCollection<string> _sortOptions = new()
|
|
{
|
|
"Date — Newest first",
|
|
"Date — Oldest first",
|
|
"Amount — High to low",
|
|
"Amount — Low to high",
|
|
"Category A → Z"
|
|
};
|
|
|
|
[ObservableProperty] private ObservableCollection<string> _DateRangeOptions = new()
|
|
{
|
|
"All Time",
|
|
"Today",
|
|
"This Week",
|
|
"This Month",
|
|
"Last Month",
|
|
"This Quarter",
|
|
"This Year",
|
|
"Custom Range"
|
|
};
|
|
|
|
public List<int> PageNumbers { get; set; }
|
|
[ObservableProperty] private ObservableCollection<int> _visiblePageNumbers = new();
|
|
public int TotalPages => (int)Math.Ceiling(FilteredTransactions.Count / (double)_pageSize);
|
|
public bool HasNoTransactions => FilteredTransactions.Count == 0;
|
|
public bool HasNextPage => CurrentPage < TotalPages;
|
|
public bool HasPreviousPage => CurrentPage > 1;
|
|
|
|
[ObservableProperty] private double _totalExpenses;
|
|
[ObservableProperty] private double _totalIncome;
|
|
[ObservableProperty] private int _expensesCount;
|
|
[ObservableProperty] private int _incomeCount;
|
|
[ObservableProperty] private string _dateRangeLabel = "";
|
|
|
|
public string PrimaryCurrencySymbol =>
|
|
CurrencyService.GetSymbol(AppData.PrimaryAccount?.Currency ?? AppData.Profile?.Currency ?? "USD");
|
|
|
|
[ObservableProperty] private string _searchText = "";
|
|
[ObservableProperty] private Category _selectedCategory;
|
|
[ObservableProperty] private Account _selectedAccount;
|
|
[ObservableProperty] private string _selectedSortOption;
|
|
[ObservableProperty] private string _selectedDateRangeOption;
|
|
|
|
[ObservableProperty] private List<DateTime>? _selectedDates = new()
|
|
{
|
|
new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1),
|
|
new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.DaysInMonth(DateTime.Now.Year, DateTime.Now.Month))
|
|
};
|
|
|
|
[ObservableProperty] [NotifyPropertyChangedFor(nameof(FilterTypeAll), nameof(FilterTypeIncome), nameof(FilterTypeExpense), nameof(FilterTypeTransfer))]
|
|
private string _transactionType = "all";
|
|
|
|
|
|
public TransactionsViewModel()
|
|
{
|
|
AppData.Transactions.CollectionChanged += (_, _) =>
|
|
{
|
|
InitializeCategories();
|
|
InitializeAccounts();
|
|
LoadPage(1);
|
|
};
|
|
WeakReferenceMessenger.Default.Register<RatesRefreshed>(this, (_, _) => LoadPage(CurrentPage));
|
|
Initialize();
|
|
}
|
|
|
|
partial void OnPageSizeIndexChanged(int value)
|
|
{
|
|
_pageSize = value switch
|
|
{
|
|
0 => 25,
|
|
1 => 50,
|
|
2 => 100,
|
|
_ => 25
|
|
};
|
|
|
|
|
|
LoadPage(1);
|
|
OnPropertyChanged(nameof(HasNextPage));
|
|
OnPropertyChanged(nameof(HasPreviousPage));
|
|
}
|
|
|
|
|
|
partial void OnCurrentPageChanged(int value)
|
|
{
|
|
LoadPage(value);
|
|
OnPropertyChanged(nameof(HasNextPage));
|
|
OnPropertyChanged(nameof(HasPreviousPage));
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void LoadPageStr(string page)
|
|
{
|
|
LoadPage(int.Parse(page));
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void LoadPage(int page)
|
|
{
|
|
ApplyFilters();
|
|
if (CurrentPage != page) CurrentPage = page;
|
|
var items = FilteredTransactions.Skip((page - 1) * _pageSize)
|
|
.Take(_pageSize);
|
|
|
|
OnPropertyChanged(nameof(HasNoTransactions));
|
|
PagedTransactions.Clear();
|
|
foreach (var item in items)
|
|
PagedTransactions.Add(item);
|
|
PaginationSummaryText =
|
|
$"Showing {((page - 1) * _pageSize) + 1}-{(Math.Min(page * _pageSize, FilteredTransactions.Count))} of {FilteredTransactions.Count} transactions";
|
|
PageNumbers = Enumerable.Range(1, Math.Min(TotalPages, 5)).ToList();
|
|
var numbers = GetSurrounding(PageNumbers, page);
|
|
VisiblePageNumbers.Clear();
|
|
foreach (var number in numbers)
|
|
VisiblePageNumbers.Add(number);
|
|
WeakReferenceMessenger.Default.Send(new TransactionsScrollToTop());
|
|
GroupTransactions();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ApplyFilters()
|
|
{
|
|
var filtered = AppData.Transactions.Where(x =>
|
|
x.Type != "transfer_in" &&
|
|
(x.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|
|
|| x.Note!.Contains(SearchText, StringComparison.OrdinalIgnoreCase)));
|
|
|
|
var culture = new CultureInfo("en-US");
|
|
|
|
switch (SelectedDateRangeOption)
|
|
{
|
|
case "All Time":
|
|
DateRangeLabel = "ALL TIME";
|
|
break;
|
|
case "Today":
|
|
filtered = filtered.Where(x => x.Date == DateTime.Now.Date);
|
|
DateRangeLabel = DateTime.Now.ToString("MMM d, yyyy", culture).ToUpper();
|
|
break;
|
|
case "This Week":
|
|
var startOfWeek = DateTime.Now.Date.AddDays(-(int)DateTime.Now.DayOfWeek);
|
|
var endOfWeek = startOfWeek.AddDays(6);
|
|
filtered = filtered.Where(x => x.Date.Date >= startOfWeek && x.Date.Date <= endOfWeek);
|
|
DateRangeLabel = "THIS WEEK";
|
|
break;
|
|
case "This Month":
|
|
filtered = filtered.Where(x => x.Date.Month == DateTime.Now.Month);
|
|
DateRangeLabel = DateTime.Now.ToString("MMMM yyyy", culture).ToUpper();
|
|
break;
|
|
case "Last Month":
|
|
var lastMonth = DateTime.Now.AddMonths(-1);
|
|
filtered = filtered.Where(x => x.Date.Month == lastMonth.Month && x.Date.Year == lastMonth.Year);
|
|
DateRangeLabel = lastMonth.ToString("MMMM yyyy", culture).ToUpper();
|
|
break;
|
|
case "This Quarter":
|
|
var startOfQuarter = DateTime.Now.AddMonths(-(DateTime.Now.Month - 1) % 3);
|
|
var endOfQuarter = startOfQuarter.AddMonths(3);
|
|
filtered = filtered.Where(x => x.Date >= startOfQuarter && x.Date <= endOfQuarter);
|
|
DateRangeLabel = $"Q{(DateTime.Now.Month - 1) / 3 + 1} {DateTime.Now.Year}";
|
|
break;
|
|
case "This Year":
|
|
filtered = filtered.Where(x => x.Date.Year == DateTime.Now.Year);
|
|
DateRangeLabel = DateTime.Now.Year.ToString();
|
|
break;
|
|
case "Custom Range":
|
|
if (SelectedDates is not null && SelectedDates.Count > 0)
|
|
{
|
|
var ordered = SelectedDates
|
|
.Select(d => d.Date)
|
|
.Distinct()
|
|
.OrderBy(d => d)
|
|
.ToList();
|
|
|
|
var start = ordered.First();
|
|
var end = ordered.Last();
|
|
|
|
if (SelectedDates.Count == 1)
|
|
{
|
|
filtered = filtered.Where(x => x.Date.Date == start);
|
|
DateRangeLabel = start.ToString("MMM dd, yyyy", culture).ToUpper();
|
|
}
|
|
else
|
|
{
|
|
filtered = filtered.Where(x => x.Date.Date >= start && x.Date.Date <= end);
|
|
DateRangeLabel = $"{start.ToString("MMM dd", culture)} - {end.ToString("MMM dd, yyyy", culture)}".ToUpper();
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
|
|
// Calculate totals based on date-filtered transactions (transfers excluded)
|
|
TotalExpenses = filtered.Where(x => x.Type == "expense").Sum(x => Convert.ToDouble(x.ConvertedAmount));
|
|
TotalIncome = filtered.Where(x => x.Type == "income").Sum(x => Convert.ToDouble(x.ConvertedAmount));
|
|
|
|
if (SelectedCategory.Name != "All Categories")
|
|
filtered = filtered.Where(x => x.CategoryId == SelectedCategory.Id);
|
|
|
|
if (SelectedAccount.Name != "All Accounts")
|
|
filtered = filtered.Where(x => x.AccountId == SelectedAccount.Id);
|
|
|
|
if (TransactionType == "income")
|
|
filtered = filtered.Where(x => x.Type == "income");
|
|
else if (TransactionType == "expense")
|
|
filtered = filtered.Where(x => x.Type == "expense");
|
|
else if (TransactionType == "transfer")
|
|
filtered = filtered.Where(x => x.IsTransfer);
|
|
|
|
switch (SelectedSortOption)
|
|
{
|
|
case "Date — Newest first":
|
|
filtered = filtered.OrderByDescending(x => x.Date);
|
|
break;
|
|
case "Date — Oldest first":
|
|
filtered = filtered.OrderBy(x => x.Date);
|
|
break;
|
|
case "Amount — High to low":
|
|
filtered = filtered.OrderByDescending(x => x.Amount);
|
|
break;
|
|
case "Amount — Low to high":
|
|
filtered = filtered.OrderBy(x => x.Amount);
|
|
break;
|
|
case "Category A → Z":
|
|
filtered = filtered.OrderBy(x => x.Category?.Name);
|
|
break;
|
|
}
|
|
|
|
|
|
FilteredTransactions = filtered.ToList();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ResetFilters()
|
|
{
|
|
SearchText = "";
|
|
SelectedCategory = Categories.First();
|
|
SelectedAccount = Accounts.First();
|
|
TransactionType = "all";
|
|
SelectedSortOption = SortOptions.First();
|
|
SelectedDateRangeOption = DateRangeOptions.First();
|
|
LoadPage(1);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void SetTransactionType(string type)
|
|
{
|
|
TransactionType = type;
|
|
}
|
|
|
|
public bool FilterTypeAll => TransactionType == "all";
|
|
public bool FilterTypeIncome => TransactionType == "income";
|
|
public bool FilterTypeExpense => TransactionType == "expense";
|
|
public bool FilterTypeTransfer => TransactionType == "transfer";
|
|
|
|
[RelayCommand(CanExecute = nameof(HasNextPage))]
|
|
private void NextPage()
|
|
{
|
|
if (CurrentPage < TotalPages) CurrentPage++;
|
|
}
|
|
|
|
[RelayCommand(CanExecute = nameof(HasPreviousPage))]
|
|
private void PreviousPage()
|
|
{
|
|
if (CurrentPage > 1) CurrentPage--;
|
|
}
|
|
|
|
private void GroupTransactions()
|
|
{
|
|
var ToRemove = PagedTransactions.Where(x => x.GroupHeader).ToList();
|
|
foreach (var item in ToRemove)
|
|
{
|
|
PagedTransactions.Remove(item);
|
|
}
|
|
|
|
var dates = PagedTransactions
|
|
.Where(x => !x.GroupHeader)
|
|
.Select(x => x.Date.Date) // strip time
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
foreach (var date in dates)
|
|
{
|
|
var index = PagedTransactions.IndexOf(PagedTransactions.First(x => x.Date.Date == date && !x.GroupHeader));
|
|
string label;
|
|
var culture = new CultureInfo("en-US");
|
|
if (date.Date == DateTime.Now.Date) label = "Today - " + date.ToString("MMM dd", culture);
|
|
else if (date.Date == DateTime.Now.AddDays(-1).Date) label = "Yesterday - " + date.ToString("MMM dd", culture);
|
|
else label = date.ToString("MMM dd, yyyy", culture);
|
|
var header = new Transaction { Description = label, Date = date, GroupHeader = true };
|
|
|
|
PagedTransactions.Insert(index, header);
|
|
}
|
|
}
|
|
|
|
public void Initialize()
|
|
{
|
|
try
|
|
{
|
|
InitializeCategories();
|
|
InitializeAccounts();
|
|
|
|
CalculateMonthlyFinancials();
|
|
|
|
CurrentPage = 1;
|
|
OnPropertyChanged(nameof(TotalPages));
|
|
ResetFilters();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
DebugLogger.Log(e);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private void InitializeCategories()
|
|
{
|
|
Categories.Clear();
|
|
Categories.Insert(0, new Category() { Name = "All Categories" });
|
|
foreach (var appDataCategory in AppData.Categories)
|
|
{
|
|
Categories.Add(appDataCategory);
|
|
}
|
|
|
|
SelectedCategory = Categories.First();
|
|
}
|
|
|
|
private void InitializeAccounts()
|
|
{
|
|
Accounts.Clear();
|
|
Accounts.Insert(0, new Account() { Name = "All Accounts" });
|
|
foreach (var appDataAccount in AppData.Accounts)
|
|
{
|
|
Accounts.Add(appDataAccount);
|
|
}
|
|
|
|
SelectedAccount = Accounts.First();
|
|
}
|
|
|
|
private void CalculateMonthlyFinancials()
|
|
{
|
|
TotalExpenses = AppData.Transactions.Where(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.ConvertedAmount));
|
|
TotalIncome = AppData.Transactions.Where(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month).Sum(x => Convert.ToDouble(x.ConvertedAmount));
|
|
ExpensesCount = AppData.Transactions.Count(x => x.Type == "expense" && x.Date.Month == DateTime.Now.Month);
|
|
IncomeCount = AppData.Transactions.Count(x => x.Type == "income" && x.Date.Month == DateTime.Now.Month);
|
|
}
|
|
|
|
public static List<T> GetSurrounding<T>(List<T> list, T item, int count = 5)
|
|
{
|
|
var index = list.IndexOf(item);
|
|
if (index == -1) return new List<T>();
|
|
|
|
var half = count / 2;
|
|
var start = Math.Max(0, index - half);
|
|
var end = Math.Min(list.Count, start + count);
|
|
|
|
// shift start back if end hit the boundary
|
|
start = Math.Max(0, end - count);
|
|
|
|
return list.GetRange(start, end - start);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void CreateTransaction()
|
|
{
|
|
((MainViewModel)parentViewModel).OpenAddTransaction();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void EditTransaction(Transaction transaction)
|
|
{
|
|
((MainViewModel)parentViewModel).OpenEditTransaction(transaction);
|
|
}
|
|
} |