The last open item on the M7 list. Number and currency formatting was already locale-aware, but every string in the UI was an English literal, so a German instance read half in each language -- German data, English chrome. This translates all of it and adds the machinery to keep it translated. Strings live in Localization/Strings.resx (English, neutral) and Strings.de.resx. The neutral file generates a strongly-typed accessor at build time, aliased as S in _Imports.razor, so components reference compiled properties -- @S.Common_Save, not a string key. That choice is the point: across 4,500 lines of markup, a key lookup that silently falls back to its own name is a defect you find in production, while a renamed property is a build error. Generation runs in MSBuild rather than the IDE designer, so dotnet build alone reproduces it anywhere. Resource fallback is the hazard here. Ask for a key the German satellite lacks and ResourceManager quietly serves the English one -- correct at runtime, disastrous at release time, because a half-translated build looks perfectly healthy. StringResourceTests reads each satellite with tryParents: false, which is the only way to see what one actually contains, and fails on a missing or blank translation, a placeholder that changed arity, an orphan, or a key nothing references. Three things needed more than substitution: - Domain enums reached the screen as bare identifiers. They stay bare in the model -- they are persisted as text and appear in the REST API, so their names are part of the data contract -- and DisplayNames is now the single place that decides how each value is spoken. Every arm ends in a fallback returning the identifier, so a value added later cannot throw mid-render; EnumDisplayNameTests is what stops that safety net quietly becoming the shipping behaviour. - Infrastructure was writing display text: FlowService's "Other (X)", MeterPeriodView's "Generation"/"Consumption", the HA connection-test verdicts, the updater's snackbar, the CSV importer's row warnings. Each now returns an outcome value and the UI supplies the words, which is where the reader's language is known. Diagnostics that are not ours -- an HTTP status, systemd's stderr, an exception message -- are passed through untranslated, and every English summary is kept alongside the outcome so log lines never move with the UI language. The UpdateRunner change is additive only; no gate was touched. - Importer warnings carry their arguments rather than a finished sentence, so the numbers inside them pick up the reader's grouping. A register that reads 2.940,19 everywhere else must not read 2940.19 only inside a warning. Switching language is a redirect through /culture/set followed by a full reload, not an interactive state change: a Blazor Server circuit is fixed to the culture of the request that opened it. That makes the endpoint a redirector taking its target from the query string, so anything but a local path is refused rather than followed. Preference order is the cookie, then Accept-Language, then MeterVault__Locale -- an instance can be pinned to one language and a reader can still switch. Locale keeps its documented default of "en". Format now follows CurrentCulture instead of a hardcoded de-DE, so an instance with nothing configured and a browser asking for English will show English number formatting where it previously showed German; set MeterVault__Locale=de to pin the old behaviour. The importer's de-DE parsing is untouched and stays that way -- that dialect is a property of the spreadsheets, not of whoever is looking at the dashboard. Anything that comes from the database -- meter names, energy-type display names, category names -- is user data and is never translated. Claude-Session: https://claude.ai/code/session_0112ezeWqaZ85kTj5bYu9JHx
This commit is contained in:
@@ -5,12 +5,12 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Cost categories</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_CostCategories</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Cost categories</MudText>
|
||||
<MudText Typo="Typo.h4">@S.Nav_CostCategories</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add category
|
||||
@S.Categories_AddCategory
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@@ -22,22 +22,22 @@ else
|
||||
{
|
||||
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Sort</MudTh>
|
||||
<MudTh>Members</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
<MudTh>@S.Common_Name</MudTh>
|
||||
<MudTh>@S.Categories_Sort</MudTh>
|
||||
<MudTh>@S.Categories_Members</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">
|
||||
<MudTd DataLabel="@S.Common_Name">
|
||||
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||
{
|
||||
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||
}
|
||||
@context.Name
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Sort">@context.Sort</MudTd>
|
||||
<MudTd DataLabel="Members">@MemberSummary(context)</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudTd DataLabel="@S.Categories_Sort">@context.Sort</MudTd>
|
||||
<MudTd DataLabel="@S.Categories_Members">@MemberSummary(context)</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))" />
|
||||
</MudTd>
|
||||
@@ -47,20 +47,20 @@ else
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New category" : $"Edit {_working.Name}")</MudText>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Categories_NewCategory : Loc.F(S.Categories_EditCategory, _working.Name))</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #ff9800)" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Sort" Label="Sort order" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="@S.Categories_ColorHex" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Sort" Label="@S.Categories_SortOrder" Class="mb-2" />
|
||||
|
||||
@if (_working.Id != 0)
|
||||
{
|
||||
<MudDivider Class="my-3" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Members</MudText>
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">@S.Categories_Members</MudText>
|
||||
@if (_members.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No members yet — add a meter or an energy type.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Categories_NoMembers</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -77,30 +77,30 @@ else
|
||||
</MudList>
|
||||
}
|
||||
<div class="d-flex align-center mt-2" style="gap:.5rem; flex-wrap:wrap">
|
||||
<MudSelect T="int?" @bind-Value="_addMeterId" Label="Add meter" Dense="true" Style="min-width:180px">
|
||||
<MudSelect T="int?" @bind-Value="_addMeterId" Label="@S.Categories_AddMeter" Dense="true" Style="min-width:180px">
|
||||
@foreach (var meter in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">Add</MudButton>
|
||||
<MudSelect T="int?" @bind-Value="_addTypeId" Label="Add energy type" Dense="true" Style="min-width:180px">
|
||||
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">@S.Categories_Add</MudButton>
|
||||
<MudSelect T="int?" @bind-Value="_addTypeId" Label="@S.Categories_AddEnergyType" Dense="true" Style="min-width:180px">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">Add</MudButton>
|
||||
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">@S.Categories_Add</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Save the category first to add members.</MudAlert>
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Categories_SaveFirst</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Close</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">@S.Categories_Close</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -129,12 +129,12 @@ else
|
||||
{
|
||||
var meters = c.Members.Count(m => m.MeterId is not null);
|
||||
var types = c.Members.Count(m => m.EnergyTypeId is not null);
|
||||
return meters + types == 0 ? "—" : $"{meters} meter(s), {types} type(s)";
|
||||
return meters + types == 0 ? "—" : Loc.F(S.Categories_MemberSummary, meters, types);
|
||||
}
|
||||
|
||||
private string MemberLabel(CostCategoryMember m) =>
|
||||
m.MeterId is { } meterId ? $"Meter: {_meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}"}"
|
||||
: m.EnergyTypeId is { } typeId ? $"Type: {_energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}"}"
|
||||
m.MeterId is { } meterId ? Loc.F(S.Categories_MemberMeter, _meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}")
|
||||
: m.EnergyTypeId is { } typeId ? Loc.F(S.Categories_MemberType, _energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}")
|
||||
: "—";
|
||||
|
||||
private void OpenEdit(CostCategory? category)
|
||||
@@ -158,7 +158,7 @@ else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||
{
|
||||
Snackbar.Add("Name is required.", Severity.Warning);
|
||||
Snackbar.Add(S.Common_NameRequired, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ else
|
||||
db.CostCategories.Add(category);
|
||||
await db.SaveChangesAsync();
|
||||
// Re-open on the new category so members can be added.
|
||||
Snackbar.Add("Saved. Add members below.", Severity.Success);
|
||||
Snackbar.Add(S.Categories_SavedAddMembers, Severity.Success);
|
||||
await LoadAsync();
|
||||
OpenEdit(_categories!.First(c => c.Id == category.Id));
|
||||
return;
|
||||
@@ -181,7 +181,7 @@ else
|
||||
existing.Sort = _working.Sort;
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -235,8 +235,8 @@ else
|
||||
|
||||
private async Task DeleteAsync(CostCategory category)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete category",
|
||||
$"Delete '{category.Name}' and its {category.Members.Count} membership(s)? Manual costs in this category are kept but unlinked."))
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.Categories_DeleteTitle,
|
||||
Loc.F(S.Categories_DeleteBody, category.Name, category.Members.Count)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -244,7 +244,7 @@ else
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
// Members cascade with the category; manual_cost.category_id is SetNull.
|
||||
await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,18 +8,17 @@
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Connectors</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Connectors</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Connectors</MudText>
|
||||
<MudText Typo="Typo.h4">@S.Nav_Connectors</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add connector
|
||||
@S.Connectors_AddConnector
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||
Secrets are never stored here. Credentials/tokens are referenced by the <b>name of an environment variable</b>
|
||||
(or Docker secret) resolved at runtime.
|
||||
@S.Connectors_SecretsNoticePrefix <b>@S.Connectors_SecretsNoticeEnvVar</b> @S.Connectors_SecretsNoticeSuffix
|
||||
</MudAlert>
|
||||
|
||||
@if (_endpoints is null)
|
||||
@@ -30,20 +29,20 @@ else
|
||||
{
|
||||
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh>Enabled</MudTh>
|
||||
<MudTh>Last status</MudTh>
|
||||
<MudTh>Last seen</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
<MudTh>@S.Common_Name</MudTh>
|
||||
<MudTh>@S.Common_Type</MudTh>
|
||||
<MudTh>@S.Common_Enabled</MudTh>
|
||||
<MudTh>@S.Connectors_LastStatus</MudTh>
|
||||
<MudTh>@S.Common_LastSeen</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Type">@context.Type</MudTd>
|
||||
<MudTd DataLabel="Enabled">@(context.IsEnabled ? "yes" : "no")</MudTd>
|
||||
<MudTd DataLabel="Last status">@(context.LastStatus ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Last seen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<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_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">
|
||||
<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))" />
|
||||
</MudTd>
|
||||
@@ -51,49 +50,49 @@ else
|
||||
</MudTable>
|
||||
@if (_endpoints.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Normal" Class="mt-4">No connectors yet. Add an MQTT broker or a Home Assistant connection.</MudAlert>
|
||||
<MudAlert Severity="Severity.Normal" Class="mt-4">@S.Connectors_Empty</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}")</MudText>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Connectors_NewConnector : Loc.F(S.Connectors_EditTitle, _working.Name))</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="Type" Class="mb-2">
|
||||
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="@S.Common_Type" Class="mb-2">
|
||||
@foreach (var type in Enum.GetValues<EndpointType>())
|
||||
{
|
||||
<MudSelectItem T="EndpointType" Value="type">@type</MudSelectItem>
|
||||
<MudSelectItem T="EndpointType" Value="type">@type.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
|
||||
|
||||
@if (_working.Type == EndpointType.HomeAssistant)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.BaseUrl" Label="Base URL (e.g. http://homeassistant.local:8123)" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="Enter the token here" Color="Color.Primary" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.BaseUrl" Label="@S.Connectors_BaseUrl" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="@S.Connectors_EnterTokenHere" Color="Color.Primary" Class="mb-1" />
|
||||
@if (_working.UseDirectToken)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Token" InputType="InputType.Password" Class="mb-1"
|
||||
Label="@(_working.HasStoredToken ? "Long-lived access token (stored — type to replace)" : "Long-lived access token")" />
|
||||
Label="@(_working.HasStoredToken ? S.Connectors_TokenStored : S.Connectors_Token)" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
Encrypted before it is stored; database dumps and JSON exports carry nothing usable.
|
||||
@S.Connectors_EncryptedHint
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.TokenEnv" Label="@S.Connectors_TokenEnv" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
The variable's <em>name</em>, not the token. Set it on the server and restart the app.
|
||||
@S.Connectors_TokenEnvHintPrefix <em>@S.Connectors_TokenEnvHintEmphasis</em>@S.Connectors_TokenEnvHintSuffix
|
||||
</MudText>
|
||||
}
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="Real-time WebSocket push" Color="Color.Primary" Class="mb-1" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="@S.Connectors_WebSocketPush" Color="Color.Primary" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
On: subscribe to state changes and ingest in real time. Off: REST poll on each source's interval.
|
||||
@S.Connectors_WebSocketHint
|
||||
</MudText>
|
||||
<MudTextField @bind-Value="_working.TestEntityId" Label="Test entity id (optional, e.g. sensor.house_power)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.TestEntityId" Label="@S.Connectors_TestEntityId" Class="mb-2" />
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
|
||||
Test connection
|
||||
@S.Connectors_TestConnection
|
||||
</MudButton>
|
||||
@if (_testing)
|
||||
{
|
||||
@@ -101,36 +100,36 @@ else
|
||||
}
|
||||
@if (_testResult is not null)
|
||||
{
|
||||
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@_testResult.Message</MudAlert>
|
||||
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@TestText(_testResult)</MudAlert>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Host" Label="Host" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Port" Label="Port" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="TLS" Color="Color.Primary" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="Enter credentials here" Color="Color.Primary" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.Host" Label="@S.Connectors_Host" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Port" Label="@S.Connectors_Port" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="@S.Connectors_Tls" Color="Color.Primary" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="@S.Connectors_EnterCredentialsHere" Color="Color.Primary" Class="mb-1" />
|
||||
@if (_working.UseDirectCredentials)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Username" Label="Username (optional)" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.Username" Label="@S.Connectors_UsernameOptional" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.Password" InputType="InputType.Password" Class="mb-1"
|
||||
Label="@(_working.HasStoredPassword ? "Password (stored — type to replace)" : "Password (optional)")" />
|
||||
Label="@(_working.HasStoredPassword ? S.Connectors_PasswordStored : S.Connectors_PasswordOptional)" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
Encrypted before it is stored; database dumps and JSON exports carry nothing usable.
|
||||
@S.Connectors_EncryptedHint
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.UsernameEnv" Label="@S.Connectors_UsernameEnv" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.PasswordEnv" Label="@S.Connectors_PasswordEnv" Class="mb-2" />
|
||||
}
|
||||
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ExtraTopics" Label="@S.Connectors_ExtraTopics" Class="mb-2" />
|
||||
}
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -210,9 +209,7 @@ else
|
||||
// "Test connection" into an exfiltration primitive — point it at any host and the
|
||||
// token arrives as a Bearer header. A stored secret only ever goes to the origin
|
||||
// it was saved for; testing elsewhere means typing the token again.
|
||||
_testResult = new HaTestResult(false,
|
||||
"Base URL differs from the saved one. Re-enter the token to test against a different host — "
|
||||
+ "a stored token is only sent to the host it was saved for.");
|
||||
_testResult = new HaTestResult(false, S.Connectors_TestBaseUrlChanged);
|
||||
}
|
||||
else if (Secrets.TryUnprotect(_working.TokenEnc, out var stored) && stored is { Length: > 0 })
|
||||
{
|
||||
@@ -220,19 +217,17 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
_testResult = new HaTestResult(false, "Enter a token first.");
|
||||
_testResult = new HaTestResult(false, S.Connectors_TestNoToken);
|
||||
}
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(_working.TokenEnv))
|
||||
{
|
||||
_testResult = new HaTestResult(false,
|
||||
"Name the environment variable holding the token, or switch on \"Enter the token here\".");
|
||||
_testResult = new HaTestResult(false, S.Connectors_TestNoTokenEnv);
|
||||
}
|
||||
else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken)
|
||||
{
|
||||
_testResult = new HaTestResult(false,
|
||||
$"Environment variable '{_working.TokenEnv}' is not set on the server. Set it and restart the app, "
|
||||
+ "or switch on \"Enter the token here\" to store the token directly.");
|
||||
Loc.F(S.Connectors_TestEnvVarMissing, _working.TokenEnv));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -249,7 +244,7 @@ else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||
{
|
||||
Snackbar.Add("Name is required.", Severity.Warning);
|
||||
Snackbar.Add(S.Common_NameRequired, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -258,7 +253,7 @@ else
|
||||
&& string.IsNullOrWhiteSpace(_working.Token)
|
||||
&& !_working.HasStoredToken)
|
||||
{
|
||||
Snackbar.Add("Enter the token, or switch off \"Enter the token here\" and name an env var.", Severity.Warning);
|
||||
Snackbar.Add(S.Connectors_TokenRequired, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -305,7 +300,7 @@ else
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -316,15 +311,16 @@ else
|
||||
// Routing is endpoint-scoped, so "unlinked" now means those sources stop ingesting entirely
|
||||
// rather than falling back to any broker. Say so plainly.
|
||||
var note = sourceCount > 0
|
||||
? $" {sourceCount} source(s) use it and will stop ingesting until reassigned to another connector."
|
||||
? " " + Loc.F(S.Connectors_DeleteInUseNote, sourceCount)
|
||||
: "";
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}"))
|
||||
if (!await Confirm.DeleteAsync(
|
||||
DialogService, S.Connectors_DeleteTitle, Loc.F(S.Connectors_DeleteBody, endpoint.Name) + note))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -398,4 +394,23 @@ else
|
||||
|
||||
public bool HasStoredPassword => !string.IsNullOrWhiteSpace(PasswordEnc);
|
||||
}
|
||||
|
||||
// HaTestResult.Message stays English for the log; the reader gets the verdict in their own
|
||||
// language. Anything the connector page decided for itself (Precondition) already carries its
|
||||
// own localized wording, and HA's own diagnostics — an HTTP status, an exception — are passed
|
||||
// through untranslated because they are not ours to reword.
|
||||
private static string TestText(HaTestResult result) => result.Outcome switch
|
||||
{
|
||||
HaTestOutcome.Connected => S.Connectors_TestConnected,
|
||||
HaTestOutcome.ConnectedWithValue => Loc.F(
|
||||
S.Connectors_TestConnectedValue,
|
||||
result.EntityId ?? string.Empty,
|
||||
result.SampleValue is { } value ? Format.Number(value, 2) : string.Empty),
|
||||
HaTestOutcome.BaseUrlMissing => S.Connectors_TestBaseUrlRequired,
|
||||
HaTestOutcome.TokenMissing => S.Connectors_TestTokenMissing,
|
||||
HaTestOutcome.HttpError => Loc.F(S.Connectors_TestHttpError, result.Detail ?? string.Empty),
|
||||
HaTestOutcome.NoNumericState => Loc.F(S.Connectors_TestNoNumericState, result.EntityId ?? string.Empty),
|
||||
HaTestOutcome.RequestFailed => Loc.F(S.Connectors_TestFailed, result.Detail ?? string.Empty),
|
||||
_ => result.Message,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Energy types</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_EnergyTypes</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Energy types</MudText>
|
||||
<MudText Typo="Typo.h4">@S.Nav_EnergyTypes</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add energy type
|
||||
@S.EnergyTypes_Add
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@@ -22,24 +22,24 @@ else
|
||||
{
|
||||
<MudTable Items="_types" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Key</MudTh>
|
||||
<MudTh>Display name</MudTh>
|
||||
<MudTh>Base unit</MudTh>
|
||||
<MudTh>Default mode</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
<MudTh>@S.EnergyTypes_Key</MudTh>
|
||||
<MudTh>@S.EnergyTypes_DisplayName</MudTh>
|
||||
<MudTh>@S.EnergyTypes_BaseUnit</MudTh>
|
||||
<MudTh>@S.EnergyTypes_DefaultMode</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Key">@context.Key</MudTd>
|
||||
<MudTd DataLabel="Display name">
|
||||
<MudTd DataLabel="@S.EnergyTypes_Key">@context.Key</MudTd>
|
||||
<MudTd DataLabel="@S.EnergyTypes_DisplayName">
|
||||
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||
{
|
||||
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||
}
|
||||
@context.DisplayName
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
|
||||
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudTd DataLabel="@S.EnergyTypes_BaseUnit">@context.BaseUnit</MudTd>
|
||||
<MudTd DataLabel="@S.EnergyTypes_DefaultMode">@context.DefaultMode.Display()</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))" />
|
||||
</MudTd>
|
||||
@@ -49,24 +49,24 @@ else
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New energy type" : $"Edit {_working.DisplayName}")</MudText>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.EnergyTypes_NewTitle : Loc.F(S.EnergyTypes_EditTitle, _working.DisplayName))</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Key" Label="Key (stable machine key, e.g. electricity)" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.DisplayName" Label="Display name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.BaseUnit" Label="Base unit (kWh, m3, L, h)" Required="true" Class="mb-2" />
|
||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Default mode" Class="mb-2">
|
||||
<MudTextField @bind-Value="_working.Key" Label="@S.EnergyTypes_KeyLabel" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.DisplayName" Label="@S.EnergyTypes_DisplayName" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.BaseUnit" Label="@S.EnergyTypes_BaseUnitLabel" Required="true" Class="mb-2" />
|
||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.EnergyTypes_DefaultMode" Class="mb-2">
|
||||
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||
{
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Icon" Label="Icon (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #4caf50)" />
|
||||
<MudTextField @bind-Value="_working.Icon" Label="@S.EnergyTypes_IconLabel" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="@S.EnergyTypes_ColorLabel" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -105,14 +105,14 @@ else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit))
|
||||
{
|
||||
Snackbar.Add("Key, display name and base unit are required.", Severity.Warning);
|
||||
Snackbar.Add(S.EnergyTypes_RequiredFields, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id))
|
||||
{
|
||||
Snackbar.Add($"Key '{_working.Key}' is already in use.", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.EnergyTypes_KeyInUse, _working.Key), Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ else
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -151,11 +151,11 @@ else
|
||||
var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id);
|
||||
if (meterCount > 0)
|
||||
{
|
||||
Snackbar.Add($"Cannot delete '{type.DisplayName}': {meterCount} meter(s) still use it.", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.EnergyTypes_DeleteBlocked, type.DisplayName, meterCount), Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete energy type", $"Delete '{type.DisplayName}'? This cannot be undone."))
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.EnergyTypes_DeleteTitle, Loc.F(S.EnergyTypes_DeleteBody, type.DisplayName)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -165,7 +165,7 @@ else
|
||||
{
|
||||
db.EnergyTypes.Remove(target);
|
||||
await db.SaveChangesAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
|
||||
@@ -2,29 +2,28 @@
|
||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Settings</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Settings</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-2">Settings</MudText>
|
||||
<MudText Typo="Typo.h4" Class="mb-2">@S.Nav_Settings</MudText>
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||
These are the <b>effective</b> settings the running instance is using. They are configured via environment
|
||||
variables (<code>MeterVault__Key</code> / <code>Section__Key</code>) or Docker/compose, not stored in the
|
||||
database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart.
|
||||
@S.Settings_EffectiveLead <b>@S.Settings_EffectiveEmphasis</b> @S.Settings_EffectiveRest
|
||||
(<code>MeterVault__Key</code> / <code>Section__Key</code>) @S.Settings_EffectiveTail
|
||||
</MudAlert>
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Locale & time</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_LocaleAndTime</MudText>
|
||||
<MudSimpleTable Dense="true">
|
||||
<tbody>
|
||||
<tr><td>Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr>
|
||||
<tr><td>Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
|
||||
<tr><td>Currency</td><td style="text-align:right"><code>@_o.Currency</code></td></tr>
|
||||
<tr><td>Raw-reading retention</td><td style="text-align:right">@_o.RawRetentionDays days</td></tr>
|
||||
<tr><td>@S.Settings_Timezone</td><td style="text-align:right"><code>@_o.TimeZone</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>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
Env keys: <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
|
||||
@S.Settings_EnvKeysLabel <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
|
||||
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
@@ -32,7 +31,7 @@
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Access & ingestion</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_AccessAndIngestion</MudText>
|
||||
<MudSimpleTable Dense="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -40,26 +39,26 @@
|
||||
<td style="text-align:right">
|
||||
@if (_o.ApiKeys.Count > 0)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_o.ApiKeys.Count key(s) configured</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">@Loc.F(S.Settings_ApiKeysConfigured, _o.ApiKeys.Count)</MudChip>
|
||||
}
|
||||
else if (_o.AllowAnonymousApi)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">open (anonymous)</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@S.Settings_ApiOpen</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">closed (401)</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">@S.Settings_ApiClosed</MudChip>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Reverse-proxy trust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? "on" : "off")</td></tr>
|
||||
<tr><td>Live ingestion workers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? "on" : "off")</td></tr>
|
||||
<tr><td>Seed reference data on start</td><td style="text-align:right">@(_o.SeedReferenceData ? "on" : "off")</td></tr>
|
||||
<tr><td>@S.Settings_ReverseProxyTrust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||
<tr><td>@S.Settings_LiveIngestionWorkers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||
<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">
|
||||
Set API keys with <code>MeterVault__ApiKeys__0</code>. Keys themselves are never shown here.
|
||||
API docs at <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
|
||||
@S.Settings_ApiKeysHintBefore <code>MeterVault__ApiKeys__0</code>. @S.Settings_ApiKeysHintAfter
|
||||
@S.Settings_ApiDocsLabel <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Tariffs</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Tariffs</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Tariffs</MudText>
|
||||
<MudText Typo="Typo.h4">@S.Nav_Tariffs</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add tariff
|
||||
@S.Tariffs_AddTariff
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@@ -22,22 +22,22 @@ else
|
||||
{
|
||||
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Scope</MudTh>
|
||||
<MudTh>Component</MudTh>
|
||||
<MudTh>Value</MudTh>
|
||||
<MudTh>Unit</MudTh>
|
||||
<MudTh>Valid from</MudTh>
|
||||
<MudTh>Valid to</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
<MudTh>@S.Common_Scope</MudTh>
|
||||
<MudTh>@S.Tariffs_Component</MudTh>
|
||||
<MudTh>@S.Common_Value</MudTh>
|
||||
<MudTh>@S.Common_Unit</MudTh>
|
||||
<MudTh>@S.Tariffs_ValidFrom</MudTh>
|
||||
<MudTh>@S.Tariffs_ValidTo</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Scope">@ScopeLabel(context)</MudTd>
|
||||
<MudTd DataLabel="Component">@context.Component</MudTd>
|
||||
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
|
||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
||||
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudTd DataLabel="@S.Common_Scope">@ScopeLabel(context)</MudTd>
|
||||
<MudTd DataLabel="@S.Tariffs_Component">@context.Component.Display()</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_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))" />
|
||||
</MudTd>
|
||||
@@ -45,24 +45,24 @@ else
|
||||
</MudTable>
|
||||
@if (_tariffs.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">No tariffs yet. Add one, or load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">@S.Tariffs_EmptyState <MudLink Href="/import">@S.Nav_Import</MudLink>.</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New tariff" : "Edit tariff")</MudText>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Tariffs_NewTariff : S.Tariffs_EditTariff)</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="Scope" Class="mb-2">
|
||||
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="@S.Common_Scope" Class="mb-2">
|
||||
@foreach (var scope in Enum.GetValues<TariffScope>())
|
||||
{
|
||||
<MudSelectItem T="TariffScope" Value="scope">@scope</MudSelectItem>
|
||||
<MudSelectItem T="TariffScope" Value="scope">@scope.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_working.ScopeType == TariffScope.EnergyType)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Energy type" Class="mb-2">
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="@S.Common_EnergyType" Class="mb-2">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||
@@ -71,29 +71,29 @@ else
|
||||
}
|
||||
else if (_working.ScopeType == TariffScope.Meter)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Meter" Class="mb-2">
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" 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="Component" Class="mb-2">
|
||||
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="@S.Tariffs_Component" Class="mb-2">
|
||||
@foreach (var component in Enum.GetValues<TariffComponent>())
|
||||
{
|
||||
<MudSelectItem T="TariffComponent" Value="component">@component</MudSelectItem>
|
||||
<MudSelectItem T="TariffComponent" Value="component">@component.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudNumericField T="double" @bind-Value="_working.Value" Label="Value" Format="0.####" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Unit" Label="Unit (e.g. EUR/kWh, EUR/m3, EUR/month)" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Currency" Label="Currency" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidFrom" Label="Valid from" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidTo" Label="Valid to (empty = open-ended)" Clearable="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Notes" Label="Notes (optional)" />
|
||||
<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" />
|
||||
<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" />
|
||||
<MudTextField @bind-Value="_working.Notes" Label="@S.Tariffs_NotesLabel" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -117,9 +117,9 @@ else
|
||||
|
||||
private string ScopeLabel(Tariff t) => t.ScopeType switch
|
||||
{
|
||||
TariffScope.Global => "Global",
|
||||
TariffScope.EnergyType => $"Type: {_energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"}",
|
||||
TariffScope.Meter => $"Meter: {_meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"}",
|
||||
TariffScope.Global => S.Tariffs_ScopeGlobal,
|
||||
TariffScope.EnergyType => Loc.F(S.Tariffs_ScopeType, _energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"),
|
||||
TariffScope.Meter => Loc.F(S.Tariffs_ScopeMeter, _meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"),
|
||||
_ => t.ScopeType.ToString(),
|
||||
};
|
||||
|
||||
@@ -147,13 +147,13 @@ else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null)
|
||||
{
|
||||
Snackbar.Add("Unit and valid-from are required.", Severity.Warning);
|
||||
Snackbar.Add(S.Tariffs_UnitAndValidFromRequired, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null)
|
||||
{
|
||||
Snackbar.Add("Select the energy type or meter this tariff applies to.", Severity.Warning);
|
||||
Snackbar.Add(S.Tariffs_ScopeTargetRequired, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,13 +191,14 @@ else
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Tariff tariff)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete tariff", $"Delete this {tariff.Component} tariff ({Format.Number(tariff.Value, 4)} {tariff.Unit})?"))
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.Tariffs_DeleteTitle,
|
||||
Loc.F(S.Tariffs_DeleteBody, tariff.Component.Display(), Format.Number(tariff.Value, 4), tariff.Unit)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -208,7 +209,7 @@ else
|
||||
{
|
||||
db.Tariffs.Remove(target);
|
||||
await db.SaveChangesAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
@inject ConsumableService ConsumablesSvc
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Consumables</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Consumables_PageTitle</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Oil / consumables</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
<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>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
else if (_items.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No consumable meters found. Add a meter with mode <b>ConsumableBalance</b> and a tank, or load the reference
|
||||
data from <MudLink Href="/import">Import</MudLink>.
|
||||
@S.Consumables_NoMetersLead <b>@MeterMode.ConsumableBalance.Display()</b> @S.Consumables_NoMetersTail
|
||||
<MudLink Href="/import">@S.Nav_Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
@@ -33,20 +33,20 @@ else
|
||||
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Tank level</MudText>
|
||||
<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">
|
||||
@Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit
|
||||
@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> · as of @asOf.ToString("yyyy-MM-dd")</text>
|
||||
<text> · @Loc.F(S.Consumables_AsOf, asOf.ToString("yyyy-MM-dd"))</text>
|
||||
}
|
||||
</MudText>
|
||||
</MudItem>
|
||||
@@ -54,15 +54,15 @@ else
|
||||
<MudItem xs="12" md="8">
|
||||
<MudGrid>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</MudText>
|
||||
<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">Burner runtime</MudText>
|
||||
<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">Effective rate</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_EffectiveRate</MudText>
|
||||
<MudText Typo="Typo.subtitle1">
|
||||
@if (item.FixedRate is { } fr)
|
||||
{
|
||||
@@ -77,20 +77,20 @@ else
|
||||
<text>—</text>
|
||||
}
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</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">Cost (range)</MudText>
|
||||
<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">Forecast to empty</MudText>
|
||||
<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">
|
||||
(@Format.Number(apd, 1) @item.Unit/day)
|
||||
@Loc.F(S.Consumables_PerDay, Format.Number(apd, 1), item.Unit)
|
||||
</MudText>
|
||||
}
|
||||
</MudText>
|
||||
@@ -99,22 +99,22 @@ else
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</MudText>
|
||||
<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">Deliveries (@item.Deliveries.Count)</MudText>
|
||||
<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">No deliveries recorded.</MudText>
|
||||
<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>Date</th><th style="text-align:right">Amount</th></tr>
|
||||
<tr><th>@S.Common_Date</th><th style="text-align:right">@S.Common_Amount</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var delivery in item.Deliveries)
|
||||
@@ -171,9 +171,9 @@ else
|
||||
private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item)
|
||||
{
|
||||
var points = item.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Consumption))
|
||||
.Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Consumption))
|
||||
.ToList();
|
||||
return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)];
|
||||
return [new SeriesChart.SeriesDef(Loc.F(S.Consumables_UnitUsed, item.Unit), ApexCharts.SeriesType.Bar, points)];
|
||||
}
|
||||
|
||||
private static Color FillColor(double fraction) => fraction switch
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@page "/"
|
||||
@inject DashboardService Dash
|
||||
|
||||
<PageTitle>MeterVault — Overview</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Overview</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Overview</MudText>
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Overview</MudText>
|
||||
|
||||
<UpdateBanner />
|
||||
|
||||
@@ -16,28 +16,28 @@ else
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This month</MudText>
|
||||
<MudText Typo="Typo.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">This year</MudText>
|
||||
<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">Latest month with data</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Dashboard_LatestMonthWithData</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">What costs most (this year)</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatCostsMost</MudText>
|
||||
@if (_breakdown is { Count: > 0 })
|
||||
{
|
||||
<CategoryDonut Slices="_breakdown" />
|
||||
@@ -55,17 +55,17 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No cost data yet — import a sheet or add tariffs.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Dashboard_NoCostData</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">What cost more / less (year vs last year)</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatChanged</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr><th>Category</th><th style="text-align:right">Now</th><th style="text-align:right">Prev</th><th style="text-align:right">Δ</th></tr>
|
||||
<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)
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle>
|
||||
<PageTitle>MeterVault — @(_graph?.EnergyType ?? S.EnergyView_EnergyFallback)</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">@(_graph?.EnergyType ?? "Energy") flow</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
<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>
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
else if (!_graph.HasData)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the
|
||||
reference data from <MudLink Href="/import">Import</MudLink>.
|
||||
@S.EnergyView_NoMetersIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.EnergyView_NoMetersOr
|
||||
<MudLink Href="/import">@S.Nav_Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
@@ -33,48 +33,48 @@ else
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level throughput</MudText>
|
||||
<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">Cost (range)</MudText>
|
||||
<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">Meters</MudText>
|
||||
<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">Flow</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-1">@S.EnergyView_Flow</MudText>
|
||||
@if (_graph.HasChain)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
|
||||
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder.
|
||||
@S.EnergyView_FlowCaption
|
||||
</MudText>
|
||||
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||
No meter chain configured yet. In <MudLink Href="/meters">Meters</MudLink> → edit a sub-meter and set its
|
||||
<b>upstream meter(s)</b> to show where the main meter's flow divides (e.g. main → car, pool, other).
|
||||
@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>Meter</th><th style="text-align:right">Consumption</th></tr></thead>
|
||||
<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.Label</td>
|
||||
<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>
|
||||
}
|
||||
@@ -85,15 +85,15 @@ else
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Common_Meters</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Name</th><th>Mode</th><th>Upstream of</th><th style="text-align:right">Consumption</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></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var meter in _meters)
|
||||
{
|
||||
<tr>
|
||||
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
|
||||
<td>@meter.Mode</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>
|
||||
</tr>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
<PageTitle>@S.Error_PageTitle</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
<h1 class="text-danger">@S.Error_Heading</h1>
|
||||
<h2 class="text-danger">@S.Error_Message</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
<strong>@S.Error_RequestId</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<h3>@S.Error_DevelopmentMode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
@* "Development" and ASPNETCORE_ENVIRONMENT are literal environment names — emphasised, never translated. *@
|
||||
@((MarkupString)Loc.F(S.Error_DevelopmentSwap, "<strong>Development</strong>"))
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
<strong>@S.Error_DevelopmentWarning</strong>
|
||||
@S.Error_DevelopmentWarningDetail
|
||||
@((MarkupString)Loc.F(S.Error_DevelopmentEnableHint, "<strong>Development</strong>", "<strong>ASPNETCORE_ENVIRONMENT</strong>"))
|
||||
</p>
|
||||
|
||||
@code{
|
||||
|
||||
@@ -9,21 +9,20 @@
|
||||
@using MeterVault.Infrastructure.Persistence
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>MeterVault — Import</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Import</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Import</MudText>
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Import</MudText>
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6">Reference dataset</MudText>
|
||||
<MudText Typo="Typo.h6">@S.Import_ReferenceDataset</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
||||
Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a
|
||||
starter dataset with meters, tariffs and categories.
|
||||
@S.Import_ReferenceDatasetHelp
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload"
|
||||
OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded">
|
||||
@(_referenceLoaded ? "Loaded" : "Load reference data")
|
||||
@(_referenceLoaded ? S.Import_ReferenceLoaded : S.Import_LoadReferenceData)
|
||||
</MudButton>
|
||||
@if (_loadingReference)
|
||||
{
|
||||
@@ -35,22 +34,21 @@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<MudText Typo="Typo.h6">Your own CSV</MudText>
|
||||
<MudText Typo="Typo.h6">@S.Import_YourOwnCsv</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Href="/import/wizard"
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh">Mapping wizard</MudButton>
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh">@S.Import_MappingWizard</MudButton>
|
||||
</div>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3 mt-1">
|
||||
Map an arbitrary sheet's columns to your meters/categories, preview and commit it as a
|
||||
revertible import. Or dry-run against one of the built-in reference profiles below.
|
||||
@S.Import_YourOwnCsvHelp
|
||||
</MudText>
|
||||
<MudSelect T="string" @bind-Value="_profileName" Label="Reference profile" Dense="true" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem>
|
||||
<MudSelect T="string" @bind-Value="_profileName" Label="@S.Import_ReferenceProfile" Dense="true" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("Strom")">@S.Import_ProfileElectricity</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Wasser")">@S.Import_ProfileWater</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Heizöl")">@S.Import_ProfileHeatingOil</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Kosten")">@S.Import_ProfileCosts</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
|
||||
Dry-run a reference sheet
|
||||
@S.Import_DryRunReferenceSheet
|
||||
</MudButton>
|
||||
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
|
||||
</MudPaper>
|
||||
@@ -60,20 +58,20 @@
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Preview</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Common_Preview</MudText>
|
||||
<div class="d-flex" style="gap:2rem; flex-wrap:wrap">
|
||||
<MudText>Readings: <b>@_preview.Readings.Count</b></MudText>
|
||||
<MudText>Events: <b>@_preview.Events.Count</b></MudText>
|
||||
<MudText>Manual costs: <b>@_preview.ManualCosts.Count</b></MudText>
|
||||
<MudText>Skipped rows: <b>@_preview.SkippedRows</b></MudText>
|
||||
<MudText>@S.Common_ReadingsLabel <b>@_preview.Readings.Count</b></MudText>
|
||||
<MudText>@S.Common_EventsLabel <b>@_preview.Events.Count</b></MudText>
|
||||
<MudText>@S.Common_ManualCostsLabel <b>@_preview.ManualCosts.Count</b></MudText>
|
||||
<MudText>@S.Common_SkippedRowsLabel <b>@_preview.SkippedRows</b></MudText>
|
||||
</div>
|
||||
@if (_preview.Warnings.Count > 0)
|
||||
{
|
||||
<MudExpansionPanels Class="mt-3">
|
||||
<MudExpansionPanel Text="@($"{_preview.Warnings.Count} warnings")">
|
||||
<MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _preview.Warnings.Count)">
|
||||
@foreach (var warning in _preview.Warnings.Take(50))
|
||||
{
|
||||
<MudText Typo="Typo.body2">@warning</MudText>
|
||||
<MudText Typo="Typo.body2">@warning.Display()</MudText>
|
||||
}
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
@@ -84,15 +82,15 @@
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Recent imports</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Import_RecentImports</MudText>
|
||||
@if (_batches.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No imports yet.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Import_NoImportsYet</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>#</th><th>Source</th><th style="text-align:right">Rows</th><th>Imported</th><th>Status</th><th></th></tr></thead>
|
||||
<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>
|
||||
<tbody>
|
||||
@foreach (var batch in _batches)
|
||||
{
|
||||
@@ -104,18 +102,18 @@
|
||||
<td>
|
||||
@if (batch.RevertedAt is not null)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">reverted</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">@S.Import_StatusReverted</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">active</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">@S.Import_StatusActive</MudChip>
|
||||
}
|
||||
</td>
|
||||
<td style="text-align:right">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Undo"
|
||||
Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)"
|
||||
OnClick="@(() => RevertAsync(batch))">Revert</MudButton>
|
||||
OnClick="@(() => RevertAsync(batch))">@S.Import_Revert</MudButton>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -151,9 +149,9 @@
|
||||
|
||||
private async Task RevertAsync(ImportBatch batch)
|
||||
{
|
||||
if (!await Confirm.ConfirmAsync(Dialogs, "Revert import?",
|
||||
$"Delete all {batch.RowCount} rows from import #{batch.Id} ({batch.SourceName ?? "unnamed"}) and recompute the affected meters?",
|
||||
"Revert"))
|
||||
if (!await Confirm.ConfirmAsync(Dialogs, S.Import_RevertConfirmTitle,
|
||||
Loc.F(S.Import_RevertConfirmBody, batch.RowCount, batch.Id, batch.SourceName ?? S.Import_UnnamedSource),
|
||||
S.Import_Revert))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -162,12 +160,12 @@
|
||||
try
|
||||
{
|
||||
await ImportService.RevertAsync(batch.Id);
|
||||
Snackbar.Add($"Import #{batch.Id} reverted.", Severity.Success);
|
||||
Snackbar.Add(Loc.F(S.Import_BatchReverted, batch.Id), Severity.Success);
|
||||
await LoadBatchesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Revert failed: {ex.Message}", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.Import_RevertFailed, ex.Message), Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -183,12 +181,12 @@
|
||||
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
|
||||
await ReferenceImporter.LoadAsync(dir);
|
||||
_referenceLoaded = true;
|
||||
Snackbar.Add("Reference data loaded.", Severity.Success);
|
||||
Snackbar.Add(S.Import_ReferenceDataLoaded, Severity.Success);
|
||||
await LoadBatchesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Import failed: {ex.Message}", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.Import_Failed, ex.Message), Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -10,29 +10,28 @@
|
||||
@using MeterVault.Infrastructure.Persistence
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>MeterVault — Import wizard</PageTitle>
|
||||
<PageTitle>MeterVault — @S.ImportWizard_Title</PageTitle>
|
||||
|
||||
<div class="d-flex align-center mb-4" style="gap:1rem">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="Back to Import" />
|
||||
<MudText Typo="Typo.h4">Import wizard</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="@S.ImportWizard_BackToImport" />
|
||||
<MudText Typo="Typo.h4">@S.ImportWizard_Title</MudText>
|
||||
</div>
|
||||
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
|
||||
Upload any CSV, map its columns to your meters and categories, preview what would be staged, then
|
||||
commit it as a revertible import. Values may use the German dialect (decimal comma, unit suffixes,
|
||||
<code>Monat JJJJ</code> or <code>TT.MM.JJJJ</code> dates) — the same parser the reference sheets use.
|
||||
@S.ImportWizard_IntroLead
|
||||
<code>Monat JJJJ</code> @S.ImportWizard_IntroOr <code>TT.MM.JJJJ</code> @S.ImportWizard_IntroTail
|
||||
</MudText>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<div class="d-flex align-center" style="gap:1rem; flex-wrap:wrap">
|
||||
<MudButton HtmlTag="label" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.UploadFile" for="wizardUpload">
|
||||
Choose CSV
|
||||
@S.ImportWizard_ChooseCsv
|
||||
</MudButton>
|
||||
<InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" />
|
||||
@if (_fileName is not null)
|
||||
{
|
||||
<MudText><b>@_fileName</b> — @_rows.Count rows, @_colCount columns</MudText>
|
||||
<MudText><b>@_fileName</b> — @Loc.F(S.ImportWizard_FileSummary, _rows.Count, _colCount)</MudText>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
@@ -40,10 +39,10 @@
|
||||
@if (_colCount > 0)
|
||||
{
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">1. Parsing options</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step1Title</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudSelect T="int" @bind-Value="_dateColumn" Label="Date column" Dense="true">
|
||||
<MudSelect T="int" @bind-Value="_dateColumn" Label="@S.ImportWizard_DateColumn" Dense="true">
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<MudSelectItem T="int" Value="i">@ColLabel(i)</MudSelectItem>
|
||||
@@ -51,27 +50,27 @@
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="Date format" Dense="true">
|
||||
<MudSelectItem T="DateKind" Value="DateKind.Auto">Auto-detect</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.MonthName">Month name (Januar 2024)</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">Day (31.12.2024)</MudSelectItem>
|
||||
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="@S.ImportWizard_DateFormat" Dense="true">
|
||||
<MudSelectItem T="DateKind" Value="DateKind.Auto">@S.ImportWizard_DateAutoDetect</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.MonthName">@S.ImportWizard_DateMonthName</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">@S.ImportWizard_DateDay</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="6" md="2">
|
||||
<MudNumericField T="int" @bind-Value="_headerRow" Label="Header row" Min="0" Margin="Margin.Dense" />
|
||||
<MudNumericField T="int" @bind-Value="_headerRow" Label="@S.ImportWizard_HeaderRow" Min="0" Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="6" md="2">
|
||||
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="First data row" Min="0" Margin="Margin.Dense" />
|
||||
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="@S.ImportWizard_FirstDataRow" Min="0" Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="2" Class="d-flex flex-column">
|
||||
<MudSwitch T="bool" @bind-Value="_skipAllZero" Label="Skip zero rows" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="Detect swaps" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_skipAllZero" Label="@S.ImportWizard_SkipZeroRows" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="@S.ImportWizard_DetectSwaps" Color="Color.Primary" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">2. Column preview</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step2Title</MudText>
|
||||
<div style="overflow-x:auto">
|
||||
<MudSimpleTable Dense="true" Bordered="true" Style="min-width:100%">
|
||||
<thead>
|
||||
@@ -79,7 +78,7 @@
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<th style="@(i == _dateColumn ? "background:var(--mud-palette-primary-hover)" : "")">
|
||||
Col @i@(i == _dateColumn ? " 📅" : "")
|
||||
@Loc.F(S.ImportWizard_ColumnN, i)@(i == _dateColumn ? " 📅" : "")
|
||||
</th>
|
||||
}
|
||||
</tr>
|
||||
@@ -98,23 +97,23 @@
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Faded rows are before the first data row. The 📅 column supplies the date.
|
||||
@S.ImportWizard_PreviewCaption
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">3. Map columns</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step3Title</MudText>
|
||||
<div style="overflow-x:auto">
|
||||
<MudSimpleTable Dense="true" Style="min-width:100%">
|
||||
<thead>
|
||||
<tr><th>Column</th><th>Sample</th><th style="min-width:160px">Role</th><th style="min-width:220px">Target</th><th style="min-width:120px">Unit</th></tr>
|
||||
<tr><th>@S.ImportWizard_HeaderColumn</th><th>@S.ImportWizard_HeaderSample</th><th style="min-width:160px">@S.ImportWizard_HeaderRole</th><th style="min-width:220px">@S.Common_Target</th><th style="min-width:120px">@S.Common_Unit</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<b>Col @i</b>
|
||||
<b>@Loc.F(S.ImportWizard_ColumnN, i)</b>
|
||||
@if (!string.IsNullOrWhiteSpace(Header(i)))
|
||||
{
|
||||
<br /><span style="font-size:0.72rem; opacity:0.7">@Header(i)</span>
|
||||
@@ -125,7 +124,7 @@
|
||||
<MudSelect T="MappingRole" @bind-Value="_columns[i].Role" Dense="true" Margin="Margin.Dense">
|
||||
@foreach (var role in Enum.GetValues<MappingRole>())
|
||||
{
|
||||
<MudSelectItem T="MappingRole" Value="role">@role</MudSelectItem>
|
||||
<MudSelectItem T="MappingRole" Value="role">@role.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</td>
|
||||
@@ -133,7 +132,7 @@
|
||||
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_columns[i].MeterId" Dense="true" Margin="Margin.Dense"
|
||||
Placeholder="Select meter" Clearable="true">
|
||||
Placeholder="@S.ImportWizard_SelectMeter" Clearable="true">
|
||||
@foreach (var m in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="m.Id">@m.Name (@m.Unit)</MudSelectItem>
|
||||
@@ -143,7 +142,7 @@
|
||||
else if (_columns[i].Role == MappingRole.ManualCost)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_columns[i].CategoryId" Dense="true" Margin="Margin.Dense"
|
||||
Placeholder="Select category" Clearable="true">
|
||||
Placeholder="@S.ImportWizard_SelectCategory" Clearable="true">
|
||||
@foreach (var c in _categories)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="c.Id">@c.Name</MudSelectItem>
|
||||
@@ -154,7 +153,7 @@
|
||||
<td>
|
||||
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
||||
{
|
||||
<MudTextField @bind-Value="_columns[i].Unit" Placeholder="e.g. kWh" Margin="Margin.Dense" />
|
||||
<MudTextField @bind-Value="_columns[i].Unit" Placeholder="@S.ImportWizard_UnitPlaceholder" Margin="Margin.Dense" />
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -176,12 +175,12 @@
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<div class="d-flex align-center mb-2" style="gap:1rem; flex-wrap:wrap">
|
||||
<MudText Typo="Typo.h6">4. Preview & commit</MudText>
|
||||
<MudText Typo="Typo.h6">@S.ImportWizard_Step4Title</MudText>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Visibility"
|
||||
OnClick="Preview">Dry-run preview</MudButton>
|
||||
OnClick="Preview">@S.ImportWizard_DryRunButton</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing">
|
||||
Commit import
|
||||
@S.ImportWizard_CommitButton
|
||||
</MudButton>
|
||||
@if (_committing)
|
||||
{
|
||||
@@ -192,24 +191,24 @@
|
||||
@if (_staged is not null)
|
||||
{
|
||||
<div class="d-flex mt-2" style="gap:2rem; flex-wrap:wrap">
|
||||
<MudText>Readings: <b>@_staged.Readings.Count</b></MudText>
|
||||
<MudText>Events: <b>@_staged.Events.Count</b></MudText>
|
||||
<MudText>Manual costs: <b>@_staged.ManualCosts.Count</b></MudText>
|
||||
<MudText>Skipped rows: <b>@_staged.SkippedRows</b></MudText>
|
||||
<MudText>@S.Common_ReadingsLabel <b>@_staged.Readings.Count</b></MudText>
|
||||
<MudText>@S.Common_EventsLabel <b>@_staged.Events.Count</b></MudText>
|
||||
<MudText>@S.Common_ManualCostsLabel <b>@_staged.ManualCosts.Count</b></MudText>
|
||||
<MudText>@S.Common_SkippedRowsLabel <b>@_staged.SkippedRows</b></MudText>
|
||||
</div>
|
||||
@if (_staged.TotalRows == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">
|
||||
Nothing staged. Check the first-data-row, date column and column mappings above.
|
||||
@S.ImportWizard_NothingStaged
|
||||
</MudAlert>
|
||||
}
|
||||
@if (_staged.Warnings.Count > 0)
|
||||
{
|
||||
<MudExpansionPanels Class="mt-3">
|
||||
<MudExpansionPanel Text="@($"{_staged.Warnings.Count} warnings")">
|
||||
<MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _staged.Warnings.Count)">
|
||||
@foreach (var warning in _staged.Warnings.Take(100))
|
||||
{
|
||||
<MudText Typo="Typo.body2">@warning</MudText>
|
||||
<MudText Typo="Typo.body2">@warning.Display()</MudText>
|
||||
}
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
@@ -326,7 +325,7 @@
|
||||
{
|
||||
_staged = null;
|
||||
_stagedMapping = null;
|
||||
_validationErrors = ["The mapping changed after the preview. Run the dry run again, then commit."];
|
||||
_validationErrors = [S.ImportWizard_MappingChanged];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -334,12 +333,12 @@
|
||||
try
|
||||
{
|
||||
var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson);
|
||||
Snackbar.Add($"Imported batch #{batchId}: {_staged.TotalRows} rows staged. Consumption recomputed.", Severity.Success);
|
||||
Snackbar.Add(Loc.F(S.ImportWizard_CommitSuccess, batchId, _staged.TotalRows), Severity.Success);
|
||||
Nav.NavigateTo("/import");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Commit failed: {ex.Message}", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.ImportWizard_CommitFailed, ex.Message), Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -352,7 +351,7 @@
|
||||
var errors = new List<string>();
|
||||
if (_columns.Count(c => c.Role != MappingRole.Ignore) == 0)
|
||||
{
|
||||
errors.Add("Map at least one column to a role other than Ignore.");
|
||||
errors.Add(S.ImportWizard_ValidateNoMappedColumn);
|
||||
}
|
||||
|
||||
for (var i = 0; i < _columns.Length; i++)
|
||||
@@ -360,12 +359,12 @@
|
||||
var c = _columns[i];
|
||||
if (c.Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel && c.MeterId is null)
|
||||
{
|
||||
errors.Add($"Col {i} ({c.Role}) needs a target meter.");
|
||||
errors.Add(Loc.F(S.ImportWizard_ValidateNeedsMeter, i, c.Role.Display()));
|
||||
}
|
||||
|
||||
if (c.Role == MappingRole.ManualCost && c.CategoryId is null)
|
||||
{
|
||||
errors.Add($"Col {i} (ManualCost) needs a target category.");
|
||||
errors.Add(Loc.F(S.ImportWizard_ValidateNeedsCategory, i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,9 +378,10 @@
|
||||
|
||||
foreach (var group in duplicateTargets)
|
||||
{
|
||||
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name ?? $"meter {group.Key}";
|
||||
var cols = string.Join(", ", group.Select(x => $"Col {x.Index}"));
|
||||
errors.Add($"{cols} all read into '{meterName}'. Each Reading column needs its own meter.");
|
||||
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name
|
||||
?? Loc.F(S.ImportWizard_MeterFallback, group.Key);
|
||||
var cols = string.Join(", ", group.Select(x => Loc.F(S.ImportWizard_ColumnN, x.Index)));
|
||||
errors.Add(Loc.F(S.ImportWizard_ValidateDuplicateMeter, cols, meterName));
|
||||
}
|
||||
|
||||
return errors;
|
||||
@@ -431,6 +431,8 @@
|
||||
private string ColLabel(int col)
|
||||
{
|
||||
var header = Header(col);
|
||||
return string.IsNullOrWhiteSpace(header) ? $"Col {col}" : $"Col {col}: {header}";
|
||||
return string.IsNullOrWhiteSpace(header)
|
||||
? Loc.F(S.ImportWizard_ColumnN, col)
|
||||
: Loc.F(S.ImportWizard_ColumnWithHeader, col, header);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Meter</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Common_Meter</PageTitle>
|
||||
|
||||
@if (_detail is null)
|
||||
{
|
||||
@if (_notFound)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning">Meter #@Id not found. <MudLink Href="/meters">Back to meters</MudLink></MudAlert>
|
||||
<MudAlert Severity="Severity.Warning">@Loc.F(S.MeterDetail_NotFound, Id) <MudLink Href="/meters">@S.MeterDetail_BackToMeters</MudLink></MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -43,10 +43,10 @@ else
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
|
||||
<MudText Typo="Typo.h4">@_detail.Name</MudText>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Primary">@_detail.EnergyType</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode.Display()</MudChip>
|
||||
@if (!_detail.IsActive)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">retired</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@S.MeterDetail_Retired</MudChip>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -55,41 +55,41 @@ else
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@p.Label this month</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@Loc.F(S.MeterDetail_LabelThisMonth, p.Kind.Display())</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.MonthToDate, 0) @p.Unit</MudText>
|
||||
@if (p.MonthIsPartial)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
≈ @Format.Number(p.MonthProjected, 0) @p.Unit by month end
|
||||
@Loc.F(S.MeterDetail_ProjectedByMonthEnd, Format.Number(p.MonthProjected, 0), p.Unit)
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">vs last month</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_VsLastMonth</MudText>
|
||||
<MudText Typo="Typo.h6">@ChangeText(p.MonthChange)</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
last month @Format.Number(p.LastMonth, 0) @p.Unit
|
||||
@Loc.F(S.MeterDetail_LastMonthValue, Format.Number(p.LastMonth, 0), p.Unit)
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_ThisYear</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.YearToDate, 0) @p.Unit</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@ChangeText(p.YearChange) vs @Format.Number(p.LastYear, 0) last year
|
||||
@Loc.F(S.MeterDetail_VsLastYear, ChangeText(p.YearChange), Format.Number(p.LastYear, 0))
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost this year</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_CostThisYear</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.YearToDateCost, 2) @p.Currency</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
≈ @Format.Number(p.YearProjectedCost, 0) @p.Currency full year
|
||||
@(p.LastYearCost > 0 ? $"· {Format.Number(p.LastYearCost, 0)} last year" : "")
|
||||
@Loc.F(S.MeterDetail_ProjectedFullYear, Format.Number(p.YearProjectedCost, 0), p.Currency)
|
||||
@(p.LastYearCost > 0 ? Loc.F(S.MeterDetail_LastYearCost, Format.Number(p.LastYearCost, 0)) : "")
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
@@ -98,7 +98,7 @@ else
|
||||
@if (p.HasHistory)
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-2" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Last 12 months</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_RangeLast12Months</MudText>
|
||||
<div class="d-flex align-end mt-2" style="gap:.35rem; height:110px">
|
||||
@foreach (var m in p.Last12Months)
|
||||
{
|
||||
@@ -118,31 +118,30 @@ else
|
||||
@if (_periods is null && _detail.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Virtual meter — its value is an expression over other meters, evaluated when read, so it has
|
||||
no stored series of its own. See <MudLink Href="/trends">Trends</MudLink> for its figures.
|
||||
@S.MeterDetail_VirtualNotice <MudLink Href="/trends">@S.MeterDetail_VirtualNoticeTrends</MudLink>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudExpansionPanels Elevation="0" Class="mb-2">
|
||||
<MudExpansionPanel Text="Meter register details">
|
||||
<MudExpansionPanel Text="@S.MeterDetail_RegisterDetails">
|
||||
<div class="d-flex flex-wrap" style="gap:2rem">
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_RegisterSpan</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
|
||||
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
|
||||
(baseline @Format.Number(_detail.InitialBaseline, 0))
|
||||
@Loc.F(S.MeterDetail_BaselineValue, Format.Number(_detail.InitialBaseline, 0))
|
||||
</MudText>
|
||||
</div>
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_Readings</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@_detail.ReadingCount ·
|
||||
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
|
||||
</MudText>
|
||||
</div>
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Lifetime total</MudText>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_LifetimeTotal</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit
|
||||
</MudText>
|
||||
@@ -152,12 +151,11 @@ else
|
||||
</MudExpansionPanels>
|
||||
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
|
||||
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
|
||||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabReadings, _detail.ReadingCount)">
|
||||
@if (_detail.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
A virtual meter is an expression over other meters, so it stores no readings of its own —
|
||||
enter the reading on the meter the expression refers to.
|
||||
@S.MeterDetail_VirtualNoReadings
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
@@ -165,21 +163,21 @@ else
|
||||
<div class="d-flex justify-end mb-2">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add" OnClick="OpenReading">
|
||||
Add reading
|
||||
@S.MeterDetail_AddReading
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
@if (_detail.RecentReadings.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoRawReadings</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Most recent @_detail.RecentReadings.Count (raw, immutable audit truth). Times in @_tz.Id.
|
||||
@Loc.F(S.MeterDetail_RecentReadingsCaption, _detail.RecentReadings.Count, _tz.Id)
|
||||
</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
|
||||
<thead><tr><th>@S.MeterDetail_Time</th><th style="text-align:right">@S.Common_Value</th><th>@S.MeterDetail_Quality</th><th>@S.MeterDetail_Flags</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _detail.RecentReadings)
|
||||
{
|
||||
@@ -187,7 +185,7 @@ else
|
||||
<td>@Local(r.Time).ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
|
||||
<td>@QualityChip(r.Quality)</td>
|
||||
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
|
||||
<td>@r.Flags.Display()</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -195,23 +193,23 @@ else
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")">
|
||||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabConsumption, _detail.ConsumptionCount)">
|
||||
@if (_detail.RecentConsumption.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No normalized consumption yet.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoConsumption</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentConsumption.Count normalized deltas.</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.MeterDetail_RecentConsumptionCaption, _detail.RecentConsumption.Count)</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||
<thead><tr><th>Time</th><th style="text-align:right">Amount</th><th>Kind</th><th>Quality</th></tr></thead>
|
||||
<thead><tr><th>@S.MeterDetail_Time</th><th style="text-align:right">@S.Common_Amount</th><th>@S.MeterDetail_Kind</th><th>@S.MeterDetail_Quality</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _detail.RecentConsumption)
|
||||
{
|
||||
<tr>
|
||||
<td>@Local(c.Time).ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td>
|
||||
<td>@c.Kind</td>
|
||||
<td>@c.Kind.Display()</td>
|
||||
<td>@QualityChip(c.Quality)</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -220,21 +218,21 @@ else
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")">
|
||||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabEvents, _detail.Events.Count)">
|
||||
@if (_detail.Events.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No events (swaps, deliveries, corrections).</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoEvents</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Time</th><th>Type</th><th style="text-align:right">Amount</th><th style="text-align:right">Prev→New</th><th>Notes</th></tr></thead>
|
||||
<thead><tr><th>@S.MeterDetail_Time</th><th>@S.Common_Type</th><th style="text-align:right">@S.Common_Amount</th><th style="text-align:right">@S.MeterDetail_PrevNew</th><th>@S.MeterDetail_Notes</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var e in _detail.Events)
|
||||
{
|
||||
<tr>
|
||||
<td>@Local(e.Time).ToString("yyyy-MM-dd")</td>
|
||||
<td>@e.Type</td>
|
||||
<td>@e.Type.Display()</td>
|
||||
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
|
||||
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
|
||||
<td>@e.Notes</td>
|
||||
@@ -245,25 +243,25 @@ else
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")">
|
||||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabTariffs, _detail.Tariffs.Count)">
|
||||
@if (_detail.Tariffs.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No applicable tariffs.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoTariffs</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Scope</th><th>Component</th><th style="text-align:right">Value</th><th>Unit</th><th>From</th><th>To</th></tr></thead>
|
||||
<thead><tr><th>@S.Common_Scope</th><th>@S.MeterDetail_Component</th><th style="text-align:right">@S.Common_Value</th><th>@S.Common_Unit</th><th>@S.MeterDetail_From</th><th>@S.MeterDetail_To</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var t in _detail.Tariffs)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Scope @(t.ScopeId is { } id ? $"#{id}" : "")</td>
|
||||
<td>@t.Component</td>
|
||||
<td>@t.Scope.Display() @(t.ScopeId is { } id ? $"#{id}" : "")</td>
|
||||
<td>@t.Component.Display()</td>
|
||||
<td style="text-align:right">@Format.Number(t.Value, 4)</td>
|
||||
<td>@t.Unit</td>
|
||||
<td>@t.ValidFrom.ToString("yyyy-MM-dd")</td>
|
||||
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</td>
|
||||
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? S.MeterDetail_TariffOpenEnd)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -271,25 +269,25 @@ else
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Sources ({_sources.Count})")">
|
||||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabSources, _sources.Count)">
|
||||
<div class="d-flex justify-end mb-2">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenSource(null))">
|
||||
Add source
|
||||
@S.MeterDetail_AddSource
|
||||
</MudButton>
|
||||
</div>
|
||||
@if (_sources.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant.</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoSources</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Type</th><th>Target</th><th>Connector</th><th>Enabled</th><th>Last seen</th><th style="text-align:right">Last value</th><th>Status</th><th style="text-align:right">Actions</th></tr></thead>
|
||||
<thead><tr><th>@S.Common_Type</th><th>@S.Common_Target</th><th>@S.MeterDetail_Connector</th><th>@S.Common_Enabled</th><th>@S.Common_LastSeen</th><th style="text-align:right">@S.MeterDetail_LastValue</th><th>@S.Common_Status</th><th style="text-align:right">@S.Common_Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var s in _sources)
|
||||
{
|
||||
<tr>
|
||||
<td>@s.SourceType</td>
|
||||
<td>@s.SourceType.Display()</td>
|
||||
<td>@SourceTarget(s)</td>
|
||||
<td>
|
||||
@{ var problem = ConnectorProblem(s); }
|
||||
@@ -305,7 +303,7 @@ else
|
||||
</MudTooltip>
|
||||
}
|
||||
</td>
|
||||
<td>@(s.IsEnabled ? "yes" : "no")</td>
|
||||
<td>@(s.IsEnabled ? S.MeterDetail_Yes : S.MeterDetail_No)</td>
|
||||
<td>@(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</td>
|
||||
<td style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td>
|
||||
<td>@(s.LastStatus ?? "—")</td>
|
||||
@@ -323,13 +321,13 @@ else
|
||||
|
||||
<MudDialog @bind-Visible="_readingOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Add reading — @_detail.Name</MudText>
|
||||
<MudText Typo="Typo.h6">@Loc.F(S.MeterDetail_AddReadingTitle, _detail.Name)</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="@($"Reading ({_detail.Unit})")" Variant="Variant.Outlined"
|
||||
Label="@Loc.F(S.MeterDetail_ReadingLabel, _detail.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
|
||||
@@ -338,12 +336,12 @@ else
|
||||
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)} {_detail.Unit}" : "Enter a value")
|
||||
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {_detail.Unit}" : S.MeterDetail_EnterValue)
|
||||
</MudText>
|
||||
@if (ChangeSinceLast is { } change)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="@(WouldBeRejected ? Color.Warning : Color.Secondary)">
|
||||
@ChangeSinceText(change)@(WouldBeRejected ? " — will be rejected" : "")
|
||||
@ChangeSinceText(change)@(WouldBeRejected ? S.MeterDetail_WillBeRejectedSuffix : "")
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
@@ -357,66 +355,64 @@ else
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
|
||||
<MudDatePicker @bind-Date="_readingDate" Label="Date" Variant="Variant.Outlined"
|
||||
<MudDatePicker @bind-Date="_readingDate" Label="@S.Common_Date" Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" Style="min-width:150px" />
|
||||
<MudTimePicker @bind-Time="_readingTime" Label="Time" Variant="Variant.Outlined"
|
||||
<MudTimePicker @bind-Time="_readingTime" 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="SetNow">Now</MudButton>
|
||||
StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNow">@S.Common_Now</MudButton>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">Local time in @_tz.Id.</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">@Loc.F(S.MeterDetail_LocalTimeIn, _tz.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 (EnteredTimeSkipped)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">
|
||||
That clock time never happened in @_tz.Id — the clocks moved forward. Pick another time.
|
||||
@Loc.F(S.MeterDetail_SkippedTime, _tz.Id)
|
||||
</MudAlert>
|
||||
}
|
||||
@if (WouldBeRejected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
|
||||
Below the last reading (@Format.Number(_detail.LastReadingValue ?? 0, 2) @_detail.Unit) on a
|
||||
register that only counts up, so it will be rejected. If the meter was swapped or reset,
|
||||
record that on the Events tab first.
|
||||
@Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(_detail.LastReadingValue ?? 0, 2), _detail.Unit)
|
||||
</MudAlert>
|
||||
}
|
||||
@if (ReplacesRecentReading)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
This meter already has a reading at that time — saving replaces its value.
|
||||
@S.MeterDetail_ReplaceNotice
|
||||
</MudAlert>
|
||||
}
|
||||
@if (IsFuture)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">That time is in the future.</MudAlert>
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_FutureTime</MudAlert>
|
||||
}
|
||||
else if (IsBackdated)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
Backdated before the latest reading — consumption from there on is recomputed.
|
||||
@S.MeterDetail_BackdatedNotice
|
||||
</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _readingOpen = false)" Disabled="_readingSaving">Cancel</MudButton>
|
||||
<MudButton OnClick="@(() => _readingOpen = false)" Disabled="_readingSaving">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
|
||||
OnClick="SaveReadingAsync" Disabled="@(!CanSaveReading)">
|
||||
@(_readingSaving ? "Saving…" : "Save reading")
|
||||
@(_readingSaving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading)
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
|
||||
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? S.MeterDetail_NewSource : S.MeterDetail_EditSource)</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="SourceType" Value="_sourceEdit.SourceType" ValueChanged="OnSourceTypeChanged" Label="Source type" Class="mb-2">
|
||||
<MudSelect T="SourceType" Value="_sourceEdit.SourceType" ValueChanged="OnSourceTypeChanged" Label="@S.MeterDetail_SourceType" Class="mb-2">
|
||||
@foreach (var type in Enum.GetValues<SourceType>())
|
||||
{
|
||||
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem>
|
||||
<MudSelectItem T="SourceType" Value="type">@type.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
|
||||
@@ -424,13 +420,13 @@ else
|
||||
if (ConnectorsFor(needed).Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
|
||||
No @needed connector yet — <MudLink Href="/admin/connectors">create one</MudLink>
|
||||
(set it up once; every source then just picks it).
|
||||
@Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) <MudLink Href="/admin/connectors">@S.MeterDetail_CreateConnectorLink</MudLink>
|
||||
@S.MeterDetail_CreateConnectorHint
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Required="true" Class="mb-2">
|
||||
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="@S.MeterDetail_Connector" Required="true" Class="mb-2">
|
||||
@foreach (var e in ConnectorsFor(needed))
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
|
||||
@@ -440,35 +436,35 @@ else
|
||||
}
|
||||
@if (_sourceEdit.SourceType == SourceType.HomeAssistant)
|
||||
{
|
||||
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="Entity id (e.g. sensor.house_power)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="Attribute (optional; blank = state)" Class="mb-2" />
|
||||
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollMinutes" Label="Poll interval (minutes)" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="@S.MeterDetail_EntityIdLabel" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="@S.MeterDetail_AttributeLabel" Class="mb-2" />
|
||||
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollMinutes" Label="@S.MeterDetail_PollIntervalLabel" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
Hourly is plenty for a meter — monthly totals and cost come out identical, with far less raw data.
|
||||
@S.MeterDetail_PollHint
|
||||
</MudText>
|
||||
}
|
||||
else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
|
||||
{
|
||||
<MudTextField @bind-Value="_sourceEdit.Topic" Label="MQTT topic (e.g. tele/plug1/SENSOR)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Path" Label="Value path (e.g. ENERGY.Total; blank = bare scalar)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="Time path (optional, e.g. Time)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Topic" Label="@S.MeterDetail_TopicLabel" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Path" Label="@S.MeterDetail_ValuePathLabel" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="@S.MeterDetail_TimePathLabel" Class="mb-2" />
|
||||
}
|
||||
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="Value kind" Class="mb-2">
|
||||
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="@S.MeterDetail_ValueKind" Class="mb-2">
|
||||
@foreach (var kind in Enum.GetValues<SourceValueKind>())
|
||||
{
|
||||
<MudSelectItem T="SourceValueKind" Value="kind">@kind</MudSelectItem>
|
||||
<MudSelectItem T="SourceValueKind" Value="kind">@kind.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<div class="d-flex" style="gap:1rem">
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="Scale" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="Offset" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="Priority" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="@S.MeterDetail_Scale" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="@S.MeterDetail_Offset" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="@S.MeterDetail_Priority" Class="mb-2" />
|
||||
</div>
|
||||
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _sourceOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _sourceOpen = false)">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
}
|
||||
@@ -547,11 +543,11 @@ else
|
||||
{
|
||||
if (change is not { } c)
|
||||
{
|
||||
return "no basis yet";
|
||||
return S.MeterDetail_NoBasisYet;
|
||||
}
|
||||
|
||||
return Math.Abs(c) < 0.005
|
||||
? "about the same"
|
||||
? S.MeterDetail_AboutTheSame
|
||||
: $"{(c > 0 ? "+" : "−")}{Format.Number(Math.Abs(c) * 100, 0)}%";
|
||||
}
|
||||
|
||||
@@ -613,8 +609,9 @@ else
|
||||
}
|
||||
|
||||
return detail is { LastReadingValue: { } value, LastReadingTime: { } time }
|
||||
? $"Last reading {Format.Number(value, 2)} {detail.Unit} on {Local(time):yyyy-MM-dd HH:mm}."
|
||||
: $"No readings yet — prefilled with this meter's baseline ({Format.Number(detail.InitialBaseline, 2)} {detail.Unit}).";
|
||||
? Loc.F(S.MeterDetail_LastReadingCaption,
|
||||
Format.Number(value, 2), detail.Unit, Local(time).ToString("yyyy-MM-dd HH:mm"))
|
||||
: Loc.F(S.MeterDetail_NoReadingsPrefill, Format.Number(detail.InitialBaseline, 2), detail.Unit);
|
||||
}
|
||||
|
||||
/// <summary>The wall-clock instant the two pickers describe, read in the instance timezone.</summary>
|
||||
@@ -678,8 +675,9 @@ else
|
||||
|
||||
private string ChangeSinceText(double change) =>
|
||||
Math.Abs(change) < 1e-9
|
||||
? "no change since last reading"
|
||||
: $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)} {_detail?.Unit} since last reading";
|
||||
? S.MeterDetail_NoChangeSinceLast
|
||||
: Loc.F(S.MeterDetail_ChangeSinceLast,
|
||||
$"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)}", _detail?.Unit);
|
||||
|
||||
private async Task SaveReadingAsync()
|
||||
{
|
||||
@@ -701,20 +699,18 @@ else
|
||||
switch (outcome)
|
||||
{
|
||||
case IngestionOutcome.Written:
|
||||
Snackbar.Add($"Reading saved: {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_ReadingSaved, Format.Number(value, 2), _detail.Unit), Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.Updated:
|
||||
Snackbar.Add($"Replaced the reading at that time with {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_ReadingReplaced, Format.Number(value, 2), _detail.Unit), Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.RejectedDecrease:
|
||||
// Leave the dialog open: the typed value is still on screen to correct, and the
|
||||
// alternative fix — recording a reset or swap — is a decision, not a retry.
|
||||
Snackbar.Add(
|
||||
"Rejected — below the previous reading on a register that only counts up. "
|
||||
+ "Record a counter reset or meter swap first.", Severity.Error);
|
||||
Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error);
|
||||
return;
|
||||
default:
|
||||
Snackbar.Add("This meter no longer exists.", Severity.Error);
|
||||
Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -783,19 +779,21 @@ else
|
||||
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
|
||||
if (selected is null)
|
||||
{
|
||||
Snackbar.Add($"Pick a {needed} connector for this {_sourceEdit.SourceType} source.", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_PickConnector, needed.Display(), _sourceEdit.SourceType.Display()), Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.Type != needed)
|
||||
{
|
||||
Snackbar.Add($"'{selected.Name}' is a {selected.Type} connector; a {_sourceEdit.SourceType} source needs {needed}.", Severity.Error);
|
||||
Snackbar.Add(
|
||||
Loc.F(S.MeterDetail_ConnectorTypeMismatch, selected.Name, selected.Type.Display(), _sourceEdit.SourceType.Display(), needed.Display()),
|
||||
Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selected.IsEnabled)
|
||||
{
|
||||
Snackbar.Add($"'{selected.Name}' is disabled, so this source would never ingest. Enable it first.", Severity.Error);
|
||||
Snackbar.Add(Loc.F(S.MeterDetail_ConnectorDisabled, selected.Name), Severity.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -847,20 +845,21 @@ else
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_sourceOpen = false;
|
||||
Snackbar.Add("Source saved.", Severity.Success);
|
||||
Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteSourceAsync(MeterSource source)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete source", $"Delete this {source.SourceType} 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("Source deleted.", Severity.Success);
|
||||
Snackbar.Add(S.MeterDetail_SourceDeleted, Severity.Success);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
|
||||
@@ -898,9 +897,9 @@ else
|
||||
var endpoint = _endpoints.FirstOrDefault(e => e.Id == source.EndpointId);
|
||||
return endpoint switch
|
||||
{
|
||||
null => "no connector — never ingests",
|
||||
{ IsEnabled: false } => $"'{endpoint.Name}' is disabled",
|
||||
_ when endpoint.Type != needed => $"'{endpoint.Name}' is {endpoint.Type}, needs {needed}",
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -948,5 +947,5 @@ else
|
||||
}
|
||||
|
||||
private static RenderFragment QualityChip(ReadingQuality quality) =>@<MudChip T="string" Size="Size.Small" Variant="Variant.Text"
|
||||
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality</MudChip>;
|
||||
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality.Display()</MudChip>;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Meters</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Meters</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Meters</MudText>
|
||||
<MudText Typo="Typo.h4">@S.Common_Meters</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add meter
|
||||
@S.Meters_AddMeter
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@@ -23,29 +23,29 @@ else
|
||||
{
|
||||
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh>Mode</MudTh>
|
||||
<MudTh>Unit</MudTh>
|
||||
<MudTh>Sources</MudTh>
|
||||
<MudTh>Last seen</MudTh>
|
||||
<MudTh>Active</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
<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>
|
||||
<MudTh>@S.Common_LastSeen</MudTh>
|
||||
<MudTh>@S.Meters_Active</MudTh>
|
||||
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
|
||||
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
|
||||
<MudTd DataLabel="Mode">@context.Mode</MudTd>
|
||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
|
||||
<MudTd DataLabel="Last seen">
|
||||
<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">
|
||||
@{
|
||||
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="Active">@(context.IsActive ? "yes" : "no")</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudTd DataLabel="@S.Meters_Active">@(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))" />
|
||||
</MudTd>
|
||||
@@ -55,76 +55,73 @@ else
|
||||
@if (_meters.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
No meters yet. Add one, or go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
||||
@S.Meters_EmptyBefore <MudLink Href="/import">@S.Nav_Import</MudLink> @S.Meters_EmptyAfter
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}")</MudText>
|
||||
<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="Name" Required="true" Class="mb-2" />
|
||||
<MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="Energy type" Class="mb-2">
|
||||
<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="Measurement mode" Class="mb-2">
|
||||
<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</MudSelectItem>
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_working.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the
|
||||
upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2).
|
||||
@S.Meters_VirtualHelp
|
||||
</MudAlert>
|
||||
}
|
||||
else if (_working.Mode == MeterMode.InstantRate)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Power/flow sensor — readings are an instantaneous rate, integrated over time into consumption.
|
||||
Store the value as a <b>per-hour</b> rate in this meter's unit (e.g. kW for kWh, L/h for L): a
|
||||
source reporting W or L/min should carry a scale factor to convert it first.
|
||||
@S.Meters_InstantRateHelpBefore <b>@S.Meters_InstantRateHelpPerHour</b> @S.Meters_InstantRateHelpAfter
|
||||
</MudAlert>
|
||||
}
|
||||
<MudTextField @bind-Value="_working.Unit" Label="Unit" Required="true" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="Initial register baseline" Class="mb-2" />
|
||||
<MudSelect T="string" @bind-Value="_working.Role" Label="PV role (optional)" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("")">— none —</MudSelectItem>
|
||||
<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="Sub-meter of (upstream meters)" Class="mb-2"
|
||||
Label="@S.Meters_UpstreamLabel" Class="mb-2"
|
||||
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
|
||||
HelperText="This meter measures a subsection of the selected meter(s)' flow.">
|
||||
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="Location (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.SerialNumber" Label="Serial number (optional)" Class="mb-2" />
|
||||
<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="Manufacturer (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Model" Label="Model (optional)" Class="mb-2" />
|
||||
<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="Active" Color="Color.Primary" />
|
||||
<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">Mode/baseline changed — consumption will be recomputed on save.</MudAlert>
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Meters_RecomputeNotice</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -228,7 +225,7 @@ else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
|
||||
{
|
||||
Snackbar.Add("Name, energy type and unit are required.", Severity.Warning);
|
||||
Snackbar.Add(S.Meters_RequiredFields, Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,7 +283,7 @@ else
|
||||
await SyncUpstreamAsync(db, meterId, _working.Upstream);
|
||||
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -314,11 +311,11 @@ else
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id);
|
||||
var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id);
|
||||
var detail = readings + consumption > 0
|
||||
? $" This will also delete {readings} reading(s) and {consumption} consumption row(s)."
|
||||
: "";
|
||||
var message = readings + consumption > 0
|
||||
? Loc.F(S.Meters_DeleteConfirmWithData, meter.Name, readings, consumption)
|
||||
: Loc.F(S.Meters_DeleteConfirm, meter.Name);
|
||||
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete meter", $"Delete '{meter.Name}'?{detail} This cannot be undone."))
|
||||
if (!await Confirm.DeleteAsync(DialogService, S.Meters_DeleteTitle, message))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -330,7 +327,7 @@ else
|
||||
await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
Snackbar.Add(S.Common_Deleted, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
<h3>@S.NotFound_Title</h3>
|
||||
<p>@S.NotFound_Message</p>
|
||||
@@ -2,15 +2,15 @@
|
||||
@inject SolarService SolarSvc
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Solar / PV</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Solar</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Solar / PV</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
<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>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
else if (!_summary.HasGeneration)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No generation meters found. Add a meter with mode <b>GenerationCounter</b>, or load the reference data from
|
||||
<MudLink Href="/import">Import</MudLink>.
|
||||
@S.Solar_NoGenerationLead <b>@MeterMode.GenerationCounter.Display()</b> @S.Solar_NoGenerationTail
|
||||
<MudLink Href="/import">@S.Nav_Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
@@ -30,47 +30,47 @@ else
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
|
||||
<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">Self-consumption</MudText>
|
||||
<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">@Format.Number(ratio * 100, 0)% of generation</MudText>
|
||||
<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">Autarky</MudText>
|
||||
<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">Grid draw @Format.Number(grid, 0) kWh</MudText>
|
||||
<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">Savings (Ersparnis)</MudText>
|
||||
<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">Generation & self-consumption</MudText>
|
||||
<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">Generation by meter</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationByMeter</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
@foreach (var meter in _summary.Meters)
|
||||
@@ -85,8 +85,8 @@ else
|
||||
@if (!_summary.HasLoadContext)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
Tag a meter <code>total_load</code> and one <code>grid_import</code> (in meter metadata)
|
||||
to unlock self-consumption, autarky and savings.
|
||||
@S.Solar_TagMetersLead <code>total_load</code> @S.Solar_TagMetersMid <code>grid_import</code>
|
||||
@S.Solar_TagMetersTail
|
||||
</MudAlert>
|
||||
}
|
||||
</MudPaper>
|
||||
@@ -124,18 +124,18 @@ else
|
||||
_summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||
|
||||
var generation = _summary.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Generation))
|
||||
.Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Generation))
|
||||
.ToList();
|
||||
var series = new List<SeriesChart.SeriesDef>
|
||||
{
|
||||
new("Generation", ApexCharts.SeriesType.Bar, generation),
|
||||
new(S.Solar_Generation, ApexCharts.SeriesType.Bar, generation),
|
||||
};
|
||||
if (_summary.HasLoadContext)
|
||||
{
|
||||
var self = _summary.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.SelfConsumption ?? 0))
|
||||
.Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.SelfConsumption ?? 0))
|
||||
.ToList();
|
||||
series.Add(new("Self-consumption", ApexCharts.SeriesType.Bar, self));
|
||||
series.Add(new(S.Solar_SelfConsumption, ApexCharts.SeriesType.Bar, self));
|
||||
}
|
||||
|
||||
_chart = series;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
@page "/trends"
|
||||
@inject DashboardService Dash
|
||||
|
||||
<PageTitle>MeterVault — Trends</PageTitle>
|
||||
<PageTitle>MeterVault — @S.Nav_Trends</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Cost trend</MudText>
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@S.Trends_Title</MudText>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<div class="d-flex align-center mb-3" style="gap:1rem">
|
||||
<MudSelect T="int" @bind-Value="_months" Label="Range" Dense="true" Style="max-width:180px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="48">Last 48 months</MudSelectItem>
|
||||
<MudSelect 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">Apply</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync" Disabled="_loading">@S.Trends_Apply</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
@@ -23,7 +23,7 @@
|
||||
{
|
||||
<TrendChart Points="_points" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-4">
|
||||
Total over range: @Format.Euro(_points.Sum(p => p.Cost))
|
||||
@Loc.F(S.Trends_TotalOverRange, Format.Euro(_points.Sum(p => p.Cost)))
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
Reference in New Issue
Block a user