WIP
This commit is contained in:
1 parent
2bfe914c8f
commit
4281924ee5
16 files changed
+369
-76
No files matched your search
@@ -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);
|
||||
};
|
||||
Reference in new issue
Block a user