Files
Budgeteer/BudgetApp/Components/Pages/Home.razor
T
2026-03-01 21:47:44 +00:00

650 lines
31 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/"
@attribute [Authorize]
@inject DashboardService DashboardService
@inject TransactionService TransactionService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@implements IAsyncDisposable
@rendermode InteractiveServer
<PageTitle>Dashboard</PageTitle>
<h3 class="mb-4">Dashboard</h3>
@if (data is null)
{
<p><em>Loading...</em></p>
}
else if (data.Accounts.Count == 0)
{
<div class="alert alert-info">
<h5>Welcome!</h5>
<p>Get started by <a href="/accounts">adding an account</a> and then <a href="/transactions">recording transactions</a>.</p>
</div>
}
else
{
<div class="card mb-4">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<h5 class="mb-0">Balance Trend</h5>
<div class="d-flex flex-wrap align-items-center gap-2">
<div class="btn-group btn-group-sm">
<button class="btn @(windowPreset == "7d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetWindow(7)">7d</button>
<button class="btn @(windowPreset == "30d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetWindow(30)">30d</button>
<button class="btn @(windowPreset == "90d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetWindow(90)">90d</button>
<button class="btn @(windowPreset == "1y" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetWindow(365)">1y</button>
</div>
<input type="date" class="form-control form-control-sm date-picker"
@bind="windowStart" @bind:after="OnWindowDateChanged" />
<span class="text-muted">to</span>
<input type="date" class="form-control form-control-sm date-picker"
@bind="windowEnd" @bind:after="OnWindowDateChanged" />
</div>
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="balanceChart"></canvas>
</div>
</div>
</div>
@* ── Edit / Add Modal ── *@
@if (showModal)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(editingTransactionId is null ? "Add Transaction" : "Edit Transaction")</h5>
<button type="button" class="btn-close" @onclick="CloseModal"></button>
</div>
<EditForm Model="modalForm" OnValidSubmit="SaveModal" FormName="dashboardModal">
<ToastValidator />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Description</label>
<InputText @bind-Value="modalForm.Description" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">Amount</label>
<InputNumber @bind-Value="modalForm.Amount" class="form-control numeric-input" />
</div>
<div class="mb-3">
<label class="form-label">Date</label>
<InputDate @bind-Value="modalForm.Date" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<InputSelect @bind-Value="modalForm.Type" class="form-select">
<option value="@TransactionType.Expense">Expense</option>
<option value="@TransactionType.Income">Income</option>
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Account</label>
<InputSelect @bind-Value="modalForm.AccountId" class="form-select">
@foreach (var a in data.Accounts)
{
<option value="@a.Id">@a.Name</option>
}
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Category</label>
<InputSelect @bind-Value="modalForm.CategoryId" class="form-select">
<option value="">— None —</option>
@foreach (var c in data.Categories)
{
<option value="@c.Id">@(string.IsNullOrEmpty(c.Icon) ? "" : c.Icon + " ")@c.Name</option>
}
</InputSelect>
</div>
@if (editingTransactionId is null)
{
<div class="mb-3">
<label class="form-label">Recurrence</label>
<InputSelect @bind-Value="modalForm.RecurrenceType" class="form-select">
<option value="@RecurrenceType.None">None</option>
<option value="@RecurrenceType.Daily">Daily</option>
<option value="@RecurrenceType.Weekly">Weekly</option>
<option value="@RecurrenceType.Monthly">Monthly</option>
<option value="@RecurrenceType.Yearly">Yearly</option>
</InputSelect>
</div>
@if (modalForm.RecurrenceType != RecurrenceType.None)
{
<div class="mb-3">
<label class="form-label">Recurrence End Date <small class="text-muted">(max 1 year, defaults to 1 year if blank)</small></label>
<InputDate @bind-Value="modalForm.RecurrenceEndDate" class="form-control" max="@modalForm.Date.AddYears(1).ToString("yyyy-MM-dd")" />
</div>
}
}
else if (editIsPartOfSeries)
{
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i> Changes will apply to this and all future occurrences in the series.
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseModal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</EditForm>
</div>
</div>
</div>
}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item">
<button class="nav-link @(activeTab == "recent" ? "active" : "")" @onclick='() => { activeTab = "recent"; recentPage = 1; }'>
Recent <span class="badge bg-secondary ms-1">@filteredRecent.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "upcoming" ? "active" : "")" @onclick='() => { activeTab = "upcoming"; upcomingPage = 1; }'>
Upcoming <span class="badge bg-secondary ms-1">@filteredUpcoming.Count</span>
</button>
</li>
</ul>
<button class="btn btn-sm btn-primary" @onclick="OpenAddModal">+ Add</button>
</div>
@if (data.Accounts.Count > 1)
{
<div class="px-3 py-2 border-bottom d-flex flex-wrap align-items-center gap-2">
<small class="text-muted me-1">Filter:</small>
@foreach (var a in data.Accounts)
{
<button class="btn btn-sm px-2 py-0 @(hiddenAccountIds.Contains(a.Id) ? "btn-outline-secondary" : "")"
style="font-size: 0.75rem; @(hiddenAccountIds.Contains(a.Id) ? "opacity: 0.5;" : $"background-color: {a.Color}; color: white;")"
@onclick="() => ToggleAccountFilter(a.Id)">
@a.Name
</button>
}
</div>
}
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
@if (activeTab == "recent")
{
@if (filteredRecent.Count == 0)
{
<p class="p-3 text-muted mb-0">No recent transactions.</p>
}
else
{
<div class="list-group list-group-flush">
@foreach (var t in filteredRecent.Skip((recentPage - 1) * PageSize).Take(PageSize))
{
<div class="list-group-item list-group-item-action txn-row @(selectedTransactionId == t.Id ? "txn-row-selected" : "")"
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
@onclick="() => SelectTransaction(t.Id)">
<div class="d-flex justify-content-between align-items-start">
<div class="me-3" style="min-width: 0;">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="fw-semibold text-truncate">@t.Description</span>
@if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
{
<span class="badge bg-info bg-opacity-10 text-info" style="font-size: 0.7rem;">🔁 @GetRecurrenceLabel(t)</span>
}
</div>
<div class="d-flex align-items-center gap-2 mt-1 flex-wrap" style="font-size: 0.8rem;">
<span class="text-muted">@t.Date.ToString("MMM dd, yyyy")</span>
<span class="badge" style="background-color: @t.Account.Color; font-size: 0.7rem;">@t.Account.Name</span>
@if (t.Category is not null)
{
<span class="text-muted">@(string.IsNullOrEmpty(t.Category.Icon) ? "" : t.Category.Icon + " ")@t.Category.Name</span>
}
</div>
</div>
<div class="text-end text-nowrap" style="flex-shrink: 0;">
<div class="@(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold" style="font-size: 1.05rem;">
@(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
</div>
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
</div>
</div>
@if (selectedTransactionId == t.Id)
{
<div class="mt-2 pt-2 border-top d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
</div>
}
</div>
}
</div>
@if (RecentTotalPages > 1)
{
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
<button class="btn btn-sm btn-outline-secondary" disabled="@(recentPage <= 1 ? true : (bool?)null)" @onclick="() => recentPage--">← Prev</button>
<small class="text-muted">Page @recentPage of @RecentTotalPages</small>
<button class="btn btn-sm btn-outline-secondary" disabled="@(recentPage >= RecentTotalPages ? true : (bool?)null)" @onclick="() => recentPage++">Next →</button>
</div>
}
}
}
else
{
@if (filteredUpcoming.Count == 0)
{
<p class="p-3 text-muted mb-0">No upcoming transactions.</p>
}
else
{
<div class="list-group list-group-flush">
@foreach (var t in filteredUpcoming.Skip((upcomingPage - 1) * PageSize).Take(PageSize))
{
<div class="list-group-item list-group-item-action txn-row @(selectedTransactionId == t.Id ? "txn-row-selected" : "")"
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
@onclick="() => SelectTransaction(t.Id)">
<div class="d-flex justify-content-between align-items-start">
<div class="me-3" style="min-width: 0;">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="fw-semibold text-truncate">@t.Description</span>
@if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
{
<span class="badge bg-info bg-opacity-10 text-info" style="font-size: 0.7rem;">🔁 @GetRecurrenceLabel(t)</span>
}
</div>
<div class="d-flex align-items-center gap-2 mt-1 flex-wrap" style="font-size: 0.8rem;">
<span class="text-muted">@t.Date.ToString("MMM dd, yyyy")</span>
<span class="badge" style="background-color: @t.Account.Color; font-size: 0.7rem;">@t.Account.Name</span>
@if (t.Category is not null)
{
<span class="text-muted">@(string.IsNullOrEmpty(t.Category.Icon) ? "" : t.Category.Icon + " ")@t.Category.Name</span>
}
</div>
</div>
<div class="text-end text-nowrap" style="flex-shrink: 0;">
<div class="@(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold" style="font-size: 1.05rem;">
@(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
</div>
@if (projectedBalances.TryGetValue(t.Id, out var projected))
{
<small class="@(projected >= 0 ? "text-success" : "text-danger")" style="font-size: 0.7rem;">
Projected: @t.Account.FormatAmount(projected)
</small>
}
else
{
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
}
</div>
</div>
@if (selectedTransactionId == t.Id)
{
<div class="mt-2 pt-2 border-top d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
</div>
}
</div>
}
</div>
@if (UpcomingTotalPages > 1)
{
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
<button class="btn btn-sm btn-outline-secondary" disabled="@(upcomingPage <= 1 ? true : (bool?)null)" @onclick="() => upcomingPage--">← Prev</button>
<small class="text-muted">Page @upcomingPage of @UpcomingTotalPages</small>
<button class="btn btn-sm btn-outline-secondary" disabled="@(upcomingPage >= UpcomingTotalPages ? true : (bool?)null)" @onclick="() => upcomingPage++">Next →</button>
</div>
}
}
}
</div>
</div>
@if (showDeleteModal && pendingDeleteTransaction is not null)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Recurring Transaction</h5>
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
</div>
<div class="modal-body">
<p>
<strong>@pendingDeleteTransaction.Description</strong> is part of a recurring series.
</p>
<p>Would you like to delete just this transaction, or all future transactions in the series?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
</div>
</div>
</div>
</div>
}
}
@code {
private DashboardData? data;
private string? userId;
private int? selectedTransactionId;
private string activeTab = "recent";
private Dictionary<int, decimal> projectedBalances = new();
// Modal state
private bool showModal;
private int? editingTransactionId;
private bool editIsPartOfSeries;
private ModalTransactionForm modalForm = new();
// Chart redraw tracking
private int chartDataVersion;
private int lastRenderedChartVersion;
// Time window
private DateTime windowStart = DateTime.Today.AddDays(-30);
private DateTime windowEnd = DateTime.Today.AddDays(30);
private string windowPreset = "30d";
// Account filter
private HashSet<int> hiddenAccountIds = [];
private List<Transaction> filteredRecent => data is null ? []
: data.RecentTransactions.Where(t => !hiddenAccountIds.Contains(t.AccountId)).ToList();
private List<Transaction> filteredUpcoming => data is null ? []
: data.UpcomingTransactions.Where(t => !hiddenAccountIds.Contains(t.AccountId)).ToList();
private void ToggleAccountFilter(int accountId)
{
if (!hiddenAccountIds.Remove(accountId))
hiddenAccountIds.Add(accountId);
recentPage = 1;
upcomingPage = 1;
}
// Pagination
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,
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 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();
}
}
// Delete confirmation state
private bool showDeleteModal;
private Transaction? pendingDeleteTransaction;
private void ConfirmDelete(Transaction t)
{
pendingDeleteTransaction = t;
if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
{
showDeleteModal = true;
}
else
{
_ = DeleteSingle();
}
}
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 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;
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.AddDays(-days);
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)]);
}
}
}
}
}