Analysis: one selected period, one set of numbers, on every page
ci / build-test (push) Successful in 2m31s
ci / build-test (push) Successful in 2m31s
The dashboards told several stories at once. Overview asked for full calendar years, meter detail for a fixed 12-month window that was really 13, Trends for 24 months with an Apply button, and the energy pages for 60. Each page derived "today" from UTC, so the first hours of a local day belonged to yesterday. A missing tariff, a month nobody measured and a genuine zero all rendered as 0. And a virtual meter -- the one thing the spreadsheet leans on hardest -- was excluded from analysis outright: MeterPeriodService returned null for it and the page offered a flow diagram instead. docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58 plus amendments A-01..A-30; code, tests and release notes cite those ids. The analysis layer Core/Analysis holds the pure rules: period presets resolved once in the instance zone into a local date range and a half-open UTC range, bucket plans, calendar-unit comparisons, coverage runs with a resolution class, normalized quantities and units, the totals policy, the virtual formula parser/validator/evaluator, and the cost calculator. "Now" comes from TimeProvider; services never read the clock. Normalization now writes, in the same transaction as consumption and by diff, per-meter rollups by local day and month plus coverage runs and a rollup state (AnalysisDataWriter). AnalysisReader answers a request from those tables -- month rollups for month and year buckets, day rollups otherwise, at most two partial edge days from consumption -- and CostReader prices the result month by month. Pages, /api/v1 and the CSV export read nothing else. The unused continuous aggregates are dropped. The reader's statement count per request is constant whether it covers one meter or a thousand. On a synthetic 1,000-meter, ten-year instance the brief's target request (100 meters, ten years, monthly) takes 374 ms against a two-second target, and the Overview went from 48,244 SQL statements per load to 205. Missing is not zero Every bucket carries a status -- available, partial, missing, unresolved, invalid, pending -- derived from coverage, never from the amount, with provenance and a reason code beside it. A true zero is a number and a bar on the baseline; an unknown bucket is a gap that says why; a month whose data only exists monthly says so instead of inventing daily detail; a scope with no tariff says "not priced" instead of 0. Rows whose interval closes after now are reported separately rather than counted. Virtual meters are analysis subjects A virtual meter stores a canonical definition -- expression over m<id> references, result kind, unit and cost rule -- validated on save and on read for syntax, unknown or self references, loops and unit/kind rules. It is evaluated on read from its sources' rollups over their joint coverage: a missing source makes the bucket missing, an observed zero is a valid input, a non-finite result is invalid with its dependency path, and the page lists each source's contribution. Topology links are topology only and never rewrite a saved calculation; expression-less meters from older installs are converted once at startup. The editor has Sum, Difference and Advanced modes with a live preview. Totals and the bill Per energy type the totals policy separates use, grid import, export, generation and runtime, marks breakdown meters as breakdowns and virtual meters as views, and never adds across units. The bill follows it: grid import where there is one, separately priced subsections at their own price, feed-in only on export meters, standing charges once per scope per local day, manual costs once on their start day, categories as non-overlapping covers whose composition reconciles to the bill. The seeded demo's yearly totals now match the spreadsheet. Pages and navigation The period lives in the URL and every page reads the same contract, so a link, a reload and the browser's Back button keep it. Shared components carry it: page header with breadcrumbs, period toolbar, theme-aware chart with an accessible table beside it, metric cards, comparison and availability states, attention items that each link to the one action that fixes them. Meter detail leads with an Analysis tab and resolves its tabs by key; the energy page has Overview, History, Flow and Meters; the old cost-only Trends page is a general Analysis page over portfolio, type, category, meter or a meter comparison. Records tabs are paged server-side instead of showing the latest 200. Everything is English and German, light and dark, down to 360px. Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and what the first start after the update does (it rebuilds all analysis data before the web server listens). docs/SDD.md and CLAUDE.md describe the system as it now is. Tests: 1,733 Core and 746 integration, all green, plus an opt-in performance suite with a synthetic 1,000-meter generator.
This commit is contained in:
@@ -6,14 +6,17 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — @S.Nav_EnergyTypes</PageTitle>
|
||||
<PageTitle>MeterVault — @S.EnergyTypes_Title</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">@S.Nav_EnergyTypes</MudText>
|
||||
@* Configuration, not analysis: the menu's "Energy types" group opens each type's analysis, this page edits what a type
|
||||
is. The title and the line below say which one the reader is on. *@
|
||||
<div class="d-flex align-center justify-space-between mb-1 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4" HtmlTag="h1">@S.EnergyTypes_Title</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
@S.EnergyTypes_Add
|
||||
</MudButton>
|
||||
</div>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">@S.EnergyTypes_Description</MudText>
|
||||
|
||||
@if (_types is null)
|
||||
{
|
||||
@@ -162,11 +165,9 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
var target = await db.EnergyTypes.FirstOrDefaultAsync(t => t.Id == type.Id);
|
||||
if (target is not null)
|
||||
// Its type-scoped prices go with it: tariff.scope_id has no foreign key.
|
||||
if (await MeterVault.Infrastructure.Persistence.EntityDeletion.DeleteEnergyTypeAsync(db, type.Id))
|
||||
{
|
||||
db.EnergyTypes.Remove(target);
|
||||
await db.SaveChangesAsync();
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
NavState.NotifyEnergyTypesChanged();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
@page "/admin/settings"
|
||||
@using System.Text.Json
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@using MeterVault.Infrastructure.Normalization
|
||||
@using MeterVault.Infrastructure.Persistence
|
||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||
@inject IDbContextFactory<MeterVaultDbContext> DbFactory
|
||||
@inject AnalysisReader Reader
|
||||
@inject InstanceCurrency Currency
|
||||
@inject ILogger<Settings> Logger
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — @S.Nav_Settings</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-2">@S.Nav_Settings</MudText>
|
||||
<PageHeader Title="@S.Nav_Settings" Class="mb-2" />
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||
@S.Settings_EffectiveLead <b>@S.Settings_EffectiveEmphasis</b> @S.Settings_EffectiveRest
|
||||
(<code>MeterVault__Key</code> / <code>Section__Key</code>) @S.Settings_EffectiveTail
|
||||
@@ -16,13 +23,20 @@
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_LocaleAndTime</MudText>
|
||||
<MudSimpleTable Dense="true">
|
||||
<tbody>
|
||||
<tr><td>@S.Settings_Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr>
|
||||
<tr><td>@S.Settings_Timezone</td><td style="text-align:right"><code>@Reader.Zone.Id</code></td></tr>
|
||||
<tr><td>@S.Settings_Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
|
||||
<tr><td>@S.Common_Currency</td><td style="text-align:right"><code>@_o.Currency</code></td></tr>
|
||||
<tr><td>@S.Settings_RawRetention</td><td style="text-align:right">@Loc.F(S.Settings_RetentionDays, _o.RawRetentionDays)</td></tr>
|
||||
<tr><td>@S.Common_Currency</td><td style="text-align:right"><code>@Currency.Code</code> (@Currency.Symbol)</td></tr>
|
||||
<tr>
|
||||
<td>@S.Settings_RawRetention</td>
|
||||
<td style="text-align:right">
|
||||
@* D-57: every recompute rebuilds a meter from its readings, so deleting old ones would erase history. *@
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined">@S.Settings_RetentionNotEnforced</MudChip>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mt-2">@Loc.F(S.Settings_RetentionReason, _o.RawRetentionDays)</MudText>
|
||||
<MudText Typo="Typo.caption" Class="mv-muted mt-2">
|
||||
@S.Settings_EnvKeysLabel <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
|
||||
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
|
||||
</MudText>
|
||||
@@ -56,16 +70,137 @@
|
||||
<tr><td>@S.Settings_SeedReferenceData</td><td style="text-align:right">@(_o.SeedReferenceData ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
<MudText Typo="Typo.caption" Class="mv-muted mt-2">
|
||||
@S.Settings_ApiKeysHintBefore <code>MeterVault__ApiKeys__0</code>. @S.Settings_ApiKeysHintAfter
|
||||
@S.Settings_ApiDocsLabel <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_AnalysisData</MudText>
|
||||
@if (_analysis is { } state)
|
||||
{
|
||||
<MudSimpleTable Dense="true">
|
||||
<tbody>
|
||||
<tr><td>@S.Settings_NormalizationRevision</td><td style="text-align:right">@RevisionText(state)</td></tr>
|
||||
<tr><td>@S.Settings_NormalizationZone</td><td style="text-align:right">@ZoneText(state)</td></tr>
|
||||
<tr>
|
||||
<td>@S.Settings_MetersCurrent</td>
|
||||
<td style="text-align:right">
|
||||
@Loc.F(S.Settings_MetersCurrentValue, state.CurrentMeters, state.PhysicalMeters)
|
||||
@if (state.PhysicalMeters > state.CurrentMeters)
|
||||
{
|
||||
<span class="mv-muted"> · @Loc.F(S.Settings_MetersPending, state.PhysicalMeters - state.CurrentMeters)</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (state.Retry.Count > 0)
|
||||
{
|
||||
<tr><td>@S.Settings_RebuildRetry</td><td style="text-align:right">@string.Join(", ", state.Retry)</td></tr>
|
||||
}
|
||||
<tr><td>@S.Settings_VirtualMeters</td><td style="text-align:right">@VirtualText(state)</td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
else if (_analysisFailed)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true">@S.Settings_AnalysisUnavailable</MudAlert>
|
||||
}
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mt-2">@S.Settings_AnalysisHelp</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
private MeterVault.Infrastructure.Options.MeterVaultOptions _o = new();
|
||||
private AnalysisState? _analysis;
|
||||
private bool _analysisFailed;
|
||||
|
||||
protected override void OnInitialized() => _o = Options.Value;
|
||||
/// <summary>What the analysis data is built with (D-16) and how the calculated meters stand (D-26, D-28).</summary>
|
||||
private sealed record AnalysisState(
|
||||
int? Revision,
|
||||
string? Zone,
|
||||
int PhysicalMeters,
|
||||
int CurrentMeters,
|
||||
IReadOnlyList<string> Retry,
|
||||
IReadOnlyList<(VirtualMeterStatus Status, int Count)> Virtual);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_o = Options.Value;
|
||||
try
|
||||
{
|
||||
_analysis = await LoadAnalysisStateAsync();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// The settings themselves are configuration and always show; only this read-out depends on the database.
|
||||
Logger.LogWarning(ex, "Could not read the analysis data state");
|
||||
_analysisFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AnalysisState> LoadAnalysisStateAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var settings = await db.AppSettings.AsNoTracking()
|
||||
.Where(s => s.Key == NormalizationUpgrade.SettingKey || s.Key == NormalizationUpgrade.ZoneSettingKey || s.Key == NormalizationUpgrade.PendingSettingKey)
|
||||
.ToDictionaryAsync(s => s.Key, s => s.Value);
|
||||
|
||||
var catalog = await Reader.LoadCatalogAsync();
|
||||
var physical = catalog.Meters.Values.Where(m => !m.IsVirtual).ToList();
|
||||
var retryIds = Read<int[]>(settings, NormalizationUpgrade.PendingSettingKey) ?? [];
|
||||
var virtualStates = catalog.Meters.Values
|
||||
.Where(m => m.IsVirtual)
|
||||
.GroupBy(m => m.VirtualStatus ?? VirtualMeterStatus.NeedsConfiguration)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => (g.Key, g.Count()))
|
||||
.ToList();
|
||||
|
||||
return new AnalysisState(
|
||||
Read<int?>(settings, NormalizationUpgrade.SettingKey),
|
||||
Read<string>(settings, NormalizationUpgrade.ZoneSettingKey),
|
||||
physical.Count,
|
||||
physical.Count(m => !m.IsPending),
|
||||
[.. retryIds.Select(id => catalog.Find(id)?.Name ?? $"#{id}")],
|
||||
virtualStates);
|
||||
}
|
||||
|
||||
private static T? Read<T>(IReadOnlyDictionary<string, string> settings, string key)
|
||||
{
|
||||
if (!settings.TryGetValue(key, out var json))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RevisionText(AnalysisState state) => state.Revision switch
|
||||
{
|
||||
null => S.Settings_RevisionNone,
|
||||
var revision when revision < NormalizationUpgrade.CurrentRevision =>
|
||||
Loc.F(S.Settings_RevisionOutdated, revision, NormalizationUpgrade.CurrentRevision),
|
||||
var revision => Loc.F(S.Settings_RevisionValue, revision),
|
||||
};
|
||||
|
||||
private string ZoneText(AnalysisState state) => state.Zone switch
|
||||
{
|
||||
null => S.Settings_RevisionNone,
|
||||
var zone when !string.Equals(zone, Reader.Zone.Id, StringComparison.Ordinal) => Loc.F(S.Settings_ZoneDiffers, zone),
|
||||
var zone => zone,
|
||||
};
|
||||
|
||||
private static string VirtualText(AnalysisState state) => state.Virtual.Count == 0
|
||||
? S.Settings_None
|
||||
: string.Join(" · ", state.Virtual.Select(v => $"{v.Status.Display()}: {v.Count}"));
|
||||
}
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
@page "/admin/tariffs"
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
@using MeterVault.App.TariffEditing
|
||||
@using MeterVault.Core.Analysis.Quantities
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject AnalysisReader Reader
|
||||
@inject InstanceCurrency Currency
|
||||
@inject InstanceClock Clock
|
||||
@inject NavigationManager Nav
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject ILogger<Tariffs> Logger
|
||||
|
||||
<PageTitle>MeterVault — @S.Nav_Tariffs</PageTitle>
|
||||
<PageHeader Title="@S.Nav_Tariffs" Description="@S.Tariffs_Description" Class="mb-1">
|
||||
<Actions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
@S.Tariffs_AddTariff
|
||||
</MudButton>
|
||||
</Actions>
|
||||
</PageHeader>
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-3">@S.Tariffs_NotAppliedNote</MudText>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">@S.Nav_Tariffs</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
@S.Tariffs_AddTariff
|
||||
</MudButton>
|
||||
</div>
|
||||
@if (_link.HasScope)
|
||||
{
|
||||
@* D-52: a link from a missing-cost explanation scopes the list to what can price that meter or type. *@
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||
<div class="d-flex flex-wrap align-center" style="gap:0.25rem 1rem">
|
||||
<span>@FilterText</span>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" OnClick="ShowAll">@S.Tariffs_ShowAll</MudButton>
|
||||
</div>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@if (_tariffs is null)
|
||||
{
|
||||
@@ -20,7 +39,8 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
|
||||
var shown = _tariffs.Where(t => _link.Lists(t, MeterEnergyType)).ToList();
|
||||
<MudTable Items="shown" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>@S.Common_Scope</MudTh>
|
||||
<MudTh>@S.Tariffs_Component</MudTh>
|
||||
@@ -32,14 +52,30 @@ else
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="@S.Common_Scope">@ScopeLabel(context)</MudTd>
|
||||
<MudTd DataLabel="@S.Tariffs_Component">@context.Component.Display()</MudTd>
|
||||
<MudTd DataLabel="@S.Tariffs_Component">
|
||||
@context.Component.Display()
|
||||
@if (IsNotApplied(context.Component))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Class="ml-1">@S.Tariffs_NotAppliedChip</MudChip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Value">@Format.Number(context.Value, 4)</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="@S.Tariffs_ValidFrom">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
||||
<MudTd DataLabel="@S.Tariffs_ValidTo">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? S.Tariffs_OpenEnded)</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Unit">
|
||||
@context.Unit
|
||||
@if (RowIssue(context) is { } issue)
|
||||
{
|
||||
<MudTooltip Text="@issue.Text">
|
||||
<MudIcon Icon="@(issue.IsError ? Icons.Material.Filled.ErrorOutline : Icons.Material.Filled.WarningAmber)"
|
||||
Color="@(issue.IsError ? Color.Error : Color.Warning)" Size="Size.Small" Class="ml-1"
|
||||
Style="vertical-align:middle" aria-label="@issue.Text" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="@S.Tariffs_ValidFrom" Style="white-space:nowrap">@Format.Date(context.ValidFrom)</MudTd>
|
||||
<MudTd DataLabel="@S.Tariffs_ValidTo" Style="white-space:nowrap">@(_effectiveEnds.GetValueOrDefault(context.Id) is { } until ? Format.Date(until) : S.Tariffs_OpenEnded)</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" aria-label="@S.Tariffs_EditTariff" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" aria-label="@S.Tariffs_DeleteTitle" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@@ -47,6 +83,10 @@ else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">@S.Tariffs_EmptyState <MudLink Href="/import">@S.Nav_Import</MudLink>.</MudAlert>
|
||||
}
|
||||
else if (shown.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">@S.Tariffs_FilterEmpty</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
@@ -54,7 +94,7 @@ else
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Tariffs_NewTariff : S.Tariffs_EditTariff)</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="@S.Common_Scope" Class="mb-2">
|
||||
<MudSelect T="TariffScope" Value="_working.ScopeType" ValueChanged="SetScope" Label="@S.Common_Scope" Class="mb-2">
|
||||
@foreach (var scope in Enum.GetValues<TariffScope>())
|
||||
{
|
||||
<MudSelectItem T="TariffScope" Value="scope">@scope.Display()</MudSelectItem>
|
||||
@@ -62,7 +102,7 @@ else
|
||||
</MudSelect>
|
||||
@if (_working.ScopeType == TariffScope.EnergyType)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="@S.Common_EnergyType" Class="mb-2">
|
||||
<MudSelect T="int?" Value="_working.ScopeId" ValueChanged="SetScopeId" Label="@S.Common_EnergyType" Class="mb-2">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||
@@ -71,21 +111,32 @@ else
|
||||
}
|
||||
else if (_working.ScopeType == TariffScope.Meter)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="@S.Common_Meter" Class="mb-2">
|
||||
<MudSelect T="int?" Value="_working.ScopeId" ValueChanged="SetScopeId" Label="@S.Common_Meter" Class="mb-2">
|
||||
@foreach (var m in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="@S.Tariffs_Component" Class="mb-2">
|
||||
<MudSelect T="TariffComponent" Value="_working.Component" ValueChanged="SetComponent" Label="@S.Tariffs_Component" Class="mb-2">
|
||||
@foreach (var component in Enum.GetValues<TariffComponent>())
|
||||
{
|
||||
<MudSelectItem T="TariffComponent" Value="component">@component.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudNumericField T="double" @bind-Value="_working.Value" Label="@S.Common_Value" Format="0.####" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Unit" Label="@S.Tariffs_UnitLabel" Required="true" Class="mb-2" />
|
||||
@* D-38: a new tariff starts without a value, and saving needs one; a typed 0 is a deliberate free price, said so. *@
|
||||
<MudNumericField T="double?" @bind-Value="_working.Value" Label="@S.Common_Value" Format="0.####" Class="mb-2"
|
||||
Required="true" RequiredError="@S.Tariffs_ValueRequired" Immediate="true"
|
||||
HelperText="@(_working.Value is null ? null : TariffValue.Note(ValueVerdict))" />
|
||||
<MudTextField T="string" Value="_working.Unit" ValueChanged="SetUnit" Label="@S.Tariffs_UnitLabel" Required="true"
|
||||
Immediate="true" DebounceInterval="300" Class="mb-1" />
|
||||
@* D-37: how the unit was read and whether it fits what this tariff would price, before it is saved. *@
|
||||
<div class="mb-3" aria-live="polite">
|
||||
@foreach (var (isError, text) in TariffUnitCheck.Describe(Verdict, Currency.Code))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="@(isError ? Color.Error : Color.Default)" Class="@(isError ? "d-block" : "d-block mv-muted")">@text</MudText>
|
||||
}
|
||||
</div>
|
||||
<MudTextField @bind-Value="_working.Currency" Label="@S.Common_Currency" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidFrom" Label="@S.Tariffs_ValidFrom" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidTo" Label="@S.Tariffs_ValidToLabel" Clearable="true" Class="mb-2" />
|
||||
@@ -99,20 +150,104 @@ else
|
||||
|
||||
@code {
|
||||
private List<Tariff>? _tariffs;
|
||||
private IReadOnlyDictionary<int, DateOnly?> _effectiveEnds = new Dictionary<int, DateOnly?>();
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
private List<Meter> _meters = [];
|
||||
private AnalysisCatalog? _catalog;
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
private TariffDeepLink _link = TariffDeepLink.None;
|
||||
|
||||
/// <summary>A new-tariff request from the link, held until the action has been dropped from the address.</summary>
|
||||
private TariffDeepLink? _pendingNew;
|
||||
|
||||
private bool _droppingAction;
|
||||
|
||||
[SupplyParameterFromQuery(Name = TariffLinks.ParamScope)]
|
||||
public string? ScopeParam { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = TariffLinks.ParamId)]
|
||||
public string? IdParam { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = TariffLinks.ParamComponent)]
|
||||
public string? ComponentParam { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = TariffLinks.ParamFrom)]
|
||||
public string? FromParam { get; set; }
|
||||
|
||||
/// <summary>A dialog to open once the page is interactive (<c>new</c>); dropped from the address when consumed.</summary>
|
||||
[SupplyParameterFromQuery(Name = TariffLinks.ParamAction)]
|
||||
public string? Action { get; set; }
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_link = TariffDeepLink.Parse(ScopeParam, IdParam, ComponentParam, FromParam, Action);
|
||||
if (_link.OpenNew)
|
||||
{
|
||||
_pendingNew = _link;
|
||||
}
|
||||
else if (string.IsNullOrEmpty(Action))
|
||||
{
|
||||
_droppingAction = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the deep-linked new-tariff dialog once the page is interactive (D-52). As on a meter's page, the action is
|
||||
/// dropped from the address first and the dialog opened when that navigation has come back: a circuit's first
|
||||
/// location change would otherwise dismiss the dialog, and a reload must not reopen it. The scope stays in the
|
||||
/// address, so the list stays scoped.
|
||||
/// </summary>
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (_pendingNew is not { } request || _tariffs is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Action))
|
||||
{
|
||||
if (!_droppingAction)
|
||||
{
|
||||
_droppingAction = true;
|
||||
Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
|
||||
{
|
||||
[TariffLinks.ParamAction] = null,
|
||||
[TariffLinks.ParamComponent] = null,
|
||||
[TariffLinks.ParamFrom] = null,
|
||||
}), replace: true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingNew = null;
|
||||
OpenNew(request);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync();
|
||||
_effectiveEnds = TariffValidity.EffectiveEnds(
|
||||
_tariffs.Select(t => new TariffSpan(t.Id, t.ScopeType, t.ScopeId, t.Component, t.ValidFrom, t.ValidTo)));
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
|
||||
try
|
||||
{
|
||||
_catalog = await Reader.LoadCatalogAsync();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// Without it the unit is still parsed; only the check against the scope's units is skipped.
|
||||
Logger.LogWarning(ex, "Could not load the meter catalog for the tariff unit check");
|
||||
_catalog = null;
|
||||
}
|
||||
}
|
||||
|
||||
private string ScopeLabel(Tariff t) => t.ScopeType switch
|
||||
@@ -123,26 +258,124 @@ else
|
||||
_ => t.ScopeType.ToString(),
|
||||
};
|
||||
|
||||
private string FilterText => _link.Scope switch
|
||||
{
|
||||
TariffScope.Meter => Loc.F(S.Tariffs_FilterMeter, _meters.FirstOrDefault(m => m.Id == _link.ScopeId)?.Name ?? $"#{_link.ScopeId}"),
|
||||
TariffScope.EnergyType => Loc.F(S.Tariffs_FilterType, _energyTypes.FirstOrDefault(t => t.Id == _link.ScopeId)?.DisplayName ?? $"#{_link.ScopeId}"),
|
||||
_ => S.Tariffs_FilterGlobal,
|
||||
};
|
||||
|
||||
private int? MeterEnergyType(int meterId) => _meters.FirstOrDefault(m => m.Id == meterId)?.EnergyTypeId;
|
||||
|
||||
private static bool IsNotApplied(TariffComponent component) =>
|
||||
component is TariffComponent.Bonus or TariffComponent.Discount or TariffComponent.Tax;
|
||||
|
||||
private void ShowAll() => Nav.NavigateTo(TariffLinks.Path);
|
||||
|
||||
/// <summary>What a tariff of this scope and component would price, by unit (D-20, D-34).</summary>
|
||||
private IReadOnlyList<TariffUnitTarget> TargetsFor(TariffScope scope, int? scopeId, TariffComponent component) =>
|
||||
_catalog is null
|
||||
? []
|
||||
: TariffUnitCheck.TargetsFor(_catalog, scope, scopeId, component, id =>
|
||||
_energyTypes.FirstOrDefault(t => t.Id == id) is { } type ? (type.DisplayName, type.BaseUnit) : null);
|
||||
|
||||
private TariffUnitVerdict Verdict => TariffUnitCheck.Check(
|
||||
_working.Unit, _working.Component, TargetsFor(_working.ScopeType, _working.ScopeId, _working.Component), Currency.Code);
|
||||
|
||||
/// <summary>A stored tariff whose unit would be refused or warned about now, with the first line that says why.</summary>
|
||||
private (bool IsError, string Text)? RowIssue(Tariff tariff)
|
||||
{
|
||||
var verdict = TariffUnitCheck.Check(tariff.Unit, tariff.Component, TargetsFor(tariff.ScopeType, tariff.ScopeId, tariff.Component), Currency.Code);
|
||||
if (verdict.Kind is TariffUnitVerdictKind.Fits or TariffUnitVerdictKind.NotApplied)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lines = TariffUnitCheck.Describe(verdict, Currency.Code);
|
||||
var reason = lines.FirstOrDefault(l => l.IsError);
|
||||
return reason.Text is null ? (false, lines[^1].Text) : (true, reason.Text);
|
||||
}
|
||||
|
||||
private void OpenEdit(Tariff? tariff)
|
||||
{
|
||||
_working = tariff is null
|
||||
? new EditModel { ValidFrom = DateTime.Today }
|
||||
: new EditModel
|
||||
{
|
||||
Id = tariff.Id,
|
||||
ScopeType = tariff.ScopeType,
|
||||
ScopeId = tariff.ScopeId,
|
||||
Component = tariff.Component,
|
||||
Value = tariff.Value,
|
||||
Unit = tariff.Unit,
|
||||
Currency = tariff.Currency,
|
||||
ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue),
|
||||
ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue),
|
||||
Notes = tariff.Notes,
|
||||
};
|
||||
if (tariff is null)
|
||||
{
|
||||
OpenNew(TariffDeepLink.None with { Scope = _link.Scope, ScopeId = _link.ScopeId });
|
||||
return;
|
||||
}
|
||||
|
||||
_working = new EditModel
|
||||
{
|
||||
Id = tariff.Id,
|
||||
ScopeType = tariff.ScopeType,
|
||||
ScopeId = tariff.ScopeId,
|
||||
Component = tariff.Component,
|
||||
Value = tariff.Value,
|
||||
Unit = tariff.Unit,
|
||||
UnitTouched = true,
|
||||
Currency = tariff.Currency,
|
||||
ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue),
|
||||
ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue),
|
||||
Notes = tariff.Notes,
|
||||
};
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>A new tariff, prefilled from a link (D-52): its scope, component and first month, and a unit that fits.</summary>
|
||||
private void OpenNew(TariffDeepLink request)
|
||||
{
|
||||
_working = new EditModel
|
||||
{
|
||||
ScopeType = request.Scope ?? TariffScope.EnergyType,
|
||||
ScopeId = request.Scope == TariffScope.Global ? null : request.ScopeId,
|
||||
Component = request.Component ?? TariffComponent.UnitPrice,
|
||||
Currency = Currency.Code,
|
||||
ValidFrom = (request.From ?? Clock.Today).ToDateTime(TimeOnly.MinValue),
|
||||
};
|
||||
SuggestUnit();
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>A unit nobody typed follows the scope and component: currency per the priced unit, or per month.</summary>
|
||||
private void SuggestUnit()
|
||||
{
|
||||
if (_working.UnitTouched)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var target = TargetsFor(_working.ScopeType, _working.ScopeId, _working.Component).FirstOrDefault();
|
||||
_working.Unit = TariffUnit.Suggest(_working.Component, target?.Unit ?? (_working.Component is TariffComponent.UnitPrice or TariffComponent.FeedIn ? "kWh" : null), Currency.Code);
|
||||
}
|
||||
|
||||
private void SetScope(TariffScope scope)
|
||||
{
|
||||
_working.ScopeType = scope;
|
||||
_working.ScopeId = null;
|
||||
SuggestUnit();
|
||||
}
|
||||
|
||||
private void SetScopeId(int? id)
|
||||
{
|
||||
_working.ScopeId = id;
|
||||
SuggestUnit();
|
||||
}
|
||||
|
||||
private void SetComponent(TariffComponent component)
|
||||
{
|
||||
_working.Component = component;
|
||||
SuggestUnit();
|
||||
}
|
||||
|
||||
private void SetUnit(string? unit)
|
||||
{
|
||||
_working.Unit = unit ?? "";
|
||||
_working.UnitTouched = true;
|
||||
}
|
||||
|
||||
/// <summary>What the value field holds, for the save guard and the note under it (D-38).</summary>
|
||||
private TariffValueVerdict ValueVerdict => TariffValue.Check(_working.Value, _working.Component);
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null)
|
||||
@@ -157,7 +390,22 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
// D-38: an untouched value is not a price. Without this, the missing-price deep link would save a free period.
|
||||
if (ValueVerdict.BlocksSave())
|
||||
{
|
||||
Snackbar.Add(S.Tariffs_ValueRequired, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// D-37: a unit that is read and does not fit would leave the cost "unavailable (unit)"; refuse it here instead.
|
||||
if (Verdict.Blocks)
|
||||
{
|
||||
Snackbar.Add(S.Tariffs_UnitBlocked, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var scopeId = _working.ScopeType == TariffScope.Global ? null : _working.ScopeId;
|
||||
var value = _working.Value!.Value;
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_working.Id == 0)
|
||||
@@ -167,9 +415,9 @@ else
|
||||
ScopeType = _working.ScopeType,
|
||||
ScopeId = scopeId,
|
||||
Component = _working.Component,
|
||||
Value = _working.Value,
|
||||
Value = value,
|
||||
Unit = _working.Unit.Trim(),
|
||||
Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim(),
|
||||
Currency = string.IsNullOrWhiteSpace(_working.Currency) ? Currency.Code : _working.Currency.Trim(),
|
||||
ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value),
|
||||
ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null,
|
||||
Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes,
|
||||
@@ -181,9 +429,9 @@ else
|
||||
existing.ScopeType = _working.ScopeType;
|
||||
existing.ScopeId = scopeId;
|
||||
existing.Component = _working.Component;
|
||||
existing.Value = _working.Value;
|
||||
existing.Value = value;
|
||||
existing.Unit = _working.Unit.Trim();
|
||||
existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim();
|
||||
existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? Currency.Code : _working.Currency.Trim();
|
||||
existing.ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value);
|
||||
existing.ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null;
|
||||
existing.Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes;
|
||||
@@ -221,8 +469,14 @@ else
|
||||
public TariffScope ScopeType { get; set; } = TariffScope.EnergyType;
|
||||
public int? ScopeId { get; set; }
|
||||
public TariffComponent Component { get; set; } = TariffComponent.UnitPrice;
|
||||
public double Value { get; set; }
|
||||
/// <summary>The price; null until one is typed (a new tariff never defaults to a free 0, D-38).</summary>
|
||||
public double? Value { get; set; }
|
||||
|
||||
public string Unit { get; set; } = "EUR/kWh";
|
||||
|
||||
/// <summary>True once the user typed a unit (or it is a stored tariff's): it is no longer re-suggested.</summary>
|
||||
public bool UnitTouched { get; set; }
|
||||
|
||||
public string Currency { get; set; } = "EUR";
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
public DateTime? ValidTo { get; set; }
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
@using MeterVault.App.AnalysisPage
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Core.Analysis.Costing
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@inject InstanceCurrency Currency
|
||||
|
||||
@* The period figures of the Analysis page (brief §7.4, D-08): one card per series with its total — the status in words
|
||||
when there is no number, never a zero — and its change against the comparison over the dates both periods cover. A cost
|
||||
card says what it is made of (metered use, standing charges, manual costs, feed-in credit), so manual costs are visibly
|
||||
counted once; a meter's cost names its rule (D-39). *@
|
||||
|
||||
<MudGrid Spacing="2" Class="mb-3">
|
||||
@if (View.Kind == AnalysisPageViewKind.Quantity)
|
||||
{
|
||||
@foreach (var series in View.Series)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3">
|
||||
<MetricCard Title="@View.NameOf(series)" Value="series.Total" Unit="@series.Unit"
|
||||
Change="series.Comparison?.Change" Polarity="ChangePolarities.For(series.Kind)"
|
||||
ChangeCaption="@ChangeCaption" Caption="@CaptionOf(series)"
|
||||
Href="@HrefOf(series)" LinkText="@LinkTextOf(series)" />
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
@if (View.MeterCost is { } meterCost)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3">
|
||||
<MetricCard Title="@AnalysisMetric.Cost.Display()" Cost="meterCost.Total" Currency="@meterCost.Currency"
|
||||
Caption="@RuleOf(meterCost)"
|
||||
Change="CostChanges.ForCard(View.MeterCostChange)" Polarity="CostChanges.Polarity(View.MeterCostChange)"
|
||||
ChangeCaption="@CostChanges.Caption(Shown, View.MeterCostChange)" />
|
||||
</MudItem>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var cost in View.Costs)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3">
|
||||
<MetricCard Title="@cost.Name" Cost="cost.Current.Total" Currency="@cost.Current.Currency"
|
||||
Change="CostChanges.ForCard(cost.CostChange)" Polarity="CostChanges.Polarity(cost.CostChange)"
|
||||
ChangeCaption="@CostChanges.Caption(Shown, cost.CostChange)" Caption="@CaptionOf(cost)"
|
||||
Href="@HrefOf(cost)" LinkText="@(HrefOf(cost) is null ? null : S.Analysis_OpenMeter)" />
|
||||
</MudItem>
|
||||
}
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisPageView View { get; set; } = null!;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Shown { get; set; } = null!;
|
||||
|
||||
private string? ChangeCaption => View.Comparison?.IsApplicable == true ? Shown.Comparison.Display() : null;
|
||||
|
||||
/// <summary>A meter: its energy type and what it measures; a measure: the meters counted in it (D-22).</summary>
|
||||
private string? CaptionOf(AnalysisSeries series)
|
||||
{
|
||||
if (series.MeterId is { } meterId)
|
||||
{
|
||||
var type = series.EnergyTypeId is { } typeId ? Options.TypeName(typeId) : null;
|
||||
var what = series.Kind.Display();
|
||||
var calculated = Options.Meter(meterId) is { IsVirtual: true } ? " · " + S.Analysis_Calculated : string.Empty;
|
||||
return (type is null ? what : type + " · " + what) + calculated;
|
||||
}
|
||||
|
||||
return series.MemberIds.Count > 0
|
||||
? Loc.F(S.Analysis_Counted, string.Join(", ", series.MemberIds.Select(Options.MeterName)))
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>A meter's page, or — on the portfolio — the energy type in this page.</summary>
|
||||
private string? HrefOf(AnalysisSeries series)
|
||||
{
|
||||
if (series.MeterId is { } meterId)
|
||||
{
|
||||
return View.Selection.Scope.Kind == QueryScopeKind.Meter ? null : MeterLinks.Analysis(meterId, Shown);
|
||||
}
|
||||
|
||||
return View.Selection.Scope.Kind == QueryScopeKind.Portfolio && series.EnergyTypeId is { } typeId
|
||||
? AnalysisLinks.Analysis(QueryScope.ForEnergyType(typeId), null, Shown)
|
||||
: null;
|
||||
}
|
||||
|
||||
private string? LinkTextOf(AnalysisSeries series) =>
|
||||
series.MeterId is not null ? S.Analysis_OpenMeter
|
||||
: series.EnergyTypeId is { } typeId ? Loc.F(S.Analysis_AnalyseScope, Options.TypeName(typeId))
|
||||
: null;
|
||||
|
||||
/// <summary>What a cost is made of, and for a type its billing basis, for a meter its rule.</summary>
|
||||
private string? CaptionOf(AnalysisCostSeries cost)
|
||||
{
|
||||
var current = cost.Current;
|
||||
if (current.Meter is { } meter)
|
||||
{
|
||||
return RuleOf(current);
|
||||
}
|
||||
|
||||
var parts = PartsOf(current.Total);
|
||||
if (current.Request.Scope.Kind == CostScopeKind.EnergyType && current.EnergyTypes.FirstOrDefault() is { } type)
|
||||
{
|
||||
parts = type.Basis.Display() + (parts is null ? string.Empty : " · " + parts);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
private string? HrefOf(AnalysisCostSeries cost) =>
|
||||
cost.Current.Request.Scope is { Kind: CostScopeKind.Meter, Id: { } meterId } && View.Selection.Scope.Kind != QueryScopeKind.Meter
|
||||
? MeterLinks.Analysis(meterId, Shown)
|
||||
: null;
|
||||
|
||||
/// <summary>"Metered use 1.234,00 € · Standing charges 96,00 € · Manual costs 800,00 €" — only the parts there are.</summary>
|
||||
private string? PartsOf(CostAmount total)
|
||||
{
|
||||
List<string> parts = [];
|
||||
if (total.Usage is { } usage)
|
||||
{
|
||||
parts.Add(Loc.F(S.Analysis_PartUsage, Currency.Format(usage)));
|
||||
}
|
||||
|
||||
if (total.StandingCharge is { } standing)
|
||||
{
|
||||
parts.Add(Loc.F(S.Analysis_PartStanding, Currency.Format(standing)));
|
||||
}
|
||||
|
||||
if (total.Manual is { } manual)
|
||||
{
|
||||
parts.Add(Loc.F(S.Analysis_PartManual, Currency.Format(manual)));
|
||||
}
|
||||
|
||||
if (total.FeedInCredit is { } credit)
|
||||
{
|
||||
parts.Add(Loc.F(S.Analysis_PartCredit, Currency.Format(credit)));
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? string.Join(" · ", parts) : null;
|
||||
}
|
||||
|
||||
/// <summary>How a meter's cost is formed (D-39), or why it has none.</summary>
|
||||
private static string? RuleOf(Infrastructure.Costing.CostAnalysis cost) => cost.Meter switch
|
||||
{
|
||||
{ Rule: Infrastructure.Costing.MeterCostRule.None } meter => Loc.F(S.Analysis_NotCosted, meter.NotCosted.Display()),
|
||||
{ } meter => Loc.F(S.Analysis_CostRule, meter.Rule.Display()),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@using MeterVault.App.AnalysisPage
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
|
||||
@* Why the Analysis page shows no figures for its address, and what would work (brief §7.4, §4.3): a scope that no longer
|
||||
exists, more meters than can be compared, or a cost category whose meters cannot be one quantity — named per kind and
|
||||
unit, with the comparison of each group and the category's cost one click away. Never a silently different view. *@
|
||||
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3" role="status">
|
||||
@switch (Selection.Refusal)
|
||||
{
|
||||
case AnalysisPageRefusal.UnknownScope:
|
||||
<div>@S.Analysis_RefusalUnknownScope</div>
|
||||
<div class="mv-analysis-refusal__actions">
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="() => Show(Shown.WithScope(QueryScope.Portfolio).WithMetric(null))">
|
||||
@S.Analysis_ShowAll
|
||||
</MudButton>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case AnalysisPageRefusal.TooManyMeters:
|
||||
<div>@Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries)</div>
|
||||
<div class="mv-analysis-refusal__actions">
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small"
|
||||
OnClick="() => Show(Shown.WithScope(QueryScope.ForMeters(Selection.Scope.MeterIds.Take(AnalysisLimits.MaxSeries))))">
|
||||
@Loc.F(S.Analysis_CompareFirst, AnalysisLimits.MaxSeries)
|
||||
</MudButton>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case AnalysisPageRefusal.CategoryMixed:
|
||||
<div>@Loc.F(S.Analysis_RefusalCategoryMixed, Selection.ScopeName ?? string.Empty)</div>
|
||||
<ul class="mv-analysis-refusal__groups">
|
||||
@foreach (var group in Selection.Groups)
|
||||
{
|
||||
<li>
|
||||
<span>@Loc.F(S.Analysis_Group, group.Kind.Display(), group.Unit, string.Join(", ", group.MeterIds.Select(Options.MeterName)))</span>
|
||||
@if (group.MeterIds.Count <= AnalysisLimits.MaxSeries)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small"
|
||||
OnClick="() => Show(Shown.WithScope(QueryScope.ForMeters(group.MeterIds)).WithMetric(group.Metric))">
|
||||
@S.Analysis_CompareGroup
|
||||
</MudButton>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="mv-analysis-refusal__actions">@ShowCost</div>
|
||||
break;
|
||||
|
||||
case AnalysisPageRefusal.CategoryWithoutMeters:
|
||||
<div>@Loc.F(S.Analysis_RefusalCategoryWithoutMeters, Selection.ScopeName ?? string.Empty)</div>
|
||||
<div class="mv-analysis-refusal__actions">@ShowCost</div>
|
||||
break;
|
||||
|
||||
case AnalysisPageRefusal.CategoryTooManyMeters:
|
||||
<div>@Loc.F(S.Analysis_RefusalCategoryTooManyMeters, Selection.ScopeName ?? string.Empty, CategoryMeterCount, AnalysisLimits.MaxSeries)</div>
|
||||
<div class="mv-analysis-refusal__actions">@ShowCost</div>
|
||||
break;
|
||||
}
|
||||
</MudAlert>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisSelection Selection { get; set; } = null!;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Shown { get; set; } = null!;
|
||||
|
||||
/// <summary>Shows the chosen alternative (replacing the address).</summary>
|
||||
[Parameter]
|
||||
public EventCallback<AnalysisQuery> OnShow { get; set; }
|
||||
|
||||
private int CategoryMeterCount => Options.Category(Selection.Scope.Id)?.MeterIds.Count ?? 0;
|
||||
|
||||
private RenderFragment ShowCost => __builder =>
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="() => Show(Shown.WithMetric(null))">@S.Analysis_ShowCost</MudButton>
|
||||
};
|
||||
|
||||
private Task Show(AnalysisQuery query) => OnShow.InvokeAsync(query);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.mv-analysis-refusal__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||
.mv-analysis-refusal__groups { margin: 8px 0 0 0; padding-left: 1.25rem; }
|
||||
.mv-analysis-refusal__groups li { overflow-wrap: anywhere; }
|
||||
@@ -0,0 +1,61 @@
|
||||
@using MeterVault.App.AnalysisPage
|
||||
@using MeterVault.Core.Analysis.Totals
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
|
||||
@* What the figures of the Analysis page are — and are not (brief §6.1, D-22, D-42): a category's meters are shown side by
|
||||
side and never added; a comparison names the selected meters it does not show for this measure; total use and grid
|
||||
import are side by side, never summed; an overlapping category is a view on the bill, not a slice of it. *@
|
||||
|
||||
@if (_notes.Count > 0)
|
||||
{
|
||||
<ul class="mv-analysis-notes mb-3">
|
||||
@foreach (var note in _notes)
|
||||
{
|
||||
<li>
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Info" Size="Size.Small" aria-hidden="true" />
|
||||
<span>@note</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisPageView View { get; set; } = null!;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
|
||||
|
||||
private List<string> _notes = [];
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var selection = View.Selection;
|
||||
_notes = [];
|
||||
|
||||
if (selection.ShowsMeters && selection.Scope.Kind == QueryScopeKind.Category && View.Series.Count > 1)
|
||||
{
|
||||
_notes.Add(S.Analysis_CategorySideBySide);
|
||||
}
|
||||
|
||||
if (selection.HiddenMeterIds.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", selection.HiddenMeterIds.Select(Options.MeterName));
|
||||
_notes.Add(selection.IsCost
|
||||
? Loc.F(S.Analysis_NotShownNoCost, names)
|
||||
: Loc.F(S.Analysis_NotShownForMetric, selection.Metric?.Display() ?? string.Empty, names));
|
||||
}
|
||||
|
||||
// Total use and grid import answer different questions: side by side, never added (D-22).
|
||||
if (!selection.ShowsMeters && View.Kind == AnalysisPageViewKind.Quantity
|
||||
&& View.Series.Any(s => s.Key.Measure == TotalsMeasure.Use) && View.Series.Any(s => s.Key.Measure == TotalsMeasure.GridImport))
|
||||
{
|
||||
_notes.Add(S.Analysis_UseAndGridApart);
|
||||
}
|
||||
|
||||
if (View.Costs.FirstOrDefault()?.Current.Category is { IsOverlappingView: true })
|
||||
{
|
||||
_notes.Add(S.Analysis_OverlappingView);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.mv-analysis-notes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.mv-analysis-notes li { display: flex; align-items: flex-start; gap: 6px; font-size: 0.875rem; color: var(--mud-palette-text-secondary); }
|
||||
.mv-analysis-notes li span { min-width: 0; overflow-wrap: anywhere; }
|
||||
@@ -0,0 +1,172 @@
|
||||
@using MeterVault.App.AnalysisPage
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Core.Analysis.Costing
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@* Everything the Analysis page shows for one committed load (brief §7.4, §4.3): an explanation instead of figures when the
|
||||
selection cannot be one quantity, the attention items, the empty and pending states, the period figures with their
|
||||
change, which dates are compared, the chart (a click drills into a bucket, D-51) and the table with its drill-down
|
||||
links. Everything comes from the one view, so a title never sits above another selection's chart. *@
|
||||
|
||||
@if (View.Selection.Refusal != AnalysisPageRefusal.None)
|
||||
{
|
||||
<AnalysisExplanation Selection="View.Selection" Options="Options" Shown="Shown" OnShow="OnShow" />
|
||||
}
|
||||
else if (View.ReaderRefusal == AnalysisRefusal.TooManySeries)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mb-3">@Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries)</MudAlert>
|
||||
}
|
||||
else if (View.ReaderRefusal == AnalysisRefusal.TooManyPoints)
|
||||
{
|
||||
@* The toolbar says which interval would work and offers it. *@
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.Analysis_ChooseCoarserBucket</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<AttentionList Problems="View.Problems" CostAttention="View.CostAttention" Names="_names" Query="Shown" MaxItems="5" Class="mb-3" />
|
||||
|
||||
@if (View.NotYetOccurred)
|
||||
{
|
||||
<EmptyPeriodState NotYetOccurred="true" />
|
||||
}
|
||||
else if (View.IsPending)
|
||||
{
|
||||
<PendingState OnRefresh="OnRefresh" />
|
||||
}
|
||||
else if (View.HasNoData)
|
||||
{
|
||||
<EmptyPeriodState Availability="View.Availability" LatestHref="@LatestHref">
|
||||
@if (View.Availability is null)
|
||||
{
|
||||
@* Nothing at all yet: where data comes from. With older data, the dates and "Go to latest data" say it. *@
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mt-1">
|
||||
@S.Analysis_NoDataYetHint
|
||||
<MudLink Href="/import" Typo="Typo.body2">@S.Nav_Import</MudLink>
|
||||
</MudText>
|
||||
}
|
||||
</EmptyPeriodState>
|
||||
}
|
||||
else
|
||||
{
|
||||
<AnalysisCards View="View" Options="Options" Shown="Shown" />
|
||||
|
||||
<ComparisonSummary Period="View.Period" Resolution="View.Comparison" Matched="View.Matched" Class="mb-3" />
|
||||
|
||||
<AnalysisNotes View="View" Options="Options" />
|
||||
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-2">@ChartTitle</MudText>
|
||||
<AnalysisChart Buckets="View.Plan!.Buckets" Series="View.Chart" ComparisonPairs="View.Pairs" Title="@ChartTitle"
|
||||
OnBucketClick="_onBucketClick" Resolution="View.Resolution" OnUseBucket="size => OnShow.InvokeAsync(Shown.WithBucket(size))" />
|
||||
@if (HiddenOverlays)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.Analysis_ComparisonInTable, AnalysisPageLoader.MaxOverlaidSeries)</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-2">@S.Analysis_TableTitle</MudText>
|
||||
<AnalysisTable Buckets="View.Plan!.Buckets" Series="View.Table" ComparisonPairs="View.Pairs" DrillHref="_drillHref"
|
||||
Caption="@Loc.F(S.Analysis_TableCaption, ChartTitle)" />
|
||||
</MudPaper>
|
||||
|
||||
@if (View.Series is [{ Basis: SeriesBasis.Virtual or SeriesBasis.LegacyVirtual } only])
|
||||
{
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
|
||||
<SeriesContributions Series="only" Query="Shown" MeterName="@(id => Options.Meter(id)?.Name)"
|
||||
UnitOfSource="@(id => Options.Meter(id)?.Unit)" />
|
||||
</MudPaper>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisPageView View { get; set; } = null!;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
|
||||
|
||||
/// <summary>The address as shown when the view was read: drill-downs and links build on it.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Shown { get; set; } = null!;
|
||||
|
||||
/// <summary>The page defaults, which an address leaves out.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisDefaults Defaults { get; set; } = null!;
|
||||
|
||||
/// <summary>Shows another state (an action of an explanation), replacing the address.</summary>
|
||||
[Parameter]
|
||||
public EventCallback<AnalysisQuery> OnShow { get; set; }
|
||||
|
||||
/// <summary>Reads the same state again (analysis being prepared: check whether it is ready).</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnRefresh { get; set; }
|
||||
|
||||
private AttentionNames _names = new();
|
||||
private EventCallback<AnalysisBucket> _onBucketClick;
|
||||
private Func<AnalysisBucket, string?>? _drillHref;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_names = new AttentionNames(Options.MeterNames, Options.TypeNames, Options.Categories.ToDictionary(c => c.Id, c => c.Name));
|
||||
|
||||
// Buckets are clickable, and the table has its drill column, only when some bucket leads somewhere (D-51): a
|
||||
// comparison of monthly imports has nothing finer to open.
|
||||
var drills = View.Plan is { } plan && plan.Buckets.Any(b => DrillHref(b) is not null);
|
||||
_onBucketClick = drills ? EventCallback.Factory.Create<AnalysisBucket>(this, DrillAsync) : default;
|
||||
_drillHref = drills ? DrillHref : null;
|
||||
}
|
||||
|
||||
/// <summary>"Consumption — Strom", "Cost — All energy types".</summary>
|
||||
private string ChartTitle
|
||||
{
|
||||
get
|
||||
{
|
||||
var selection = View.Selection;
|
||||
var what = selection.Metric?.Display() ?? View.Series.FirstOrDefault()?.Kind.Display() ?? string.Empty;
|
||||
var scope = selection.ScopeName ?? selection.Scope.Kind.Display();
|
||||
return what.Length == 0 ? scope : what + " — " + scope;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HiddenOverlays =>
|
||||
View.Pairs is not null && (View.Kind == AnalysisPageViewKind.Cost ? View.Costs.Count : View.Series.Count) > AnalysisPageLoader.MaxOverlaidSeries;
|
||||
|
||||
private string? LatestHref =>
|
||||
AnalysisNavigation.LatestData(Shown, View.Availability) is { } latest ? AnalysisNavigation.UriFor(Nav, latest, Defaults) : null;
|
||||
|
||||
/// <summary>
|
||||
/// Where a bucket leads (D-51): the same view over the bucket in the next finer size the data resolves; for one meter
|
||||
/// whose data is too coarse for that, its records of the bucket; otherwise nowhere.
|
||||
/// </summary>
|
||||
private string? DrillHref(AnalysisBucket bucket)
|
||||
{
|
||||
if (AnalysisNavigation.DrillInto(Shown, bucket, View.Resolution) is { } next)
|
||||
{
|
||||
return AnalysisNavigation.UriFor(Nav, next, Defaults);
|
||||
}
|
||||
|
||||
if (View.Selection.Scope.Kind == QueryScopeKind.Meter && View.Selection.Scope.Id is { } meterId)
|
||||
{
|
||||
var (first, last) = AnalysisNavigation.DaysOf(bucket);
|
||||
return Options.Meter(meterId) is { IsVirtual: true }
|
||||
? MeterLinks.Analysis(meterId, PeriodResolver.IsValidCustomRange(first, last) ? Shown.WithCustomRange(first, last) : Shown)
|
||||
: AnalysisNavigation.NormalizedData(meterId, Shown, bucket);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Task DrillAsync(AnalysisBucket bucket)
|
||||
{
|
||||
// A drill-down is a new history entry (D-46): Back returns to the coarser view.
|
||||
if (DrillHref(bucket) is { } href)
|
||||
{
|
||||
Nav.NavigateTo(href);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@* A group heading inside a MudSelect's list (the meters of one energy type). MudSelect renders its items twice — once in
|
||||
a hidden "shadow" pass that only registers them, once in the dropdown — and a plain MudListSubheader would show up in
|
||||
the page from the first pass. This one renders only in the dropdown. *@
|
||||
|
||||
@if (!HideContent)
|
||||
{
|
||||
<MudListSubheader Class="mv-picker-group">@ChildContent</MudListSubheader>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// True in MudSelect's hidden registration pass, where nothing may be drawn: MudSelect cascades it by this name to its
|
||||
/// items (MudSelectItem.HideContent).
|
||||
/// </summary>
|
||||
[CascadingParameter(Name = "HideContent")]
|
||||
public bool HideContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
@using MeterVault.App.AnalysisPage
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@* What the Analysis page analyses (brief §7.4, D-47): everything, an energy type, a cost category, one meter or up to
|
||||
six meters side by side — and which measure of it. Meters are picked by name, grouped by energy type, with calculated
|
||||
and retired meters marked. Nothing here navigates: every choice raises QueryChanged, and the page writes it into its
|
||||
address (replace), so reload, Back and a shared link restore it. A seventh meter is refused with an explanation,
|
||||
never dropped silently. *@
|
||||
|
||||
<div class="mv-scope" role="group" aria-label="@S.Analysis_ScopeGroup">
|
||||
<div class="mv-scope__field">
|
||||
<MudSelect T="QueryScopeKind" Value="Selection.Scope.Kind" ValueChanged="OnKindChanged" Label="@S.Analysis_ScopeLabel"
|
||||
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true" ToStringFunc="@(k => k.Display())">
|
||||
@foreach (var kind in Kinds)
|
||||
{
|
||||
<MudSelectItem T="QueryScopeKind" Value="kind" Disabled="@(!IsOffered(kind))">@kind.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@switch (Selection.Scope.Kind)
|
||||
{
|
||||
case QueryScopeKind.EnergyType:
|
||||
<div class="mv-scope__field">
|
||||
<MudSelect T="int" Value="@(Selection.Scope.Id ?? 0)" ValueChanged="id => ChangeScope(QueryScope.ForEnergyType(id))"
|
||||
Label="@QueryScopeKind.EnergyType.Display()" Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true"
|
||||
ToStringFunc="@(id => Options.Type(id)?.Name ?? string.Empty)">
|
||||
@foreach (var type in Options.Types)
|
||||
{
|
||||
<MudSelectItem T="int" Value="type.Id">@type.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case QueryScopeKind.Category:
|
||||
<div class="mv-scope__field">
|
||||
<MudSelect T="int" Value="@(Selection.Scope.Id ?? 0)" ValueChanged="id => ChangeScope(QueryScope.ForCategory(id))"
|
||||
Label="@QueryScopeKind.Category.Display()" Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true"
|
||||
ToStringFunc="@(id => Options.Category(id)?.Name ?? string.Empty)">
|
||||
@foreach (var category in Options.Categories)
|
||||
{
|
||||
<MudSelectItem T="int" Value="category.Id">@category.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case QueryScopeKind.Meter:
|
||||
<div class="mv-scope__field mv-scope__field--wide">
|
||||
<MudSelect T="int" Value="@(Selection.Scope.Id ?? 0)" ValueChanged="id => ChangeScope(QueryScope.ForMeter(id))"
|
||||
Label="@S.Common_Meter" Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true"
|
||||
ToStringFunc="@(id => MeterLabel(id))" MaxHeight="420">
|
||||
@MeterItems
|
||||
</MudSelect>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case QueryScopeKind.Meters:
|
||||
<div class="mv-scope__field mv-scope__field--wide">
|
||||
<MudSelect @key="_pickerGeneration" T="int" MultiSelection="true" SelectedValues="_meters" SelectedValuesChanged="OnMetersChanged"
|
||||
Label="@Loc.F(S.Analysis_MetersLabel, AnalysisLimits.MaxSeries)" Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" Dense="true" MaxHeight="420"
|
||||
MultiSelectionTextFunc="@(ids => MetersText(ids))"
|
||||
Error="@(_meterHint is not null)" ErrorText="@_meterHint">
|
||||
@MeterItems
|
||||
</MudSelect>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
|
||||
@if (Selection.Metric is { } shown && (Selection.Metrics.Count > 1 || !Selection.Metrics.Contains(shown)))
|
||||
{
|
||||
<div class="mv-scope__field">
|
||||
<MudSelect T="AnalysisMetric" Value="shown" ValueChanged="OnMetricChanged" Label="@S.Toolbar_Metric"
|
||||
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true" ToStringFunc="@(m => m.Display())">
|
||||
@* A category quantity that cannot be shown is still what the address asks for: listed, so the way back to
|
||||
the cost is one choice away. *@
|
||||
@foreach (var metric in Selection.Metrics.Contains(shown) ? Selection.Metrics : [shown, .. Selection.Metrics])
|
||||
{
|
||||
<MudSelectItem T="AnalysisMetric" Value="metric">@metric.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>The meters, energy types and categories to choose from.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisPageOptions Options { get; set; } = AnalysisPageOptions.Empty;
|
||||
|
||||
/// <summary>The page's current reading of its address.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisSelection Selection { get; set; } = null!;
|
||||
|
||||
/// <summary>The address as shown (<see cref="AnalysisSelection.Shown"/>): every choice starts from it.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Query { get; set; } = null!;
|
||||
|
||||
/// <summary>Raised with the new state; the page writes it into its address (replace).</summary>
|
||||
[Parameter]
|
||||
public EventCallback<AnalysisQuery> QueryChanged { get; set; }
|
||||
|
||||
private static readonly IReadOnlyList<QueryScopeKind> Kinds =
|
||||
[QueryScopeKind.Portfolio, QueryScopeKind.EnergyType, QueryScopeKind.Category, QueryScopeKind.Meter, QueryScopeKind.Meters];
|
||||
|
||||
private IReadOnlyCollection<int> _meters = [];
|
||||
private AnalysisQuery? _synced;
|
||||
private string? _meterHint;
|
||||
|
||||
/// <summary>Re-creates the multi-select after a refused pick, so it drops the box it ticked on its own.</summary>
|
||||
private int _pickerGeneration;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// The multi-select follows the address; a new address also clears the reason for a refused pick.
|
||||
if (Query != _synced)
|
||||
{
|
||||
_synced = Query;
|
||||
_meters = Selection.Scope.Kind == QueryScopeKind.Meters ? [.. Selection.Scope.MeterIds] : [];
|
||||
_meterHint = null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsOffered(QueryScopeKind kind) => kind switch
|
||||
{
|
||||
QueryScopeKind.EnergyType => Options.Types.Count > 0,
|
||||
QueryScopeKind.Category => Options.Categories.Count > 0,
|
||||
QueryScopeKind.Meter or QueryScopeKind.Meters => Options.Meters.Count > 0,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
/// <summary>The meters grouped by energy type, calculated and retired ones marked.</summary>
|
||||
private RenderFragment MeterItems => __builder =>
|
||||
{
|
||||
foreach (var group in Options.Meters.GroupBy(m => m.EnergyTypeId))
|
||||
{
|
||||
<PickerGroupHeader>@Options.TypeName(group.Key)</PickerGroupHeader>
|
||||
foreach (var meter in group)
|
||||
{
|
||||
<MudSelectItem T="int" Value="meter.Id">@MeterLabel(meter.Id)</MudSelectItem>
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private string MeterLabel(int id)
|
||||
{
|
||||
if (Options.Meter(id) is not { } meter)
|
||||
{
|
||||
return Options.MeterName(id);
|
||||
}
|
||||
|
||||
var label = meter.IsVirtual ? Loc.F(S.Analysis_CalculatedMeter, meter.Name) : meter.Name;
|
||||
return meter.IsRetired ? Loc.F(S.Analysis_RetiredMeter, label) : label;
|
||||
}
|
||||
|
||||
private string MetersText(IReadOnlyList<string?> ids) =>
|
||||
string.Join(", ", ids.Select(text =>
|
||||
int.TryParse(text, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var id) ? MeterLabel(id) : text));
|
||||
|
||||
private async Task OnKindChanged(QueryScopeKind kind)
|
||||
{
|
||||
if (kind == Selection.Scope.Kind)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = Selection.Scope;
|
||||
var meterIds = current.MeterIds.Where(id => Options.Meter(id) is not null).ToList();
|
||||
var typeId = Selection.EnergyTypeId ?? meterIds.Select(id => Options.Meter(id)!.EnergyTypeId).Cast<int?>().FirstOrDefault();
|
||||
QueryScope? next = kind switch
|
||||
{
|
||||
QueryScopeKind.Portfolio => QueryScope.Portfolio,
|
||||
QueryScopeKind.EnergyType => (typeId ?? Options.Types.FirstOrDefault()?.Id) is { } type ? QueryScope.ForEnergyType(type) : null,
|
||||
QueryScopeKind.Category => Options.Categories.FirstOrDefault() is { } category ? QueryScope.ForCategory(category.Id) : null,
|
||||
QueryScopeKind.Meter => FirstMeter(meterIds, typeId) is { } meter ? QueryScope.ForMeter(meter) : null,
|
||||
_ => MetersFor(meterIds, typeId) is { Count: > 0 } ids ? QueryScope.ForMeters(ids) : null,
|
||||
};
|
||||
|
||||
if (next is not null)
|
||||
{
|
||||
await ChangeScope(next);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The meter a switch to one meter starts with: the one shown, else the type's first, else the first.</summary>
|
||||
private int? FirstMeter(IReadOnlyList<int> meterIds, int? typeId) =>
|
||||
meterIds.Count > 0 ? meterIds[0]
|
||||
: (Options.Meters.FirstOrDefault(m => m.EnergyTypeId == typeId && !m.IsVirtual && !m.IsRetired)
|
||||
?? Options.Meters.FirstOrDefault(m => !m.IsVirtual && !m.IsRetired)
|
||||
?? Options.Meters.FirstOrDefault())?.Id;
|
||||
|
||||
/// <summary>
|
||||
/// The meters a switch to a comparison starts with: those shown, else the energy type's meters of the measure shown
|
||||
/// (when they fit), else one meter to add others to.
|
||||
/// </summary>
|
||||
private List<int> MetersFor(IReadOnlyList<int> meterIds, int? typeId)
|
||||
{
|
||||
if (meterIds.Count > 0)
|
||||
{
|
||||
return [.. meterIds];
|
||||
}
|
||||
|
||||
var ofType = Options.Meters
|
||||
.Where(m => m.EnergyTypeId == typeId && !m.IsRetired && (Selection.IsCost ? m.IsCostable : m.Metric == Selection.Metric))
|
||||
.Select(m => m.Id)
|
||||
.ToList();
|
||||
if (ofType.Count is > 0 and <= AnalysisLimits.MaxSeries)
|
||||
{
|
||||
return ofType;
|
||||
}
|
||||
|
||||
return FirstMeter([], typeId) is { } first ? [first] : [];
|
||||
}
|
||||
|
||||
private async Task ChangeScope(QueryScope scope)
|
||||
{
|
||||
if (scope.Equals(Selection.Scope))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The measure goes along when the new scope has it; otherwise the new scope's natural one.
|
||||
var next = Query.WithScope(scope);
|
||||
if (next.Metric is { } metric && !AnalysisSelection.Resolve(next, Options).Metrics.Contains(metric))
|
||||
{
|
||||
next = next.WithMetric(null);
|
||||
}
|
||||
|
||||
await QueryChanged.InvokeAsync(next);
|
||||
}
|
||||
|
||||
private async Task OnMetersChanged(IReadOnlyCollection<int> values)
|
||||
{
|
||||
var chosen = values.ToList();
|
||||
if (chosen.Count > AnalysisLimits.MaxSeries)
|
||||
{
|
||||
// Refused with the reason; the selection stays as it was.
|
||||
_meterHint = Loc.F(S.Analysis_TooManyMeters, AnalysisLimits.MaxSeries);
|
||||
_meters = [.. Selection.Scope.MeterIds];
|
||||
_pickerGeneration++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (chosen.Count == 0)
|
||||
{
|
||||
_meterHint = S.Analysis_AtLeastOneMeter;
|
||||
_meters = [.. Selection.Scope.MeterIds];
|
||||
_pickerGeneration++;
|
||||
return;
|
||||
}
|
||||
|
||||
_meterHint = null;
|
||||
_meters = chosen;
|
||||
|
||||
// Keep the order in which meters were picked: the ones already shown first.
|
||||
var ordered = Selection.Scope.MeterIds.Where(chosen.Contains).Concat(chosen.Where(id => !Selection.Scope.MeterIds.Contains(id))).ToList();
|
||||
await ChangeScope(QueryScope.ForMeters(ordered));
|
||||
}
|
||||
|
||||
private async Task OnMetricChanged(AnalysisMetric metric)
|
||||
{
|
||||
if (metric != Selection.Metric)
|
||||
{
|
||||
await QueryChanged.InvokeAsync(Query.WithMetric(metric == Selection.NaturalMetric ? null : metric));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* The "what" row of the Analysis page: wraps like the period toolbar, one field per row on a phone. */
|
||||
.mv-scope { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px 12px; }
|
||||
.mv-scope__field { flex: 1 1 180px; min-width: 160px; max-width: 280px; }
|
||||
.mv-scope__field--wide { flex: 2 1 280px; max-width: 560px; }
|
||||
@media (max-width: 599.98px) {
|
||||
.mv-scope__field, .mv-scope__field--wide { flex: 1 1 100%; min-width: 0; max-width: none; }
|
||||
}
|
||||
@@ -1,215 +1,126 @@
|
||||
@page "/consumables"
|
||||
@using MeterVault.App.Components.Pages.Specialized
|
||||
@using MeterVault.Core.Analysis
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Nav
|
||||
@inject InstanceClock Clock
|
||||
@inject ConsumableService ConsumablesSvc
|
||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||
@using MudBlazor
|
||||
@inject ILogger<Consumables> Logger
|
||||
|
||||
<PageTitle>MeterVault — @S.Consumables_PageTitle</PageTitle>
|
||||
@* Tanks & consumables (brief §7.5, D-54): the shared header, toolbar and missing-data semantics around every tank's
|
||||
specialised measures. Each tank keeps its state now — the last dipstick as measured, the contents estimated from it,
|
||||
the forecast as a projection — apart from the selected period, which has its own usage, deliveries, burner runtime,
|
||||
cost and, for a period that is over, the contents at its end. *@
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">@S.Nav_Consumables</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
<PageHeader Title="@S.Nav_Consumables" Description="@S.Consumables_Description">
|
||||
<Breadcrumbs>
|
||||
<AnalysisBreadcrumbs Query="_query" Current="@S.Nav_Consumables" />
|
||||
</Breadcrumbs>
|
||||
</PageHeader>
|
||||
|
||||
@* A consumable meter without a tank has no capacity or calibration, so it cannot be summarised —
|
||||
but it must not just be missing from the page, with nothing saying where it went. *@
|
||||
@foreach (var meter in _unconfigured)
|
||||
@if (_query is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mb-3">
|
||||
@Loc.F(S.Consumables_TankNotConfigured, meter.Name)
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Warning" Class="ml-2"
|
||||
Href="@MeterLinks.Detail(meter.MeterId, action: MeterLinks.ActionEdit)">@S.MeterDetail_SetUpTank</MudButton>
|
||||
</MudAlert>
|
||||
<PeriodToolbar Query="_query" Period="_state.Value?.Analysis.Period" Plan="_state.Value?.Analysis.Plan" Defaults="Defaults"
|
||||
QueryChanged="OnQueryChanged" ExportHref="@_state.Value?.ExportHref" Class="mb-4" />
|
||||
}
|
||||
|
||||
@if (_items is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (_items.Count == 0 && _unconfigured.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
@S.Consumables_NoMetersLead <b>@MeterMode.ConsumableBalance.Display()</b> @S.Consumables_NoMetersTail
|
||||
<MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.Consumables_NoMetersOrImport
|
||||
<MudLink Href="/import">@S.Nav_Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var item in _items)
|
||||
<LoadPanel State="_state" OnRetry="RetryAsync" Context="view">
|
||||
@* A consumable meter without a tank has no capacity or calibration, so it cannot be summarised — but it must not just
|
||||
be missing from the page, with nothing saying where it went. *@
|
||||
@foreach (var meter in view.Analysis.Unconfigured)
|
||||
{
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
@* The actions for a tank are the ones done standing next to it, so they sit on its card. *@
|
||||
<div class="d-flex align-center flex-wrap mb-3" style="gap:.5rem">
|
||||
<MudLink Href="@MeterLinks.Detail(item.MeterId)" Typo="Typo.h6">@item.Name</MudLink>
|
||||
<MudSpacer />
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Straighten"
|
||||
Href="@MeterLinks.Event(item.MeterId, MeterEventType.TankLevel)">@S.MeterDetail_RecordTankLevel</MudButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.LocalShipping"
|
||||
Href="@MeterLinks.Event(item.MeterId, MeterEventType.Delivery)">@S.Consumables_RecordDelivery</MudButton>
|
||||
</div>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_TankLevel</MudText>
|
||||
<MudText Typo="Typo.h5">
|
||||
@(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—")
|
||||
</MudText>
|
||||
<MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@Loc.F(S.Consumables_FillOfCapacity, Format.Number(item.FillFraction * 100, 0), Format.Number(item.Capacity, 0), item.Unit)
|
||||
@if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm")
|
||||
{
|
||||
<text> · @Format.Number(cm, 0) cm</text>
|
||||
}
|
||||
@if (item.LevelAsOf is { } asOf)
|
||||
{
|
||||
<text> · @Loc.F(S.Consumables_AsOf, Local(asOf).ToString("yyyy-MM-dd"))</text>
|
||||
}
|
||||
</MudText>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="8">
|
||||
<MudGrid>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_UsedRange</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_BurnerRuntime</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_EffectiveRate</MudText>
|
||||
<MudText Typo="Typo.subtitle1">
|
||||
@if (item.FixedRate is { } fr)
|
||||
{
|
||||
<text>@Format.Number(fr, 2) @item.Unit/h</text>
|
||||
}
|
||||
else if (item.EffectiveRate is { } er)
|
||||
{
|
||||
<text>@Format.Number(er, 2) @item.Unit/h</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>—</text>
|
||||
}
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@item.RateMode.Display()</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_CostRange</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_ForecastEmpty</MudText>
|
||||
<MudText Typo="Typo.subtitle1">
|
||||
@(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—")
|
||||
@if (item.AveragePerDay is { } apd)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline">
|
||||
@Loc.F(S.Consumables_PerDay, Format.Number(apd, 1), item.Unit)
|
||||
</MudText>
|
||||
}
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">@S.Consumables_ConsumptionByMonth</MudText>
|
||||
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="5">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">@Loc.F(S.Consumables_DeliveriesCount, item.Deliveries.Count)</MudText>
|
||||
@if (item.Deliveries.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Consumables_NoDeliveries</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="max-height:260px; overflow-y:auto">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr><th>@S.Common_Date</th><th style="text-align:right">@S.Common_Amount</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var delivery in item.Deliveries)
|
||||
{
|
||||
<tr>
|
||||
<td>@Local(delivery.Time).ToString("yyyy-MM-dd")</td>
|
||||
<td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
}
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
<MudAlert Severity="Severity.Warning" Class="mb-3">
|
||||
@Loc.F(S.Consumables_TankNotConfigured, meter.Name)
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Warning" Class="ml-2"
|
||||
Href="@MeterLinks.Detail(meter.MeterId, action: MeterLinks.ActionEdit)">@S.Consumables_SetUpTank</MudButton>
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
@if (view.Analysis.Tanks.Count == 0 && view.Analysis.Unconfigured.Count == 0)
|
||||
{
|
||||
<div class="mv-empty" role="status">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.PropaneTank" Class="mv-empty__icon" aria-hidden="true" />
|
||||
<div class="mv-empty__body">
|
||||
<MudText Typo="Typo.subtitle1">@S.Consumables_NoTanksTitle</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@Loc.F(S.Consumables_NoTanksHelp, MeterMode.ConsumableBalance.Display())</MudText>
|
||||
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" Href="/meters">@S.Nav_Meters</MudButton>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" Size="Size.Small" Href="/import">@S.Nav_Import</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (!view.Analysis.IsRefused)
|
||||
{
|
||||
@foreach (var tank in view.Tanks)
|
||||
{
|
||||
<TankSection @key="tank.Tank.MeterId" View="tank" Query="view.Query" Period="view.Analysis.Period"
|
||||
Problems="ProblemsOf(view.Analysis, tank.Tank)" Currency="@view.Analysis.Currency" OnRefresh="RetryAsync" />
|
||||
}
|
||||
}
|
||||
</LoadPanel>
|
||||
|
||||
@code {
|
||||
private int _months = 60;
|
||||
private bool _loading;
|
||||
private IReadOnlyList<ConsumableSummary>? _items;
|
||||
private IReadOnlyList<UnconfiguredConsumable> _unconfigured = [];
|
||||
private TimeZoneInfo _tz = TimeZoneInfo.Utc;
|
||||
private static readonly AnalysisDefaults Defaults = AnalysisDefaults.History;
|
||||
|
||||
protected override Task OnInitializedAsync()
|
||||
private readonly LoadSequencer _loads = new();
|
||||
private readonly LoadState<ConsumablesPageView> _state = new();
|
||||
private AnalysisQuery? _query;
|
||||
|
||||
/// <summary>One committed result: the query it answers, the read model, every tank's series and the export link.</summary>
|
||||
private sealed record ConsumablesPageView(AnalysisQuery Query, ConsumableAnalysis Analysis, IReadOnlyList<TankView> Tanks, string? ExportHref);
|
||||
|
||||
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
|
||||
|
||||
protected override Task OnParametersSetAsync() => ReloadIfChangedAsync();
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(ReloadIfChangedAsync);
|
||||
|
||||
private async Task ReloadIfChangedAsync()
|
||||
{
|
||||
_tz = LocalTimeEntry.Resolve(Options.Value.TimeZone);
|
||||
return LoadAsync();
|
||||
}
|
||||
|
||||
private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz);
|
||||
|
||||
private async Task OnRangeChanged(int months)
|
||||
{
|
||||
_months = months;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_loading)
|
||||
var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
|
||||
if (query == _query)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_items = null;
|
||||
try
|
||||
{
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = asOf.AddMonths(-_months);
|
||||
_unconfigured = await ConsumablesSvc.GetUnconfiguredAsync();
|
||||
_items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
_query = query;
|
||||
await LoadAsync(query);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item)
|
||||
private Task RetryAsync() => _query is null ? Task.CompletedTask : LoadAsync(_query);
|
||||
|
||||
private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token =>
|
||||
{
|
||||
var points = item.Months
|
||||
.Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Consumption))
|
||||
.ToList();
|
||||
return [new SeriesChart.SeriesDef(Loc.F(S.Consumables_UnitUsed, item.Unit), ApexCharts.SeriesType.Bar, points)];
|
||||
// "Now" is read once per load (D-01); "all" spans what the tanks have data for (D-19).
|
||||
var now = Clock.Now;
|
||||
var availability = query.Period == PeriodPreset.AllHistory ? await ConsumablesSvc.GetAvailabilityAsync(now, token) : null;
|
||||
var period = query.Resolve(now, ConsumablesSvc.Zone, availability);
|
||||
var analysis = await ConsumablesSvc.GetAsync(new ConsumableRequest(period) { Bucket = query.Bucket, Comparison = query.Comparison }, token);
|
||||
var tanks = analysis.Tanks.Select(t => TankView.Build(t, analysis.Quantities, query, analysis.Currency)).ToList();
|
||||
|
||||
// The CSV export of what the tables show: every tank's usage (D-55), within the chart's series limit.
|
||||
var ids = analysis.Tanks.Select(t => t.MeterId).ToList();
|
||||
var export = ids.Count is > 0 and <= MeterVault.Infrastructure.Analysis.AnalysisLimits.MaxSeries
|
||||
? AnalysisLinks.Export(query.WithScope(QueryScope.ForMeters(ids)).WithMetric(AnalysisMetric.Consumption))
|
||||
: null;
|
||||
return new ConsumablesPageView(query, analysis, tanks, export);
|
||||
}, Logger);
|
||||
|
||||
private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
|
||||
|
||||
/// <summary>The reader's problems about one tank and the burners it feeds.</summary>
|
||||
private static IReadOnlyList<MeterVault.Infrastructure.Analysis.AnalysisProblem> ProblemsOf(ConsumableAnalysis analysis, TankAnalysis tank)
|
||||
{
|
||||
var ids = tank.Runtime.Select(r => r.MeterId).OfType<int>().Append(tank.MeterId).ToHashSet();
|
||||
var problems = (analysis.Quantities?.Problems ?? []).Concat(tank.Cost?.QuantityProblems ?? []);
|
||||
return [.. problems.Where(p => p.MeterId is { } id ? ids.Contains(id) : p.MeterIds.Any(ids.Contains))];
|
||||
}
|
||||
|
||||
private static Color FillColor(double fraction) => fraction switch
|
||||
public void Dispose()
|
||||
{
|
||||
< 0.15 => Color.Error,
|
||||
< 0.30 => Color.Warning,
|
||||
_ => Color.Success,
|
||||
};
|
||||
Nav.LocationChanged -= OnLocationChanged;
|
||||
_loads.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +1,251 @@
|
||||
@page "/"
|
||||
@using MeterVault.App.Components.Pages.Overview
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Core.Analysis.Coverage
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@inject NavigationManager Nav
|
||||
@inject InstanceClock Clock
|
||||
@inject AnalysisPeriods Periods
|
||||
@inject DashboardService Dash
|
||||
@inject ILogger<Dashboard> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MeterVault — @S.Nav_Overview</PageTitle>
|
||||
@* The Overview (brief §7.1): what happened in the chosen period, what changed, and where to look — the portfolio's
|
||||
quantities per energy type in their own units and the bill for the same resolved period and buckets, with the
|
||||
comparison over the coverage both periods share. The toolbar's range applies to every panel; a period without data
|
||||
says so and offers the latest data as its own period instead of silently showing it (D-19). The update banner sits in
|
||||
the header, apart from the analytical status. *@
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Overview</MudText>
|
||||
<PageHeader Title="@S.Nav_Overview" Description="@S.Overview_Description">
|
||||
<UpdateBanner />
|
||||
</PageHeader>
|
||||
|
||||
<UpdateBanner />
|
||||
<div class="mv-ov">
|
||||
<PeriodToolbar Query="_query!" Period="_state.Value?.Period" Plan="_state.Value?.Data.Plan" Defaults="Defaults"
|
||||
QueryChanged="OnQueryChanged" ExportHref="@ExportHref" />
|
||||
|
||||
@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">@S.Dashboard_ThisMonth</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">@S.Common_ThisYear</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">@S.Dashboard_LatestMonthWithData</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<LoadPanel State="_state" OnRetry="RetryAsync" Context="view" Class="mt-2">
|
||||
@if (!view.Data.IsRefused)
|
||||
{
|
||||
<OverviewCoverage Data="view.Data" />
|
||||
}
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatCostsMost</MudText>
|
||||
@if (_breakdown is { Count: > 0 })
|
||||
@if (view.Data.IsRefused)
|
||||
{
|
||||
@* The toolbar explains the refused bucket size and offers a coarser one; nothing was read. *@
|
||||
}
|
||||
else if (view.Data.IsPending)
|
||||
{
|
||||
<PendingState OnRefresh="RetryAsync" Class="mb-4" />
|
||||
<AttentionList Problems="view.Problems" CostAttention="view.Data.Cost.Attention" Names="view.Names" Query="view.Query" />
|
||||
}
|
||||
else if (view.Data.NotYetOccurred || view.Data.HasNoData)
|
||||
{
|
||||
<EmptyPeriodState NotYetOccurred="view.Data.NotYetOccurred" Availability="view.Data.Availability"
|
||||
LatestHref="@LatestHref(view)" Class="mb-4">
|
||||
@if (view.Data.Availability is null)
|
||||
{
|
||||
<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
|
||||
{
|
||||
@* Names the one step that is missing, in setup order, rather than every admin page. *@
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
@switch (_setup?.FirstGap)
|
||||
{
|
||||
case CostSetupGap.NoMeters:
|
||||
<span>@S.Dashboard_SetupNoMeters <MudLink Href="/meters">@S.Nav_Meters</MudLink> · <MudLink Href="/import">@S.Nav_Import</MudLink></span>
|
||||
break;
|
||||
case CostSetupGap.NoCategories:
|
||||
<span>@S.Dashboard_SetupNoCategories <MudLink Href="/admin/categories">@S.Nav_CostCategories</MudLink></span>
|
||||
break;
|
||||
case CostSetupGap.NoMembers:
|
||||
<span>@S.Dashboard_SetupNoMembers <MudLink Href="/meters">@S.Nav_Meters</MudLink> · <MudLink Href="/admin/categories">@S.Nav_CostCategories</MudLink></span>
|
||||
break;
|
||||
case CostSetupGap.NoTariffs:
|
||||
<span>@S.Dashboard_SetupNoTariffs <MudLink Href="/admin/tariffs">@S.Nav_Tariffs</MudLink></span>
|
||||
break;
|
||||
default:
|
||||
<span>@S.Dashboard_SetupNoCostsThisYear</span>
|
||||
break;
|
||||
}
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mt-1">
|
||||
@S.Dashboard_SetupNoMeters
|
||||
<MudLink Href="/meters" Typo="Typo.body2">@S.Nav_Meters</MudLink> ·
|
||||
<MudLink Href="/import" Typo="Typo.body2">@S.Nav_Import</MudLink>
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatChanged</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr><th>@S.Dashboard_ColCategory</th><th style="text-align:right">@S.Dashboard_ColCurrent</th><th style="text-align:right">@S.Dashboard_ColPrevious</th><th style="text-align:right">Δ</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in _difference)
|
||||
</EmptyPeriodState>
|
||||
@if (view.Data.Types.Count > 0)
|
||||
{
|
||||
<nav class="mv-ov-typelinks mb-4" aria-label="@S.Nav_EnergyTypes">
|
||||
<span class="mv-muted">@S.Overview_TypesInPeriod</span>
|
||||
@foreach (var type in view.Data.Types)
|
||||
{
|
||||
<MudLink Href="@AnalysisLinks.EnergyType(type.Type.Id, null, view.Query)" Typo="Typo.body2">@type.Type.Name</MudLink>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
<AttentionList Problems="view.Problems" CostAttention="view.Data.Cost.Attention" Names="view.Names" Query="view.Query" MaxItems="5" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="12" sm="6" lg="3">
|
||||
<div class="mv-ov-lead">
|
||||
<OverviewCostCard Data="view.Data" Query="view.Query" />
|
||||
@if (HasAttention(view))
|
||||
{
|
||||
<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>
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-ov-attention">
|
||||
@if (view.Data.Setup?.FirstGap is CostSetupGap.NoMeters or CostSetupGap.NoTariffs)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<h2 class="mud-typography mud-typography-subtitle2 mb-1">@S.Overview_SetupTitle</h2>
|
||||
<MudText Typo="Typo.body2">
|
||||
@if (view.Data.Setup.FirstGap == CostSetupGap.NoMeters)
|
||||
{
|
||||
@S.Dashboard_SetupNoMeters
|
||||
<MudLink Href="/meters" Typo="Typo.body2">@S.Nav_Meters</MudLink><span> · </span>
|
||||
<MudLink Href="/import" Typo="Typo.body2">@S.Nav_Import</MudLink>
|
||||
}
|
||||
else
|
||||
{
|
||||
@S.Dashboard_SetupNoTariffs
|
||||
<MudLink Href="/admin/tariffs" Typo="Typo.body2">@S.Nav_Tariffs</MudLink>
|
||||
}
|
||||
</MudText>
|
||||
</div>
|
||||
}
|
||||
<AttentionList Problems="view.Problems" CostAttention="view.Data.Cost.Attention" Names="view.Names"
|
||||
Query="view.Query" MaxItems="4" />
|
||||
</MudPaper>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
</div>
|
||||
</MudItem>
|
||||
@foreach (var type in view.Data.Types)
|
||||
{
|
||||
<MudItem xs="12" sm="6" lg="3" @key="type.Type.Id">
|
||||
<OverviewTypeCard Figures="type" Query="view.Query" Currency="@view.Currency" Zone="view.Period.Zone" />
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@if (TypesWithoutMeters(view) is { Count: > 0 } empty)
|
||||
{
|
||||
<p class="mv-ov-typelinks mt-2">
|
||||
<span class="mv-muted">@S.Overview_TypesWithoutMeters</span>
|
||||
@foreach (var type in empty)
|
||||
{
|
||||
<MudLink Href="@AnalysisLinks.EnergyType(type.Id, null, view.Query)" Typo="Typo.body2">@type.Name</MudLink>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
@* The ranges compared; the matched stretch only when less than the whole periods is compared (D-07). *@
|
||||
<ComparisonSummary Period="view.Period" Resolution="view.Data.Comparison"
|
||||
Matched="@(view.Data.CostChange.IsPartial || view.Data.CostChange.Basis == CostChangeBasis.NotComparable ? view.Data.CostChange.Matched : null)"
|
||||
Class="mt-3" />
|
||||
|
||||
<MudGrid Spacing="3" Class="mt-1">
|
||||
<MudItem xs="12">
|
||||
<OverviewHistory View="view" ChartKey="@_chartKey" ChartKeyChanged="OnChartKeyChanged" OnDrill="Drill" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" lg="7">
|
||||
<OverviewChanges View="view" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" lg="5">
|
||||
<OverviewComposition View="view" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
</LoadPanel>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private DashboardSummary? _summary;
|
||||
private IReadOnlyList<CategorySlice> _breakdown = [];
|
||||
private IReadOnlyList<DifferenceRow> _difference = [];
|
||||
private CostSetup? _setup;
|
||||
private static readonly AnalysisDefaults Defaults = AnalysisDefaults.Overview;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
private readonly LoadSequencer _loads = new();
|
||||
private readonly LoadState<OverviewView> _state = new();
|
||||
private AnalysisQuery? _query;
|
||||
private AnalysisQuery? _requested;
|
||||
private string? _chartKey;
|
||||
|
||||
private string? ExportHref
|
||||
{
|
||||
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));
|
||||
if (_breakdown.Count == 0)
|
||||
get
|
||||
{
|
||||
_setup = await Dash.GetCostSetupAsync();
|
||||
if (_query is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var option = _state.Value?.Option(_chartKey);
|
||||
var scoped = _query.WithScope(option?.Scope ?? QueryScope.Portfolio).WithMetric(option?.Metric ?? AnalysisMetric.Cost);
|
||||
return AnalysisLinks.Export(scoped);
|
||||
}
|
||||
_difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1));
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_query = AnalysisQuery.Parse(Nav.Uri, Defaults);
|
||||
_chartKey = OverviewView.ChartKeyOf(Nav.Uri);
|
||||
Nav.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
protected override Task OnParametersSetAsync() => ReloadIfChangedAsync();
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () =>
|
||||
{
|
||||
_chartKey = OverviewView.ChartKeyOf(Nav.Uri);
|
||||
StateHasChanged();
|
||||
await ReloadIfChangedAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
/// <summary>Loads when the analysis state changed; a chart selection alone is not a new analysis (D-46).</summary>
|
||||
private async Task ReloadIfChangedAsync()
|
||||
{
|
||||
var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
|
||||
if (query == _requested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_requested = query;
|
||||
_query = query;
|
||||
await LoadAsync(query);
|
||||
}
|
||||
|
||||
private Task RetryAsync() => LoadAsync(_query!);
|
||||
|
||||
private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token =>
|
||||
{
|
||||
// "Now" once per load (D-01); the whole page answers this one period.
|
||||
var now = Clock.Now;
|
||||
var period = query.Period == PeriodPreset.AllHistory
|
||||
? query.Resolve(now, Periods.Zone, await AllHistoryAsync(query, now, token))
|
||||
: await Periods.ResolveAsync(query, now, token);
|
||||
var data = await Dash.GetOverviewAsync(period, query.Bucket, query.Comparison, token);
|
||||
return OverviewView.Build(query, data);
|
||||
}, Logger);
|
||||
|
||||
/// <summary>All history on the Overview spans both what the meters measured and the bill (manual costs included, D-19).</summary>
|
||||
private async Task<AvailableRange?> AllHistoryAsync(AnalysisQuery query, DateTimeOffset now, CancellationToken token)
|
||||
{
|
||||
var quantities = await Periods.AvailabilityAsync(query.WithMetric(null), now, token);
|
||||
var costs = await Periods.AvailabilityAsync(query.WithMetric(AnalysisMetric.Cost), now, token);
|
||||
return AvailableRange.Union([quantities, costs], Periods.Zone);
|
||||
}
|
||||
|
||||
private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
|
||||
|
||||
/// <summary>The chart selection goes into the address (replace): a reload or a shared link shows the same (D-46).</summary>
|
||||
private void OnChartKeyChanged(string key)
|
||||
{
|
||||
_chartKey = key == OverviewView.CostKey ? null : key;
|
||||
Nav.NavigateTo(Nav.GetUriWithQueryParameter(OverviewView.ChartParameter, _chartKey), replace: true);
|
||||
}
|
||||
|
||||
/// <summary>A clicked bucket opens on the Overview itself, one size finer (D-51) — a drill-down pushes.</summary>
|
||||
private void Drill((AnalysisBucket Bucket, ResolutionClass? Resolution) click)
|
||||
{
|
||||
if (_query is not null && AnalysisNavigation.DrillInto(_query, click.Bucket, click.Resolution) is { } next)
|
||||
{
|
||||
Nav.NavigateTo(AnalysisNavigation.UriFor(Nav, next, Defaults));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Go to latest data": a period of the same kind ending with the latest data — for month to date the latest month
|
||||
/// with data — opened as its own period on the Overview (brief §4.3). Nothing moves by itself.
|
||||
/// </summary>
|
||||
private static string? LatestHref(OverviewView view) =>
|
||||
AnalysisNavigation.LatestData(view.Query, view.Data.Availability) is { } target ? AnalysisLinks.Overview(target) : null;
|
||||
|
||||
private static bool HasAttention(OverviewView view) =>
|
||||
view.AttentionCount > 0 || view.Data.Setup?.FirstGap is CostSetupGap.NoMeters or CostSetupGap.NoTariffs;
|
||||
|
||||
private static List<OverviewEnergyType> TypesWithoutMeters(OverviewView view) => [.. view.Data.EnergyTypes.Where(t => !t.HasMeters)];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Nav.LocationChanged -= OnLocationChanged;
|
||||
_loads.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/* The Overview's panels (Components/Pages/Overview): one look for their heads and footers. Palette variables only, so
|
||||
light and dark mode both work; everything wraps down to 360px and wide tables scroll inside their own box. */
|
||||
|
||||
.mv-ov ::deep .mv-ov-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-panel__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-panel__title {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-panel__controls {
|
||||
flex: 0 1 320px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-panel__foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-typelinks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 12px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* The first column: the period's cost, and under it what needs attention, together as tall as the type cards. */
|
||||
.mv-ov ::deep .mv-ov-lead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-lead .mv-metric {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-lead .mv-metric:only-child {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-attention {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* In the narrow first column: the icon beside the words, the action under them. */
|
||||
.mv-ov ::deep .mv-ov-attention .mv-attention__item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
column-gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-attention .mv-attention__icon {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-attention .mv-attention__action {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
/* The change table and the composition: names and status words wrap, amounts do not. The tone classes win over the
|
||||
table cell's own text colour; the total row is set apart. */
|
||||
.mv-ov ::deep .mv-ov-amount {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-changes th.mv-num,
|
||||
.mv-ov ::deep .mv-ov-changes td.mv-num,
|
||||
.mv-ov ::deep .mv-ov-composition th.mv-num,
|
||||
.mv-ov ::deep .mv-ov-composition td.mv-num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mv-ov ::deep .mv-ov-changes td.mv-change-good { color: var(--mud-palette-success); }
|
||||
.mv-ov ::deep .mv-ov-changes td.mv-change-bad { color: var(--mud-palette-error); }
|
||||
.mv-ov ::deep .mv-ov-changes td.mv-change-neutral { color: var(--mud-palette-text-secondary); }
|
||||
|
||||
.mv-ov ::deep .mv-ov-total > td {
|
||||
font-weight: 600;
|
||||
border-top: 2px solid var(--mud-palette-lines-default);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.mv-ov ::deep .mv-ov-changes td.mv-num,
|
||||
.mv-ov ::deep .mv-ov-composition td.mv-num {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 599.98px) {
|
||||
.mv-ov ::deep .mv-ov-changes .mud-table-row + .mud-table-row > td:first-child,
|
||||
.mv-ov ::deep .mv-ov-composition .mud-table-row + .mud-table-row > td:first-child {
|
||||
border-top: 1px solid var(--mud-palette-lines-default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
@using MeterVault.App.Energy
|
||||
@using MeterVault.Core.Analysis
|
||||
|
||||
@* The energy type's Flow (brief §7.3, D-30): the Sankey as a topology tool, fed with the canonical period totals — a
|
||||
virtual sum drawn from its calculation inputs (marked calculated), a meter below several others split in proportion
|
||||
(marked estimated) — and its table equivalent: every ribbon with what it is, every meter with its signed value or its
|
||||
status in words, and why a meter is not drawn. "Manage connections" edits the topology. No topology never stops the
|
||||
analysis: the other tabs do not depend on it. *@
|
||||
|
||||
@if (Analysis.Flow is { } flow)
|
||||
{
|
||||
<div class="mv-energy-flow">
|
||||
<div class="mv-energy-flow__head">
|
||||
<div class="mv-energy-flow__intro">
|
||||
<MudText Typo="Typo.h6">@S.EnergyView_Flow</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@Loc.F(S.EnergyView_FlowCaption, flow.Unit)</MudText>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Hub"
|
||||
OnClick="() => ManageConnections.InvokeAsync()">@S.EnergyView_ManageConnections</MudButton>
|
||||
</div>
|
||||
|
||||
@if (!flow.HasChain)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">@S.EnergyView_NoConnections</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<SankeyChart Nodes="flow.Nodes" Links="flow.Links" Unit="@flow.Unit" />
|
||||
<ul class="mv-energy-flow__legend" aria-label="@S.EnergyView_Legend">
|
||||
<li><span class="mv-swatch mv-swatch--measured" aria-hidden="true"></span>@S.EnergyView_LegendMeasured</li>
|
||||
<li><span class="mv-swatch mv-swatch--calculated" aria-hidden="true"></span>@S.EnergyView_LegendCalculated</li>
|
||||
<li><span class="mv-swatch mv-swatch--estimated" aria-hidden="true"></span>@S.EnergyView_LegendEstimated</li>
|
||||
<li><span class="mv-swatch mv-swatch--other" aria-hidden="true"></span>@S.EnergyView_LegendOther</li>
|
||||
</ul>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.subtitle1" Class="mt-4 mb-1">@S.EnergyView_FlowTable</MudText>
|
||||
<MudGrid Spacing="3">
|
||||
@if (flow.Links.Count > 0)
|
||||
{
|
||||
<MudItem xs="12" lg="7">
|
||||
<div class="mv-table-scroll" role="region" aria-label="@S.EnergyView_Connections" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mv-analysis-table">
|
||||
<caption class="mv-sr-only">@S.EnergyView_Connections</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@S.EnergyView_ColFrom</th>
|
||||
<th scope="col">@S.EnergyView_ColTo</th>
|
||||
<th scope="col" class="mv-num">@S.Common_Amount</th>
|
||||
<th scope="col">@S.Common_Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var link in flow.Links.OrderBy(l => FlowText.NodeName(flow, l.From), StringComparer.CurrentCultureIgnoreCase).ThenByDescending(l => l.Value))
|
||||
{
|
||||
<tr>
|
||||
<td>@FlowText.NodeName(flow, link.From)</td>
|
||||
<td>@FlowText.NodeName(flow, link.To)</td>
|
||||
<td class="mv-num">@Format.Quantity(link.Value, flow.Unit)</td>
|
||||
<td class="mv-energy-flow__kind">@FlowText.EdgeKind(flow, link)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
<MudItem xs="12" lg="@(flow.Links.Count > 0 ? 5 : 12)">
|
||||
<div class="mv-table-scroll" role="region" aria-label="@S.Common_Meters" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mv-analysis-table">
|
||||
<caption class="mv-sr-only">@S.Common_Meters</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@S.Common_Meter</th>
|
||||
<th scope="col" class="mv-num">@S.Meters_ColValue</th>
|
||||
<th scope="col">@S.EnergyView_ColDiagram</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var meter in flow.Meters.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||
{
|
||||
<tr>
|
||||
<td><MudLink Href="@MeterLinks.Analysis(meter.MeterId, Query)" Typo="Typo.body2">@meter.Name</MudLink></td>
|
||||
<td class="mv-num @(meter.Value is null ? "mv-unknown" : null)">@FlowText.MeterValue(meter)</td>
|
||||
<td class="mv-energy-flow__kind">@(FlowText.NotDrawnReason(flow, meter) ?? S.EnergyView_InDiagram)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The page's committed value.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public EnergyAnalysis Analysis { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis state (meter links carry its period).</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Query { get; set; } = null!;
|
||||
|
||||
/// <summary>Opens the page's "Manage connections" dialog.</summary>
|
||||
[Parameter]
|
||||
public EventCallback ManageConnections { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
.mv-energy-flow__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mv-energy-flow__intro {
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mv-energy-flow__legend {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
margin: 8px 0 0 0;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mv-energy-flow__legend li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mv-swatch {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.mv-swatch--measured {
|
||||
background: var(--mud-palette-text-secondary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mv-swatch--calculated {
|
||||
border: 1.5px dashed var(--mud-palette-text-secondary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mv-swatch--estimated {
|
||||
border: 1.5px dotted var(--mud-palette-text-primary);
|
||||
background: var(--mud-palette-action-disabled-background);
|
||||
}
|
||||
|
||||
.mv-swatch--other {
|
||||
background: #78909C;
|
||||
}
|
||||
|
||||
.mv-energy-flow ::deep td.mv-energy-flow__kind {
|
||||
white-space: normal;
|
||||
min-width: 24ch;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
@using MeterVault.App.Energy
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@* The energy type's History (brief §7.3): the shared chart and table for the chosen metric, over the page's period and
|
||||
interval, compared with the chosen period (a calendar year for a year). "Total" charts the type's measures — never a
|
||||
breakdown on top of its parent or a calculated view on top of its sources (D-22); "Individual meters" charts the
|
||||
meters side by side and says how each one counts, so their bars are not read as adding up. Signed values stay signed.
|
||||
A bucket opens its finer detail (D-51). *@
|
||||
|
||||
<div class="mv-energy-history">
|
||||
<div class="mv-energy-history__views">
|
||||
<MudToggleGroup T="string" Value="@View" ValueChanged="OnViewChangedAsync" SelectionMode="SelectionMode.SingleSelection"
|
||||
Outlined="true" Color="Color.Primary" Size="Size.Small" aria-label="@S.EnergyView_ViewLabel"
|
||||
Class="mv-energy-history__toggle">
|
||||
<MudToggleItem T="string" Value="@EnergyPageKeys.ViewTotal" Text="@S.EnergyView_ViewTotal" />
|
||||
<MudToggleItem T="string" Value="@EnergyPageKeys.ViewMeters" Text="@S.EnergyView_ViewMeters" />
|
||||
</MudToggleGroup>
|
||||
<MudText Typo="Typo.caption" Class="mv-muted">
|
||||
@(_view?.IsIndividual == true ? S.EnergyView_ViewMetersHelp : S.EnergyView_ViewTotalHelp)
|
||||
</MudText>
|
||||
</div>
|
||||
|
||||
@if (_view is null || _quantities is null)
|
||||
{
|
||||
@* Nothing read yet: the page shows its own loading state. *@
|
||||
}
|
||||
else if (_view.IsEmpty)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true">@EmptyText()</MudAlert>
|
||||
}
|
||||
else if (_view.Main is { IsPending: true })
|
||||
{
|
||||
<PendingState OnRefresh="OnRefresh" />
|
||||
}
|
||||
else if (_quantities.NotYetOccurred || NoData())
|
||||
{
|
||||
<EmptyPeriodState NotYetOccurred="_quantities.NotYetOccurred" Availability="Availability()" LatestHref="@LatestHref()" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<ComparisonSummary Period="Analysis.Period" Resolution="ComparisonResolution()" Matched="_view.Main?.Comparison?.Matched" Class="mb-3" />
|
||||
<AnalysisChart Buckets="_buckets" Series="_view.Chart" ComparisonPairs="_pairs" Title="@_title" OnBucketClick="_onBucketClick"
|
||||
Resolution="_view.Coarsest" OnUseBucket="UseBucket" />
|
||||
|
||||
@if (_view.IsIndividual)
|
||||
{
|
||||
@if (_view.Hidden > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mt-2">
|
||||
@Loc.F(S.EnergyView_MoreMeters, _view.Shown.Count, _view.Shown.Count + _view.Hidden)
|
||||
<MudLink Href="@AnalysisLinks.Analysis(QueryScope.ForEnergyType(Analysis.EnergyTypeId), _view.Metric, Query)" Typo="Typo.body2">@S.Nav_Analysis</MudLink>
|
||||
</MudText>
|
||||
}
|
||||
@if (_view.Memberships.Count > 0)
|
||||
{
|
||||
<section class="mv-energy-history__counts" aria-labelledby="@_countsId">
|
||||
<MudText Typo="Typo.subtitle2" id="@_countsId">@S.EnergyView_HowCounted</MudText>
|
||||
<ul>
|
||||
@foreach (var (series, membership) in _view.Memberships)
|
||||
{
|
||||
<li>
|
||||
<MudIcon Icon="@MeterListRows.MembershipIcon(membership)" Size="Size.Small" aria-hidden="true" Class="mv-muted" />
|
||||
<span>
|
||||
<MudLink Href="@MeterLinks.Analysis(series.MeterId!.Value, Query)" Typo="Typo.body2">@series.Name</MudLink>:
|
||||
@membership.Label@(string.IsNullOrEmpty(membership.Detail) ? null : " — " + membership.Detail)
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
else if (_view.Metric == AnalysisMetric.Cost && Analysis.Cost is { } cost)
|
||||
{
|
||||
<AttentionList CostAttention="cost.Attention" Problems="cost.QuantityProblems" Names="Analysis.Names" Query="Query"
|
||||
MaxItems="3" Class="mt-3" />
|
||||
}
|
||||
|
||||
<div class="mt-4">
|
||||
<AnalysisTable Buckets="_buckets" Series="_view.Table" ComparisonPairs="_pairs" Caption="@_title" DrillHref="_drillHref" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>The page's committed value.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public EnergyAnalysis Analysis { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis state.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Query { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's defaults (drill-downs and "latest data" keep the page's other keys).</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisDefaults Defaults { get; set; } = null!;
|
||||
|
||||
/// <summary>The metric shown (<see cref="EnergyMetrics.Effective"/>).</summary>
|
||||
[Parameter]
|
||||
public AnalysisMetric Metric { get; set; }
|
||||
|
||||
/// <summary>The view key (<see cref="EnergyPageKeys.ViewTotal"/> or <see cref="EnergyPageKeys.ViewMeters"/>).</summary>
|
||||
[Parameter]
|
||||
public string View { get; set; } = EnergyPageKeys.ViewTotal;
|
||||
|
||||
/// <summary>The view was switched; the page writes it into its address.</summary>
|
||||
[Parameter]
|
||||
public EventCallback<string> ViewChanged { get; set; }
|
||||
|
||||
/// <summary>Loads again (analysis being prepared).</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnRefresh { get; set; }
|
||||
|
||||
private readonly string _countsId = "mv-counts-" + Guid.NewGuid().ToString("N")[..8];
|
||||
private object? _builtFrom;
|
||||
private EnergyHistoryView? _view;
|
||||
private AnalysisResult? _quantities;
|
||||
private IReadOnlyList<AnalysisBucket> _buckets = [];
|
||||
private IReadOnlyList<BucketPair>? _pairs;
|
||||
private string _title = string.Empty;
|
||||
private EventCallback<AnalysisBucket> _onBucketClick;
|
||||
private Func<AnalysisBucket, string?>? _drillHref;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Rebuilt only for a new value, metric, view or comparison: the chart re-keys on a new list.
|
||||
var source = (Analysis, Metric, View, Query.Comparison);
|
||||
if (Equals(_builtFrom, source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_builtFrom = source;
|
||||
_quantities = Analysis.Quantities;
|
||||
_view = EnergyHistoryView.Build(Analysis, Metric, View == EnergyPageKeys.ViewMeters, Query.Comparison);
|
||||
if (_view.Metric == AnalysisMetric.Cost && Analysis.Cost is { } cost)
|
||||
{
|
||||
_buckets = cost.Plan.Buckets;
|
||||
_pairs = Analysis.CostComparison?.Pairs is { Count: > 0 } pairs ? pairs : null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_buckets = _quantities?.Plan.Buckets ?? [];
|
||||
_pairs = _quantities?.Comparison?.Buckets;
|
||||
}
|
||||
|
||||
var typeName = Analysis.Type?.Name ?? string.Empty;
|
||||
_title = Loc.F(S.EnergyView_ChartTitle, Metric.Display(), typeName);
|
||||
|
||||
// Buckets are clickable, and the table has its drill column, only when some bucket leads somewhere (D-51): monthly
|
||||
// data has no days to open, and a click that does nothing is a dead end.
|
||||
var drills = _buckets.Any(b => DrillHref(b) is not null);
|
||||
_onBucketClick = drills ? EventCallback.Factory.Create<AnalysisBucket>(this, DrillAsync) : default;
|
||||
_drillHref = drills ? DrillHref : null;
|
||||
}
|
||||
|
||||
/// <summary>The buckets are finer than the data: open the interval that shows it (replacing the address, D-46).</summary>
|
||||
private void UseBucket(BucketSize size) => AnalysisNavigation.Replace(Nav, Query.WithBucket(size), Defaults);
|
||||
|
||||
private async Task OnViewChangedAsync(string? view) =>
|
||||
await ViewChanged.InvokeAsync(EnergyPageKeys.ResolveView(view));
|
||||
|
||||
private string EmptyText()
|
||||
{
|
||||
if (_view!.Metric == AnalysisMetric.Cost)
|
||||
{
|
||||
return S.EnergyView_CostPerMeterNote;
|
||||
}
|
||||
|
||||
return _view.IsIndividual
|
||||
? Loc.F(S.EnergyView_NoMetersForMetric, _view.Metric.Display())
|
||||
: Loc.F(S.EnergyView_NoTotalForMetric, _view.Metric.Display());
|
||||
}
|
||||
|
||||
private bool NoData()
|
||||
{
|
||||
if (_view!.Metric == AnalysisMetric.Cost)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var shown = _view.IsIndividual ? _view.Shown : EnergyMetrics.MeasuresOf(_quantities, _view.Metric);
|
||||
return _view.HasNoData(shown);
|
||||
}
|
||||
|
||||
private MeterVault.Core.Analysis.Coverage.AvailableRange? Availability() =>
|
||||
_view?.Main?.Availability ?? _quantities?.Availability.Quantity;
|
||||
|
||||
private ComparisonResolution? ComparisonResolution() =>
|
||||
_view?.Metric == AnalysisMetric.Cost ? Analysis.CostComparison?.Resolution : _quantities?.Comparison?.Resolution;
|
||||
|
||||
private string? LatestHref() =>
|
||||
AnalysisNavigation.LatestData(Query, Availability()) is { } latest ? AnalysisNavigation.UriFor(Nav, latest, Defaults) : null;
|
||||
|
||||
private string? DrillHref(AnalysisBucket bucket) =>
|
||||
AnalysisNavigation.DrillInto(Query, bucket, _view?.Coarsest) is { } next ? AnalysisNavigation.UriFor(Nav, next, Defaults) : null;
|
||||
|
||||
/// <summary>A chart bucket opens its finer detail (D-51), pushing a history entry so Back returns here.</summary>
|
||||
private void DrillAsync(AnalysisBucket bucket)
|
||||
{
|
||||
if (DrillHref(bucket) is { } href)
|
||||
{
|
||||
Nav.NavigateTo(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.mv-energy-history__views {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mv-energy-history__views ::deep .mv-energy-history__toggle {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.mv-energy-history__counts {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mv-energy-history__counts ul {
|
||||
list-style: none;
|
||||
margin: 4px 0 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mv-energy-history__counts li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
@using MeterVault.App.Energy
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Core.Analysis.Totals
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@* The energy type's Overview (brief §7.3): one card per measure — use, grid import, export, generation, runtime, each
|
||||
with its own unit and status, never added to each other (D-22) — the type's bill with its basis and standing charge
|
||||
(D-34, D-40), the comparison over matched coverage (D-07), a compact trend, the data's coverage, and the meters that
|
||||
changed most. *@
|
||||
|
||||
@if (_quantities is not null)
|
||||
{
|
||||
<div class="mv-energy-overview">
|
||||
<AttentionList Problems="Analysis.Problems" CostAttention="Analysis.Cost?.Attention" Names="Analysis.Names" Query="Query"
|
||||
MaxItems="4" Class="mb-4" />
|
||||
|
||||
@if (_pending)
|
||||
{
|
||||
<PendingState OnRefresh="OnRefresh" Class="mb-4" />
|
||||
}
|
||||
|
||||
@if (_measures.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4">
|
||||
@S.EnergyView_NoMeasures
|
||||
<MudLink Href="@AnalysisLinks.EnergyType(Analysis.EnergyTypeId, AnalysisLinks.EnergyTabMeters, Query)">@S.Nav_Meters</MudLink>
|
||||
</MudAlert>
|
||||
}
|
||||
else if (_quantities.NotYetOccurred || _noData)
|
||||
{
|
||||
<EmptyPeriodState NotYetOccurred="_quantities.NotYetOccurred" Availability="_quantities.Availability.Quantity"
|
||||
LatestHref="@LatestHref" Class="mb-4" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="3" Class="mb-1">
|
||||
@foreach (var series in _measures)
|
||||
{
|
||||
<MudItem xs="12" sm="6" lg="3">
|
||||
<MetricCard Title="@EnergyHistoryView.MeasureName(series, _measures)" Value="series.Total" Unit="@series.Unit"
|
||||
Change="@ChangeOf(series)" Polarity="ChangePolarities.For(series.Kind)" ChangeCaption="@_comparisonCaption"
|
||||
Caption="@MembersCaption(series)" Href="@HistoryHref(AnalysisMetrics.MetricOf(series.Kind))"
|
||||
LinkText="@S.EnergyView_OpenHistory" />
|
||||
</MudItem>
|
||||
}
|
||||
@if (Analysis.Cost is { } cost && Analysis.Metrics.Contains(AnalysisMetric.Cost))
|
||||
{
|
||||
<MudItem xs="12" sm="6" lg="3">
|
||||
<MetricCard Title="@S.AnalysisTable_Cost" Cost="cost.Total" Currency="@cost.Currency" Caption="@_costBasis"
|
||||
Change="_costChange" Polarity="_costPolarity" ChangeCaption="@_costCaption"
|
||||
Href="@HistoryHref(AnalysisMetric.Cost)" LinkText="@S.EnergyView_OpenHistory">
|
||||
@foreach (var line in _costLines)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block">@line</MudText>
|
||||
}
|
||||
</MetricCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
<ComparisonSummary Period="Analysis.Period" Resolution="_quantities.Comparison?.Resolution" Matched="_main?.Comparison?.Matched" Class="mt-3 mb-4" />
|
||||
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="12" md="8">
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-energy-panel">
|
||||
<div class="mv-energy-panel__head">
|
||||
<MudText Typo="Typo.h6" Class="mv-energy-panel__title">@_trendTitle</MudText>
|
||||
<MudLink Href="@HistoryHref(_main is null ? null : AnalysisMetrics.MetricOf(_main.Kind))" Typo="Typo.body2">@S.EnergyView_OpenHistory</MudLink>
|
||||
</div>
|
||||
<AnalysisChart Buckets="_quantities.Plan.Buckets" Series="_trend" ComparisonPairs="_quantities.Comparison?.Buckets"
|
||||
Title="@_trendTitle" Height="240" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-energy-panel">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.EnergyView_Coverage</MudText>
|
||||
@if (_quantities.Availability.Quantity is { } available)
|
||||
{
|
||||
<MudText Typo="Typo.body2">@Loc.F(S.Empty_AvailableRange, Format.Date(available.FirstDay), Format.Date(available.LastDay))</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2">@S.Empty_NoDataYet</MudText>
|
||||
}
|
||||
<dl class="mv-energy-coverage">
|
||||
@foreach (var series in _measures)
|
||||
{
|
||||
var status = FigureText.Of(series.Total, Analysis.Names.MeterOrNull);
|
||||
<dt>@EnergyHistoryView.MeasureName(series, _measures)</dt>
|
||||
<dd>
|
||||
@status.Summary
|
||||
@if (series.Resolution is { } resolution)
|
||||
{
|
||||
<span> · @resolution.Display()</span>
|
||||
}
|
||||
@if (series.Freshness.State != FreshnessState.NoData)
|
||||
{
|
||||
<span> · @series.Freshness.State.Display()</span>
|
||||
}
|
||||
@if (status.IsQualified && status.Detail is { } detail)
|
||||
{
|
||||
<div class="mv-cell-secondary">@detail</div>
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6">@S.EnergyView_LargestChanges</MudText>
|
||||
@if (_quantities.Comparison is not { IsApplicable: true })
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mt-1">@S.EnergyView_NoComparison</MudText>
|
||||
}
|
||||
else if (_changes.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mt-1">@S.EnergyView_NoComparableMeters</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-2">@S.EnergyView_LargestChangesNote</MudText>
|
||||
<div class="mv-table-scroll" role="region" aria-label="@S.EnergyView_LargestChanges" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mv-analysis-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@S.Common_Meter</th>
|
||||
<th scope="col" class="mv-num">@S.EnergyView_ColCurrent</th>
|
||||
<th scope="col" class="mv-num">@S.AnalysisTable_Comparison</th>
|
||||
<th scope="col">@S.AnalysisTable_Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var change in _changes)
|
||||
{
|
||||
var series = change.Series;
|
||||
<tr>
|
||||
<th scope="row" class="mv-row-label">
|
||||
<MudLink Href="@MeterLinks.Analysis(series.MeterId!.Value, Query)">@series.Name</MudLink>
|
||||
@if (Analysis.TotalsOf(series.MeterId!.Value) is { IsCounted: false } entry)
|
||||
{
|
||||
<div class="mv-cell-secondary">@entry.Class.Display()</div>
|
||||
}
|
||||
</th>
|
||||
<td class="mv-num">
|
||||
@Format.Quantity(change.Current, series.Unit)
|
||||
<div class="mv-cell-secondary">@Format.DateRange(change.Matched.Current!.FirstDay, change.Matched.Current.LastDay)</div>
|
||||
</td>
|
||||
<td class="mv-num">
|
||||
@Format.Quantity(change.Previous, series.Unit)
|
||||
<div class="mv-cell-secondary">@Format.DateRange(change.Matched.Comparison!.FirstDay, change.Matched.Comparison.LastDay)</div>
|
||||
</td>
|
||||
<td>
|
||||
<ChangeChip Change="change.Change" Polarity="ChangePolarities.For(series.Kind)"
|
||||
FormatMagnitude="@(v => Format.Quantity(v, series.Unit))" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The page's committed value.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public EnergyAnalysis Analysis { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis state (links carry its period).</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Query { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's defaults, for "go to latest data" on the page itself.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisDefaults Defaults { get; set; } = null!;
|
||||
|
||||
/// <summary>Loads again (analysis being prepared).</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnRefresh { get; set; }
|
||||
|
||||
private const int MaxChanges = 6;
|
||||
|
||||
private EnergyAnalysis? _builtFor;
|
||||
private AnalysisResult? _quantities;
|
||||
private List<AnalysisSeries> _measures = [];
|
||||
private AnalysisSeries? _main;
|
||||
private IReadOnlyList<AnalysisChartSeries> _trend = [];
|
||||
private IReadOnlyList<MeterChange> _changes = [];
|
||||
private string _trendTitle = string.Empty;
|
||||
private string? _comparisonCaption;
|
||||
private string? _costBasis;
|
||||
private List<string> _costLines = [];
|
||||
private Change? _costChange;
|
||||
private string? _costCaption;
|
||||
private ChangePolarity _costPolarity = ChangePolarity.HigherIsWorse;
|
||||
private bool _pending;
|
||||
private bool _noData;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ReferenceEquals(_builtFor, Analysis))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Built once per committed value (the chart re-keys on a new list): every text in the reader's culture.
|
||||
_builtFor = Analysis;
|
||||
_quantities = Analysis.Quantities;
|
||||
if (_quantities is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_measures = [.. _quantities.Measures.OrderBy(s => s.Key.Measure).ThenBy(s => s.Unit, StringComparer.Ordinal)];
|
||||
_main = _measures.FirstOrDefault(s => s.Key.Measure == TotalsMeasure.Use) ?? _measures.FirstOrDefault();
|
||||
_pending = _measures.Any(s => s.IsPending);
|
||||
_noData = _measures.Count > 0 && _measures.All(s => s.Total.Status == BucketStatus.Missing && !s.IsPending);
|
||||
_comparisonCaption = _quantities.Comparison is { IsApplicable: true } ? Query.Comparison.Display() : null;
|
||||
|
||||
if (_main is { } main)
|
||||
{
|
||||
var name = EnergyHistoryView.MeasureName(main, _measures);
|
||||
_trendTitle = Loc.F(S.EnergyView_TrendOf, name);
|
||||
List<AnalysisChartSeries> trend = [AnalysisChartSeries.ForSeries(main, name)];
|
||||
if (AnalysisChartSeries.ComparisonOf(main, AnalysisChartSeries.ComparisonName(name, Query.Comparison)) is { } overlay)
|
||||
{
|
||||
trend.Add(overlay);
|
||||
}
|
||||
|
||||
_trend = trend;
|
||||
}
|
||||
else
|
||||
{
|
||||
_trendTitle = S.EnergyView_Trend;
|
||||
_trend = [];
|
||||
}
|
||||
|
||||
_changes = MeterChanges.Largest(_quantities.Series, MaxChanges);
|
||||
BuildCost();
|
||||
}
|
||||
|
||||
private void BuildCost()
|
||||
{
|
||||
_costBasis = null;
|
||||
_costLines = [];
|
||||
_costChange = null;
|
||||
_costCaption = null;
|
||||
if (Analysis.Cost is not { } cost)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var figure = cost.EnergyTypes.FirstOrDefault(t => t.EnergyTypeId == Analysis.EnergyTypeId);
|
||||
var billed = cost.Lines.Where(l => l.Kind != BillLineKind.FeedIn).Select(l => l.Name).Distinct().ToList();
|
||||
if (figure is { } type)
|
||||
{
|
||||
_costBasis = billed.Count > 0 ? Loc.F(S.EnergyView_CostBasis, type.Basis.Display(), string.Join(", ", billed)) : type.Basis.Display();
|
||||
}
|
||||
|
||||
var credited = cost.Lines.Where(l => l.Kind == BillLineKind.FeedIn).Select(l => l.Name).Distinct().ToList();
|
||||
if (credited.Count > 0)
|
||||
{
|
||||
_costLines.Add(Loc.F(S.EnergyView_CostCredit, string.Join(", ", credited)));
|
||||
}
|
||||
|
||||
// Every standing charge of the bill (D-40) — the type's rows and the meter fees on its lines — as the Overview and
|
||||
// the Analysis page show it for the same scope.
|
||||
if (Analysis.StandingCharge is { } standing && standing != 0)
|
||||
{
|
||||
_costLines.Add(Loc.F(S.EnergyView_StandingCharge, Format.Money(standing, cost.Currency)));
|
||||
}
|
||||
|
||||
if (cost.ManualCosts.Bookings.Count > 0)
|
||||
{
|
||||
_costLines.Add(Loc.F(S.EnergyView_ManualCosts, Format.Money(cost.ManualCosts.Total.Cost, cost.Currency)));
|
||||
}
|
||||
|
||||
// The change over what both bills cover completely, by the rule every page uses (D-07).
|
||||
var change = Analysis.CostChange;
|
||||
_costChange = CostChanges.ForCard(change);
|
||||
_costPolarity = CostChanges.Polarity(change);
|
||||
_costCaption = CostChanges.Caption(Query, change);
|
||||
}
|
||||
|
||||
private Change? ChangeOf(AnalysisSeries series) =>
|
||||
_quantities?.Comparison is { IsApplicable: true } ? series.Comparison?.Change ?? Change.Unavailable : null;
|
||||
|
||||
private string? MembersCaption(AnalysisSeries series) =>
|
||||
series.MemberIds.Count == 0 ? null : Loc.F(S.EnergyView_CountedMeters, string.Join(", ", series.MemberIds.Select(Analysis.MeterName)));
|
||||
|
||||
private string HistoryHref(AnalysisMetric? metric) =>
|
||||
AnalysisLinks.EnergyType(Analysis.EnergyTypeId, AnalysisLinks.EnergyTabHistory, metric is { } m ? Query.WithMetric(m) : Query);
|
||||
|
||||
private string? LatestHref =>
|
||||
AnalysisNavigation.LatestData(Query, _quantities?.Availability.Quantity) is { } latest
|
||||
? AnalysisNavigation.UriFor(Nav, latest, Defaults)
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.mv-energy-overview ::deep .mv-energy-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mv-energy-panel__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 4px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mv-energy-coverage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 2px;
|
||||
margin: 12px 0 0 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mv-energy-coverage dt {
|
||||
font-weight: 500;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.mv-energy-coverage dd {
|
||||
margin: 0;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
@using MeterVault.App.Energy
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<ManageConnectionsDialog> Logger
|
||||
|
||||
@* "Manage connections" (brief §3.2, D-30): an energy type's flow topology with clear source → destination names, where
|
||||
a connection is added or removed on the spot. The rules (MeterLinkRules) refuse a meter into itself, a duplicate, a
|
||||
link across types, a loop — the verdict names it before anything is saved — and any change to the incoming
|
||||
connections of a virtual meter still calculated from them. A connection is topology only: it never writes or changes
|
||||
a calculated meter's formula (D-25); one that mirrors a formula input says so. *@
|
||||
|
||||
<MudDialog Visible="_open" VisibleChanged="OnVisibleChanged" Options="_options">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@Loc.F(S.EnergyView_ConnectionsTitle, EnergyTypeName)</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.EnergyView_ConnectionsIntro</MudText>
|
||||
|
||||
@if (_error)
|
||||
{
|
||||
<PanelError HasStaleValue="false" OnRetry="LoadAsync" Class="mb-2" />
|
||||
}
|
||||
else if (_topology is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" aria-label="@S.Refresh_Updating" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">@S.EnergyView_Connections</MudText>
|
||||
@if (_topology.Links.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.EnergyView_ConnectionsEmpty</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="mv-connections">
|
||||
@foreach (var link in _topology.Links)
|
||||
{
|
||||
var from = Name(link.FromMeterId);
|
||||
var to = Name(link.ToMeterId);
|
||||
var removal = _topology.CheckRemove(link);
|
||||
<li class="mv-connections__item">
|
||||
<div class="mv-connections__names">
|
||||
<span class="mv-connections__meter">@from</span>
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" Size="Size.Small" aria-hidden="true" />
|
||||
<span class="mv-sr-only">@S.EnergyView_FlowsInto</span>
|
||||
<span class="mv-connections__meter">@to</span>
|
||||
</div>
|
||||
<div class="mv-connections__note">
|
||||
@if (!removal.IsAllowed)
|
||||
{
|
||||
<span>@FlowText.Refusal(removal, Name, link.ToMeterId)</span>
|
||||
<MudLink Href="@MeterLinks.Detail(link.ToMeterId, MeterLinks.TabCalculation, null)" Typo="Typo.caption">@S.EnergyView_EditCalculation</MudLink>
|
||||
}
|
||||
else if (_topology.MirrorsCalculation(link))
|
||||
{
|
||||
<span>@Loc.F(S.EnergyView_MirrorsCalculation, to, from)</span>
|
||||
}
|
||||
</div>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.LinkOff" Size="Size.Small" Color="Color.Error"
|
||||
Disabled="@(_busy || !removal.IsAllowed)" OnClick="() => RemoveAsync(link)"
|
||||
aria-label="@Loc.F(S.EnergyView_RemoveConnection, from, to)"
|
||||
title="@Loc.F(S.EnergyView_RemoveConnection, from, to)" Class="mv-connections__remove" />
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-1">@S.EnergyView_AddConnection</MudText>
|
||||
@if (_topology.Meters.Count < 2)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.EnergyView_ConnectionsNeedTwo</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mv-connections__add">
|
||||
<div class="mv-connections__select">
|
||||
<MudSelect T="int" Value="_from" ValueChanged="OnFromChanged" Label="@S.EnergyView_From" HelperText="@S.EnergyView_FromHelp"
|
||||
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true">
|
||||
<MudSelectItem T="int" Value="0">@S.EnergyView_ChooseMeter</MudSelectItem>
|
||||
@foreach (var meter in _topology.Meters)
|
||||
{
|
||||
<MudSelectItem T="int" Value="meter.Id">@Label(meter)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</div>
|
||||
<div class="mv-connections__select">
|
||||
<MudSelect T="int" Value="_to" ValueChanged="OnToChanged" Label="@S.EnergyView_To" HelperText="@S.EnergyView_ToHelp"
|
||||
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true">
|
||||
<MudSelectItem T="int" Value="0">@S.EnergyView_ChooseMeter</MudSelectItem>
|
||||
@foreach (var meter in _topology.Meters)
|
||||
{
|
||||
<MudSelectItem T="int" Value="meter.Id">@Label(meter)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.AddLink"
|
||||
Disabled="@(_busy || _verdict is not { IsAllowed: true })" OnClick="AddAsync"
|
||||
Class="mv-connections__button">@S.EnergyView_AddConnectionAction</MudButton>
|
||||
</div>
|
||||
@* A fixed slot, so the verdict appearing does not move the button under the pointer. *@
|
||||
<div class="mv-connections__verdict" role="status" aria-live="polite">
|
||||
@if (_verdict is { IsAllowed: false } refused)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Block" Size="Size.Small" aria-hidden="true" />
|
||||
<span>@FlowText.Refusal(refused, Name, _to)</span>
|
||||
@if (refused.Refusal == MeterLinkRefusal.CalculatedFromLinks)
|
||||
{
|
||||
<MudLink Href="@MeterLinks.Detail(_to, MeterLinks.TabCalculation, null)" Typo="Typo.caption">@S.EnergyView_EditCalculation</MudLink>
|
||||
}
|
||||
}
|
||||
else if (_verdict is { IsAllowed: true })
|
||||
{
|
||||
<span>@Loc.F(S.EnergyView_ConnectionPreview, Name(_from), Name(_to))</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseAsync">@S.EnergyView_Done</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
/// <summary>The energy type whose connections are edited.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public int EnergyTypeId { get; set; }
|
||||
|
||||
/// <summary>Its name, for the title (user data).</summary>
|
||||
[Parameter]
|
||||
public string EnergyTypeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Raised when the dialog closes after at least one change, so the page reads the analysis again.</summary>
|
||||
[Parameter]
|
||||
public EventCallback Changed { get; set; }
|
||||
|
||||
private readonly DialogOptions _options = new() { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true };
|
||||
private MeterLinkService? _service;
|
||||
private MeterLinkTopology? _topology;
|
||||
private MeterLinkCheck? _verdict;
|
||||
private bool _open;
|
||||
private bool _busy;
|
||||
private bool _error;
|
||||
private bool _changed;
|
||||
private int _from;
|
||||
private int _to;
|
||||
|
||||
private MeterLinkService Service => _service ??= new MeterLinkService(DbFactory);
|
||||
|
||||
/// <summary>Opens the dialog and reads the type's connections afresh.</summary>
|
||||
public async Task OpenAsync()
|
||||
{
|
||||
_open = true;
|
||||
_changed = false;
|
||||
_from = 0;
|
||||
_to = 0;
|
||||
_verdict = null;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_error = false;
|
||||
_topology = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
_topology = await Service.GetAsync(EnergyTypeId);
|
||||
Check();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Logger.LogError(ex, "Loading the connections of energy type {EnergyTypeId} failed", EnergyTypeId);
|
||||
_error = true;
|
||||
}
|
||||
|
||||
// The dialog renders through the dialog provider: say so when the list arrives, whoever awaited it.
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private string Name(int meterId)
|
||||
{
|
||||
if (_topology?.Find(meterId) is not { } meter)
|
||||
{
|
||||
return MeterMembership.FallbackName(meterId);
|
||||
}
|
||||
|
||||
return meter.EnergyTypeId == EnergyTypeId ? meter.Name : Loc.F(S.EnergyView_OtherTypeMeter, meter.Name);
|
||||
}
|
||||
|
||||
/// <summary>The meter's name, marked when it is calculated or retired — the two things that change what a link means.</summary>
|
||||
private static string Label(MeterLinkMeter meter)
|
||||
{
|
||||
var marks = new List<string>(2);
|
||||
if (meter.IsVirtual)
|
||||
{
|
||||
marks.Add(meter.Mode.Display());
|
||||
}
|
||||
|
||||
if (!meter.IsActive)
|
||||
{
|
||||
marks.Add(S.Meters_Retired);
|
||||
}
|
||||
|
||||
return marks.Count == 0 ? meter.Name : meter.Name + " (" + string.Join(", ", marks) + ")";
|
||||
}
|
||||
|
||||
private void OnFromChanged(int id)
|
||||
{
|
||||
_from = id;
|
||||
Check();
|
||||
}
|
||||
|
||||
private void OnToChanged(int id)
|
||||
{
|
||||
_to = id;
|
||||
Check();
|
||||
}
|
||||
|
||||
private void Check() =>
|
||||
_verdict = _topology is not null && _from != 0 && _to != 0 ? _topology.CheckAdd(_from, _to) : null;
|
||||
|
||||
private async Task AddAsync()
|
||||
{
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var result = await Service.AddAsync(_from, _to);
|
||||
if (result.IsAllowed)
|
||||
{
|
||||
Snackbar.Add(Loc.F(S.EnergyView_ConnectionAdded, Name(_from), Name(_to)), Severity.Success);
|
||||
_from = 0;
|
||||
_to = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(FlowText.Refusal(result, Name, _to), Severity.Warning);
|
||||
}
|
||||
|
||||
return result.IsAllowed;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task RemoveAsync(MeterLinkEntry link)
|
||||
{
|
||||
var from = Name(link.FromMeterId);
|
||||
var to = Name(link.ToMeterId);
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var result = await Service.RemoveAsync(link.LinkId);
|
||||
Snackbar.Add(
|
||||
result.IsAllowed ? Loc.F(S.EnergyView_ConnectionRemoved, from, to) : FlowText.Refusal(result, Name, link.ToMeterId),
|
||||
result.IsAllowed ? Severity.Success : Severity.Warning);
|
||||
return result.IsAllowed;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>One change at a time; the list is read again afterwards, so it always shows what is stored.</summary>
|
||||
private async Task RunAsync(Func<Task<bool>> change)
|
||||
{
|
||||
if (_busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
_changed |= await change();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Logger.LogError(ex, "Changing a connection of energy type {EnergyTypeId} failed", EnergyTypeId);
|
||||
Snackbar.Add(S.EnergyView_ConnectionFailed, Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task OnVisibleChanged(bool visible)
|
||||
{
|
||||
if (!visible)
|
||||
{
|
||||
await CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
_open = false;
|
||||
if (_changed)
|
||||
{
|
||||
_changed = false;
|
||||
await Changed.InvokeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.mv-connections {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mv-connections__item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas: "names remove" "note remove";
|
||||
align-items: center;
|
||||
gap: 0 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--mud-palette-lines-default);
|
||||
}
|
||||
|
||||
.mv-connections__names {
|
||||
grid-area: names;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mv-connections__meter {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mv-connections__note {
|
||||
grid-area: note;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mv-connections__note:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mv-connections__item ::deep .mv-connections__remove {
|
||||
grid-area: remove;
|
||||
}
|
||||
|
||||
.mv-connections__add {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.mv-connections__select {
|
||||
flex: 1 1 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mv-connections__add ::deep .mv-connections__button {
|
||||
align-self: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.mv-connections__verdict {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
min-height: 3em;
|
||||
margin-top: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
@@ -1,196 +1,267 @@
|
||||
@page "/energy/{Id:int}"
|
||||
@inject FlowService Flow
|
||||
@inject MeterVault.Infrastructure.Costing.CostService Costs
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||
@using MeterVault.App.Energy
|
||||
@using MeterVault.App.Components.Pages.Energy
|
||||
@using MeterVault.App.Components.Shared.MeterLists
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
@inject NavigationManager Nav
|
||||
@inject InstanceClock Clock
|
||||
@inject AnalysisPeriods Periods
|
||||
@inject AnalysisReader Reader
|
||||
@inject CostReader Costs
|
||||
@inject FlowService Flow
|
||||
@inject IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ILogger<EnergyView> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MeterVault — @(_graph?.EnergyType ?? S.EnergyView_EnergyFallback)</PageTitle>
|
||||
@* One energy type (brief §7.3): its user-defined name as the title, one period toolbar for every tab, and the tabs
|
||||
Overview | History | Flow | Meters by key (tab=…, written with replace). The type is read once per period, bucket and
|
||||
comparison — its measures and every meter's own series, its bill, the comparison bill and the flow of those same
|
||||
totals — so a tab switch, the History view or the metric never reloads anything (D-46). *@
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">@Loc.F(S.EnergyView_FlowTitle, _graph?.EnergyType ?? S.EnergyView_EnergyFallback)</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
<PageHeader Title="@Title" Description="@S.EnergyView_Description">
|
||||
<Breadcrumbs>
|
||||
<AnalysisBreadcrumbs Query="_query" EnergyTypeId="Id" EnergyTypeName="@Title" />
|
||||
</Breadcrumbs>
|
||||
<Actions>
|
||||
@if (_state.Value is { Type: not null } header)
|
||||
{
|
||||
@if (header.HasGeneration)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" StartIcon="@Icons.Material.Filled.WbSunny"
|
||||
Href="@AnalysisLinks.Solar(_query)">@S.Nav_Solar</MudButton>
|
||||
}
|
||||
@if (header.HasTank)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" StartIcon="@Icons.Material.Filled.PropaneTank"
|
||||
Href="@AnalysisLinks.Consumables(_query)">@S.Nav_Consumables</MudButton>
|
||||
}
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" StartIcon="@Icons.Material.Filled.Settings"
|
||||
Href="/admin/energy-types">@S.EnergyView_EditDefinition</MudButton>
|
||||
}
|
||||
</Actions>
|
||||
</PageHeader>
|
||||
|
||||
@if (_graph is null)
|
||||
@if (_query is not null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
<PeriodToolbar Query="_query" Period="_state.Value?.Period" Plan="ToolbarPlan" Defaults="Defaults" QueryChanged="OnQueryChanged"
|
||||
ShowBucket="ShowsBuckets" ShowComparison="ShowsBuckets"
|
||||
Metrics="@(_tab == AnalysisLinks.EnergyTabHistory ? _state.Value?.Metrics : null)" NaturalMetric="NaturalMetric"
|
||||
ExportHref="@ExportHref" Class="mb-3" />
|
||||
}
|
||||
else if (_meters.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
@S.EnergyView_NoMetersIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.EnergyView_NoMetersOr
|
||||
<MudLink Href="/import">@S.Nav_Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Meters without consumption in the range still exist — a new one, or a quiet stretch — and this
|
||||
page is a natural way in to them, so the list below always renders; only the figures wait. *@
|
||||
@if (!_graph.HasData)
|
||||
|
||||
<div class="mv-energy-page">
|
||||
<LoadPanel State="_state" OnRetry="Retry" Context="analysis" PlaceholderHeight="320">
|
||||
@if (analysis.Type is null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4">@S.EnergyView_NoDataInRange</MudAlert>
|
||||
<MudAlert Severity="Severity.Warning">@S.EnergyView_NotFound</MudAlert>
|
||||
}
|
||||
else if (analysis.Meters.Count == 0)
|
||||
{
|
||||
<div class="mv-empty" role="status">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Speed" Class="mv-empty__icon" aria-hidden="true" />
|
||||
<div class="mv-empty__body">
|
||||
<MudText Typo="Typo.subtitle1">@S.EnergyView_NoMeters</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.EnergyView_NoMetersHelp</MudText>
|
||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small" Href="/meters"
|
||||
StartIcon="@Icons.Material.Filled.Add">@S.Meters_AddMeter</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" Href="/import"
|
||||
StartIcon="@Icons.Material.Filled.UploadFile">@S.Nav_Import</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.EnergyView_TopLevelThroughput</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_CostRange</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_Meters</MudText>
|
||||
<MudText Typo="Typo.h5">@_meters.Count</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-1">@S.EnergyView_Flow</MudText>
|
||||
@if (_graph.HasChain)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
|
||||
@S.EnergyView_FlowCaption
|
||||
</MudText>
|
||||
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||
@S.EnergyView_NoChainIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink> @S.EnergyView_NoChainMiddle
|
||||
<b>@S.EnergyView_NoChainUpstream</b> @S.EnergyView_NoChainRest
|
||||
</MudAlert>
|
||||
@if (_graph.Nodes.Count > 0)
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>@S.Common_Meter</th><th style="text-align:right">@S.EnergyView_ColConsumption</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
|
||||
{
|
||||
<tr>
|
||||
<td>@(node.IsOther ? Loc.F(S.Flow_OtherNode, node.Label) : node.Label)</td>
|
||||
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
<MudTabs ActivePanelIndex="AnalysisLinks.EnergyTabIndex(_tab)" ActivePanelIndexChanged="OnTabChanged"
|
||||
Elevation="0" Rounded="true" Border="false" TabPanelsClass="pt-4" Class="mv-energy-tabs">
|
||||
<MudTabPanel Text="@S.EnergyView_TabOverview">
|
||||
<EnergyOverviewTab Analysis="analysis" Query="_query!" Defaults="Defaults" OnRefresh="Retry" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@S.EnergyView_TabHistory">
|
||||
<EnergyHistoryTab Analysis="analysis" Query="_query!" Defaults="Defaults" Metric="EffectiveMetric(analysis)"
|
||||
View="@_view" ViewChanged="OnViewChanged" OnRefresh="Retry" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@S.EnergyView_TabFlow">
|
||||
<EnergyFlowTab Analysis="analysis" Query="_query!" ManageConnections="OpenConnectionsAsync" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@Loc.F(S.EnergyView_TabMeters, analysis.Meters.Count)">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2 mb-2">
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.EnergyView_MetersHelp</MudText>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" Size="Size.Small" StartIcon="@Icons.Material.Filled.Hub"
|
||||
OnClick="OpenConnectionsAsync">@S.EnergyView_ManageConnections</MudButton>
|
||||
</div>
|
||||
<MeterList Rows="MeterRows(analysis)" Query="_query" />
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
}
|
||||
</LoadPanel>
|
||||
</div>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Common_Meters</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>@S.Common_Name</th><th>@S.Common_Mode</th><th>@S.EnergyView_ColUpstreamOf</th><th style="text-align:right">@S.EnergyView_ColConsumption</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var meter in _meters)
|
||||
{
|
||||
<tr>
|
||||
<td><MudLink Href="@MeterLinks.Detail(meter.Id)">@meter.Name</MudLink></td>
|
||||
<td>@meter.Mode.Display()</td>
|
||||
<td>@UpstreamLabel(meter.Id)</td>
|
||||
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
|
||||
<td style="text-align:right">
|
||||
@if (MeterLinks.QuickEntry(meter.Id, meter.Mode) is { } entry)
|
||||
{
|
||||
<MudTooltip Text="@(meter.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)">
|
||||
<MudIconButton Icon="@(meter.Mode == MeterMode.ConsumableBalance ? Icons.Material.Filled.Straighten : Icons.Material.Filled.EditNote)"
|
||||
Size="Size.Small" Color="Color.Primary" Href="@entry"
|
||||
aria-label="@(meter.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudPaper>
|
||||
}
|
||||
<ManageConnectionsDialog @ref="_connections" EnergyTypeId="Id" EnergyTypeName="@Title" Changed="OnConnectionsChanged" />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private int _months = 60;
|
||||
private bool _loading;
|
||||
private FlowGraph? _graph;
|
||||
private double _cost;
|
||||
private List<Meter> _meters = [];
|
||||
private Dictionary<int, List<string>> _downstream = [];
|
||||
private readonly LoadSequencer _loads = new();
|
||||
private readonly LoadState<EnergyAnalysis> _state = new();
|
||||
private AnalysisQuery? _query;
|
||||
private (int Id, AnalysisQuery Query)? _loaded;
|
||||
private string _tab = AnalysisLinks.EnergyTabOverview;
|
||||
private string _view = EnergyPageKeys.ViewTotal;
|
||||
private ManageConnectionsDialog? _connections;
|
||||
private EnergyAnalysis? _rowsFor;
|
||||
private IReadOnlyList<MeterListRow> _rows = [];
|
||||
|
||||
protected override Task OnParametersSetAsync() => LoadAsync();
|
||||
/// <summary>The History defaults (last 12 months, automatic, previous year) on a page whose route names the type.</summary>
|
||||
private AnalysisDefaults Defaults => AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(Math.Max(1, Id)));
|
||||
|
||||
private async Task OnRangeChanged(int months)
|
||||
private string Title => _state.Value is { EnergyTypeId: var shown, Type: { } type } && shown == Id ? type.Name : S.EnergyView_EnergyFallback;
|
||||
|
||||
/// <summary>The interval and the comparison change only the Overview's and the History's figures.</summary>
|
||||
private bool ShowsBuckets => _tab is AnalysisLinks.EnergyTabOverview or AnalysisLinks.EnergyTabHistory;
|
||||
|
||||
private BucketPlan? ToolbarPlan => _state.Value is { } value ? value.RefusedPlan ?? value.Quantities?.Plan : null;
|
||||
|
||||
private AnalysisMetric? NaturalMetric => _state.Value is { Metrics.Count: > 0 } value ? value.Metrics[0] : null;
|
||||
|
||||
/// <summary>
|
||||
/// The History's CSV (D-55): the type's measures of the metric, or — in the individual view — the meters it charts.
|
||||
/// </summary>
|
||||
private string? ExportHref
|
||||
{
|
||||
_months = months;
|
||||
await LoadAsync();
|
||||
get
|
||||
{
|
||||
if (_tab != AnalysisLinks.EnergyTabHistory || _query is null || Id <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var metric = _state.Value is { } value ? EffectiveMetric(value) : _query.Metric;
|
||||
var scope = QueryScope.ForEnergyType(Id);
|
||||
if (_view == EnergyPageKeys.ViewMeters && metric is { } m && m.IsQuantity()
|
||||
&& EnergyMetrics.MetersOf(_state.Value?.Quantities, m).Take(AnalysisLimits.MaxSeries).Select(s => s.MeterId!.Value).ToList() is { Count: > 0 } shown)
|
||||
{
|
||||
scope = QueryScope.ForMeters(shown);
|
||||
}
|
||||
|
||||
return AnalysisLinks.Export(_query.WithScope(scope).WithMetric(metric));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
|
||||
|
||||
protected override Task OnParametersSetAsync() => SyncAsync();
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () =>
|
||||
{
|
||||
if (_loading)
|
||||
// Only this page's own address: a link away fires this too, just before the page goes.
|
||||
if (!IsThisPage(e.Location))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_graph = null;
|
||||
try
|
||||
{
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = new DateOnly(asOf.AddMonths(-_months).Year, asOf.AddMonths(-_months).Month, 1);
|
||||
var to = asOf.AddMonths(1);
|
||||
var typeId = (short)Id;
|
||||
await SyncAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
// Assigned last: the page branches on the graph being loaded, and must not render it
|
||||
// against the previous type's (or an empty) meter list in between.
|
||||
var graph = await Flow.GetFlowAsync(typeId, from, to);
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync();
|
||||
var links = await db.MeterLinks.AsNoTracking()
|
||||
.Where(l => _meters.Select(m => m.Id).Contains(l.FromMeterId))
|
||||
.ToListAsync();
|
||||
var names = _meters.ToDictionary(m => m.Id, m => m.Name);
|
||||
_downstream = links
|
||||
.GroupBy(l => l.FromMeterId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList());
|
||||
|
||||
var zone = MeterVault.Infrastructure.Options.InstanceTimeZone.Resolve(Options.Value.TimeZone);
|
||||
var fromUtc = MeterVault.Infrastructure.Options.InstanceTimeZone.StartOf(from, zone);
|
||||
var toUtc = MeterVault.Infrastructure.Options.InstanceTimeZone.StartOf(to, zone);
|
||||
double cost = 0;
|
||||
foreach (var meter in _meters)
|
||||
{
|
||||
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
|
||||
}
|
||||
_cost = cost;
|
||||
_graph = graph;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
private bool IsThisPage(string uri)
|
||||
{
|
||||
var path = Nav.ToBaseRelativePath(uri);
|
||||
var end = path.IndexOfAny(['?', '#']);
|
||||
path = (end >= 0 ? path[..end] : path).TrimEnd('/');
|
||||
return string.Equals(path, "energy/" + Id.ToString(System.Globalization.CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private double NodeValue(int meterId) => _graph?.Nodes.FirstOrDefault(n => n.MeterId == meterId)?.Value ?? 0;
|
||||
/// <summary>Reads the address: the tab and view always, the analysis only when period, bucket or comparison changed.</summary>
|
||||
private async Task SyncAsync()
|
||||
{
|
||||
(_tab, _view) = EnergyPageKeys.Parse(Nav.Uri);
|
||||
var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
|
||||
_query = query;
|
||||
|
||||
private string UpstreamLabel(int meterId) =>
|
||||
_downstream.TryGetValue(meterId, out var children) && children.Count > 0 ? string.Join(", ", children) : "—";
|
||||
var key = (Id, LoadKey(query));
|
||||
if (_loaded == key)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_loaded?.Id != Id)
|
||||
{
|
||||
// Another type: its figures must not show under this title, not even dimmed.
|
||||
_state.Clear();
|
||||
}
|
||||
|
||||
_loaded = key;
|
||||
await LoadAsync(query);
|
||||
}
|
||||
|
||||
/// <summary>What the read depends on: the metric only picks what History charts, the scope is the route's.</summary>
|
||||
private AnalysisQuery LoadKey(AnalysisQuery query) => EnergyAnalysisLoader.LoadKey(query, Id);
|
||||
|
||||
private Task Retry() => _query is null ? Task.CompletedTask : LoadAsync(_query);
|
||||
|
||||
private Task LoadAsync(AnalysisQuery query)
|
||||
{
|
||||
var id = Id;
|
||||
return _loads.RunAsync(_state, token => ReadAsync(id, query, token), Logger);
|
||||
}
|
||||
|
||||
private Task<EnergyAnalysis> ReadAsync(int id, AnalysisQuery query, CancellationToken token) =>
|
||||
new EnergyAnalysisLoader(DbFactory, Periods, Reader, Costs, Flow).LoadAsync(id, query, Clock.Now, token);
|
||||
|
||||
/// <summary>The metric History charts: the address's when the type has it, else the type's first (consumption first).</summary>
|
||||
private AnalysisMetric EffectiveMetric(EnergyAnalysis analysis) => EnergyMetrics.Effective(_query?.Metric, analysis.Metrics);
|
||||
|
||||
private IReadOnlyList<MeterListRow> MeterRows(EnergyAnalysis analysis)
|
||||
{
|
||||
// Built once per committed value: the list keeps its search and filter across renders.
|
||||
if (!ReferenceEquals(_rowsFor, analysis))
|
||||
{
|
||||
_rowsFor = analysis;
|
||||
_rows = MeterListRows.Build(analysis.Meters, analysis.Quantities);
|
||||
}
|
||||
|
||||
return _rows;
|
||||
}
|
||||
|
||||
private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
|
||||
|
||||
/// <summary>A tab click writes <c>tab=</c> (replace, D-46); the Overview is the default and is not written.</summary>
|
||||
private void OnTabChanged(int index)
|
||||
{
|
||||
var tab = index >= 0 && index < AnalysisLinks.EnergyTabs.Count ? AnalysisLinks.EnergyTabs[index] : AnalysisLinks.EnergyTabOverview;
|
||||
if (tab == _tab)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_tab = tab;
|
||||
Nav.NavigateTo(Nav.GetUriWithQueryParameter(EnergyPageKeys.Tab, tab == AnalysisLinks.EnergyTabOverview ? null : tab), replace: true);
|
||||
}
|
||||
|
||||
private void OnViewChanged(string view)
|
||||
{
|
||||
if (view == _view)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_view = view;
|
||||
Nav.NavigateTo(Nav.GetUriWithQueryParameter(EnergyPageKeys.View, view == EnergyPageKeys.ViewTotal ? null : view), replace: true);
|
||||
}
|
||||
|
||||
private Task OpenConnectionsAsync() => _connections?.OpenAsync() ?? Task.CompletedTask;
|
||||
|
||||
/// <summary>The topology changed: the measures, the bill and the flow may classify differently, so read again.</summary>
|
||||
private Task OnConnectionsChanged() => Retry();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Nav.LocationChanged -= OnLocationChanged;
|
||||
_loads.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/* Four tabs fit a phone: plain case and tighter padding below 600px, so none of them is cut off or scrolled away. */
|
||||
@media (max-width: 599.98px) {
|
||||
.mv-energy-page ::deep .mv-energy-tabs .mud-tab {
|
||||
/* MudTabs sets the tab's minimum width inline. */
|
||||
min-width: 0 !important;
|
||||
padding: 6px 8px;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
@* A record dated after now (D-04): the record tabs list the whole named range, so a row stamped later this month — a
|
||||
current-month label, a device clock ahead — is shown, and marked in words, since it is not counted in any actual yet. *@
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning" Class="ml-1 mv-after-now"
|
||||
title="@S.MeterDetail_AfterNowHint">@S.MeterDetail_AfterNow</MudChip>
|
||||
@@ -0,0 +1,393 @@
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@inject MeterDetailService Details
|
||||
@inject IServiceScopeFactory Scopes
|
||||
@inject ISnackbar Snackbar
|
||||
@inject InstanceClock Clock
|
||||
@inject ILogger<ManualReadingDialog> Logger
|
||||
|
||||
@* Big touch targets and tabular digits for the manual-reading dialog: it is used standing at a
|
||||
meter on a phone, where the default input sizes are fiddly. *@
|
||||
<style>
|
||||
.mv-reading-value input { font-size: 1.9rem; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
/* nowrap pins it to exactly two lines, so a long unit or a big delta cannot spill over and
|
||||
move the keypad; the full wording is repeated in the alert below the fold. */
|
||||
.mv-reading-verdict { display: flex; flex-direction: column; min-height: 2.6rem; }
|
||||
.mv-reading-verdict > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mv-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; }
|
||||
.mv-keypad .mud-button { height: 56px; font-size: 1.35rem; }
|
||||
</style>
|
||||
|
||||
<MudDialog Visible="_open" VisibleChanged="OnVisibleChanged" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@Loc.F(S.MeterDetail_AddReadingTitle, MeterName)</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@LastReadingCaption()</MudText>
|
||||
|
||||
<MudTextField T="string" Value="_entry.Text" ValueChanged="OnReadingTyped" Immediate="true"
|
||||
Label="@Loc.F(S.MeterDetail_ReadingLabel, Unit)" Variant="Variant.Outlined"
|
||||
InputMode="DecimalKeyboard" Class="mv-reading-value" Clearable="true" />
|
||||
|
||||
@* Fixed height, and above the keypad on purpose. The verdict on a value has to be visible
|
||||
while it is being typed — the keypad pushes anything below it off a phone screen — but
|
||||
anything that grows or shrinks here would move the keys out from under the user's
|
||||
thumb mid-entry. So the slot is always the same size whether or not it says anything. *@
|
||||
<div class="mv-reading-verdict mt-1 mb-3">
|
||||
<MudText Typo="Typo.caption" Color="@(_entry.Value is null ? Color.Error : Color.Secondary)">
|
||||
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {Unit}" : S.MeterDetail_EnterValue)
|
||||
</MudText>
|
||||
@if (Verdict.WouldBeRejected)
|
||||
{
|
||||
@* Names the likely cause, but deliberately is not a button: this line shows for most of
|
||||
an ordinary entry (every prefix of 12351 is below 12345) and sits just above the
|
||||
keypad, so a slightly high tap on the top keys would leave the reading mid-entry. The
|
||||
swap and reset buttons are in the alert below and on the rejection message. *@
|
||||
<MudText Typo="Typo.caption" Color="Color.Warning">@S.MeterDetail_SwappedOrResetHint</MudText>
|
||||
}
|
||||
else if (Verdict.ChangeSincePrevious is { } change)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@ChangeSinceText(change)</MudText>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mv-keypad mb-3">
|
||||
@foreach (var key in Keypad)
|
||||
{
|
||||
var pressed = key;
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@(() => PressKey(pressed))">@pressed</MudButton>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
|
||||
<MudDatePicker Date="_when.Date" DateChanged="OnDateChangedAsync" Label="@S.Common_Date" Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" Style="min-width:150px" />
|
||||
<MudTimePicker Time="_when.TimeOfDay" TimeChanged="OnTimeChangedAsync" Label="@S.MeterDetail_TimeOfDay" Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" Style="min-width:130px" />
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNowAsync">@S.Common_Now</MudButton>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">@Loc.F(S.MeterDetail_LocalTimeIn, Zone.Id)</MudText>
|
||||
|
||||
@* Everything below here can reflow freely: the dialog's buttons sit outside this scroll
|
||||
area, so nothing the user is aiming at moves. *@
|
||||
@if (_when.IsSkipped)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">
|
||||
@Loc.F(S.MeterDetail_SkippedTime, Zone.Id)
|
||||
</MudAlert>
|
||||
}
|
||||
@if (Verdict is { WouldBeRejected: true, Previous: { } previous })
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
|
||||
@Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(previous.Value, 2), Unit)
|
||||
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.SwapHoriz"
|
||||
OnClick="@(() => SwitchToEventAsync(MeterEventType.MeterSwap))">@S.MeterDetail_RecordSwap</MudButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.RestartAlt"
|
||||
OnClick="@(() => SwitchToEventAsync(MeterEventType.CounterReset))">@S.MeterDetail_RecordReset</MudButton>
|
||||
</div>
|
||||
</MudAlert>
|
||||
}
|
||||
@if (Verdict.ReplacesRegisterStart)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_ReplaceSwapStartNotice</MudAlert>
|
||||
}
|
||||
else if (Verdict.ReplacesReading)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
@S.MeterDetail_ReplaceNotice
|
||||
</MudAlert>
|
||||
}
|
||||
@if (Verdict.IsFuture)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_FutureTime</MudAlert>
|
||||
}
|
||||
else if (Verdict.IsBackdated)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
@S.MeterDetail_BackdatedNotice
|
||||
</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Close" Disabled="_saving">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
|
||||
OnClick="SaveAsync" Disabled="@(!CanSave)">
|
||||
@(_saving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading)
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
/// <summary>The meter the reading is for.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public int MeterId { get; set; }
|
||||
|
||||
/// <summary>Its name, for the title (user data).</summary>
|
||||
[Parameter]
|
||||
public string MeterName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The raw unit of the register (readings are raw values).</summary>
|
||||
[Parameter]
|
||||
public string Unit { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The instance zone the date and time are typed in.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public TimeZoneInfo Zone { get; set; } = TimeZoneInfo.Utc;
|
||||
|
||||
/// <summary>Raised after a reading was stored (the meter is renormalized already).</summary>
|
||||
[Parameter]
|
||||
public EventCallback Saved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user leaves for a swap or reset at the entered time; the typed value is kept for
|
||||
/// <see cref="ResumeAsync"/>.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<(MeterEventType Type, DateTimeOffset? At)> SwitchToEvent { get; set; }
|
||||
|
||||
/// <summary>Phone-dialpad order, ending in the row the thumb reaches last: separator, zero, backspace.</summary>
|
||||
private static readonly string[] Keypad = ["7", "8", "9", "4", "5", "6", "1", "2", "3", ",", "0", "⌫"];
|
||||
|
||||
/// <summary>
|
||||
/// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because
|
||||
/// <c>decimal</c> is a C# keyword and Razor would read the required <c>@</c> escape in an
|
||||
/// attribute as a transition.
|
||||
/// </summary>
|
||||
private const InputMode DecimalKeyboard = InputMode.@decimal;
|
||||
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
private readonly ReadingEntry _entry = new();
|
||||
private LocalTimeEntry _when = new(TimeZoneInfo.Utc);
|
||||
private bool _open;
|
||||
private bool _saving;
|
||||
|
||||
/// <summary>The context for the instant the pickers show (D-50); versioned, as the pickers move faster than queries return.</summary>
|
||||
private ReadingEntryContext? _context;
|
||||
private int _contextVersion;
|
||||
|
||||
/// <summary>
|
||||
/// A reading typed before the user detoured into recording a swap. It is handed back to this dialog once the swap
|
||||
/// is saved or abandoned, so the detour costs no retyping.
|
||||
/// </summary>
|
||||
private (string Text, DateTimeOffset? At)? _resume;
|
||||
|
||||
/// <summary>True while a typed reading waits for the swap or reset the user left to record.</summary>
|
||||
public bool HasPendingResume => _resume is not null;
|
||||
|
||||
private ReadingEntryVerdict Verdict => ReadingEntryVerdict.Of(_context, _entry.Value, _when.Utc, Clock.Now);
|
||||
|
||||
private bool CanSave => !_saving && _entry.Value is not null && _when.WallClock is not null && !_when.IsSkipped;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(_when.Zone, Zone))
|
||||
{
|
||||
_when = new LocalTimeEntry(Zone);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the dialog at now, prefilled with the meter's latest reading (or its baseline while it has none) — a register
|
||||
/// only moves in its final digits, so backspace-and-retype beats keying six digits from scratch.
|
||||
/// </summary>
|
||||
public async Task OpenAsync()
|
||||
{
|
||||
_resume = null;
|
||||
_entry.Clear();
|
||||
_when.Set(Clock.Now);
|
||||
await LoadContextAsync();
|
||||
_entry.Prefill(_context?.Latest?.Value ?? _context?.InitialBaseline ?? 0);
|
||||
_open = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Reopens the dialog with the reading typed before the swap/reset detour, at its time.</summary>
|
||||
public async Task ResumeAsync()
|
||||
{
|
||||
if (_resume is not { } resume)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_resume = null;
|
||||
_entry.SetText(resume.Text);
|
||||
if (resume.At is { } at)
|
||||
{
|
||||
_when.Set(at);
|
||||
}
|
||||
else
|
||||
{
|
||||
_when.Set(Clock.Now);
|
||||
}
|
||||
|
||||
await LoadContextAsync();
|
||||
_open = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Forgets a typed reading waiting for a detour (the user went on to something else).</summary>
|
||||
public void DropResume() => _resume = null;
|
||||
|
||||
/// <summary>Closes the dialog without saving.</summary>
|
||||
public void Close()
|
||||
{
|
||||
_open = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnVisibleChanged(bool visible)
|
||||
{
|
||||
if (!visible)
|
||||
{
|
||||
_open = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnReadingTyped(string? value) => _entry.SetText(value);
|
||||
|
||||
private void PressKey(string key)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "⌫":
|
||||
_entry.Backspace();
|
||||
break;
|
||||
case ",":
|
||||
_entry.AppendSeparator();
|
||||
break;
|
||||
default:
|
||||
_entry.AppendDigit(key[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnDateChangedAsync(DateTime? date)
|
||||
{
|
||||
_when.Date = date;
|
||||
await LoadContextAsync();
|
||||
}
|
||||
|
||||
private async Task OnTimeChangedAsync(TimeSpan? time)
|
||||
{
|
||||
_when.TimeOfDay = time;
|
||||
await LoadContextAsync();
|
||||
}
|
||||
|
||||
private async Task SetNowAsync()
|
||||
{
|
||||
_when.Set(Clock.Now);
|
||||
await LoadContextAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads what the entry at the chosen instant is judged against (D-50). Versioned: a late answer for an earlier time
|
||||
/// must not overwrite a newer one. A failure leaves no verdict, and the save still goes through the guard.
|
||||
/// </summary>
|
||||
private async Task LoadContextAsync()
|
||||
{
|
||||
if (_when.Utc is not { } utc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var version = ++_contextVersion;
|
||||
try
|
||||
{
|
||||
var context = await Details.GetReadingEntryContextAsync(MeterId, utc);
|
||||
if (version == _contextVersion)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Logger.LogWarning(ex, "Reading the entry context of meter {MeterId} failed", MeterId);
|
||||
}
|
||||
}
|
||||
|
||||
private string LastReadingCaption() => _context switch
|
||||
{
|
||||
{ Latest: { } latest } => Loc.F(S.MeterDetail_LastReadingCaption,
|
||||
Format.Number(latest.Value, 2), Unit, _when.Local(latest.Time).ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)),
|
||||
{ } context => Loc.F(S.MeterDetail_NoReadingsPrefill, Format.Number(context.InitialBaseline, 2), Unit),
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private string ChangeSinceText(double change) =>
|
||||
Math.Abs(change) < 1e-9
|
||||
? S.MeterDetail_NoChangeSinceLast
|
||||
: Loc.F(S.MeterDetail_ChangeSinceLast, $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)}", Unit);
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_entry.Value is not { } value || _when.Utc is not { } utc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
// A scope per operation: IngestionService holds a scoped DbContext, and a Blazor circuit
|
||||
// long outlives the unit of work a single save should share one with.
|
||||
await using var scope = Scopes.CreateAsyncScope();
|
||||
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||
IngestionOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = await ingestion.IngestByMeterAsync(MeterId, utc, value, renormalize: true, quality: ReadingQuality.Manual);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Logger.LogError(ex, "Saving a manual reading on meter {MeterId} failed", MeterId);
|
||||
Snackbar.Add(S.MeterDetail_ReadingFailed, Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (outcome)
|
||||
{
|
||||
case IngestionOutcome.Written:
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_ReadingSaved, Format.Number(value, 2), Unit), Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.Updated:
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_ReadingReplaced, Format.Number(value, 2), Unit), Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.RejectedDecrease:
|
||||
// Leave the dialog open with the value still on screen, and offer the fix right on
|
||||
// the message: recording a swap or reset is a decision, not a retry.
|
||||
Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error, config =>
|
||||
{
|
||||
config.Action = S.MeterDetail_RecordSwap;
|
||||
config.ActionColor = Color.Inherit;
|
||||
config.OnClick = _ => InvokeAsync(() => SwitchToEventAsync(MeterEventType.MeterSwap));
|
||||
});
|
||||
await LoadContextAsync();
|
||||
return;
|
||||
default:
|
||||
Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_open = false;
|
||||
_resume = null;
|
||||
await Saved.InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// From this dialog into the swap/reset dialog, at the time the reading was being entered — the latest the swap can
|
||||
/// have happened. The typed value is kept and handed back afterwards (<see cref="ResumeAsync"/>).
|
||||
/// </summary>
|
||||
private async Task SwitchToEventAsync(MeterEventType type)
|
||||
{
|
||||
_resume = (_entry.Text, _when.Utc);
|
||||
_open = false;
|
||||
await SwitchToEvent.InvokeAsync((type, _resume.Value.At));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@* The meter's Analysis tab (brief §7.2): the shared period toolbar with CSV export, the period's quantity (normalized
|
||||
unit, D-20) and cost (with its rule named, or why there is none), the projection kept apart from the actual (D-09),
|
||||
the full-size chart with the comparison overlay, the accessible table, a click on a bucket drilling down (D-51), and
|
||||
what qualifies the figures: coverage, resolution, opening balance, rows after now, freshness, and the events and
|
||||
tariff changes inside the range. A virtual meter gets the same from its formula, with its sources' contributions. *@
|
||||
|
||||
<PeriodToolbar Query="Query" Period="Current?.Period" Plan="Current?.Quantities.Plan" Defaults="Defaults"
|
||||
QueryChanged="OnQueryChanged" ExportHref="@ExportHref"
|
||||
Metrics="Current?.Metrics" NaturalMetric="Current?.QuantityMetric" Class="mb-3" />
|
||||
|
||||
<LoadPanel State="State" OnRetry="OnRetry" Context="r">
|
||||
@if (r.MeterId == Detail.Id)
|
||||
{
|
||||
var s = r.Series;
|
||||
@if (s is null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true">@Loc.F(S.MeterDetail_NotFound, Detail.Id)</MudAlert>
|
||||
}
|
||||
else if (r.Quantities.Refusal != AnalysisRefusal.None)
|
||||
{
|
||||
@* The toolbar above says why (too many points) and offers the coarser bucket. *@
|
||||
}
|
||||
else
|
||||
{
|
||||
<AttentionList Problems="ProblemsOf(r)" CostAttention="r.Cost?.Attention" Names="r.AttentionNames"
|
||||
Query="Query" MaxItems="4" Class="mb-3" />
|
||||
|
||||
@if (s.IsPending)
|
||||
{
|
||||
<PendingState OnRefresh="OnRetry" />
|
||||
}
|
||||
else if (s.Total.Status == BucketStatus.Invalid)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mb-3">
|
||||
@Loc.F(S.MeterDetail_CalculationNotEvaluable, (s.Virtual?.Status ?? VirtualMeterStatus.Invalid).Display())
|
||||
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Functions"
|
||||
OnClick="OnEdit">@S.MeterDetail_EditCalculation</MudButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text"
|
||||
Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabCalculation, null, Query)">@S.MeterDetail_ShowCalculation</MudButton>
|
||||
</div>
|
||||
</MudAlert>
|
||||
}
|
||||
else if (r.Quantities.NotYetOccurred || s.Total.Status == BucketStatus.Missing)
|
||||
{
|
||||
<EmptyPeriodState NotYetOccurred="r.Quantities.NotYetOccurred" Availability="s.Availability" LatestHref="@LatestHref(s)" Class="mb-3">
|
||||
@if (s.Availability is null)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@NextStepText</MudText>
|
||||
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||||
@if (MeterEventRules.TakesReadings(Detail.Mode))
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.EditNote"
|
||||
OnClick="OnAddReading">@S.MeterDetail_AddFirstReading</MudButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.SettingsInputComponent"
|
||||
Href="@MeterLinks.Source(Detail.Id)">@S.MeterDetail_ConnectSource</MudButton>
|
||||
}
|
||||
else if (Detail.Mode == MeterMode.ConsumableBalance)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Straighten"
|
||||
OnClick="@(() => OnRecordEvent.InvokeAsync(MeterEventType.TankLevel))">@S.MeterDetail_RecordTankLevel</MudButton>
|
||||
}
|
||||
else if (Detail.IsVirtual)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Functions"
|
||||
Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabCalculation, null, Query)">@S.MeterDetail_ShowCalculation</MudButton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</EmptyPeriodState>
|
||||
@if (Detail.IsVirtual)
|
||||
{
|
||||
<SeriesContributions Series="s" Query="Query" Class="mt-3" />
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="2" Class="mb-1">
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MetricCard Title="@s.Kind.Display()" Value="s.Total" Unit="@s.Unit" Caption="@Format.PeriodRange(r.Period)"
|
||||
Change="s.Comparison?.Change" Polarity="ChangePolarities.For(s.Kind)"
|
||||
ChangeCaption="@ComparisonCaption">
|
||||
@if (r.Projection is { } projection)
|
||||
{
|
||||
<ProjectionNote Days="projection.Days" ValueText="@Format.Quantity(projection.Value, projection.Unit)" Class="mt-2" />
|
||||
}
|
||||
</MetricCard>
|
||||
</MudItem>
|
||||
@if (r.Cost is { } cost)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
@if (r.IsCosted)
|
||||
{
|
||||
<MetricCard Title="@S.MeterDetail_CostTitle" Cost="cost.Total" Currency="@cost.Currency"
|
||||
Caption="@CostRuleText(cost)"
|
||||
Change="CostChanges.ForCard(r.CostChange)" Polarity="CostChanges.Polarity(r.CostChange)"
|
||||
ChangeCaption="@CostChanges.Caption(Query, r.CostChange)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-metric">
|
||||
<MudText Typo="Typo.overline" Class="mv-muted mv-metric__title">@S.MeterDetail_CostTitle</MudText>
|
||||
<div class="mv-metric__value-row">
|
||||
<span class="mv-metric__value mv-metric__value--words">@MeterCostRule.None.Display()</span>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Class="mv-muted">@((cost.Meter?.NotCosted ?? MeterNotCostedReason.None).Display())</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
<ComparisonSummary Period="r.Period" Resolution="r.Quantities.Comparison?.Resolution" Matched="s.Comparison?.Matched" Class="mb-2" />
|
||||
|
||||
@* A click leads somewhere or is not offered (D-51): the chart is clickable, the table has its drill column and
|
||||
the hint shows only when some bucket opens something. *@
|
||||
var drills = r.Quantities.Plan.Buckets.Any(b => DrillHref(b, r, s) is not null);
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3">
|
||||
@if (drills)
|
||||
{
|
||||
<AnalysisChart Buckets="r.Quantities.Plan.Buckets" Series="r.Chart" ComparisonPairs="r.Quantities.Comparison?.Buckets"
|
||||
Title="@ChartTitle(r, s)" Height="340" OnBucketClick="b => Drill(b, r, s)"
|
||||
Resolution="s.Resolution" OnUseBucket="UseBucketAsync" />
|
||||
<MudText Typo="Typo.caption" Class="mv-muted">@DrillHint(s)</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<AnalysisChart Buckets="r.Quantities.Plan.Buckets" Series="r.Chart" ComparisonPairs="r.Quantities.Comparison?.Buckets"
|
||||
Title="@ChartTitle(r, s)" Height="340" Resolution="s.Resolution" OnUseBucket="UseBucketAsync" />
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<AnalysisTable Buckets="r.Quantities.Plan.Buckets" Series="r.Table" ComparisonPairs="r.Quantities.Comparison?.Buckets"
|
||||
DrillHref="@(drills ? b => DrillHref(b, r, s) : null)" Caption="@ChartTitle(r, s)" />
|
||||
|
||||
@if (Detail.IsVirtual)
|
||||
{
|
||||
<MudText Typo="Typo.h6" Class="mt-5 mb-1">@S.MeterDetail_SourcesOfCalculation</MudText>
|
||||
<SeriesContributions Series="s" Query="Query" />
|
||||
}
|
||||
}
|
||||
|
||||
<MeterCoverageNote View="r" Detail="Detail" Query="Query" OnEdit="OnEdit" Class="mt-5" />
|
||||
}
|
||||
}
|
||||
</LoadPanel>
|
||||
|
||||
@code {
|
||||
/// <summary>The meter.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public MeterDetailView Detail { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis state.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Query { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis load (owned by the page, so switching tabs never reloads it, D-46).</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public LoadState<MeterAnalysisView> State { get; set; } = null!;
|
||||
|
||||
/// <summary>A toolbar choice: the page writes it into its address (replace).</summary>
|
||||
[Parameter]
|
||||
public EventCallback<AnalysisQuery> OnQueryChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnRetry { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnAddReading { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<MeterEventType> OnRecordEvent { get; set; }
|
||||
|
||||
/// <summary>Opens the shared meter editor (install date, calculation).</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnEdit { get; set; }
|
||||
|
||||
private AnalysisDefaults Defaults => MeterAnalysisLoader.DefaultsFor(Detail.Id);
|
||||
|
||||
/// <summary>The committed value when it is this meter's.</summary>
|
||||
private MeterAnalysisView? Current => State.Value is { } value && value.MeterId == Detail.Id ? value : null;
|
||||
|
||||
private string ExportHref => AnalysisLinks.Export(MeterAnalysisLoader.ForMeter(Query, Detail.Id));
|
||||
|
||||
private string? ComparisonCaption => Query.Comparison.Kind == ComparisonKind.None ? null : Query.Comparison.Display();
|
||||
|
||||
private string NextStepText => Detail.Mode switch
|
||||
{
|
||||
MeterMode.ConsumableBalance => S.MeterDetail_NextStepTank,
|
||||
MeterMode.Virtual => S.MeterDetail_NextStepVirtual,
|
||||
_ => S.MeterDetail_NextStepCounter,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The readers' problems for the attention list. Rows recorded after now are explained with their dates and amount in
|
||||
/// the coverage section below, so they are not listed twice.
|
||||
/// </summary>
|
||||
private static IEnumerable<AnalysisProblem> ProblemsOf(MeterAnalysisView view) =>
|
||||
view.Quantities.Problems.Concat(view.Cost?.QuantityProblems ?? [])
|
||||
.Where(p => p.Kind != AnalysisProblemKind.RecordedAfterNow);
|
||||
|
||||
private string CostRuleText(Infrastructure.Costing.CostAnalysis cost) =>
|
||||
Loc.F(S.MeterDetail_CostRule, (cost.Meter?.Rule ?? MeterCostRule.None).Display());
|
||||
|
||||
private static string ChartTitle(MeterAnalysisView view, AnalysisSeries series) =>
|
||||
view.ShowsCost
|
||||
? Loc.F(S.MeterDetail_CostOf, AnalysisChartSeries.NameOf(series))
|
||||
: Loc.F(S.MeterDetail_ChartTitle, series.Kind.Display(), AnalysisChartSeries.NameOf(series));
|
||||
|
||||
private string DrillHint(AnalysisSeries series) =>
|
||||
Detail.IsVirtual ? S.MeterDetail_DrillHintVirtual : S.MeterDetail_DrillHint;
|
||||
|
||||
private string? LatestHref(AnalysisSeries series) =>
|
||||
AnalysisNavigation.LatestData(Query, series.Availability) is { } latest ? MeterLinks.Analysis(Detail.Id, latest) : null;
|
||||
|
||||
/// <summary>
|
||||
/// Where a bucket leads (D-51, <see cref="MeterDrill"/>): the next finer size the data resolves; otherwise a physical
|
||||
/// meter's records of the bucket, or a virtual meter's own analysis over just that bucket, whose source contributions
|
||||
/// link on to each source's records — never a dead end.
|
||||
/// </summary>
|
||||
private string? DrillHref(AnalysisBucket bucket, MeterAnalysisView view, AnalysisSeries series) =>
|
||||
MeterDrill.Href(Detail.Id, Detail.IsVirtual, Query, view.Period, bucket, series.Resolution);
|
||||
|
||||
/// <summary>A chart click drills down, pushing a history entry so Back returns to the range it came from (D-46).</summary>
|
||||
private void Drill(AnalysisBucket bucket, MeterAnalysisView view, AnalysisSeries series)
|
||||
{
|
||||
if (DrillHref(bucket, view, series) is { } href)
|
||||
{
|
||||
Nav.NavigateTo(href);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The buckets are finer than the data: apply the interval that shows it (the page replaces its address).</summary>
|
||||
private Task UseBucketAsync(BucketSize size) => OnQueryChanged.InvokeAsync(Query.WithBucket(size));
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
@implements IDisposable
|
||||
@inject MeterDetailService Details
|
||||
@inject ILogger<MeterCalculationTab> Logger
|
||||
|
||||
@* A virtual meter's calculation (brief §5.1, D-25 – D-31): its status (valid, legacy — confirm, needs configuration,
|
||||
invalid), the formula with each m<id> token beside the meter's name, what it yields and in which unit, its cost rule,
|
||||
the meters it reads (linked to their own analysis), and every validation problem with the meters involved — plus the
|
||||
one action that fixes them: Edit calculation, in the shared meter editor. It replaces Sources (a calculation has no
|
||||
ingest), and register details never show here. *@
|
||||
|
||||
<LoadPanel State="_state" OnRetry="LoadAsync" Context="calc" PlaceholderHeight="160">
|
||||
@if (calc.MeterId == Detail.Id)
|
||||
{
|
||||
<div class="d-flex align-center flex-wrap mb-3" style="gap:.5rem 1rem">
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
|
||||
Color="@(calc.Status == VirtualMeterStatus.Valid ? Color.Success : calc.Status is VirtualMeterStatus.Legacy ? Color.Info : Color.Error)"
|
||||
Icon="@(calc.Status == VirtualMeterStatus.Valid ? Icons.Material.Outlined.CheckCircle : Icons.Material.Outlined.Info)">
|
||||
@calc.Status.Display()
|
||||
</MudChip>
|
||||
<MudText Typo="Typo.body2" Class="flex-grow-1" Style="min-width:12rem">@StatusText(calc.Status)</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Functions" OnClick="OnEdit">
|
||||
@S.MeterDetail_EditCalculation
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">@S.Contributions_Formula</MudText>
|
||||
@if (FormulaText.Split(calc.Expression) is { Count: > 0 } segments)
|
||||
{
|
||||
<div class="mv-formula mb-3" style="flex-wrap:wrap">
|
||||
@foreach (var segment in segments)
|
||||
{
|
||||
@if (segment.MeterId is { } id)
|
||||
{
|
||||
<span class="mv-formula__ref">
|
||||
@if (calc.NameOf(id) is { } name)
|
||||
{
|
||||
<MudLink Href="@MeterLinks.Analysis(id, Query)" Typo="Typo.body2">@name</MudLink>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="mv-unknown">@S.MeterDetail_UnknownMeter</span>
|
||||
}
|
||||
<code>@segment.Text</code>
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<code>@segment.Text</code>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.MeterDetail_NoFormula</MudText>
|
||||
}
|
||||
|
||||
<dl class="mv-calc-facts">
|
||||
<dt>@S.MeterDetail_Result</dt>
|
||||
<dd>@Loc.F(S.MeterDetail_QuantityIn, calc.Kind.Display(), calc.Unit)</dd>
|
||||
<dt>@S.MeterDetail_CostRuleLabel</dt>
|
||||
<dd>
|
||||
@calc.CostRule.Display()
|
||||
@if (calc.CostRuleProblem is not null && calc.DeclaredCostRule is { } declared && declared != calc.CostRule)
|
||||
{
|
||||
<div class="mv-cell-secondary">@Loc.F(S.MeterDetail_CostRuleIgnored, declared.Display())</div>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</MudPaper>
|
||||
|
||||
@if (calc.Problems.Count > 0 || calc.CostRuleProblem is not null || calc.Legacy is { NeedsConfiguration: true })
|
||||
{
|
||||
<MudText Typo="Typo.h6" Class="mb-1">@S.MeterDetail_CalcProblems</MudText>
|
||||
<ul class="mv-calc-problems mb-4">
|
||||
@if (calc.Legacy is { NeedsConfiguration: true } legacy)
|
||||
{
|
||||
<li>
|
||||
<MudIcon Icon="@Icons.Material.Outlined.ErrorOutline" Size="Size.Small" aria-hidden="true" />
|
||||
<span>@LegacyText(legacy)</span>
|
||||
<MeterPath Ids="legacy.MeterIds" Calculation="calc" Query="Query" />
|
||||
</li>
|
||||
}
|
||||
@foreach (var problem in calc.Problems.Concat(calc.CostRuleProblem is { } p ? [p] : []))
|
||||
{
|
||||
<li>
|
||||
<MudIcon Icon="@Icons.Material.Outlined.ErrorOutline" Size="Size.Small" aria-hidden="true" />
|
||||
<span>@ProblemText(problem)</span>
|
||||
<MeterPath Ids="problem.MeterIds" Calculation="calc" Query="Query"
|
||||
Separator="@(problem.Kind == VirtualProblemKind.DependencyCycle ? " → " : ", ")" />
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.h6" Class="mb-1">@S.MeterDetail_ReferencedMeters</MudText>
|
||||
@if (calc.Sources.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.MeterDetail_NoReferencedMeters</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mv-table-scroll" role="region" aria-label="@S.MeterDetail_ReferencedMeters" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@S.Common_Name</th>
|
||||
<th scope="col">@S.Common_Mode</th>
|
||||
<th scope="col">@S.MeterDetail_Kind</th>
|
||||
<th scope="col">@S.Common_Unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var source in calc.Sources)
|
||||
{
|
||||
<tr>
|
||||
<th scope="row" class="mv-row-label">
|
||||
@if (source.Exists)
|
||||
{
|
||||
<MudLink Href="@MeterLinks.Analysis(source.MeterId, Query)" Typo="Typo.body2">@source.Name</MudLink>
|
||||
<code class="ml-1 mv-muted">@($"m{source.MeterId}")</code>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="mv-unknown">@S.MeterDetail_UnknownMeter</span>
|
||||
<code class="ml-1 mv-muted">@($"m{source.MeterId}")</code>
|
||||
}
|
||||
</th>
|
||||
<td>@(source.Exists ? source.Mode.Display() : Format.Unknown)</td>
|
||||
<td>@(source.Exists ? source.Kind.Display() : Format.Unknown)</td>
|
||||
<td>@(source.Exists ? source.Unit : Format.Unknown)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
@if (calc.Sources.Any(s => s.IsVirtual) && calc.PhysicalLeaves.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mt-2">
|
||||
@S.MeterDetail_ReadsPhysical
|
||||
@for (var i = 0; i < calc.PhysicalLeaves.Count; i++)
|
||||
{
|
||||
var leaf = calc.PhysicalLeaves[i];
|
||||
@(i > 0 ? ", " : " ")<MudLink Href="@MeterLinks.Analysis(leaf.MeterId, Query)" Typo="Typo.body2">@leaf.Name</MudLink>
|
||||
}
|
||||
</MudText>
|
||||
}
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mt-4">@S.MeterDetail_CalculationVsFlow</MudText>
|
||||
}
|
||||
</LoadPanel>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public MeterDetailView Detail { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis state: links to the source meters carry its period.</summary>
|
||||
[Parameter]
|
||||
public AnalysisQuery? Query { get; set; }
|
||||
|
||||
/// <summary>Bumped by the page whenever the meter (or its definition) changed.</summary>
|
||||
[Parameter]
|
||||
public int Version { get; set; }
|
||||
|
||||
/// <summary>Opens the shared meter editor on this meter.</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnEdit { get; set; }
|
||||
|
||||
private readonly LoadSequencer _loads = new();
|
||||
private readonly LoadState<MeterCalculationView> _state = new();
|
||||
private (int MeterId, int Version)? _loadedFor;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var key = (Detail.Id, Version);
|
||||
if (_loadedFor == key)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_loadedFor?.MeterId != Detail.Id)
|
||||
{
|
||||
_state.Clear();
|
||||
}
|
||||
|
||||
_loadedFor = key;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private Task LoadAsync()
|
||||
{
|
||||
var meterId = Detail.Id;
|
||||
return _loads.RunAsync(_state, async token =>
|
||||
await Details.GetCalculationAsync(meterId, token)
|
||||
?? throw new InvalidOperationException($"Meter {meterId} is not a virtual meter."), Logger);
|
||||
}
|
||||
|
||||
private static string StatusText(VirtualMeterStatus status) => status switch
|
||||
{
|
||||
VirtualMeterStatus.Valid => S.MeterDetail_CalcStatusValid,
|
||||
VirtualMeterStatus.Legacy => S.MeterDetail_CalcStatusLegacy,
|
||||
VirtualMeterStatus.NeedsConfiguration => S.MeterDetail_CalcStatusNeedsConfiguration,
|
||||
VirtualMeterStatus.Malformed => S.MeterDetail_CalcStatusMalformed,
|
||||
_ => S.MeterDetail_CalcStatusInvalid,
|
||||
};
|
||||
|
||||
private static string LegacyText(LegacyDerivation legacy) => legacy.Outcome switch
|
||||
{
|
||||
LegacyDerivationOutcome.NoSources => S.MeterDetail_LegacyNoSources,
|
||||
LegacyDerivationOutcome.UnknownSource => S.MeterDetail_LegacyUnknownSource,
|
||||
LegacyDerivationOutcome.SourceNeedsConfiguration => S.MeterDetail_LegacySourceNeedsConfiguration,
|
||||
LegacyDerivationOutcome.NotAdditive => S.MeterDetail_LegacyNotAdditive,
|
||||
LegacyDerivationOutcome.MixedUnits => S.MeterDetail_LegacyMixed,
|
||||
LegacyDerivationOutcome.MixedKinds => S.MeterDetail_LegacyMixed,
|
||||
_ => S.MeterDetail_CalcStatusNeedsConfiguration,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// One validation finding in words — the same sentence the attention list and <see cref="DisplayNames"/> give it, with
|
||||
/// the units or kinds involved; a syntax error in the editor's words, with its position. The meters involved follow
|
||||
/// it as links (<see cref="MeterPath"/>).
|
||||
/// </summary>
|
||||
private static string ProblemText(VirtualProblem problem) =>
|
||||
problem is { Kind: VirtualProblemKind.Syntax, SyntaxError: { } error }
|
||||
? MeterVault.App.MeterEditing.MeterEditorText.FormulaError(error)
|
||||
: AttentionItems.VirtualReasonWithoutMeters(problem);
|
||||
|
||||
public void Dispose() => _loads.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.mv-calc-facts {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, max-content) 1fr;
|
||||
gap: .35rem 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mv-calc-facts dt {
|
||||
color: var(--mud-palette-text-secondary);
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.mv-calc-facts dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mv-calc-problems {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mv-calc-problems li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: .25rem .5rem;
|
||||
padding: .25rem 0;
|
||||
color: var(--mud-palette-text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 599.98px) {
|
||||
.mv-calc-facts {
|
||||
grid-template-columns: 1fr;
|
||||
gap: .1rem;
|
||||
}
|
||||
|
||||
.mv-calc-facts dd {
|
||||
margin-bottom: .4rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
@* What qualifies the meter's figures (brief §4.3, §7.2, D-13 – D-19): the resolution its data has, the dates it covers,
|
||||
an opening balance of unknown start (with the offer to set an install date), rows dated after now that are not
|
||||
counted yet, how current the data is, what the normalized quantity assumes, and — as context for the chart — the
|
||||
events and tariff changes inside the range. Worded, never a bare percentage the metadata cannot support. *@
|
||||
|
||||
<section class="@Class" aria-labelledby="@_headingId">
|
||||
<MudText Typo="Typo.h6" id="@_headingId" Class="mb-2">@S.MeterDetail_QualityTitle</MudText>
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-3">
|
||||
<dl class="mv-facts">
|
||||
<div class="mv-facts__row">
|
||||
<dt>@S.MeterDetail_Resolution</dt>
|
||||
<dd>
|
||||
@if (Series?.Resolution is { } resolution)
|
||||
{
|
||||
@resolution.Display()
|
||||
@if (resolution >= ResolutionClass.Month)
|
||||
{
|
||||
<div class="mv-cell-secondary">@(Detail.IsVirtual ? S.MeterDetail_ResolutionMonthlyHintVirtual : S.MeterDetail_ResolutionMonthlyHint)</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@Format.Unknown
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="mv-facts__row">
|
||||
<dt>@S.MeterDetail_DataRange</dt>
|
||||
<dd>
|
||||
@if (Series?.Availability is { } available)
|
||||
{
|
||||
@Format.DateRange(available.FirstDay, available.LastDay)
|
||||
}
|
||||
else
|
||||
{
|
||||
@S.MeterDetail_NoDataAtAll
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="mv-facts__row">
|
||||
<dt>@S.MeterDetail_Freshness</dt>
|
||||
<dd>
|
||||
@if (Series?.Freshness is { State: not FreshnessState.NoData } freshness)
|
||||
{
|
||||
@freshness.State.Display()
|
||||
@if (freshness.LastActivity is { } last)
|
||||
{
|
||||
<div class="mv-cell-secondary">@Loc.F(S.MeterDetail_LastActivity, Format.Date(PeriodResolver.LocalDate(last, View.Period.Zone)))</div>
|
||||
}
|
||||
@if (freshness.State == FreshnessState.Stale && !Detail.IsVirtual)
|
||||
{
|
||||
<MudLink Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabSources, null, Query)" Typo="Typo.body2">@S.Attention_CheckSource</MudLink>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@FreshnessState.NoData.Display()
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="mv-facts__row">
|
||||
<dt>@S.MeterDetail_Quantity</dt>
|
||||
<dd>
|
||||
@QuantityText
|
||||
@foreach (var note in NoteTexts)
|
||||
{
|
||||
<div class="mv-cell-secondary">@note</div>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if (HasOpeningBalance)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
@Loc.F(S.MeterDetail_OpeningBalanceNote, Format.Number(Detail.InitialBaseline, 2), Detail.Unit)
|
||||
@if (Detail.InstalledAt is null && !Detail.IsVirtual)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Event"
|
||||
OnClick="OnEdit">@S.MeterDetail_SetInstallDate</MudButton>
|
||||
</div>
|
||||
}
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@foreach (var block in Series?.RecordedAfterNow ?? [])
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
@Loc.F(S.MeterDetail_RecordedAfterNow,
|
||||
NameOf(block.MeterId),
|
||||
block.Rows,
|
||||
Format.Quantity(block.Amount, Series!.Unit),
|
||||
Format.DateRange(block.FirstDay, block.LastDay))
|
||||
<div class="mt-1">
|
||||
<MudLink Href="@RecordsLink(block)" Typo="Typo.body2">@S.MeterDetail_ShowRecords</MudLink>
|
||||
</div>
|
||||
</MudAlert>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@if (!View.Markers.IsEmpty)
|
||||
{
|
||||
<MudText Typo="Typo.subtitle1" Class="mt-4 mb-1">@S.MeterDetail_InThisPeriod</MudText>
|
||||
<ul class="mv-markers">
|
||||
@foreach (var e in View.Markers.Events)
|
||||
{
|
||||
<li>
|
||||
<MudIcon Icon="@MeterEventText.Icon(e.Type)" Size="Size.Small" aria-hidden="true" />
|
||||
<span class="mv-markers__date">@Format.Date(PeriodResolver.LocalDate(e.Time, View.Period.Zone))</span>
|
||||
<span>@e.Type.Display()@EventDetail(e)</span>
|
||||
</li>
|
||||
}
|
||||
@foreach (var t in View.Markers.TariffChanges)
|
||||
{
|
||||
<li>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Sell" Size="Size.Small" aria-hidden="true" />
|
||||
<span class="mv-markers__date">@Format.Date(t.ValidFrom)</span>
|
||||
<span>@Loc.F(S.MeterDetail_TariffChange, t.Component.Display(), Format.Number(t.Value, 4), t.Unit, ScopeText(t))</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="d-flex flex-wrap" style="gap:1rem">
|
||||
@if (View.Markers.Events.Count > 0)
|
||||
{
|
||||
<MudLink Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabEvents, null, Query)" Typo="Typo.body2">
|
||||
@(View.Markers.MoreEvents ? S.MeterDetail_AllEventsInPeriod : S.MeterDetail_GoToEvents)
|
||||
</MudLink>
|
||||
}
|
||||
@if (View.Markers.TariffChanges.Count > 0)
|
||||
{
|
||||
<MudLink Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabTariffs, null, Query)" Typo="Typo.body2">@S.MeterDetail_OpenTariffs</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>The committed analysis.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public MeterAnalysisView View { get; set; } = null!;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public MeterDetailView Detail { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public AnalysisQuery? Query { get; set; }
|
||||
|
||||
/// <summary>Opens the meter editor (to set an install date).</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnEdit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? Class { get; set; }
|
||||
|
||||
private readonly string _headingId = "mv-quality-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
private AnalysisSeries? Series => View.Series;
|
||||
|
||||
/// <summary>A first reading booked against the baseline with an unknown start (D-14) sits in the range.</summary>
|
||||
private bool HasOpeningBalance =>
|
||||
Series is { } s && (s.Total.Provenance.HasFlag(Provenance.OpeningBalance) || s.Values.Any(v => v.Provenance.HasFlag(Provenance.OpeningBalance)));
|
||||
|
||||
private string QuantityText => Series is { } s
|
||||
? Loc.F(S.MeterDetail_QuantityIn, s.Kind.Display(), s.Unit)
|
||||
: Format.Unknown;
|
||||
|
||||
private IEnumerable<string> NoteTexts
|
||||
{
|
||||
get
|
||||
{
|
||||
var notes = Series?.Notes ?? default;
|
||||
if (notes.HasFlag(QuantityNotes.FixedRateEstimate))
|
||||
{
|
||||
yield return S.MeterDetail_NoteFixedRate;
|
||||
}
|
||||
|
||||
if (notes.HasFlag(QuantityNotes.RateAssumedPerHour))
|
||||
{
|
||||
yield return S.MeterDetail_NoteRatePerHour;
|
||||
}
|
||||
|
||||
if (notes.HasFlag(QuantityNotes.RateNotPerHour))
|
||||
{
|
||||
yield return S.MeterDetail_NoteRateNotPerHour;
|
||||
}
|
||||
|
||||
if (notes.HasFlag(QuantityNotes.RegisterNotInHours))
|
||||
{
|
||||
yield return S.MeterDetail_NoteRegisterNotInHours;
|
||||
}
|
||||
|
||||
if (notes.HasFlag(QuantityNotes.UndeclaredResult))
|
||||
{
|
||||
yield return S.MeterDetail_NoteUndeclaredResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string NameOf(int meterId) =>
|
||||
meterId == Detail.Id ? Detail.Name
|
||||
: Series?.Contributions.SelectMany(Flatten).FirstOrDefault(c => c.MeterId == meterId)?.Name is { Length: > 0 } name ? name
|
||||
: Loc.F(S.Attention_MeterFallback, meterId);
|
||||
|
||||
private static IEnumerable<SeriesContribution> Flatten(SeriesContribution contribution) =>
|
||||
contribution.Nested.SelectMany(Flatten).Prepend(contribution);
|
||||
|
||||
/// <summary>The normalized records of the days holding rows after now, on the meter that holds them.</summary>
|
||||
private string RecordsLink(RecordedAfterNow block)
|
||||
{
|
||||
var target = Query is null || !PeriodResolver.IsValidCustomRange(block.FirstDay, block.LastDay)
|
||||
? Query
|
||||
: Query.WithCustomRange(block.FirstDay, block.LastDay);
|
||||
return MeterLinks.Detail(block.MeterId, MeterLinks.TabNormalized, null, target);
|
||||
}
|
||||
|
||||
private static string EventDetail(EventRow e) => e.Type switch
|
||||
{
|
||||
MeterEventType.Delivery or MeterEventType.TankLevel when e.Amount is { } amount => $": {Format.Number(amount, 1)} {e.Unit}".TrimEnd(),
|
||||
MeterEventType.MeterSwap or MeterEventType.CounterReset when e.PrevValue is not null || e.NewValue is not null =>
|
||||
$": {(e.PrevValue is { } p ? Format.Number(p, 2) : Format.Unknown)} → {(e.NewValue is { } n ? Format.Number(n, 2) : Format.Unknown)}",
|
||||
_ when !string.IsNullOrWhiteSpace(e.Notes) => ": " + e.Notes,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private string ScopeText(TariffRow tariff) => tariff.Scope switch
|
||||
{
|
||||
TariffScope.Meter => S.MeterDetail_ScopeThisMeter,
|
||||
TariffScope.EnergyType => Detail.EnergyType,
|
||||
_ => tariff.Scope.Display(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* The quality facts as a two-column list on wide screens, stacked on a phone. Palette variables only. */
|
||||
.mv-facts {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, max-content) 1fr;
|
||||
gap: .5rem 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mv-facts__row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.mv-facts dt {
|
||||
color: var(--mud-palette-text-secondary);
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.mv-facts dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 599.98px) {
|
||||
.mv-facts {
|
||||
grid-template-columns: 1fr;
|
||||
gap: .15rem;
|
||||
}
|
||||
|
||||
.mv-facts dd {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mv-markers {
|
||||
list-style: none;
|
||||
margin: 0 0 .5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mv-markers li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: .25rem .5rem;
|
||||
padding: .2rem 0;
|
||||
}
|
||||
|
||||
.mv-markers__date {
|
||||
color: var(--mud-palette-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 8.5rem;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
@inherits RecordTabBase<EventRow>
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@inject IServiceScopeFactory Scopes
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@* A meter's events (brief §7.2, D-50): swaps and resets that keep a register's history continuous, a tank's levels and
|
||||
deliveries, notes. Recorded through MeterEventService (the dialog), deleted there too — an imported one only by
|
||||
reverting its import. Paged and filtered by the page period. *@
|
||||
|
||||
<div class="d-flex align-center justify-space-between flex-wrap mb-3" style="gap:.5rem">
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@EventsHint</MudText>
|
||||
<MudMenu Label="@S.MeterDetail_RecordEvent" Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Add" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Dense="true">
|
||||
@foreach (var type in MeterEventRules.RecordableFor(Detail.Mode))
|
||||
{
|
||||
var chosen = type;
|
||||
<MudMenuItem Icon="@MeterEventText.Icon(chosen)" OnClick="@(() => OnRecordEvent.InvokeAsync(chosen))">@($"{chosen.Display()}…")</MudMenuItem>
|
||||
}
|
||||
</MudMenu>
|
||||
</div>
|
||||
|
||||
<PeriodToolbar Query="Query" Period="Current?.Period" Defaults="Defaults" QueryChanged="OnQueryChanged"
|
||||
ShowBucket="false" ShowComparison="false" ShowsRecords="true" Class="mb-2" />
|
||||
|
||||
<LoadPanel State="State" OnRetry="LoadAsync" Context="view" PlaceholderHeight="160">
|
||||
@if (view.MeterId == Detail.Id)
|
||||
{
|
||||
@if (view.Page.Rows.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@(Detail.HasEvents ? S.MeterDetail_NoEventsInPeriod : S.MeterDetail_NoEvents)</MudText>
|
||||
@if (Detail.HasEvents && view.Range.IsBounded)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Class="mt-1" OnClick="ShowAllAsync">@S.MeterDetail_ShowAllDates</MudButton>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id)</MudText>
|
||||
<div class="mv-table-scroll mt-1" role="region" aria-label="@S.MeterDetail_TabEvents" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@S.MeterDetail_Time</th>
|
||||
<th scope="col">@S.Common_Type</th>
|
||||
<th scope="col" class="mv-num">@S.Common_Amount</th>
|
||||
<th scope="col" class="mv-num">@S.MeterDetail_PrevNew</th>
|
||||
<th scope="col">@S.MeterDetail_Notes</th>
|
||||
<th scope="col"><span class="mv-sr-only">@S.Common_Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in view.Page.Rows)
|
||||
{
|
||||
<tr>
|
||||
<td style="white-space:nowrap">
|
||||
@Local(e.Time)
|
||||
@if (IsAfterNow(e.Time))
|
||||
{
|
||||
<AfterNowChip />
|
||||
}
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<MudIcon Icon="@MeterEventText.Icon(e.Type)" Size="Size.Small" Class="mr-1" Style="vertical-align:middle" aria-hidden="true" />@e.Type.Display()
|
||||
</td>
|
||||
<td class="mv-num">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : Format.Unknown)</td>
|
||||
<td class="mv-num" style="white-space:nowrap">@PrevNewText(e)</td>
|
||||
<td>@e.Notes</td>
|
||||
<td style="text-align:right">
|
||||
@if (e.ImportBatchId is not null)
|
||||
{
|
||||
<MudTooltip Text="@S.MeterDetail_ImportedEventHint">
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Href="/import">@S.MeterDetail_Imported</MudChip>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@S.MeterDetail_DeleteEvent">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
|
||||
OnClick="@(() => DeleteAsync(e))" aria-label="@S.MeterDetail_DeleteEvent" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
<RecordPagerBar Pager="Pager" Count="view.Page.Rows.Count" Total="view.Page.Total" TotalIsCapped="view.Page.TotalIsCapped"
|
||||
HasOlder="view.Page.Next is not null" OnNewest="NewestAsync" OnNewer="NewerAsync" OnOlder="OlderAsync" />
|
||||
}
|
||||
}
|
||||
</LoadPanel>
|
||||
|
||||
@code {
|
||||
/// <summary>Opens the event dialog for a type (the page owns it).</summary>
|
||||
[Parameter]
|
||||
public EventCallback<MeterEventType> OnRecordEvent { get; set; }
|
||||
|
||||
private string EventsHint => Detail.Mode switch
|
||||
{
|
||||
MeterMode.ConsumableBalance => S.MeterDetail_EventsHintTank,
|
||||
MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter => S.MeterDetail_EventsHintRegister,
|
||||
_ => S.MeterDetail_EventsHintNote,
|
||||
};
|
||||
|
||||
protected override Task<RecordPage<EventRow>> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) =>
|
||||
Details.GetEventsAsync(meterId, range, cursor, cancellationToken);
|
||||
|
||||
private static string PrevNewText(EventRow e) => (e.PrevValue, e.NewValue) switch
|
||||
{
|
||||
(null, null) => Format.Unknown,
|
||||
({ } prev, var next) => $"{Format.Number(prev, 2)} → {(next is { } n ? Format.Number(n, 2) : Format.Unknown)}",
|
||||
(null, { } next) => $"→ {Format.Number(next, 2)}",
|
||||
};
|
||||
|
||||
private async Task DeleteAsync(EventRow meterEvent)
|
||||
{
|
||||
var message = MeterEventRules.IsRegisterBoundary(meterEvent.Type)
|
||||
? Loc.F(S.MeterDetail_DeleteBoundaryConfirm, meterEvent.Type.Display(), Local(meterEvent.Time))
|
||||
: Loc.F(S.MeterDetail_DeleteEventConfirm, meterEvent.Type.Display(), Local(meterEvent.Time));
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteEvent, message))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = Scopes.CreateAsyncScope();
|
||||
var result = await scope.ServiceProvider.GetRequiredService<MeterEventService>().DeleteEventAsync(Detail.Id, meterEvent.Id);
|
||||
Snackbar.Add(result.Succeeded ? S.MeterDetail_EventDeleted : MeterEventText.Problem(result.Problem),
|
||||
result.Succeeded ? Severity.Success : Severity.Error);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// The service's transaction has rolled back; report rather than end the circuit.
|
||||
LoggerFactory.CreateLogger<MeterEventsTab>().LogError(ex, "Deleting an event on meter {MeterId} failed", Detail.Id);
|
||||
Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error);
|
||||
}
|
||||
|
||||
await OnChanged.InvokeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
@* The meter page's header (brief §7.2, D-48): Overview → energy type → meter, carrying the period; the name; chips for
|
||||
the energy type (its analysis), the mode and "retired"; and the meter's own actions, so entering a reading, recording
|
||||
a swap or fixing a setting never depends on finding the right tab first. The tab bar follows directly below. *@
|
||||
|
||||
<PageHeader Title="@Detail.Name" Description="@IdentityLine()">
|
||||
<Breadcrumbs>
|
||||
<AnalysisBreadcrumbs Query="Query" EnergyTypeId="Detail.EnergyTypeId" EnergyTypeName="@Detail.EnergyType"
|
||||
MeterId="Detail.Id" MeterName="@Detail.Name" />
|
||||
</Breadcrumbs>
|
||||
<Chips>
|
||||
<MudTooltip Text="@Loc.F(S.MeterDetail_EnergyTypeAnalysis, Detail.EnergyType)">
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Primary"
|
||||
Href="@AnalysisLinks.EnergyType(Detail.EnergyTypeId, AnalysisLinks.EnergyTabHistory, Query)">@Detail.EnergyType</MudChip>
|
||||
</MudTooltip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@Detail.Mode.Display()</MudChip>
|
||||
@if (IsRetired)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@RetiredText</MudChip>
|
||||
}
|
||||
</Chips>
|
||||
<Actions>
|
||||
@if (MeterEventRules.TakesReadings(Detail.Mode))
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote" OnClick="OnAddReading">
|
||||
@S.MeterDetail_AddReading
|
||||
</MudButton>
|
||||
}
|
||||
else if (Detail.Mode == MeterMode.ConsumableBalance)
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Straighten"
|
||||
OnClick="@(() => OnRecordEvent.InvokeAsync(MeterEventType.TankLevel))">
|
||||
@S.MeterDetail_RecordTankLevel
|
||||
</MudButton>
|
||||
}
|
||||
<MudMenu Label="@S.MeterDetail_RecordEvent" Variant="Variant.Outlined" Color="Color.Primary"
|
||||
EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Dense="true">
|
||||
@foreach (var type in MeterEventRules.RecordableFor(Detail.Mode))
|
||||
{
|
||||
var chosen = type;
|
||||
<MudMenuItem Icon="@MeterEventText.Icon(chosen)" OnClick="@(() => OnRecordEvent.InvokeAsync(chosen))">@($"{chosen.Display()}…")</MudMenuItem>
|
||||
}
|
||||
</MudMenu>
|
||||
<MudTooltip Text="@S.MeterDetail_EditMeter">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Variant="Variant.Outlined" Size="Size.Medium"
|
||||
OnClick="OnEdit" aria-label="@S.MeterDetail_EditMeter" />
|
||||
</MudTooltip>
|
||||
</Actions>
|
||||
<ChildContent>
|
||||
@ChildContent
|
||||
</ChildContent>
|
||||
</PageHeader>
|
||||
|
||||
@code {
|
||||
/// <summary>The meter.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public MeterDetailView Detail { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis state: breadcrumbs and the type chip carry its period.</summary>
|
||||
[Parameter]
|
||||
public AnalysisQuery? Query { get; set; }
|
||||
|
||||
/// <summary>Today in the instance zone: a retire date on or before it makes the meter retired.</summary>
|
||||
[Parameter]
|
||||
public DateOnly Today { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnAddReading { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<MeterEventType> OnRecordEvent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnEdit { get; set; }
|
||||
|
||||
/// <summary>Notices that belong to the header (a tank to set up, a first step).</summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private bool IsRetired => !Detail.IsActive || Detail.RetiredAt is { } retired && retired <= Today;
|
||||
|
||||
private string RetiredText => Detail.RetiredAt is { } retired
|
||||
? Loc.F(S.MeterDetail_RetiredOn, Format.Date(retired))
|
||||
: S.MeterDetail_Retired;
|
||||
|
||||
private string? IdentityLine()
|
||||
{
|
||||
var parts = new List<string>(3);
|
||||
if (!string.IsNullOrWhiteSpace(Detail.SerialNumber))
|
||||
{
|
||||
parts.Add(Loc.F(S.MeterDetail_SerialValue, Detail.SerialNumber));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Detail.Location))
|
||||
{
|
||||
parts.Add(Detail.Location);
|
||||
}
|
||||
|
||||
var device = string.Join(' ', new[] { Detail.Manufacturer, Detail.Model }.Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||
if (device.Length > 0)
|
||||
{
|
||||
parts.Add(device);
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? null : string.Join(" · ", parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
@inherits RecordTabBase<ConsumptionDetailRow>
|
||||
|
||||
@* Normalized data (brief §7.2, D-50): the consumption or generation deltas derived from the readings and events, in the
|
||||
meter's normalized unit (D-20) — what every chart, total and cost is built from. Derived and reproducible: they are
|
||||
rebuilt whenever a reading or event changes. A drill-down from a chart bucket the data cannot resolve lands here,
|
||||
filtered to that bucket. *@
|
||||
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@Loc.F(S.MeterDetail_NormalizedIntro, Detail.NormalizedUnit)</MudText>
|
||||
|
||||
<PeriodToolbar Query="Query" Period="Current?.Period" Defaults="Defaults" QueryChanged="OnQueryChanged"
|
||||
ShowBucket="false" ShowComparison="false" ShowsRecords="true" Class="mb-2" />
|
||||
|
||||
<LoadPanel State="State" OnRetry="LoadAsync" Context="view" PlaceholderHeight="160">
|
||||
@if (view.MeterId == Detail.Id)
|
||||
{
|
||||
@if (view.Page.Rows.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@(view.Range.IsBounded ? S.MeterDetail_NoNormalizedInPeriod : S.MeterDetail_NoConsumption)</MudText>
|
||||
@if (view.Range.IsBounded)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Class="mt-1" OnClick="ShowAllAsync">@S.MeterDetail_ShowAllDates</MudButton>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id)</MudText>
|
||||
<div class="mv-table-scroll mt-1" role="region" aria-label="@S.MeterDetail_TabNormalized" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@S.MeterDetail_Time</th>
|
||||
<th scope="col" class="mv-num">@Loc.F(S.MeterDetail_AmountIn, Detail.NormalizedUnit)</th>
|
||||
<th scope="col">@S.MeterDetail_Kind</th>
|
||||
<th scope="col">@S.MeterDetail_Quality</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in view.Page.Rows)
|
||||
{
|
||||
<tr>
|
||||
<td style="white-space:nowrap">
|
||||
@Local(c.Time)
|
||||
@if (IsAfterNow(c.Time))
|
||||
{
|
||||
<AfterNowChip />
|
||||
}
|
||||
</td>
|
||||
<td class="mv-num">@Format.Number(c.Amount, 3)</td>
|
||||
<td>@c.Kind.Display()</td>
|
||||
<td><QualityChip Quality="c.Quality" /></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
<RecordPagerBar Pager="Pager" Count="view.Page.Rows.Count" Total="view.Page.Total" TotalIsCapped="view.Page.TotalIsCapped"
|
||||
HasOlder="view.Page.Next is not null" OnNewest="NewestAsync" OnNewer="NewerAsync" OnOlder="OlderAsync" />
|
||||
}
|
||||
}
|
||||
</LoadPanel>
|
||||
|
||||
@code {
|
||||
protected override Task<RecordPage<ConsumptionDetailRow>> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) =>
|
||||
Details.GetConsumptionAsync(meterId, range, cursor, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@* The meters a calculation finding involves — the operands of a mismatch, the path of a loop — by name, each linked to
|
||||
its own page over the same period; an id that names no meter says so. *@
|
||||
|
||||
@if (Ids.Count > 0)
|
||||
{
|
||||
<span class="mv-cell-secondary">
|
||||
—
|
||||
@for (var i = 0; i < Ids.Count; i++)
|
||||
{
|
||||
var id = Ids[i];
|
||||
@(i > 0 ? Separator : string.Empty)
|
||||
@if (Calculation.NameOf(id) is { } name)
|
||||
{
|
||||
<MudLink Href="@MeterLinks.Analysis(id, Query)" Typo="Typo.body2">@name</MudLink>
|
||||
}
|
||||
else
|
||||
{
|
||||
@Loc.F(S.Attention_MeterFallback, id)
|
||||
}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<int> Ids { get; set; } = [];
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public MeterCalculationView Calculation { get; set; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public AnalysisQuery? Query { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Separator { get; set; } = ", ";
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
@inherits RecordTabBase<ReadingRow>
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@inject IServiceScopeFactory Scopes
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@* Raw readings (brief §7.2, D-50, D-57): every value exactly as it arrived, in the register's raw unit — the audit record
|
||||
everything else is derived from, never changed to fix a figure. Paged and filtered by the page period; hand-entered
|
||||
ones can be deleted (the meter is recomputed). *@
|
||||
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mb-3">@S.MeterDetail_ReadingsIntro</MudText>
|
||||
|
||||
@if (Detail.Mode == MeterMode.ConsumableBalance)
|
||||
{
|
||||
@* A tank's consumption comes from level and delivery events; a reading typed here would
|
||||
save cleanly and change nothing, so the tab sends the user where it counts. *@
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||
@S.MeterDetail_TankUsesEvents
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Primary" Class="ml-2"
|
||||
Href="@MeterLinks.Detail(Detail.Id, MeterLinks.TabEvents, null, Query)">@S.MeterDetail_GoToEvents</MudButton>
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex align-center flex-wrap justify-space-between mb-2" style="gap:.5rem 1rem">
|
||||
<MudText Typo="Typo.body2">
|
||||
@if (Detail.FirstReading is { } first && Detail.LastReading is { } last)
|
||||
{
|
||||
@Loc.F(S.MeterDetail_RegisterSummary,
|
||||
Format.Number(first.Value, 2), Local(first.Time), Format.Number(last.Value, 2), Local(last.Time), Detail.Unit)
|
||||
}
|
||||
<span class="mv-muted">@(" " + Loc.F(S.MeterDetail_BaselineValue, Format.Number(Detail.InitialBaseline, 2)))</span>
|
||||
</MudText>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add" OnClick="OnAddReading">
|
||||
@S.MeterDetail_AddReading
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
|
||||
<PeriodToolbar Query="Query" Period="Current?.Period" Defaults="Defaults" QueryChanged="OnQueryChanged"
|
||||
ShowBucket="false" ShowComparison="false" ShowsRecords="true" Class="mb-2" />
|
||||
|
||||
<LoadPanel State="State" OnRetry="LoadAsync" Context="view" PlaceholderHeight="160">
|
||||
@if (view.MeterId == Detail.Id)
|
||||
{
|
||||
@if (view.Page.Rows.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@(Detail.HasReadings ? S.MeterDetail_NoReadingsInPeriod : S.MeterDetail_NoRawReadings)</MudText>
|
||||
@if (Detail.HasReadings && view.Range.IsBounded)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Class="mt-1" OnClick="ShowAllAsync">@S.MeterDetail_ShowAllDates</MudButton>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted">@Loc.F(S.MeterDetail_TimesIn, Periods.Zone.Id)</MudText>
|
||||
<div class="mv-table-scroll mt-1" role="region" aria-label="@S.MeterDetail_TabReadings" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@S.MeterDetail_Time</th>
|
||||
<th scope="col" class="mv-num">@Loc.F(S.MeterDetail_ValueIn, Detail.Unit)</th>
|
||||
<th scope="col">@S.MeterDetail_Quality</th>
|
||||
<th scope="col">@S.MeterDetail_Flags</th>
|
||||
<th scope="col"><span class="mv-sr-only">@S.Common_Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in view.Page.Rows)
|
||||
{
|
||||
<tr>
|
||||
<td style="white-space:nowrap">
|
||||
@Local(r.Time)
|
||||
@if (IsAfterNow(r.Time))
|
||||
{
|
||||
<AfterNowChip />
|
||||
}
|
||||
</td>
|
||||
<td class="mv-num">@Format.Number(r.Value, 2)</td>
|
||||
<td><QualityChip Quality="r.Quality" /></td>
|
||||
<td>@r.Flags.Display()</td>
|
||||
<td style="text-align:right">
|
||||
@if (r.Quality == ReadingQuality.Manual)
|
||||
{
|
||||
<MudTooltip Text="@S.MeterDetail_DeleteReading">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
|
||||
OnClick="@(() => DeleteAsync(r))" aria-label="@S.MeterDetail_DeleteReading" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
<RecordPagerBar Pager="Pager" Count="view.Page.Rows.Count" Total="view.Page.Total" TotalIsCapped="view.Page.TotalIsCapped"
|
||||
HasOlder="view.Page.Next is not null" OnNewest="NewestAsync" OnNewer="NewerAsync" OnOlder="OlderAsync" />
|
||||
}
|
||||
}
|
||||
</LoadPanel>
|
||||
|
||||
@code {
|
||||
/// <summary>Opens the manual-reading dialog (the page owns it).</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnAddReading { get; set; }
|
||||
|
||||
protected override Task<RecordPage<ReadingRow>> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken) =>
|
||||
Details.GetReadingsAsync(meterId, range, cursor, cancellationToken);
|
||||
|
||||
private async Task DeleteAsync(ReadingRow reading)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteReadingTitle,
|
||||
Loc.F(S.MeterDetail_DeleteReadingConfirm, Format.Number(reading.Value, 2), Detail.Unit, Local(reading.Time))))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = Scopes.CreateAsyncScope();
|
||||
var result = await scope.ServiceProvider.GetRequiredService<MeterEventService>().DeleteManualReadingAsync(Detail.Id, reading.Time);
|
||||
Snackbar.Add(result.Succeeded ? S.MeterDetail_ReadingDeleted : MeterEventText.Problem(result.Problem),
|
||||
result.Succeeded ? Severity.Success : Severity.Error);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// The service's transaction has rolled back; report rather than end the circuit.
|
||||
LoggerFactory.CreateLogger<MeterReadingsTab>().LogError(ex, "Deleting a manual reading on meter {MeterId} failed", Detail.Id);
|
||||
Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error);
|
||||
}
|
||||
|
||||
await OnChanged.InvokeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
@using System.Globalization
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject DraftStore Drafts
|
||||
@implements IDisposable
|
||||
|
||||
@* A meter's ingest source: its type, the connector that serves it and where the value sits in the payload. Every way to
|
||||
a missing connector is a detour that comes back here with the connector picked and everything typed restored: the
|
||||
dialog saves itself to the circuit's DraftStore when the page is left while it is open. *@
|
||||
<MudDialog Visible="_open" VisibleChanged="OnVisibleChanged" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_edit.Id == 0 ? S.MeterDetail_NewSource : S.MeterDetail_EditSource)</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="SourceType" Value="_edit.SourceType" ValueChanged="OnSourceTypeChanged" Label="@S.MeterDetail_SourceType" Class="mb-2">
|
||||
@foreach (var type in Enum.GetValues<SourceType>())
|
||||
{
|
||||
<MudSelectItem T="SourceType" Value="type">@type.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (SourceRouting.RequiredEndpoint(_edit.SourceType) is { } needed)
|
||||
{
|
||||
@* Every way to a missing connector leads back here with it picked, so setting one up is a
|
||||
detour rather than a dead end that loses the meter. *@
|
||||
var usable = ConnectorsFor(needed);
|
||||
if (usable.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
|
||||
@if (_endpoints.FirstOrDefault(e => e.Type == needed && !e.IsEnabled) is { } disabled)
|
||||
{
|
||||
<span>@Loc.F(S.MeterDetail_ConnectorOnlyDisabled, disabled.Name) <MudLink Href="@MeterLinks.EditConnector(MeterId, SourceIdOrNull, _edit.SourceType, disabled.Id)">@S.MeterDetail_EnableConnectorLink</MudLink></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) <MudLink Href="@MeterLinks.NewConnector(MeterId, SourceIdOrNull, _edit.SourceType, needed)">@S.MeterDetail_CreateConnectorLink</MudLink> @S.MeterDetail_CreateConnectorHint</span>
|
||||
}
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_edit.EndpointId" Label="@S.MeterDetail_Connector" Required="true" Class="mb-1">
|
||||
@foreach (var e in usable)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<div class="mb-2 d-flex flex-wrap" style="gap:.25rem 1rem">
|
||||
@if (_edit.EndpointId is { } chosen && usable.Any(e => e.Id == chosen))
|
||||
{
|
||||
@* Change the connection itself (URL, token, broker) without losing what is typed here: the dialog's
|
||||
draft is kept and the connector page leads back to it. *@
|
||||
<MudLink Typo="Typo.caption" Href="@MeterLinks.EditConnector(MeterId, SourceIdOrNull, _edit.SourceType, chosen)">
|
||||
@S.MeterDetail_EditConnectorLink
|
||||
</MudLink>
|
||||
}
|
||||
<MudLink Typo="Typo.caption" Href="@MeterLinks.NewConnector(MeterId, SourceIdOrNull, _edit.SourceType, needed)">
|
||||
@S.MeterDetail_AnotherConnector
|
||||
</MudLink>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (_edit.SourceType == SourceType.HomeAssistant)
|
||||
{
|
||||
<MudTextField @bind-Value="_edit.EntityId" Label="@S.MeterDetail_EntityIdLabel" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_edit.Attribute" Label="@S.MeterDetail_AttributeLabel" Class="mb-2" />
|
||||
<MudNumericField T="int?" @bind-Value="_edit.PollMinutes" Label="@S.MeterDetail_PollIntervalLabel" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
@S.MeterDetail_PollHint
|
||||
</MudText>
|
||||
}
|
||||
else if (_edit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
|
||||
{
|
||||
<MudTextField @bind-Value="_edit.Topic" Label="@S.MeterDetail_TopicLabel" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_edit.Path" Label="@S.MeterDetail_ValuePathLabel" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_edit.TimePath" Label="@S.MeterDetail_TimePathLabel" Class="mb-2" />
|
||||
}
|
||||
<MudSelect T="SourceValueKind" @bind-Value="_edit.ValueKind" Label="@S.MeterDetail_ValueKind" Class="mb-2">
|
||||
@foreach (var kind in Enum.GetValues<SourceValueKind>())
|
||||
{
|
||||
<MudSelectItem T="SourceValueKind" Value="kind">@kind.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<div class="d-flex flex-wrap" style="column-gap:1rem">
|
||||
<MudNumericField T="double" @bind-Value="_edit.Scale" Label="@S.MeterDetail_Scale" Class="mb-2" Style="min-width:6rem" />
|
||||
<MudNumericField T="double" @bind-Value="_edit.Offset" Label="@S.MeterDetail_Offset" Class="mb-2" Style="min-width:6rem" />
|
||||
<MudNumericField T="int" @bind-Value="_edit.Priority" Label="@S.MeterDetail_Priority" Class="mb-2" Style="min-width:6rem" />
|
||||
</div>
|
||||
<MudSwitch T="bool" @bind-Value="_edit.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
/// <summary>The meter the source feeds.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public int MeterId { get; set; }
|
||||
|
||||
/// <summary>Raised after a source was saved.</summary>
|
||||
[Parameter]
|
||||
public EventCallback Saved { get; set; }
|
||||
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
private List<IngestionEndpoint> _endpoints = [];
|
||||
private SourceEdit _edit = new();
|
||||
private bool _open;
|
||||
|
||||
/// <summary>The meter the dialog was opened for: a draft is only ever saved under the meter it belongs to.</summary>
|
||||
private int? _openFor;
|
||||
|
||||
/// <summary>Opens the dialog for a new source, or for <paramref name="source"/>.</summary>
|
||||
public async Task OpenAsync(MeterSource? source)
|
||||
{
|
||||
await LoadEndpointsAsync();
|
||||
Fill(source);
|
||||
Show();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the dialog a link asked for — typically the way back from the connector page, with the source it left, its
|
||||
/// type and the connector just saved for it (<see cref="MeterLinks.Source"/>).
|
||||
/// </summary>
|
||||
public async Task OpenFromLinkAsync(int? sourceId, SourceType? type, int? connectorId)
|
||||
{
|
||||
await LoadEndpointsAsync();
|
||||
MeterSource? source = null;
|
||||
if (sourceId is { } id)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
source = await db.MeterSources.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id && s.MeterId == MeterId);
|
||||
}
|
||||
|
||||
Fill(source);
|
||||
|
||||
// Back from the connector page: everything typed before the detour comes back with the dialog. A
|
||||
// link that names a source type is only ever that way back; other links open the dialog fresh.
|
||||
if (type is not null && Drafts.TryTake<SourceEdit>(DraftKey(MeterId, sourceId), out var draft))
|
||||
{
|
||||
draft.Id = _edit.Id;
|
||||
_edit = draft;
|
||||
}
|
||||
|
||||
var connector = connectorId is { } cid ? _endpoints.FirstOrDefault(e => e.Id == cid) : null;
|
||||
if ((type ?? (connector is null ? null : SourceRouting.DefaultSourceFor(connector.Type))) is { } wanted
|
||||
&& wanted != _edit.SourceType)
|
||||
{
|
||||
OnSourceTypeChanged(wanted);
|
||||
}
|
||||
|
||||
// Only a connector that can serve the source: a disabled or mismatched one would be refused on save.
|
||||
if (connector is { IsEnabled: true } && SourceRouting.Serves(connector.Type, _edit.SourceType))
|
||||
{
|
||||
_edit.EndpointId = connector.Id;
|
||||
}
|
||||
|
||||
Show();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leaving the page with the dialog open — the connector links in it do exactly that — keeps what was typed for the
|
||||
/// way back. Disposal runs after every input already sent has been applied.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_open && _openFor is { } meterId)
|
||||
{
|
||||
Drafts.Save(DraftKey(meterId, SourceIdOrNull), _edit.Clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The draft key of a meter's source dialog (a new source when <paramref name="sourceId"/> is null).</summary>
|
||||
public static string DraftKey(int meterId, int? sourceId) =>
|
||||
$"meter:{meterId.ToString(CultureInfo.InvariantCulture)}:source:{sourceId?.ToString(CultureInfo.InvariantCulture) ?? "new"}";
|
||||
|
||||
private void Show()
|
||||
{
|
||||
_openFor = MeterId;
|
||||
_open = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dialog closed on its own (backdrop, Escape, or the dialog provider dismissing it on navigation). Only the
|
||||
/// Cancel button throws the draft away: when the page is left through a connector link, the provider dismisses the
|
||||
/// dialog after <see cref="Dispose"/> has saved what was typed, and that draft is the way back.
|
||||
/// </summary>
|
||||
private void OnVisibleChanged(bool visible)
|
||||
{
|
||||
if (!visible)
|
||||
{
|
||||
_open = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The source being edited, or null for a new one — what a detour to the connector page returns to.</summary>
|
||||
private int? SourceIdOrNull => _edit.Id == 0 ? null : _edit.Id;
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
_open = false;
|
||||
Drafts.Discard(DraftKey(MeterId, SourceIdOrNull));
|
||||
}
|
||||
|
||||
private async Task LoadEndpointsAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private void Fill(MeterSource? source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
_edit = new SourceEdit();
|
||||
OnSourceTypeChanged(_edit.SourceType);
|
||||
return;
|
||||
}
|
||||
|
||||
var config = SourceConfig.Parse(source.Config);
|
||||
_edit = new SourceEdit
|
||||
{
|
||||
Id = source.Id,
|
||||
SourceType = source.SourceType,
|
||||
EndpointId = source.EndpointId,
|
||||
ValueKind = source.ValueKind,
|
||||
Scale = source.Scale,
|
||||
Offset = source.Offset,
|
||||
Priority = source.Priority,
|
||||
IsEnabled = source.IsEnabled,
|
||||
EntityId = config.EntityId,
|
||||
Attribute = config.Attribute,
|
||||
PollMinutes = config.PollMinutes,
|
||||
Topic = config.Topic,
|
||||
Path = config.Path,
|
||||
TimePath = config.TimePath,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
// A live source without a matching connector has no connection details and would silently
|
||||
// never ingest, so refuse it here rather than letting it look configured.
|
||||
if (SourceRouting.RequiredEndpoint(_edit.SourceType) is { } needed)
|
||||
{
|
||||
var selected = _endpoints.FirstOrDefault(e => e.Id == _edit.EndpointId);
|
||||
if (selected is null)
|
||||
{
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_PickConnector, needed.Display(), _edit.SourceType.Display()), Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.Type != needed)
|
||||
{
|
||||
Snackbar.Add(
|
||||
Loc.F(S.MeterDetail_ConnectorTypeMismatch, selected.Name, selected.Type.Display(), _edit.SourceType.Display(), needed.Display()),
|
||||
Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selected.IsEnabled)
|
||||
{
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_ConnectorDisabled, selected.Name), Severity.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_edit.EndpointId = null;
|
||||
}
|
||||
|
||||
var config = new SourceConfig
|
||||
{
|
||||
EntityId = Trim(_edit.EntityId),
|
||||
Attribute = Trim(_edit.Attribute),
|
||||
PollMinutes = _edit.PollMinutes,
|
||||
Topic = Trim(_edit.Topic),
|
||||
Path = Trim(_edit.Path),
|
||||
TimePath = Trim(_edit.TimePath),
|
||||
};
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(config,
|
||||
new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
||||
|
||||
await using (var db = await DbFactory.CreateDbContextAsync())
|
||||
{
|
||||
if (_edit.Id == 0)
|
||||
{
|
||||
db.MeterSources.Add(new MeterSource
|
||||
{
|
||||
MeterId = MeterId,
|
||||
SourceType = _edit.SourceType,
|
||||
EndpointId = _edit.EndpointId,
|
||||
Config = configJson,
|
||||
ValueKind = _edit.ValueKind,
|
||||
Scale = _edit.Scale,
|
||||
Offset = _edit.Offset,
|
||||
Priority = _edit.Priority,
|
||||
IsEnabled = _edit.IsEnabled,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.MeterSources.FirstAsync(s => s.Id == _edit.Id);
|
||||
existing.SourceType = _edit.SourceType;
|
||||
existing.EndpointId = _edit.EndpointId;
|
||||
existing.Config = configJson;
|
||||
existing.ValueKind = _edit.ValueKind;
|
||||
existing.Scale = _edit.Scale;
|
||||
existing.Offset = _edit.Offset;
|
||||
existing.Priority = _edit.Priority;
|
||||
existing.IsEnabled = _edit.IsEnabled;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_open = false;
|
||||
Drafts.Discard(DraftKey(MeterId, SourceIdOrNull));
|
||||
Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success);
|
||||
await Saved.InvokeAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
// Only enabled connectors can ingest: both MQTT and HA workers filter on IsEnabled, so offering
|
||||
// a disabled one would produce a source that saves cleanly and then never runs.
|
||||
private List<IngestionEndpoint> ConnectorsFor(EndpointType type) =>
|
||||
_endpoints.Where(e => e.Type == type && e.IsEnabled).ToList();
|
||||
|
||||
// Changing the source type can invalidate the chosen connector (an HA connector cannot serve an
|
||||
// MQTT source), so drop a selection that no longer fits rather than saving a mismatched pair.
|
||||
private void OnSourceTypeChanged(SourceType sourceType)
|
||||
{
|
||||
_edit.SourceType = sourceType;
|
||||
|
||||
var needed = SourceRouting.RequiredEndpoint(sourceType);
|
||||
var selected = _endpoints.FirstOrDefault(e => e.Id == _edit.EndpointId);
|
||||
if (needed is null || (selected is not null && selected.Type != needed))
|
||||
{
|
||||
_edit.EndpointId = null;
|
||||
}
|
||||
|
||||
// Sole candidate: preselect it, so the common single-broker / single-HA setup is one click. Only
|
||||
// enabled ones count — the picker offers nothing else, so a disabled pick would be invisible.
|
||||
if (needed is { } kind && _edit.EndpointId is null)
|
||||
{
|
||||
var candidates = ConnectorsFor(kind);
|
||||
if (candidates.Count == 1)
|
||||
{
|
||||
_edit.EndpointId = candidates[0].Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SourceEdit
|
||||
{
|
||||
public SourceEdit Clone() => (SourceEdit)MemberwiseClone();
|
||||
|
||||
public int Id { get; set; }
|
||||
public SourceType SourceType { get; set; } = SourceType.HomeAssistant;
|
||||
public int? EndpointId { get; set; }
|
||||
public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register;
|
||||
public double Scale { get; set; } = 1;
|
||||
public double Offset { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public string? EntityId { get; set; }
|
||||
public string? Attribute { get; set; }
|
||||
public int? PollMinutes { get; set; } = 60;
|
||||
public string? Topic { get; set; }
|
||||
public string? Path { get; set; }
|
||||
public string? TimePath { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@implements IDisposable
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<MeterSourcesTab> Logger
|
||||
|
||||
@* How a physical meter is fed (brief §3.2 "Meter → Sources → Edit connection"): its ingest sources with their connector,
|
||||
last value and status — and a source that can never ingest (no connector, a disabled one, the wrong kind) says so in
|
||||
its row instead of looking healthy. Editing opens the page's source dialog, whose connector detour keeps the draft. *@
|
||||
|
||||
<div class="d-flex align-center justify-space-between flex-wrap mb-3" style="gap:.5rem">
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.MeterDetail_SourcesIntro</MudText>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OnEdit.InvokeAsync(null))">
|
||||
@S.MeterDetail_AddSource
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
<LoadPanel State="_state" OnRetry="LoadAsync" Context="view" PlaceholderHeight="120">
|
||||
@if (view.Sources.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.MeterDetail_NoSources</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mv-table-scroll" role="region" aria-label="@S.MeterDetail_TabSources" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@S.Common_Type</th>
|
||||
<th scope="col">@S.Common_Target</th>
|
||||
<th scope="col">@S.MeterDetail_Connector</th>
|
||||
<th scope="col">@S.Common_Enabled</th>
|
||||
<th scope="col">@S.Common_LastSeen</th>
|
||||
<th scope="col" class="mv-num">@S.MeterDetail_LastValue</th>
|
||||
<th scope="col">@S.Common_Status</th>
|
||||
<th scope="col" style="text-align:right">@S.Common_Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var s in view.Sources)
|
||||
{
|
||||
<tr>
|
||||
<td>@s.SourceType.Display()</td>
|
||||
<td style="overflow-wrap:anywhere">@SourceTarget(s)</td>
|
||||
<td>
|
||||
@{ var problem = ConnectorProblem(view, s); }
|
||||
@if (problem is null)
|
||||
{
|
||||
@* Brief §3.2 "Meter → Sources → Edit connection": the connector in use opens for editing, and the
|
||||
connector page leads back here (MeterLinks.EditConnector). *@
|
||||
@if (view.Endpoints.FirstOrDefault(e => e.Id == s.EndpointId) is { } endpoint)
|
||||
{
|
||||
<MudLink Href="@MeterLinks.EditConnector(Detail.Id, s.Id, s.SourceType, endpoint.Id)" Typo="Typo.body2"
|
||||
title="@S.MeterDetail_EditConnectorLink">@endpoint.Name</MudLink>
|
||||
}
|
||||
else
|
||||
{
|
||||
@Format.Unknown
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@problem">
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text"
|
||||
Icon="@Icons.Material.Filled.LinkOff">@problem</MudChip>
|
||||
</MudTooltip>
|
||||
}
|
||||
</td>
|
||||
<td>@(s.IsEnabled ? S.MeterDetail_Yes : S.MeterDetail_No)</td>
|
||||
<td style="white-space:nowrap">@(s.LastSeenAt is { } seen ? TimeZoneInfo.ConvertTime(seen, Zone).ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture) : Format.Unknown)</td>
|
||||
<td class="mv-num">@(s.LastValue is { } v ? Format.Number(v, 2) : Format.Unknown)</td>
|
||||
<td>@(s.LastStatus ?? Format.Unknown)</td>
|
||||
<td style="text-align:right; white-space:nowrap">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OnEdit.InvokeAsync(s))"
|
||||
aria-label="@S.MeterDetail_EditSource" title="@S.MeterDetail_EditSource" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(s))"
|
||||
aria-label="@S.MeterDetail_DeleteSourceTitle" title="@S.MeterDetail_DeleteSourceTitle" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
}
|
||||
</LoadPanel>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public MeterDetailView Detail { get; set; } = null!;
|
||||
|
||||
/// <summary>The instance zone the last-seen times are shown in.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public TimeZoneInfo Zone { get; set; } = TimeZoneInfo.Utc;
|
||||
|
||||
/// <summary>Bumped by the page whenever the meter or its sources changed.</summary>
|
||||
[Parameter]
|
||||
public int Version { get; set; }
|
||||
|
||||
/// <summary>Opens the page's source dialog: a new source (null) or an existing one.</summary>
|
||||
[Parameter]
|
||||
public EventCallback<MeterSource?> OnEdit { get; set; }
|
||||
|
||||
/// <summary>Raised after a source was deleted.</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnChanged { get; set; }
|
||||
|
||||
private readonly LoadSequencer _loads = new();
|
||||
private readonly LoadState<SourcesView> _state = new();
|
||||
private (int MeterId, int Version)? _loadedFor;
|
||||
|
||||
private sealed record SourcesView(IReadOnlyList<MeterSource> Sources, IReadOnlyList<IngestionEndpoint> Endpoints);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var key = (Detail.Id, Version);
|
||||
if (_loadedFor == key)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_loadedFor?.MeterId != Detail.Id)
|
||||
{
|
||||
_state.Clear();
|
||||
}
|
||||
|
||||
_loadedFor = key;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private Task LoadAsync()
|
||||
{
|
||||
var meterId = Detail.Id;
|
||||
return _loads.RunAsync(_state, async token =>
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync(token);
|
||||
var sources = await db.MeterSources.AsNoTracking().Where(s => s.MeterId == meterId).OrderBy(s => s.Priority).ToListAsync(token);
|
||||
var endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync(token);
|
||||
return new SourcesView(sources, endpoints);
|
||||
}, Logger);
|
||||
}
|
||||
|
||||
private static string SourceTarget(MeterSource s)
|
||||
{
|
||||
var config = SourceConfig.Parse(s.Config);
|
||||
return s.SourceType == SourceType.HomeAssistant
|
||||
? config.EntityId ?? Format.Unknown
|
||||
: config.Topic ?? Format.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Why this source cannot ingest, or null if it can. Routing is endpoint-scoped, so an unbound
|
||||
/// or mis-bound source is silently dead — and deleting a connector unlinks its sources, which
|
||||
/// used to be harmless. Without this column such a source is indistinguishable from a healthy
|
||||
/// one at "Enabled: yes".
|
||||
/// </summary>
|
||||
private static string? ConnectorProblem(SourcesView view, MeterSource source)
|
||||
{
|
||||
if (SourceRouting.RequiredEndpoint(source.SourceType) is not { } needed)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var endpoint = view.Endpoints.FirstOrDefault(e => e.Id == source.EndpointId);
|
||||
return endpoint switch
|
||||
{
|
||||
null => S.MeterDetail_ProblemNoConnector,
|
||||
{ IsEnabled: false } => Loc.F(S.MeterDetail_ProblemDisabled, endpoint.Name),
|
||||
_ when endpoint.Type != needed => Loc.F(S.MeterDetail_ProblemTypeMismatch, endpoint.Name, endpoint.Type.Display(), needed.Display()),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(MeterSource source)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteSourceTitle,
|
||||
Loc.F(S.MeterDetail_DeleteSourceConfirm, source.SourceType.Display())))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using (var db = await DbFactory.CreateDbContextAsync())
|
||||
{
|
||||
await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
Snackbar.Add(S.MeterDetail_SourceDeleted, Severity.Success);
|
||||
await OnChanged.InvokeAsync();
|
||||
}
|
||||
|
||||
public void Dispose() => _loads.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
@using MeterVault.App.TariffEditing
|
||||
@implements IDisposable
|
||||
@inject MeterDetailService Details
|
||||
@inject InstanceClock Clock
|
||||
@inject ILogger<MeterTariffsTab> Logger
|
||||
|
||||
@* The prices that can apply to this meter (brief §7.2): its own, its energy type's and the global ones, each with its
|
||||
validity. A meter price wins over the type's, the type's over the global one. "Add tariff for this meter" opens the
|
||||
tariff editor prefilled for exactly this meter (D-52). *@
|
||||
|
||||
<div class="d-flex align-center justify-space-between flex-wrap mb-3" style="gap:.5rem">
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.MeterDetail_TariffsIntro</MudText>
|
||||
<div class="d-flex flex-wrap" style="gap:.5rem">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="@TariffLinks.New(TariffScope.Meter, Detail.Id, DefaultComponent, new DateOnly(Clock.Today.Year, Clock.Today.Month, 1))">
|
||||
@S.MeterDetail_AddMeterTariff
|
||||
</MudButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary" Href="@TariffLinks.Path"
|
||||
StartIcon="@Icons.Material.Filled.Sell">@S.MeterDetail_ManageTariffs</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoadPanel State="_state" OnRetry="LoadAsync" Context="tariffs" PlaceholderHeight="120">
|
||||
@if (tariffs.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.MeterDetail_NoTariffs</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mv-table-scroll" role="region" aria-label="@S.MeterDetail_TabTariffs" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@S.Common_Scope</th>
|
||||
<th scope="col">@S.MeterDetail_Component</th>
|
||||
<th scope="col" class="mv-num">@S.Common_Value</th>
|
||||
<th scope="col">@S.Common_Unit</th>
|
||||
<th scope="col">@S.MeterDetail_From</th>
|
||||
<th scope="col">@S.MeterDetail_To</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var line in Lines(tariffs, Clock.Today))
|
||||
{
|
||||
var t = line.Row;
|
||||
<tr>
|
||||
<td>@ScopeText(t)</td>
|
||||
<td>
|
||||
@t.Component.Display()
|
||||
@if (line.AppliesNow)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary" Class="ml-1">@S.MeterDetail_TariffCurrent</MudChip>
|
||||
}
|
||||
</td>
|
||||
<td class="mv-num">@Format.Number(t.Value, 4)</td>
|
||||
<td>@t.Unit</td>
|
||||
<td style="white-space:nowrap">@Format.Date(t.ValidFrom)</td>
|
||||
<td style="white-space:nowrap">@(line.EffectiveTo is { } to ? Format.Date(to) : S.MeterDetail_TariffOpenEnd)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Class="mv-muted mt-1">@S.MeterDetail_TariffsCurrentNote</MudText>
|
||||
}
|
||||
</LoadPanel>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public MeterDetailView Detail { get; set; } = null!;
|
||||
|
||||
/// <summary>Bumped by the page whenever the meter changed.</summary>
|
||||
[Parameter]
|
||||
public int Version { get; set; }
|
||||
|
||||
private readonly LoadSequencer _loads = new();
|
||||
private readonly LoadState<IReadOnlyList<TariffRow>> _state = new();
|
||||
private (int MeterId, int Version)? _loadedFor;
|
||||
|
||||
/// <summary>What a new price for this meter usually is: the credit for an export meter, the unit price otherwise.</summary>
|
||||
private TariffComponent DefaultComponent => Detail.Kind == QuantityKind.Export ? TariffComponent.FeedIn : TariffComponent.UnitPrice;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var key = (Detail.Id, Version);
|
||||
if (_loadedFor == key)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_loadedFor?.MeterId != Detail.Id)
|
||||
{
|
||||
_state.Clear();
|
||||
}
|
||||
|
||||
_loadedFor = key;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private Task LoadAsync()
|
||||
{
|
||||
var meterId = Detail.Id;
|
||||
return _loads.RunAsync(_state, token => Details.GetTariffsAsync(meterId, token), Logger);
|
||||
}
|
||||
|
||||
/// <summary>A tariff with the day it effectively ends, and whether it is the price that applies today.</summary>
|
||||
private sealed record TariffLine(TariffRow Row, DateOnly? EffectiveTo, bool AppliesNow);
|
||||
|
||||
/// <summary>
|
||||
/// Each tariff runs until its own end or the day before the next one of the same scope and component starts; per
|
||||
/// component the one valid today with the narrowest scope applies (meter, then energy type, then global).
|
||||
/// </summary>
|
||||
private static List<TariffLine> Lines(IReadOnlyList<TariffRow> tariffs, DateOnly today)
|
||||
{
|
||||
var ends = TariffValidity.EffectiveEnds(
|
||||
tariffs.Select(t => new TariffSpan(t.Id, t.Scope, t.ScopeId, t.Component, t.ValidFrom, t.ValidTo)));
|
||||
|
||||
bool Valid(TariffRow t) => t.ValidFrom <= today && (ends[t.Id] is not { } end || end >= today);
|
||||
var current = tariffs.Where(Valid)
|
||||
.GroupBy(t => t.Component)
|
||||
.Select(g => g.OrderBy(t => t.Scope switch { TariffScope.Meter => 0, TariffScope.EnergyType => 1, _ => 2 }).First().Id)
|
||||
.ToHashSet();
|
||||
return [.. tariffs.Select(t => new TariffLine(t, ends[t.Id], current.Contains(t.Id)))];
|
||||
}
|
||||
|
||||
private string ScopeText(TariffRow tariff) => tariff.Scope switch
|
||||
{
|
||||
TariffScope.Meter => S.MeterDetail_ScopeThisMeter,
|
||||
TariffScope.EnergyType => $"{tariff.Scope.Display()}: {Detail.EnergyType}",
|
||||
_ => tariff.Scope.Display(),
|
||||
};
|
||||
|
||||
public void Dispose() => _loads.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@* A row's quality in words, with a colour that only repeats what the word says. *@
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text"
|
||||
Color="@(Quality == ReadingQuality.Measured ? Color.Success : Quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@Quality.Display()</MudChip>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public ReadingQuality Quality { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@* The paging row under a record table (D-50): which rows are shown out of how many the filter holds, and the way to
|
||||
the newest, newer and older pages. Keyset pages, so "Newer" returns to exactly the page it came from. *@
|
||||
|
||||
<nav class="mv-pager" aria-label="@S.MeterDetail_PagerLabel">
|
||||
<MudText Typo="Typo.caption" Class="mv-muted mv-pager__range">@RangeText</MudText>
|
||||
<div class="mv-pager__buttons">
|
||||
@if (Pager.PageIndex > 1)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.FirstPage" OnClick="OnNewest">@S.MeterDetail_PageNewest</MudButton>
|
||||
}
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.ChevronLeft"
|
||||
Disabled="@(!Pager.CanGoNewer)" OnClick="OnNewer">@S.MeterDetail_PageNewer</MudButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" EndIcon="@Icons.Material.Filled.ChevronRight"
|
||||
Disabled="@(!HasOlder)" OnClick="OnOlder">@S.MeterDetail_PageOlder</MudButton>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public RecordPager Pager { get; set; } = null!;
|
||||
|
||||
/// <summary>Rows on the page shown.</summary>
|
||||
[Parameter]
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>Rows the filter holds (up to the count cap).</summary>
|
||||
[Parameter]
|
||||
public int Total { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool TotalIsCapped { get; set; }
|
||||
|
||||
/// <summary>True when an older page exists.</summary>
|
||||
[Parameter]
|
||||
public bool HasOlder { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnNewest { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnNewer { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnOlder { get; set; }
|
||||
|
||||
private string RangeText
|
||||
{
|
||||
get
|
||||
{
|
||||
var first = Pager.FirstRow;
|
||||
var last = first + Count - 1;
|
||||
var total = Total.ToString("N0", System.Globalization.CultureInfo.CurrentCulture) + (TotalIsCapped ? "+" : string.Empty);
|
||||
return Loc.F(S.MeterDetail_PageRange,
|
||||
first.ToString("N0", System.Globalization.CultureInfo.CurrentCulture),
|
||||
last.ToString("N0", System.Globalization.CultureInfo.CurrentCulture),
|
||||
total);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.mv-pager {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .25rem 1rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.mv-pager__buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .25rem;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using MeterVault.App.Analysis;
|
||||
using MeterVault.App.MeterDetails;
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Infrastructure.Dashboard;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace MeterVault.App.Components.Pages.MeterPage;
|
||||
|
||||
/// <summary>One committed page of a record tab: for which meter, over which period, and the rows.</summary>
|
||||
public sealed record RecordView<TRow>(int MeterId, ResolvedPeriod Period, RecordRange Range, RecordPage<TRow> Page);
|
||||
|
||||
/// <summary>
|
||||
/// The shared behaviour of the meter page's record tabs — Readings, Normalized data, Events (D-50): the rows of the page
|
||||
/// period (the page's own <c>period</c>/<c>from</c>/<c>to</c> keys, so a drill-down lands on exactly its bucket), one
|
||||
/// keyset page of <see cref="MeterDetailService.PageSize"/> at a time, newest first, loaded through a
|
||||
/// <see cref="LoadSequencer"/> so a late answer for another meter, range or page is never shown. A new meter, period or
|
||||
/// <see cref="Version"/> (the page's data changed) starts again at the newest rows.
|
||||
/// </summary>
|
||||
public abstract class RecordTabBase<TRow> : ComponentBase, IDisposable
|
||||
{
|
||||
private (int MeterId, AnalysisQuery Query, int Version)? _loadedFor;
|
||||
|
||||
/// <summary>The meter.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public MeterDetailView Detail { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis state; its period filters the rows.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Query { get; set; } = null!;
|
||||
|
||||
/// <summary>Bumped by the page whenever the meter's data changed (a reading saved, an event deleted, …).</summary>
|
||||
[Parameter]
|
||||
public int Version { get; set; }
|
||||
|
||||
/// <summary>A period chosen in the tab's toolbar: the page writes it into its address (replace).</summary>
|
||||
[Parameter]
|
||||
public EventCallback<AnalysisQuery> OnQueryChanged { get; set; }
|
||||
|
||||
/// <summary>Raised after the tab changed the meter's data itself (a deletion), so the page refreshes everything.</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnChanged { get; set; }
|
||||
|
||||
[Inject]
|
||||
protected AnalysisPeriods Periods { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
protected InstanceClock Clock { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
protected MeterDetailService Details { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
protected ILoggerFactory LoggerFactory { get; set; } = null!;
|
||||
|
||||
/// <summary>Where the tab is in its pages.</summary>
|
||||
protected RecordPager Pager { get; } = new();
|
||||
|
||||
/// <summary>The tab's load.</summary>
|
||||
protected LoadState<RecordView<TRow>> State { get; } = new();
|
||||
|
||||
/// <summary>The page defaults (the toolbar's Reset).</summary>
|
||||
protected AnalysisDefaults Defaults => MeterAnalysisLoader.DefaultsFor(Detail.Id);
|
||||
|
||||
/// <summary>The committed page when it is this meter's.</summary>
|
||||
protected RecordView<TRow>? Current => State.Value is { } value && value.MeterId == Detail.Id ? value : null;
|
||||
|
||||
private LoadSequencer Loads { get; } = new();
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var key = (Detail.Id, MeterAnalysisLoader.ForMeter(Query, Detail.Id), Version);
|
||||
if (_loadedFor == key)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_loadedFor?.MeterId != Detail.Id)
|
||||
{
|
||||
State.Clear();
|
||||
}
|
||||
|
||||
_loadedFor = key;
|
||||
Pager.Reset();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
/// <summary>Reads one page of rows.</summary>
|
||||
protected abstract Task<RecordPage<TRow>> FetchAsync(int meterId, RecordRange range, RecordCursor? cursor, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Loads the page the pager points at.</summary>
|
||||
protected Task LoadAsync()
|
||||
{
|
||||
var meterId = Detail.Id;
|
||||
var query = MeterAnalysisLoader.ForMeter(Query, meterId);
|
||||
var cursor = Pager.Current;
|
||||
return Loads.RunAsync(State, async token =>
|
||||
{
|
||||
var period = await Periods.ResolveAsync(query, Clock.Now, token);
|
||||
var range = MeterRecordRange.Of(period);
|
||||
var page = await FetchAsync(meterId, range, cursor, token);
|
||||
return new RecordView<TRow>(meterId, period, range, page);
|
||||
}, LoggerFactory.CreateLogger(GetType()));
|
||||
}
|
||||
|
||||
protected async Task OlderAsync()
|
||||
{
|
||||
if (Current?.Page.Next is { } next)
|
||||
{
|
||||
Pager.Older(next);
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task NewerAsync()
|
||||
{
|
||||
Pager.Newer();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
protected async Task NewestAsync()
|
||||
{
|
||||
Pager.Reset();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
/// <summary>Shows every record: the <c>all</c> period (no date filter on the record tabs).</summary>
|
||||
protected Task ShowAllAsync() => OnQueryChanged.InvokeAsync(Query.WithPeriod(PeriodPreset.AllHistory));
|
||||
|
||||
/// <summary>True for a record dated after the instant the page read now at (D-04): it is listed, and marked.</summary>
|
||||
protected bool IsAfterNow(DateTimeOffset time) => Current is { } view && MeterRecordRange.IsAfterNow(time, view.Period);
|
||||
|
||||
/// <summary>An instant in the instance zone, as the record tables show it.</summary>
|
||||
protected string Local(DateTimeOffset instant) =>
|
||||
TimeZoneInfo.ConvertTime(instant, Periods.Zone).ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Loads.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@* The meter page's parts (brief §7.2) speak the analysis layer's types and the meter page's own helpers. *@
|
||||
@using MeterVault.App.MeterDetails
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Core.Analysis.Coverage
|
||||
@using MeterVault.Core.Analysis.Quantities
|
||||
@using MeterVault.Core.Analysis.Virtual
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@@ -1,151 +1,126 @@
|
||||
@page "/meters"
|
||||
@using MeterVault.App.Energy
|
||||
@using MeterVault.App.Components.Shared.MeterLists
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
@inject InstanceClock Clock
|
||||
@inject AnalysisPeriods Periods
|
||||
@inject AnalysisReader Reader
|
||||
@inject MeterVault.Infrastructure.Analysis.VirtualMeterService VirtualMeters
|
||||
@inject NavState NavState
|
||||
@inject ILogger<Meters> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MeterVault — @S.Nav_Meters</PageTitle>
|
||||
@* Every meter (brief §7.3, §3.2): the shared meter list, grouped by energy type with a type filter and a search, each
|
||||
meter with what it measured in the chosen period — a calculated meter by its formula, a meter without data in words —
|
||||
and how it counts. Names open the meter's Analysis tab for the same period; the quick entry, edit and delete stay
|
||||
one click away. Deleting names the calculated meters that depend on the meter first (D-33). *@
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">@S.Common_Meters</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => _editor!.OpenNewAsync())">
|
||||
@S.Meters_AddMeter
|
||||
</MudButton>
|
||||
</div>
|
||||
<PageHeader Title="@S.Nav_Meters" Description="@S.Meters_Description">
|
||||
<Breadcrumbs>
|
||||
<AnalysisBreadcrumbs Query="_query" Current="@S.Nav_Meters" />
|
||||
</Breadcrumbs>
|
||||
<Actions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => _editor!.OpenNewAsync())">
|
||||
@S.Meters_AddMeter
|
||||
</MudButton>
|
||||
</Actions>
|
||||
</PageHeader>
|
||||
|
||||
@if (_meters is null)
|
||||
@if (_query is not null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
<PeriodToolbar Query="_query" Period="_state.Value?.Period" Defaults="AnalysisDefaults.History" QueryChanged="OnQueryChanged"
|
||||
ShowBucket="false" ShowComparison="false" Class="mb-3" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@* The whole row opens the meter — on a phone this table collapses to cards, where a name link
|
||||
is a small target between edit and delete. Grouped by energy type, because that is how people
|
||||
look for a meter ("the water one"), not by the order they were created in. *@
|
||||
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2" Filter="Matches"
|
||||
GroupBy="_byEnergyType" OnRowClick="@((TableRowClickEventArgs<Meter> e) => Nav.NavigateTo(MeterLinks.Detail(e.Item!.Id)))"
|
||||
RowClass="cursor-pointer">
|
||||
<ToolBarContent>
|
||||
<MudTextField T="string" @bind-Value="_search" Immediate="true" Placeholder="@S.Meters_SearchPlaceholder"
|
||||
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Clearable="true"
|
||||
Class="mt-0" Style="max-width:420px" />
|
||||
</ToolBarContent>
|
||||
<HeaderContent>
|
||||
<MudTh>@S.Common_Name</MudTh>
|
||||
<MudTh>@S.Common_Mode</MudTh>
|
||||
<MudTh>@S.Common_Unit</MudTh>
|
||||
<MudTh>@S.Meters_Sources</MudTh>
|
||||
<MudTh>@S.Common_LastSeen</MudTh>
|
||||
<MudTh>@S.Meters_Active</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<GroupHeaderTemplate>
|
||||
<MudTh colspan="7" Class="mud-table-cell-custom-group">
|
||||
<MudText Typo="Typo.subtitle2">@context.Key</MudText>
|
||||
</MudTh>
|
||||
</GroupHeaderTemplate>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="@S.Common_Name">
|
||||
@* The link stays a real link (keyboard, open in new tab) but must not also fire the row's
|
||||
click, or one tap pushes the same page onto the history twice. *@
|
||||
<span @onclick:stopPropagation="true"><MudLink Href="@MeterLinks.Detail(context.Id)">@context.Name</MudLink></span>
|
||||
@if (!context.IsActive)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Class="ml-2">@S.MeterDetail_Retired</MudChip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Mode" HideSmall="true">@context.Mode.Display()</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Unit" HideSmall="true">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="@S.Meters_Sources" HideSmall="true">@context.Sources.Count</MudTd>
|
||||
<MudTd DataLabel="@S.Common_LastSeen" HideSmall="true">
|
||||
@{
|
||||
var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max();
|
||||
}
|
||||
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
|
||||
</MudTd>
|
||||
<MudTd DataLabel="@S.Meters_Active" HideSmall="true">@(context.IsActive ? S.Meters_Yes : S.Meters_No)</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
|
||||
@* Buttons inside a clickable row must not also open the meter. *@
|
||||
<div class="d-inline-flex" @onclick:stopPropagation="true">
|
||||
@if (MeterLinks.QuickEntry(context.Id, context.Mode) is { } entry)
|
||||
{
|
||||
<MudTooltip Text="@(context.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)">
|
||||
<MudIconButton Icon="@(context.Mode == MeterMode.ConsumableBalance ? Icons.Material.Filled.Straighten : Icons.Material.Filled.EditNote)"
|
||||
Size="Size.Small" Color="Color.Primary" Href="@entry"
|
||||
aria-label="@(context.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)" />
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => _editor!.OpenAsync(context.Id))" aria-label="@S.MeterDetail_EditMeter" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" aria-label="@S.Common_Delete" />
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
@if (_meters.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@Loc.F(S.Meters_NoSearchMatch, _search)</MudText>
|
||||
}
|
||||
</NoRecordsContent>
|
||||
</MudTable>
|
||||
|
||||
@if (_meters.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
@S.Meters_EmptyBefore <MudLink Href="/import">@S.Nav_Import</MudLink> @S.Meters_EmptyAfter
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
<LoadPanel State="_state" OnRetry="Retry" Context="list" PlaceholderHeight="320">
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-2 pa-sm-4">
|
||||
<MeterList Rows="list.Rows" Query="_query" GroupByType="true" ShowTypeFilter="true"
|
||||
OnEdit="@(id => _editor!.OpenAsync(id))" OnDelete="DeleteAsync">
|
||||
<EmptyContent>
|
||||
<MudAlert Severity="Severity.Info">
|
||||
@S.Meters_EmptyBefore <MudLink Href="/import">@S.Nav_Import</MudLink> @S.Meters_EmptyAfter
|
||||
</MudAlert>
|
||||
</EmptyContent>
|
||||
</MeterList>
|
||||
</MudPaper>
|
||||
</LoadPanel>
|
||||
|
||||
<MeterEditor @ref="_editor" Saved="OnSavedAsync" SwapInsteadRequested="@(id => Nav.NavigateTo(MeterLinks.Event(id, MeterEventType.MeterSwap)))" />
|
||||
|
||||
@code {
|
||||
private List<Meter>? _meters;
|
||||
private readonly LoadSequencer _loads = new();
|
||||
private readonly LoadState<MeterListState> _state = new();
|
||||
private AnalysisQuery? _query;
|
||||
private MeterEditor? _editor;
|
||||
private string? _search;
|
||||
|
||||
private readonly TableGroupDefinition<Meter> _byEnergyType = new()
|
||||
/// <summary>What the list shows for one period: the resolved period and a row per meter.</summary>
|
||||
private sealed record MeterListState(ResolvedPeriod Period, IReadOnlyList<MeterListRow> Rows);
|
||||
|
||||
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
|
||||
|
||||
protected override Task OnParametersSetAsync() => SyncAsync();
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () =>
|
||||
{
|
||||
Indentation = false,
|
||||
Expandable = false,
|
||||
Selector = m => m.EnergyType?.DisplayName ?? "—",
|
||||
};
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var meters = await db.Meters
|
||||
.AsNoTracking()
|
||||
.Include(m => m.EnergyType)
|
||||
.Include(m => m.Sources)
|
||||
.ToListAsync();
|
||||
|
||||
// Grouping follows item order, so sort by the group label first. Retired meters sink to the
|
||||
// bottom of their group: their history still counts, but nobody reads them any more.
|
||||
_meters = meters
|
||||
.OrderBy(m => m.EnergyType?.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ThenBy(m => !m.IsActive)
|
||||
.ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private bool Matches(Meter meter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_search))
|
||||
// Only this page's own address: a link away fires this too, just before the page goes.
|
||||
var path = Nav.ToBaseRelativePath(e.Location);
|
||||
var end = path.IndexOfAny(['?', '#']);
|
||||
if (!string.Equals((end >= 0 ? path[..end] : path).TrimEnd('/'), "meters", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
var term = _search.Trim();
|
||||
return Contains(meter.Name) || Contains(meter.SerialNumber) || Contains(meter.Location)
|
||||
|| Contains(meter.EnergyType?.DisplayName) || Contains(meter.Mode.Display());
|
||||
await SyncAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
bool Contains(string? value) => value?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true;
|
||||
private async Task SyncAsync()
|
||||
{
|
||||
var query = AnalysisQuery.Parse(Nav.Uri, AnalysisDefaults.History);
|
||||
if (query == _query)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_query = query;
|
||||
await LoadAsync(query);
|
||||
}
|
||||
|
||||
private Task Retry() => _query is null ? Task.CompletedTask : LoadAsync(_query);
|
||||
|
||||
private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, token => ReadAsync(query, token), Logger);
|
||||
|
||||
/// <summary>
|
||||
/// The meters and their period totals: one portfolio read with every meter's own series, in year buckets — only the
|
||||
/// totals are shown, and they do not depend on the buckets — without a comparison.
|
||||
/// </summary>
|
||||
private async Task<MeterListState> ReadAsync(AnalysisQuery query, CancellationToken token)
|
||||
{
|
||||
var period = await Periods.ResolveAsync(query.WithMetric(null).WithScope(QueryScope.Portfolio), Clock.Now, token);
|
||||
List<MeterFacts> meters;
|
||||
await using (var db = await DbFactory.CreateDbContextAsync(token))
|
||||
{
|
||||
meters = await MeterFacts.LoadAsync(db, null, token);
|
||||
}
|
||||
|
||||
AnalysisResult? result = null;
|
||||
if (meters.Count > 0)
|
||||
{
|
||||
var request = new AnalysisRequest(AnalysisScope.Portfolio, period) { Bucket = BucketSize.Year, IncludeMeterSeries = true };
|
||||
result = await Reader.ReadAsync(request, token);
|
||||
}
|
||||
|
||||
return new MeterListState(period, MeterListRows.Build(meters, result));
|
||||
}
|
||||
|
||||
private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, AnalysisDefaults.History);
|
||||
|
||||
/// <summary>A new meter goes straight to its own page, where adding readings or a source is the next step.</summary>
|
||||
private async Task OnSavedAsync((int MeterId, bool Created) saved)
|
||||
{
|
||||
@@ -155,10 +130,10 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
await Retry();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Meter meter)
|
||||
private async Task DeleteAsync(MeterFacts meter)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id);
|
||||
@@ -167,19 +142,30 @@ else
|
||||
? Loc.F(S.Meters_DeleteConfirmWithData, meter.Name, readings, consumption)
|
||||
: Loc.F(S.Meters_DeleteConfirm, meter.Name);
|
||||
|
||||
// Virtual meters whose formula reads this one break with it (D-33): name them before anything is deleted.
|
||||
var dependents = await VirtualMeters.GetDependentsAsync(meter.Id);
|
||||
if (dependents.Count > 0)
|
||||
{
|
||||
message += " " + Loc.F(S.Meters_DeleteVirtualDependents, string.Join(", ", dependents.Select(d => d.Name)));
|
||||
}
|
||||
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.Meters_DeleteTitle, message))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
// reading/consumption FKs are Restrict — remove them first; events/sources/tank/members cascade.
|
||||
await db.Consumption.Where(c => c.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||
await db.Readings.Where(r => r.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||
await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync();
|
||||
await tx.CommitAsync();
|
||||
// Readings and consumption first (restricted keys), and its meter-scoped prices, which no key ties to it;
|
||||
// events, sources, tank and members cascade.
|
||||
await MeterVault.Infrastructure.Persistence.EntityDeletion.DeleteMeterAsync(db, meter.Id);
|
||||
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
await LoadAsync();
|
||||
NavState.NotifyMetersChanged();
|
||||
await Retry();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Nav.LocationChanged -= OnLocationChanged;
|
||||
_loads.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
@* What changed (brief §7.1 item 4): the bill ranked by its change, either by cost category (the composition's
|
||||
slices, D-42) or by what is billed (bill lines, standing charges, manual costs). Each row has the current and the
|
||||
comparison figure, the absolute change always and the percentage only where it applies (D-08), both over the
|
||||
coverage both periods share (D-07, marked ¹ when that is less than both whole periods); a row links to its scoped
|
||||
analysis with the same dates. The rows of either grouping add up to the bill in the total row. From the small
|
||||
breakpoint up a table; on a phone each row stacks into labelled lines, so the change is never scrolled away. *@
|
||||
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-ov-panel">
|
||||
<div class="mv-ov-panel__head">
|
||||
<h2 class="mud-typography mud-typography-h6 mv-ov-panel__title">@S.Overview_Changes</h2>
|
||||
<MudButtonGroup Variant="Variant.Outlined" Size="Size.Small" aria-label="@S.Overview_ChangesGrouping">
|
||||
<MudButton OnClick="() => _byMeter = false" Variant="@(_byMeter ? Variant.Outlined : Variant.Filled)" Color="Color.Primary"
|
||||
aria-pressed="@(_byMeter ? "false" : "true")">@S.Overview_ChangesByCategory</MudButton>
|
||||
<MudButton OnClick="() => _byMeter = true" Variant="@(_byMeter ? Variant.Filled : Variant.Outlined)" Color="Color.Primary"
|
||||
aria-pressed="@(_byMeter ? "true" : "false")">@S.Overview_ChangesByMeter</MudButton>
|
||||
</MudButtonGroup>
|
||||
</div>
|
||||
|
||||
@if (Lines.Count <= 1)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.Overview_ChangesEmpty</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable T="ChangeLine" Items="Lines" Dense="true" Hover="true" Elevation="0" Breakpoint="Breakpoint.Sm"
|
||||
Class="mv-ov-changes" RowClassFunc="@((line, _) => line.IsTotal ? "mv-ov-total" : string.Empty)">
|
||||
<HeaderContent>
|
||||
<MudTh>@NameHeader</MudTh>
|
||||
<MudTh Class="mv-num">@S.Overview_ColCurrent</MudTh>
|
||||
@if (_compared)
|
||||
{
|
||||
<MudTh Class="mv-num">@View.Query.Comparison.Display()</MudTh>
|
||||
<MudTh Class="mv-num">@S.AnalysisTable_Change</MudTh>
|
||||
<MudTh Class="mv-num">@S.Overview_ColPercentShort</MudTh>
|
||||
}
|
||||
</HeaderContent>
|
||||
<RowTemplate Context="line">
|
||||
<MudTd DataLabel="@NameHeader">
|
||||
<span>
|
||||
@if (line.Href is not null)
|
||||
{
|
||||
<MudLink Href="@line.Href" Typo="Typo.body2">@line.Name</MudLink>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@line.Name</span>
|
||||
}
|
||||
@if (line.Detail is not null)
|
||||
{
|
||||
<span class="mv-cell-secondary d-block">@line.Detail</span>
|
||||
}
|
||||
</span>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="@S.Overview_ColCurrent" Class="mv-num">@FigureCell(line.Current)</MudTd>
|
||||
@if (_compared)
|
||||
{
|
||||
<MudTd DataLabel="@View.Query.Comparison.Display()" Class="mv-num">@FigureCell(line.Previous)</MudTd>
|
||||
<MudTd DataLabel="@S.AnalysisTable_Change" Class="@("mv-num " + line.Tone)">@line.ChangeText</MudTd>
|
||||
<MudTd DataLabel="@S.Overview_ColPercent" Class="@("mv-num " + line.Tone)">@line.PercentText</MudTd>
|
||||
}
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@if (_compared && Lines.Any(l => l.Change.IsPartial))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mt-1">@S.Overview_MatchedNote</MudText>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
/// <summary>The committed Overview.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public OverviewView View { get; set; } = null!;
|
||||
|
||||
/// <summary>One line of the table: a row of either grouping, or the bill's total.</summary>
|
||||
private sealed record ChangeLine(
|
||||
string Name,
|
||||
string? Href,
|
||||
string? Detail,
|
||||
CostAmount? Current,
|
||||
CostAmount? Previous,
|
||||
CostChange Change,
|
||||
bool IsTotal,
|
||||
string Tone,
|
||||
string ChangeText,
|
||||
string PercentText);
|
||||
|
||||
/// <summary>The footnote marker of a change measured over less than both whole periods (D-07).</summary>
|
||||
private const string Marker = " ¹";
|
||||
|
||||
private bool _byMeter;
|
||||
private bool _compared;
|
||||
private object? _builtFrom;
|
||||
private List<ChangeLine> _categories = [];
|
||||
private List<ChangeLine> _meters = [];
|
||||
|
||||
private IReadOnlyList<ChangeLine> Lines => _byMeter ? _meters : _categories;
|
||||
|
||||
private string NameHeader => _byMeter ? S.Overview_ColItem : S.Dashboard_ColCategory;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ReferenceEquals(_builtFrom, View))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_builtFrom = View;
|
||||
_compared = View.Data.PreviousCost is not null;
|
||||
_categories = LinesOf(View.Data.CategoryChanges);
|
||||
_meters = LinesOf(View.Data.LineChanges);
|
||||
}
|
||||
|
||||
private List<ChangeLine> LinesOf(IReadOnlyList<OverviewChangeRow> rows)
|
||||
{
|
||||
var lines = rows
|
||||
.Select(r => Line(OverviewText.NameOf(r, View.Data.MeterNames), OverviewText.HrefOf(r, View.Query), OverviewText.DetailOf(r), r.Current, r.Previous, r.Change, false))
|
||||
.ToList();
|
||||
lines.Add(Line(S.Overview_BillTotal, null, null, View.Data.Cost.Total, View.Data.PreviousCost?.Total, View.Data.CostChange, true));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private ChangeLine Line(string name, string? href, string? detail, CostAmount? current, CostAmount? previous, CostChange change, bool isTotal)
|
||||
{
|
||||
var tone = ChangeDisplay.CssClass(ChangeDisplay.Tone(change.Change, ChangePolarities.ForCost(change.Current, change.Previous)));
|
||||
var text = Format.ChangeAbsolute(change.Change, v => Format.Money(v, View.Currency)) + (change.IsPartial ? Marker : string.Empty);
|
||||
var percent = change.Change.IsAvailable ? Format.ChangePercent(change.Change) : Format.Unknown;
|
||||
return new ChangeLine(name, href, detail, current, previous, change, isTotal, tone, text, percent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A figure's amount with its status underneath when it is not complete ("Partial"), its status in words when it has
|
||||
/// no amount ("Not priced (no tariff)"), or "—" when the row does not occur.
|
||||
/// </summary>
|
||||
private RenderFragment FigureCell(CostAmount? amount)
|
||||
{
|
||||
if (amount is null)
|
||||
{
|
||||
return @<span class="mv-unknown">@Format.Unknown</span>;
|
||||
}
|
||||
|
||||
var status = FigureText.Of(amount);
|
||||
if (!status.IsKnown)
|
||||
{
|
||||
return amount.Status == CostStatus.Priced
|
||||
? @<span class="mv-unknown">@Format.Unknown</span>
|
||||
: @<span class="mv-unknown">@status.Status</span>;
|
||||
}
|
||||
|
||||
var text = Format.Money(amount.Cost, View.Currency);
|
||||
return status.IsComplete
|
||||
? @<span class="mv-ov-amount">@text</span>
|
||||
: @<span class="mv-qualified" title="@status.Detail"><span class="mv-ov-amount">@text</span><span class="mv-cell-secondary d-block">@OverviewText.CostQualifier(amount, status)</span></span>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
@* The bill's composition (brief §7.1 item 5, D-42): the disjoint cost categories, Uncategorized and the standing
|
||||
charges no category holds — rows that add up to the bill, manual costs in them exactly once. A donut only when every
|
||||
slice is ≥ 0 (DonutAllowed); a credit larger than its charges is drawn as signed bars around zero instead. Categories
|
||||
that overlap another (a view on the bill) are listed apart, as views, and never added up. Setting up categories is
|
||||
never required: without any, the whole bill is Uncategorized, and a hint says how to split it. *@
|
||||
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-ov-panel">
|
||||
<div class="mv-ov-panel__head">
|
||||
<h2 class="mud-typography mud-typography-h6 mv-ov-panel__title">@S.Overview_Composition</h2>
|
||||
</div>
|
||||
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.Overview_CompositionEmpty</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_donut.Count > 1)
|
||||
{
|
||||
<OverviewDonut Slices="_donut" Currency="@View.Currency" Title="@S.Overview_Composition" />
|
||||
}
|
||||
else if (_signed)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-1">@S.Overview_SignedBarsNote</MudText>
|
||||
}
|
||||
|
||||
<MudTable T="Row" Items="_rows" Dense="true" Hover="true" Elevation="0" Breakpoint="Breakpoint.Sm" Class="mv-ov-composition"
|
||||
RowClassFunc="@((row, _) => row.IsTotal ? "mv-ov-total" : string.Empty)">
|
||||
<HeaderContent>
|
||||
<MudTh>@S.Dashboard_ColCategory</MudTh>
|
||||
@if (_signed)
|
||||
{
|
||||
<MudTh><span class="mv-sr-only">@S.Overview_SignedBars</span></MudTh>
|
||||
}
|
||||
<MudTh Class="mv-num">@S.AnalysisTable_Cost</MudTh>
|
||||
@if (!_signed)
|
||||
{
|
||||
<MudTh Class="mv-num">@S.Overview_Share</MudTh>
|
||||
}
|
||||
</HeaderContent>
|
||||
<RowTemplate Context="row">
|
||||
<MudTd DataLabel="@S.Dashboard_ColCategory">
|
||||
<span>
|
||||
<span class="mv-ov-swatch" style="@SwatchStyle(row)" aria-hidden="true"></span>
|
||||
@if (row.Href is not null)
|
||||
{
|
||||
<MudLink Href="@row.Href" Typo="Typo.body2">@row.Name</MudLink>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@row.Name</span>
|
||||
}
|
||||
</span>
|
||||
</MudTd>
|
||||
@if (_signed)
|
||||
{
|
||||
<MudTd DataLabel="@S.Overview_SignedBars" Class="mv-ov-bars-cell">
|
||||
@if (!row.IsTotal)
|
||||
{
|
||||
<span class="mv-ov-bars" aria-hidden="true">
|
||||
<span class="mv-ov-bars__neg"><span class="mv-ov-bars__bar" style="@BarStyle(row.Amount, negative: true)"></span></span>
|
||||
<span class="mv-ov-bars__pos"><span class="mv-ov-bars__bar" style="@BarStyle(row.Amount, negative: false)"></span></span>
|
||||
</span>
|
||||
}
|
||||
</MudTd>
|
||||
}
|
||||
<MudTd DataLabel="@S.AnalysisTable_Cost" Class="@(row.Status.IsKnown ? "mv-num" : "mv-num mv-unknown")">
|
||||
@if (row.Status.IsKnown)
|
||||
{
|
||||
<span title="@row.Status.Detail">
|
||||
<span class="mv-ov-amount">@Format.Money(row.Amount, View.Currency)</span>
|
||||
@if (!row.Status.IsComplete)
|
||||
{
|
||||
<span class="mv-cell-secondary d-block">@row.Chip</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span title="@row.Status.Detail">@row.Status.Status</span>
|
||||
}
|
||||
</MudTd>
|
||||
@if (!_signed)
|
||||
{
|
||||
<MudTd DataLabel="@S.Overview_Share" Class="mv-num">@(row.Share is { } share ? Format.Number(share * 100, 1) + " %" : row.IsTotal ? string.Empty : Format.Unknown)</MudTd>
|
||||
}
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
@if (_views.Count > 0)
|
||||
{
|
||||
<h3 class="mud-typography mud-typography-subtitle2 mt-4 mb-1">@S.Overview_Views</h3>
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-1">@S.Overview_ViewsNote</MudText>
|
||||
<div class="mv-table-scroll" role="region" aria-label="@S.Overview_Views" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Class="mv-ov-composition">
|
||||
<tbody>
|
||||
@foreach (var view in _views)
|
||||
{
|
||||
<tr>
|
||||
<th scope="row" class="mv-row-label">
|
||||
<MudLink Href="@view.Href" Typo="Typo.body2">@view.Name</MudLink>
|
||||
@if (view.Overlaps is not null)
|
||||
{
|
||||
<div class="mv-cell-secondary">@view.Overlaps</div>
|
||||
}
|
||||
</th>
|
||||
<td class="mv-num @(view.Status.IsKnown ? null : "mv-unknown")">
|
||||
@(view.Status.IsKnown ? Format.Money(view.Amount, View.Currency) : view.Status.Status)
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (View.Data.Setup?.FirstGap is CostSetupGap.NoCategories or CostSetupGap.NoMembers)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mt-3">
|
||||
@(View.Data.Setup.FirstGap == CostSetupGap.NoCategories ? S.Dashboard_SetupNoCategories : S.Dashboard_SetupNoMembers)
|
||||
<MudLink Href="/admin/categories" Typo="Typo.body2">@S.Nav_CostCategories</MudLink>
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
/// <summary>The committed Overview.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public OverviewView View { get; set; } = null!;
|
||||
|
||||
private sealed record Row(string Name, string? Href, string? Color, double? Amount, FigureStatus Status, double? Share)
|
||||
{
|
||||
/// <summary>The bill's total, the last line.</summary>
|
||||
public bool IsTotal { get; init; }
|
||||
|
||||
/// <summary>What qualifies a known amount: the quantities' state for a priced one, else its price coverage.</summary>
|
||||
public string Chip { get; init; } = Status.Status;
|
||||
|
||||
/// <summary>The slice's place among the donut's slices, for its palette colour.</summary>
|
||||
public int? DonutIndex { get; set; }
|
||||
}
|
||||
|
||||
private sealed record ViewRow(string Name, string Href, double? Amount, FigureStatus Status, string? Overlaps);
|
||||
|
||||
private CategoryComposition? _composition;
|
||||
private List<Row> _rows = [];
|
||||
private List<ViewRow> _views = [];
|
||||
private IReadOnlyList<OverviewDonutSlice> _donut = [];
|
||||
private bool _signed;
|
||||
private double _maxAbs;
|
||||
private object? _builtFrom;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Built once per committed value: the donut re-keys only for a new list.
|
||||
if (ReferenceEquals(_builtFrom, View))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_builtFrom = View;
|
||||
_composition = View.Data.Cost.Composition;
|
||||
_rows = [];
|
||||
_views = [];
|
||||
_donut = [];
|
||||
if (_composition is not { } composition)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var data = View.Data;
|
||||
_signed = !composition.DonutAllowed;
|
||||
var positive = composition.Slices.Sum(s => s.Total.Cost is { } c && c > 0 ? c : 0);
|
||||
foreach (var slice in composition.Slices)
|
||||
{
|
||||
var status = FigureText.Of(slice.Total);
|
||||
var category = slice.CategoryId is { } id ? composition.Categories.FirstOrDefault(c => c.CategoryId == id) : null;
|
||||
|
||||
// A slice with nothing in it (a category without members) is not a row; one that could not be priced is, and
|
||||
// so is a category whose members price nothing (calculated views, generation, A-22): it says so.
|
||||
if (!status.IsKnown && slice.Total.Status == CostStatus.Priced)
|
||||
{
|
||||
if (category?.Cover is not { CoverMeterIds.Count: 0, AnalysisOnlyMeterIds.Count: > 0 })
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
status = new FigureStatus(false, false, true, S.Overview_CategoryPricesNothing, S.Overview_CategoryPricesNothingDetail, string.Empty);
|
||||
}
|
||||
|
||||
var href = slice.CategoryId is { } categoryId ? AnalysisLinks.Analysis(QueryScope.ForCategory(categoryId), AnalysisMetric.Cost, View.Query) : null;
|
||||
var amount = status.IsKnown ? slice.Total.Cost : null;
|
||||
double? share = !_signed && amount is { } a && positive > CategoryComposition.DonutTolerance ? a / positive : null;
|
||||
_rows.Add(new Row(OverviewText.SliceName(slice, composition, data.EnergyTypes, data.MeterNames), href, OverviewDonutSlice.SafeColor(category?.ColorHex), amount, status, share)
|
||||
{
|
||||
Chip = OverviewText.CostQualifier(slice.Total, status),
|
||||
});
|
||||
}
|
||||
|
||||
_maxAbs = _rows.Select(r => Math.Abs(r.Amount ?? 0)).DefaultIfEmpty(0).Max();
|
||||
if (!_signed)
|
||||
{
|
||||
var inDonut = _rows.Where(r => r.Amount is > CategoryComposition.DonutTolerance).ToList();
|
||||
for (var i = 0; i < inDonut.Count; i++)
|
||||
{
|
||||
inDonut[i].DonutIndex = i;
|
||||
}
|
||||
|
||||
_donut = [.. inDonut.Select(r => new OverviewDonutSlice(r.Name, r.Amount!.Value, r.Color))];
|
||||
}
|
||||
|
||||
if (_rows.Count > 0)
|
||||
{
|
||||
var totalStatus = FigureText.Of(composition.Total);
|
||||
_rows.Add(new Row(S.Overview_BillTotal, null, null, totalStatus.IsKnown ? composition.Total.Cost : null, totalStatus, null)
|
||||
{
|
||||
IsTotal = true,
|
||||
Chip = OverviewText.CostQualifier(composition.Total, totalStatus),
|
||||
});
|
||||
}
|
||||
|
||||
var names = composition.Categories.ToDictionary(c => c.CategoryId, c => c.Name);
|
||||
foreach (var category in composition.Categories.Where(c => c.IsOverlappingView))
|
||||
{
|
||||
var status = FigureText.Of(category.Total);
|
||||
var others = category.OverlapsWith.Select(o => names.GetValueOrDefault(o)).OfType<string>().ToList();
|
||||
_views.Add(new ViewRow(
|
||||
category.Name,
|
||||
AnalysisLinks.Analysis(QueryScope.ForCategory(category.CategoryId), AnalysisMetric.Cost, View.Query),
|
||||
status.IsKnown ? category.Total.Cost : null,
|
||||
status,
|
||||
others.Count > 0 ? Loc.F(S.Overview_ViewOverlaps, string.Join(", ", others)) : category.Cover.LiesOutsideBill ? S.Overview_ViewOutsideBill : null));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The swatch of a donut slice: its category's colour, else the palette hue the donut gives it (same order).</summary>
|
||||
private string SwatchStyle(Row row) =>
|
||||
_donut.Count <= 1 || row.DonutIndex is not { } index ? "visibility:hidden"
|
||||
: "background:" + (row.Color ?? OverviewDonutSlice.PaletteVariable(index));
|
||||
|
||||
/// <summary>The width of a signed bar in its half: the share of the largest amount either way.</summary>
|
||||
private string BarStyle(double? amount, bool negative)
|
||||
{
|
||||
if (amount is not { } value || _maxAbs <= 0 || (negative ? value >= 0 : value <= 0))
|
||||
{
|
||||
return "width:0";
|
||||
}
|
||||
|
||||
var percent = Math.Abs(value) / _maxAbs * 100;
|
||||
return string.Create(System.Globalization.CultureInfo.InvariantCulture, $"width:{percent:0.#}%");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
.mv-ov-swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mv-ov-bars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.mv-ov-bars__neg,
|
||||
.mv-ov-bars__pos {
|
||||
display: flex;
|
||||
flex: 1 1 50%;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.mv-ov-bars__neg {
|
||||
justify-content: flex-end;
|
||||
border-right: 1px solid var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mv-ov-bars__neg .mv-ov-bars__bar {
|
||||
background: var(--mud-palette-info);
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
.mv-ov-bars__pos .mv-ov-bars__bar {
|
||||
background: var(--mud-palette-primary);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.mv-ov-bars__bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@* The period's cost (brief §7.1 item 1): the bill with its price coverage (priced / partly / not priced), the change
|
||||
over the coverage both periods share (D-07), what it is made of (metered usage, standing charges, manual costs, the
|
||||
feed-in credit), a projection only where D-09 allows one, and the way into the cost analysis with the same dates. *@
|
||||
|
||||
<MetricCard Title="@S.Overview_TotalCost" Cost="Data.Cost.Total" Currency="@Data.Cost.Currency"
|
||||
Caption="@Format.PeriodRange(Data.Period)"
|
||||
Change="@(Data.CostChange.Basis == CostChangeBasis.NoComparison ? null : Data.CostChange.Change)"
|
||||
Polarity="@ChangePolarities.ForCost(Data.CostChange.Current, Data.CostChange.Previous)"
|
||||
ChangeCaption="@OverviewText.ChangeCaption(Query, Data.CostChange)"
|
||||
Href="@AnalysisLinks.Analysis(QueryScope.Portfolio, AnalysisMetric.Cost, Query)" LinkText="@S.Overview_AnalyseCosts"
|
||||
Class="mv-ov-cost">
|
||||
@if (_parts.Count > 0)
|
||||
{
|
||||
<dl class="mv-ov-parts">
|
||||
@foreach (var (label, amount) in _parts)
|
||||
{
|
||||
<div class="mv-ov-parts__row">
|
||||
<dt>@label</dt>
|
||||
<dd>@amount</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
@if (Data.Projection is { } projection)
|
||||
{
|
||||
<ProjectionNote Days="projection.Days" ValueText="@Format.Money(projection.Value, Data.Cost.Currency)" Class="mt-1" />
|
||||
}
|
||||
</MetricCard>
|
||||
|
||||
@code {
|
||||
/// <summary>The Overview's read model.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public DashboardOverview Data { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis state; the link carries its dates.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Query { get; set; } = null!;
|
||||
|
||||
private List<(string Label, string Amount)> _parts = [];
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var total = Data.Cost.Total;
|
||||
var currency = Data.Cost.Currency;
|
||||
_parts = [];
|
||||
|
||||
// Only a bill made of more than one part is broken down; the parts add up to the total.
|
||||
var parts = new List<(string, double?)>
|
||||
{
|
||||
(S.Overview_CostUsage, total.Usage),
|
||||
(S.Overview_CostStanding, total.StandingCharge),
|
||||
(S.Overview_CostManual, total.Manual),
|
||||
(S.Overview_CostCredit, total.FeedInCredit is { } credit ? -credit : null),
|
||||
};
|
||||
var known = parts.Where(p => p.Item2 is not null).ToList();
|
||||
if (known.Count > 1)
|
||||
{
|
||||
_parts = [.. known.Select(p => (p.Item1, Format.Money(p.Item2, currency)))];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.mv-ov-parts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.mv-ov-parts__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mv-ov-parts__row dt {
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mv-ov-parts__row dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
@* The Overview's coverage and freshness summary (brief §7.1): how complete the chosen period is, which dates the
|
||||
instance has data for (meters and manual costs, D-19), the latest month with data and what it rests on, and how
|
||||
current the meters are (D-18) — one wrapping line under the toolbar, words and icons, never colour alone. *@
|
||||
|
||||
<section class="mv-ov-coverage" aria-label="@S.Overview_CoverageLabel">
|
||||
<span class="mv-ov-coverage__item">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.DateRange" Size="Size.Small" aria-hidden="true" />
|
||||
<span>@Loc.F(S.Overview_PeriodStatus, PeriodStatus.Display())</span>
|
||||
</span>
|
||||
<span class="mv-ov-coverage__item">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Storage" Size="Size.Small" aria-hidden="true" />
|
||||
@if (Data.Availability is { } available)
|
||||
{
|
||||
<span>@Loc.F(S.Overview_DataAvailable, Format.DateRange(available.FirstDay, available.LastDay))</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@S.Empty_NoDataYet</span>
|
||||
}
|
||||
</span>
|
||||
@if (Data.Latest is { } latest)
|
||||
{
|
||||
<span class="mv-ov-coverage__item">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.EventAvailable" Size="Size.Small" aria-hidden="true" />
|
||||
<span>@Loc.F(S.Overview_LatestMonth, Format.MonthYear(latest.Month), latest.Basis.Display())</span>
|
||||
</span>
|
||||
}
|
||||
@if (_freshness.Count > 0)
|
||||
{
|
||||
<span class="mv-ov-coverage__item">
|
||||
<MudIcon Icon="@(_stale > 0 ? Icons.Material.Outlined.SensorsOff : Icons.Material.Outlined.Sensors)" Size="Size.Small" aria-hidden="true" />
|
||||
<span>@S.Overview_Meters @string.Join(" · ", _freshness)</span>
|
||||
</span>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>The Overview's read model.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public DashboardOverview Data { get; set; } = null!;
|
||||
|
||||
private List<string> _freshness = [];
|
||||
private int _stale;
|
||||
|
||||
/// <summary>
|
||||
/// The period as a whole: complete when every measure is, no data when none has any, being prepared while a rebuild
|
||||
/// runs, partial otherwise. Without any meter, the bill's own availability (manual costs are always available).
|
||||
/// </summary>
|
||||
private BucketStatus PeriodStatus
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Data.IsPending)
|
||||
{
|
||||
return BucketStatus.Pending;
|
||||
}
|
||||
|
||||
if (Data.HasNoData)
|
||||
{
|
||||
return BucketStatus.Missing;
|
||||
}
|
||||
|
||||
var statuses = Data.Quantities.Measures.Select(m => m.Total.Status).ToList();
|
||||
if (statuses.Count == 0)
|
||||
{
|
||||
return Data.Cost.Total.Cost is null ? BucketStatus.Missing : Data.Cost.Total.Availability;
|
||||
}
|
||||
|
||||
return statuses.All(s => s == BucketStatus.Available) ? BucketStatus.Available
|
||||
: statuses.All(s => s == BucketStatus.Missing) ? BucketStatus.Missing
|
||||
: BucketStatus.Partial;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var meters = Data.Quantities.Series.Where(s => s.Basis == SeriesBasis.Physical).ToList();
|
||||
_stale = meters.Count(s => s.Freshness.State == FreshnessState.Stale);
|
||||
_freshness = [];
|
||||
Add(meters.Count(s => s.Freshness.State == FreshnessState.Live), S.Overview_MetersLive);
|
||||
Add(_stale, S.Overview_MetersStale);
|
||||
Add(meters.Count(s => s.Freshness.State == FreshnessState.Historical), S.Overview_MetersHistorical);
|
||||
Add(meters.Count(s => s.Freshness.State == FreshnessState.NoData), S.Overview_MetersNoData);
|
||||
}
|
||||
|
||||
private void Add(int count, string format)
|
||||
{
|
||||
if (count > 0)
|
||||
{
|
||||
_freshness.Add(Loc.F(format, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.mv-ov-coverage {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 20px;
|
||||
margin: 8px 0 16px 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mv-ov-coverage__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
@* The bill's composition as a donut (D-42) — drawn only for a non-negative, disjoint composition; the panel decides
|
||||
that and shows the same values in its table. Amounts in the instance currency in the tooltip; transparent, in the
|
||||
theme's mode, re-keyed on a theme change or a new result; no animation. *@
|
||||
|
||||
@using ApexCharts
|
||||
@implements IDisposable
|
||||
@inject ThemeState ThemeState
|
||||
|
||||
@if (Slices.Count > 0)
|
||||
{
|
||||
<figure class="mv-ov-donut" aria-label="@Loc.F(S.AnalysisChart_AriaLabel, Title)">
|
||||
<ApexChart @key="_generation" TItem="OverviewDonutSlice" Options="_options" Height="Height">
|
||||
<ApexPointSeries TItem="OverviewDonutSlice"
|
||||
Items="Slices"
|
||||
SeriesType="SeriesType.Donut"
|
||||
Name="@Title"
|
||||
XValue="s => s.Label"
|
||||
YValue="s => (decimal)Math.Round(s.Amount, 2)" />
|
||||
</ApexChart>
|
||||
</figure>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The slices, each with a positive amount, in the order the table lists them.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<OverviewDonutSlice> Slices { get; set; } = [];
|
||||
|
||||
/// <summary>The ISO currency of the amounts.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Currency { get; set; } = "EUR";
|
||||
|
||||
/// <summary>What the chart shows, for its accessible name.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public int Height { get; set; } = 260;
|
||||
|
||||
private ApexChartOptions<OverviewDonutSlice> _options = new();
|
||||
private long _generation;
|
||||
private object? _builtFrom;
|
||||
|
||||
protected override void OnInitialized() => ThemeState.Changed += OnThemeChanged;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var source = (Slices, Currency, System.Globalization.CultureInfo.CurrentCulture.Name);
|
||||
if (!Equals(_builtFrom, source))
|
||||
{
|
||||
_builtFrom = source;
|
||||
Rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
var palette = ChartPalette.For(ThemeState.IsDark);
|
||||
var mode = palette.IsDark ? Mode.Dark : Mode.Light;
|
||||
var money = ChartFormatters.Axis(MeterVault.App.Format.CurrencySymbol(Currency), System.Globalization.CultureInfo.CurrentCulture);
|
||||
_options = new ApexChartOptions<OverviewDonutSlice>
|
||||
{
|
||||
Chart = new Chart
|
||||
{
|
||||
Background = "transparent",
|
||||
ForeColor = palette.Text,
|
||||
Toolbar = new Toolbar { Show = false },
|
||||
Animations = new Animations { Enabled = false },
|
||||
RedrawOnParentResize = true,
|
||||
},
|
||||
Theme = new ApexCharts.Theme { Mode = mode },
|
||||
Colors = [.. Slices.Select((s, i) => s.Color ?? palette.SeriesColor(i))],
|
||||
Legend = new Legend { Position = LegendPosition.Bottom },
|
||||
Stroke = new Stroke { Colors = [palette.Surface], Width = 2 },
|
||||
Tooltip = new Tooltip { Enabled = true, Theme = mode, Y = new TooltipY { Formatter = money } },
|
||||
PlotOptions = new PlotOptions { Pie = new PlotOptionsPie { Donut = new PlotOptionsDonut { Size = "62%" } } },
|
||||
};
|
||||
_generation++;
|
||||
}
|
||||
|
||||
private void OnThemeChanged() => _ = InvokeAsync(() =>
|
||||
{
|
||||
Rebuild();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
public void Dispose() => ThemeState.Changed -= OnThemeChanged;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
@* The Overview's shared history chart (brief §7.1 item 3): the bill, or one measure of one energy type, in the
|
||||
toolbar's buckets with the comparison drawn over it (A-10 pairs), unknown buckets as gaps. The selection lives in the
|
||||
address (chart=…, replace) so a reload or a shared link shows the same; a bucket click drills into it on the Overview
|
||||
(D-51); the table is the chart's accessible alternative. Every option comes pre-built from the one committed value. *@
|
||||
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-ov-panel">
|
||||
<div class="mv-ov-panel__head">
|
||||
<h2 class="mud-typography mud-typography-h6 mv-ov-panel__title">@S.Overview_History</h2>
|
||||
<div class="mv-ov-panel__controls">
|
||||
<MudSelect T="string" Value="@Selected.Key" ValueChanged="OnSelect" Label="@S.Overview_ChartShows"
|
||||
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true" Class="mv-ov-history__select"
|
||||
ToStringFunc="@(k => View.Option(k).Label)">
|
||||
@foreach (var option in View.ChartOptions)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@option.Key">@option.Label</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnalysisChart Buckets="View.Data.Plan.Buckets" Series="Selected.Chart" ComparisonPairs="Pairs"
|
||||
Title="@Selected.Label" OnBucketClick="OnBucketClick" Height="300" />
|
||||
|
||||
<div class="mv-ov-panel__foot">
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" StartIcon="@(_table ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.TableRows)"
|
||||
OnClick="() => _table = !_table" aria-expanded="@(_table ? "true" : "false")">
|
||||
@(_table ? S.Overview_HideTable : S.Overview_ShowTable)
|
||||
</MudButton>
|
||||
<MudLink Href="@AnalysisLinks.Analysis(Selected.Scope, Selected.Metric, View.Query)" Typo="Typo.body2">@S.Overview_OpenInAnalysis</MudLink>
|
||||
</div>
|
||||
|
||||
@if (_table)
|
||||
{
|
||||
<AnalysisTable Buckets="View.Data.Plan.Buckets" Series="Selected.Table" ComparisonPairs="Pairs" Caption="@Selected.Label" />
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
/// <summary>The committed Overview.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public OverviewView View { get; set; } = null!;
|
||||
|
||||
/// <summary>The selected option's key (<c>chart=</c>); the bill when unknown.</summary>
|
||||
[Parameter]
|
||||
public string? ChartKey { get; set; }
|
||||
|
||||
/// <summary>Another option was chosen: the page writes it into its address.</summary>
|
||||
[Parameter]
|
||||
public EventCallback<string> ChartKeyChanged { get; set; }
|
||||
|
||||
/// <summary>A bucket was clicked, with the resolution behind the selected option (D-51).</summary>
|
||||
[Parameter]
|
||||
public EventCallback<(AnalysisBucket Bucket, ResolutionClass? Resolution)> OnDrill { get; set; }
|
||||
|
||||
private bool _table;
|
||||
|
||||
private OverviewChartOption Selected => View.Option(ChartKey);
|
||||
|
||||
private IReadOnlyList<BucketPair>? Pairs =>
|
||||
View.Data.Pairs.Count > 0 ? View.Data.Pairs : View.Data.Quantities.Comparison?.Buckets;
|
||||
|
||||
private Task OnSelect(string key) => ChartKeyChanged.InvokeAsync(key);
|
||||
|
||||
private Task OnBucketClick(AnalysisBucket bucket) => OnDrill.InvokeAsync((bucket, Selected.Resolution));
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
@* One energy type on the Overview (brief §7.1 item 2): each of its measures (D-22) in its own unit — never added across
|
||||
units — with its status and its change over the matched coverage (D-07); its part of the bill with the billing basis
|
||||
and change; how current its meters are; and the way into the type's analysis with the same dates. Works without any
|
||||
tariff or category: a type nothing prices says so instead of showing 0. *@
|
||||
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-ov-card">
|
||||
<div class="mv-ov-type">
|
||||
<div class="mv-ov-type__head">
|
||||
<h2 class="mud-typography mud-typography-h6 mv-ov-type__name">@Figures.Type.Name</h2>
|
||||
@if (Figures.Freshness.State != FreshnessState.NoData)
|
||||
{
|
||||
<span class="mv-ov-type__fresh" title="@FreshnessDetail">
|
||||
<MudIcon Icon="@FreshnessIcon" Size="Size.Small" aria-hidden="true" />
|
||||
<span>@Figures.Freshness.State.Display()</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Figures.Measures.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.Overview_TypeNoMeasures</MudText>
|
||||
}
|
||||
else if (Figures.HasNoValues)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.Empty_NoDataForPeriod</MudText>
|
||||
}
|
||||
|
||||
<dl class="mv-ov-type__rows">
|
||||
@foreach (var measure in Figures.Measures)
|
||||
{
|
||||
var status = FigureText.Of(measure.Total);
|
||||
<div class="mv-ov-type__row">
|
||||
<dt>@(measure.Key.Measure?.Display() ?? measure.Kind.Display())</dt>
|
||||
<dd>
|
||||
<span class="@(status.IsKnown ? "mv-ov-type__value" : "mv-ov-type__value mv-ov-type__value--words")">
|
||||
@(status.IsKnown ? Format.Quantity(measure.Total.Value, measure.Unit) : status.Status)
|
||||
</span>
|
||||
@if (status.IsKnown && !status.IsComplete)
|
||||
{
|
||||
<span class="mv-ov-type__status" title="@status.Detail">@status.Status</span>
|
||||
}
|
||||
@if (status.IsKnown && measure.Comparison is { } comparison)
|
||||
{
|
||||
<ChangeChip Change="comparison.Change" Polarity="@ChangePolarities.For(measure.Kind)"
|
||||
FormatMagnitude="@(v => Format.Quantity(v, measure.Unit))"
|
||||
Caption="@(IsMatchedOnly(measure) ? Marker : null)" Class="mv-ov-type__change" />
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Figures.Cost is { } cost)
|
||||
{
|
||||
var status = FigureText.Of(cost.Total);
|
||||
<div class="mv-ov-type__row mv-ov-type__row--cost">
|
||||
<dt>@S.AnalysisTable_Cost</dt>
|
||||
<dd>
|
||||
@if (cost.Basis == BillingBasis.None && cost.Total.Cost is null)
|
||||
{
|
||||
<span class="mv-ov-type__value mv-ov-type__value--words">@cost.Basis.Display()</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="@(status.IsKnown ? "mv-ov-type__value" : "mv-ov-type__value mv-ov-type__value--words")">
|
||||
@(status.IsKnown ? Format.Money(cost.Total.Cost, Currency) : status.Status)
|
||||
</span>
|
||||
@if (status.IsKnown && !status.IsComplete)
|
||||
{
|
||||
<span class="mv-ov-type__status" title="@status.Detail">@OverviewText.CostQualifier(cost.Total, status)</span>
|
||||
}
|
||||
<span class="mv-ov-type__basis">@cost.Basis.Display()</span>
|
||||
@if (status.IsKnown && Figures.CostChange.Basis != CostChangeBasis.NoComparison)
|
||||
{
|
||||
<ChangeChip Change="Figures.CostChange.Change"
|
||||
Polarity="@ChangePolarities.ForCost(Figures.CostChange.Current, Figures.CostChange.Previous)"
|
||||
FormatMagnitude="@(v => Format.Money(v, Currency))"
|
||||
Caption="@(Figures.CostChange.IsPartial ? Marker : null)"
|
||||
Class="mv-ov-type__change" />
|
||||
}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
@if (_matchedOnly)
|
||||
{
|
||||
<p class="mv-ov-type__note">@S.Overview_MatchedNote</p>
|
||||
}
|
||||
<div class="mv-ov-type__link">
|
||||
<MudLink Href="@AnalysisLinks.EnergyType(Figures.Type.Id, null, Query)" Typo="Typo.body2">
|
||||
@Loc.F(S.Overview_OpenType, Figures.Type.Name)
|
||||
</MudLink>
|
||||
</div>
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
/// <summary>The type's figures.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public OverviewTypeFigures Figures { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis state; the link carries its dates.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Query { get; set; } = null!;
|
||||
|
||||
/// <summary>The currency of every amount.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Currency { get; set; } = "EUR";
|
||||
|
||||
/// <summary>The instance zone, for the date of the last reading.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public TimeZoneInfo Zone { get; set; } = TimeZoneInfo.Utc;
|
||||
|
||||
/// <summary>The footnote marker of a change measured over less than both whole periods (D-07).</summary>
|
||||
private const string Marker = "¹";
|
||||
|
||||
private bool _matchedOnly;
|
||||
|
||||
protected override void OnParametersSet() =>
|
||||
_matchedOnly = Figures.Measures.Any(m => m.Total.Value is not null && IsMatchedOnly(m))
|
||||
|| (Figures.Cost?.Total.Cost is not null && Figures.CostChange.IsPartial);
|
||||
|
||||
/// <summary>
|
||||
/// True when a measure's change is stated over the coverage both periods share rather than over both whole periods:
|
||||
/// either side is not complete (D-07).
|
||||
/// </summary>
|
||||
private static bool IsMatchedOnly(AnalysisSeries measure) =>
|
||||
measure.Comparison is { Change.IsAvailable: true } comparison
|
||||
&& (measure.Total.Status != BucketStatus.Available || comparison.Total.Status != BucketStatus.Available);
|
||||
|
||||
private string FreshnessIcon => Figures.Freshness.State switch
|
||||
{
|
||||
FreshnessState.Live => Icons.Material.Outlined.Sensors,
|
||||
FreshnessState.Stale => Icons.Material.Outlined.SensorsOff,
|
||||
_ => Icons.Material.Outlined.History,
|
||||
};
|
||||
|
||||
private string? FreshnessDetail => Figures.Freshness.LastActivity is { } last
|
||||
? Loc.F(S.Overview_LastActivity, Format.Date(PeriodResolver.LocalDate(last, Zone)))
|
||||
: null;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
.mv-ov-type {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mv-ov-type__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 4px 12px;
|
||||
}
|
||||
|
||||
.mv-ov-type__name {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mv-ov-type__fresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mv-ov-type__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.mv-ov-type__row dt {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mv-ov-type__row dd {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 2px 8px;
|
||||
}
|
||||
|
||||
.mv-ov-type__row--cost {
|
||||
border-top: 1px solid var(--mud-palette-lines-default);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.mv-ov-type__value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mv-ov-type__value--words {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mv-ov-type__status,
|
||||
.mv-ov-type__basis {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mv-ov-type__status {
|
||||
border: 1px solid var(--mud-palette-lines-default);
|
||||
border-radius: 999px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.mv-ov-type__row dd ::deep .mv-change {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.mv-ov-type__note {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mv-ov-type__link {
|
||||
align-self: flex-start;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using MeterVault.App.Analysis;
|
||||
using MeterVault.App.Localization;
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Core.Analysis.Costing;
|
||||
using MeterVault.Core.Analysis.Totals;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Analysis;
|
||||
using MeterVault.Infrastructure.Costing;
|
||||
using MeterVault.Infrastructure.Dashboard;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace MeterVault.App.Components.Pages.Overview;
|
||||
|
||||
/// <summary>
|
||||
/// One thing the Overview's history chart can show (brief §7.1 item 3): the bill, or one measure of one energy type —
|
||||
/// with its chart and table series built once, in the load's culture, so the chart re-keys only for a new result.
|
||||
/// </summary>
|
||||
/// <param name="Key">The invariant token in the address (<c>chart=</c>): <see cref="OverviewView.CostKey"/> or a measure's <see cref="SeriesKey.Id"/>.</param>
|
||||
/// <param name="Label">What the selector says ("Strom · Total use (kWh)"); a type's name is user data.</param>
|
||||
/// <param name="Metric">The metric it charts, for the Analysis link and the export.</param>
|
||||
/// <param name="Scope">The scope it belongs to: the portfolio for the bill, the energy type for a measure.</param>
|
||||
/// <param name="Chart">The chart series: the value and, with a comparison, its overlay.</param>
|
||||
/// <param name="Table">The table series (the chart's accessible alternative).</param>
|
||||
/// <param name="Resolution">The coarsest resolution behind it, for drilling into a bucket (D-51).</param>
|
||||
public sealed record OverviewChartOption(
|
||||
string Key,
|
||||
string Label,
|
||||
AnalysisMetric Metric,
|
||||
QueryScope Scope,
|
||||
IReadOnlyList<AnalysisChartSeries> Chart,
|
||||
IReadOnlyList<AnalysisTableSeries> Table,
|
||||
ResolutionClass? Resolution);
|
||||
|
||||
/// <summary>
|
||||
/// The Overview as one committed value (the LoadSequencer pattern): the query and period it answers, the read model, and
|
||||
/// everything built from it in the reader's culture. Every panel renders from this one value, so a title never sits above
|
||||
/// another period's chart.
|
||||
/// </summary>
|
||||
/// <param name="Query">The analysis state the value answers.</param>
|
||||
/// <param name="Data">The read model.</param>
|
||||
/// <param name="ChartOptions">What the history chart can show; the bill first.</param>
|
||||
/// <param name="Names">The meter and energy type names attention items speak of.</param>
|
||||
/// <param name="AttentionCount">How many attention items there are (the layout makes room for them only then).</param>
|
||||
public sealed record OverviewView(
|
||||
AnalysisQuery Query,
|
||||
DashboardOverview Data,
|
||||
IReadOnlyList<OverviewChartOption> ChartOptions,
|
||||
AttentionNames Names,
|
||||
int AttentionCount)
|
||||
{
|
||||
/// <summary>The address key of the history chart's selection.</summary>
|
||||
public const string ChartParameter = "chart";
|
||||
|
||||
/// <summary>The token of the bill in <see cref="ChartParameter"/>; the default, never written.</summary>
|
||||
public const string CostKey = "cost";
|
||||
|
||||
public ResolvedPeriod Period => Data.Period;
|
||||
|
||||
/// <summary>The instance currency every amount is in (D-43).</summary>
|
||||
public string Currency => Data.Cost.Currency;
|
||||
|
||||
/// <summary>The problems of the quantity and the cost reader together (duplicates collapse in the list).</summary>
|
||||
public IEnumerable<AnalysisProblem> Problems => Data.Quantities.Problems.Concat(Data.Cost.QuantityProblems);
|
||||
|
||||
/// <summary>The option <paramref name="key"/> names, or the bill.</summary>
|
||||
public OverviewChartOption Option(string? key) =>
|
||||
ChartOptions.FirstOrDefault(o => string.Equals(o.Key, key, StringComparison.Ordinal)) ?? ChartOptions[0];
|
||||
|
||||
/// <summary>Builds the view of <paramref name="data"/> for <paramref name="query"/> in the current culture.</summary>
|
||||
public static OverviewView Build(AnalysisQuery query, DashboardOverview data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
var typeNames = data.EnergyTypes.ToDictionary(t => t.Id, t => t.Name);
|
||||
var categoryNames = (data.Cost.Composition?.Categories ?? []).ToDictionary(c => c.CategoryId, c => c.Name);
|
||||
var names = new AttentionNames(data.MeterNames, typeNames, categoryNames);
|
||||
var attention = AttentionItems.Build(data.Quantities.Problems.Concat(data.Cost.QuantityProblems), data.Cost.Attention, names, query);
|
||||
return new OverviewView(query, data, ChartOptionsOf(query, data, typeNames), names, attention.Count);
|
||||
}
|
||||
|
||||
/// <summary>The <c>chart</c> key of an address; null when absent.</summary>
|
||||
public static string? ChartKeyOf(string uri)
|
||||
{
|
||||
var start = uri.IndexOf('?', StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var end = uri.IndexOf('#', start);
|
||||
var query = QueryHelpers.ParseQuery(end < 0 ? uri[start..] : uri[start..end]);
|
||||
return query.TryGetValue(ChartParameter, out var values) && values.Count > 0 && !string.IsNullOrWhiteSpace(values[0]) ? values[0]!.Trim() : null;
|
||||
}
|
||||
|
||||
/// <summary>"Strom · Total use (kWh)": a measure as the chart selector names it.</summary>
|
||||
public static string MeasureLabel(string typeName, AnalysisSeries measure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(measure);
|
||||
|
||||
var what = measure.Key.Measure is { } m ? m.Display() : measure.Kind.Display();
|
||||
return Loc.F(Strings.Overview_ChartMeasure, typeName, what, measure.Unit);
|
||||
}
|
||||
|
||||
private static List<OverviewChartOption> ChartOptionsOf(AnalysisQuery query, DashboardOverview data, Dictionary<int, string> typeNames)
|
||||
{
|
||||
var buckets = data.Plan.Buckets;
|
||||
var currency = data.Cost.Currency;
|
||||
var costName = Strings.Overview_TotalCost;
|
||||
|
||||
List<AnalysisChartSeries> costChart = [AnalysisChartSeries.ForCost(CostKey, costName, currency, data.Cost.Buckets)];
|
||||
var costTable = AnalysisTableSeries.ForCosts(CostKey, costName, currency, data.Cost.Buckets, data.Cost.Total);
|
||||
if (data.PreviousCost is { } previous && previous.Buckets.Count == buckets.Count)
|
||||
{
|
||||
costChart.Add(AnalysisChartSeries.ComparisonForCost(
|
||||
CostKey, AnalysisChartSeries.ComparisonName(costName, query.Comparison), currency, previous.Buckets));
|
||||
costTable = costTable.WithComparisonCosts(previous.Buckets, previous.Total, currency) with
|
||||
{
|
||||
TotalChange = data.CostChange.Change.IsAvailable ? data.CostChange.Change : null,
|
||||
};
|
||||
}
|
||||
|
||||
string? MeterNameOf(int id) => data.MeterNames.GetValueOrDefault(id);
|
||||
|
||||
var options = new List<OverviewChartOption>
|
||||
{
|
||||
new(CostKey, costName, AnalysisMetric.Cost, QueryScope.Portfolio, costChart, [costTable], data.CoarsestResolution),
|
||||
};
|
||||
|
||||
foreach (var type in data.Types)
|
||||
{
|
||||
var typeName = typeNames.GetValueOrDefault(type.Type.Id, type.Type.Name);
|
||||
foreach (var measure in type.Measures)
|
||||
{
|
||||
var label = MeasureLabel(typeName, measure);
|
||||
List<AnalysisChartSeries> chart = [AnalysisChartSeries.ForSeries(measure, label, meterName: MeterNameOf)];
|
||||
if (AnalysisChartSeries.ComparisonOf(measure, AnalysisChartSeries.ComparisonName(label, query.Comparison), meterName: MeterNameOf) is { } overlay)
|
||||
{
|
||||
chart.Add(overlay);
|
||||
}
|
||||
|
||||
options.Add(new OverviewChartOption(
|
||||
measure.Key.Id,
|
||||
label,
|
||||
AnalysisMetrics.MetricOf(measure.Kind) ?? AnalysisMetric.Consumption,
|
||||
QueryScope.ForEnergyType(type.Type.Id),
|
||||
chart,
|
||||
[AnalysisTableSeries.ForSeries(measure, label, MeterNameOf)],
|
||||
data.ResolutionOf(measure)));
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One slice of the composition donut: its name (user data or the page's words), its positive amount and colour.</summary>
|
||||
/// <param name="Label">The slice's name.</param>
|
||||
/// <param name="Amount">Its cost, > 0.</param>
|
||||
/// <param name="Color">Its category's colour (<c>#rrggbb</c>); null for the palette's hue by position.</param>
|
||||
public sealed record OverviewDonutSlice(string Label, double Amount, string? Color)
|
||||
{
|
||||
private static readonly string[] PaletteNames = ["primary", "secondary", "info", "error", "warning", "success"];
|
||||
|
||||
/// <summary>A category colour when it is a plain hex colour; null otherwise (it goes into a style and a script).</summary>
|
||||
public static string? SafeColor(string? color) =>
|
||||
color is { Length: 4 or 7 } hex && hex[0] == '#' && hex.Skip(1).All(Uri.IsHexDigit) ? hex : null;
|
||||
|
||||
/// <summary>The theme variable of the palette hue the chart gives the <paramref name="index"/>-th slice (ChartPalette's order).</summary>
|
||||
public static string PaletteVariable(int index) => "var(--mud-palette-" + PaletteNames[((index % PaletteNames.Length) + PaletteNames.Length) % PaletteNames.Length] + ")";
|
||||
}
|
||||
|
||||
/// <summary>The words and links of the Overview's rows: a composition slice, a bill line, a standing charge, the manual costs.</summary>
|
||||
public static class OverviewText
|
||||
{
|
||||
/// <summary>A row's name in the reader's language; categories, meters and types keep their own names (user data).</summary>
|
||||
public static string NameOf(OverviewChangeRow row, IReadOnlyDictionary<int, string>? meterNames = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
return row.Kind switch
|
||||
{
|
||||
OverviewRowKind.Uncategorized => Strings.Dashboard_SliceUncategorized,
|
||||
OverviewRowKind.StandingCharge => StandingChargeName(row.StandingCharge, row.Name),
|
||||
OverviewRowKind.ManualCosts => Strings.Overview_CostManual,
|
||||
OverviewRowKind.Line when row.ForMeterId is { } forMeter =>
|
||||
Loc.F(Strings.Overview_LineFor, row.Name, meterNames?.GetValueOrDefault(forMeter) ?? Loc.F(Strings.Attention_MeterFallback, forMeter)),
|
||||
_ => row.Name,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>"Standing charge — Strom", "Standing charge — global".</summary>
|
||||
public static string StandingChargeName(StandingChargeKey? key, string name) =>
|
||||
key is { Scope: TariffScope.Global } || string.IsNullOrWhiteSpace(name)
|
||||
? Strings.Dashboard_SliceStandingChargeGlobal
|
||||
: Loc.F(Strings.Dashboard_SliceStandingCharge, name);
|
||||
|
||||
/// <summary>What a line row is when it is not a plain unit-price line ("Feed-in credit", "Own meter price"); null otherwise.</summary>
|
||||
public static string? DetailOf(OverviewChangeRow row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
return row is { Kind: OverviewRowKind.Line, LineKind: { } kind } && kind != BillLineKind.UnitPrice ? kind.Display() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a row is explored, with the same dates (brief §7.1 item 4): a category's cost analysis, a meter's page, an
|
||||
/// energy type's page, the portfolio's cost analysis for the global charge and the manual costs; none for
|
||||
/// Uncategorized, which is no scope.
|
||||
/// </summary>
|
||||
public static string? HrefOf(OverviewChangeRow row, AnalysisQuery query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
return row.Kind switch
|
||||
{
|
||||
OverviewRowKind.Category when row.CategoryId is { } category =>
|
||||
AnalysisLinks.Analysis(QueryScope.ForCategory(category), AnalysisMetric.Cost, query),
|
||||
OverviewRowKind.Line when row.MeterId is { } meter => MeterLinks.Analysis(meter, query),
|
||||
OverviewRowKind.StandingCharge when row.MeterId is { } meter => MeterLinks.Analysis(meter, query),
|
||||
OverviewRowKind.StandingCharge when row.EnergyTypeId is { } type => AnalysisLinks.EnergyType(type, null, query),
|
||||
OverviewRowKind.StandingCharge or OverviewRowKind.ManualCosts =>
|
||||
AnalysisLinks.Analysis(QueryScope.Portfolio, AnalysisMetric.Cost, query),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>A composition slice's name: the category's, Uncategorized, or its standing charge's.</summary>
|
||||
public static string SliceName(CompositionSlice slice, CategoryComposition composition, IReadOnlyList<OverviewEnergyType> types, IReadOnlyDictionary<int, string> meterNames)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(slice);
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
ArgumentNullException.ThrowIfNull(types);
|
||||
ArgumentNullException.ThrowIfNull(meterNames);
|
||||
|
||||
return slice.Kind switch
|
||||
{
|
||||
CompositionSliceKind.Category => composition.Categories.FirstOrDefault(c => c.CategoryId == slice.CategoryId)?.Name ?? string.Empty,
|
||||
CompositionSliceKind.Uncategorized => Strings.Dashboard_SliceUncategorized,
|
||||
_ => StandingChargeName(slice.StandingCharge, slice.StandingCharge switch
|
||||
{
|
||||
{ Scope: TariffScope.EnergyType, ScopeId: { } type } => types.FirstOrDefault(t => t.Id == type)?.Name ?? string.Empty,
|
||||
{ Scope: TariffScope.Meter, ScopeId: { } meter } => meterNames.GetValueOrDefault(meter, string.Empty),
|
||||
_ => string.Empty,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What qualifies a known cost that is not complete: for a fully priced figure over incomplete quantities, the
|
||||
/// quantities' state ("Partial"); otherwise its price coverage ("Partly priced") — as <see cref="Shared.Analysis.MetricCard"/> says it.
|
||||
/// </summary>
|
||||
public static string CostQualifier(CostAmount amount, FigureStatus status)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(amount);
|
||||
ArgumentNullException.ThrowIfNull(status);
|
||||
|
||||
return amount.Status == CostStatus.Priced && amount.Availability != BucketStatus.Available ? amount.Availability.Display() : status.Status;
|
||||
}
|
||||
|
||||
/// <summary>The caption of a change: what it is compared with, and — when only part of the period could be matched — that it is.</summary>
|
||||
public static string? ChangeCaption(AnalysisQuery query, CostChange change) => CostChanges.Caption(query, change);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@* The Overview's panels speak the analysis layer's types directly (periods, buckets, values, cost figures). *@
|
||||
@using MeterVault.App.Theme
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Core.Analysis.Costing
|
||||
@using MeterVault.Core.Analysis.Totals
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@@ -1,149 +1,100 @@
|
||||
@page "/solar"
|
||||
@using MeterVault.App.Components.Pages.Specialized
|
||||
@using MeterVault.Core.Analysis
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Nav
|
||||
@inject InstanceClock Clock
|
||||
@inject SolarService SolarSvc
|
||||
@using MudBlazor
|
||||
@inject ILogger<Solar> Logger
|
||||
|
||||
<PageTitle>MeterVault — @S.Nav_Solar</PageTitle>
|
||||
@* The Solar view (brief §7.5, D-54): the shared header, toolbar and missing-data semantics around the specialised
|
||||
measures — generation, self-consumption, feed-in, autarky and what the generation is worth — one section per energy
|
||||
type with generation. Each figure carries its status and says how it was obtained; a role the figures need and
|
||||
nobody holds gets a setup card with a scoped path into the meter editor. *@
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">@S.Nav_Solar</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
<PageHeader Title="@S.Nav_Solar" Description="@S.Solar_Description">
|
||||
<Breadcrumbs>
|
||||
<AnalysisBreadcrumbs Query="_query" Current="@S.Nav_Solar" />
|
||||
</Breadcrumbs>
|
||||
</PageHeader>
|
||||
|
||||
@if (_summary is null)
|
||||
@if (_query is not null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
<PeriodToolbar Query="_query" Period="_state.Value?.Analysis.Period" Plan="_state.Value?.Analysis.Plan" Defaults="Defaults"
|
||||
QueryChanged="OnQueryChanged" Class="mb-4" />
|
||||
}
|
||||
else if (!_summary.HasGeneration)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
@S.Solar_NoGenerationLead <b>@MeterMode.GenerationCounter.Display()</b> @S.Solar_NoGenerationTail
|
||||
<MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.Solar_NoGenerationOrImport
|
||||
<MudLink Href="/import">@S.Nav_Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Generation</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Number(_summary.Generation, 0) kWh</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_SelfConsumption</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—")</MudText>
|
||||
@if (_summary.SelfConsumptionRatio is { } ratio)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.Solar_ShareOfGeneration, Format.Number(ratio * 100, 0))</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Autarky</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—")</MudText>
|
||||
@if (_summary.GridImport is { } grid)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.Solar_GridDraw, Format.Number(grid, 0))</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Savings</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.Savings is { } sav ? Format.Euro(sav) : "—")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="8">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationAndSelfConsumption</MudText>
|
||||
<SeriesChart Series="_chart" Decimals="0" Height="340" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="4">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationByMeter</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
@foreach (var meter in _summary.Meters)
|
||||
{
|
||||
<tr>
|
||||
<td><MudLink Href="@MeterLinks.Detail(meter.MeterId)">@meter.Name</MudLink></td>
|
||||
<td style="text-align:right">@Format.Number(meter.Generation, 0) kWh</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
@if (!_summary.HasLoadContext)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
@S.Solar_TagMetersLead <code>total_load</code> @S.Solar_TagMetersMid <code>grid_import</code>@S.Solar_TagMetersTail
|
||||
<MudLink Href="/meters">@S.Nav_Meters</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
<LoadPanel State="_state" OnRetry="RetryAsync" Context="view">
|
||||
@if (view.Analysis.Sites.Count == 0)
|
||||
{
|
||||
<div class="mv-empty" role="status">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.SolarPower" Class="mv-empty__icon" aria-hidden="true" />
|
||||
<div class="mv-empty__body">
|
||||
<MudText Typo="Typo.subtitle1">@S.Solar_NoGenerationTitle</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@Loc.F(S.Solar_NoGenerationHelp, MeterMode.GenerationCounter.Display())</MudText>
|
||||
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" Href="/meters">@S.Nav_Meters</MudButton>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" Size="Size.Small" Href="/import">@S.Nav_Import</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (!view.Analysis.IsRefused)
|
||||
{
|
||||
@foreach (var site in view.Sites)
|
||||
{
|
||||
<SolarSiteSection @key="site.Site.EnergyTypeId" View="site" Query="view.Query" Currency="@view.Analysis.Currency"
|
||||
ShowHeading="@(view.Sites.Count > 1)" OnRefresh="RetryAsync" />
|
||||
}
|
||||
}
|
||||
</LoadPanel>
|
||||
|
||||
@code {
|
||||
private int _months = 60;
|
||||
private bool _loading;
|
||||
private SolarSummary? _summary;
|
||||
private IReadOnlyList<SeriesChart.SeriesDef> _chart = [];
|
||||
private static readonly AnalysisDefaults Defaults = AnalysisDefaults.History;
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
private readonly LoadSequencer _loads = new();
|
||||
private readonly LoadState<SolarPageView> _state = new();
|
||||
private AnalysisQuery? _query;
|
||||
|
||||
private async Task OnRangeChanged(int months)
|
||||
/// <summary>One committed result: the query it answers, the read model and every section's series.</summary>
|
||||
private sealed record SolarPageView(AnalysisQuery Query, SolarAnalysis Analysis, IReadOnlyList<SolarSiteView> Sites);
|
||||
|
||||
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
|
||||
|
||||
protected override Task OnParametersSetAsync() => ReloadIfChangedAsync();
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(ReloadIfChangedAsync);
|
||||
|
||||
private async Task ReloadIfChangedAsync()
|
||||
{
|
||||
_months = months;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_loading)
|
||||
var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
|
||||
if (query == _query)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_summary = null;
|
||||
try
|
||||
{
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = asOf.AddMonths(-_months);
|
||||
_summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||
_query = query;
|
||||
await LoadAsync(query);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
var generation = _summary.Months
|
||||
.Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Generation))
|
||||
.ToList();
|
||||
var series = new List<SeriesChart.SeriesDef>
|
||||
{
|
||||
new(S.Solar_Generation, ApexCharts.SeriesType.Bar, generation),
|
||||
};
|
||||
if (_summary.HasLoadContext)
|
||||
{
|
||||
var self = _summary.Months
|
||||
.Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.SelfConsumption ?? 0))
|
||||
.ToList();
|
||||
series.Add(new(S.Solar_SelfConsumption, ApexCharts.SeriesType.Bar, self));
|
||||
}
|
||||
private Task RetryAsync() => _query is null ? Task.CompletedTask : LoadAsync(_query);
|
||||
|
||||
_chart = series;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token =>
|
||||
{
|
||||
// "Now" is read once per load (D-01); "all" spans what the energy types with generation have data for (D-19).
|
||||
var now = Clock.Now;
|
||||
var availability = query.Period == PeriodPreset.AllHistory ? await SolarSvc.GetAvailabilityAsync(now, token) : null;
|
||||
var period = query.Resolve(now, SolarSvc.Zone, availability);
|
||||
var analysis = await SolarSvc.GetAsync(new SolarRequest(period) { Bucket = query.Bucket, Comparison = query.Comparison }, token);
|
||||
return new SolarPageView(query, analysis, [.. analysis.Sites.Select(s => SolarSiteView.Build(s, query, analysis.Currency))]);
|
||||
}, Logger);
|
||||
|
||||
private void OnQueryChanged(AnalysisQuery query) => AnalysisNavigation.Replace(Nav, query, Defaults);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Nav.LocationChanged -= OnLocationChanged;
|
||||
_loads.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Core.Analysis.Quantities
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@* One energy type's Solar section (brief §7.5, D-54): its attention items, the period figures as cards — each with its
|
||||
status, its unit (D-20, never assumed) and how it was obtained — the shared chart and table, the roles the figures
|
||||
rest on with a setup card for each missing one, and the generation meters with what each counts for. *@
|
||||
|
||||
<section class="mv-solar-site mb-6" aria-labelledby="@_headingId">
|
||||
@if (ShowHeading)
|
||||
{
|
||||
<h2 id="@_headingId" class="mud-typography mud-typography-h5 mb-3">@Site.EnergyTypeName</h2>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h2 id="@_headingId" class="mv-sr-only">@Site.EnergyTypeName</h2>
|
||||
}
|
||||
|
||||
<AttentionList Problems="Site.Problems" CostAttention="Site.CostAttention" Names="View.Names" Query="Query" MaxItems="5" Class="mb-3" />
|
||||
|
||||
@if (Site.IsPending)
|
||||
{
|
||||
<PendingState OnRefresh="OnRefresh" Class="mb-4" />
|
||||
}
|
||||
else if (IsEmpty)
|
||||
{
|
||||
<EmptyPeriodState NotYetOccurred="Site.Quantities.NotYetOccurred" Availability="Site.Availability" LatestHref="@LatestHref" Class="mb-4">
|
||||
@if (Site.Availability is null)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.Solar_NoDataNextStep</MudText>
|
||||
}
|
||||
</EmptyPeriodState>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="3" Class="mb-3">
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MetricCard Title="@S.Solar_Generation" Value="@(Site.Generation?.Total ?? BucketValue.Missing())" Unit="@Site.Unit"
|
||||
Change="@ChangeOf(Site.Generation?.Comparison?.Change)" Polarity="ChangePolarity.HigherIsBetter"
|
||||
ChangeCaption="@ChangeCaption" Caption="@GenerationCaption" Class="mv-solar-card" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
@if (Site.SelfConsumption is { } self)
|
||||
{
|
||||
<MetricCard Title="@S.Solar_SelfConsumption" Value="self.Total" Unit="@self.Unit" Change="@ChangeOf(self.Change)"
|
||||
Polarity="ChangePolarity.HigherIsBetter" ChangeCaption="@ChangeCaption" Caption="@BasisText(self)" Class="mv-solar-card">
|
||||
@if (Site.SelfConsumptionShare is { Value: { } share })
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block">@Loc.F(S.Solar_ShareOfGeneration, Format.Quantity(share, "%"))</MudText>
|
||||
}
|
||||
</MetricCard>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MetricCard Title="@S.Solar_SelfConsumption" Caption="@S.Solar_SelfConsumptionNeedsRoles" Class="mv-solar-card" />
|
||||
}
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
@if (Site.FeedIn is { } feedIn)
|
||||
{
|
||||
<MetricCard Title="@S.Solar_FeedIn" Value="feedIn.Total" Unit="@feedIn.Unit" Change="@ChangeOf(feedIn.Change)"
|
||||
Polarity="ChangePolarity.Neutral" ChangeCaption="@ChangeCaption" Caption="@BasisText(feedIn)" Class="mv-solar-card" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MetricCard Title="@S.Solar_FeedIn" Caption="@S.Solar_FeedInNeedsRoles" Class="mv-solar-card" />
|
||||
}
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
@if (Site.Autarky is { } autarky)
|
||||
{
|
||||
<MetricCard Title="@S.Solar_Autarky" Value="autarky" Unit="%" Caption="@S.Solar_AutarkyCaption" Class="mv-solar-card">
|
||||
@if (UseLine is { } line)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block">@line</MudText>
|
||||
}
|
||||
</MetricCard>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MetricCard Title="@S.Solar_Autarky" Caption="@S.Solar_AutarkyNeedsRoles" Class="mv-solar-card" />
|
||||
}
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
@if (Site.Savings is { } savings)
|
||||
{
|
||||
<MetricCard Title="@S.Solar_Savings" Cost="savings.Total" Currency="@Currency" Caption="@SavingsCaption(savings)" Class="mv-solar-card">
|
||||
@if (Site.FeedInCredit is { } credit)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block">@Loc.F(S.Solar_FeedInCreditLine, CreditText(credit.Total))</MudText>
|
||||
}
|
||||
</MetricCard>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MetricCard Title="@S.Solar_Savings" Caption="@S.Solar_SavingsNeedsRoles" Class="mv-solar-card">
|
||||
@if (Site.FeedInCredit is { } credit)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block">@Loc.F(S.Solar_FeedInCreditLine, CreditText(credit.Total))</MudText>
|
||||
}
|
||||
</MetricCard>
|
||||
}
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<ComparisonSummary Period="Site.Quantities.Period" Resolution="Site.Quantities.Comparison?.Resolution"
|
||||
Matched="Site.Generation?.Comparison?.Matched" Class="mb-3" />
|
||||
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mb-4">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationAndSelfConsumption</MudText>
|
||||
<AnalysisChart Buckets="Site.Quantities.Plan.Buckets" Series="View.Chart" ComparisonPairs="Site.Quantities.Comparison?.Buckets"
|
||||
Title="@ChartTitle" OnBucketClick="_onBucketClick" Resolution="Coarsest" OnUseBucket="UseBucket" />
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mb-4">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Solar_TableTitle</MudText>
|
||||
<AnalysisTable Buckets="Site.Quantities.Plan.Buckets" Series="View.Table" ComparisonPairs="Site.Quantities.Comparison?.Buckets"
|
||||
DrillHref="_drillHref" Caption="@S.Solar_TableTitle" />
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@if (Site.Roles.Any(r => r.IsSet))
|
||||
{
|
||||
<div class="d-flex flex-wrap mv-muted mb-3 mud-typography mud-typography-body2" style="gap:.25rem 1rem">
|
||||
<span>@S.Solar_RolesInUse</span>
|
||||
@foreach (var role in Site.Roles.Where(r => r.IsSet))
|
||||
{
|
||||
<span>
|
||||
@role.Role.Display():
|
||||
@for (var i = 0; i < role.Holders.Count; i++)
|
||||
{
|
||||
var holder = role.Holders[i];
|
||||
@if (i > 0)
|
||||
{
|
||||
<text>, </text>
|
||||
}
|
||||
<MudLink Href="@MeterLinks.Analysis(holder.MeterId, Query)" Typo="Typo.body2">@holder.Name</MudLink>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Site.Roles.Any(r => !r.IsSet))
|
||||
{
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-2">@S.Solar_SetupTitle</MudText>
|
||||
<MudGrid Spacing="3" Class="mb-4">
|
||||
@foreach (var role in Site.Roles.Where(r => !r.IsSet))
|
||||
{
|
||||
<MudItem xs="12" md="4">
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-solar-setup" role="region" aria-label="@role.Role.Display()">
|
||||
<div class="d-flex align-center mb-1" style="gap:.5rem">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" aria-hidden="true" />
|
||||
<MudText Typo="Typo.subtitle2">@Loc.F(S.Solar_RoleNotSet, role.Role.Display())</MudText>
|
||||
</div>
|
||||
<MudText Typo="Typo.body2" Class="mv-muted mb-1">@role.Role.Meaning()</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-2">@RoleUse(role.Role)</MudText>
|
||||
@if (role.Candidates.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block mb-1">@S.Solar_RoleChooseMeter</MudText>
|
||||
<div class="d-flex flex-wrap mb-1" style="gap:.25rem .5rem">
|
||||
@foreach (var candidate in role.Candidates.Take(MaxCandidates))
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Primary" StartIcon="@Icons.Material.Outlined.Edit"
|
||||
Style="text-transform:none" Href="@MeterLinks.Detail(candidate.MeterId, null, MeterLinks.ActionEdit, Query)">@candidate.Name</MudButton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<MudLink Href="@AnalysisLinks.EnergyType(Site.EnergyTypeId, AnalysisLinks.EnergyTabMeters, Query)" Typo="Typo.body2">
|
||||
@Loc.F(S.Solar_RoleAllMeters, Site.EnergyTypeName)
|
||||
</MudLink>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@if (Site.Meters.Count > 0)
|
||||
{
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mb-4">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationByMeter</MudText>
|
||||
<div class="mv-table-scroll" role="region" aria-label="@S.Solar_GenerationByMeter" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@S.Common_Meter</th>
|
||||
<th scope="col" class="mv-num">@S.Solar_Generation</th>
|
||||
<th scope="col">@S.Solar_MeterCounts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var meter in Site.Meters)
|
||||
{
|
||||
<tr>
|
||||
<th scope="row" class="mv-row-label"><MudLink Href="@MeterLinks.Analysis(meter.MeterId, Query)">@meter.Name</MudLink></th>
|
||||
<td class="mv-num">
|
||||
@Format.Quantity(meter.Total.Value, meter.Unit)
|
||||
<div class="mv-cell-secondary">@FigureText.Of(meter.Total).Summary</div>
|
||||
</td>
|
||||
<td>@(meter.IsCounted ? S.Solar_MeterCounted : meter.IsVirtual ? S.Solar_MeterView : S.Solar_MeterNotCounted)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private const int MaxCandidates = 4;
|
||||
|
||||
/// <summary>The section's figures and series, built inside the page's load.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public SolarSiteView View { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis state; links carry it.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Query { get; set; } = null!;
|
||||
|
||||
/// <summary>The instance currency (D-43).</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Currency { get; set; } = "EUR";
|
||||
|
||||
/// <summary>Shows the energy type's name as a heading (when there are several sections).</summary>
|
||||
[Parameter]
|
||||
public bool ShowHeading { get; set; }
|
||||
|
||||
/// <summary>Loads again (for "being prepared").</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnRefresh { get; set; }
|
||||
|
||||
private readonly string _headingId = "solar-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
private SolarSite Site => View.Site;
|
||||
|
||||
/// <summary>Nothing in the period: no generation and no self-consumption to show (D-54: never a row of zeros).</summary>
|
||||
private bool IsEmpty =>
|
||||
Site.Quantities.NotYetOccurred
|
||||
|| ((Site.Generation is null || Site.Generation.Total.Status == BucketStatus.Missing)
|
||||
&& (Site.SelfConsumption?.Total.Status is null or BucketStatus.Missing)
|
||||
&& (Site.FeedIn?.Total.Status is null or BucketStatus.Missing));
|
||||
|
||||
private string? LatestHref =>
|
||||
AnalysisNavigation.LatestData(Query, Site.Availability) is { } latest ? AnalysisLinks.Solar(latest) : null;
|
||||
|
||||
private string? ChangeCaption => Query.Comparison.Kind == ComparisonKind.None ? null : Query.Comparison.Display();
|
||||
|
||||
private string ChartTitle => Site.Unit is { Length: > 0 } unit
|
||||
? S.Solar_GenerationAndSelfConsumption + " (" + unit + ")"
|
||||
: S.Solar_GenerationAndSelfConsumption;
|
||||
|
||||
/// <summary>The change chip only when a comparison was asked for.</summary>
|
||||
private Change? ChangeOf(Change? change) => Query.Comparison.Kind == ComparisonKind.None ? null : change ?? Change.Unavailable;
|
||||
|
||||
private string? GenerationCaption
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Site.Generation is null)
|
||||
{
|
||||
return S.Solar_GenerationNotCounted;
|
||||
}
|
||||
|
||||
var others = Site.OtherGeneration.Where(o => o.Total.Value is not null).Select(o => Format.Quantity(o.Total.Value, o.Unit)).ToList();
|
||||
return others.Count > 0 ? Loc.F(S.Solar_GenerationOtherUnits, string.Join(", ", others)) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How a figure was obtained, in the role names the editor uses (never raw tokens).</summary>
|
||||
private static string BasisText(SolarFigure figure)
|
||||
{
|
||||
if (figure.UnitsDiffer)
|
||||
{
|
||||
return Loc.F(S.Solar_BasisUnitsDiffer, string.Join(", ", figure.InputUnits));
|
||||
}
|
||||
|
||||
return figure.Basis switch
|
||||
{
|
||||
SolarBasis.LoadMinusImport => Loc.F(S.Solar_BasisDifference, MeterRole.TotalLoad.Display(), MeterRole.GridImport.Display()),
|
||||
SolarBasis.GenerationMinusExport => Loc.F(S.Solar_BasisDifference, S.Solar_Generation, MeterRole.GridExport.Display()),
|
||||
SolarBasis.GenerationMinusSelfConsumption => Loc.F(S.Solar_BasisDifference, S.Solar_Generation, S.Solar_SelfConsumption),
|
||||
SolarBasis.SelfConsumptionPlusImport => Loc.F(S.Solar_BasisSum, S.Solar_SelfConsumption, MeterRole.GridImport.Display()),
|
||||
_ => figure.MeterIds.Count > 0 ? S.Solar_BasisMeasured : string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>"Use 1,234 kWh · bought 567 kWh": what autarky is taken of.</summary>
|
||||
private string? UseLine
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = new List<string>(2);
|
||||
if (Site.SiteUse is { Total.Value: { } use } siteUse)
|
||||
{
|
||||
parts.Add(Loc.F(S.Solar_UseValue, Format.Quantity(use, siteUse.Unit)));
|
||||
}
|
||||
|
||||
if (Site.GridImport is { Total.Value: { } bought } import)
|
||||
{
|
||||
parts.Add(Loc.F(S.Solar_ImportValue, Format.Quantity(bought, import.Unit)));
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? string.Join(" · ", parts) : null;
|
||||
}
|
||||
}
|
||||
|
||||
private string SavingsCaption(SolarMoney savings) =>
|
||||
Loc.F(S.Solar_SavingsCaption, View.Names.Meter(savings.MeterIds.FirstOrDefault()));
|
||||
|
||||
/// <summary>The credit as a positive amount, or its status in words (never a fabricated 0, D-38).</summary>
|
||||
private string CreditText(Core.Analysis.Costing.CostAmount credit) =>
|
||||
credit.FeedInCredit is { } amount ? Format.Money(amount, Currency) : FigureText.Of(credit).Status;
|
||||
|
||||
/// <summary>What holding a role adds to the section.</summary>
|
||||
private static string RoleUse(MeterRole role) => role switch
|
||||
{
|
||||
MeterRole.TotalLoad => S.Solar_RoleUseTotalLoad,
|
||||
MeterRole.GridImport => S.Solar_RoleUseGridImport,
|
||||
_ => S.Solar_RoleUseGridExport,
|
||||
};
|
||||
|
||||
private EventCallback<AnalysisBucket> _onBucketClick;
|
||||
private Func<AnalysisBucket, string?>? _drillHref;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Buckets are clickable, and the table has its drill column, only when some bucket leads somewhere (D-51): monthly
|
||||
// data has no days to open.
|
||||
var drills = Site.Quantities.Plan.Buckets.Any(b => DrillHref(b) is not null);
|
||||
_onBucketClick = drills ? EventCallback.Factory.Create<AnalysisBucket>(this, Drill) : default;
|
||||
_drillHref = drills ? DrillHref : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The coarsest resolution among everything the section charts (D-51): the generation and the meters behind the
|
||||
/// self-consumption, feed-in, use and import figures — a bucket is opened no finer than all of them resolve.
|
||||
/// </summary>
|
||||
private ResolutionClass? Coarsest =>
|
||||
new[] { Site.SelfConsumption, Site.FeedIn, Site.SiteUse, Site.GridImport }
|
||||
.OfType<SolarFigure>()
|
||||
.SelectMany(f => f.MeterIds)
|
||||
.Select(id => Site.Quantities.SeriesFor(id)?.Resolution)
|
||||
.Append(Site.Generation?.Resolution)
|
||||
.Where(r => r is not null)
|
||||
.Max();
|
||||
|
||||
private string? DrillHref(AnalysisBucket bucket) =>
|
||||
AnalysisNavigation.DrillInto(Query, bucket, Coarsest) is { } next ? AnalysisLinks.Solar(next) : null;
|
||||
|
||||
/// <summary>The buckets are finer than the data: open the interval that shows it (replacing the address, D-46).</summary>
|
||||
private void UseBucket(BucketSize size) => Nav.NavigateTo(AnalysisLinks.Solar(Query.WithBucket(size)), replace: true);
|
||||
|
||||
/// <summary>A chart bucket opens the next finer period (D-51), as a new history entry so Back returns.</summary>
|
||||
private void Drill(AnalysisBucket bucket)
|
||||
{
|
||||
if (DrillHref(bucket) is { } href)
|
||||
{
|
||||
Nav.NavigateTo(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using MeterVault.App.Analysis;
|
||||
using MeterVault.App.Localization;
|
||||
using MeterVault.Core.Analysis;
|
||||
using MeterVault.Core.Analysis.Quantities;
|
||||
using MeterVault.Infrastructure.Analysis;
|
||||
using MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
namespace MeterVault.App.Components.Pages.Specialized;
|
||||
|
||||
/// <summary>
|
||||
/// One Solar section as the page draws it: the read model and its chart and table series, built once inside the load so
|
||||
/// they are formatted in the request's culture and the chart re-keys only on a new result (foundation §13.10).
|
||||
/// </summary>
|
||||
/// <param name="Site">The energy type's solar figures.</param>
|
||||
/// <param name="Chart">Generation and self-consumption per bucket (one unit), with the generation comparison overlay.</param>
|
||||
/// <param name="Table">Generation, self-consumption (with its savings) and feed-in per bucket.</param>
|
||||
/// <param name="Names">The meter names the attention items and value details speak of.</param>
|
||||
public sealed record SolarSiteView(
|
||||
SolarSite Site, IReadOnlyList<AnalysisChartSeries> Chart, IReadOnlyList<AnalysisTableSeries> Table, AttentionNames Names)
|
||||
{
|
||||
/// <summary>Builds the series of a section for <paramref name="query"/>'s comparison.</summary>
|
||||
public static SolarSiteView Build(SolarSite site, AnalysisQuery query, string currency)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(site);
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var chart = new List<AnalysisChartSeries>();
|
||||
var table = new List<AnalysisTableSeries>();
|
||||
var generation = site.Generation;
|
||||
if (generation is not null)
|
||||
{
|
||||
chart.Add(AnalysisChartSeries.ForSeries(generation, Strings.Solar_Generation));
|
||||
table.Add(AnalysisTableSeries.ForSeries(generation, Strings.Solar_Generation));
|
||||
}
|
||||
|
||||
if (site.SelfConsumption is { UnitsDiffer: false } self)
|
||||
{
|
||||
if (generation is null || Units.AreSame(self.Unit, generation.Unit))
|
||||
{
|
||||
chart.Add(new AnalysisChartSeries("self-consumption", Strings.Solar_SelfConsumption, self.Unit, self.Values));
|
||||
}
|
||||
|
||||
var row = AnalysisTableSeries.ForValues("self-consumption", Strings.Solar_SelfConsumption, self.Unit, self.Values, self.Total) with
|
||||
{
|
||||
Polarity = ChangePolarity.HigherIsBetter,
|
||||
};
|
||||
table.Add(row);
|
||||
}
|
||||
|
||||
// What the self-consumption saved, as its own money column: it is a value, not a cost of anything.
|
||||
if (site.Savings is { } savings)
|
||||
{
|
||||
table.Add(AnalysisTableSeries.ForCosts("savings", Strings.Solar_Savings, currency, savings.Buckets, savings.Total) with
|
||||
{
|
||||
Polarity = ChangePolarity.HigherIsBetter,
|
||||
});
|
||||
}
|
||||
|
||||
if (site.FeedIn is { UnitsDiffer: false } feedIn)
|
||||
{
|
||||
table.Add(AnalysisTableSeries.ForValues("feed-in", Strings.Solar_FeedIn, feedIn.Unit, feedIn.Values, feedIn.Total) with
|
||||
{
|
||||
Polarity = ChangePolarity.Neutral,
|
||||
});
|
||||
}
|
||||
|
||||
if (generation is not null
|
||||
&& AnalysisChartSeries.ComparisonOf(generation, AnalysisChartSeries.ComparisonName(Strings.Solar_Generation, query.Comparison)) is { } overlay)
|
||||
{
|
||||
chart.Add(overlay);
|
||||
}
|
||||
|
||||
return new SolarSiteView(site, chart, table, AttentionNames.From(site.Quantities));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One tank as the page draws it: the read model and its usage chart and table series (with its cost and the burner
|
||||
/// runtime), built inside the load.
|
||||
/// </summary>
|
||||
/// <param name="Tank">The tank's figures.</param>
|
||||
/// <param name="Chart">Usage per bucket, with its comparison overlay.</param>
|
||||
/// <param name="Table">Usage per bucket with its cost, and the burner runtime.</param>
|
||||
/// <param name="Names">The meter names the attention items and value details speak of.</param>
|
||||
public sealed record TankView(TankAnalysis Tank, IReadOnlyList<AnalysisChartSeries> Chart, IReadOnlyList<AnalysisTableSeries> Table, AttentionNames Names)
|
||||
{
|
||||
/// <summary>The buckets of the read every tank shares.</summary>
|
||||
public IReadOnlyList<AnalysisBucket> Buckets { get; init; } = [];
|
||||
|
||||
/// <summary>The comparison buckets paired with <see cref="Buckets"/> (A-10), when a comparison was read.</summary>
|
||||
public IReadOnlyList<BucketPair>? Pairs { get; init; }
|
||||
|
||||
/// <summary>Builds the series of a tank for <paramref name="query"/>'s comparison.</summary>
|
||||
public static TankView Build(TankAnalysis tank, AnalysisResult? quantities, AnalysisQuery query, string currency)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tank);
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var chart = new List<AnalysisChartSeries>();
|
||||
var table = new List<AnalysisTableSeries>();
|
||||
if (tank.Usage is { } usage)
|
||||
{
|
||||
chart.Add(AnalysisChartSeries.ForSeries(usage, Strings.Consumables_Used));
|
||||
if (AnalysisChartSeries.ComparisonOf(usage, AnalysisChartSeries.ComparisonName(Strings.Consumables_Used, query.Comparison)) is { } overlay)
|
||||
{
|
||||
chart.Add(overlay);
|
||||
}
|
||||
|
||||
var row = AnalysisTableSeries.ForSeries(usage, Strings.Consumables_Used);
|
||||
// A tank without any tariff says so once (its cost card, its attention item), not in every row.
|
||||
table.Add(tank.Cost is { Refusal: Infrastructure.Costing.CostRefusal.None } cost && cost.Total.Status != Core.Analysis.Costing.CostStatus.NotPriced
|
||||
? row.WithCosts(cost.Buckets, cost.Total, currency)
|
||||
: row);
|
||||
}
|
||||
|
||||
foreach (var runtime in tank.Runtime)
|
||||
{
|
||||
table.Add(AnalysisTableSeries.ForValues(runtime.Key.Id, Loc.F(Strings.Consumables_RuntimeOf, runtime.Name), runtime.Unit, runtime.Values, runtime.Total) with
|
||||
{
|
||||
Polarity = ChangePolarity.Neutral,
|
||||
IsAdditive = runtime.IsAdditive,
|
||||
});
|
||||
}
|
||||
|
||||
return new TankView(tank, chart, table, AttentionNames.From(quantities, tank.Cost))
|
||||
{
|
||||
Buckets = quantities?.Plan.Buckets ?? [],
|
||||
Pairs = quantities?.Comparison?.Buckets,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@* One tank (brief §7.5, D-54). "Now" and "the selected period" are two labelled parts, so a historical range never shows
|
||||
today's contents as if they were then: now = the last dipstick as measured, the contents estimated from it with the
|
||||
deliveries since, and the forecast — always a projection, hidden when the dipstick is too old; the period = usage per
|
||||
bucket, deliveries in it, the contents at its end when it is over, burner runtime, the burn rate and the cost, each
|
||||
with its status (an unknown cost is never 0). The actions done standing next to the tank sit in its header. *@
|
||||
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mb-5 mv-tank">
|
||||
<div class="d-flex align-center flex-wrap mb-2" style="gap:.5rem">
|
||||
<h2 class="mud-typography mud-typography-h6 mv-tank__name">
|
||||
<MudLink Href="@MeterLinks.Analysis(Tank.MeterId, Query)" Typo="Typo.h6">@Tank.Name</MudLink>
|
||||
</h2>
|
||||
<MudSpacer />
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Straighten"
|
||||
Href="@MeterLinks.Event(Tank.MeterId, MeterEventType.TankLevel)">@S.Consumables_RecordTankLevel</MudButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.LocalShipping"
|
||||
Href="@MeterLinks.Event(Tank.MeterId, MeterEventType.Delivery)">@S.Consumables_RecordDelivery</MudButton>
|
||||
</div>
|
||||
|
||||
<AttentionList Problems="Problems" CostAttention="Tank.Cost?.Attention" Names="View.Names" Query="Query" MaxItems="4" Class="mb-3" />
|
||||
|
||||
@* ---------------------------------------------------------------- now *@
|
||||
<div class="mv-tank__part-head">
|
||||
<MudText Typo="Typo.subtitle1">@S.Consumables_NowTitle</MudText>
|
||||
<MudText Typo="Typo.caption" Class="mv-muted">@S.Consumables_NowHint</MudText>
|
||||
</div>
|
||||
<MudGrid Spacing="3" Class="mb-4">
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
@if (Tank.LastDipstick is { } dipstick)
|
||||
{
|
||||
<MetricCard Title="@S.Consumables_LastDipstick" Value="@BucketValue.Available(dipstick.Volume, Provenance.None)" Unit="@Tank.Unit"
|
||||
Caption="@DipstickCaption(dipstick)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MetricCard Title="@S.Consumables_LastDipstick" Caption="@S.Consumables_NoDipstick">
|
||||
<MudLink Href="@MeterLinks.Event(Tank.MeterId, MeterEventType.TankLevel)" Typo="Typo.body2">@S.Consumables_RecordTankLevel</MudLink>
|
||||
</MetricCard>
|
||||
}
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
@if (Tank.EstimatedNow is { } estimate)
|
||||
{
|
||||
<MetricCard Title="@S.Consumables_EstimatedNow" Value="@BucketValue.Available(estimate.Volume, Provenance.Estimated)" Unit="@Tank.Unit"
|
||||
Caption="@ContentsCaption(estimate)">
|
||||
@if (Tank.FillFraction is { } fill)
|
||||
{
|
||||
<MudProgressLinear Color="@FillColor(estimate.Volume)" Value="@(Math.Min(fill, 1) * 100)" Class="my-2" Size="Size.Medium"
|
||||
aria-label="@S.Consumables_Fill" />
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block">
|
||||
@Loc.F(S.Consumables_FillOfCapacity, Format.Quantity(fill * 100, "%"), Format.Quantity(Tank.Capacity, Tank.Unit))
|
||||
</MudText>
|
||||
}
|
||||
</MetricCard>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MetricCard Title="@S.Consumables_EstimatedNow" Caption="@S.Consumables_EstimatedNowNeedsDipstick" />
|
||||
}
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="12" md="4">
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-metric">
|
||||
<MudText Typo="Typo.overline" Class="mv-muted mv-metric__title">@S.Consumables_ForecastEmpty</MudText>
|
||||
@if (Tank.Forecast is { State: TankForecastState.Projected } forecast)
|
||||
{
|
||||
<div class="mv-metric__value-row">
|
||||
<span class="mv-metric__value">@(forecast.EmptyOn is { } day ? Format.Date(day) : S.Consumables_ForecastBeyond)</span>
|
||||
</div>
|
||||
<ProjectionNote Days="forecast.BasisDays" ValueText="@PerDayText(forecast)" Class="mt-1" />
|
||||
@if (forecast.EmptyOn is { } empty && empty < Today)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block">@S.Consumables_ForecastPassed</MudText>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mv-metric__value-row">
|
||||
<span class="mv-metric__value mv-metric__value--words">@S.Consumables_ForecastNone</span>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block">@ForecastReason(Tank.Forecast)</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@* ---------------------------------------------------------------- the period *@
|
||||
<div class="mv-tank__part-head">
|
||||
<MudText Typo="Typo.subtitle1">@S.Consumables_PeriodTitle</MudText>
|
||||
<MudText Typo="Typo.caption" Class="mv-muted">@Format.PeriodRange(Period)</MudText>
|
||||
</div>
|
||||
|
||||
@if (Tank.Usage is { IsPending: true })
|
||||
{
|
||||
<PendingState OnRefresh="OnRefresh" Class="mb-3" />
|
||||
}
|
||||
else if (Period.NotYetOccurred || Period.HasNotStarted())
|
||||
{
|
||||
<EmptyPeriodState NotYetOccurred="true" Class="mb-3" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="3" Class="mb-3">
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MetricCard Title="@S.Consumables_Used" Value="@(Tank.Usage?.Total ?? BucketValue.Missing())" Unit="@(Tank.Usage?.Unit ?? Tank.Unit)"
|
||||
Change="@ChangeOf(Tank.Usage?.Comparison?.Change)" Polarity="ChangePolarity.HigherIsWorse" ChangeCaption="@ChangeCaption"
|
||||
Caption="@S.Consumables_UsedCaption" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-4 mv-metric">
|
||||
<MudText Typo="Typo.overline" Class="mv-muted mv-metric__title">@S.Consumables_Delivered</MudText>
|
||||
<div class="mv-metric__value-row">
|
||||
@if (Tank.Deliveries.Count > 0)
|
||||
{
|
||||
<span class="mv-metric__value">@Format.Quantity(Tank.DeliveredInPeriod, Tank.Unit)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="mv-metric__value mv-metric__value--words">@S.Consumables_NoDeliveriesShort</span>
|
||||
}
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Class="mv-muted d-block">@Loc.F(S.Consumables_DeliveriesCount, Tank.Deliveries.Count)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
@if (Tank.PeriodEndsBeforeNow)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
@if (Tank.AtPeriodEnd is { } end)
|
||||
{
|
||||
<MetricCard Title="@Loc.F(S.Consumables_AtPeriodEnd, Format.Date(Period.LastDay))" Value="@BucketValue.Available(end.Volume, Provenance.Estimated)"
|
||||
Unit="@Tank.Unit" Caption="@ContentsCaption(end)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MetricCard Title="@Loc.F(S.Consumables_AtPeriodEnd, Format.Date(Period.LastDay))" Caption="@S.Consumables_NoDipstickBeforeEnd" />
|
||||
}
|
||||
</MudItem>
|
||||
}
|
||||
@if (Tank.Runtime.Count > 0)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MetricCard Title="@S.Consumables_BurnerRuntime" Value="Tank.RuntimeTotal" Unit="@Tank.RuntimeUnit"
|
||||
Change="@ChangeOf(RuntimeChange)" Polarity="ChangePolarity.Neutral" ChangeCaption="@ChangeCaption"
|
||||
Caption="@Loc.F(S.Consumables_RuntimeMeters, string.Join(", ", Tank.Runtime.Select(r => r.Name)))" />
|
||||
</MudItem>
|
||||
}
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
@if (Tank.Rate is { } rate)
|
||||
{
|
||||
<MetricCard Title="@S.Consumables_Rate" Value="rate.Value" Unit="@rate.Unit"
|
||||
Caption="@(rate.Source == TankRateSource.Fixed ? S.Consumables_RateFixed : S.Consumables_RateEmpirical)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MetricCard Title="@S.Consumables_Rate" Caption="@(Tank.Runtime.Count == 0 ? S.Consumables_RateNoRuntime : S.Consumables_RateNotHours)" />
|
||||
}
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MetricCard Title="@S.Consumables_Cost" Cost="Tank.Cost?.Total" Currency="@Currency" Caption="@S.Consumables_CostCaption" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@if (Tank.Usage is { } usage && (usage.Total.Status == BucketStatus.Missing))
|
||||
{
|
||||
<EmptyPeriodState Availability="usage.Availability" LatestHref="@LatestHref(usage)" Class="mb-3">
|
||||
@if (usage.Availability is null)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.Consumables_NoUsageNextStep</MudText>
|
||||
}
|
||||
</EmptyPeriodState>
|
||||
}
|
||||
else if (Tank.Usage is { } series)
|
||||
{
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">@Loc.F(S.Consumables_UsageChartTitle, Tank.Unit)</MudText>
|
||||
<AnalysisChart Buckets="Buckets" Series="View.Chart" ComparisonPairs="Pairs" Title="@Loc.F(S.Consumables_UsageChartTitle, Tank.Unit)"
|
||||
OnBucketClick="b => Drill(b, series)" Height="260" />
|
||||
<div class="mt-3">
|
||||
<AnalysisTable Buckets="Buckets" Series="View.Table" ComparisonPairs="Pairs" DrillHref="b => DrillHref(b, series)"
|
||||
Caption="@Loc.F(S.Consumables_UsageChartTitle, Tank.Unit)" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-1">@S.Consumables_DeliveriesInPeriod</MudText>
|
||||
@if (Tank.Deliveries.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mv-muted">@S.Consumables_NoDeliveries</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mv-table-scroll mv-tank__deliveries" role="region" aria-label="@S.Consumables_DeliveriesInPeriod" tabindex="0">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr><th scope="col">@S.Common_Date</th><th scope="col" class="mv-num">@S.Common_Amount</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var delivery in Tank.Deliveries)
|
||||
{
|
||||
<tr>
|
||||
<td>@Format.Date(LocalDate(delivery.Time))</td>
|
||||
<td class="mv-num">@Format.Quantity(delivery.Amount, delivery.Unit ?? Tank.Unit)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
/// <summary>The tank's figures and series, built inside the page's load.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public TankView View { get; set; } = null!;
|
||||
|
||||
/// <summary>The page's analysis state; links carry it.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public AnalysisQuery Query { get; set; } = null!;
|
||||
|
||||
/// <summary>The resolved period the figures are for.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public ResolvedPeriod Period { get; set; } = null!;
|
||||
|
||||
/// <summary>The reader's problems about this tank and its burners (D-53).</summary>
|
||||
[Parameter]
|
||||
public IReadOnlyList<AnalysisProblem> Problems { get; set; } = [];
|
||||
|
||||
/// <summary>The instance currency (D-43).</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Currency { get; set; } = "EUR";
|
||||
|
||||
/// <summary>Loads again (for "being prepared").</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnRefresh { get; set; }
|
||||
|
||||
private TankAnalysis Tank => View.Tank;
|
||||
|
||||
private IReadOnlyList<AnalysisBucket> Buckets => View.Buckets;
|
||||
|
||||
private IReadOnlyList<BucketPair>? Pairs => View.Pairs;
|
||||
|
||||
private DateOnly Today => PeriodResolver.LocalDate(Period.Now, Period.Zone);
|
||||
|
||||
private string? ChangeCaption => Query.Comparison.Kind == ComparisonKind.None ? null : Query.Comparison.Display();
|
||||
|
||||
/// <summary>The runtime change over the matched coverage, when there is one burner.</summary>
|
||||
private Change? RuntimeChange => Tank.Runtime.Count == 1 ? Tank.Runtime[0].Comparison?.Change : null;
|
||||
|
||||
/// <summary>The change chip only when a comparison was asked for.</summary>
|
||||
private Change? ChangeOf(Change? change) => Query.Comparison.Kind == ComparisonKind.None ? null : change ?? Change.Unavailable;
|
||||
|
||||
private DateOnly LocalDate(DateTimeOffset instant) => PeriodResolver.LocalDate(instant, Period.Zone);
|
||||
|
||||
private string DipstickCaption(TankDipstick dipstick)
|
||||
{
|
||||
var date = Format.Date(LocalDate(dipstick.Time));
|
||||
var showReading = dipstick.IsCalibrated
|
||||
|| (dipstick.ReadingUnit is not null && !string.Equals(dipstick.ReadingUnit, Tank.Unit, StringComparison.OrdinalIgnoreCase));
|
||||
return showReading
|
||||
? Loc.F(S.Consumables_DipstickOnReading, date, Format.Quantity(dipstick.Reading, dipstick.ReadingUnit))
|
||||
: Loc.F(S.Consumables_DipstickOn, date);
|
||||
}
|
||||
|
||||
/// <summary>"Dipstick of 1 May 2026 plus 4,000 L delivered since; use since then is not deducted."</summary>
|
||||
private string ContentsCaption(TankContents contents)
|
||||
{
|
||||
var date = Format.Date(LocalDate(contents.Dipstick.Time));
|
||||
return contents.DeliveriesSince > 0
|
||||
? Loc.F(S.Consumables_ContentsWithDeliveries, date, Format.Quantity(contents.DeliveredSince, Tank.Unit))
|
||||
: Loc.F(S.Consumables_ContentsNoDeliveries, date);
|
||||
}
|
||||
|
||||
private string PerDayText(TankForecast forecast) =>
|
||||
forecast.PerDay is { } perDay ? Loc.F(S.Consumables_PerDay, Format.Quantity(perDay, Tank.Unit)) : string.Empty;
|
||||
|
||||
private string ForecastReason(TankForecast forecast) => forecast.State switch
|
||||
{
|
||||
TankForecastState.DipstickTooOld => Loc.F(S.Consumables_ForecastTooOld, forecast.DipstickAgeDays ?? 0, TankForecast.MaxDipstickAgeDays),
|
||||
TankForecastState.NotEnoughHistory => Loc.F(S.Consumables_ForecastShort, TankForecast.MinBasisDays),
|
||||
TankForecastState.NoUse => S.Consumables_ForecastNoUse,
|
||||
_ => S.Consumables_ForecastNoDipstick,
|
||||
};
|
||||
|
||||
/// <summary>The fill bar's colour: the tank's own thresholds when set, else 15 % / 30 % of the capacity.</summary>
|
||||
private Color FillColor(double volume)
|
||||
{
|
||||
var low = Tank.LowThreshold ?? Tank.Capacity * 0.15;
|
||||
var reorder = Tank.ReorderThreshold ?? Tank.Capacity * 0.30;
|
||||
return volume < low ? Color.Error : volume < reorder ? Color.Warning : Color.Success;
|
||||
}
|
||||
|
||||
private string? LatestHref(AnalysisSeries usage) =>
|
||||
AnalysisNavigation.LatestData(Query, usage.Availability) is { } latest ? AnalysisLinks.Consumables(latest) : null;
|
||||
|
||||
/// <summary>
|
||||
/// The next finer period on this page (D-51); for data too coarse to cut finer (dipsticks every few weeks), the tank's
|
||||
/// normalized rows of that bucket.
|
||||
/// </summary>
|
||||
private string DrillHref(AnalysisBucket bucket, AnalysisSeries usage) =>
|
||||
AnalysisNavigation.DrillInto(Query, bucket, usage.Resolution) is { } next
|
||||
? AnalysisLinks.Consumables(next)
|
||||
: AnalysisNavigation.NormalizedData(Tank.MeterId, Query, bucket);
|
||||
|
||||
private void Drill(AnalysisBucket bucket, AnalysisSeries usage) => Nav.NavigateTo(DrillHref(bucket, usage));
|
||||
}
|
||||
@@ -1,60 +1,238 @@
|
||||
@page "/trends"
|
||||
@inject DashboardService Dash
|
||||
@using MeterVault.App.AnalysisPage
|
||||
@using MeterVault.App.Components.Pages.AnalysisPage
|
||||
@using MeterVault.Core.Analysis
|
||||
@using MeterVault.Infrastructure.Analysis
|
||||
@using MeterVault.Infrastructure.Persistence
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Nav
|
||||
@inject InstanceClock Clock
|
||||
@inject AnalysisPeriods Periods
|
||||
@inject AnalysisReader Reader
|
||||
@inject CostReader Costs
|
||||
@inject IDbContextFactory<MeterVaultDbContext> Db
|
||||
@inject ILogger<Trends> Logger
|
||||
|
||||
<PageTitle>MeterVault — @S.Nav_Trends</PageTitle>
|
||||
@* The Analysis page (brief §7.4; the route stays /trends): one place to explore everything, an energy type, a cost
|
||||
category, one meter or up to six meters side by side — by a quantity or by cost, over any period, against the previous
|
||||
year or a named year, down to the finest bucket the data resolves. Every choice is part of the address (replace), so
|
||||
reload, Back and a shared link restore it. Quantities come from the shared analysis reader, costs from the cost reader:
|
||||
the same figures as the meter and energy type pages and the Overview, manual costs included once. *@
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@S.Trends_Title</MudText>
|
||||
<PageHeader Title="@S.Nav_Analysis" Description="@S.Analysis_Description">
|
||||
<Breadcrumbs>
|
||||
<AnalysisBreadcrumbs Query="_shown" Current="@S.Nav_Analysis" />
|
||||
</Breadcrumbs>
|
||||
<Actions>
|
||||
@if (ScopeHref is { } href)
|
||||
{
|
||||
<MudButton Href="@href" Variant="Variant.Outlined" Size="Size.Small" StartIcon="@Icons.Material.Filled.OpenInNew">@ScopeHrefText</MudButton>
|
||||
}
|
||||
</Actions>
|
||||
</PageHeader>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<div class="d-flex align-center mb-3" style="gap:1rem">
|
||||
<MudSelect T="int" @bind-Value="_months" Label="@S.Common_Range" Dense="true" Style="max-width:180px">
|
||||
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="48">@S.Trends_RangeLast48Months</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync" Disabled="_loading">@S.Trends_Apply</MudButton>
|
||||
</div>
|
||||
@foreach (var notice in Notices.Where(n => !_dismissed.Contains(n)))
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" ShowCloseIcon="true" CloseIconClicked="() => _dismissed.Add(notice)" Class="mb-2">
|
||||
@notice
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@if (_loading)
|
||||
<MudPaper Outlined="true" Elevation="0" Class="pa-3 mb-3 mv-analysis-controls">
|
||||
@if (_options is not null && _selection is not null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<TrendChart Points="_points" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-4">
|
||||
@Loc.F(S.Trends_TotalOverRange, Format.Euro(_points.Sum(p => p.Cost)))
|
||||
</MudText>
|
||||
<ScopeSelector Options="_options" Selection="_selection" Query="_shown" QueryChanged="ApplyAsync" />
|
||||
}
|
||||
<PeriodToolbar Query="_shown" Period="_state.Value?.View.Period" Plan="_state.Value?.View.Plan" Defaults="Defaults"
|
||||
QueryChanged="ApplyAsync" ExportHref="@ExportHref" Class="mt-2">
|
||||
@if (YearOptions.Count > 0)
|
||||
{
|
||||
<div class="mv-toolbar__field mv-analysis-year">
|
||||
<MudSelect T="int?" Value="SelectedYear" ValueChanged="OnYearChanged" Label="@S.Analysis_CalendarYear"
|
||||
Variant="Variant.Outlined" Margin="Margin.Dense" Dense="true" Clearable="false"
|
||||
ToStringFunc="@(y => y?.ToString(System.Globalization.CultureInfo.CurrentCulture) ?? string.Empty)">
|
||||
@foreach (var year in YearOptions)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="year">@year.ToString(System.Globalization.CultureInfo.CurrentCulture)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</div>
|
||||
}
|
||||
</PeriodToolbar>
|
||||
</MudPaper>
|
||||
|
||||
<LoadPanel State="_state" OnRetry="RetryAsync" Context="page">
|
||||
<AnalysisPageContent View="page.View" Options="page.Options" Shown="page.Shown" Defaults="Defaults" OnShow="ApplyAsync"
|
||||
OnRefresh="() => LoadAsync(_query)" />
|
||||
</LoadPanel>
|
||||
|
||||
@code {
|
||||
private int _months = 24;
|
||||
/// <summary>The history defaults (D-02, A-13): the last 12 months, automatic buckets, against the previous year; the portfolio.</summary>
|
||||
private static readonly AnalysisDefaults Defaults = AnalysisDefaults.History;
|
||||
|
||||
// Starts idle: LoadAsync is the one that sets it. Starting busy made the very first load bail out
|
||||
// on its own guard, so the page never got past the progress bar and Apply stayed disabled.
|
||||
private bool _loading;
|
||||
private IReadOnlyList<TrendPoint> _points = [];
|
||||
private readonly LoadSequencer _loads = new();
|
||||
private readonly LoadState<PageState> _state = new();
|
||||
private readonly HashSet<string> _dismissed = [];
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
private AnalysisPageOptions? _options;
|
||||
private AnalysisQuery _query = AnalysisQuery.Default(Defaults);
|
||||
private AnalysisSelection? _selection;
|
||||
private AnalysisQuery _shown = AnalysisQuery.Default(Defaults);
|
||||
private bool _loaded;
|
||||
|
||||
private async Task LoadAsync()
|
||||
/// <summary>One committed load: the options it was read against, the address shown and the view.</summary>
|
||||
private sealed record PageState(AnalysisPageOptions Options, AnalysisQuery Shown, AnalysisPageView View);
|
||||
|
||||
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
|
||||
|
||||
protected override Task OnParametersSetAsync() => ReloadIfChangedAsync();
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _ = InvokeAsync(async () =>
|
||||
{
|
||||
if (_loading)
|
||||
await ReloadIfChangedAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
private async Task ReloadIfChangedAsync()
|
||||
{
|
||||
var query = AnalysisQuery.Parse(Nav.Uri, Defaults);
|
||||
if (_loaded && query == _query)
|
||||
{
|
||||
return; // guard against overlapping loads
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
try
|
||||
_loaded = true;
|
||||
_query = query;
|
||||
Select();
|
||||
await LoadAsync(query);
|
||||
}
|
||||
|
||||
/// <summary>Reads the address against the options at once, so the selectors follow a change before its data arrives.</summary>
|
||||
private void Select()
|
||||
{
|
||||
_selection = _options is null ? null : AnalysisSelection.Resolve(_query, _options);
|
||||
_shown = _selection?.Shown(_query) ?? _query;
|
||||
}
|
||||
|
||||
private Task LoadAsync(AnalysisQuery query) => _loads.RunAsync(_state, async token =>
|
||||
{
|
||||
var options = _options ?? await AnalysisPageOptions.LoadAsync(Db, Reader, token);
|
||||
var selection = AnalysisSelection.Resolve(query, options);
|
||||
var view = await new AnalysisPageLoader(Reader, Costs, Periods).LoadAsync(query, selection, options, Clock.Now, token);
|
||||
if (_options is null)
|
||||
{
|
||||
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));
|
||||
_options = options;
|
||||
if (query == _query)
|
||||
{
|
||||
Select();
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
return new PageState(options, selection.Shown(query), view);
|
||||
}, Logger);
|
||||
|
||||
private async Task RetryAsync()
|
||||
{
|
||||
// A retry reads the meters, types and categories again too: one of them may be what failed.
|
||||
_options = null;
|
||||
Select();
|
||||
await LoadAsync(_query);
|
||||
}
|
||||
|
||||
/// <summary>A selector or the toolbar changed the state: written into the address, replacing the entry (D-46).</summary>
|
||||
private Task ApplyAsync(AnalysisQuery next)
|
||||
{
|
||||
AnalysisNavigation.Replace(Nav, next, Defaults);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>The notices of the address (invalid keys) and of the selection (a metric that does not apply, meters gone).</summary>
|
||||
private IEnumerable<string> Notices
|
||||
{
|
||||
get
|
||||
{
|
||||
_loading = false;
|
||||
foreach (var notice in _query.Notices)
|
||||
{
|
||||
yield return notice.Kind.Display();
|
||||
}
|
||||
|
||||
foreach (var notice in _selection?.Notices ?? [])
|
||||
{
|
||||
yield return notice.Kind switch
|
||||
{
|
||||
AnalysisPageNoticeKind.MetricNotAvailable when notice.Shown is { } shown =>
|
||||
Loc.F(S.Analysis_NoticeMetricNotAvailable, notice.Requested?.Display() ?? string.Empty, shown.Display()),
|
||||
AnalysisPageNoticeKind.MetricNotAvailable => Loc.F(S.Analysis_NoticeMetricNotShown, notice.Requested?.Display() ?? string.Empty),
|
||||
_ => S.Analysis_NoticeUnknownMeters,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The CSV of what is shown (D-55): the readers' query, so the file holds the figures on screen.</summary>
|
||||
private string? ExportHref => _selection is { Refusal: AnalysisPageRefusal.None } selection ? AnalysisLinks.Export(selection.ReadQuery(_query)) : null;
|
||||
|
||||
/// <summary>The page the scope has of its own (an energy type, a meter), with the period.</summary>
|
||||
private string? ScopeHref => _selection is not { Refusal: AnalysisPageRefusal.None } selection
|
||||
? null
|
||||
: selection.Scope.Kind switch
|
||||
{
|
||||
QueryScopeKind.EnergyType => AnalysisLinks.EnergyType(selection.Scope.Id!.Value, AnalysisLinks.EnergyTabHistory, _shown),
|
||||
QueryScopeKind.Meter => MeterLinks.Analysis(selection.Scope.Id!.Value, _shown),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private string ScopeHrefText => _selection?.Scope.Kind == QueryScopeKind.Meter ? S.Analysis_OpenMeter : S.Analysis_OpenEnergyType;
|
||||
|
||||
/// <summary>
|
||||
/// Calendar years to pick (the "compare two years" journey): from the first year with data to the current one.
|
||||
/// Picking one shows that year; the comparison then offers the years before it.
|
||||
/// </summary>
|
||||
private IReadOnlyList<int> YearOptions
|
||||
{
|
||||
get
|
||||
{
|
||||
var today = Clock.Today;
|
||||
var first = _state.Value?.View.Availability?.FirstDay.Year ?? today.Year;
|
||||
first = Math.Max(first, today.Year - 60);
|
||||
return [.. Enumerable.Range(first, today.Year - first + 1).Reverse()];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The calendar year the period is, if it is one (a custom 1 Jan – 31 Dec, the previous year, the year to date).</summary>
|
||||
private int? SelectedYear
|
||||
{
|
||||
get
|
||||
{
|
||||
var today = Clock.Today;
|
||||
return _shown.Period switch
|
||||
{
|
||||
PeriodPreset.YearToDate => today.Year,
|
||||
PeriodPreset.PreviousYear => today.Year - 1,
|
||||
PeriodPreset.Custom when _shown.From is { Month: 1, Day: 1 } from && _shown.To is { Month: 12, Day: 31 } to && from.Year == to.Year => from.Year,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnYearChanged(int? year)
|
||||
{
|
||||
if (year is not { } chosen || chosen == SelectedYear)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// The current year runs to date; a past year is the whole calendar year.
|
||||
var next = chosen == Clock.Today.Year
|
||||
? _shown.WithPeriod(PeriodPreset.YearToDate)
|
||||
: _shown.WithCustomRange(new DateOnly(chosen, 1, 1), new DateOnly(chosen, 12, 31));
|
||||
return ApplyAsync(next);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Nav.LocationChanged -= OnLocationChanged;
|
||||
_loads.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* The calendar-year picker takes little room, so the period, interval and comparison keep theirs ("Automatisch (Monatlich)"). */
|
||||
.mv-analysis-year { flex: 0 1 150px; min-width: 130px; }
|
||||
@media (max-width: 599.98px) {
|
||||
.mv-analysis-year { flex: 1 1 100%; max-width: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user