diff --git a/BudgetApp.Data/Interfaces/ISalaryRepository.cs b/BudgetApp.Data/Interfaces/ISalaryRepository.cs index 0fe2096..0ffdb3d 100644 --- a/BudgetApp.Data/Interfaces/ISalaryRepository.cs +++ b/BudgetApp.Data/Interfaces/ISalaryRepository.cs @@ -6,6 +6,7 @@ public interface ISalaryRepository { Task> GetSalariesAsync(string userId); Task CreateSalaryAsync(Salary salary, DateTime windowEnd); + Task UpdateSalaryAsync(Salary salary, DateTime windowEnd, string userId); Task DeleteSalaryAsync(int id, string userId); Task MaterializeSalaryTransactionsAsync(string userId, DateTime windowEnd); Task GetNextPaydateAsync(int salaryId); diff --git a/BudgetApp.Data/Repositories/SalaryRepository.cs b/BudgetApp.Data/Repositories/SalaryRepository.cs index f8d6825..eb81fb7 100644 --- a/BudgetApp.Data/Repositories/SalaryRepository.cs +++ b/BudgetApp.Data/Repositories/SalaryRepository.cs @@ -29,6 +29,27 @@ public class SalaryRepository(ApplicationDbContext db) : ISalaryRepository 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) { var exists = await db.Salaries.AnyAsync(s => s.Id == id && s.UserId == userId); diff --git a/BudgetApp/Components/Layout/NavMenu.razor.css b/BudgetApp/Components/Layout/NavMenu.razor.css index dcfc2f6..e3fd9de 100644 --- a/BudgetApp/Components/Layout/NavMenu.razor.css +++ b/BudgetApp/Components/Layout/NavMenu.razor.css @@ -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; } diff --git a/BudgetApp/Components/Pages/Home.razor b/BudgetApp/Components/Pages/Home.razor index 5610017..14074b3 100644 --- a/BudgetApp/Components/Pages/Home.razor +++ b/BudgetApp/Components/Pages/Home.razor @@ -139,6 +139,29 @@ else } + @if (_nextPayday.HasValue && _predictedPaydayBalances.Count > 0) + { +
+
+
+ + 💰 Predicted balance before next payday @_nextPayday.Value.ToString("MMM d"): + + @foreach (var account in _data!.Accounts) + { + if (_predictedPaydayBalances.TryGetValue(account.Id, out var pred)) + { + + @account.Name + @account.FormatAmount(pred) + + } + } +
+
+
+ } +
+ @if (RecentTotalPages > 1) {
@@ -257,8 +286,13 @@ else else {
- @foreach (var t in FilteredUpcoming.Skip((_upcomingPage - 1) * PageSize).Take(PageSize)) + @foreach (var group in GroupedUpcomingPage) { +
+ @group.Key.ToString("dddd, d MMMM") +
+ @foreach (var t in OrderGroupByIncomeFirst(group)) + {
@@ -307,6 +341,7 @@ else
}
+ } }
@if (UpcomingTotalPages > 1) diff --git a/BudgetApp/Components/Pages/Home.razor.cs b/BudgetApp/Components/Pages/Home.razor.cs index c1a3a14..d1cf668 100644 --- a/BudgetApp/Components/Pages/Home.razor.cs +++ b/BudgetApp/Components/Pages/Home.razor.cs @@ -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 _predictedPaydayBalances = new(); + + private IEnumerable> GroupedRecentPage => + FilteredRecent + .Skip((_recentPage - 1) * PageSize) + .Take(PageSize) + .GroupBy(t => t.Date.Date) + .OrderByDescending(g => g.Key); + + private IEnumerable> GroupedUpcomingPage => + FilteredUpcoming + .Skip((_upcomingPage - 1) * PageSize) + .Take(PageSize) + .GroupBy(t => t.Date.Date) + .OrderBy(g => g.Key); + + private static IOrderedEnumerable OrderGroupByIncomeFirst(IGrouping 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" }; diff --git a/BudgetApp/Components/Pages/Salary.razor b/BudgetApp/Components/Pages/Salary.razor index 356db2f..d19cef7 100644 --- a/BudgetApp/Components/Pages/Salary.razor +++ b/BudgetApp/Components/Pages/Salary.razor @@ -119,6 +119,7 @@ else } + @@ -155,6 +156,64 @@ else
} +@if (_showEditModal) +{ + +} + @code { // Delete state diff --git a/BudgetApp/Components/Pages/Salary.razor.cs b/BudgetApp/Components/Pages/Salary.razor.cs index 8db5bc1..7f6b82c 100644 --- a/BudgetApp/Components/Pages/Salary.razor.cs +++ b/BudgetApp/Components/Pages/Salary.razor.cs @@ -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; diff --git a/BudgetApp/Components/Pages/Transactions.razor b/BudgetApp/Components/Pages/Transactions.razor index 3e907bd..45b4d8a 100644 --- a/BudgetApp/Components/Pages/Transactions.razor +++ b/BudgetApp/Components/Pages/Transactions.razor @@ -6,6 +6,7 @@ @inject ICategoryRepository CategoryService @inject ISalaryRepository SalaryService @inject AuthenticationStateProvider AuthStateProvider +@inject IJSRuntime JS @rendermode InteractiveServer Transactions @@ -29,26 +30,26 @@
-
+
-
+
-
+
-
+
-
+
@if (_accountsList is not null) @@ -60,7 +61,7 @@ }
-
+
@@ -73,7 +74,7 @@ }
-
+
@@ -85,12 +86,12 @@
@if (_formModel.RecurrenceType != RecurrenceType.None) { -
+
} -
+
@@ -153,6 +154,9 @@ else }
+
+ +
@@ -162,23 +166,22 @@ else - - + - - - - - + + + + @foreach (var t in PagedTransactions) { - - + - - - } + + }
+ Date @SortIndicator("date")Date @SortIndicator("date") Description @SortIndicator("description")AccountCategoryRecurrenceAmount @SortIndicator("amount")AccountCategoryRecurrenceAmount @SortIndicator("amount")
+
@if (t.SalaryId is null) { @@ -195,10 +198,6 @@ else { 💰 Salary } - @if (t.IsPaid && t.Date.Date > DateTime.Today) - { - ✓ Paid - } @@ -228,31 +227,16 @@ else { 🔁 @(t.ParentTransaction?.RecurrenceType.ToString() ?? "Recurring") } + else + { + None + } @(t.Type == TransactionType.Income ? "+" : "-")@t.Account.FormatAmount(t.Amount) -
- @if (t.Date.Date > DateTime.Today) - { - - } - - @if (t.SalaryId is null) - { - - } - else - { - 🔒 - } -
-
@@ -391,16 +375,41 @@ else }
- @if (_editIsPartOfSeries) + @if (_editingTransaction?.SalaryId is not null) + { +
+ This is a salary transaction. To modify it, use the Salary page. +
+ } + else if (_editIsPartOfSeries) {
Changes will apply to this and all future occurrences in the series.
}
- diff --git a/BudgetApp/Components/Pages/Transactions.razor.cs b/BudgetApp/Components/Pages/Transactions.razor.cs index 2613b8a..4b240c8 100644 --- a/BudgetApp/Components/Pages/Transactions.razor.cs +++ b/BudgetApp/Components/Pages/Transactions.razor.cs @@ -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)] diff --git a/BudgetApp/Components/Pages/Transactions.razor.css b/BudgetApp/Components/Pages/Transactions.razor.css index 9103893..4c7a5b1 100644 --- a/BudgetApp/Components/Pages/Transactions.razor.css +++ b/BudgetApp/Components/Pages/Transactions.razor.css @@ -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; diff --git a/BudgetApp/Program.cs b/BudgetApp/Program.cs index 79852a6..7a3f826 100644 --- a/BudgetApp/Program.cs +++ b/BudgetApp/Program.cs @@ -71,6 +71,7 @@ if (!string.IsNullOrEmpty(googleSection["ClientId"]) && !string.IsNullOrEmpty(go { options.ClientId = googleSection["ClientId"]!; options.ClientSecret = googleSection["ClientSecret"]!; + options.SignInScheme = IdentityConstants.ApplicationScheme; }); } @@ -168,6 +169,7 @@ app.MapGet("/api/auth/external-callback", async ( string? returnUrl) => { returnUrl ??= "/"; + var info = await signInManager.GetExternalLoginInfoAsync(); if (info is null) return Results.Redirect("/login"); diff --git a/BudgetApp/appsettings.json b/BudgetApp/appsettings.json index 264e69b..e581f21 100644 --- a/BudgetApp/appsettings.json +++ b/BudgetApp/appsettings.json @@ -18,8 +18,8 @@ }, "Authentication": { "Google": { - "ClientId": "", - "ClientSecret": "" + "ClientId": "666457807183-elgmh4qqi0ms7mqd43v8pbls2n3laoai.apps.googleusercontent.com", + "ClientSecret": "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" }, "Microsoft": { "ClientId": "", diff --git a/BudgetApp/budgetapp.db-shm b/BudgetApp/budgetapp.db-shm index 2671ba6..b1ccf5b 100644 Binary files a/BudgetApp/budgetapp.db-shm and b/BudgetApp/budgetapp.db-shm differ diff --git a/BudgetApp/budgetapp.db-wal b/BudgetApp/budgetapp.db-wal index 15c1a58..36c7307 100644 Binary files a/BudgetApp/budgetapp.db-wal and b/BudgetApp/budgetapp.db-wal differ diff --git a/BudgetApp/wwwroot/js/chart-interop.js b/BudgetApp/wwwroot/js/chart-interop.js index 4418a3b..9736b23 100644 --- a/BudgetApp/wwwroot/js/chart-interop.js +++ b/BudgetApp/wwwroot/js/chart-interop.js @@ -90,6 +90,14 @@ window.chartInterop = { const storedLabels = this.transactionLabels[canvasId]; 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 const salaryPlugin = { id: 'salaryDays', @@ -158,6 +166,8 @@ window.chartInterop = { } }, y: { + suggestedMin: ySuggestedMin, + suggestedMax: ySuggestedMax, ticks: { callback: function (value) { return sym + value.toFixed(0); @@ -306,3 +316,15 @@ document.addEventListener('focusin', function (e) { 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); +}; diff --git a/README.md b/README.md index 4a05540..d55f6a7 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,14 @@ BudgetApp.Tests/ │ └── 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 Default connection string in `appsettings.json`: