380 lines
13 KiB
C#
380 lines
13 KiB
C#
using BudgetApp.Data.Entities;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.JSInterop;
|
|
|
|
namespace BudgetApp.Components.Pages;
|
|
|
|
public partial class Transactions
|
|
{
|
|
private List<Transaction>? _transactions;
|
|
private List<Account>? _accountsList;
|
|
private List<Category>? _categoriesList;
|
|
private TransactionForm _formModel = new();
|
|
private string? _userId;
|
|
private int? _filterAccountId;
|
|
private string _searchQuery = string.Empty;
|
|
private DateTime _windowStart = DateTime.Today;
|
|
private DateTime _windowEnd = DateTime.Today.AddDays(30);
|
|
private string? _windowPreset = "30d";
|
|
private int _currentPage = 1;
|
|
private int _pageSize = 25;
|
|
private static readonly int[] PageSizeOptions = [10, 25, 50, 100];
|
|
private int TotalPages => (int)Math.Ceiling(FilteredTransactions.Count / (double)_pageSize);
|
|
|
|
private List<Transaction> PagedTransactions => FilteredTransactions
|
|
.Skip((_currentPage - 1) * _pageSize)
|
|
.Take(_pageSize)
|
|
.ToList();
|
|
|
|
private string _sortColumn = "date";
|
|
private bool _sortAscending = true;
|
|
|
|
private List<Transaction> FilteredTransactions
|
|
{
|
|
get
|
|
{
|
|
var list = (_transactions ?? []).Where(t =>
|
|
t.Date.Date >= _windowStart.Date && t.Date.Date <= _windowEnd.Date);
|
|
if (_filterAccountId is not null)
|
|
list = list.Where(t => t.AccountId == _filterAccountId);
|
|
|
|
if (!string.IsNullOrWhiteSpace(_searchQuery))
|
|
{
|
|
var q = _searchQuery.Trim();
|
|
list = list.Where(t =>
|
|
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));
|
|
}
|
|
|
|
list = _sortColumn switch
|
|
{
|
|
"description" => _sortAscending
|
|
? list.OrderBy(t => t.Description, StringComparer.OrdinalIgnoreCase)
|
|
: list.OrderByDescending(t => t.Description, StringComparer.OrdinalIgnoreCase),
|
|
"amount" => _sortAscending
|
|
? list.OrderBy(t => t.Amount)
|
|
: list.OrderByDescending(t => t.Amount),
|
|
_ => _sortAscending
|
|
? list.OrderBy(t => t.Date)
|
|
: list.OrderByDescending(t => t.Date),
|
|
};
|
|
|
|
return list.ToList();
|
|
}
|
|
}
|
|
|
|
private void OnAccountFilterChanged(ChangeEventArgs e)
|
|
{
|
|
_filterAccountId = int.TryParse(e.Value?.ToString(), out var id) ? id : null;
|
|
_currentPage = 1;
|
|
}
|
|
|
|
private void OnSearchChanged() => _currentPage = 1;
|
|
|
|
private async Task SetWindow(int days)
|
|
{
|
|
_windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null };
|
|
_windowStart = DateTime.Today;
|
|
_windowEnd = DateTime.Today.AddDays(days);
|
|
_currentPage = 1;
|
|
await LoadData();
|
|
}
|
|
|
|
private async Task OnWindowDateChanged()
|
|
{
|
|
_windowPreset = null;
|
|
_currentPage = 1;
|
|
await LoadData();
|
|
}
|
|
|
|
private void OnPageSizeChanged(ChangeEventArgs e)
|
|
{
|
|
if (int.TryParse(e.Value?.ToString(), out var size))
|
|
{
|
|
_pageSize = size;
|
|
_currentPage = 1;
|
|
}
|
|
}
|
|
|
|
private void SetSort(string column)
|
|
{
|
|
if (_sortColumn == column)
|
|
_sortAscending = !_sortAscending;
|
|
else
|
|
{
|
|
_sortColumn = column;
|
|
_sortAscending = true;
|
|
}
|
|
|
|
_currentPage = 1;
|
|
}
|
|
|
|
private MarkupString SortIndicator(string column)
|
|
{
|
|
if (_sortColumn != column)
|
|
return new MarkupString("<span class=\"text-muted opacity-50\">⇅</span>");
|
|
return new MarkupString(_sortAscending ? "▲" : "▼");
|
|
}
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
await LoadData();
|
|
}
|
|
|
|
private async Task LoadData()
|
|
{
|
|
if (_userId is null) return;
|
|
|
|
await SalaryService.MaterializeSalaryTransactionsAsync(_userId, _windowEnd);
|
|
_transactions = await TransactionService.GetTransactionsAsync(_userId);
|
|
_accountsList = await AccountService.GetAccountsAsync(_userId);
|
|
_categoriesList = await CategoryService.GetCategoriesAsync(_userId);
|
|
|
|
if (_accountsList.Count > 0 && _formModel.AccountId == 0)
|
|
{
|
|
_formModel.AccountId = _accountsList[0].Id;
|
|
}
|
|
}
|
|
|
|
private async Task AddTransaction()
|
|
{
|
|
if (_userId is null) return;
|
|
|
|
var transaction = new Transaction
|
|
{
|
|
Description = _formModel.Description,
|
|
Amount = _formModel.Amount,
|
|
Date = _formModel.Date,
|
|
Type = _formModel.Type,
|
|
AccountId = _formModel.AccountId,
|
|
CategoryId = _formModel.CategoryId,
|
|
RecurrenceType = _formModel.RecurrenceType,
|
|
RecurrenceEndDate = _formModel.RecurrenceEndDate
|
|
};
|
|
await TransactionService.CreateTransactionAsync(transaction);
|
|
_formModel = new TransactionForm
|
|
{
|
|
AccountId = _accountsList?.FirstOrDefault()?.Id ?? 0
|
|
};
|
|
await LoadData();
|
|
}
|
|
|
|
private readonly HashSet<int> _selectedIds = [];
|
|
|
|
private void ToggleSelect(int id)
|
|
{
|
|
var t = _transactions?.FirstOrDefault(x => x.Id == id);
|
|
if (t?.SalaryId is not null) return;
|
|
if (!_selectedIds.Remove(id))
|
|
_selectedIds.Add(id);
|
|
}
|
|
|
|
private bool IsAllPageSelected()
|
|
{
|
|
var selectable = PagedTransactions.Where(t => t.SalaryId is null).ToList();
|
|
return selectable.Count > 0 && selectable.All(t => _selectedIds.Contains(t.Id));
|
|
}
|
|
|
|
private void ToggleSelectAll(ChangeEventArgs e)
|
|
{
|
|
var selectable = PagedTransactions.Where(t => t.SalaryId is null).ToList();
|
|
if (e.Value is true)
|
|
{
|
|
foreach (var t in selectable)
|
|
_selectedIds.Add(t.Id);
|
|
}
|
|
else
|
|
{
|
|
foreach (var t in selectable)
|
|
_selectedIds.Remove(t.Id);
|
|
}
|
|
}
|
|
|
|
private bool _showDeleteModal;
|
|
private Transaction? _pendingDeleteTransaction;
|
|
private bool _pendingDeleteIsRecurring;
|
|
private int _bulkDeleteCount;
|
|
private string _deleteModalTitle = "Delete Transaction";
|
|
private bool _showEditModal;
|
|
private int? _editingTransactionId;
|
|
private Transaction? _editingTransaction;
|
|
private bool _editIsPartOfSeries;
|
|
private TransactionForm _editModel = new();
|
|
|
|
private void OpenEditModal(Transaction t)
|
|
{
|
|
_editingTransactionId = t.Id;
|
|
_editingTransaction = t;
|
|
_editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
|
_editModel = new TransactionForm
|
|
{
|
|
Description = t.Description,
|
|
Amount = t.Amount,
|
|
Date = t.Date,
|
|
Type = t.Type,
|
|
AccountId = t.AccountId,
|
|
CategoryId = t.CategoryId,
|
|
RecurrenceType = t.RecurrenceType,
|
|
RecurrenceEndDate = t.RecurrenceEndDate
|
|
};
|
|
_showEditModal = true;
|
|
}
|
|
|
|
private void CloseEditModal()
|
|
{
|
|
_showEditModal = false;
|
|
_editingTransaction = null;
|
|
}
|
|
|
|
private async Task SaveEdit()
|
|
{
|
|
if (_userId is null || _editingTransactionId is null || _editingTransaction?.SalaryId is not null) return;
|
|
|
|
await TransactionService.UpdateTransactionAsync(
|
|
_editingTransactionId.Value, _userId,
|
|
_editModel.Description, _editModel.Amount, _editModel.Date,
|
|
_editModel.Type, _editModel.AccountId, _editModel.CategoryId,
|
|
_editModel.RecurrenceType, _editModel.RecurrenceEndDate);
|
|
|
|
_showEditModal = false;
|
|
_editingTransactionId = null;
|
|
await LoadData();
|
|
}
|
|
|
|
private void ConfirmDelete(Transaction t)
|
|
{
|
|
_pendingDeleteTransaction = t;
|
|
_pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
|
_bulkDeleteCount = 0;
|
|
_deleteModalTitle = _pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction";
|
|
_showDeleteModal = true;
|
|
}
|
|
|
|
private void ConfirmBulkDelete()
|
|
{
|
|
_pendingDeleteTransaction = null;
|
|
_pendingDeleteIsRecurring = false;
|
|
_bulkDeleteCount = _selectedIds.Count;
|
|
_deleteModalTitle = $"Delete {_bulkDeleteCount} Transactions";
|
|
_showDeleteModal = true;
|
|
}
|
|
|
|
private async Task DeleteSingle()
|
|
{
|
|
if (_userId is null || _pendingDeleteTransaction is null) return;
|
|
_showDeleteModal = false;
|
|
await TransactionService.DeleteTransactionAsync(_pendingDeleteTransaction.Id, _userId);
|
|
_selectedIds.Remove(_pendingDeleteTransaction.Id);
|
|
_pendingDeleteTransaction = null;
|
|
await LoadData();
|
|
}
|
|
|
|
private async Task DeleteSeries()
|
|
{
|
|
if (_userId is null || _pendingDeleteTransaction is null) return;
|
|
_showDeleteModal = false;
|
|
await TransactionService.DeleteTransactionSeriesAsync(_pendingDeleteTransaction.Id, _userId);
|
|
_selectedIds.Remove(_pendingDeleteTransaction.Id);
|
|
_pendingDeleteTransaction = null;
|
|
await LoadData();
|
|
}
|
|
|
|
private async Task ExecuteBulkDelete()
|
|
{
|
|
if (_userId is null) return;
|
|
_showDeleteModal = false;
|
|
foreach (var id in _selectedIds.ToList())
|
|
{
|
|
var t = _transactions?.FirstOrDefault(x => x.Id == id);
|
|
if (t?.SalaryId is not null) continue;
|
|
await TransactionService.DeleteTransactionAsync(id, _userId);
|
|
}
|
|
|
|
_selectedIds.Clear();
|
|
await LoadData();
|
|
}
|
|
|
|
private void CancelDelete()
|
|
{
|
|
_showDeleteModal = false;
|
|
_pendingDeleteTransaction = null;
|
|
_bulkDeleteCount = 0;
|
|
}
|
|
|
|
private async Task TogglePaid(Transaction t)
|
|
{
|
|
if (_userId is null) return;
|
|
await TransactionService.MarkAsPaidAsync(t.Id, _userId, !t.IsPaid);
|
|
await LoadData();
|
|
}
|
|
|
|
private async Task ExportCsv()
|
|
{
|
|
var sb = new System.Text.StringBuilder();
|
|
sb.AppendLine("Date,Description,Type,Amount,Account,Category,Recurrence");
|
|
foreach (var t in FilteredTransactions)
|
|
{
|
|
var category = t.Category?.Name ?? "";
|
|
var recurrence = t.RecurrenceType != RecurrenceType.None ? t.RecurrenceType.ToString() : "";
|
|
sb.AppendLine($"{t.Date:yyyy-MM-dd},{CsvEscape(t.Description)},{t.Type},{t.Amount:F2},{CsvEscape(t.Account.Name)},{CsvEscape(category)},{recurrence}");
|
|
}
|
|
var filename = $"transactions_{DateTime.Today:yyyy-MM-dd}.csv";
|
|
await JS.InvokeVoidAsync("downloadCsv", filename, sb.ToString());
|
|
}
|
|
|
|
private static string CsvEscape(string value)
|
|
{
|
|
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
|
return $"\"{value.Replace("\"", "\"\"\"")}\"";
|
|
return value;
|
|
}
|
|
|
|
private sealed class TransactionForm
|
|
{
|
|
[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.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)]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |