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

@@ -6,6 +6,7 @@ public interface ISalaryRepository
{ {
Task<List<Salary>> GetSalariesAsync(string userId); Task<List<Salary>> GetSalariesAsync(string userId);
Task CreateSalaryAsync(Salary salary, DateTime windowEnd); Task CreateSalaryAsync(Salary salary, DateTime windowEnd);
Task UpdateSalaryAsync(Salary salary, DateTime windowEnd, string userId);
Task DeleteSalaryAsync(int id, string userId); Task DeleteSalaryAsync(int id, string userId);
Task MaterializeSalaryTransactionsAsync(string userId, DateTime windowEnd); Task MaterializeSalaryTransactionsAsync(string userId, DateTime windowEnd);
Task<DateTime?> GetNextPaydateAsync(int salaryId); Task<DateTime?> GetNextPaydateAsync(int salaryId);
@@ -29,6 +29,27 @@ public class SalaryRepository(ApplicationDbContext db) : ISalaryRepository
await MaterializeSalaryTransactionsAsync(salary.UserId, windowEnd); await MaterializeSalaryTransactionsAsync(salary.UserId, windowEnd);
} }
public async Task UpdateSalaryAsync(Salary salary, DateTime windowEnd, string userId)
{
var existing = await db.Salaries
.FirstOrDefaultAsync(s => s.Id == salary.Id && s.UserId == userId);
if (existing is null) return;
existing.Name = salary.Name;
existing.Amount = salary.Amount;
existing.AccountId = salary.AccountId;
existing.RecurrenceType = salary.RecurrenceType;
existing.StartDate = salary.StartDate;
await db.SaveChangesAsync();
// Delete future unpaid transactions and re-materialise with the updated settings
await db.Transactions
.Where(t => t.SalaryId == salary.Id && t.Date.Date > DateTime.Today && !t.IsPaid)
.ExecuteDeleteAsync();
await MaterializeSalaryTransactionsAsync(userId, windowEnd);
}
public async Task DeleteSalaryAsync(int id, string userId) public async Task DeleteSalaryAsync(int id, string userId)
{ {
var exists = await db.Salaries.AnyAsync(s => s.Id == id && s.UserId == userId); var exists = await db.Salaries.AnyAsync(s => s.Id == id && s.UserId == userId);
+19 -19
View File
@@ -133,38 +133,38 @@
} }
.nav-balances { .nav-balances {
border-top: 1px solid rgba(255, 255, 255, 0.15); border-top: 1px solid rgba(255, 255, 255, 0.2);
padding-top: 0.75rem; padding-top: 0.85rem;
padding-bottom: 0.75rem; padding-bottom: 0.85rem;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.balances-header { .balances-header {
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.6);
font-size: 0.7rem; font-size: 0.72rem;
font-weight: 600; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.07em;
margin-bottom: 0.5rem; margin-bottom: 0.6rem;
} }
.balance-item { .balance-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.6rem;
padding: 0.35rem 0; padding: 0.4rem 0.25rem;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.15s; transition: background-color 0.15s;
} }
.balance-item:hover { .balance-item:hover {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.12);
} }
.balance-color { .balance-color {
width: 4px; width: 5px;
height: 28px; height: 34px;
border-radius: 2px; border-radius: 3px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -175,8 +175,8 @@
} }
.balance-name { .balance-name {
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.85);
font-size: 0.75rem; font-size: 0.78rem;
line-height: 1.2; line-height: 1.2;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -184,7 +184,7 @@
} }
.balance-amount { .balance-amount {
font-size: 0.85rem; font-size: 1rem;
font-weight: 600; font-weight: 700;
line-height: 1.2; line-height: 1.3;
} }
+39 -4
View File
@@ -139,6 +139,29 @@ else
</div> </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 mb-4">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2"> <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;"> <ul class="nav nav-tabs card-header-tabs" style="flex-grow: 0;">
@@ -184,8 +207,13 @@ else
else else
{ {
<div class="list-group list-group-flush"> <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" : "")" <div class="list-group-item list-group-item-action txn-row @(_selectedTransactionId == t.Id ? "txn-row-selected" : "")"
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");" style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
@onclick="() => SelectTransaction(t.Id)"> @onclick="() => SelectTransaction(t.Id)">
@@ -237,8 +265,9 @@ else
} }
</div> </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"> <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> <button class="btn btn-sm btn-outline-secondary" disabled="@(_recentPage <= 1 ? true : (bool?)null)" @onclick="() => _recentPage--">← Prev</button>
@@ -257,8 +286,13 @@ else
else else
{ {
<div class="list-group list-group-flush"> <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" : "")" <div class="list-group-item list-group-item-action txn-row @(_selectedTransactionId == t.Id ? "txn-row-selected" : "")"
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");" style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
@onclick="() => SelectTransaction(t.Id)"> @onclick="() => SelectTransaction(t.Id)">
@@ -307,6 +341,7 @@ else
</div> </div>
} }
</div> </div>
}
} }
</div> </div>
@if (UpcomingTotalPages > 1) @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 RecentTotalPages => (int)Math.Ceiling(FilteredRecent.Count / (double)PageSize);
private int UpcomingTotalPages => (int)Math.Ceiling(FilteredUpcoming.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() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
@@ -291,14 +311,48 @@ public partial class Home
private async Task RefreshData() private async Task RefreshData()
{ {
if (_userId is null) return; 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); _data = await DashboardService.GetDashboardDataAsync(_userId, _windowStart, _windowEnd);
_chartDataVersion++; _chartDataVersion++;
ComputeProjectedBalances(); ComputeProjectedBalances();
await ComputeNextPaydayBalance();
_recentPage = 1; _recentPage = 1;
_upcomingPage = 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) private async Task SetWindow(int days)
{ {
_windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => "custom" }; _windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => "custom" };
+59
View File
@@ -119,6 +119,7 @@ else
} }
</td> </td>
<td class="text-end text-nowrap"> <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> <button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(s)">Delete</button>
</td> </td>
</tr> </tr>
@@ -155,6 +156,64 @@ else
</div> </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 { @code {
// Delete state // Delete state
@@ -67,6 +67,44 @@ public partial class Salary
private bool _showDeleteModal; private bool _showDeleteModal;
private Data.Entities.Salary? _pendingDeleteSalary; 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) private void ConfirmDelete(Data.Entities.Salary s)
{ {
_pendingDeleteSalary = s; _pendingDeleteSalary = s;
+56 -47
View File
@@ -6,6 +6,7 @@
@inject ICategoryRepository CategoryService @inject ICategoryRepository CategoryService
@inject ISalaryRepository SalaryService @inject ISalaryRepository SalaryService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@rendermode InteractiveServer @rendermode InteractiveServer
<PageTitle>Transactions</PageTitle> <PageTitle>Transactions</PageTitle>
@@ -29,26 +30,26 @@
<EditForm Model="_formModel" OnValidSubmit="AddTransaction" FormName="addTransaction"> <EditForm Model="_formModel" OnValidSubmit="AddTransaction" FormName="addTransaction">
<ToastValidator /> <ToastValidator />
<div class="row g-3"> <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> <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" /> <InputText @bind-Value="_formModel.Description" class="form-control" placeholder="e.g. Food shopping" title="A short description of the transaction" />
</div> </div>
<div class="col-md-2"> <div class="col-6 col-sm-4 col-md-2">
<label class="form-label">Amount</label> <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" /> <InputNumber @bind-Value="_formModel.Amount" class="form-control numeric-input" title="Enter a positive amount — the type determines income or expense" />
</div> </div>
<div class="col-md-2"> <div class="col-6 col-sm-4 col-md-2">
<label class="form-label">Date</label> <label class="form-label">Date</label>
<InputDate @bind-Value="_formModel.Date" class="form-control" /> <InputDate @bind-Value="_formModel.Date" class="form-control" />
</div> </div>
<div class="col-md-2"> <div class="col-6 col-sm-4 col-md-2">
<label class="form-label">Type</label> <label class="form-label">Type</label>
<InputSelect @bind-Value="_formModel.Type" class="form-select"> <InputSelect @bind-Value="_formModel.Type" class="form-select">
<option value="@TransactionType.Expense">Expense</option> <option value="@TransactionType.Expense">Expense</option>
<option value="@TransactionType.Income">Income</option> <option value="@TransactionType.Income">Income</option>
</InputSelect> </InputSelect>
</div> </div>
<div class="col-md-2"> <div class="col-6 col-sm-4 col-md-2">
<label class="form-label">Account</label> <label class="form-label">Account</label>
<InputSelect @bind-Value="_formModel.AccountId" class="form-select"> <InputSelect @bind-Value="_formModel.AccountId" class="form-select">
@if (_accountsList is not null) @if (_accountsList is not null)
@@ -60,7 +61,7 @@
} }
</InputSelect> </InputSelect>
</div> </div>
<div class="col-md-4"> <div class="col-12 col-sm-6 col-md-4">
<label class="form-label">Category</label> <label class="form-label">Category</label>
<InputSelect @bind-Value="_formModel.CategoryId" class="form-select"> <InputSelect @bind-Value="_formModel.CategoryId" class="form-select">
<option value="">— None —</option> <option value="">— None —</option>
@@ -73,7 +74,7 @@
} }
</InputSelect> </InputSelect>
</div> </div>
<div class="col-md-2"> <div class="col-6 col-sm-4 col-md-2">
<label class="form-label">Recurrence</label> <label class="form-label">Recurrence</label>
<InputSelect @bind-Value="_formModel.RecurrenceType" class="form-select" title="Set a recurring schedule — future occurrences are generated automatically"> <InputSelect @bind-Value="_formModel.RecurrenceType" class="form-select" title="Set a recurring schedule — future occurrences are generated automatically">
<option value="@RecurrenceType.None">None</option> <option value="@RecurrenceType.None">None</option>
@@ -85,12 +86,12 @@
</div> </div>
@if (_formModel.RecurrenceType != RecurrenceType.None) @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> <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")" /> <InputDate @bind-Value="_formModel.RecurrenceEndDate" class="form-control" max="@_formModel.Date.AddYears(1).ToString("yyyy-MM-dd")" />
</div> </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> <button type="submit" class="btn btn-primary w-100">Add</button>
</div> </div>
</div> </div>
@@ -153,6 +154,9 @@ else
} }
</select> </select>
</div> </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> </div>
</div> </div>
@@ -162,23 +166,22 @@ else
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <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" /> <input type="checkbox" class="form-check-input" title="Select all transactions on this page" checked="@IsAllPageSelected()" @onchange="ToggleSelectAll" />
</th> </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 role="button" class="user-select-none" title="Sort by description" @onclick='() => SetSort("description")'>Description @SortIndicator("description")</th>
<th>Account</th> <th style="width:8rem;">Account</th>
<th class="d-none d-md-table-cell">Category</th> <th class="d-none d-md-table-cell" style="width:9rem;">Category</th>
<th class="d-none d-md-table-cell">Recurrence</th> <th class="d-none d-md-table-cell" style="width:7rem;">Recurrence</th>
<th role="button" class="user-select-none text-end" title="Sort by amount" @onclick='() => SetSort("amount")'>Amount @SortIndicator("amount")</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>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var t in PagedTransactions) @foreach (var t in PagedTransactions)
{ {
<tr> <tr class="row-clickable" @onclick="() => OpenEditModal(t)">
<td> <td @onclick:stopPropagation="true">
@if (t.SalaryId is null) @if (t.SalaryId is null)
{ {
<input type="checkbox" class="form-check-input" checked="@_selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" /> <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> <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>
<td> <td>
<span class="badge" style="background-color: @t.Account.Color;"> <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> <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>
<td class="text-end text-nowrap @(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold"> <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) @(t.Type == TransactionType.Income ? "+" : "-")@t.Account.FormatAmount(t.Amount)
</td> </td>
<td class="text-end text-nowrap"> </tr>
<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>
}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -391,16 +375,41 @@ else
} }
</InputSelect> </InputSelect>
</div> </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"> <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. <i class="bi bi-info-circle"></i> Changes will apply to this and all future occurrences in the series.
</div> </div>
} }
</div> </div>
<div class="modal-footer"> <div class="modal-footer justify-content-between">
<button type="button" class="btn btn-secondary" @onclick="CloseEditModal">Cancel</button> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button> @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> </div>
</EditForm> </EditForm>
</div> </div>
@@ -1,5 +1,6 @@
using BudgetApp.Data.Entities; using BudgetApp.Data.Entities;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace BudgetApp.Components.Pages; namespace BudgetApp.Components.Pages;
@@ -200,12 +201,14 @@ public partial class Transactions
private string _deleteModalTitle = "Delete Transaction"; private string _deleteModalTitle = "Delete Transaction";
private bool _showEditModal; private bool _showEditModal;
private int? _editingTransactionId; private int? _editingTransactionId;
private Transaction? _editingTransaction;
private bool _editIsPartOfSeries; private bool _editIsPartOfSeries;
private TransactionForm _editModel = new(); private TransactionForm _editModel = new();
private void OpenEditModal(Transaction t) private void OpenEditModal(Transaction t)
{ {
_editingTransactionId = t.Id; _editingTransactionId = t.Id;
_editingTransaction = t;
_editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null; _editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
_editModel = new TransactionForm _editModel = new TransactionForm
{ {
@@ -221,11 +224,15 @@ public partial class Transactions
_showEditModal = true; _showEditModal = true;
} }
private void CloseEditModal() => _showEditModal = false; private void CloseEditModal()
{
_showEditModal = false;
_editingTransaction = null;
}
private async Task SaveEdit() 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( await TransactionService.UpdateTransactionAsync(
_editingTransactionId.Value, _userId, _editingTransactionId.Value, _userId,
@@ -305,7 +312,28 @@ public partial class Transactions
await LoadData(); 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.Required(ErrorMessage = "Description is required")]
[System.ComponentModel.DataAnnotations.MaxLength(200)] [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) { @media (max-width: 767.98px) {
::deep .table > :not(caption) > * > * { ::deep .table > :not(caption) > * > * {
padding: 0.3rem 0.4rem; padding: 0.3rem 0.4rem;
+2
View File
@@ -71,6 +71,7 @@ if (!string.IsNullOrEmpty(googleSection["ClientId"]) && !string.IsNullOrEmpty(go
{ {
options.ClientId = googleSection["ClientId"]!; options.ClientId = googleSection["ClientId"]!;
options.ClientSecret = googleSection["ClientSecret"]!; options.ClientSecret = googleSection["ClientSecret"]!;
options.SignInScheme = IdentityConstants.ApplicationScheme;
}); });
} }
@@ -168,6 +169,7 @@ app.MapGet("/api/auth/external-callback", async (
string? returnUrl) => string? returnUrl) =>
{ {
returnUrl ??= "/"; returnUrl ??= "/";
var info = await signInManager.GetExternalLoginInfoAsync(); var info = await signInManager.GetExternalLoginInfoAsync();
if (info is null) if (info is null)
return Results.Redirect("/login"); return Results.Redirect("/login");
+2 -2
View File
@@ -18,8 +18,8 @@
}, },
"Authentication": { "Authentication": {
"Google": { "Google": {
"ClientId": "", "ClientId": "666457807183-elgmh4qqi0ms7mqd43v8pbls2n3laoai.apps.googleusercontent.com",
"ClientSecret": "" "ClientSecret": "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"
}, },
"Microsoft": { "Microsoft": {
"ClientId": "", "ClientId": "",
Binary file not shown.
Binary file not shown.
+22
View File
@@ -90,6 +90,14 @@ window.chartInterop = {
const storedLabels = this.transactionLabels[canvasId]; const storedLabels = this.transactionLabels[canvasId];
const salaryIndices = salaryDayIndices || []; const salaryIndices = salaryDayIndices || [];
const allVals = datasets.flatMap(ds => ds.data);
const yMin = allVals.length ? Math.min(...allVals) : 0;
const yMax = allVals.length ? Math.max(...allVals) : 0;
const yRange = yMax - yMin;
const yPad = yRange > 0 ? yRange * 0.15 : (Math.abs(yMin) * 0.15 || 100);
const ySuggestedMin = yMin - yPad;
const ySuggestedMax = yMax + yPad;
// Salary payday highlight plugin — subtle green band + 💰 marker // Salary payday highlight plugin — subtle green band + 💰 marker
const salaryPlugin = { const salaryPlugin = {
id: 'salaryDays', id: 'salaryDays',
@@ -158,6 +166,8 @@ window.chartInterop = {
} }
}, },
y: { y: {
suggestedMin: ySuggestedMin,
suggestedMax: ySuggestedMax,
ticks: { ticks: {
callback: function (value) { callback: function (value) {
return sym + value.toFixed(0); return sym + value.toFixed(0);
@@ -306,3 +316,15 @@ document.addEventListener('focusin', function (e) {
e.target.select(); e.target.select();
} }
}); });
window.downloadCsv = function (filename, content) {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
+8
View File
@@ -190,6 +190,14 @@ BudgetApp.Tests/
│ └── TransactionServiceTests.cs │ └── TransactionServiceTests.cs
``` ```
### Project Roles
- **BudgetApp**: Main Blazor Server application. Contains the UI, interactive pages, layouts, authentication logic, and application services. This is the entry point for running the app and includes configuration, dependency injection, and SSL setup.
- **BudgetApp.Data**: Data access layer. Provides EF Core entities, the ApplicationDbContext, repository interfaces and implementations, and database migrations. Handles all database operations and schema management.
- **BudgetApp.Tests**: Unit tests for services, models, and data access. Ensures correctness of business logic and data access. Uses xUnit for test execution.
## Configuration ## Configuration
Default connection string in `appsettings.json`: Default connection string in `appsettings.json`: