WIP
This commit is contained in:
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 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<DateTime?> GetNextPaydateAsync(int salaryId);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
"Authentication": {
|
||||
"Google": {
|
||||
"ClientId": "",
|
||||
"ClientSecret": ""
|
||||
"ClientId": "666457807183-elgmh4qqi0ms7mqd43v8pbls2n3laoai.apps.googleusercontent.com",
|
||||
"ClientSecret": "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"
|
||||
},
|
||||
"Microsoft": {
|
||||
"ClientId": "",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
};
|
||||
@@ -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`:
|
||||
|
||||
Reference in new issue
Block a user