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

No files matched your search

@@ -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>
+26 -20
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,31 +48,38 @@ else
{
<div class="col">
<div class="card h-100 text-center">
@if (editingCategoryId == category.Id)
{
<div class="card-body">
<div class="card-body d-flex flex-column align-items-center justify-content-center">
@if (editingCategoryId == category.Id)
{
<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">
<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 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>
</div>
</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>
<div class="card-footer bg-transparent border-0 pt-0">
<div class="d-flex gap-1 justify-content-center">
}
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>
}
+57 -89
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="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;"
@bind="windowStart" @bind:after="OnWindowDateChanged" />
<span class="text-muted">to</span>
<input type="date" class="form-control form-control-sm" style="width: auto;"
@bind="windowEnd" @bind:after="OnWindowDateChanged" />
</div>
<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">
<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,22 +99,30 @@ else
}
</InputSelect>
</div>
<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)
@if (editingTransactionId is null)
{
<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" />
<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>
@@ -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,10 +469,10 @@ else
modalForm.Description, modalForm.Amount, modalForm.Date,
modalForm.Type, modalForm.AccountId, modalForm.CategoryId,
modalForm.RecurrenceType, modalForm.RecurrenceEndDate);
}
showModal = false;
await RefreshData();
showModal = false;
await RefreshData();
}
}
// Delete confirmation state
@@ -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;
}
}
}