Files
MeterVault/src/App/Components/Pages/Admin/Tariffs.razor
T
schmidt.florian 85d650a8f5
ci / build-test (push) Successful in 1m12s
Fix: enable global interactivity so dialogs/selects actually work
Editing a meter (any admin dialog, select dropdown, snackbar, dark-mode toggle)
did nothing: pages declared @rendermode InteractiveServer individually, but
MainLayout — which hosts MudDialogProvider/MudPopoverProvider/MudSnackbarProvider
— was rendered by <Routes>, which was static. Inline MudDialogs and MudSelect
popovers render through those providers, so with the providers non-interactive no
dialog could ever open.

Set the render mode on <Routes> and <HeadOutlet> in App.razor (global
interactivity) and removed the now-redundant per-page @rendermode declarations
(they would otherwise throw "parent already has a render mode").

Why it slipped through: the integration render tests only issue a GET (static
prerender), which never exercises the SignalR circuit. Verified this fix with a
real headless-browser run (Playwright): the meter edit dialog opens and the
"Sub-meter of (upstream meters)" multi-select opens with options — proving the
layout providers are now interactive. 116 tests still green.

Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
2026-07-14 14:22:15 +02:00

231 lines
9.8 KiB
Plaintext

@page "/admin/tariffs"
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — Tariffs</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Tariffs</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add tariff
</MudButton>
</div>
@if (_tariffs is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
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>
</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">
<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>
</RowTemplate>
</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>
}
}
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New tariff" : "Edit tariff")</MudText>
</TitleContent>
<DialogContent>
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="Scope" Class="mb-2">
@foreach (var scope in Enum.GetValues<TariffScope>())
{
<MudSelectItem T="TariffScope" Value="scope">@scope</MudSelectItem>
}
</MudSelect>
@if (_working.ScopeType == TariffScope.EnergyType)
{
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Energy type" Class="mb-2">
@foreach (var t in _energyTypes)
{
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
}
</MudSelect>
}
else if (_working.ScopeType == TariffScope.Meter)
{
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="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">
@foreach (var component in Enum.GetValues<TariffComponent>())
{
<MudSelectItem T="TariffComponent" Value="component">@component</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)" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
</DialogActions>
</MudDialog>
@code {
private List<Tariff>? _tariffs;
private List<EnergyType> _energyTypes = [];
private List<Meter> _meters = [];
private bool _editOpen;
private EditModel _working = new();
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override Task OnInitializedAsync() => LoadAsync();
private async Task LoadAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync();
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
}
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}"}",
_ => t.ScopeType.ToString(),
};
private void OpenEdit(Tariff? tariff)
{
_working = tariff is null
? new EditModel { ValidFrom = DateTime.Today }
: new EditModel
{
Id = tariff.Id,
ScopeType = tariff.ScopeType,
ScopeId = tariff.ScopeId,
Component = tariff.Component,
Value = tariff.Value,
Unit = tariff.Unit,
Currency = tariff.Currency,
ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue),
ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue),
Notes = tariff.Notes,
};
_editOpen = true;
}
private async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null)
{
Snackbar.Add("Unit and valid-from are required.", 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);
return;
}
var scopeId = _working.ScopeType == TariffScope.Global ? null : _working.ScopeId;
await using var db = await DbFactory.CreateDbContextAsync();
if (_working.Id == 0)
{
db.Tariffs.Add(new Tariff
{
ScopeType = _working.ScopeType,
ScopeId = scopeId,
Component = _working.Component,
Value = _working.Value,
Unit = _working.Unit.Trim(),
Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim(),
ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value),
ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null,
Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes,
});
}
else
{
var existing = await db.Tariffs.FirstAsync(t => t.Id == _working.Id);
existing.ScopeType = _working.ScopeType;
existing.ScopeId = scopeId;
existing.Component = _working.Component;
existing.Value = _working.Value;
existing.Unit = _working.Unit.Trim();
existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim();
existing.ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value);
existing.ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null;
existing.Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes;
}
await db.SaveChangesAsync();
_editOpen = false;
Snackbar.Add("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})?"))
{
return;
}
await using var db = await DbFactory.CreateDbContextAsync();
var target = await db.Tariffs.FirstOrDefaultAsync(t => t.Id == tariff.Id);
if (target is not null)
{
db.Tariffs.Remove(target);
await db.SaveChangesAsync();
Snackbar.Add("Deleted.", Severity.Success);
}
await LoadAsync();
}
private sealed class EditModel
{
public int Id { get; set; }
public TariffScope ScopeType { get; set; } = TariffScope.EnergyType;
public int? ScopeId { get; set; }
public TariffComponent Component { get; set; } = TariffComponent.UnitPrice;
public double Value { get; set; }
public string Unit { get; set; } = "EUR/kWh";
public string Currency { get; set; } = "EUR";
public DateTime? ValidFrom { get; set; }
public DateTime? ValidTo { get; set; }
public string? Notes { get; set; }
}
}