Meters: record events from the UI, and book consumption in the months it accrued in
Two threads that ended up in the same files. One is navigation: a meter
swap that happened today had no click path at all, and most per-meter
tasks were reachable only by knowing which admin page owned them. The
other is attribution: readings on 1 August and 16 September showed six
weeks of water under September and nothing under August.
Meter events from the UI
Swap, counter reset, tank level, delivery and note are recorded through
MeterEventService rather than ad-hoc inserts, so the dialog's verdict and
the saved result come from the same Validate call, and every record or
delete recomputes the meter inside one transaction. MeterEventRules
decides which events a mode offers -- a tank has no register to swap, and
Correction is offered nowhere because nothing reads it.
A swap is stored as the event at T plus a manual reading of the new
register's start value at exactly T. That pairing is the whole trick: the
boundary window is (previousReading, reading], so the old register's tail
books at T and every later reading counts from the new start. Writing the
old final value as the reading at T instead -- the obvious thing -- double
counts the tail and then rejects every reading the new register produces.
Deleting a swap removes that start reading only while it is still the
untouched start value, and only Manual readings can be deleted at all.
Navigation
The meter page is now the hub: primary entry by mode, a "Record event"
menu, and Edit through a shared MeterEditor that also owns tank setup.
Other pages link into it with MeterLinks (/meters/{id}?tab=...&action=...),
whose action is consumed once after the interactive render and dropped
from the address -- the reverse order flashes the dialog and closes it,
because a circuit's first location change dismisses every open dialog.
The app bar gains a "Find a meter" dialog with the same quick entry.
A source that has no usable connector now links to creating (or enabling)
one and comes back to the same source dialog with the connector picked
and everything typed still there; the draft survives in a circuit-scoped
DraftStore, and the way back is a meter id rather than a URL, so the page
cannot be made to redirect anywhere else. The connector list shows which
meters use each connector, import batches list the meters and categories
they wrote to, the meter editor owns the meter's own cost categories, and
the dashboard's empty cost panel names the first missing step instead of
listing every admin page.
Months
A reading is an instant, and what it measures accrued over the time since
the previous one. Booking the whole delta at the closing reading misfiles
it whenever the interval crosses a month boundary, so a plain increase is
now divided at local month boundaries in proportion to elapsed time, each
share stamped inside its month and marked estimated: the meter recorded a
total, not a shape. The parts always sum to the original.
Imported monthly tables are the exception that keeps the golden fixtures
reconciling. "Mai 2026" carries the register at the end of May but is
stamped on the 1st, so the importer -- the only place that still knows
whether the date cell named a month or a day -- flags it MonthLabel, and
the engine reads it as the end of its month. Inferring that from the
stamp instead would catch day-dated rows: a sheet with "01.08.2026" in it
is not a monthly table, and reading it as one moves two thirds of July
into August.
ReadingTimeline is the single ordering built on that: effective time,
then stamp. The register normalizers walk it, and so do the decrease
guard and the event dialog, which is what stops them disagreeing about
which reading is "previous" -- a sheet imported after live readings of the
same month used to count that month twice, and a mid-month reading below
the month's end value was rejected as a drop. A swap detected in a
monthly table applies from the start of that local month, i.e. to the
first reading in it, and a recorded start value never counts above the
reading it lands on.
Every reader buckets in the configured timezone rather than a hardcoded
one, and turns a requested date into that zone's local midnight, so the
divided shares are read back under the months they were stamped in. The
zone id is normalised to its IANA form, because .NET accepts a Windows id
that PostgreSQL will not bucket by, and both are checked at startup.
Stored consumption is derived, so a rule change reaches a meter only at
its next reading -- weeks, for a meter read monthly. NormalizationUpgrade
records the revision and zone the stored series was built with and
rebuilds everything once at startup when either differs, each meter in
its own transaction. A meter that fails is logged, kept in
normalization_pending and retried at the next start: one bad series must
never keep the application down.
What an operator sees once
Existing charts change on the first start after the update: months that
carried a neighbour's use give it back. Rows of earlier imports from
monthly tables are marked as such before anything is recomputed, and if
that marking fails nothing is rebuilt or recorded, so the upgrade simply
runs again next time rather than shifting every imported month by one. A
wizard import whose date format was left on auto-detect is treated as a
monthly table when all of its rows sit on the 1st across at least two
months -- exactly how those rows were attributed before -- and each such
batch is named in the log, because a day-dated sheet always read on the
1st looks identical; revert and re-import it with the day format if that
is what it was.
Tests: 120 unit and 230 integration, including the reference fixtures,
which still reconcile month for month.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
@inject MeterVault.Infrastructure.Security.SecretProtector Secrets
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
@@ -17,6 +18,14 @@
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_returnTo is { } back)
|
||||
{
|
||||
@* Arrived from a meter's source dialog: the way back is always in view, saved or not. *@
|
||||
<MudAlert Severity="Severity.Normal" Variant="Variant.Outlined" Class="mb-4" Dense="true" Icon="@Icons.Material.Filled.Sensors">
|
||||
@Loc.F(S.Connectors_ForMeter, back.MeterName) <MudLink Href="@MeterLinks.Source(back.MeterId, back.SourceId, back.SourceType)">@Loc.F(S.Connectors_BackToMeter, back.MeterName)</MudLink>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||
@S.Connectors_SecretsNoticePrefix <b>@S.Connectors_SecretsNoticeEnvVar</b> @S.Connectors_SecretsNoticeSuffix
|
||||
</MudAlert>
|
||||
@@ -32,6 +41,7 @@ else
|
||||
<MudTh>@S.Common_Name</MudTh>
|
||||
<MudTh>@S.Common_Type</MudTh>
|
||||
<MudTh>@S.Common_Enabled</MudTh>
|
||||
<MudTh>@S.Connectors_UsedBy</MudTh>
|
||||
<MudTh>@S.Connectors_LastStatus</MudTh>
|
||||
<MudTh>@S.Common_LastSeen</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||
@@ -40,6 +50,28 @@ else
|
||||
<MudTd DataLabel="@S.Common_Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Type">@context.Type.Display()</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Enabled">@(context.IsEnabled ? S.Connectors_Yes : S.Connectors_No)</MudTd>
|
||||
<MudTd DataLabel="@S.Connectors_UsedBy">
|
||||
@if (_usage.TryGetValue(context.Id, out var users))
|
||||
{
|
||||
@foreach (var user in users.Take(UsersShown))
|
||||
{
|
||||
<MudLink Href="@MeterLinks.Detail(user.MeterId, MeterLinks.TabSources)" Class="mr-2">@user.MeterName</MudLink>
|
||||
}
|
||||
@if (users.Count > UsersShown)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Inline="true">@Loc.F(S.Connectors_UsedByMore, users.Count - UsersShown)</MudText>
|
||||
}
|
||||
@if (!context.IsEnabled)
|
||||
{
|
||||
@* Workers skip disabled connectors, so every source on it has stopped. *@
|
||||
<MudText Typo="Typo.caption" Color="Color.Warning" Class="d-block">@S.Connectors_DisabledInUse</MudText>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Connectors_Unused</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="@S.Connectors_LastStatus">@(context.LastStatus ?? "—")</MudTd>
|
||||
<MudTd DataLabel="@S.Common_LastSeen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
|
||||
@@ -134,7 +166,31 @@ else
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
/// <summary>Opens a new connector of this <see cref="EndpointType"/> once the page is interactive.</summary>
|
||||
[SupplyParameterFromQuery(Name = "new")]
|
||||
public string? NewParam { get; set; }
|
||||
|
||||
/// <summary>Opens this connector for editing once the page is interactive.</summary>
|
||||
[SupplyParameterFromQuery(Name = "edit")]
|
||||
public int? EditParam { get; set; }
|
||||
|
||||
/// <summary>The meter whose source dialog sent the user here; see <see cref="MeterLinks.NewConnector"/>.</summary>
|
||||
[SupplyParameterFromQuery(Name = "meter")]
|
||||
public int? MeterParam { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = MeterLinks.ParamSource)]
|
||||
public int? SourceParam { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = MeterLinks.ParamSourceType)]
|
||||
public string? SourceTypeParam { get; set; }
|
||||
|
||||
private const int UsersShown = 3;
|
||||
|
||||
private List<IngestionEndpoint>? _endpoints;
|
||||
private Dictionary<int, List<ConnectorUser>> _usage = [];
|
||||
private ReturnTarget? _returnTo;
|
||||
private (EndpointType? New, int? Edit)? _pendingOpen;
|
||||
private bool _droppingOpen;
|
||||
private bool _editOpen;
|
||||
private bool _testing;
|
||||
private HaTestResult? _testResult;
|
||||
@@ -143,10 +199,97 @@ else
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (MeterParam != _returnTo?.MeterId)
|
||||
{
|
||||
_returnTo = null;
|
||||
if (MeterParam is { } meterId)
|
||||
{
|
||||
// Resolved against the database, so the way back names a meter that exists — and only
|
||||
// ever leads to a meter page, whatever the query string says.
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var name = await db.Meters.AsNoTracking().Where(m => m.Id == meterId).Select(m => m.Name).FirstOrDefaultAsync();
|
||||
if (name is not null)
|
||||
{
|
||||
_returnTo = new ReturnTarget(meterId, name, SourceParam, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_returnTo is not null)
|
||||
{
|
||||
_returnTo = _returnTo with
|
||||
{
|
||||
SourceId = SourceParam,
|
||||
SourceType = Enum.TryParse<SourceType>(SourceTypeParam, ignoreCase: true, out var sourceType) ? sourceType : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
|
||||
{
|
||||
_pendingOpen = (Enum.TryParse<EndpointType>(NewParam, ignoreCase: true, out var type) ? type : null, EditParam);
|
||||
}
|
||||
else
|
||||
{
|
||||
_droppingOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a deep-linked connector dialog. The request is dropped from the address first and the dialog
|
||||
/// opened once that navigation has come back — the order the meter page uses, for the same reason: a
|
||||
/// circuit's first location change dismisses any dialog already open.
|
||||
/// </summary>
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (_pendingOpen is not { } open || _endpoints is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
|
||||
{
|
||||
if (!_droppingOpen)
|
||||
{
|
||||
_droppingOpen = true;
|
||||
Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
|
||||
{
|
||||
["new"] = null,
|
||||
["edit"] = null,
|
||||
}), replace: true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingOpen = null;
|
||||
if (open.Edit is { } editId && _endpoints.FirstOrDefault(e => e.Id == editId) is { } endpoint)
|
||||
{
|
||||
OpenEdit(endpoint);
|
||||
}
|
||||
else if (open.New is { } newType)
|
||||
{
|
||||
OpenEdit(null);
|
||||
_working.Type = newType;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
||||
var users = await db.MeterSources.AsNoTracking()
|
||||
.Where(s => s.EndpointId != null)
|
||||
.Select(s => new { EndpointId = s.EndpointId!.Value, s.MeterId, MeterName = s.Meter!.Name })
|
||||
.ToListAsync();
|
||||
_usage = users
|
||||
.GroupBy(u => u.EndpointId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.DistinctBy(u => u.MeterId).OrderBy(u => u.MeterName).Select(u => new ConnectorUser(u.MeterId, u.MeterName)).ToList());
|
||||
}
|
||||
|
||||
private void OpenEdit(IngestionEndpoint? endpoint)
|
||||
@@ -282,25 +425,38 @@ else
|
||||
}.ToJson();
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
IngestionEndpoint saved;
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
db.IngestionEndpoints.Add(new IngestionEndpoint
|
||||
saved = new IngestionEndpoint
|
||||
{
|
||||
Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled,
|
||||
});
|
||||
};
|
||||
db.IngestionEndpoints.Add(saved);
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
|
||||
existing.Type = _working.Type;
|
||||
existing.Name = _working.Name.Trim();
|
||||
existing.Config = config;
|
||||
existing.IsEnabled = _working.IsEnabled;
|
||||
saved = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
|
||||
saved.Type = _working.Type;
|
||||
saved.Name = _working.Name.Trim();
|
||||
saved.Config = config;
|
||||
saved.IsEnabled = _working.IsEnabled;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
|
||||
// Back to the source that was waiting for this connector, with it picked — when it can serve that
|
||||
// source. Anything else (disabled, or of another kind) keeps the user here, the way back in view.
|
||||
if (_returnTo is { } back
|
||||
&& saved.IsEnabled
|
||||
&& (back.SourceType is not { } sourceType || SourceRouting.Serves(saved.Type, sourceType)))
|
||||
{
|
||||
Nav.NavigateTo(MeterLinks.Source(back.MeterId, back.SourceId, back.SourceType, saved.Id));
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -348,6 +504,11 @@ else
|
||||
private static IReadOnlyList<string> SplitTopics(string? csv) =>
|
||||
string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||
|
||||
private sealed record ConnectorUser(int MeterId, string MeterName);
|
||||
|
||||
/// <summary>The meter source a user came here to set up a connector for.</summary>
|
||||
private sealed record ReturnTarget(int MeterId, string MeterName, int? SourceId, SourceType? SourceType);
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavState NavState
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
@@ -142,6 +143,7 @@ else
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
NavState.NotifyEnergyTypesChanged();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -166,6 +168,7 @@ else
|
||||
db.EnergyTypes.Remove(target);
|
||||
await db.SaveChangesAsync();
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
NavState.NotifyEnergyTypesChanged();
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/consumables"
|
||||
@inject ConsumableService ConsumablesSvc
|
||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — @S.Consumables_PageTitle</PageTitle>
|
||||
@@ -14,14 +15,26 @@
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@* 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)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
|
||||
@if (_items is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (_items.Count == 0)
|
||||
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>
|
||||
}
|
||||
@@ -30,7 +43,15 @@ else
|
||||
@foreach (var item in _items)
|
||||
{
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
|
||||
@* 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>
|
||||
@@ -46,7 +67,7 @@ else
|
||||
}
|
||||
@if (item.LevelAsOf is { } asOf)
|
||||
{
|
||||
<text> · @Loc.F(S.Consumables_AsOf, asOf.ToString("yyyy-MM-dd"))</text>
|
||||
<text> · @Loc.F(S.Consumables_AsOf, Local(asOf).ToString("yyyy-MM-dd"))</text>
|
||||
}
|
||||
</MudText>
|
||||
</MudItem>
|
||||
@@ -120,7 +141,7 @@ else
|
||||
@foreach (var delivery in item.Deliveries)
|
||||
{
|
||||
<tr>
|
||||
<td>@delivery.Time.ToString("yyyy-MM-dd")</td>
|
||||
<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>
|
||||
}
|
||||
@@ -138,8 +159,16 @@ else
|
||||
private int _months = 60;
|
||||
private bool _loading;
|
||||
private IReadOnlyList<ConsumableSummary>? _items;
|
||||
private IReadOnlyList<UnconfiguredConsumable> _unconfigured = [];
|
||||
private TimeZoneInfo _tz = TimeZoneInfo.Utc;
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
_tz = LocalTimeEntry.Resolve(Options.Value.TimeZone);
|
||||
return LoadAsync();
|
||||
}
|
||||
|
||||
private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz);
|
||||
|
||||
private async Task OnRangeChanged(int months)
|
||||
{
|
||||
@@ -160,6 +189,7 @@ else
|
||||
{
|
||||
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
|
||||
|
||||
@@ -55,7 +55,27 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Dashboard_NoCostData</MudText>
|
||||
@* 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>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
@@ -90,6 +110,7 @@ else
|
||||
private DashboardSummary? _summary;
|
||||
private IReadOnlyList<CategorySlice> _breakdown = [];
|
||||
private IReadOnlyList<DifferenceRow> _difference = [];
|
||||
private CostSetup? _setup;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -98,6 +119,10 @@ else
|
||||
|
||||
var yearStart = new DateOnly(asOf.Year, 1, 1);
|
||||
_breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1));
|
||||
if (_breakdown.Count == 0)
|
||||
{
|
||||
_setup = await Dash.GetCostSetupAsync();
|
||||
}
|
||||
_difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@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 Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
@@ -21,7 +22,7 @@
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (!_graph.HasData)
|
||||
else if (_meters.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
@S.EnergyView_NoMetersIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.EnergyView_NoMetersOr
|
||||
@@ -30,72 +31,91 @@ else if (!_graph.HasData)
|
||||
}
|
||||
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>
|
||||
@* 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)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4">@S.EnergyView_NoDataInRange</MudAlert>
|
||||
}
|
||||
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)
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-1">@S.EnergyView_Flow</MudText>
|
||||
@if (_graph.HasChain)
|
||||
{
|
||||
<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>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
|
||||
@S.EnergyView_FlowCaption
|
||||
</MudText>
|
||||
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
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>
|
||||
}
|
||||
|
||||
<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></tr></thead>
|
||||
<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="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
|
||||
<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>
|
||||
@@ -138,7 +158,9 @@ else
|
||||
var to = asOf.AddMonths(1);
|
||||
var typeId = (short)Id;
|
||||
|
||||
_graph = await Flow.GetFlowAsync(typeId, from, to);
|
||||
// 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();
|
||||
@@ -150,14 +172,16 @@ else
|
||||
.GroupBy(l => l.FromMeterId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList());
|
||||
|
||||
var fromUtc = new DateTimeOffset(from.Year, from.Month, from.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
var toUtc = new DateTimeOffset(to.Year, to.Month, to.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
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
|
||||
{
|
||||
|
||||
@@ -90,13 +90,32 @@
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>#</th><th>@S.Import_ColumnSource</th><th style="text-align:right">@S.Import_ColumnRows</th><th>@S.Import_ColumnImported</th><th>@S.Common_Status</th><th></th></tr></thead>
|
||||
<thead><tr><th>#</th><th>@S.Import_ColumnSource</th><th>@S.Import_ColumnWritesTo</th><th style="text-align:right">@S.Import_ColumnRows</th><th>@S.Import_ColumnImported</th><th>@S.Common_Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var batch in _batches)
|
||||
{
|
||||
<tr>
|
||||
<td>@batch.Id</td>
|
||||
<td>@(batch.SourceName ?? "—")</td>
|
||||
<td>
|
||||
@* What the batch wrote to, so an import can be checked where it landed — and a
|
||||
revert weighed against what it will take away. *@
|
||||
@if (_targets.TryGetValue(batch.Id, out var targets))
|
||||
{
|
||||
@foreach (var meter in targets.Meters)
|
||||
{
|
||||
<MudLink Href="@MeterLinks.Detail(meter.Id)" Class="mr-2">@meter.Name</MudLink>
|
||||
}
|
||||
@foreach (var category in targets.Categories)
|
||||
{
|
||||
<MudLink Href="/admin/categories" Class="mr-2" Color="Color.Secondary">@category</MudLink>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>—</span>
|
||||
}
|
||||
</td>
|
||||
<td style="text-align:right">@batch.RowCount</td>
|
||||
<td>@batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td>
|
||||
@@ -130,6 +149,7 @@
|
||||
private string _profileName = "Strom";
|
||||
private StagedImport? _preview;
|
||||
private List<ImportBatch> _batches = [];
|
||||
private Dictionary<int, BatchTargets> _targets = [];
|
||||
private int? _reverting;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -145,8 +165,69 @@
|
||||
.OrderByDescending(b => b.Id)
|
||||
.Take(25)
|
||||
.ToListAsync();
|
||||
_targets = await LoadTargetsAsync(db, [.. _batches.Where(b => b.RevertedAt is null).Select(b => b.Id)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The meters and cost categories each batch wrote rows for. Read from the rows themselves rather
|
||||
/// than the stored mapping, which the reference import does not have and which cannot say what a
|
||||
/// partly failed or reverted batch actually left behind.
|
||||
/// </summary>
|
||||
private static async Task<Dictionary<int, BatchTargets>> LoadTargetsAsync(MeterVaultDbContext db, List<int> batchIds)
|
||||
{
|
||||
if (batchIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var readingMeters = await db.Readings.AsNoTracking()
|
||||
.Where(r => r.ImportBatchId != null && batchIds.Contains(r.ImportBatchId.Value))
|
||||
.Select(r => new { Batch = r.ImportBatchId!.Value, r.MeterId })
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var eventMeters = await db.MeterEvents.AsNoTracking()
|
||||
.Where(e => e.ImportBatchId != null && batchIds.Contains(e.ImportBatchId.Value))
|
||||
.Select(e => new { Batch = e.ImportBatchId!.Value, e.MeterId })
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var costs = await db.ManualCosts.AsNoTracking()
|
||||
.Where(c => c.ImportBatchId != null && batchIds.Contains(c.ImportBatchId.Value))
|
||||
.Select(c => new { Batch = c.ImportBatchId!.Value, c.MeterId, c.CategoryId })
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
var meterPairs = readingMeters.Select(r => (r.Batch, MeterId: (int?)r.MeterId))
|
||||
.Concat(eventMeters.Select(e => (e.Batch, MeterId: (int?)e.MeterId)))
|
||||
.Concat(costs.Select(c => (c.Batch, c.MeterId)))
|
||||
.Where(p => p.MeterId is not null)
|
||||
.Select(p => (p.Batch, MeterId: p.MeterId!.Value))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var categoryPairs = costs.Where(c => c.CategoryId is not null).Select(c => (c.Batch, CategoryId: c.CategoryId!.Value)).Distinct().ToList();
|
||||
|
||||
var meterIds = meterPairs.Select(p => p.MeterId).Distinct().ToList();
|
||||
var categoryIds = categoryPairs.Select(p => p.CategoryId).Distinct().ToList();
|
||||
var meterNames = await db.Meters.AsNoTracking().Where(m => meterIds.Contains(m.Id))
|
||||
.ToDictionaryAsync(m => m.Id, m => m.Name);
|
||||
var categoryNames = await db.CostCategories.AsNoTracking().Where(c => categoryIds.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id, c => c.Name);
|
||||
|
||||
return batchIds
|
||||
.Select(id => (Id: id, Targets: new BatchTargets(
|
||||
[.. meterPairs.Where(p => p.Batch == id && meterNames.ContainsKey(p.MeterId))
|
||||
.Select(p => new MeterTarget(p.MeterId, meterNames[p.MeterId]))
|
||||
.OrderBy(m => m.Name, StringComparer.CurrentCulture)],
|
||||
[.. categoryPairs.Where(p => p.Batch == id && categoryNames.ContainsKey(p.CategoryId))
|
||||
.Select(p => categoryNames[p.CategoryId])
|
||||
.Order(StringComparer.CurrentCulture)])))
|
||||
.Where(t => t.Targets.Meters.Count > 0 || t.Targets.Categories.Count > 0)
|
||||
.ToDictionary(t => t.Id, t => t.Targets);
|
||||
}
|
||||
|
||||
private sealed record MeterTarget(int Id, string Name);
|
||||
|
||||
private sealed record BatchTargets(IReadOnlyList<MeterTarget> Meters, IReadOnlyList<string> Categories);
|
||||
|
||||
private async Task RevertAsync(ImportBatch batch)
|
||||
{
|
||||
if (!await Confirm.ConfirmAsync(Dialogs, S.Import_RevertConfirmTitle,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
@page "/meters"
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject MeterVault.Core.Normalization.INormalizationEngine Engine
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<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="@(() => OpenEdit(null))">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => _editor!.OpenNewAsync())">
|
||||
@S.Meters_AddMeter
|
||||
</MudButton>
|
||||
</div>
|
||||
@@ -21,10 +21,19 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2">
|
||||
@* 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_Type</MudTh>
|
||||
<MudTh>@S.Common_Mode</MudTh>
|
||||
<MudTh>@S.Common_Unit</MudTh>
|
||||
<MudTh>@S.Meters_Sources</MudTh>
|
||||
@@ -32,24 +41,53 @@ else
|
||||
<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"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
|
||||
<MudTd DataLabel="@S.Common_Type">@context.EnergyType?.DisplayName</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Mode">@context.Mode.Display()</MudTd>
|
||||
<MudTd DataLabel="@S.Common_Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="@S.Meters_Sources">@context.Sources.Count</MudTd>
|
||||
<MudTd DataLabel="@S.Common_LastSeen">
|
||||
<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">@(context.IsActive ? S.Meters_Yes : S.Meters_No)</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">
|
||||
<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))" />
|
||||
@* 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)
|
||||
@@ -60,252 +98,66 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Meters_NewMeter : Loc.F(S.Meters_EditMeter, _working.Name))</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
|
||||
<MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="@S.Common_EnergyType" Class="mb-2">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.Meters_MeasurementMode" Class="mb-2">
|
||||
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||
{
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_working.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
@S.Meters_VirtualHelp
|
||||
</MudAlert>
|
||||
}
|
||||
else if (_working.Mode == MeterMode.InstantRate)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
@S.Meters_InstantRateHelpBefore <b>@S.Meters_InstantRateHelpPerHour</b> @S.Meters_InstantRateHelpAfter
|
||||
</MudAlert>
|
||||
}
|
||||
<MudTextField @bind-Value="_working.Unit" Label="@S.Common_Unit" Required="true" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="@S.Meters_InitialBaseline" Class="mb-2" />
|
||||
<MudSelect T="string" @bind-Value="_working.Role" Label="@S.Meters_PvRole" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("")">@S.Meters_RoleNone</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
|
||||
Label="@S.Meters_UpstreamLabel" Class="mb-2"
|
||||
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
|
||||
HelperText="@S.Meters_UpstreamHelp">
|
||||
@foreach (var m in AvailableUpstream())
|
||||
{
|
||||
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Location" Label="@S.Meters_Location" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.SerialNumber" Label="@S.Meters_SerialNumber" Class="mb-2" />
|
||||
<div class="d-flex" style="gap:1rem">
|
||||
<MudTextField @bind-Value="_working.Manufacturer" Label="@S.Meters_Manufacturer" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Model" Label="@S.Meters_Model" Class="mb-2" />
|
||||
</div>
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="@S.Meters_Active" Color="Color.Primary" />
|
||||
@if (_working.Id != 0 && _working.RecomputeNeeded)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Meters_RecomputeNotice</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
<MeterEditor @ref="_editor" Saved="OnSavedAsync" SwapInsteadRequested="@(id => Nav.NavigateTo(MeterLinks.Event(id, MeterEventType.MeterSwap)))" />
|
||||
|
||||
@code {
|
||||
private List<Meter>? _meters;
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
private List<MeterLink> _allLinks = [];
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
private MeterEditor? _editor;
|
||||
private string? _search;
|
||||
|
||||
private readonly TableGroupDefinition<Meter> _byEnergyType = new()
|
||||
{
|
||||
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();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||
_allLinks = await db.MeterLinks.AsNoTracking().ToListAsync();
|
||||
_meters = await db.Meters
|
||||
var meters = await db.Meters
|
||||
.AsNoTracking()
|
||||
.Include(m => m.EnergyType)
|
||||
.Include(m => m.Sources)
|
||||
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
|
||||
.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();
|
||||
}
|
||||
|
||||
// Upstream candidates: same energy type, not self, and not a descendant (would create a cycle).
|
||||
private IEnumerable<Meter> AvailableUpstream()
|
||||
private bool Matches(Meter meter)
|
||||
{
|
||||
if (_meters is null)
|
||||
if (string.IsNullOrWhiteSpace(_search))
|
||||
{
|
||||
return [];
|
||||
return true;
|
||||
}
|
||||
|
||||
var descendants = Descendants(_working.Id);
|
||||
return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id));
|
||||
var term = _search.Trim();
|
||||
return Contains(meter.Name) || Contains(meter.SerialNumber) || Contains(meter.Location)
|
||||
|| Contains(meter.EnergyType?.DisplayName) || Contains(meter.Mode.Display());
|
||||
|
||||
bool Contains(string? value) => value?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true;
|
||||
}
|
||||
|
||||
private HashSet<int> Descendants(int meterId)
|
||||
/// <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)
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
if (meterId == 0)
|
||||
if (saved.Created)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(meterId);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var link in _allLinks.Where(l => l.FromMeterId == current))
|
||||
{
|
||||
if (result.Add(link.ToMeterId))
|
||||
{
|
||||
queue.Enqueue(link.ToMeterId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string UpstreamText(IReadOnlyList<string> ids)
|
||||
{
|
||||
var names = ids.Select(idText => int.TryParse(idText, out var id) ? _meters?.FirstOrDefault(m => m.Id == id)?.Name ?? idText : idText);
|
||||
return string.Join(", ", names);
|
||||
}
|
||||
|
||||
private void OpenEdit(Meter? meter)
|
||||
{
|
||||
if (meter is null)
|
||||
{
|
||||
_working = new EditModel { EnergyTypeId = _energyTypes.FirstOrDefault()?.Id ?? 0 };
|
||||
}
|
||||
else
|
||||
{
|
||||
_working = new EditModel
|
||||
{
|
||||
Id = meter.Id,
|
||||
Name = meter.Name,
|
||||
EnergyTypeId = meter.EnergyTypeId,
|
||||
Mode = meter.Mode,
|
||||
OriginalMode = meter.Mode,
|
||||
Unit = meter.Unit,
|
||||
InitialBaseline = meter.InitialBaseline,
|
||||
OriginalBaseline = meter.InitialBaseline,
|
||||
Role = MeterMeta.Role(meter.Meta) ?? "",
|
||||
Location = meter.Location,
|
||||
SerialNumber = meter.SerialNumber,
|
||||
Manufacturer = meter.Manufacturer,
|
||||
Model = meter.Model,
|
||||
IsActive = meter.IsActive,
|
||||
Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(),
|
||||
};
|
||||
}
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
|
||||
{
|
||||
Snackbar.Add(S.Meters_RequiredFields, Severity.Warning);
|
||||
Nav.NavigateTo(MeterLinks.Detail(saved.MeterId));
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
int meterId;
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
var meter = new Meter
|
||||
{
|
||||
Name = _working.Name.Trim(),
|
||||
EnergyTypeId = _working.EnergyTypeId,
|
||||
Mode = _working.Mode,
|
||||
Unit = _working.Unit.Trim(),
|
||||
InitialBaseline = _working.InitialBaseline,
|
||||
Meta = MeterMeta.SetRole("{}", _working.Role),
|
||||
Location = Trim(_working.Location),
|
||||
SerialNumber = Trim(_working.SerialNumber),
|
||||
Manufacturer = Trim(_working.Manufacturer),
|
||||
Model = Trim(_working.Model),
|
||||
IsActive = _working.IsActive,
|
||||
};
|
||||
db.Meters.Add(meter);
|
||||
await db.SaveChangesAsync();
|
||||
meterId = meter.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
var existing = await db.Meters.FirstAsync(m => m.Id == _working.Id);
|
||||
existing.Name = _working.Name.Trim();
|
||||
existing.EnergyTypeId = _working.EnergyTypeId;
|
||||
existing.Mode = _working.Mode;
|
||||
existing.Unit = _working.Unit.Trim();
|
||||
existing.InitialBaseline = _working.InitialBaseline;
|
||||
existing.Meta = MeterMeta.SetRole(existing.Meta, _working.Role);
|
||||
existing.Location = Trim(_working.Location);
|
||||
existing.SerialNumber = Trim(_working.SerialNumber);
|
||||
existing.Manufacturer = Trim(_working.Manufacturer);
|
||||
existing.Model = Trim(_working.Model);
|
||||
existing.IsActive = _working.IsActive;
|
||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (_working.RecomputeNeeded)
|
||||
{
|
||||
var normalization = new MeterVault.Infrastructure.Normalization.NormalizationService(db, Engine);
|
||||
await normalization.RecomputeMeterAsync(existing.Id, null);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
meterId = existing.Id;
|
||||
}
|
||||
|
||||
await SyncUpstreamAsync(db, meterId, _working.Upstream);
|
||||
|
||||
_editOpen = false;
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
|
||||
private static async Task SyncUpstreamAsync(MeterVault.Infrastructure.Persistence.MeterVaultDbContext db, int meterId, IEnumerable<int> desiredUpstream)
|
||||
{
|
||||
var desired = desiredUpstream.Where(id => id != meterId).ToHashSet();
|
||||
var existing = await db.MeterLinks.Where(l => l.ToMeterId == meterId).ToListAsync();
|
||||
|
||||
foreach (var link in existing.Where(l => !desired.Contains(l.FromMeterId)))
|
||||
{
|
||||
db.MeterLinks.Remove(link);
|
||||
}
|
||||
|
||||
foreach (var fromId in desired.Where(id => existing.All(l => l.FromMeterId != id)))
|
||||
{
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = fromId, ToMeterId = meterId });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Meter meter)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
@@ -330,27 +182,4 @@ else
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public short EnergyTypeId { get; set; }
|
||||
public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter;
|
||||
public MeterMode OriginalMode { get; set; } = MeterMode.CumulativeCounter;
|
||||
public string Unit { get; set; } = "";
|
||||
public double InitialBaseline { get; set; }
|
||||
public double OriginalBaseline { get; set; }
|
||||
public string Role { get; set; } = "";
|
||||
public string? Location { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public IReadOnlyCollection<int> Upstream { get; set; } = new HashSet<int>();
|
||||
|
||||
public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ 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>
|
||||
}
|
||||
@@ -76,7 +77,7 @@ else
|
||||
@foreach (var meter in _summary.Meters)
|
||||
{
|
||||
<tr>
|
||||
<td>@meter.Name</td>
|
||||
<td><MudLink Href="@MeterLinks.Detail(meter.MeterId)">@meter.Name</MudLink></td>
|
||||
<td style="text-align:right">@Format.Number(meter.Generation, 0) kWh</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -85,8 +86,8 @@ else
|
||||
@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
|
||||
@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>
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
|
||||
@code {
|
||||
private int _months = 24;
|
||||
private bool _loading = true;
|
||||
|
||||
// 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 = [];
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
Reference in New Issue
Block a user