619 lines
30 KiB
Plaintext
619 lines
30 KiB
Plaintext
@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"'>
|
||
Recent <span class="badge bg-secondary ms-1">@data.RecentTransactions.Count</span>
|
||
</button>
|
||
</li>
|
||
<li class="nav-item">
|
||
<button class="nav-link @(activeTab == "upcoming" ? "active" : "")" @onclick='() => activeTab = "upcoming"'>
|
||
Upcoming <span class="badge bg-secondary ms-1">@data.UpcomingTransactions.Count</span>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
<button class="btn btn-sm btn-primary" @onclick="OpenAddModal">+ Add</button>
|
||
</div>
|
||
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
|
||
@if (activeTab == "recent")
|
||
{
|
||
@if (data.RecentTransactions.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 data.RecentTransactions.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 (data.UpcomingTransactions.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 data.UpcomingTransactions.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";
|
||
|
||
// Pagination
|
||
private int recentPage = 1;
|
||
private int upcomingPage = 1;
|
||
private const int PageSize = 10;
|
||
private int RecentTotalPages => data is null ? 0 : (int)Math.Ceiling(data.RecentTransactions.Count / (double)PageSize);
|
||
private int UpcomingTotalPages => data is null ? 0 : (int)Math.Ceiling(data.UpcomingTransactions.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)]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|