ci / build-test (push) Successful in 1m12s
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
231 lines
9.8 KiB
Plaintext
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; }
|
|
}
|
|
}
|