WIP
This commit is contained in:
1 parent
38067df8c3
commit
492d86db9e
30 files changed
+2473
-40
No files matched your search
@@ -33,6 +33,12 @@
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="salary">
|
||||
<span class="bi bi-cash-coin-nav-menu" aria-hidden="true"></span> Salary
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="categories">
|
||||
<span class="bi bi-tag-nav-menu" aria-hidden="true"></span> Categories
|
||||
|
||||
@@ -50,6 +50,10 @@
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-arrow-left-right' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5zm14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-cash-coin-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M11 15a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm5-4a5 5 0 1 1-10 0 5 5 0 0 1 10 0z'/%3E%3Cpath d='M9.438 11.944c.047.596.518 1.06 1.363 1.116v.44h.375v-.443c.875-.061 1.386-.529 1.386-1.207 0-.618-.39-.936-1.09-1.1l-.296-.07v-1.2c.376.043.614.248.671.532h.658c-.047-.575-.54-1.024-1.329-1.073V8.5h-.375v.45c-.747.073-1.255.522-1.255 1.158 0 .562.378.92 1.007 1.066l.248.061v1.272c-.384-.058-.639-.27-.696-.563h-.668zm1.36-1.354c-.369-.085-.569-.26-.569-.522 0-.294.216-.514.572-.578v1.1h-.003zm.432.746c.449.104.655.272.655.569 0 .339-.257.571-.709.614v-1.195l.054.012z'/%3E%3Cpath d='M1 0a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4.083c.058-.344.145-.678.258-1H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3.528c.38.34.717.728 1 1.154V1a1 1 0 0 0-1-1H1z'/%3E%3Cpath d='M9.998 5.083 10 5a2 2 0 1 0-3.132 1.65 5.982 5.982 0 0 1 3.13-1.567z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-tag-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-tag-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 1a1 1 0 0 0-1 1v4.586a1 1 0 0 0 .293.707l7 7a1 1 0 0 0 1.414 0l4.586-4.586a1 1 0 0 0 0-1.414l-7-7A1 1 0 0 0 6.586 1H2zm4 3.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
@@ -69,7 +69,11 @@ else
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-0 pt-0">
|
||||
<div class="d-flex gap-1 justify-content-center">
|
||||
@if (editingCategoryId == category.Id)
|
||||
@if (category.IsDefault)
|
||||
{
|
||||
<span class="badge bg-secondary" title="Default categories cannot be edited or deleted">Default</span>
|
||||
}
|
||||
else if (editingCategoryId == category.Id)
|
||||
{
|
||||
<button class="btn btn-success btn-sm" @onclick="() => SaveCategoryEdit(category.Id)">Save</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="CancelCategoryEdit">Cancel</button>
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
<strong>Create an account</strong> — Head to <a href="/accounts">Accounts</a> and add at least one financial account (e.g. "Current Account", "Savings"). Set the opening balance and choose a currency.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Set up categories</strong> — Visit <a href="/categories">Categories</a> to organise your spending. Pick an emoji icon for each category to make them easy to spot.
|
||||
<strong>Set up categories</strong> — Visit <a href="/categories">Categories</a> to organise your spending. A set of default categories (including Salary) are provided automatically.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Configure your salary</strong> — Visit <a href="/salary">Salary</a> to add your regular income sources. Each salary generates recurring Income transactions automatically and highlights paydays on the chart.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Record transactions</strong> — Go to <a href="/transactions">Transactions</a> or use the <strong>+ Add</strong> button on the <a href="/">Dashboard</a> to log income and expenses.
|
||||
@@ -42,7 +45,7 @@
|
||||
<p>The <a href="/">Dashboard</a> is your home screen and gives you an at-a-glance view of your finances.</p>
|
||||
|
||||
<h6>Balance Trend Chart</h6>
|
||||
<p>The chart shows projected balances for each account over a time window starting from today. Use the preset buttons (<strong>7d</strong>, <strong>30d</strong>, <strong>90d</strong>, <strong>1y</strong>) to look ahead, or pick custom dates with the date pickers.</p>
|
||||
<p>The chart shows projected balances for each account over a time window starting from today. Use the preset buttons (<strong>7d</strong>, <strong>30d</strong>, <strong>90d</strong>, <strong>1y</strong>) to look ahead, or pick custom dates with the date pickers. Payday dates from your <a href="/salary">Salary</a> sources are highlighted with a 💰 marker and a subtle green band.</p>
|
||||
|
||||
<h6>Recent & Upcoming Tabs</h6>
|
||||
<ul>
|
||||
@@ -109,6 +112,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Salary ── *@
|
||||
<div class="card mb-4" id="salary">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">💰 Salary</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>The <a href="/salary">Salary</a> page lets you define regular income sources that generate recurring transactions automatically.</p>
|
||||
|
||||
<h6>Adding a Salary</h6>
|
||||
<ul>
|
||||
<li><strong>Name</strong> — A label for this income source (e.g. "Main Job", "Freelance").</li>
|
||||
<li><strong>Amount</strong> — The net amount credited each payday.</li>
|
||||
<li><strong>Account</strong> — Which account receives the payment.</li>
|
||||
<li><strong>Recurrence</strong> — How often you are paid: <strong>Weekly</strong>, <strong>Monthly</strong> or <strong>Yearly</strong>.</li>
|
||||
<li><strong>First Payday</strong> — The date of the first payment. The day of month (or week) is derived from this and repeats indefinitely.</li>
|
||||
</ul>
|
||||
|
||||
<h6>How Salary Transactions Work</h6>
|
||||
<ul>
|
||||
<li>Salary transactions are generated automatically as Income entries up to the end of the current time window, and refreshed whenever you visit or change the date range on any page.</li>
|
||||
<li>They appear on the <a href="/transactions">Transactions</a> page with a 💰 badge and cannot be deleted from there — manage them on the Salary page instead.</li>
|
||||
<li>Payday dates are highlighted on the <a href="/">Dashboard</a> balance chart with a 💰 marker and subtle green band so you can see upcoming income at a glance.</li>
|
||||
<li>The category is automatically set to <strong>Salary</strong>.</li>
|
||||
</ul>
|
||||
|
||||
<h6>Deleting a Salary</h6>
|
||||
<p class="mb-0">Deleting a salary removes all future unpaid transactions for that income source. Past transactions remain in your history for accurate record-keeping.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Categories ── *@
|
||||
<div class="card mb-4" id="categories">
|
||||
<div class="card-header">
|
||||
@@ -120,6 +153,7 @@
|
||||
<li><strong>Add a category</strong> — Enter a name and pick an emoji icon from the icon picker.</li>
|
||||
<li><strong>Edit</strong> — Click the Edit button on a category card to change its name or icon.</li>
|
||||
<li><strong>Delete</strong> — Removes the category. Transactions using it will become uncategorised.</li>
|
||||
<li><strong>Default categories</strong> — A set of built-in categories (Groceries, Salary, Rent / Mortgage, etc.) are created automatically and shown with a <strong>Default</strong> badge. These cannot be edited or deleted.</li>
|
||||
</ul>
|
||||
<p class="mb-0">Categories are optional — you can assign them to transactions to get better insight into your spending patterns.</p>
|
||||
</div>
|
||||
@@ -175,6 +209,7 @@
|
||||
<ul class="mb-0">
|
||||
<li>Use the Dashboard <strong>+ Add</strong> button for rapid entry — the modal stays open after each save so you can batch-add transactions.</li>
|
||||
<li>Set up recurring transactions for regular bills and income to keep your upcoming view accurate.</li>
|
||||
<li>Use the <a href="/salary">Salary</a> page for regular pay — it is protected from accidental deletion and highlights paydays on the chart. Use the <strong>90d</strong> or <strong>1y</strong> window to plan ahead.</li>
|
||||
<li>Assign colours to your accounts to easily distinguish them on the chart and in transaction lists.</li>
|
||||
<li>Hover over elements for tooltips with additional context throughout the app.</li>
|
||||
<li>Click your account balances in the sidebar to quickly navigate to account management.</li>
|
||||
@@ -193,6 +228,7 @@
|
||||
<a href="/help#dashboard" class="list-group-item list-group-item-action">📊 Dashboard</a>
|
||||
<a href="/help#accounts" class="list-group-item list-group-item-action">🏦 Accounts</a>
|
||||
<a href="/help#transactions" class="list-group-item list-group-item-action">💸 Transactions</a>
|
||||
<a href="/help#salary" class="list-group-item list-group-item-action">💰 Salary</a>
|
||||
<a href="/help#categories" class="list-group-item list-group-item-action">🏷️ Categories</a>
|
||||
<a href="/help#reports" class="list-group-item list-group-item-action">📈 Reports</a>
|
||||
<a href="/help#settings" class="list-group-item list-group-item-action">⚙️ Settings</a>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@attribute [Authorize]
|
||||
@inject DashboardService DashboardService
|
||||
@inject TransactionService TransactionService
|
||||
@inject SalaryService SalaryService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime JS
|
||||
@implements IAsyncDisposable
|
||||
@@ -195,6 +196,10 @@ else
|
||||
{
|
||||
<span class="badge bg-info bg-opacity-10 text-info" style="font-size: 0.7rem;">🔁 @GetRecurrenceLabel(t)</span>
|
||||
}
|
||||
@if (t.IsPaid)
|
||||
{
|
||||
<span class="badge bg-success bg-opacity-10 text-success" style="font-size: 0.7rem;">✓ Paid</span>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 mt-1 flex-wrap" style="font-size: 0.8rem;">
|
||||
<span class="text-muted">@t.Date.ToString("MMM dd, yyyy")</span>
|
||||
@@ -209,20 +214,30 @@ else
|
||||
<div class="@(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold" style="font-size: 1.05rem;">
|
||||
@(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
|
||||
</div>
|
||||
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (selectedTransactionId == t.Id)
|
||||
{
|
||||
<div class="mt-2 pt-2 border-top d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (RecentTotalPages > 1)
|
||||
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (selectedTransactionId == t.Id)
|
||||
{
|
||||
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center gap-2">
|
||||
@if (t.IsPaid)
|
||||
{
|
||||
<button class="btn btn-outline-secondary btn-sm" title="Revert to upcoming" @onclick="() => MarkAsPaid(t.Id, false)" @onclick:stopPropagation="true">↩ Unpaid</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span></span>
|
||||
}
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</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>
|
||||
@@ -282,9 +297,12 @@ else
|
||||
</div>
|
||||
@if (selectedTransactionId == t.Id)
|
||||
{
|
||||
<div class="mt-2 pt-2 border-top d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
|
||||
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center gap-2">
|
||||
<button class="btn btn-outline-success btn-sm" title="Mark as paid and move to Recent" @onclick="() => MarkAsPaid(t.Id, true)" @onclick:stopPropagation="true">✅ Mark as Paid</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -446,6 +464,7 @@ else
|
||||
datasets,
|
||||
dayLabels,
|
||||
highlightIndex,
|
||||
GetSalaryDayIndices(),
|
||||
data.CurrencySymbol);
|
||||
}
|
||||
}
|
||||
@@ -462,6 +481,23 @@ else
|
||||
return data.ChartData.Labels.IndexOf(label);
|
||||
}
|
||||
|
||||
private int[] GetSalaryDayIndices()
|
||||
{
|
||||
if (data is null) return [];
|
||||
|
||||
var salaryDates = data.RecentTransactions
|
||||
.Concat(data.UpcomingTransactions)
|
||||
.Where(t => t.SalaryId is not null)
|
||||
.Select(t => t.Date.ToString("MMM dd"))
|
||||
.ToHashSet();
|
||||
|
||||
return data.ChartData.Labels
|
||||
.Select((label, index) => (label, index))
|
||||
.Where(x => salaryDates.Contains(x.label))
|
||||
.Select(x => x.index)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task SelectTransaction(int id)
|
||||
{
|
||||
selectedTransactionId = selectedTransactionId == id ? null : id;
|
||||
@@ -583,6 +619,14 @@ else
|
||||
pendingDeleteTransaction = null;
|
||||
}
|
||||
|
||||
private async Task MarkAsPaid(int id, bool isPaid)
|
||||
{
|
||||
if (userId is null) return;
|
||||
await TransactionService.MarkAsPaidAsync(id, userId, isPaid);
|
||||
selectedTransactionId = null;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private static string GetRecurrenceLabel(Transaction t)
|
||||
{
|
||||
if (t.RecurrenceType != RecurrenceType.None)
|
||||
@@ -609,6 +653,7 @@ else
|
||||
private async Task RefreshData()
|
||||
{
|
||||
if (userId is null) return;
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd);
|
||||
data = await DashboardService.GetDashboardDataAsync(userId, windowStart, windowEnd);
|
||||
chartDataVersion++;
|
||||
ComputeProjectedBalances();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@inject TransactionService TransactionService
|
||||
@inject AccountService AccountService
|
||||
@inject CategoryService CategoryService
|
||||
@inject SalaryService SalaryService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime JS
|
||||
@implements IAsyncDisposable
|
||||
@@ -220,6 +221,7 @@ else
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, dateTo);
|
||||
var transactions = await TransactionService.GetTransactionsInRangeAsync(userId, dateFrom, dateTo);
|
||||
|
||||
var filtered = transactions
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
@page "/salary"
|
||||
@attribute [Authorize]
|
||||
@inject SalaryService SalaryService
|
||||
@inject AccountService AccountService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Salary</PageTitle>
|
||||
|
||||
<h3 class="mb-2">Salary</h3>
|
||||
<p class="text-muted mb-4">Define your regular income sources. Each salary generates recurring transactions at the set interval, forever.</p>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Add Income Source</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (accountsList is not null && accountsList.Count == 0)
|
||||
{
|
||||
<div class="alert alert-warning mb-0">
|
||||
You need to <a href="/accounts">create an account</a> before adding a salary.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="formModel" OnValidSubmit="AddSalary" FormName="addSalary">
|
||||
<ToastValidator />
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Name</label>
|
||||
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Main Job" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Amount</label>
|
||||
<InputNumber @bind-Value="formModel.Amount" class="form-control numeric-input" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Account</label>
|
||||
<InputSelect @bind-Value="formModel.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="col-md-2">
|
||||
<label class="form-label">Recurrence</label>
|
||||
<InputSelect @bind-Value="formModel.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="col-md-3">
|
||||
<label class="form-label">First Payday</label>
|
||||
<InputDate @bind-Value="formModel.StartDate" class="form-control" title="The date of the first payment — the interval repeats from here indefinitely" />
|
||||
</div>
|
||||
<div class="col-md-12 col-lg-auto d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (salaries is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (salaries.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No income sources yet. Add one above to start tracking your salary.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Income Sources</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Amount</th>
|
||||
<th>Account</th>
|
||||
<th class="d-none d-md-table-cell">Schedule</th>
|
||||
<th>Next Payday</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var s in salaries)
|
||||
{
|
||||
<tr>
|
||||
<td class="fw-semibold">💰 @s.Name</td>
|
||||
<td class="text-success fw-semibold text-nowrap">+@s.Account.FormatAmount(s.Amount)</td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: @s.Account.Color;">
|
||||
@s.Account.Name
|
||||
</span>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
<span class="badge bg-info text-nowrap">🔁 @s.GetPaydaySummary()</span>
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
@if (nextPaydates.TryGetValue(s.Id, out var next) && next.HasValue)
|
||||
{
|
||||
<span>@next.Value.ToString("MMM dd, yyyy")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(s)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small class="text-muted">@salaries.Count income source@(salaries.Count == 1 ? "" : "s") · Transactions are generated automatically and appear on the <a href="/transactions">Transactions</a> page.</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showDeleteModal && pendingDeleteSalary is not null)
|
||||
{
|
||||
<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">Delete Income Source</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>@pendingDeleteSalary.Name</strong>?</p>
|
||||
<p>All future unpaid salary transactions will be removed. Past transactions remain in your history.</p>
|
||||
<p class="text-danger mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="ExecuteDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<BudgetApp.Data.Salary>? salaries;
|
||||
private List<Account>? accountsList;
|
||||
private Dictionary<int, DateTime?> nextPaydates = [];
|
||||
private string? userId;
|
||||
|
||||
private SalaryForm formModel = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
var windowEnd = DateTime.Today.AddDays(90);
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd);
|
||||
|
||||
salaries = await SalaryService.GetSalariesAsync(userId);
|
||||
accountsList = await AccountService.GetAccountsAsync(userId);
|
||||
|
||||
nextPaydates.Clear();
|
||||
foreach (var sal in salaries)
|
||||
{
|
||||
nextPaydates[sal.Id] = await SalaryService.GetNextPaydateAsync(sal.Id);
|
||||
}
|
||||
|
||||
if (accountsList.Count > 0 && formModel.AccountId == 0)
|
||||
{
|
||||
formModel.AccountId = accountsList[0].Id;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddSalary()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
var entry = new BudgetApp.Data.Salary
|
||||
{
|
||||
Name = formModel.Name,
|
||||
Amount = formModel.Amount,
|
||||
AccountId = formModel.AccountId,
|
||||
RecurrenceType = formModel.RecurrenceType,
|
||||
StartDate = formModel.StartDate,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
await SalaryService.CreateSalaryAsync(entry, DateTime.Today.AddDays(90));
|
||||
|
||||
formModel = new SalaryForm
|
||||
{
|
||||
AccountId = accountsList?.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
// Delete state
|
||||
private bool showDeleteModal;
|
||||
private BudgetApp.Data.Salary? pendingDeleteSalary;
|
||||
|
||||
private void ConfirmDelete(BudgetApp.Data.Salary s)
|
||||
{
|
||||
pendingDeleteSalary = s;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
showDeleteModal = false;
|
||||
pendingDeleteSalary = null;
|
||||
}
|
||||
|
||||
private async Task ExecuteDelete()
|
||||
{
|
||||
if (userId is null || pendingDeleteSalary is null) return;
|
||||
showDeleteModal = false;
|
||||
await SalaryService.DeleteSalaryAsync(pendingDeleteSalary.Id, userId);
|
||||
pendingDeleteSalary = null;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private sealed class SalaryForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Name is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than zero")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.Monthly;
|
||||
|
||||
public DateTime StartDate { get; set; } = DateTime.Today;
|
||||
|
||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(
|
||||
System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.card {
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.07);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-radius: 1rem 1rem 0 0 !important;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
::deep .table > :not(caption) > * > * {
|
||||
padding: 0.3rem 0.4rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
@inject TransactionService TransactionService
|
||||
@inject AccountService AccountService
|
||||
@inject CategoryService CategoryService
|
||||
@inject SalaryService SalaryService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@@ -176,11 +177,28 @@ else
|
||||
@foreach (var t in pagedTransactions)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input" checked="@selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" />
|
||||
</td>
|
||||
<td>
|
||||
@if (t.SalaryId is null)
|
||||
{
|
||||
<input type="checkbox" class="form-check-input" checked="@selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted" style="font-size: 0.85rem;" title="Salary transactions cannot be bulk deleted — manage them on the Salary page">🔒</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">@t.Date.ToString("MMM dd, yyyy")</td>
|
||||
<td>@t.Description</td>
|
||||
<td>
|
||||
@t.Description
|
||||
@if (t.SalaryId is not null)
|
||||
{
|
||||
<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;">
|
||||
@t.Account.Name
|
||||
@@ -215,8 +233,21 @@ else
|
||||
</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>
|
||||
<button class="btn btn-outline-danger" @onclick="() => ConfirmDelete(t)">Delete</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>
|
||||
@@ -448,18 +479,20 @@ else
|
||||
|
||||
private void OnSearchChanged() => currentPage = 1;
|
||||
|
||||
private void SetWindow(int days)
|
||||
private async Task SetWindow(int days)
|
||||
{
|
||||
windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null };
|
||||
windowStart = DateTime.Today;
|
||||
windowEnd = DateTime.Today.AddDays(days);
|
||||
currentPage = 1;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private void OnWindowDateChanged()
|
||||
private async Task OnWindowDateChanged()
|
||||
{
|
||||
windowPreset = null;
|
||||
currentPage = 1;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private void OnPageSizeChanged(ChangeEventArgs e)
|
||||
@@ -501,6 +534,7 @@ else
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd);
|
||||
transactions = await TransactionService.GetTransactionsAsync(userId);
|
||||
accountsList = await AccountService.GetAccountsAsync(userId);
|
||||
categoriesList = await CategoryService.GetCategoriesAsync(userId);
|
||||
@@ -539,22 +573,29 @@ else
|
||||
|
||||
private void ToggleSelect(int id)
|
||||
{
|
||||
var t = transactions?.FirstOrDefault(x => x.Id == id);
|
||||
if (t?.SalaryId is not null) return;
|
||||
if (!selectedIds.Remove(id))
|
||||
selectedIds.Add(id);
|
||||
}
|
||||
|
||||
private bool IsAllPageSelected() => pagedTransactions.Count > 0 && pagedTransactions.All(t => selectedIds.Contains(t.Id));
|
||||
private bool IsAllPageSelected()
|
||||
{
|
||||
var selectable = pagedTransactions.Where(t => t.SalaryId is null).ToList();
|
||||
return selectable.Count > 0 && selectable.All(t => selectedIds.Contains(t.Id));
|
||||
}
|
||||
|
||||
private void ToggleSelectAll(ChangeEventArgs e)
|
||||
{
|
||||
var selectable = pagedTransactions.Where(t => t.SalaryId is null).ToList();
|
||||
if (e.Value is true)
|
||||
{
|
||||
foreach (var t in pagedTransactions)
|
||||
foreach (var t in selectable)
|
||||
selectedIds.Add(t.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var t in pagedTransactions)
|
||||
foreach (var t in selectable)
|
||||
selectedIds.Remove(t.Id);
|
||||
}
|
||||
}
|
||||
@@ -649,8 +690,10 @@ else
|
||||
{
|
||||
if (userId is null) return;
|
||||
showDeleteModal = false;
|
||||
foreach (var id in selectedIds)
|
||||
foreach (var id in selectedIds.ToList())
|
||||
{
|
||||
var t = transactions?.FirstOrDefault(x => x.Id == id);
|
||||
if (t?.SalaryId is not null) continue;
|
||||
await TransactionService.DeleteTransactionAsync(id, userId);
|
||||
}
|
||||
selectedIds.Clear();
|
||||
@@ -664,6 +707,13 @@ else
|
||||
bulkDeleteCount = 0;
|
||||
}
|
||||
|
||||
private async Task TogglePaid(Transaction t)
|
||||
{
|
||||
if (userId is null) return;
|
||||
await TransactionService.MarkAsPaidAsync(t.Id, userId, !t.IsPaid);
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private sealed class TransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
|
||||
|
||||
Reference in new issue
Block a user