This commit is contained in:
rossmill76 committed 2026-03-01 21:19:34 +00:00
1 parent 9faa49daa1
commit 08eefe2973
20 files changed
+539 -109

No files matched your search

+15 -3
View File
@@ -41,13 +41,25 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/budgeteer
tags: |
type=raw,value=latest
type=sha,prefix=
type=raw,value={{date 'YYYY.MM.DD'}}
labels: |
org.opencontainers.image.title=Budgeteer
org.opencontainers.image.description=A personal finance dashboard built with Blazor Server (.NET 10). Multi-account support, recurring transactions, category management, balance trend charts, and ASP.NET Identity authentication.
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/budgeteer:latest
${{ secrets.DOCKERHUB_USERNAME }}/budgeteer:${{ github.sha }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -21,6 +21,8 @@
</main>
</div>
<ToastContainer />
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
+43 -5
View File
@@ -1,4 +1,8 @@
<div class="top-row ps-3 navbar navbar-dark">
@inject AccountService AccountService
@inject AuthenticationStateProvider AuthStateProvider
@rendermode InteractiveServer
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">💰 Budgeteer</a>
</div>
@@ -22,27 +26,61 @@
<div class="nav-item px-3">
<NavLink class="nav-link" href="transactions">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Transactions
<span class="bi bi-arrow-left-right-nav-menu" aria-hidden="true"></span> Transactions
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="categories">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Categories
<span class="bi bi-tag-nav-menu" aria-hidden="true"></span> Categories
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="settings">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Settings
<span class="bi bi-gear-nav-menu" aria-hidden="true"></span> Settings
</NavLink>
</div>
<div class="nav-item px-3 mt-auto">
<NavLink class="nav-link" href="logout">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Logout
<span class="bi bi-box-arrow-right-nav-menu" aria-hidden="true"></span> Logout
</NavLink>
</div>
</nav>
@if (accounts is not null && accounts.Count > 0)
{
<div class="nav-balances px-3">
<div class="balances-header">Balances</div>
@foreach (var account in accounts)
{
<div class="balance-item">
<div class="balance-color" style="background-color: @account.Color;"></div>
<div class="balance-details">
<span class="balance-name">@account.Name</span>
<span class="balance-amount @(account.CurrentBalance >= 0 ? "text-success" : "text-danger")">
@account.FormatAmount(account.CurrentBalance)
</span>
</div>
</div>
}
</div>
}
</div>
@code {
private List<Account>? accounts;
protected override async Task OnInitializedAsync()
{
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);
}
}
}
@@ -46,6 +46,22 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.bi-arrow-left-right-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-arrow-left-right' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5zm14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5z'/%3E%3C/svg%3E");
}
.bi-tag-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-tag-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 1a1 1 0 0 0-1 1v4.586a1 1 0 0 0 .293.707l7 7a1 1 0 0 0 1.414 0l4.586-4.586a1 1 0 0 0 0-1.414l-7-7A1 1 0 0 0 6.586 1H2zm4 3.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z'/%3E%3C/svg%3E");
}
.bi-gear-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-gear-fill' viewBox='0 0 16 16'%3E%3Cpath d='M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z'/%3E%3C/svg%3E");
}
.bi-box-arrow-right-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-box-arrow-right' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z'/%3E%3Cpath fill-rule='evenodd' d='M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
@@ -103,3 +119,54 @@
overflow-y: auto;
}
}
.nav-balances {
border-top: 1px solid rgba(255, 255, 255, 0.15);
padding-top: 0.75rem;
padding-bottom: 0.75rem;
margin-top: 0.5rem;
}
.balances-header {
color: rgba(255, 255, 255, 0.5);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.balance-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
}
.balance-color {
width: 4px;
height: 28px;
border-radius: 2px;
flex-shrink: 0;
}
.balance-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.balance-name {
color: rgba(255, 255, 255, 0.7);
font-size: 0.75rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.balance-amount {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
}
+1 -2
View File
@@ -14,12 +14,11 @@
</div>
<div class="card-body">
<EditForm Model="formModel" OnValidSubmit="AddAccount" FormName="addAccount">
<DataAnnotationsValidator />
<ToastValidator />
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label">Account Name</label>
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Current Account" />
<ValidationMessage For="() => formModel.Name" />
</div>
<div class="col-md-2">
<label class="form-label">Current Balance</label>
+19 -13
View File
@@ -14,12 +14,11 @@
</div>
<div class="card-body">
<EditForm Model="formModel" OnValidSubmit="AddCategory" FormName="addCategory">
<DataAnnotationsValidator />
<ToastValidator />
<div class="row g-3 align-items-end">
<div class="col-md-6">
<label class="form-label">Category Name</label>
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Groceries, Rent, Salary" />
<ValidationMessage For="() => formModel.Name" />
</div>
<div class="col-md-2">
<label class="form-label">Icon</label>
@@ -49,33 +48,40 @@ else
{
<div class="col">
<div class="card h-100 text-center">
<div class="card-body d-flex flex-column align-items-center justify-content-center">
@if (editingCategoryId == category.Id)
{
<div class="card-body">
<button type="button" class="btn btn-outline-secondary" style="font-size: 2rem; width: 52px; height: 52px; padding: 0;"
@onclick="OpenEditIconPicker">@(string.IsNullOrEmpty(editIcon) ? "📁" : editIcon)</button>
<input type="text" class="form-control form-control-sm mt-2 text-center" @bind="editCategoryName" />
<div class="mt-2 d-flex gap-1 justify-content-center">
<button class="btn btn-success btn-sm" @onclick="() => SaveCategoryEdit(category.Id)">Save</button>
<button class="btn btn-secondary btn-sm" @onclick="CancelCategoryEdit">Cancel</button>
</div>
<div class="mt-2" style="height: 31px; width: 100%;">
<input type="text" class="form-control form-control-sm text-center"
style="font-size: 1rem; font-weight: 500;" @bind="editCategoryName" />
</div>
}
else
{
<div class="card-body">
<div style="font-size: 2rem;">@(string.IsNullOrEmpty(category.Icon) ? "📁" : category.Icon)</div>
<h6 class="card-title mt-2 mb-0">@category.Name</h6>
<div style="font-size: 2rem; width: 52px; height: 52px; display: flex; align-items: center; justify-content: center;">@(string.IsNullOrEmpty(category.Icon) ? "📁" : category.Icon)</div>
<div class="mt-2" style="height: 31px; display: flex; align-items: center;">
<h6 class="card-title mb-0">@category.Name</h6>
</div>
}
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<div class="d-flex gap-1 justify-content-center">
@if (editingCategoryId == category.Id)
{
<button class="btn btn-success btn-sm" @onclick="() => SaveCategoryEdit(category.Id)">Save</button>
<button class="btn btn-secondary btn-sm" @onclick="CancelCategoryEdit">Cancel</button>
}
else
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => StartCategoryEdit(category)">Edit</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => DeleteCategory(category.Id)">Delete</button>
</div>
</div>
}
</div>
</div>
</div>
</div>
}
</div>
}
+33 -65
View File
@@ -2,7 +2,6 @@
@attribute [Authorize]
@inject DashboardService DashboardService
@inject TransactionService TransactionService
@inject AccountService AccountService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@implements IAsyncDisposable
@@ -25,53 +24,25 @@ else if (data.Accounts.Count == 0)
}
else
{
<div class="d-flex flex-wrap gap-2 mb-3" style="overflow-x: auto;">
@foreach (var account in data.Accounts)
{
<div class="border rounded px-3 py-2 balance-chip" style="border-left: 3px solid @account.Color !important; min-width: 180px;">
<small class="text-muted">@account.Name</small>
@if (editingBalanceAccountId == account.Id)
{
<div class="d-flex align-items-center gap-1">
<span class="text-muted" style="font-size: 0.85rem;">@account.GetSymbol()</span>
<input type="number" class="form-control form-control-sm numeric-input" step="0.01"
style="width: 90px;" @bind="editingBalanceValue" />
<button class="btn btn-success btn-sm px-1" @onclick="() => SaveBalance(account.Id)" title="Save">✓</button>
<button class="btn btn-outline-secondary btn-sm px-1" @onclick="CancelBalanceEdit" title="Cancel">✕</button>
</div>
}
else
{
<div style="cursor: pointer;" title="Click to edit balance"
@onclick="() => StartBalanceEdit(account)">
<span class="fw-semibold @(account.CurrentBalance >= 0 ? "text-success" : "text-danger")">
@account.FormatAmount(account.CurrentBalance)
</span>
</div>
}
</div>
}
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<small class="text-muted fw-semibold">Window:</small>
<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" style="width: auto;"
<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" style="width: auto;"
<input type="date" class="form-control form-control-sm date-picker"
@bind="windowEnd" @bind:after="OnWindowDateChanged" />
</div>
<div class="card mb-4">
</div>
<div class="card-body">
<h5 class="card-title">Balance Trend</h5>
<div style="height: clamp(200px, 40vw, 350px); position: relative;">
<div class="chart-container">
<canvas id="balanceChart"></canvas>
</div>
</div>
@@ -88,12 +59,11 @@ else
<button type="button" class="btn-close" @onclick="CloseModal"></button>
</div>
<EditForm Model="modalForm" OnValidSubmit="SaveModal" FormName="dashboardModal">
<DataAnnotationsValidator />
<ToastValidator />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Description</label>
<InputText @bind-Value="modalForm.Description" class="form-control" />
<ValidationMessage For="() => modalForm.Description" />
</div>
<div class="mb-3">
<label class="form-label">Amount</label>
@@ -129,6 +99,8 @@ else
}
</InputSelect>
</div>
@if (editingTransactionId is null)
{
<div class="mb-3">
<label class="form-label">Recurrence</label>
<InputSelect @bind-Value="modalForm.RecurrenceType" class="form-select">
@@ -144,7 +116,13 @@ else
<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")" />
<ValidationMessage For="() => modalForm.RecurrenceEndDate" />
</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>
@@ -341,6 +319,7 @@ else
// Modal state
private bool showModal;
private int? editingTransactionId;
private bool editIsPartOfSeries;
private ModalTransactionForm modalForm = new();
// Chart redraw tracking
@@ -422,30 +401,6 @@ else
}
}
// Balance editing state
private int? editingBalanceAccountId;
private decimal editingBalanceValue;
private void StartBalanceEdit(Account account)
{
editingBalanceAccountId = account.Id;
editingBalanceValue = account.CurrentBalance;
}
private void CancelBalanceEdit()
{
editingBalanceAccountId = null;
}
private async Task SaveBalance(int accountId)
{
if (userId is null) return;
await AccountService.UpdateBalanceAsync(accountId, userId, editingBalanceValue);
editingBalanceAccountId = null;
await RefreshData();
}
private void OpenAddModal()
{
editingTransactionId = null;
@@ -459,6 +414,7 @@ else
private void OpenEditModal(Transaction t)
{
editingTransactionId = t.Id;
editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
modalForm = new ModalTransactionForm
{
Description = t.Description,
@@ -493,6 +449,18 @@ else
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
{
@@ -501,11 +469,11 @@ else
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;
@@ -0,0 +1,8 @@
.date-picker {
width: auto;
}
.chart-container {
height: clamp(200px, 40vw, 350px);
position: relative;
}
+1 -4
View File
@@ -30,22 +30,19 @@
}
<EditForm Model="passwordForm" OnValidSubmit="ChangePassword" FormName="changePassword">
<DataAnnotationsValidator />
<ToastValidator />
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Current Password</label>
<InputText @bind-Value="passwordForm.CurrentPassword" type="password" class="form-control" />
<ValidationMessage For="() => passwordForm.CurrentPassword" />
</div>
<div class="col-md-4">
<label class="form-label">New Password</label>
<InputText @bind-Value="passwordForm.NewPassword" type="password" class="form-control" />
<ValidationMessage For="() => passwordForm.NewPassword" />
</div>
<div class="col-md-4">
<label class="form-label">Confirm New Password</label>
<InputText @bind-Value="passwordForm.ConfirmNewPassword" type="password" class="form-control" />
<ValidationMessage For="() => passwordForm.ConfirmNewPassword" />
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Update Password</button>
+118 -3
View File
@@ -24,12 +24,11 @@
else
{
<EditForm Model="formModel" OnValidSubmit="AddTransaction" FormName="addTransaction">
<DataAnnotationsValidator />
<ToastValidator />
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Description</label>
<InputText @bind-Value="formModel.Description" class="form-control" placeholder="e.g. Food shopping" />
<ValidationMessage For="() => formModel.Description" />
</div>
<div class="col-md-2">
<label class="form-label">Amount</label>
@@ -86,7 +85,6 @@
<div class="col-md-2">
<label class="form-label">End Date <small class="text-muted">(max 1yr)</small></label>
<InputDate @bind-Value="formModel.RecurrenceEndDate" class="form-control" max="@formModel.Date.AddYears(1).ToString("yyyy-MM-dd")" />
<ValidationMessage For="() => formModel.RecurrenceEndDate" />
</div>
}
<div class="col-md-2 d-flex align-items-end">
@@ -174,6 +172,9 @@ else
@(t.Type == TransactionType.Income ? "+" : "-")@t.Account.FormatAmount(t.Amount)
</td>
<td class="text-end">
<button class="btn btn-outline-primary btn-sm" @onclick="() => OpenEditModal(t)">
Edit
</button>
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)">
Delete
</button>
@@ -209,6 +210,79 @@ else
</div>
</div>
}
@if (showEditModal)
{
<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">Edit Transaction</h5>
<button type="button" class="btn-close" @onclick="CloseEditModal"></button>
</div>
<EditForm Model="editModel" OnValidSubmit="SaveEdit" FormName="editTransaction">
<ToastValidator />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Description</label>
<InputText @bind-Value="editModel.Description" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">Amount</label>
<InputNumber @bind-Value="editModel.Amount" class="form-control numeric-input" />
</div>
<div class="mb-3">
<label class="form-label">Date</label>
<InputDate @bind-Value="editModel.Date" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<InputSelect @bind-Value="editModel.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="editModel.AccountId" class="form-select">
@if (accountsList is not null)
{
@foreach (var account in accountsList)
{
<option value="@account.Id">@account.Name</option>
}
}
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Category</label>
<InputSelect @bind-Value="editModel.CategoryId" class="form-select">
<option value="">— None —</option>
@if (categoriesList is not null)
{
@foreach (var category in categoriesList)
{
<option value="@category.Id">@(string.IsNullOrEmpty(category.Icon) ? "" : category.Icon + " ")@category.Name</option>
}
}
</InputSelect>
</div>
@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="CloseEditModal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</EditForm>
</div>
</div>
</div>
}
}
@code {
@@ -319,6 +393,47 @@ else
private bool showDeleteModal;
private Transaction? pendingDeleteTransaction;
// Edit modal state
private bool showEditModal;
private int? editingTransactionId;
private bool editIsPartOfSeries;
private TransactionForm editModel = new();
private void OpenEditModal(Transaction t)
{
editingTransactionId = t.Id;
editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
editModel = new TransactionForm
{
Description = t.Description,
Amount = t.Amount,
Date = t.Date,
Type = t.Type,
AccountId = t.AccountId,
CategoryId = t.CategoryId,
RecurrenceType = t.RecurrenceType,
RecurrenceEndDate = t.RecurrenceEndDate
};
showEditModal = true;
}
private void CloseEditModal() => showEditModal = false;
private async Task SaveEdit()
{
if (userId is null || editingTransactionId is null) return;
await TransactionService.UpdateTransactionAsync(
editingTransactionId.Value, userId,
editModel.Description, editModel.Amount, editModel.Date,
editModel.Type, editModel.AccountId, editModel.CategoryId,
editModel.RecurrenceType, editModel.RecurrenceEndDate);
showEditModal = false;
editingTransactionId = null;
await LoadData();
}
private void ConfirmDelete(Transaction t)
{
pendingDeleteTransaction = t;
+51
View File
@@ -0,0 +1,51 @@
@inject ToastService ToastService
@implements IDisposable
@rendermode InteractiveServer
<div class="toast-stack">
@foreach (var toast in toasts)
{
<div class="toast-item @(toast.Dismissing ? "toast-dismiss" : "")">
<span class="toast-icon">⚠</span>
<span class="toast-message">@toast.Message</span>
</div>
}
</div>
@code {
private readonly List<ToastItem> toasts = [];
protected override void OnInitialized()
{
ToastService.OnShow += ShowToast;
}
private async void ShowToast(string message)
{
var toast = new ToastItem { Message = message };
toasts.Add(toast);
await InvokeAsync(StateHasChanged);
_ = DismissAfterDelay(toast);
}
private async Task DismissAfterDelay(ToastItem toast)
{
await Task.Delay(3500);
toast.Dismissing = true;
await InvokeAsync(StateHasChanged);
await Task.Delay(300);
toasts.Remove(toast);
await InvokeAsync(StateHasChanged);
}
public void Dispose()
{
ToastService.OnShow -= ShowToast;
}
private sealed class ToastItem
{
public string Message { get; set; } = "";
public bool Dismissing { get; set; }
}
}
@@ -0,0 +1,61 @@
.toast-stack {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1080;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast-item {
display: flex;
align-items: center;
gap: 0.5rem;
background: #dc3545;
color: white;
padding: 0.75rem 1rem;
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: toastSlideIn 0.3s ease;
pointer-events: auto;
max-width: 400px;
font-size: 0.875rem;
}
.toast-dismiss {
animation: toastSlideOut 0.3s ease forwards;
}
.toast-icon {
font-size: 1.1rem;
flex-shrink: 0;
}
.toast-message {
flex: 1;
min-width: 0;
}
@keyframes toastSlideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes toastSlideOut {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}
+47
View File
@@ -0,0 +1,47 @@
@implements IDisposable
<DataAnnotationsValidator />
@code {
[CascadingParameter]
private EditContext? CurrentEditContext { get; set; }
[Inject]
private ToastService ToastService { get; set; } = default!;
private bool validationRequested;
protected override void OnInitialized()
{
if (CurrentEditContext is not null)
{
CurrentEditContext.OnValidationRequested += OnValidationRequested;
CurrentEditContext.OnValidationStateChanged += OnValidationStateChanged;
}
}
private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
{
validationRequested = true;
}
private void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e)
{
if (!validationRequested || CurrentEditContext is null) return;
validationRequested = false;
foreach (var message in CurrentEditContext.GetValidationMessages())
{
ToastService.Show(message);
}
}
public void Dispose()
{
if (CurrentEditContext is not null)
{
CurrentEditContext.OnValidationRequested -= OnValidationRequested;
CurrentEditContext.OnValidationStateChanged -= OnValidationStateChanged;
}
}
}
+1
View File
@@ -39,6 +39,7 @@ builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<TransactionService>();
builder.Services.AddScoped<CategoryService>();
builder.Services.AddScoped<DashboardService>();
builder.Services.AddScoped<ToastService>();
builder.Services.AddCascadingAuthenticationState();
+8
View File
@@ -0,0 +1,8 @@
namespace BudgetApp.Services;
public sealed class ToastService
{
public event Action<string>? OnShow;
public void Show(string message) => OnShow?.Invoke(message);
}
+29
View File
@@ -116,14 +116,43 @@ public class TransactionService(ApplicationDbContext db)
if (transaction is null) return;
var isPartOfSeries = transaction.RecurrenceType != RecurrenceType.None
|| transaction.ParentTransactionId is not null;
transaction.Description = description;
transaction.Amount = amount;
transaction.Date = date;
transaction.Type = type;
transaction.AccountId = accountId;
transaction.CategoryId = categoryId;
if (isPartOfSeries)
{
// Cascade edits to all future occurrences in the series
var parentId = transaction.ParentTransactionId ?? transaction.Id;
var cutoffDate = transaction.Date.Date;
var futureSiblings = await _db.Transactions
.Where(t => (t.Id == parentId || t.ParentTransactionId == parentId)
&& t.Id != id
&& t.Date.Date >= cutoffDate)
.ToListAsync();
foreach (var sibling in futureSiblings)
{
sibling.Description = description;
sibling.Amount = amount;
sibling.Type = type;
sibling.AccountId = accountId;
sibling.CategoryId = categoryId;
}
}
else
{
// Standalone transaction — preserve existing recurrence state (None)
transaction.RecurrenceType = recurrenceType;
transaction.RecurrenceEndDate = recurrenceEndDate;
}
await _db.SaveChangesAsync();
}
Binary file not shown.
Binary file not shown.
+29 -13
View File
@@ -1,5 +1,6 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
scrollbar-gutter: stable;
}
a, .btn-link {
@@ -29,11 +30,21 @@ h1:focus {
}
.invalid {
outline: 1px solid #e50000;
outline: 2px solid #dc3545;
outline-offset: -2px;
}
input.invalid:not([type="checkbox"]):not([type="radio"]):not([type="color"]),
textarea.invalid {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23dc3545' viewBox='0 0 16 16'%3E%3Cpath d='M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 1rem 1rem;
padding-right: 2.25rem;
}
.validation-message {
color: #e50000;
display: none;
}
.blazor-error-boundary {
@@ -83,23 +94,13 @@ h1:focus {
/* Cards */
.card {
transition: box-shadow 0.25s ease, transform 0.25s ease;
transition: box-shadow 0.25s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
/* Balance chips */
.balance-chip {
transition: box-shadow 0.25s ease, transform 0.25s ease;
}
.balance-chip:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
/* List group items */
.list-group-item {
transition: background-color 0.2s ease, border-left-color 0.2s ease, box-shadow 0.2s ease;
@@ -121,12 +122,19 @@ h1:focus {
/* Modal */
.modal.d-block {
animation: modalFadeIn 0.2s ease;
overflow-y: auto;
}
.modal.d-block .modal-content {
animation: modalSlideDown 0.3s ease;
}
/* Prevent body scroll shift when modal is open */
body:has(.modal.d-block) {
overflow: hidden;
padding-right: var(--scrollbar-width, 0px);
}
/* Buttons */
.btn {
transition: transform 0.15s ease, box-shadow 0.15s ease,
@@ -136,6 +144,9 @@ h1:focus {
.btn:active {
transform: scale(0.96);
/* Scale from center so layout dimensions stay fixed */
transform-origin: center;
will-change: transform;
}
/* Nav tabs */
@@ -157,3 +168,8 @@ h1:focus {
canvas {
transition: opacity 0.3s ease;
}
/* Prevent scrollbar appearance from shifting content width */
.card-body[style*="overflow-y"] {
scrollbar-gutter: stable;
}
+6 -1
View File
@@ -14,8 +14,13 @@ FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
LABEL org.opencontainers.image.title="Budgeteer" \
org.opencontainers.image.description="A personal finance dashboard built with Blazor Server (.NET 10). Multi-account support, recurring transactions, category management, balance trend charts, and ASP.NET Identity authentication." \
org.opencontainers.image.url="https://github.com/edgeofsanity76/Budgeteer" \
org.opencontainers.image.source="https://github.com/edgeofsanity76/Budgeteer" \
org.opencontainers.image.licenses="MIT"
# Ensure the SQLite data directory exists and is writable
RUN mkdir -p /app/data
ENV ConnectionStrings__DefaultConnection="Data Source=/app/data/budgetapp.db"
ENTRYPOINT ["dotnet", "BudgetApp.dll"]