diff --git a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs
index 079c009..c8f0a21 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs
+++ b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs
@@ -1,3 +1,5 @@
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites;
@@ -26,11 +28,32 @@ namespace ScadaLink.CentralUI.Components.Pages.SiteCalls;
/// a relay that never reaches the site is a transient transport condition, surfaced
/// to the operator differently from a generic failure.
///
+///
+///
+/// Query-string drill-in: the Health-dashboard Site Call KPI tiles deep-link here
+/// with ?status=Parked (Parked tile) or ?stuck=true (Stuck tile). On
+/// initialization those params seed /
+/// BEFORE the first , so the first grid load is already
+/// filtered and the filter card controls reflect the seeded values. Parsing is lax
+/// — an absent, blank, or unrecognised value is silently dropped and the page loads
+/// unfiltered, mirroring AuditLogPage 's drill-in convention.
+///
///
public partial class SiteCallsReport
{
private const int PageSize = 50;
+ [Inject] private NavigationManager Navigation { get; set; } = null!;
+
+ // The Status filter options — the exact strings the dropdown binds and
+ // the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
+ // filter when it matches one of these (case-insensitively); anything else is
+ // dropped so a hand-crafted bad URL still renders the page unfiltered.
+ private static readonly string[] ValidStatuses =
+ {
+ "Submitted", "Forwarded", "Attempted", "Delivered", "Parked", "Failed", "Discarded",
+ };
+
private ToastNotification _toast = default!;
private List _sites = new();
@@ -77,9 +100,51 @@ public partial class SiteCallsReport
Logger.LogWarning(ex, "Failed to load sites for the Site Calls source-site filter.");
}
+ // Seed filters from ?status= / ?stuck= BEFORE the first fetch so the initial
+ // grid load is already filtered (and the filter card controls reflect it).
+ ApplyQueryStringFilters();
+
await RefreshAll();
}
+ ///
+ /// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
+ /// string. ?status=<status> seeds when it
+ /// matches a known status (case-insensitive); ?stuck=true seeds
+ /// . Lax parsing — an absent, blank, or unrecognised value
+ /// is silently dropped, leaving the filter empty (the no-param behaviour).
+ ///
+ private void ApplyQueryStringFilters()
+ {
+ var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
+ var query = QueryHelpers.ParseQuery(uri.Query);
+
+ if (query.Count == 0)
+ {
+ return;
+ }
+
+ if (query.TryGetValue("status", out var statusValues))
+ {
+ var v = statusValues.ToString();
+ // Round-trip the dropdown's own option strings (the KPI tile emits the
+ // canonical casing, e.g. ?status=Parked); normalise to that casing so the
+ // binds. An unrecognised value leaves the filter unset.
+ var match = ValidStatuses.FirstOrDefault(
+ s => string.Equals(s, v?.Trim(), StringComparison.OrdinalIgnoreCase));
+ if (match is not null)
+ {
+ _statusFilter = match;
+ }
+ }
+
+ if (query.TryGetValue("stuck", out var stuckValues)
+ && bool.TryParse(stuckValues.ToString(), out var stuck))
+ {
+ _stuckOnly = stuck;
+ }
+ }
+
/// Re-fetch the current page (Refresh button, and after a relay action).
private async Task RefreshAll()
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs
index e4fa93d..6dd2852 100644
--- a/tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs
@@ -1,7 +1,9 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
+using Bunit.TestDoubles;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
@@ -411,6 +413,77 @@ public class SiteCallsReportPageTests : BunitContext
});
}
+ // ─────────────────────────────────────────────────────────────────────────
+ // Query-string drill-in — the Health-dashboard Site Call KPI tiles deep-link
+ // here with ?status=Parked (Parked tile) and ?stuck=true (Stuck tile). The
+ // params must seed the filter BEFORE the first query so the initial grid load
+ // is already filtered, and the filter card controls must reflect the values.
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked()
+ {
+ // The Parked KPI tile emits ?status=Parked — set the URI before render.
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+ nav.NavigateTo("/site-calls/report?status=Parked");
+
+ var cut = Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ // The first (and only) query the page issues carries the Parked
+ // status filter — the grid load is pre-filtered, not unfiltered.
+ Assert.Single(_queryRequests);
+ Assert.Equal("Parked", _queryRequests[0].StatusFilter);
+
+ // The Status control reflects the seeded value so the
+ // operator sees the filter and can Clear it.
+ var statusSelect = cut.Find("#sc-status");
+ Assert.Equal("Parked", statusSelect.GetAttribute("value"));
+ });
+ }
+
+ [Fact]
+ public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied()
+ {
+ // The Stuck KPI tile emits ?stuck=true.
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+ nav.NavigateTo("/site-calls/report?stuck=true");
+
+ var cut = Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ // The first query carries StuckOnly = true.
+ Assert.Single(_queryRequests);
+ Assert.True(_queryRequests[0].StuckOnly);
+
+ // The "Stuck only" checkbox is checked.
+ var stuckCheckbox = cut.Find("#sc-stuck-only");
+ Assert.True(stuckCheckbox.HasAttribute("checked"));
+ });
+ }
+
+ [Fact]
+ public void NavigateWithNoQueryParams_LoadsGridUnfiltered()
+ {
+ // No drill-in params — the page loads exactly as before: an unfiltered
+ // query and no status/stuck filter set on the controls.
+ var cut = Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Single(_queryRequests);
+ Assert.Null(_queryRequests[0].StatusFilter);
+ Assert.False(_queryRequests[0].StuckOnly);
+
+ var statusSelect = cut.Find("#sc-status");
+ Assert.True(string.IsNullOrEmpty(statusSelect.GetAttribute("value")));
+ var stuckCheckbox = cut.Find("#sc-stuck-only");
+ Assert.False(stuckCheckbox.HasAttribute("checked"));
+ });
+ }
+
protected override void Dispose(bool disposing)
{
if (disposing)