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")]
|
||||
|
||||
@@ -13,6 +13,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
public DbSet<Account> Accounts => Set<Account>();
|
||||
public DbSet<Transaction> Transactions => Set<Transaction>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
public DbSet<Salary> Salaries => Set<Salary>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
@@ -45,6 +46,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
.HasForeignKey(t => t.ParentTransactionId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
e.HasOne(t => t.Salary)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.SalaryId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
e.Property(t => t.Amount).HasColumnType("decimal(18,2)");
|
||||
});
|
||||
|
||||
@@ -55,5 +61,20 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
.HasForeignKey(c => c.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<Salary>(e =>
|
||||
{
|
||||
e.HasOne(s => s.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasOne(s => s.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.Property(s => s.Amount).HasColumnType("decimal(18,2)");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,5 +17,7 @@ public class Category
|
||||
|
||||
public ApplicationUser User { get; set; } = default!;
|
||||
|
||||
public bool IsDefault { get; set; }
|
||||
|
||||
public List<Transaction> Transactions { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace BudgetApp.Data;
|
||||
|
||||
public class Salary
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.Monthly;
|
||||
|
||||
public DateTime StartDate { get; set; } = DateTime.Today;
|
||||
|
||||
public int AccountId { get; set; }
|
||||
public Account Account { get; set; } = default!;
|
||||
|
||||
public int? CategoryId { get; set; }
|
||||
public Category? Category { get; set; }
|
||||
|
||||
[Required]
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public ApplicationUser User { get; set; } = default!;
|
||||
|
||||
public string GetPaydaySummary() => RecurrenceType switch
|
||||
{
|
||||
RecurrenceType.Weekly => $"Every week on {StartDate:dddd}",
|
||||
RecurrenceType.Monthly => $"Monthly on the {GetOrdinal(StartDate.Day)}",
|
||||
RecurrenceType.Yearly => $"Annually on {StartDate:MMMM d}",
|
||||
_ => RecurrenceType.ToString()
|
||||
};
|
||||
|
||||
private static string GetOrdinal(int day) => day switch
|
||||
{
|
||||
1 or 21 or 31 => $"{day}st",
|
||||
2 or 22 => $"{day}nd",
|
||||
3 or 23 => $"{day}rd",
|
||||
_ => $"{day}th"
|
||||
};
|
||||
}
|
||||
@@ -42,4 +42,9 @@ public class Transaction
|
||||
|
||||
public int? CategoryId { get; set; }
|
||||
public Category? Category { get; set; }
|
||||
|
||||
public bool IsPaid { get; set; } = false;
|
||||
|
||||
public int? SalaryId { get; set; }
|
||||
public Salary? Salary { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using BudgetApp.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BudgetApp.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260305164332_AddIsPaidToTransaction")]
|
||||
partial class AddIsPaidToTransaction
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Account", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(7)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrencyCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(3)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("CurrentBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Category", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Transaction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPaid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("ParentTransactionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("RecurrenceEndDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RecurrenceType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("ParentTransactionId");
|
||||
|
||||
b.ToTable("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Account", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Category", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Transaction", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.Account", "Account")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BudgetApp.Data.Category", "Category")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("BudgetApp.Data.Transaction", "ParentTransaction")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentTransactionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("ParentTransaction");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Account", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Category", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BudgetApp.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIsPaidToTransaction : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsPaid",
|
||||
table: "Transactions",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsPaid",
|
||||
table: "Transactions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using BudgetApp.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BudgetApp.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260305175841_AddSalary")]
|
||||
partial class AddSalary
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Account", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(7)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrencyCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(3)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("CurrentBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Category", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Salary", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RecurrenceType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Salaries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Transaction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPaid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("ParentTransactionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("RecurrenceEndDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RecurrenceType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SalaryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("ParentTransactionId");
|
||||
|
||||
b.HasIndex("SalaryId");
|
||||
|
||||
b.ToTable("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Account", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Category", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Salary", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BudgetApp.Data.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId");
|
||||
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Transaction", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.Account", "Account")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BudgetApp.Data.Category", "Category")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("BudgetApp.Data.Transaction", "ParentTransaction")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentTransactionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("BudgetApp.Data.Salary", "Salary")
|
||||
.WithMany()
|
||||
.HasForeignKey("SalaryId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("ParentTransaction");
|
||||
|
||||
b.Navigation("Salary");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Account", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Category", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BudgetApp.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalary : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SalaryId",
|
||||
table: "Transactions",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Salaries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
RecurrenceType = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
StartDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
AccountId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CategoryId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
UserId = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Salaries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Salaries_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Salaries_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Salaries_Categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Transactions_SalaryId",
|
||||
table: "Transactions",
|
||||
column: "SalaryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Salaries_AccountId",
|
||||
table: "Salaries",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Salaries_CategoryId",
|
||||
table: "Salaries",
|
||||
column: "CategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Salaries_UserId",
|
||||
table: "Salaries",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Transactions_Salaries_SalaryId",
|
||||
table: "Transactions",
|
||||
column: "SalaryId",
|
||||
principalTable: "Salaries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Transactions_Salaries_SalaryId",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Salaries");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Transactions_SalaryId",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SalaryId",
|
||||
table: "Transactions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using BudgetApp.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BudgetApp.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260305181148_AddIsDefaultToCategory")]
|
||||
partial class AddIsDefaultToCategory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Account", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(7)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrencyCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(3)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("CurrentBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Category", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Salary", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RecurrenceType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Salaries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Transaction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPaid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("ParentTransactionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("RecurrenceEndDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RecurrenceType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SalaryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("ParentTransactionId");
|
||||
|
||||
b.HasIndex("SalaryId");
|
||||
|
||||
b.ToTable("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Account", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Category", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Salary", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BudgetApp.Data.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId");
|
||||
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Transaction", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.Account", "Account")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BudgetApp.Data.Category", "Category")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("BudgetApp.Data.Transaction", "ParentTransaction")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentTransactionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("BudgetApp.Data.Salary", "Salary")
|
||||
.WithMany()
|
||||
.HasForeignKey("SalaryId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("ParentTransaction");
|
||||
|
||||
b.Navigation("Salary");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Account", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Category", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BudgetApp.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIsDefaultToCategory : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsDefault",
|
||||
table: "Categories",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsDefault",
|
||||
table: "Categories");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,9 @@ namespace BudgetApp.Migrations
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@@ -143,6 +146,47 @@ namespace BudgetApp.Migrations
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Salary", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RecurrenceType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Salaries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Transaction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -166,6 +210,9 @@ namespace BudgetApp.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPaid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("ParentTransactionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -175,6 +222,9 @@ namespace BudgetApp.Migrations
|
||||
b.Property<int>("RecurrenceType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SalaryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -186,6 +236,8 @@ namespace BudgetApp.Migrations
|
||||
|
||||
b.HasIndex("ParentTransactionId");
|
||||
|
||||
b.HasIndex("SalaryId");
|
||||
|
||||
b.ToTable("Transactions");
|
||||
});
|
||||
|
||||
@@ -339,6 +391,31 @@ namespace BudgetApp.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Salary", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BudgetApp.Data.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId");
|
||||
|
||||
b.HasOne("BudgetApp.Data.ApplicationUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BudgetApp.Data.Transaction", b =>
|
||||
{
|
||||
b.HasOne("BudgetApp.Data.Account", "Account")
|
||||
@@ -357,11 +434,18 @@ namespace BudgetApp.Migrations
|
||||
.HasForeignKey("ParentTransactionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("BudgetApp.Data.Salary", "Salary")
|
||||
.WithMany()
|
||||
.HasForeignKey("SalaryId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("ParentTransaction");
|
||||
|
||||
b.Navigation("Salary");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
|
||||
@@ -81,6 +81,7 @@ builder.Services.AddScoped<AccountService>();
|
||||
builder.Services.AddScoped<TransactionService>();
|
||||
builder.Services.AddScoped<CategoryService>();
|
||||
builder.Services.AddScoped<DashboardService>();
|
||||
builder.Services.AddScoped<SalaryService>();
|
||||
builder.Services.AddScoped<ToastService>();
|
||||
builder.Services.AddScoped<AppState>();
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ public class CategoryService(ApplicationDbContext db)
|
||||
{
|
||||
Name = d.Name,
|
||||
Icon = d.Icon,
|
||||
IsDefault = true,
|
||||
UserId = userId
|
||||
});
|
||||
|
||||
@@ -57,7 +58,7 @@ public class CategoryService(ApplicationDbContext db)
|
||||
return;
|
||||
}
|
||||
|
||||
// Backfill icons on existing default categories that have empty icons
|
||||
// Backfill icons and IsDefault on existing default categories
|
||||
var defaultLookup = DefaultCategories.ToDictionary(d => d.Name, d => d.Icon);
|
||||
bool updated = false;
|
||||
|
||||
@@ -68,6 +69,11 @@ public class CategoryService(ApplicationDbContext db)
|
||||
category.Icon = icon;
|
||||
updated = true;
|
||||
}
|
||||
if (!category.IsDefault && defaultLookup.ContainsKey(category.Name))
|
||||
{
|
||||
category.IsDefault = true;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated)
|
||||
@@ -88,6 +94,7 @@ public class CategoryService(ApplicationDbContext db)
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
|
||||
if (category is not null)
|
||||
{
|
||||
if (category.IsDefault) return;
|
||||
category.Name = name;
|
||||
category.Icon = icon;
|
||||
await _db.SaveChangesAsync();
|
||||
@@ -100,6 +107,7 @@ public class CategoryService(ApplicationDbContext db)
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
|
||||
if (category is not null)
|
||||
{
|
||||
if (category.IsDefault) return;
|
||||
_db.Categories.Remove(category);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -24,12 +24,13 @@ public class DashboardService(TransactionService transactionService, AccountServ
|
||||
var allTransactions = await _transactionService.GetTransactionsInRangeAsync(userId, fetchStart, fetchEnd);
|
||||
|
||||
var recent = allTransactions
|
||||
.Where(t => t.Date.Date >= windowStart.Date && t.Date.Date <= DateTime.Today)
|
||||
.Where(t => (t.Date.Date >= windowStart.Date && t.Date.Date <= DateTime.Today)
|
||||
|| (t.IsPaid && t.Date.Date > DateTime.Today && t.Date.Date <= windowEnd.Date))
|
||||
.OrderByDescending(t => t.Date)
|
||||
.ToList();
|
||||
|
||||
var upcoming = allTransactions
|
||||
.Where(t => t.Date.Date > DateTime.Today && t.Date.Date <= windowEnd.Date)
|
||||
.Where(t => t.Date.Date > DateTime.Today && t.Date.Date <= windowEnd.Date && !t.IsPaid)
|
||||
.OrderBy(t => t.Date)
|
||||
.ToList();
|
||||
|
||||
@@ -57,8 +58,9 @@ public class DashboardService(TransactionService transactionService, AccountServ
|
||||
{
|
||||
labels.Add(d.ToString("MMM dd"));
|
||||
|
||||
// Paid future transactions are excluded — their effect is already in CurrentBalance
|
||||
var dayTxns = transactions
|
||||
.Where(t => t.Date.Date == d.Date)
|
||||
.Where(t => t.Date.Date == d.Date && !(t.IsPaid && t.Date.Date > today))
|
||||
.Select(t => $"{(t.Type == TransactionType.Income ? "+" : "-")}{t.Account.FormatAmount(t.Amount)} {t.Description} ({t.Account.Name})")
|
||||
.ToList();
|
||||
dayTransactionLabels.Add(dayTxns);
|
||||
@@ -85,8 +87,9 @@ public class DashboardService(TransactionService transactionService, AccountServ
|
||||
}
|
||||
else
|
||||
{
|
||||
// Future-only window: project forward from CurrentBalance using only unpaid transactions
|
||||
foreach (var t in accountTransactions
|
||||
.Where(t => t.Date.Date > today && t.Date.Date < startDate.Date)
|
||||
.Where(t => t.Date.Date > today && t.Date.Date < startDate.Date && !t.IsPaid)
|
||||
.OrderBy(t => t.Date))
|
||||
{
|
||||
runningBalance += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
|
||||
@@ -95,7 +98,7 @@ public class DashboardService(TransactionService transactionService, AccountServ
|
||||
|
||||
for (var d = startDate; d <= endDate; d = d.AddDays(1))
|
||||
{
|
||||
var dayTxns = accountTransactions.Where(t => t.Date.Date == d.Date);
|
||||
var dayTxns = accountTransactions.Where(t => t.Date.Date == d.Date && !(t.IsPaid && t.Date.Date > today));
|
||||
foreach (var t in dayTxns)
|
||||
{
|
||||
runningBalance += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using BudgetApp.Data;
|
||||
|
||||
namespace BudgetApp.Services;
|
||||
|
||||
public class SalaryService(ApplicationDbContext db)
|
||||
{
|
||||
private readonly ApplicationDbContext _db = db;
|
||||
|
||||
public async Task<List<Salary>> GetSalariesAsync(string userId)
|
||||
{
|
||||
return await _db.Salaries
|
||||
.Include(s => s.Account)
|
||||
.Include(s => s.Category)
|
||||
.Where(s => s.UserId == userId)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task CreateSalaryAsync(Salary salary, DateTime windowEnd)
|
||||
{
|
||||
var salaryCategoryId = await _db.Categories
|
||||
.Where(c => c.UserId == salary.UserId && c.Name == "Salary")
|
||||
.Select(c => (int?)c.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
salary.CategoryId = salaryCategoryId;
|
||||
|
||||
_db.Salaries.Add(salary);
|
||||
await _db.SaveChangesAsync();
|
||||
await MaterializeSalaryTransactionsAsync(salary.UserId, windowEnd);
|
||||
}
|
||||
|
||||
public async Task DeleteSalaryAsync(int id, string userId)
|
||||
{
|
||||
var exists = await _db.Salaries.AnyAsync(s => s.Id == id && s.UserId == userId);
|
||||
if (!exists) return;
|
||||
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
// Delete future unpaid transactions generated by this salary
|
||||
await _db.Transactions
|
||||
.Where(t => t.SalaryId == id && t.Date.Date > DateTime.Today && !t.IsPaid)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// Unlink past / paid transactions so they stay in history without the salary reference
|
||||
await _db.Transactions
|
||||
.Where(t => t.SalaryId == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.SalaryId, (int?)null));
|
||||
|
||||
await _db.Salaries
|
||||
.Where(s => s.Id == id && s.UserId == userId)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task MaterializeSalaryTransactionsAsync(string userId, DateTime windowEnd)
|
||||
{
|
||||
var salaries = await _db.Salaries
|
||||
.Where(s => s.UserId == userId)
|
||||
.ToListAsync();
|
||||
|
||||
var newTransactions = new List<Transaction>();
|
||||
|
||||
foreach (var salary in salaries)
|
||||
{
|
||||
var latestDate = await _db.Transactions
|
||||
.Where(t => t.SalaryId == salary.Id)
|
||||
.OrderByDescending(t => t.Date)
|
||||
.Select(t => (DateTime?)t.Date)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var nextDate = latestDate.HasValue
|
||||
? GetNextDate(latestDate.Value, salary)
|
||||
: salary.StartDate.Date;
|
||||
|
||||
while (nextDate <= windowEnd)
|
||||
{
|
||||
newTransactions.Add(new Transaction
|
||||
{
|
||||
Description = salary.Name,
|
||||
Amount = salary.Amount,
|
||||
Date = nextDate,
|
||||
Type = TransactionType.Income,
|
||||
AccountId = salary.AccountId,
|
||||
CategoryId = salary.CategoryId,
|
||||
RecurrenceType = RecurrenceType.None,
|
||||
SalaryId = salary.Id
|
||||
});
|
||||
nextDate = GetNextDate(nextDate, salary);
|
||||
}
|
||||
}
|
||||
|
||||
if (newTransactions.Count > 0)
|
||||
{
|
||||
_db.Transactions.AddRange(newTransactions);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DateTime?> GetNextPaydateAsync(int salaryId)
|
||||
{
|
||||
return await _db.Transactions
|
||||
.Where(t => t.SalaryId == salaryId && t.Date.Date >= DateTime.Today)
|
||||
.OrderBy(t => t.Date)
|
||||
.Select(t => (DateTime?)t.Date)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private static DateTime GetNextDate(DateTime from, Salary salary) => salary.RecurrenceType switch
|
||||
{
|
||||
RecurrenceType.Weekly => from.AddDays(7),
|
||||
RecurrenceType.Monthly => from.AddMonths(1),
|
||||
RecurrenceType.Yearly => from.AddYears(1),
|
||||
_ => from.AddMonths(1)
|
||||
};
|
||||
}
|
||||
@@ -220,6 +220,18 @@ public class TransactionService(ApplicationDbContext db)
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task MarkAsPaidAsync(int id, string userId, bool isPaid = true)
|
||||
{
|
||||
var transaction = await _db.Transactions
|
||||
.Include(t => t.Account)
|
||||
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
|
||||
|
||||
if (transaction is null) return;
|
||||
|
||||
transaction.IsPaid = isPaid;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task MaterializeRecurringTransactionsAsync(string userId, DateTime? windowEnd = null)
|
||||
{
|
||||
var recurringParents = await _db.Transactions
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,7 @@ window.chartInterop = {
|
||||
chartInstances: {},
|
||||
transactionLabels: {},
|
||||
|
||||
createOrUpdateChart: function (canvasId, labels, datasets, dayTransactionLabels, highlightIndex, currencySymbol) {
|
||||
createOrUpdateChart: function (canvasId, labels, datasets, dayTransactionLabels, highlightIndex, salaryDayIndices, currencySymbol) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
|
||||
@@ -88,6 +88,32 @@ window.chartInterop = {
|
||||
};
|
||||
|
||||
const storedLabels = this.transactionLabels[canvasId];
|
||||
const salaryIndices = salaryDayIndices || [];
|
||||
|
||||
// Salary payday highlight plugin — subtle green band + 💰 marker
|
||||
const salaryPlugin = {
|
||||
id: 'salaryDays',
|
||||
afterDraw: (chart) => {
|
||||
const indices = chart.config._salaryDayIndices;
|
||||
if (!indices || indices.length === 0) return;
|
||||
const meta = chart.getDatasetMeta(0);
|
||||
if (!meta) return;
|
||||
const ctx = chart.ctx;
|
||||
const top = chart.chartArea.top;
|
||||
const bottom = chart.chartArea.bottom;
|
||||
indices.forEach(idx => {
|
||||
if (!meta.data[idx]) return;
|
||||
const x = meta.data[idx].x;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(25, 135, 84, 0.10)';
|
||||
ctx.fillRect(x - 6, top, 12, bottom - top);
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('💰', x, top + 14);
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const chartInstance = new Chart(canvas, {
|
||||
type: 'line',
|
||||
@@ -140,11 +166,12 @@ window.chartInterop = {
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [highlightPlugin, crosshairPlugin]
|
||||
plugins: [highlightPlugin, crosshairPlugin, salaryPlugin]
|
||||
});
|
||||
|
||||
chartInstance.config._highlightIndex = (highlightIndex != null && highlightIndex >= 0) ? highlightIndex : null;
|
||||
if (highlightIndex != null && highlightIndex >= 0) {
|
||||
chartInstance.config._salaryDayIndices = salaryIndices;
|
||||
if ((highlightIndex != null && highlightIndex >= 0) || salaryIndices.length > 0) {
|
||||
chartInstance.update();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user