@page "/meters" @inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory @inject ISnackbar Snackbar @inject IDialogService DialogService @inject NavigationManager Nav @using Microsoft.EntityFrameworkCore @using MudBlazor MeterVault — @S.Nav_Meters
@S.Common_Meters @S.Meters_AddMeter
@if (_meters is null) { } else { @* The whole row opens the meter — on a phone this table collapses to cards, where a name link is a small target between edit and delete. Grouped by energy type, because that is how people look for a meter ("the water one"), not by the order they were created in. *@ @S.Common_Name @S.Common_Mode @S.Common_Unit @S.Meters_Sources @S.Common_LastSeen @S.Meters_Active @S.Common_Actions @context.Key @* The link stays a real link (keyboard, open in new tab) but must not also fire the row's click, or one tap pushes the same page onto the history twice. *@ @context.Name @if (!context.IsActive) { @S.MeterDetail_Retired } @context.Mode.Display() @context.Unit @context.Sources.Count @{ var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max(); } @(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—") @(context.IsActive ? S.Meters_Yes : S.Meters_No) @* Buttons inside a clickable row must not also open the meter. *@
@if (MeterLinks.QuickEntry(context.Id, context.Mode) is { } entry) { }
@if (_meters.Count > 0) { @Loc.F(S.Meters_NoSearchMatch, _search) }
@if (_meters.Count == 0) { @S.Meters_EmptyBefore @S.Nav_Import @S.Meters_EmptyAfter } } @code { private List? _meters; private MeterEditor? _editor; private string? _search; private readonly TableGroupDefinition _byEnergyType = new() { Indentation = false, Expandable = false, Selector = m => m.EnergyType?.DisplayName ?? "—", }; protected override Task OnInitializedAsync() => LoadAsync(); private async Task LoadAsync() { await using var db = await DbFactory.CreateDbContextAsync(); var meters = await db.Meters .AsNoTracking() .Include(m => m.EnergyType) .Include(m => m.Sources) .ToListAsync(); // Grouping follows item order, so sort by the group label first. Retired meters sink to the // bottom of their group: their history still counts, but nobody reads them any more. _meters = meters .OrderBy(m => m.EnergyType?.DisplayName, StringComparer.CurrentCultureIgnoreCase) .ThenBy(m => !m.IsActive) .ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase) .ToList(); } private bool Matches(Meter meter) { if (string.IsNullOrWhiteSpace(_search)) { return true; } var term = _search.Trim(); return Contains(meter.Name) || Contains(meter.SerialNumber) || Contains(meter.Location) || Contains(meter.EnergyType?.DisplayName) || Contains(meter.Mode.Display()); bool Contains(string? value) => value?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true; } /// A new meter goes straight to its own page, where adding readings or a source is the next step. private async Task OnSavedAsync((int MeterId, bool Created) saved) { if (saved.Created) { Nav.NavigateTo(MeterLinks.Detail(saved.MeterId)); return; } await LoadAsync(); } private async Task DeleteAsync(Meter meter) { 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 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, S.Meters_DeleteTitle, message)) { return; } await using var tx = await db.Database.BeginTransactionAsync(); // reading/consumption FKs are Restrict — remove them first; events/sources/tank/members cascade. await db.Consumption.Where(c => c.MeterId == meter.Id).ExecuteDeleteAsync(); await db.Readings.Where(r => r.MeterId == meter.Id).ExecuteDeleteAsync(); await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync(); await tx.CommitAsync(); Snackbar.Add(S.Common_Deleted, Severity.Success); await LoadAsync(); } }