Files
2026-04-02 19:28:18 +01:00

414 lines
26 KiB
Plaintext
Raw Permalink 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 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
}