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
+102
View File
@@ -0,0 +1,102 @@
@page "/"
@rendermode InteractiveServer
@inject DashboardService Dash
<PageTitle>MeterVault — Overview</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Overview</MudText>
@if (_summary is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else
{
<MudGrid>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">This month</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_summary.Month.Current)</MudText>
<DeltaChip Kpi="_summary.Month" />
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_summary.Year.Current)</MudText>
<DeltaChip Kpi="_summary.Year" />
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Latest month with data</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">What costs most (this year)</MudText>
@if (_breakdown is { Count: > 0 })
{
<CategoryDonut Slices="_breakdown" />
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
<tbody>
@foreach (var slice in _breakdown)
{
<tr>
<td>@slice.Name</td>
<td style="text-align:right">@Format.Euro(slice.Cost)</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No cost data yet — import a sheet or add tariffs.</MudText>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">What cost more / less (year vs last year)</MudText>
<MudSimpleTable Dense="true" Hover="true">
<thead>
<tr><th>Category</th><th style="text-align:right">Now</th><th style="text-align:right">Prev</th><th style="text-align:right">Δ</th></tr>
</thead>
<tbody>
@foreach (var row in _difference)
{
<tr>
<td>@row.Name</td>
<td style="text-align:right">@Format.Euro(row.Current)</td>
<td style="text-align:right">@Format.Euro(row.Previous)</td>
<td style="text-align:right" class="@(row.Delta >= 0 ? "mv-up" : "mv-down")">
@Format.DirectionIcon(Math.Sign(row.Delta)) @Format.Euro(Math.Abs(row.Delta))
</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
</MudItem>
</MudGrid>
}
@code {
private DashboardSummary? _summary;
private IReadOnlyList<CategorySlice> _breakdown = [];
private IReadOnlyList<DifferenceRow> _difference = [];
protected override async Task OnInitializedAsync()
{
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
_summary = await Dash.GetSummaryAsync(asOf);
var yearStart = new DateOnly(asOf.Year, 1, 1);
_breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1));
_difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1));
}
}