This commit is contained in:
rossmill76 committed 2026-03-23 15:05:57 +00:00
1 parent 2bfe914c8f
commit 4281924ee5
16 files changed
+369 -76

No files matched your search

+19 -19
View File
@@ -133,38 +133,38 @@
}
.nav-balances {
border-top: 1px solid rgba(255, 255, 255, 0.15);
padding-top: 0.75rem;
padding-bottom: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.2);
padding-top: 0.85rem;
padding-bottom: 0.85rem;
margin-top: 0.5rem;
}
.balances-header {
color: rgba(255, 255, 255, 0.5);
font-size: 0.7rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
letter-spacing: 0.07em;
margin-bottom: 0.6rem;
}
.balance-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
gap: 0.6rem;
padding: 0.4rem 0.25rem;
border-radius: 4px;
transition: background-color 0.15s;
}
.balance-item:hover {
background-color: rgba(255, 255, 255, 0.1);
background-color: rgba(255, 255, 255, 0.12);
}
.balance-color {
width: 4px;
height: 28px;
border-radius: 2px;
width: 5px;
height: 34px;
border-radius: 3px;
flex-shrink: 0;
}
@@ -175,8 +175,8 @@
}
.balance-name {
color: rgba(255, 255, 255, 0.7);
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.85);
font-size: 0.78rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
@@ -184,7 +184,7 @@
}
.balance-amount {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
font-size: 1rem;
font-weight: 700;
line-height: 1.3;
}
+39 -4
View File
@@ -139,6 +139,29 @@ else
</div>
}
@if (_nextPayday.HasValue && _predictedPaydayBalances.Count > 0)
{
<div class="card mb-4 border-success border-opacity-25">
<div class="card-body py-2 px-3">
<div class="d-flex flex-wrap align-items-center gap-3">
<span class="text-muted" style="font-size: 0.85rem;">
💰 Predicted balance before next payday <strong>@_nextPayday.Value.ToString("MMM d")</strong>:
</span>
@foreach (var account in _data!.Accounts)
{
if (_predictedPaydayBalances.TryGetValue(account.Id, out var pred))
{
<span class="fw-semibold @(pred >= 0 ? "text-success" : "text-danger")" style="font-size: 0.9rem;">
<span class="badge me-1" style="background-color: @account.Color; font-size: 0.7rem;">@account.Name</span>
@account.FormatAmount(pred)
</span>
}
}
</div>
</div>
</div>
}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<ul class="nav nav-tabs card-header-tabs" style="flex-grow: 0;">
@@ -184,8 +207,13 @@ else
else
{
<div class="list-group list-group-flush">
@foreach (var t in FilteredRecent.Skip((_recentPage - 1) * PageSize).Take(PageSize))
@foreach (var group in GroupedRecentPage)
{
<div class="list-group-item py-1 px-3" style="background: rgba(0,0,0,0.03); border-left: none; font-size: 0.78rem;">
<span class="text-muted fw-semibold">@group.Key.ToString("dddd, d MMMM")</span>
</div>
@foreach (var t in OrderGroupByIncomeFirst(group))
{
<div class="list-group-item list-group-item-action txn-row @(_selectedTransactionId == t.Id ? "txn-row-selected" : "")"
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
@onclick="() => SelectTransaction(t.Id)">
@@ -237,8 +265,9 @@ else
}
</div>
}
</div>
@if (RecentTotalPages > 1)
}
</div>
@if (RecentTotalPages > 1)
{
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
<button class="btn btn-sm btn-outline-secondary" disabled="@(_recentPage <= 1 ? true : (bool?)null)" @onclick="() => _recentPage--">← Prev</button>
@@ -257,8 +286,13 @@ else
else
{
<div class="list-group list-group-flush">
@foreach (var t in FilteredUpcoming.Skip((_upcomingPage - 1) * PageSize).Take(PageSize))
@foreach (var group in GroupedUpcomingPage)
{
<div class="list-group-item py-1 px-3" style="background: rgba(0,0,0,0.03); border-left: none; font-size: 0.78rem;">
<span class="text-muted fw-semibold">@group.Key.ToString("dddd, d MMMM")</span>
</div>
@foreach (var t in OrderGroupByIncomeFirst(group))
{
<div class="list-group-item list-group-item-action txn-row @(_selectedTransactionId == t.Id ? "txn-row-selected" : "")"
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
@onclick="() => SelectTransaction(t.Id)">
@@ -307,6 +341,7 @@ else
</div>
}
</div>
}
}
</div>
@if (UpcomingTotalPages > 1)
+55 -1
View File
@@ -67,6 +67,26 @@ public partial class Home
private int RecentTotalPages => (int)Math.Ceiling(FilteredRecent.Count / (double)PageSize);
private int UpcomingTotalPages => (int)Math.Ceiling(FilteredUpcoming.Count / (double)PageSize);
private DateTime? _nextPayday;
private Dictionary<int, decimal> _predictedPaydayBalances = new();
private IEnumerable<IGrouping<DateTime, Transaction>> GroupedRecentPage =>
FilteredRecent
.Skip((_recentPage - 1) * PageSize)
.Take(PageSize)
.GroupBy(t => t.Date.Date)
.OrderByDescending(g => g.Key);
private IEnumerable<IGrouping<DateTime, Transaction>> GroupedUpcomingPage =>
FilteredUpcoming
.Skip((_upcomingPage - 1) * PageSize)
.Take(PageSize)
.GroupBy(t => t.Date.Date)
.OrderBy(g => g.Key);
private static IOrderedEnumerable<Transaction> OrderGroupByIncomeFirst(IGrouping<DateTime, Transaction> group) =>
group.OrderByDescending(t => t.Type == TransactionType.Income).ThenBy(t => t.Description);
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
@@ -291,14 +311,48 @@ public partial class Home
private async Task RefreshData()
{
if (_userId is null) return;
await SalaryService.MaterializeSalaryTransactionsAsync(_userId, _windowEnd);
var materializeEnd = _windowEnd > DateTime.Today.AddDays(90) ? _windowEnd : DateTime.Today.AddDays(90);
await SalaryService.MaterializeSalaryTransactionsAsync(_userId, materializeEnd);
_data = await DashboardService.GetDashboardDataAsync(_userId, _windowStart, _windowEnd);
_chartDataVersion++;
ComputeProjectedBalances();
await ComputeNextPaydayBalance();
_recentPage = 1;
_upcomingPage = 1;
}
private async Task ComputeNextPaydayBalance()
{
_nextPayday = null;
_predictedPaydayBalances.Clear();
if (_data is null || _userId is null) return;
var salaries = await SalaryService.GetSalariesAsync(_userId);
DateTime? soonest = null;
foreach (var s in salaries)
{
var next = await SalaryService.GetNextPaydateAsync(s.Id);
if (next.HasValue && next.Value.Date > DateTime.Today)
{
if (soonest is null || next.Value.Date < soonest.Value.Date)
soonest = next.Value.Date;
}
}
_nextPayday = soonest;
if (_nextPayday is null) return;
var txnsToPayday = await TransactionService.GetTransactionsInRangeAsync(
_userId, DateTime.Today.AddDays(1), _nextPayday.Value);
foreach (var account in _data.Accounts)
{
var balance = account.CurrentBalance;
foreach (var t in txnsToPayday.Where(t => t.AccountId == account.Id && !t.IsPaid))
balance += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
_predictedPaydayBalances[account.Id] = balance;
}
}
private async Task SetWindow(int days)
{
_windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => "custom" };
+59
View File
@@ -119,6 +119,7 @@ else
}
</td>
<td class="text-end text-nowrap">
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => OpenEditModal(s)">✏️ Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(s)">Delete</button>
</td>
</tr>
@@ -155,6 +156,64 @@ else
</div>
}
@if (_showEditModal)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Income Source</h5>
<button type="button" class="btn-close" @onclick="CancelEdit"></button>
</div>
<EditForm Model="_editFormModel" OnValidSubmit="SaveEdit" FormName="editSalary">
<ToastValidator />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name</label>
<InputText @bind-Value="_editFormModel.Name" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">Amount</label>
<InputNumber @bind-Value="_editFormModel.Amount" class="form-control numeric-input" />
</div>
<div class="mb-3">
<label class="form-label">Account</label>
<InputSelect @bind-Value="_editFormModel.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">Recurrence</label>
<InputSelect @bind-Value="_editFormModel.RecurrenceType" class="form-select">
<option value="@RecurrenceType.Weekly">Weekly</option>
<option value="@RecurrenceType.Monthly">Monthly</option>
<option value="@RecurrenceType.Yearly">Yearly</option>
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">First Payday</label>
<InputDate @bind-Value="_editFormModel.StartDate" class="form-control" />
</div>
<p class="text-muted mb-0" style="font-size: 0.85rem;">
Saving will delete all future unpaid salary transactions and regenerate them with the updated settings.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CancelEdit">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
// Delete state
@@ -67,6 +67,44 @@ public partial class Salary
private bool _showDeleteModal;
private Data.Entities.Salary? _pendingDeleteSalary;
private bool _showEditModal;
private int _editSalaryId;
private SalaryForm _editFormModel = new();
private void OpenEditModal(Data.Entities.Salary s)
{
_editSalaryId = s.Id;
_editFormModel = new SalaryForm
{
Name = s.Name,
Amount = s.Amount,
AccountId = s.AccountId,
RecurrenceType = s.RecurrenceType,
StartDate = s.StartDate
};
_showEditModal = true;
}
private void CancelEdit() => _showEditModal = false;
private async Task SaveEdit()
{
if (_userId is null) return;
var salary = new Data.Entities.Salary
{
Id = _editSalaryId,
Name = _editFormModel.Name,
Amount = _editFormModel.Amount,
AccountId = _editFormModel.AccountId,
RecurrenceType = _editFormModel.RecurrenceType,
StartDate = _editFormModel.StartDate,
UserId = _userId
};
await SalaryService.UpdateSalaryAsync(salary, DateTime.Today.AddDays(90), _userId);
_showEditModal = false;
await LoadData();
}
private void ConfirmDelete(Data.Entities.Salary s)
{
_pendingDeleteSalary = s;
+56 -47
View File
@@ -6,6 +6,7 @@
@inject ICategoryRepository CategoryService
@inject ISalaryRepository SalaryService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@rendermode InteractiveServer
<PageTitle>Transactions</PageTitle>
@@ -29,26 +30,26 @@
<EditForm Model="_formModel" OnValidSubmit="AddTransaction" FormName="addTransaction">
<ToastValidator />
<div class="row g-3">
<div class="col-md-4">
<div class="col-12 col-sm-6 col-md-4">
<label class="form-label">Description</label>
<InputText @bind-Value="_formModel.Description" class="form-control" placeholder="e.g. Food shopping" title="A short description of the transaction" />
</div>
<div class="col-md-2">
<div class="col-6 col-sm-4 col-md-2">
<label class="form-label">Amount</label>
<InputNumber @bind-Value="_formModel.Amount" class="form-control numeric-input" title="Enter a positive amount — the type determines income or expense" />
</div>
<div class="col-md-2">
<div class="col-6 col-sm-4 col-md-2">
<label class="form-label">Date</label>
<InputDate @bind-Value="_formModel.Date" class="form-control" />
</div>
<div class="col-md-2">
<div class="col-6 col-sm-4 col-md-2">
<label class="form-label">Type</label>
<InputSelect @bind-Value="_formModel.Type" class="form-select">
<option value="@TransactionType.Expense">Expense</option>
<option value="@TransactionType.Income">Income</option>
</InputSelect>
</div>
<div class="col-md-2">
<div class="col-6 col-sm-4 col-md-2">
<label class="form-label">Account</label>
<InputSelect @bind-Value="_formModel.AccountId" class="form-select">
@if (_accountsList is not null)
@@ -60,7 +61,7 @@
}
</InputSelect>
</div>
<div class="col-md-4">
<div class="col-12 col-sm-6 col-md-4">
<label class="form-label">Category</label>
<InputSelect @bind-Value="_formModel.CategoryId" class="form-select">
<option value="">— None —</option>
@@ -73,7 +74,7 @@
}
</InputSelect>
</div>
<div class="col-md-2">
<div class="col-6 col-sm-4 col-md-2">
<label class="form-label">Recurrence</label>
<InputSelect @bind-Value="_formModel.RecurrenceType" class="form-select" title="Set a recurring schedule — future occurrences are generated automatically">
<option value="@RecurrenceType.None">None</option>
@@ -85,12 +86,12 @@
</div>
@if (_formModel.RecurrenceType != RecurrenceType.None)
{
<div class="col-md-2">
<div class="col-6 col-sm-4 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")" />
</div>
}
<div class="col-md-2 d-flex align-items-end">
<div class="col-12 col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Add</button>
</div>
</div>
@@ -153,6 +154,9 @@ else
}
</select>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-outline-success" @onclick="ExportCsv" title="Export filtered transactions to CSV">⬇ CSV</button>
</div>
</div>
</div>
</div>
@@ -162,23 +166,22 @@ else
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 2rem;">
<th style="width:2rem;">
<input type="checkbox" class="form-check-input" title="Select all transactions on this page" checked="@IsAllPageSelected()" @onchange="ToggleSelectAll" />
</th>
<th role="button" class="user-select-none" title="Sort by date" @onclick='() => SetSort("date")'>Date @SortIndicator("date")</th>
<th role="button" class="user-select-none text-nowrap" style="width:7.5rem;" title="Sort by date" @onclick='() => SetSort("date")'>Date @SortIndicator("date")</th>
<th role="button" class="user-select-none" title="Sort by description" @onclick='() => SetSort("description")'>Description @SortIndicator("description")</th>
<th>Account</th>
<th class="d-none d-md-table-cell">Category</th>
<th class="d-none d-md-table-cell">Recurrence</th>
<th role="button" class="user-select-none text-end" title="Sort by amount" @onclick='() => SetSort("amount")'>Amount @SortIndicator("amount")</th>
<th></th>
<th style="width:8rem;">Account</th>
<th class="d-none d-md-table-cell" style="width:9rem;">Category</th>
<th class="d-none d-md-table-cell" style="width:7rem;">Recurrence</th>
<th role="button" class="user-select-none text-end text-nowrap" style="width:7rem;" title="Sort by amount" @onclick='() => SetSort("amount")'>Amount @SortIndicator("amount")</th>
</tr>
</thead>
<tbody>
@foreach (var t in PagedTransactions)
{
<tr>
<td>
<tr class="row-clickable" @onclick="() => OpenEditModal(t)">
<td @onclick:stopPropagation="true">
@if (t.SalaryId is null)
{
<input type="checkbox" class="form-check-input" checked="@_selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" />
@@ -195,10 +198,6 @@ else
{
<span class="badge bg-primary ms-1" style="font-size: 0.7rem;">💰 Salary</span>
}
@if (t.IsPaid && t.Date.Date > DateTime.Today)
{
<span class="badge bg-success ms-1" style="font-size: 0.7rem;">✓ Paid</span>
}
</td>
<td>
<span class="badge" style="background-color: @t.Account.Color;">
@@ -228,31 +227,16 @@ else
{
<span class="badge bg-info">🔁 @(t.ParentTransaction?.RecurrenceType.ToString() ?? "Recurring")</span>
}
else
{
<span class="text-muted" style="font-size:0.8rem;">None</span>
}
</td>
<td class="text-end text-nowrap @(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold">
@(t.Type == TransactionType.Income ? "+" : "-")@t.Account.FormatAmount(t.Amount)
</td>
<td class="text-end text-nowrap">
<div class="btn-group btn-group-sm">
@if (t.Date.Date > DateTime.Today)
{
<button class="btn @(t.IsPaid ? "btn-success" : "btn-outline-success")"
title="@(t.IsPaid ? "Mark as unpaid" : "Mark as paid")"
@onclick="() => TogglePaid(t)">✓</button>
}
<button class="btn btn-outline-primary" @onclick="() => OpenEditModal(t)">Edit</button>
@if (t.SalaryId is null)
{
<button class="btn btn-outline-danger" @onclick="() => ConfirmDelete(t)">Delete</button>
}
else
{
<a href="/salary" class="btn btn-outline-secondary" title="Salary transactions are managed on the Salary page">🔒</a>
}
</div>
</td>
</tr>
}
</tr>
}
</tbody>
</table>
</div>
@@ -391,16 +375,41 @@ else
}
</InputSelect>
</div>
@if (_editIsPartOfSeries)
@if (_editingTransaction?.SalaryId is not null)
{
<div class="alert alert-warning mb-3">
This is a salary transaction. To modify it, use the <a href="/salary">Salary page</a>.
</div>
}
else if (_editIsPartOfSeries)
{
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i> Changes will apply to this and all future occurrences in the series.
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseEditModal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
<div class="modal-footer justify-content-between">
<div class="d-flex gap-2">
@if (_editingTransaction is not null && _editingTransaction.SalaryId is null)
{
<button type="button" class="btn btn-outline-danger"
@onclick="() => { var tx = _editingTransaction!; CloseEditModal(); ConfirmDelete(tx); }">Delete</button>
}
@if (_editingTransaction is not null && _editingTransaction.Date.Date > DateTime.Today)
{
<button type="button" class="btn @(_editingTransaction.IsPaid ? "btn-success" : "btn-outline-success")"
@onclick="async () => { var tx = _editingTransaction!; CloseEditModal(); await TogglePaid(tx); }">
@(_editingTransaction.IsPaid ? "✓ Paid" : "Mark Paid")
</button>
}
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-secondary" @onclick="CloseEditModal">Cancel</button>
@if (_editingTransaction?.SalaryId is null)
{
<button type="submit" class="btn btn-primary">Save</button>
}
</div>
</div>
</EditForm>
</div>
@@ -1,5 +1,6 @@
using BudgetApp.Data.Entities;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace BudgetApp.Components.Pages;
@@ -200,12 +201,14 @@ public partial class Transactions
private string _deleteModalTitle = "Delete Transaction";
private bool _showEditModal;
private int? _editingTransactionId;
private Transaction? _editingTransaction;
private bool _editIsPartOfSeries;
private TransactionForm _editModel = new();
private void OpenEditModal(Transaction t)
{
_editingTransactionId = t.Id;
_editingTransaction = t;
_editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
_editModel = new TransactionForm
{
@@ -221,11 +224,15 @@ public partial class Transactions
_showEditModal = true;
}
private void CloseEditModal() => _showEditModal = false;
private void CloseEditModal()
{
_showEditModal = false;
_editingTransaction = null;
}
private async Task SaveEdit()
{
if (_userId is null || _editingTransactionId is null) return;
if (_userId is null || _editingTransactionId is null || _editingTransaction?.SalaryId is not null) return;
await TransactionService.UpdateTransactionAsync(
_editingTransactionId.Value, _userId,
@@ -305,7 +312,28 @@ public partial class Transactions
await LoadData();
}
private sealed class TransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
private async Task ExportCsv()
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("Date,Description,Type,Amount,Account,Category,Recurrence");
foreach (var t in FilteredTransactions)
{
var category = t.Category?.Name ?? "";
var recurrence = t.RecurrenceType != RecurrenceType.None ? t.RecurrenceType.ToString() : "";
sb.AppendLine($"{t.Date:yyyy-MM-dd},{CsvEscape(t.Description)},{t.Type},{t.Amount:F2},{CsvEscape(t.Account.Name)},{CsvEscape(category)},{recurrence}");
}
var filename = $"transactions_{DateTime.Today:yyyy-MM-dd}.csv";
await JS.InvokeVoidAsync("downloadCsv", filename, sb.ToString());
}
private static string CsvEscape(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"\"")}\"";
return value;
}
private sealed class TransactionForm
{
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
[System.ComponentModel.DataAnnotations.MaxLength(200)]
@@ -1,3 +1,19 @@
::deep .table {
table-layout: fixed;
min-width: 480px;
}
::deep .table td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Actions column: prevent clipping and centre buttons */
::deep .row-clickable {
cursor: pointer;
}
@media (max-width: 767.98px) {
::deep .table > :not(caption) > * > * {
padding: 0.3rem 0.4rem;