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
186 lines
8.1 KiB
Plaintext
186 lines
8.1 KiB
Plaintext
@page "/consumables"
|
|
@inject ConsumableService ConsumablesSvc
|
|
@using MudBlazor
|
|
|
|
<PageTitle>MeterVault — Consumables</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>
|
|
</MudSelect>
|
|
</div>
|
|
|
|
@if (_items is null)
|
|
{
|
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
|
}
|
|
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>.
|
|
</MudAlert>
|
|
}
|
|
else
|
|
{
|
|
@foreach (var item in _items)
|
|
{
|
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
|
<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.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
|
|
@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>
|
|
}
|
|
</MudText>
|
|
</MudItem>
|
|
|
|
<MudItem xs="12" md="8">
|
|
<MudGrid>
|
|
<MudItem xs="6" sm="3">
|
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</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.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.subtitle1">
|
|
@if (item.FixedRate is { } fr)
|
|
{
|
|
<text>@Format.Number(fr, 2) @item.Unit/h</text>
|
|
}
|
|
else if (item.EffectiveRate is { } er)
|
|
{
|
|
<text>@Format.Number(er, 2) @item.Unit/h</text>
|
|
}
|
|
else
|
|
{
|
|
<text>—</text>
|
|
}
|
|
</MudText>
|
|
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</MudText>
|
|
</MudItem>
|
|
<MudItem xs="6" sm="3">
|
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</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.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)
|
|
</MudText>
|
|
}
|
|
</MudText>
|
|
</MudItem>
|
|
</MudGrid>
|
|
</MudItem>
|
|
|
|
<MudItem xs="12" md="7">
|
|
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</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>
|
|
@if (item.Deliveries.Count == 0)
|
|
{
|
|
<MudText Typo="Typo.body2" Color="Color.Secondary">No deliveries recorded.</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>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var delivery in item.Deliveries)
|
|
{
|
|
<tr>
|
|
<td>@delivery.Time.ToString("yyyy-MM-dd")</td>
|
|
<td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</MudSimpleTable>
|
|
</div>
|
|
}
|
|
</MudItem>
|
|
</MudGrid>
|
|
</MudPaper>
|
|
}
|
|
}
|
|
|
|
@code {
|
|
private int _months = 60;
|
|
private bool _loading;
|
|
private IReadOnlyList<ConsumableSummary>? _items;
|
|
|
|
protected override Task OnInitializedAsync() => LoadAsync();
|
|
|
|
private async Task OnRangeChanged(int months)
|
|
{
|
|
_months = months;
|
|
await LoadAsync();
|
|
}
|
|
|
|
private async Task LoadAsync()
|
|
{
|
|
if (_loading)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_loading = true;
|
|
_items = null;
|
|
try
|
|
{
|
|
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
|
var from = asOf.AddMonths(-_months);
|
|
_items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
|
}
|
|
finally
|
|
{
|
|
_loading = false;
|
|
}
|
|
}
|
|
|
|
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))
|
|
.ToList();
|
|
return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)];
|
|
}
|
|
|
|
private static Color FillColor(double fraction) => fraction switch
|
|
{
|
|
< 0.15 => Color.Error,
|
|
< 0.30 => Color.Warning,
|
|
_ => Color.Success,
|
|
};
|
|
}
|