168 lines
7.4 KiB
Plaintext
168 lines
7.4 KiB
Plaintext
@page "/reports"
|
|
@attribute [Authorize]
|
|
@inject ITransactionRepository TransactionService
|
|
@inject IAccountRepository AccountService
|
|
@inject ICategoryRepository CategoryService
|
|
@inject ISalaryRepository SalaryService
|
|
@inject AuthenticationStateProvider AuthStateProvider
|
|
@inject IJSRuntime Js
|
|
@implements IAsyncDisposable
|
|
@rendermode InteractiveServer
|
|
|
|
<PageTitle>Reports</PageTitle>
|
|
|
|
<h3 class="mb-2">Reports</h3>
|
|
<p class="text-muted mb-4">Visual breakdown of your spending and income by category for a specified time period.</p>
|
|
|
|
@if (_loading)
|
|
{
|
|
<p><em>Loading...</em></p>
|
|
}
|
|
else if (_accounts.Count == 0)
|
|
{
|
|
<div class="alert alert-info">
|
|
<h5>No data yet</h5>
|
|
<p>Add some <a href="/accounts">accounts</a> and <a href="/transactions">transactions</a> to see your spending reports.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
@* ── Date Range & Account Filters ── *@
|
|
<div class="card mb-4">
|
|
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
|
|
<h5 class="mb-0">Filters</h5>
|
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn @(_preset == "7d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(7)">7d</button>
|
|
<button class="btn @(_preset == "30d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(30)">30d</button>
|
|
<button class="btn @(_preset == "90d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(90)">90d</button>
|
|
<button class="btn @(_preset == "1y" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(365)">1y</button>
|
|
</div>
|
|
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
|
title="Start date" @bind="_dateFrom" @bind:after="OnDateChanged" />
|
|
<span class="text-muted">to</span>
|
|
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
|
title="End date" @bind="_dateTo" @bind:after="OnDateChanged" />
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="d-flex flex-wrap gap-2">
|
|
@foreach (var account in _accounts)
|
|
{
|
|
<button class="btn btn-sm @(_selectedAccountIds.Contains(account.Id) ? "btn-primary" : "btn-outline-secondary")"
|
|
title="Toggle @account.Name" @onclick="() => ToggleAccount(account.Id)">
|
|
<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background-color:@account.Color;"></span>
|
|
@account.Name
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@* ── Expense by Category (Doughnut) ── *@
|
|
<div class="row mb-4">
|
|
<div class="col-lg-6 mb-4 mb-lg-0">
|
|
<div class="card h-100">
|
|
<div class="card-header"><h5 class="mb-0">Expenses by Category</h5></div>
|
|
<div class="card-body">
|
|
@if (_categoryBreakdown.Count == 0)
|
|
{
|
|
<p class="text-muted">No expenses in this period.</p>
|
|
}
|
|
else
|
|
{
|
|
<div style="position:relative;height:clamp(250px,35vw,400px);">
|
|
<canvas id="expensePieChart"></canvas>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@* ── Income by Category (Doughnut) ── *@
|
|
<div class="col-lg-6">
|
|
<div class="card h-100">
|
|
<div class="card-header"><h5 class="mb-0">Income by Category</h5></div>
|
|
<div class="card-body">
|
|
@if (_incomeBreakdown.Count == 0)
|
|
{
|
|
<p class="text-muted">No income in this period.</p>
|
|
}
|
|
else
|
|
{
|
|
<div style="position:relative;height:clamp(250px,35vw,400px);">
|
|
<canvas id="incomePieChart"></canvas>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@* ── Monthly Income vs Expenses (Bar) ── *@
|
|
<div class="card mb-4">
|
|
<div class="card-header"><h5 class="mb-0">Monthly Income vs Expenses</h5></div>
|
|
<div class="card-body">
|
|
@if (_monthlyLabels.Count == 0)
|
|
{
|
|
<p class="text-muted">No transactions in this period.</p>
|
|
}
|
|
else
|
|
{
|
|
<div style="position:relative;height:clamp(250px,30vw,350px);">
|
|
<canvas id="monthlyBarChart"></canvas>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@* ── Category Breakdown Table ── *@
|
|
<div class="card mb-4">
|
|
<div class="card-header"><h5 class="mb-0">Category Summary</h5></div>
|
|
<div class="card-body">
|
|
@if (_categoryBreakdown.Count == 0 && _incomeBreakdown.Count == 0)
|
|
{
|
|
<p class="text-muted">No transactions in this period.</p>
|
|
}
|
|
else
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Category</th>
|
|
<th class="text-end">Income</th>
|
|
<th class="text-end">Expenses</th>
|
|
<th class="text-end">Net</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var row in _categorySummary)
|
|
{
|
|
<tr>
|
|
<td>@row.Icon @row.Name</td>
|
|
<td class="text-end text-success">@_currencySymbol@row.Income.ToString("N2")</td>
|
|
<td class="text-end text-danger">@_currencySymbol@row.Expense.ToString("N2")</td>
|
|
<td class="text-end @(row.Net >= 0 ? "text-success" : "text-danger")">
|
|
@_currencySymbol@row.Net.ToString("N2")
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr class="fw-bold">
|
|
<td>Total</td>
|
|
<td class="text-end text-success">@_currencySymbol@_categorySummary.Sum(r => r.Income).ToString("N2")</td>
|
|
<td class="text-end text-danger">@_currencySymbol@_categorySummary.Sum(r => r.Expense).ToString("N2")</td>
|
|
@{ var totalNet = _categorySummary.Sum(r => r.Net); }
|
|
<td class="text-end @(totalNet >= 0 ? "text-success" : "text-danger")">
|
|
@_currencySymbol@totalNet.ToString("N2")
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
} |