Add booking-text charts to the transactions page
Adds two charts to the per-account transactions view: a stacked bar chart of monthly totals and a pie chart of overall totals, both grouped by booking text, so recurring bookings (rent, subscriptions, etc.) are visible at a glance. Chart components are keyed on the account id so switching accounts remounts them instead of reusing a stale Plotly instance.
This commit is contained in:
parent
a19bdf4558
commit
1dcccf89b1
9 changed files with 255 additions and 1 deletions
|
|
@ -0,0 +1,4 @@
|
||||||
|
namespace Schaad.Accounting.Datasets.Charts
|
||||||
|
{
|
||||||
|
public record CategoryTotal(string Name, decimal Value);
|
||||||
|
}
|
||||||
|
|
@ -95,6 +95,48 @@ namespace Schaad.Accounting.Services
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DataSerie>> GetBookingTextExpensesPerMonthAsync(string accountId)
|
||||||
|
{
|
||||||
|
var account = await viewService.GetAccountViewAsync(accountId);
|
||||||
|
var transactions = await viewService.GetTransactionViewListAsync(accountId);
|
||||||
|
if (transactions.Count == 0)
|
||||||
|
{
|
||||||
|
return new List<DataSerie>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var year = settingsService.GetYear();
|
||||||
|
var maxMonth = transactions.Max(t => t.ValueDate.Month);
|
||||||
|
|
||||||
|
var list = new List<DataSerie>();
|
||||||
|
foreach (var grp in transactions.GroupBy(t => t.Text))
|
||||||
|
{
|
||||||
|
var groupedByMonth = grp
|
||||||
|
.GroupBy(t => t.ValueDate.Month)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Sum(t => t.GetValue(account.IsFxAccount)));
|
||||||
|
EnsureEntryForEveryMonth(groupedByMonth, maxMonth);
|
||||||
|
|
||||||
|
list.Add(new DataSerie(
|
||||||
|
Id: grp.Key,
|
||||||
|
Name: grp.Key,
|
||||||
|
X: groupedByMonth.OrderBy(g => g.Key).Select(g => new DateOnly(year, g.Key, 1)).ToList(),
|
||||||
|
Y: groupedByMonth.OrderBy(g => g.Key).Select(g => g.Value).ToList()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<CategoryTotal>> GetBookingTextTotalsAsync(string accountId)
|
||||||
|
{
|
||||||
|
var account = await viewService.GetAccountViewAsync(accountId);
|
||||||
|
var transactions = await viewService.GetTransactionViewListAsync(accountId);
|
||||||
|
|
||||||
|
return transactions
|
||||||
|
.GroupBy(t => t.Text)
|
||||||
|
.Select(g => new CategoryTotal(g.Key, g.Sum(t => t.GetValue(account.IsFxAccount))))
|
||||||
|
.Where(c => c.Value != 0)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private static void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12)
|
private static void EnsureEntryForEveryMonth(Dictionary<int, decimal> values, int maxMonth = 12)
|
||||||
{
|
{
|
||||||
for (int i = 1; i <= maxMonth; i++)
|
for (int i = 1; i <= maxMonth; i++)
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,9 @@ namespace Schaad.Accounting.Interfaces
|
||||||
public interface IChartService
|
public interface IChartService
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync();
|
Task<IReadOnlyList<DataSerie>> GetExpensesPerMonthAsync();
|
||||||
|
|
||||||
|
Task<IReadOnlyList<DataSerie>> GetBookingTextExpensesPerMonthAsync(string accountId);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<CategoryTotal>> GetBookingTextTotalsAsync(string accountId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ public class ChartServiceTestShould
|
||||||
private static AccountDataset AsDataset(Account a, string subClassName = "") =>
|
private static AccountDataset AsDataset(Account a, string subClassName = "") =>
|
||||||
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: subClassName);
|
new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: subClassName);
|
||||||
|
|
||||||
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate, decimal? fxRate = null) =>
|
private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate, decimal? fxRate = null, string text = "") =>
|
||||||
new(
|
new(
|
||||||
new Transaction
|
new Transaction
|
||||||
{
|
{
|
||||||
|
|
@ -127,6 +127,7 @@ public class ChartServiceTestShould
|
||||||
TargetAccountId = target.Id,
|
TargetAccountId = target.Id,
|
||||||
Value = value,
|
Value = value,
|
||||||
FxRate = fxRate,
|
FxRate = fxRate,
|
||||||
|
Text = text,
|
||||||
ValueDate = valueDate,
|
ValueDate = valueDate,
|
||||||
BookingDate = valueDate
|
BookingDate = valueDate
|
||||||
},
|
},
|
||||||
|
|
@ -168,4 +169,80 @@ public class ChartServiceTestShould
|
||||||
|
|
||||||
series.Single(s => s.Id == "42").Y[0].ShouldBe(200m);
|
series.Single(s => s.Id == "42").Y[0].ShouldBe(200m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GroupBookingTextExpensesByTextWhenBuildingMonthlySeriesForAccount()
|
||||||
|
{
|
||||||
|
var checking = MakeAccount("chk", 1010);
|
||||||
|
var groceries = MakeAccount("groc", 4200);
|
||||||
|
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
|
||||||
|
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
|
||||||
|
{
|
||||||
|
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
|
||||||
|
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 3), text: "Coop")
|
||||||
|
});
|
||||||
|
|
||||||
|
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
|
||||||
|
|
||||||
|
series.Count.ShouldBe(2);
|
||||||
|
var migros = series.Single(s => s.Name == "Migros");
|
||||||
|
migros.Y[0].ShouldBe(50m);
|
||||||
|
migros.Y[1].ShouldBe(0m);
|
||||||
|
var coop = series.Single(s => s.Name == "Coop");
|
||||||
|
coop.Y[0].ShouldBe(0m);
|
||||||
|
coop.Y[1].ShouldBe(20m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConvertFxAccountValuesWhenBuildingBookingTextSeries()
|
||||||
|
{
|
||||||
|
var usChecking = MakeAccount("chk", 1010, currency: "USD");
|
||||||
|
var groceries = MakeAccount("groc", 4200);
|
||||||
|
viewService.GetAccountViewAsync("chk").Returns(AsDataset(usChecking));
|
||||||
|
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
|
||||||
|
{
|
||||||
|
MakeTransaction(usChecking, groceries, value: 100m, valueDate: new DateTime(2026, 1, 5), fxRate: 0.5m, text: "Migros")
|
||||||
|
});
|
||||||
|
|
||||||
|
var series = await BuildService().GetBookingTextExpensesPerMonthAsync("chk");
|
||||||
|
|
||||||
|
series.Single().Y.Single().ShouldBe(200m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SumBookingTextTotalsAcrossMonthsWhenBuildingTotalsForAccount()
|
||||||
|
{
|
||||||
|
var checking = MakeAccount("chk", 1010);
|
||||||
|
var groceries = MakeAccount("groc", 4200);
|
||||||
|
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
|
||||||
|
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
|
||||||
|
{
|
||||||
|
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
|
||||||
|
MakeTransaction(checking, groceries, value: 30m, valueDate: new DateTime(2026, 2, 3), text: "Migros"),
|
||||||
|
MakeTransaction(checking, groceries, value: 20m, valueDate: new DateTime(2026, 2, 4), text: "Coop")
|
||||||
|
});
|
||||||
|
|
||||||
|
var totals = await BuildService().GetBookingTextTotalsAsync("chk");
|
||||||
|
|
||||||
|
totals.Count.ShouldBe(2);
|
||||||
|
totals.ShouldContain(t => t.Name == "Migros" && t.Value == 80m);
|
||||||
|
totals.ShouldContain(t => t.Name == "Coop" && t.Value == 20m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExcludeZeroNetBookingTextsWhenBuildingTotalsForAccount()
|
||||||
|
{
|
||||||
|
var checking = MakeAccount("chk", 1010);
|
||||||
|
var groceries = MakeAccount("groc", 4200);
|
||||||
|
viewService.GetAccountViewAsync("chk").Returns(AsDataset(checking));
|
||||||
|
viewService.GetTransactionViewListAsync("chk").Returns(new List<TransactionDataset>
|
||||||
|
{
|
||||||
|
MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 5), text: "Migros"),
|
||||||
|
MakeTransaction(checking, groceries, value: -50m, valueDate: new DateTime(2026, 1, 6), text: "Migros")
|
||||||
|
});
|
||||||
|
|
||||||
|
var totals = await BuildService().GetBookingTextTotalsAsync("chk");
|
||||||
|
|
||||||
|
totals.ShouldBeEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
@if (config is not null && layout is not null && data is not null)
|
||||||
|
{
|
||||||
|
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<Loading/>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Plotly.Blazor;
|
||||||
|
using Plotly.Blazor.Traces;
|
||||||
|
using Schaad.Accounting.Interfaces;
|
||||||
|
|
||||||
|
namespace Schaad.Accounting.UI.Components.Pages.Charts;
|
||||||
|
|
||||||
|
public partial class BookingTextBreakdown : ComponentBase
|
||||||
|
{
|
||||||
|
private PlotlyChart chart = null!;
|
||||||
|
private Config? config;
|
||||||
|
private Plotly.Blazor.Layout? layout;
|
||||||
|
private IList<ITrace>? data;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string AccountId { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private IChartService chartService { get; set; } = null!;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
config = new Config
|
||||||
|
{
|
||||||
|
Responsive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
layout = new Plotly.Blazor.Layout
|
||||||
|
{
|
||||||
|
Title = new Plotly.Blazor.LayoutLib.Title { Text = "Nach Buchungstext" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var totals = await chartService.GetBookingTextTotalsAsync(AccountId);
|
||||||
|
data = new List<ITrace>
|
||||||
|
{
|
||||||
|
new Pie
|
||||||
|
{
|
||||||
|
// Values are shown as their absolute magnitude - a pie slice can't represent a signed amount.
|
||||||
|
Values = totals.Select(t => (object)Math.Abs(t.Value)).ToList(),
|
||||||
|
Labels = totals.Select(t => (object)t.Name).ToList()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
@if (config is not null && layout is not null)
|
||||||
|
{
|
||||||
|
<PlotlyChart style="height: 400px; min-height: 300px" @bind-Config="config" @bind-Layout="layout" @bind-Data="data" @ref="chart" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<Loading/>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Plotly.Blazor;
|
||||||
|
using Plotly.Blazor.LayoutLib;
|
||||||
|
using Plotly.Blazor.Traces;
|
||||||
|
using Schaad.Accounting.Interfaces;
|
||||||
|
|
||||||
|
namespace Schaad.Accounting.UI.Components.Pages.Charts;
|
||||||
|
|
||||||
|
public partial class BookingTextOverTime : ComponentBase
|
||||||
|
{
|
||||||
|
private PlotlyChart chart = null!;
|
||||||
|
private Config? config;
|
||||||
|
private Plotly.Blazor.Layout? layout;
|
||||||
|
private IList<ITrace> data = new List<ITrace>();
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string AccountId { get; set; } = null!;
|
||||||
|
|
||||||
|
[Inject]
|
||||||
|
private IChartService chartService { get; set; } = null!;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
config = new Config
|
||||||
|
{
|
||||||
|
Responsive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
layout = new Plotly.Blazor.Layout
|
||||||
|
{
|
||||||
|
Title = new Title { Text = "Nach Buchungstext im Verlauf" },
|
||||||
|
BarMode = BarModeEnum.Stack,
|
||||||
|
XAxis = new List<XAxis>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var newData = new List<ITrace>();
|
||||||
|
var dataSeries = await chartService.GetBookingTextExpensesPerMonthAsync(AccountId);
|
||||||
|
foreach (var dataSerie in dataSeries)
|
||||||
|
{
|
||||||
|
newData.Add(new Bar
|
||||||
|
{
|
||||||
|
Name = dataSerie.Name,
|
||||||
|
X = dataSerie.X.Select(d => (object)d).ToList(),
|
||||||
|
Y = dataSerie.Y.Select(d => (object)d).ToList(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
data = newData;
|
||||||
|
|
||||||
|
await base.OnInitializedAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
@page "/transactions"
|
@page "/transactions"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
@using Schaad.Accounting.UI.Components.Pages.Charts
|
||||||
|
|
||||||
<PageTitle>Transaktionen</PageTitle>
|
<PageTitle>Transaktionen</PageTitle>
|
||||||
|
|
||||||
|
|
@ -43,6 +44,15 @@
|
||||||
</FluentGridItem>
|
</FluentGridItem>
|
||||||
</FluentGrid>
|
</FluentGrid>
|
||||||
|
|
||||||
|
<FluentGrid Justify="JustifyContent.FlexStart">
|
||||||
|
<FluentGridItem xs="6">
|
||||||
|
<BookingTextOverTime @key="selectedAccount.Id" AccountId="@selectedAccount.Id"/>
|
||||||
|
</FluentGridItem>
|
||||||
|
<FluentGridItem xs="6">
|
||||||
|
<BookingTextBreakdown @key="selectedAccount.Id" AccountId="@selectedAccount.Id"/>
|
||||||
|
</FluentGridItem>
|
||||||
|
</FluentGrid>
|
||||||
|
|
||||||
<FluentPaginator State="@pagination" SummaryTemplate="@template"/>
|
<FluentPaginator State="@pagination" SummaryTemplate="@template"/>
|
||||||
<FluentDataGrid Items="@transactionList" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
<FluentDataGrid Items="@transactionList" Pagination="@pagination" RowSize="DataGridRowSize.Medium">
|
||||||
<PropertyColumn Property="@(p => p.BookingDate)" Format="dd.MM.yyyy" Sortable="true" IsDefaultSortColumn="true" InitialSortDirection="SortDirection.Descending"/>
|
<PropertyColumn Property="@(p => p.BookingDate)" Format="dd.MM.yyyy" Sortable="true" IsDefaultSortColumn="true" InitialSortDirection="SortDirection.Descending"/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue