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
+64
View File
@@ -0,0 +1,64 @@
@page "/meters"
@rendermode InteractiveServer
@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db
@using Microsoft.EntityFrameworkCore
<PageTitle>MeterVault — Meters</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Meters</MudText>
@if (_meters is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else
{
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Type</MudTh>
<MudTh>Mode</MudTh>
<MudTh>Unit</MudTh>
<MudTh>Sources</MudTh>
<MudTh>Last seen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
<MudTd DataLabel="Mode">@context.Mode</MudTd>
<MudTd DataLabel="Unit">@context.Unit</MudTd>
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
<MudTd DataLabel="Last seen">
@{
var lastSeen = context.Sources
.Where(s => s.LastSeenAt != null)
.Select(s => s.LastSeenAt)
.DefaultIfEmpty(null)
.Max();
}
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
</MudTd>
</RowTemplate>
</MudTable>
@if (_meters.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">
No meters yet. Go to <MudLink Href="/import">Import</MudLink> to load the reference data.
</MudAlert>
}
}
@code {
private List<Meter>? _meters;
protected override async Task OnInitializedAsync()
{
_meters = await Db.Meters
.AsNoTracking()
.Include(m => m.EnergyType)
.Include(m => m.Sources)
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
.ToListAsync();
}
}