M5: Blazor dashboard (MudBlazor + ApexCharts)

- MudBlazor theme (dark default) + responsive drawer/appbar layout + nav.
- DashboardService read model: KPIs with period-over-period deltas, category breakdown,
  "what cost more/less" difference view, monthly trends.
- Pages: Overview (KPI cards + DeltaChip + donut + difference table), Trends (range-select
  bar chart), Meters (list + source status), Import (load reference dataset + CSV dry-run
  preview), Admin (energy types, tariffs). Charts isolated into components to avoid the
  ApexCharts/MudBlazor Color/Format name clashes.
- ReferenceDataImporter: one-click load of all four sheets as a starter dataset (meters,
  tank, tariff history, category memberships) — bundled sample CSVs copied to app output.
- End-to-end render test: import creates meters + consumption; overview/meters/trends/
  import/admin pages all return 200 with KPI cards rendered.

92 tests green (56 Core + 36 integration).

Deferred to polish: dedicated PV & oil/consumable panels, meter-detail page, full admin
CRUD, prev-year trend overlay.

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
2026-07-13 12:11:12 +02:00
parent 6fc710d55a
commit d5419729e5
25 changed files with 1094 additions and 74 deletions
@@ -0,0 +1,37 @@
@page "/admin/energy-types"
@rendermode InteractiveServer
@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db
@using Microsoft.EntityFrameworkCore
<PageTitle>MeterVault — Energy types</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Energy types</MudText>
@if (_types is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else
{
<MudTable Items="_types" Dense="true" Hover="true" Elevation="2">
<HeaderContent>
<MudTh>Key</MudTh>
<MudTh>Display name</MudTh>
<MudTh>Base unit</MudTh>
<MudTh>Default mode</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Key">@context.Key</MudTd>
<MudTd DataLabel="Display name">@context.DisplayName</MudTd>
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
</RowTemplate>
</MudTable>
}
@code {
private List<EnergyType>? _types;
protected override async Task OnInitializedAsync() =>
_types = await Db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
}
@@ -0,0 +1,47 @@
@page "/admin/tariffs"
@rendermode InteractiveServer
@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db
@using Microsoft.EntityFrameworkCore
<PageTitle>MeterVault — Tariffs</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Tariffs</MudText>
@if (_tariffs is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_tariffs.Count == 0)
{
<MudAlert Severity="Severity.Info">No tariffs yet. Load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
}
else
{
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
<HeaderContent>
<MudTh>Scope</MudTh>
<MudTh>Component</MudTh>
<MudTh>Value</MudTh>
<MudTh>Unit</MudTh>
<MudTh>Valid from</MudTh>
<MudTh>Valid to</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Scope">@context.ScopeType @(context.ScopeId is { } id ? $"#{id}" : "")</MudTd>
<MudTd DataLabel="Component">@context.Component</MudTd>
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
<MudTd DataLabel="Unit">@context.Unit</MudTd>
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
</RowTemplate>
</MudTable>
}
@code {
private List<Tariff>? _tariffs;
protected override async Task OnInitializedAsync() =>
_tariffs = await Db.Tariffs.AsNoTracking()
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
.ToListAsync();
}