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
+128
View File
@@ -0,0 +1,128 @@
@page "/import"
@rendermode InteractiveServer
@inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter
@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter
@inject ISnackbar Snackbar
@using MeterVault.Infrastructure.Import
<PageTitle>MeterVault — Import</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Import</MudText>
<MudGrid>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6">Reference dataset</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a
starter dataset with meters, tariffs and categories.
</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload"
OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded">
@(_referenceLoaded ? "Loaded" : "Load reference data")
</MudButton>
@if (_loadingReference)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mt-3" />
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6">Dry-run a CSV</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
Upload a sheet and preview what would be staged (no changes are made).
</MudText>
<MudSelect T="string" @bind-Value="_profileName" Label="Sheet type" Dense="true" Class="mb-2">
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem>
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem>
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem>
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem>
</MudSelect>
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
Choose CSV
</MudButton>
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
</MudPaper>
</MudItem>
@if (_preview is not null)
{
<MudItem xs="12">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Preview</MudText>
<div class="d-flex" style="gap:2rem; flex-wrap:wrap">
<MudText>Readings: <b>@_preview.Readings.Count</b></MudText>
<MudText>Events: <b>@_preview.Events.Count</b></MudText>
<MudText>Manual costs: <b>@_preview.ManualCosts.Count</b></MudText>
<MudText>Skipped rows: <b>@_preview.SkippedRows</b></MudText>
</div>
@if (_preview.Warnings.Count > 0)
{
<MudExpansionPanels Class="mt-3">
<MudExpansionPanel Text="@($"{_preview.Warnings.Count} warnings")">
@foreach (var warning in _preview.Warnings.Take(50))
{
<MudText Typo="Typo.body2">@warning</MudText>
}
</MudExpansionPanel>
</MudExpansionPanels>
}
</MudPaper>
</MudItem>
}
</MudGrid>
@code {
private bool _loadingReference;
private bool _referenceLoaded;
private string _profileName = "Strom";
private StagedImport? _preview;
protected override async Task OnInitializedAsync() =>
_referenceLoaded = await ReferenceImporter.IsLoadedAsync();
private async Task LoadReferenceAsync()
{
_loadingReference = true;
try
{
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
await ReferenceImporter.LoadAsync(dir);
_referenceLoaded = true;
Snackbar.Add("Reference data loaded.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Import failed: {ex.Message}", Severity.Error);
}
finally
{
_loadingReference = false;
}
}
private async Task PreviewAsync(InputFileChangeEventArgs args)
{
var file = args.File;
if (file is null)
{
return;
}
var profile = SelectedProfile();
using var reader = new StreamReader(file.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024));
var content = await reader.ReadToEndAsync();
using var stringReader = new StringReader(content);
_preview = CsvImporter.Stage(profile, stringReader);
}
private MappingProfile SelectedProfile() => _profileName switch
{
"Wasser" => ReferenceProfiles.Water(),
"Heizöl" => ReferenceProfiles.HeatingOil(),
"Kosten" => ReferenceProfiles.Costs(),
_ => ReferenceProfiles.Electricity(),
};
}