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:
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
Welcome to your new app.
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@page "/trends"
|
||||
@rendermode InteractiveServer
|
||||
@inject DashboardService Dash
|
||||
|
||||
<PageTitle>MeterVault — Trends</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Cost trend</MudText>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<div class="d-flex align-center mb-3" style="gap:1rem">
|
||||
<MudSelect T="int" @bind-Value="_months" Label="Range" Dense="true" Style="max-width:180px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="48">Last 48 months</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync">Apply</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<TrendChart Points="_points" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-4">
|
||||
Total over range: @Format.Euro(_points.Sum(p => p.Cost))
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
private int _months = 24;
|
||||
private bool _loading = true;
|
||||
private IReadOnlyList<TrendPoint> _points = [];
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loading = true;
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = asOf.AddMonths(-_months);
|
||||
_points = await Dash.GetMonthlyTrendAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user