Updates
This commit is contained in:
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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user