This commit is contained in:
rossmill76 committed 2026-03-01 21:47:44 +00:00
1 parent 08eefe2973
commit 2cf6128a85
10 files changed
+130 -12

No files matched your search

+21
View File
@@ -1,5 +1,7 @@
@inject AccountService AccountService @inject AccountService AccountService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject AppState AppState
@implements IDisposable
@rendermode InteractiveServer @rendermode InteractiveServer
<div class="top-row ps-3 navbar navbar-dark"> <div class="top-row ps-3 navbar navbar-dark">
@@ -74,6 +76,8 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
AppState.OnAccountsChanged += HandleAccountsChanged;
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
@@ -82,5 +86,22 @@
accounts = await AccountService.GetAccountsAsync(userId); accounts = await AccountService.GetAccountsAsync(userId);
} }
} }
private async void HandleAccountsChanged()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (userId is not null)
{
accounts = await AccountService.GetAccountsAsync(userId);
await InvokeAsync(StateHasChanged);
}
}
public void Dispose()
{
AppState.OnAccountsChanged -= HandleAccountsChanged;
}
} }
@@ -2,6 +2,7 @@
@attribute [Authorize] @attribute [Authorize]
@inject AccountService AccountService @inject AccountService AccountService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject AppState AppState
@rendermode InteractiveServer @rendermode InteractiveServer
<PageTitle>Accounts</PageTitle> <PageTitle>Accounts</PageTitle>
@@ -200,6 +201,7 @@ else
await AccountService.CreateAccountAsync(account); await AccountService.CreateAccountAsync(account);
formModel = new AccountForm(); formModel = new AccountForm();
await LoadAccounts(); await LoadAccounts();
AppState.NotifyAccountsChanged();
} }
private void ConfirmDeleteAccount(Account account) private void ConfirmDeleteAccount(Account account)
@@ -216,6 +218,7 @@ else
await AccountService.DeleteAccountAsync(pendingDeleteAccount.Id, userId); await AccountService.DeleteAccountAsync(pendingDeleteAccount.Id, userId);
pendingDeleteAccount = null; pendingDeleteAccount = null;
await LoadAccounts(); await LoadAccounts();
AppState.NotifyAccountsChanged();
} }
private void CancelDeleteAccount() private void CancelDeleteAccount()
@@ -242,6 +245,7 @@ else
await AccountService.UpdateBalanceAsync(accountId, userId, editingBalanceValue); await AccountService.UpdateBalanceAsync(accountId, userId, editingBalanceValue);
editingBalanceAccountId = null; editingBalanceAccountId = null;
await LoadAccounts(); await LoadAccounts();
AppState.NotifyAccountsChanged();
} }
private void StartAccountEdit(Account account) private void StartAccountEdit(Account account)
@@ -264,6 +268,7 @@ else
await AccountService.UpdateAccountDetailsAsync(accountId, userId, editName, editColor, editCurrencyCode); await AccountService.UpdateAccountDetailsAsync(accountId, userId, editName, editColor, editCurrencyCode);
editingAccountId = null; editingAccountId = null;
await LoadAccounts(); await LoadAccounts();
AppState.NotifyAccountsChanged();
} }
private sealed class AccountForm private sealed class AccountForm
+41 -10
View File
@@ -140,29 +140,43 @@ else
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<ul class="nav nav-tabs card-header-tabs"> <ul class="nav nav-tabs card-header-tabs">
<li class="nav-item"> <li class="nav-item">
<button class="nav-link @(activeTab == "recent" ? "active" : "")" @onclick='() => activeTab = "recent"'> <button class="nav-link @(activeTab == "recent" ? "active" : "")" @onclick='() => { activeTab = "recent"; recentPage = 1; }'>
Recent <span class="badge bg-secondary ms-1">@data.RecentTransactions.Count</span> Recent <span class="badge bg-secondary ms-1">@filteredRecent.Count</span>
</button> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<button class="nav-link @(activeTab == "upcoming" ? "active" : "")" @onclick='() => activeTab = "upcoming"'> <button class="nav-link @(activeTab == "upcoming" ? "active" : "")" @onclick='() => { activeTab = "upcoming"; upcomingPage = 1; }'>
Upcoming <span class="badge bg-secondary ms-1">@data.UpcomingTransactions.Count</span> Upcoming <span class="badge bg-secondary ms-1">@filteredUpcoming.Count</span>
</button> </button>
</li> </li>
</ul> </ul>
<button class="btn btn-sm btn-primary" @onclick="OpenAddModal">+ Add</button> <button class="btn btn-sm btn-primary" @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;")"
@onclick="() => ToggleAccountFilter(a.Id)">
@a.Name
</button>
}
</div>
}
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;"> <div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
@if (activeTab == "recent") @if (activeTab == "recent")
{ {
@if (data.RecentTransactions.Count == 0) @if (filteredRecent.Count == 0)
{ {
<p class="p-3 text-muted mb-0">No recent transactions.</p> <p class="p-3 text-muted mb-0">No recent transactions.</p>
} }
else else
{ {
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
@foreach (var t in data.RecentTransactions.Skip((recentPage - 1) * PageSize).Take(PageSize)) @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" : "")" <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");" style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
@@ -214,14 +228,14 @@ else
} }
else else
{ {
@if (data.UpcomingTransactions.Count == 0) @if (filteredUpcoming.Count == 0)
{ {
<p class="p-3 text-muted mb-0">No upcoming transactions.</p> <p class="p-3 text-muted mb-0">No upcoming transactions.</p>
} }
else else
{ {
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
@foreach (var t in data.UpcomingTransactions.Skip((upcomingPage - 1) * PageSize).Take(PageSize)) @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" : "")" <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");" style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
@@ -331,12 +345,29 @@ else
private DateTime windowEnd = DateTime.Today.AddDays(30); private DateTime windowEnd = DateTime.Today.AddDays(30);
private string windowPreset = "30d"; 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 // Pagination
private int recentPage = 1; private int recentPage = 1;
private int upcomingPage = 1; private int upcomingPage = 1;
private const int PageSize = 10; private const int PageSize = 10;
private int RecentTotalPages => data is null ? 0 : (int)Math.Ceiling(data.RecentTransactions.Count / (double)PageSize); private int RecentTotalPages => (int)Math.Ceiling(filteredRecent.Count / (double)PageSize);
private int UpcomingTotalPages => data is null ? 0 : (int)Math.Ceiling(data.UpcomingTransactions.Count / (double)PageSize); private int UpcomingTotalPages => (int)Math.Ceiling(filteredUpcoming.Count / (double)PageSize);
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
+46 -2
View File
@@ -107,7 +107,7 @@ else if (transactions.Count == 0)
else else
{ {
<div class="card"> <div class="card">
<div class="card-header d-flex align-items-center gap-2"> <div class="card-header d-flex align-items-center gap-2 flex-wrap">
<label class="form-label mb-0 text-nowrap">Account:</label> <label class="form-label mb-0 text-nowrap">Account:</label>
<select class="form-select form-select-sm" style="max-width: 200px;" @onchange="OnAccountFilterChanged"> <select class="form-select form-select-sm" style="max-width: 200px;" @onchange="OnAccountFilterChanged">
<option value="">All Accounts</option> <option value="">All Accounts</option>
@@ -119,6 +119,15 @@ else
} }
} }
</select> </select>
<div class="ms-auto d-flex align-items-center gap-2">
<label class="form-label mb-0 text-nowrap">Show:</label>
<select class="form-select form-select-sm" style="max-width: 90px;" @onchange="OnPageSizeChanged">
@foreach (var size in pageSizeOptions)
{
<option value="@size" selected="@(pageSize == size)">@size</option>
}
</select>
</div>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
@@ -134,7 +143,7 @@ else
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var t in filteredTransactions) @foreach (var t in pagedTransactions)
{ {
<tr> <tr>
<td>@t.Date.ToString("MMM dd, yyyy")</td> <td>@t.Date.ToString("MMM dd, yyyy")</td>
@@ -184,6 +193,20 @@ else
</tbody> </tbody>
</table> </table>
</div> </div>
@if (totalPages > 1)
{
<div class="card-footer d-flex justify-content-between align-items-center">
<button class="btn btn-sm btn-outline-secondary" disabled="@(currentPage <= 1 ? true : (bool?)null)" @onclick="() => currentPage--">← Prev</button>
<small class="text-muted">Page @currentPage of @totalPages (@filteredTransactions.Count transactions)</small>
<button class="btn btn-sm btn-outline-secondary" disabled="@(currentPage >= totalPages ? true : (bool?)null)" @onclick="() => currentPage++">Next →</button>
</div>
}
else
{
<div class="card-footer">
<small class="text-muted">@filteredTransactions.Count transactions</small>
</div>
}
</div> </div>
@if (showDeleteModal && pendingDeleteTransaction is not null) @if (showDeleteModal && pendingDeleteTransaction is not null)
@@ -293,6 +316,16 @@ else
private string? userId; private string? userId;
private int? filterAccountId; private int? filterAccountId;
// Pagination state
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();
// Sort state // Sort state
private string sortColumn = "date"; private string sortColumn = "date";
private bool sortAscending = true; private bool sortAscending = true;
@@ -325,6 +358,16 @@ else
private void OnAccountFilterChanged(ChangeEventArgs e) private void OnAccountFilterChanged(ChangeEventArgs e)
{ {
filterAccountId = int.TryParse(e.Value?.ToString(), out var id) ? id : null; filterAccountId = int.TryParse(e.Value?.ToString(), out var id) ? id : null;
currentPage = 1;
}
private void OnPageSizeChanged(ChangeEventArgs e)
{
if (int.TryParse(e.Value?.ToString(), out var size))
{
pageSize = size;
currentPage = 1;
}
} }
private void SetSort(string column) private void SetSort(string column)
@@ -336,6 +379,7 @@ else
sortColumn = column; sortColumn = column;
sortAscending = true; sortAscending = true;
} }
currentPage = 1;
} }
private MarkupString SortIndicator(string column) private MarkupString SortIndicator(string column)
+1
View File
@@ -40,6 +40,7 @@ builder.Services.AddScoped<TransactionService>();
builder.Services.AddScoped<CategoryService>(); builder.Services.AddScoped<CategoryService>();
builder.Services.AddScoped<DashboardService>(); builder.Services.AddScoped<DashboardService>();
builder.Services.AddScoped<ToastService>(); builder.Services.AddScoped<ToastService>();
builder.Services.AddScoped<AppState>();
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
+8
View File
@@ -0,0 +1,8 @@
namespace BudgetApp.Services;
public sealed class AppState
{
public event Action? OnAccountsChanged;
public void NotifyAccountsChanged() => OnAccountsChanged?.Invoke();
}
Binary file not shown.
Binary file not shown.
+5
View File
@@ -23,4 +23,9 @@ LABEL org.opencontainers.image.title="Budgeteer" \
# Ensure the SQLite data directory exists and is writable # Ensure the SQLite data directory exists and is writable
RUN mkdir -p /app/data RUN mkdir -p /app/data
# Store the database in the volume-mountable /app/data directory
ENV ConnectionStrings__DefaultConnection="Data Source=/app/data/budgetapp.db"
VOLUME ["/app/data"]
ENTRYPOINT ["dotnet", "BudgetApp.dll"] ENTRYPOINT ["dotnet", "BudgetApp.dll"]
+3
View File
@@ -51,6 +51,9 @@ docker build -t budgeteer .
docker run -d -p 8080:8080 -v budgeteer-data:/app/data --name budgeteer budgeteer docker run -d -p 8080:8080 -v budgeteer-data:/app/data --name budgeteer budgeteer
``` ```
> **Note:** The SQLite database is stored at `/app/data/budgetapp.db` inside the container.
> Always mount a volume to `/app/data` so your data survives image updates and container replacements.
The app will be available at `http://localhost:8080`. The app will be available at `http://localhost:8080`.
## Project Structure ## Project Structure