414 lines
26 KiB
Plaintext
414 lines
26 KiB
Plaintext
@page "/"
|
||
@attribute [Authorize]
|
||
@inject DashboardService DashboardService
|
||
@inject ITransactionRepository TransactionService
|
||
@inject ISalaryRepository SalaryService
|
||
@inject AuthenticationStateProvider AuthStateProvider
|
||
@inject IJSRuntime Js
|
||
@using BudgetApp.Data.Entities
|
||
@implements IAsyncDisposable
|
||
@rendermode InteractiveServer
|
||
|
||
<PageTitle>Dashboard</PageTitle>
|
||
|
||
<h3 class="mb-2">Dashboard</h3>
|
||
<p class="text-muted mb-4">An overview of your balances, recent and upcoming transactions.</p>
|
||
|
||
@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")" title="Show next 7 days" @onclick="() => SetWindow(7)">7d</button>
|
||
<button class="btn @(_windowPreset == "30d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 30 days" @onclick="() => SetWindow(30)">30d</button>
|
||
<button class="btn @(_windowPreset == "90d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 90 days" @onclick="() => SetWindow(90)">90d</button>
|
||
<button class="btn @(_windowPreset == "1y" ? "btn-primary" : "btn-outline-secondary")" title="Show next year" @onclick="() => SetWindow(365)">1y</button>
|
||
</div>
|
||
<input type="date" class="form-control form-control-sm date-picker"
|
||
title="Custom start date" @bind="_windowStart" @bind:after="OnWindowDateChanged" />
|
||
<span class="text-muted">to</span>
|
||
<input type="date" class="form-control form-control-sm date-picker"
|
||
title="Custom end date" @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.FourWeekly">4 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>
|
||
}
|
||
|
||
@if (_nextPayday.HasValue && _predictedPaydayBalances.Count > 0)
|
||
{
|
||
<div class="card mb-4 border-success border-opacity-25">
|
||
<div class="card-body py-2 px-3">
|
||
<div class="d-flex flex-wrap align-items-center gap-3">
|
||
<span class="text-muted" style="font-size: 0.85rem;">
|
||
💰 Predicted balance before next payday <strong>@_nextPayday.Value.ToString("MMM d")</strong>:
|
||
</span>
|
||
@foreach (var account in _data!.Accounts)
|
||
{
|
||
if (_predictedPaydayBalances.TryGetValue(account.Id, out var pred))
|
||
{
|
||
<span class="fw-semibold @(pred >= 0 ? "text-success" : "text-danger")" style="font-size: 0.9rem;">
|
||
<span class="badge me-1" style="background-color: @account.Color; font-size: 0.7rem;">@account.Name</span>
|
||
@account.FormatAmount(pred)
|
||
</span>
|
||
}
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
<div class="card mb-4">
|
||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<ul class="nav nav-tabs card-header-tabs" style="flex-grow: 0;">
|
||
<li class="nav-item">
|
||
<button class="nav-link @(_activeTab == "recent" ? "active" : "")" title="Transactions that have already occurred" @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" : "")" title="Scheduled future transactions" @onclick='() => { _activeTab = "upcoming"; _upcomingPage = 1; }'>
|
||
Upcoming <span class="badge bg-secondary ms-1">@FilteredUpcoming.Count</span>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<input type="search" class="form-control form-control-sm" style="max-width: 180px;"
|
||
title="Filter by description, account, category or amount" placeholder="Search..." @bind="_dashboardSearchQuery" @bind:event="oninput" @bind:after="OnDashboardSearchChanged" />
|
||
<button class="btn btn-sm btn-primary" title="Quickly add a new transaction" @onclick="OpenAddModal">+ Add</button>
|
||
</div>
|
||
</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;")"
|
||
title="@(_hiddenAccountIds.Contains(a.Id) ? $"Show {a.Name} transactions" : $"Hide {a.Name} transactions")"
|
||
@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 group in GroupedRecentPage)
|
||
{
|
||
<div class="list-group-item py-1 px-3" style="background: rgba(0,0,0,0.03); border-left: none; font-size: 0.78rem;">
|
||
<span class="text-muted fw-semibold">@group.Key.ToString("dddd, d MMMM")</span>
|
||
</div>
|
||
@foreach (var t in OrderGroupByIncomeFirst(group))
|
||
{
|
||
<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>
|
||
}
|
||
@if (t.IsPaid)
|
||
{
|
||
<span class="badge bg-success bg-opacity-10 text-success" style="font-size: 0.7rem;">✓ Paid</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-between align-items-center gap-2">
|
||
@if (t.IsPaid)
|
||
{
|
||
<button class="btn btn-outline-secondary btn-sm" title="Revert to upcoming" @onclick="() => MarkAsPaid(t.Id, false)" @onclick:stopPropagation="true">↩ Unpaid</button>
|
||
}
|
||
else
|
||
{
|
||
<span></span>
|
||
}
|
||
<div class="d-flex 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>
|
||
}
|
||
}
|
||
</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 group in GroupedUpcomingPage)
|
||
{
|
||
<div class="list-group-item py-1 px-3" style="background: rgba(0,0,0,0.03); border-left: none; font-size: 0.78rem;">
|
||
<span class="text-muted fw-semibold">@group.Key.ToString("dddd, d MMMM")</span>
|
||
</div>
|
||
@foreach (var t in OrderGroupByIncomeFirst(group))
|
||
{
|
||
<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-between align-items-center gap-2">
|
||
<button class="btn btn-outline-success btn-sm" title="Mark as paid and move to Recent" @onclick="() => MarkAsPaid(t.Id, true)" @onclick:stopPropagation="true">✅ Mark as Paid</button>
|
||
<div class="d-flex 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>
|
||
}
|
||
}
|
||
</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">@(_pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction")</h5>
|
||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p>Are you sure you want to delete <strong>@_pendingDeleteTransaction.Description</strong>?</p>
|
||
@if (_pendingDeleteIsRecurring)
|
||
{
|
||
<p>This transaction is part of a recurring series. Would you like to delete just this one, or all future transactions in the series?</p>
|
||
}
|
||
else
|
||
{
|
||
<p class="text-danger mb-0">This action cannot be undone.</p>
|
||
}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||
@if (_pendingDeleteIsRecurring)
|
||
{
|
||
<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>
|
||
}
|
||
else
|
||
{
|
||
<button type="button" class="btn btn-danger" @onclick="DeleteSingle">Delete</button>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|
||
|
||
@code {
|
||
|
||
// Modal state
|
||
|
||
// Chart redraw tracking
|
||
|
||
// Time window
|
||
|
||
// Account filter
|
||
|
||
// Pagination
|
||
|
||
// Delete confirmation state
|
||
|
||
}
|