Files
Joseph Doherty 26ff8d9b4f Initial commit: JDE Scoping Tool migration project
Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
2026-01-02 07:43:29 -05:00

77 KiB

Web UI Specification

Purpose

This specification defines the Blazor WebAssembly user interface for the JDE Scoping Tool, mapping the legacy ASP.NET MVC 5 / Kendo UI implementation to modern Radzen Blazor components. The UI provides user authentication, search creation/management with multi-filter criteria, real-time status updates via SignalR, and data sync monitoring. The Blazor WASM client communicates with the .NET 10 backend API defined in the web-api-auth specification.

Source Reference

Legacy Files Purpose
OLD/WebInterface/Views/Account/Login.cshtml LDAP authentication form with username/password
OLD/WebInterface/Views/Account/NotAuthorized.cshtml Access denied message page
OLD/WebInterface/Views/Search/Index.cshtml User's searches list with Kendo grid, status, download
OLD/WebInterface/Views/Search/Create.cshtml Multi-filter search form with 8 filter types
OLD/WebInterface/Views/Search/Queue.cshtml Admin view of all queued searches
OLD/WebInterface/Views/RefreshStatus/Index.cshtml Data sync status dashboard
OLD/WebInterface/Views/Shared/_Layout.cshtml Navigation, header, SignalR connection
OLD/WebInterface/Scripts/model/models.js ValidCombination definitions for search types
OLD/WebInterface/Scripts/confirmationDialog.js Kendo confirmation dialog pattern
OLD/WebInterface/Scripts/kendoHelpers.js Kendo grid helper utilities

Component Mapping Reference

UI Element Legacy (Kendo/Bootstrap) Radzen Component Key Properties
Data grid kendoGrid RadzenDataGrid<T> AllowPaging, AllowSorting, PageSize, IsLoading
Dropdown select kendoDropDownList RadzenDropDown<T> Data, TextProperty, ValueProperty
Autocomplete combo kendoComboBox RadzenAutoComplete LoadData, MinLength, FilterCaseSensitivity
Date picker kendoDatePicker RadzenDatePicker DateFormat, Min, Max
Text input Bootstrap form-control RadzenTextBox Placeholder, Disabled
Password input Bootstrap form-control RadzenPassword Placeholder
Button Bootstrap btn RadzenButton Text, Icon, ButtonStyle, Click
Panel/Card Bootstrap panel RadzenCard -
Alert Bootstrap alert RadzenAlert AlertStyle, Title
Badge - RadzenBadge BadgeStyle, Text
Progress/Loading kendo.ui.progress() RadzenProgressBarCircular Mode="Indeterminate"
Confirmation dialog kendoAlert / custom RadzenDialog DialogService.Confirm()
File upload jQuery FileUpload RadzenUpload Url, Accept, Complete
Checkbox Bootstrap checkbox RadzenCheckBox @bind-Value
Form validation Kendo validator EditForm + DataAnnotationsValidator OnValidSubmit

Requirements

Requirement: Application Layout

The system SHALL provide a consistent layout with navigation header and content area.

Layout Structure

+------------------------------------------+
|  [Logo] JDE Scoping Tool    [User Menu]  |
+------------------------------------------+
|                                          |
|           Page Content Area              |
|                                          |
+------------------------------------------+
|  JDE Scoping Tool Version 5              |
+------------------------------------------+

Radzen Implementation

@* MainLayout.razor *@
@inherits LayoutComponentBase
@inject NavigationManager Navigation
@inject AuthStateProvider AuthState

<RadzenLayout>
    <RadzenHeader>
        <RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="1rem" class="rz-p-2">
            <RadzenLink Path="/" Text="JDE Scoping Tool" class="rz-text-h5" />
            <RadzenSpacer />
            <AuthorizeView>
                <Authorized>
                    <RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
                        <RadzenText Text="@context.User.Identity?.Name" />
                        <RadzenButton Text="Logout" ButtonStyle="ButtonStyle.Light" Click="@HandleLogout" />
                    </RadzenStack>
                </Authorized>
                <NotAuthorized>
                    <RadzenButton Text="Login" ButtonStyle="ButtonStyle.Primary" Click="@(() => Navigation.NavigateTo("/login"))" />
                </NotAuthorized>
            </AuthorizeView>
        </RadzenStack>
    </RadzenHeader>
    <RadzenBody>
        <RadzenContentContainer class="rz-p-4">
            @Body
        </RadzenContentContainer>
    </RadzenBody>
    <RadzenFooter>
        <RadzenText Text="JDE Scoping Tool Version 5" class="rz-text-center rz-p-2" />
    </RadzenFooter>
</RadzenLayout>

@code {
    private async Task HandleLogout()
    {
        await AuthState.LogoutAsync();
        Navigation.NavigateTo("/login");
    }
}

Business Rules

  • The system SHALL display the application title "JDE Scoping Tool" in the header
  • The system SHALL show the current user's username when authenticated
  • The system SHALL provide a Logout button when authenticated
  • The system SHALL provide a Login button when not authenticated
  • The system SHALL display the version number in the footer

Scenario: Authenticated user views layout

  • WHEN an authenticated user loads any page
  • THEN the header displays their username and a Logout button

Scenario: Unauthenticated user views layout

  • WHEN an unauthenticated user loads any page
  • THEN the header displays a Login button

Requirement: Login page

The system SHALL provide a login page for LDAP authentication.

Layout

  • Application title
  • Username field (text input)
  • Password field (password input)
  • Login button
  • Error message area

Radzen Implementation

@* Pages/Login.razor *@
@page "/login"
@inject IAuthService AuthService
@inject NavigationManager Navigation
@inject AuthStateProvider AuthState

<RadzenStack Gap="1rem" class="rz-p-4" Style="max-width: 400px; margin: 2rem auto;">
    <RadzenText TextStyle="TextStyle.H4" Text="Authentication Required" TextAlign="TextAlign.Center" />

    <RadzenCard>
        <EditForm Model="@loginModel" OnValidSubmit="@HandleLogin">
            <DataAnnotationsValidator />
            <RadzenStack Gap="1rem">
                @if (!string.IsNullOrEmpty(errorMessage))
                {
                    <RadzenAlert AlertStyle="AlertStyle.Danger" ShowIcon="true" Variant="Variant.Flat">
                        @errorMessage
                    </RadzenAlert>
                }

                <RadzenStack Gap="0.25rem">
                    <RadzenLabel Text="Username" Component="username" />
                    <RadzenTextBox @bind-Value="@loginModel.Username" Name="username"
                                   Placeholder="Enter username" Style="width: 100%;" />
                    <ValidationMessage For="@(() => loginModel.Username)" />
                </RadzenStack>

                <RadzenStack Gap="0.25rem">
                    <RadzenLabel Text="Password" Component="password" />
                    <RadzenPassword @bind-Value="@loginModel.Password" Name="password"
                                    Placeholder="Enter password" Style="width: 100%;" />
                    <ValidationMessage For="@(() => loginModel.Password)" />
                </RadzenStack>

                <RadzenButton Text="Login" ButtonType="ButtonType.Submit"
                              ButtonStyle="ButtonStyle.Primary" Style="width: 100%;"
                              IsBusy="@isLoading" BusyText="Authenticating..." />
            </RadzenStack>
        </EditForm>
    </RadzenCard>
</RadzenStack>

@code {
    private LoginModel loginModel = new();
    private string? errorMessage;
    private bool isLoading;

    [SupplyParameterFromQuery]
    public string? ReturnUrl { get; set; }

    private async Task HandleLogin()
    {
        isLoading = true;
        errorMessage = null;

        try
        {
            var result = await AuthService.LoginAsync(loginModel.Username, loginModel.Password);
            if (result.Success)
            {
                await AuthState.NotifyAuthenticationStateChanged();
                Navigation.NavigateTo(ReturnUrl ?? "/");
            }
            else
            {
                errorMessage = result.ErrorMessage ?? "Authentication failed";
            }
        }
        catch (Exception ex)
        {
            errorMessage = "An error occurred during authentication";
        }
        finally
        {
            isLoading = false;
        }
    }

    public class LoginModel
    {
        [Required(ErrorMessage = "Username is required")]
        public string Username { get; set; } = string.Empty;

        [Required(ErrorMessage = "Password is required")]
        public string Password { get; set; } = string.Empty;
    }
}

Business Rules

  • The system SHALL display "Authentication Required" as the page title
  • The system SHALL require both username and password fields
  • The system SHALL display validation errors for empty fields
  • The system SHALL display authentication errors from the server
  • The system SHALL redirect to the return URL on successful login
  • The system SHALL disable the form and show loading state during authentication

Scenario: Successful login

  • WHEN user enters valid credentials and clicks Login
  • THEN user is authenticated and redirected to the Search List page (or return URL)

Scenario: Failed login with invalid credentials

  • WHEN user enters invalid credentials and clicks Login
  • THEN an error message is displayed: "Incorrect username or password"

Scenario: Failed login - not in required group

  • WHEN user enters valid credentials but is not in the required LDAP group
  • THEN an error message is displayed: "User is not a member of the required security group"

Requirement: Not Authorized page

The system SHALL provide an access denied message page.

Layout

  • Error title
  • Error message with resource URL
  • Link to return to home or login

Radzen Implementation

@* Pages/NotAuthorized.razor *@
@page "/not-authorized"
@inject NavigationManager Navigation

<RadzenStack Gap="1rem" class="rz-p-4" Style="max-width: 600px; margin: 2rem auto;">
    <RadzenText TextStyle="TextStyle.H4" Text="Authorization Error" TextAlign="TextAlign.Center" />

    <RadzenAlert AlertStyle="AlertStyle.Danger" ShowIcon="true" Variant="Variant.Flat">
        <RadzenText>
            You are not authorized to use the resource at
            <RadzenLink Path="@ResourceUrl" Text="@ResourceUrl" Target="_self" />.
        </RadzenText>
    </RadzenAlert>

    <RadzenStack Orientation="Orientation.Horizontal" JustifyContent="JustifyContent.Center" Gap="1rem">
        <RadzenButton Text="Go to Home" ButtonStyle="ButtonStyle.Primary" Click="@(() => Navigation.NavigateTo("/"))" />
        <RadzenButton Text="Login" ButtonStyle="ButtonStyle.Secondary" Click="@(() => Navigation.NavigateTo("/login"))" />
    </RadzenStack>
</RadzenStack>

@code {
    [SupplyParameterFromQuery]
    public string? ResourceUrl { get; set; }
}

Business Rules

  • The system SHALL display "Authorization Error" as the page title
  • The system SHALL display the resource URL the user attempted to access
  • The system SHALL provide navigation options to return home or login

Scenario: User views not authorized page

  • WHEN a user is redirected to the not authorized page
  • THEN the page displays the resource URL they attempted to access

Requirement: Search list page

The system SHALL provide a page displaying the current user's searches with status and actions.

Layout

  • Page title with action buttons (New Search, View Queue)
  • Data grid with columns: Name, Submitted, Status, Actions
  • Real-time status updates via SignalR

Radzen Implementation

@* Pages/Searches.razor *@
@page "/"
@page "/searches"
@attribute [Authorize]
@inject ISearchService SearchService
@inject NavigationManager Navigation
@inject IHubConnectionService HubService
@implements IAsyncDisposable

<RadzenStack Gap="1rem">
    <RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="1rem">
        <RadzenText TextStyle="TextStyle.H4" Text="Searches" />
        <RadzenButton Text="Start New Search" Icon="add" ButtonStyle="ButtonStyle.Primary"
                      Click="@(() => Navigation.NavigateTo("/search/create"))" />
        <RadzenButton Text="View Search Queue" Icon="queue" ButtonStyle="ButtonStyle.Light"
                      Click="@(() => Navigation.NavigateTo("/search/queue"))" />
    </RadzenStack>

    <RadzenDataGrid @ref="grid" Data="@searches" TItem="SearchViewModel"
                    AllowPaging="true" PageSize="10" AllowSorting="true"
                    IsLoading="@isLoading" RowDoubleClick="@OnRowDoubleClick"
                    Style="min-height: 450px;">
        <Columns>
            <RadzenDataGridColumn TItem="SearchViewModel" Property="Name" Title="Name" />
            <RadzenDataGridColumn TItem="SearchViewModel" Property="SubmitDT" Title="Submitted" Width="180px">
                <Template Context="search">
                    @(search.SubmitDT?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
                </Template>
            </RadzenDataGridColumn>
            <RadzenDataGridColumn TItem="SearchViewModel" Property="Status" Title="Status" Width="120px">
                <Template Context="search">
                    <RadzenBadge BadgeStyle="@GetStatusStyle(search.Status)" Text="@search.Status.ToString()" />
                </Template>
            </RadzenDataGridColumn>
            <RadzenDataGridColumn TItem="SearchViewModel" Title="Actions" Width="100px" Sortable="false">
                <Template Context="search">
                    <RadzenButton Icon="visibility" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
                                  Click="@(() => ViewSearch(search.ID))" title="View" />
                </Template>
            </RadzenDataGridColumn>
        </Columns>
    </RadzenDataGrid>
</RadzenStack>

@code {
    private RadzenDataGrid<SearchViewModel>? grid;
    private List<SearchViewModel> searches = new();
    private bool isLoading = true;

    protected override async Task OnInitializedAsync()
    {
        await LoadSearches();
        await SubscribeToUpdates();
    }

    private async Task LoadSearches()
    {
        isLoading = true;
        try
        {
            searches = await SearchService.GetUserSearchesAsync();
        }
        finally
        {
            isLoading = false;
        }
    }

    private async Task SubscribeToUpdates()
    {
        await HubService.StartAsync();
        HubService.OnSearchUpdate += HandleSearchUpdate;
    }

    private async void HandleSearchUpdate(SearchUpdate update)
    {
        // Only process updates for current user
        var currentUser = await GetCurrentUsername();
        if (update.UserName != currentUser) return;

        await InvokeAsync(() =>
        {
            var existing = searches.FirstOrDefault(s => s.ID == update.ID);
            if (existing != null)
            {
                existing.Status = update.Status;
                existing.SubmitDT = update.SubmitDT;
                existing.StartDT = update.StartDT;
                existing.EndDT = update.EndDT;
            }
            else
            {
                searches.Insert(0, new SearchViewModel
                {
                    ID = update.ID,
                    Name = update.Name,
                    Status = update.Status,
                    SubmitDT = update.SubmitDT
                });
            }
            StateHasChanged();
        });
    }

    private void OnRowDoubleClick(DataGridRowMouseEventArgs<SearchViewModel> args)
    {
        ViewSearch(args.Data.ID);
    }

    private void ViewSearch(int id)
    {
        Navigation.NavigateTo($"/search/{id}");
    }

    // SearchStatus values from JdeScoping.Core.Models namespace
    private BadgeStyle GetStatusStyle(SearchStatus status) => status switch
    {
        SearchStatus.New => BadgeStyle.Info,
        SearchStatus.Queued => BadgeStyle.Warning,      // alias for Submitted
        SearchStatus.Processing => BadgeStyle.Primary,  // alias for Started
        SearchStatus.Completed => BadgeStyle.Success,   // alias for Ended
        SearchStatus.Failed => BadgeStyle.Danger,       // alias for Error
        _ => BadgeStyle.Light
    };

    private Task<string> GetCurrentUsername() => Task.FromResult("currentuser"); // From AuthState

    public async ValueTask DisposeAsync()
    {
        HubService.OnSearchUpdate -= HandleSearchUpdate;
        await HubService.StopAsync();
    }
}

Business Rules

  • The system SHALL display only searches owned by the current user
  • The system SHALL sort searches by SubmitDT descending (most recent first)
  • The system SHALL update the grid in real-time when SignalR updates are received
  • The system SHALL filter SignalR updates to only process those for the current user
  • The system SHALL allow double-click on a row to open the search detail
  • The system SHALL display status badges with appropriate colors

Scenario: User views their search history

  • WHEN an authenticated user navigates to the search list page
  • THEN the grid displays their searches ordered by most recent first

Scenario: Real-time status update received

  • WHEN a SignalR searchUpdate event is received for the current user
  • THEN the grid updates the matching row or adds a new row

Scenario: User opens search detail

  • WHEN user double-clicks a row or clicks the View button
  • THEN the search detail page opens in the same window

Requirement: Search create/edit page

The system SHALL provide a multi-filter search form with 8 filter types based on the selected search type.

Search Types (Valid Combinations)

ID Name Filters Enabled
10 Work Order WorkOrder
20 Component Lot ComponentLot
30 Time Span + Profit Center TimeSpan, ProfitCenter
40 Time Span + Work Center TimeSpan, WorkCenter
50 Time Span + Operator TimeSpan, Operator
60 Time Span + Profit Center + Item Number TimeSpan, ProfitCenter, ItemNumber
70 Time Span + Profit Center + Item/Operation/MIS TimeSpan, ProfitCenter, ItemOperationMis
80 Time Span + Profit Center + Work Order + Item/Operation/MIS TimeSpan, ProfitCenter, WorkOrder, ItemOperationMis
90 Time Span + Profit Center + Extract MIS TimeSpan, ProfitCenter, ExtractMis
100 Time Span + Work Center + Item Number TimeSpan, WorkCenter, ItemNumber
110 Time Span + Work Center + Extract MIS TimeSpan, WorkCenter, ExtractMis
120 Time Span + Work Center + Item/Operation/MIS TimeSpan, WorkCenter, ItemOperationMis
130 Time Span + Work Center + Work Order + Item/Operation/MIS TimeSpan, WorkCenter, WorkOrder, ItemOperationMis
140 Time Span + Item Number TimeSpan, ItemNumber
150 Time Span + Work Center + Operator TimeSpan, WorkCenter, Operator
160 Time Span + Profit Center + Operator TimeSpan, ProfitCenter, Operator

Layout Structure

+------------------------------------------+
| Search  [Submit]                         |
+------------------------------------------+
| [Read-only notice - if submitted]        |
| [Copy] button                            |
+------------------------------------------+
| Search Details Panel                     |
| - Search Type dropdown                   |
| - Name text field                        |
| - Submitted/Started/Completed (readonly) |
| - User (readonly)                        |
| - Status (readonly with color)           |
| - [Download Results] (if complete)       |
+------------------------------------------+
| Filter Panels (shown based on type):     |
| - Timespan Filter                        |
| - Work Order Filter                      |
| - Item Number Filter                     |
| - Profit Center Filter                   |
| - Work Center Filter                     |
| - Component Lot Filter                   |
| - Operator Filter                        |
| - Part/Operation/MIS Filter              |
| - Extract MIS Data (checkbox)            |
+------------------------------------------+

Radzen Implementation - Main Component

@* Pages/SearchEdit.razor *@
@page "/search/create"
@page "/search/{Id:int}"
@attribute [Authorize]
@inject ISearchService SearchService
@inject IFileService FileService
@inject NavigationManager Navigation
@inject DialogService DialogService
@inject NotificationService NotificationService
@inject IHubConnectionService HubService

<RadzenStack Gap="1rem">
    <RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="1rem">
        <RadzenText TextStyle="TextStyle.H4" Text="Search" />
        @if (!isReadOnly)
        {
            <RadzenButton Text="Submit" Icon="send" ButtonStyle="ButtonStyle.Primary"
                          Click="@HandleSubmit" IsBusy="@isSubmitting" />
        }
    </RadzenStack>

    @if (isLoading)
    {
        <RadzenProgressBarCircular Mode="ProgressBarMode.Indeterminate" />
    }
    else
    {
        @* Read-only notice *@
        @if (isReadOnly)
        {
            <RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat">
                <strong>Note:</strong> search is readonly because it has already been submitted.
                To change or re-run the search again click the Copy button.
                <RadzenButton Text="Copy" ButtonStyle="ButtonStyle.Secondary" Size="ButtonSize.Small"
                              Click="@HandleCopy" class="rz-ml-2" />
            </RadzenAlert>
        }

        @* Search Details Panel *@
        <RadzenCard>
            <RadzenStack Gap="1rem">
                <RadzenText TextStyle="TextStyle.H6" Text="Search Details" />

                <RadzenRow Gap="1rem">
                    <RadzenColumn Size="12">
                        <RadzenStack Gap="0.25rem">
                            <RadzenLabel Text="Search Type" />
                            <RadzenDropDown @bind-Value="@selectedSearchType" Data="@searchTypes"
                                            TextProperty="Name" ValueProperty="ID"
                                            Placeholder="Select type" Style="width: 100%;"
                                            Disabled="@isReadOnly" Change="@OnSearchTypeChanged" />
                        </RadzenStack>
                    </RadzenColumn>
                </RadzenRow>

                <RadzenRow Gap="1rem">
                    <RadzenColumn Size="12">
                        <RadzenStack Gap="0.25rem">
                            <RadzenLabel Text="Name" />
                            <RadzenTextBox @bind-Value="@searchModel.Name" Placeholder="Enter search name"
                                           Style="width: 100%;" Disabled="@isReadOnly" />
                        </RadzenStack>
                    </RadzenColumn>
                </RadzenRow>

                <RadzenRow Gap="1rem">
                    <RadzenColumn Size="4">
                        <RadzenStack Gap="0.25rem">
                            <RadzenLabel Text="Submitted At" />
                            <RadzenTextBox Value="@(searchModel.SubmitDT?.ToString("MM/dd/yyyy hh:mm:ss tt"))"
                                           ReadOnly="true" Style="width: 100%;" />
                        </RadzenStack>
                    </RadzenColumn>
                    <RadzenColumn Size="4">
                        <RadzenStack Gap="0.25rem">
                            <RadzenLabel Text="Started At" />
                            <RadzenTextBox Value="@(searchModel.StartDT?.ToString("MM/dd/yyyy hh:mm:ss tt"))"
                                           ReadOnly="true" Style="width: 100%;" />
                        </RadzenStack>
                    </RadzenColumn>
                    <RadzenColumn Size="4">
                        <RadzenStack Gap="0.25rem">
                            <RadzenLabel Text="Completed At" />
                            <RadzenTextBox Value="@(searchModel.EndDT?.ToString("MM/dd/yyyy hh:mm:ss tt"))"
                                           ReadOnly="true" Style="width: 100%;" />
                        </RadzenStack>
                    </RadzenColumn>
                </RadzenRow>

                <RadzenRow Gap="1rem">
                    <RadzenColumn Size="4">
                        <RadzenStack Gap="0.25rem">
                            <RadzenLabel Text="User" />
                            <RadzenTextBox Value="@searchModel.UserName" ReadOnly="true" Style="width: 100%;" />
                        </RadzenStack>
                    </RadzenColumn>
                    <RadzenColumn Size="4">
                        <RadzenStack Gap="0.25rem">
                            <RadzenLabel Text="Status" />
                            <RadzenTextBox Value="@searchModel.Status.ToString()" ReadOnly="true"
                                           Style="@GetStatusStyle()" />
                        </RadzenStack>
                    </RadzenColumn>
                    <RadzenColumn Size="4">
                        @if (searchModel.Status == SearchStatus.Ended)
                        {
                            <RadzenStack Gap="0.25rem">
                                <RadzenLabel Text=" " />
                                <RadzenButton Text="Download Results" Icon="download"
                                              ButtonStyle="ButtonStyle.Success" Style="width: 100%;"
                                              Click="@HandleDownloadResults" />
                            </RadzenStack>
                        }
                    </RadzenColumn>
                </RadzenRow>
            </RadzenStack>
        </RadzenCard>

        @* Filter Panels - shown conditionally based on search type *@
        @if (showTimeSpanFilter)
        {
            <TimeSpanFilterPanel @bind-MinimumDT="@searchModel.Criteria.MinimumDT"
                                 @bind-MaximumDT="@searchModel.Criteria.MaximumDT"
                                 IsReadOnly="@isReadOnly" />
        }

        @if (showWorkOrderFilter)
        {
            <WorkOrderFilterPanel @bind-WorkOrders="@searchModel.Criteria.WorkOrders"
                                  IsReadOnly="@isReadOnly" FileService="@FileService" />
        }

        @if (showItemNumberFilter)
        {
            <ItemNumberFilterPanel @bind-Items="@searchModel.Criteria.Items"
                                   IsReadOnly="@isReadOnly" FileService="@FileService" />
        }

        @if (showProfitCenterFilter)
        {
            <ProfitCenterFilterPanel @bind-ProfitCenters="@searchModel.Criteria.ProfitCenters"
                                     IsReadOnly="@isReadOnly" />
        }

        @if (showWorkCenterFilter)
        {
            <WorkCenterFilterPanel @bind-WorkCenters="@searchModel.Criteria.WorkCenters"
                                   IsReadOnly="@isReadOnly" />
        }

        @if (showComponentLotFilter)
        {
            <ComponentLotFilterPanel @bind-ComponentLots="@searchModel.Criteria.ComponentLots"
                                     IsReadOnly="@isReadOnly" FileService="@FileService" />
        }

        @if (showOperatorFilter)
        {
            <OperatorFilterPanel @bind-Operators="@searchModel.Criteria.Operators"
                                 IsReadOnly="@isReadOnly" />
        }

        @if (showPartOperationFilter)
        {
            <PartOperationFilterPanel @bind-PartOperations="@searchModel.Criteria.PartOperations"
                                      IsReadOnly="@isReadOnly" FileService="@FileService" />
        }

        @if (showExtractMisData)
        {
            <RadzenCard>
                <RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
                    <RadzenCheckBox @bind-Value="@searchModel.Criteria.ExtractMisData" Disabled="true" />
                    <RadzenLabel Text="Extract MIS data" />
                </RadzenStack>
            </RadzenCard>
        }
    }
</RadzenStack>

@code {
    [Parameter] public int? Id { get; set; }
    [SupplyParameterFromQuery] public int? CopySearchID { get; set; }

    private SearchViewModel searchModel = new();
    private List<ValidCombination> searchTypes = ValidCombination.GetAll();
    private int? selectedSearchType;
    private bool isLoading = true;
    private bool isSubmitting;
    private bool isReadOnly;

    // Filter visibility flags
    private bool showTimeSpanFilter;
    private bool showWorkOrderFilter;
    private bool showItemNumberFilter;
    private bool showProfitCenterFilter;
    private bool showWorkCenterFilter;
    private bool showComponentLotFilter;
    private bool showOperatorFilter;
    private bool showPartOperationFilter;
    private bool showExtractMisData;

    protected override async Task OnInitializedAsync()
    {
        await LoadSearch();
        await SubscribeToUpdates();
    }

    private async Task LoadSearch()
    {
        isLoading = true;
        try
        {
            if (CopySearchID.HasValue)
            {
                searchModel = await SearchService.CopySearchAsync(CopySearchID.Value);
                isReadOnly = false;
            }
            else if (Id.HasValue)
            {
                searchModel = await SearchService.GetSearchAsync(Id.Value);
                isReadOnly = searchModel.Status != SearchStatus.New;
            }
            else
            {
                searchModel = new SearchViewModel();
                isReadOnly = false;
            }

            DetectSearchType();
        }
        finally
        {
            isLoading = false;
        }
    }

    private void DetectSearchType()
    {
        // Find matching search type based on which filters have data
        var match = searchTypes.FirstOrDefault(st => st.Matches(
            searchModel.Criteria.MinimumDT.HasValue || searchModel.Criteria.MaximumDT.HasValue,
            searchModel.Criteria.WorkOrders.Any(),
            searchModel.Criteria.Items.Any(),
            searchModel.Criteria.ProfitCenters.Any(),
            searchModel.Criteria.WorkCenters.Any(),
            searchModel.Criteria.ComponentLots.Any(),
            searchModel.Criteria.Operators.Any(),
            searchModel.Criteria.PartOperations.Any(),
            searchModel.Criteria.ExtractMisData
        ));

        if (match != null)
        {
            selectedSearchType = match.ID;
            UpdateFilterVisibility(match);
        }
    }

    private void OnSearchTypeChanged()
    {
        var searchType = searchTypes.FirstOrDefault(st => st.ID == selectedSearchType);
        if (searchType != null)
        {
            UpdateFilterVisibility(searchType);
        }
    }

    private void UpdateFilterVisibility(ValidCombination searchType)
    {
        showTimeSpanFilter = searchType.Timespan;
        showWorkOrderFilter = searchType.WorkOrder;
        showItemNumberFilter = searchType.ItemNumber;
        showProfitCenterFilter = searchType.ProfitCenter;
        showWorkCenterFilter = searchType.WorkCenter;
        showComponentLotFilter = searchType.ComponentLot;
        showOperatorFilter = searchType.Operator;
        showPartOperationFilter = searchType.ItemOperationMis;
        showExtractMisData = searchType.ExtractMis;

        // Set ExtractMisData flag if search type requires it
        searchModel.Criteria.ExtractMisData = searchType.ExtractMis;
    }

    private async Task HandleSubmit()
    {
        // Validate filters
        if (!ValidateFilters()) return;

        var confirmed = await DialogService.Confirm(
            "Are you sure you want to submit the search?",
            "Confirm Submit",
            new ConfirmOptions { OkButtonText = "OK", CancelButtonText = "Cancel" });

        if (confirmed != true) return;

        isSubmitting = true;
        try
        {
            var newId = await SearchService.SaveSearchAsync(searchModel);
            Navigation.NavigateTo($"/search/{newId}");
        }
        catch (Exception ex)
        {
            NotificationService.Notify(NotificationSeverity.Error, "Error", "Failed to submit search");
        }
        finally
        {
            isSubmitting = false;
        }
    }

    private bool ValidateFilters()
    {
        if (string.IsNullOrWhiteSpace(searchModel.Name))
        {
            NotificationService.Notify(NotificationSeverity.Warning, "Validation Error", "Name is required");
            return false;
        }

        if (selectedSearchType == null)
        {
            NotificationService.Notify(NotificationSeverity.Warning, "Validation Error", "Search Type is required");
            return false;
        }

        if (showWorkOrderFilter && !searchModel.Criteria.WorkOrders.Any())
        {
            NotificationService.Notify(NotificationSeverity.Warning, "Validation Error",
                "At least one work order must be specified");
            return false;
        }

        if (showItemNumberFilter && !searchModel.Criteria.Items.Any())
        {
            NotificationService.Notify(NotificationSeverity.Warning, "Validation Error",
                "At least one item number must be specified");
            return false;
        }

        if (showProfitCenterFilter && !searchModel.Criteria.ProfitCenters.Any())
        {
            NotificationService.Notify(NotificationSeverity.Warning, "Validation Error",
                "At least one profit center must be specified");
            return false;
        }

        if (showWorkCenterFilter && !searchModel.Criteria.WorkCenters.Any())
        {
            NotificationService.Notify(NotificationSeverity.Warning, "Validation Error",
                "At least one work center must be specified");
            return false;
        }

        if (showComponentLotFilter && !searchModel.Criteria.ComponentLots.Any())
        {
            NotificationService.Notify(NotificationSeverity.Warning, "Validation Error",
                "At least one component lot must be specified");
            return false;
        }

        if (showOperatorFilter && !searchModel.Criteria.Operators.Any())
        {
            NotificationService.Notify(NotificationSeverity.Warning, "Validation Error",
                "At least one operator must be specified");
            return false;
        }

        if (showPartOperationFilter && !searchModel.Criteria.PartOperations.Any())
        {
            NotificationService.Notify(NotificationSeverity.Warning, "Validation Error",
                "At least one item/operation/MIS entry must be specified");
            return false;
        }

        return true;
    }

    private async Task HandleCopy()
    {
        Navigation.NavigateTo($"/search/create?copySearchID={Id}");
    }

    private async Task HandleDownloadResults()
    {
        await SearchService.DownloadResultsAsync(searchModel.ID);
    }

    private string GetStatusStyle()
    {
        var bgColor = searchModel.Status == SearchStatus.Error ? "#FF6347" : "#eee";
        return $"width: 100%; background-color: {bgColor};";
    }

    private async Task SubscribeToUpdates()
    {
        await HubService.StartAsync();
        HubService.OnSearchUpdate += HandleSearchUpdate;
    }

    private async void HandleSearchUpdate(SearchUpdate update)
    {
        if (update.ID != searchModel.ID) return;

        await InvokeAsync(() =>
        {
            searchModel.Status = update.Status;
            searchModel.SubmitDT = update.SubmitDT;
            searchModel.StartDT = update.StartDT;
            searchModel.EndDT = update.EndDT;

            if (update.Status != SearchStatus.New)
            {
                isReadOnly = true;
            }

            StateHasChanged();
        });
    }
}

Business Rules

  • The system SHALL detect the search type based on populated filter criteria when loading an existing search
  • The system SHALL show/hide filter panels based on the selected search type
  • The system SHALL mark the form as read-only if status is not "New"
  • The system SHALL provide a Copy button to create a new search from an existing one
  • The system SHALL validate that required filters have at least one entry before submit
  • The system SHALL show a confirmation dialog before submitting
  • The system SHALL update the UI in real-time via SignalR when search status changes
  • WHEN user navigates to /search/create
  • THEN an empty form is displayed with Search Type dropdown enabled
  • WHEN user navigates to /search/123 where search has status "Submitted"
  • THEN the form loads in read-only mode with the Copy button visible
  • WHEN user clicks Copy on a read-only search
  • THEN a new editable search is created with the same criteria

Scenario: Filter validation fails

  • WHEN user selects "Work Order" search type but adds no work orders and clicks Submit
  • THEN a validation error is displayed: "At least one work order must be specified"

Requirement: TimeSpan Filter Panel Component

The system SHALL provide a date range filter with min/max date pickers.

Radzen Implementation

@* Components/TimeSpanFilterPanel.razor *@
<RadzenCard>
    <RadzenStack Gap="1rem">
        <RadzenText TextStyle="TextStyle.H6" Text="Filter by timespan" />

        <RadzenRow Gap="1rem">
            <RadzenColumn Size="5">
                <RadzenStack Gap="0.25rem">
                    <RadzenLabel Text="Min Date" />
                    <RadzenDatePicker @bind-Value="@MinimumDT" DateFormat="MM/dd/yyyy"
                                      Min="@(new DateTime(2002, 11, 1))" Max="@DateTime.Today"
                                      Style="width: 100%;" Disabled="@IsReadOnly"
                                      Change="@OnMinDateChanged" />
                </RadzenStack>
            </RadzenColumn>
            <RadzenColumn Size="2" />
            <RadzenColumn Size="5">
                <RadzenStack Gap="0.25rem">
                    <RadzenLabel Text="Max Date" />
                    <RadzenDatePicker @bind-Value="@MaximumDT" DateFormat="MM/dd/yyyy"
                                      Min="@minMaxDate" Max="@DateTime.Today"
                                      Style="width: 100%;" Disabled="@IsReadOnly"
                                      Change="@OnMaxDateChanged" />
                </RadzenStack>
            </RadzenColumn>
        </RadzenRow>
    </RadzenStack>
</RadzenCard>

@code {
    [Parameter] public DateTime? MinimumDT { get; set; }
    [Parameter] public EventCallback<DateTime?> MinimumDTChanged { get; set; }
    [Parameter] public DateTime? MaximumDT { get; set; }
    [Parameter] public EventCallback<DateTime?> MaximumDTChanged { get; set; }
    [Parameter] public bool IsReadOnly { get; set; }

    private DateTime minMaxDate = new DateTime(2002, 11, 1);

    private async Task OnMinDateChanged()
    {
        await MinimumDTChanged.InvokeAsync(MinimumDT);
        if (MinimumDT.HasValue)
        {
            minMaxDate = MinimumDT.Value;
        }
    }

    private async Task OnMaxDateChanged()
    {
        await MaximumDTChanged.InvokeAsync(MaximumDT);
    }
}

Business Rules

  • The system SHALL enforce min date >= 2002-11-01 (November 1, 2002)
  • The system SHALL enforce max date <= today
  • The system SHALL update max date picker's min value when min date changes

Requirement: Item Number Filter Panel Component

The system SHALL provide an autocomplete search with grid of selected items.

Radzen Implementation

@* Components/ItemNumberFilterPanel.razor *@
@inject ILookupService LookupService

<RadzenCard>
    <RadzenStack Gap="1rem">
        <RadzenStack Orientation="Orientation.Horizontal" JustifyContent="JustifyContent.SpaceBetween"
                     AlignItems="AlignItems.Center">
            <RadzenText TextStyle="TextStyle.H6" Text="Filter by item number" />
            @if (!IsReadOnly)
            {
                <RadzenStack Orientation="Orientation.Horizontal" Gap="0.5rem">
                    <RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light"
                                  Size="ButtonSize.Small" Click="@HandleDownloadTemplate" />
                    <RadzenUpload Url="@uploadUrl" Accept=".xlsx,.xls" Auto="true"
                                  Complete="@OnUploadComplete" class="rz-button rz-button-sm">
                        Upload Data
                    </RadzenUpload>
                    <RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light"
                                  Size="ButtonSize.Small" Click="@HandleClear" />
                </RadzenStack>
            }
        </RadzenStack>

        @if (!IsReadOnly)
        {
            <RadzenRow Gap="1rem" AlignItems="AlignItems.End">
                <RadzenColumn Size="8">
                    <RadzenStack Gap="0.25rem">
                        <RadzenLabel Text="Item Number" />
                        <RadzenAutoComplete @bind-Value="@selectedItemText" Data="@itemSuggestions"
                                            LoadData="@LoadItemSuggestions" MinLength="3"
                                            TextProperty="DisplayText" Style="width: 100%;"
                                            Placeholder="Type to search..." />
                    </RadzenStack>
                </RadzenColumn>
                <RadzenColumn Size="4">
                    <RadzenButton Text="Add to filter" Icon="add" ButtonStyle="ButtonStyle.Primary"
                                  Click="@HandleAddItem" Visible="@(!string.IsNullOrEmpty(selectedItemText))" />
                </RadzenColumn>
            </RadzenRow>
        }

        <RadzenDataGrid Data="@Items" TItem="ItemViewModel" AllowSorting="true" Style="min-height: 50px;">
            <Columns>
                <RadzenDataGridColumn TItem="ItemViewModel" Property="ItemNumber" Title="Item Number" />
                <RadzenDataGridColumn TItem="ItemViewModel" Property="Description" Title="Description" />
                @if (!IsReadOnly)
                {
                    <RadzenDataGridColumn TItem="ItemViewModel" Title="Actions" Width="100px">
                        <Template Context="item">
                            <RadzenButton Icon="delete" ButtonStyle="ButtonStyle.Danger" Size="ButtonSize.Small"
                                          Click="@(() => HandleDeleteItem(item))" />
                        </Template>
                    </RadzenDataGridColumn>
                }
            </Columns>
        </RadzenDataGrid>

        <RadzenText Text="@($"# of item numbers: {Items.Count}")" Style="font-weight: bold;" />
    </RadzenStack>
</RadzenCard>

@code {
    [Parameter] public List<ItemViewModel> Items { get; set; } = new();
    [Parameter] public EventCallback<List<ItemViewModel>> ItemsChanged { get; set; }
    [Parameter] public bool IsReadOnly { get; set; }
    [Parameter] public IFileService FileService { get; set; } = default!;

    private string? selectedItemText;
    private ItemViewModel? selectedItem;
    private IEnumerable<ItemSuggestion> itemSuggestions = Enumerable.Empty<ItemSuggestion>();
    private string uploadUrl => "/api/file/part-numbers/upload";

    private async Task LoadItemSuggestions(LoadDataArgs args)
    {
        if (string.IsNullOrWhiteSpace(args.Filter) || args.Filter.Length < 3) return;

        var results = await LookupService.FindItemsAsync(args.Filter);
        itemSuggestions = results.Select(i => new ItemSuggestion
        {
            ItemNumber = i.ItemNumber,
            Description = i.Description,
            DisplayText = $"{i.ItemNumber} - {i.Description}"
        });
    }

    private async Task HandleAddItem()
    {
        if (string.IsNullOrEmpty(selectedItemText)) return;

        var match = itemSuggestions.FirstOrDefault(i => i.DisplayText == selectedItemText);
        if (match != null && !Items.Any(i => i.ItemNumber == match.ItemNumber))
        {
            Items.Add(new ItemViewModel { ItemNumber = match.ItemNumber, Description = match.Description });
            await ItemsChanged.InvokeAsync(Items);
        }
        selectedItemText = null;
    }

    private async Task HandleDeleteItem(ItemViewModel item)
    {
        Items.Remove(item);
        await ItemsChanged.InvokeAsync(Items);
    }

    private async Task HandleClear()
    {
        // F9.6: Confirmation required before clearing filter data
        var confirmed = await DialogService.Confirm(
            "Are you sure you want to clear all items from this filter?",
            "Confirm Clear",
            new ConfirmOptions { OkButtonText = "Clear", CancelButtonText = "Cancel" });
        if (confirmed != true) return;

        Items.Clear();
        await ItemsChanged.InvokeAsync(Items);
    }

    private async Task HandleDownloadTemplate()
    {
        await FileService.DownloadPartNumberTemplateAsync(Items);
    }

    private async Task OnUploadComplete(UploadCompleteEventArgs args)
    {
        // Parse uploaded items from response
        var result = System.Text.Json.JsonSerializer.Deserialize<FileUploadResult<ItemViewModel>>(args.RawResponse);
        if (result?.WasSuccessful == true && result.Data != null)
        {
            Items.Clear();
            Items.AddRange(result.Data);
            await ItemsChanged.InvokeAsync(Items);
        }
    }

    private class ItemSuggestion
    {
        public string ItemNumber { get; set; } = string.Empty;
        public string Description { get; set; } = string.Empty;
        public string DisplayText { get; set; } = string.Empty;
    }
}

Business Rules

  • The system SHALL require at least 3 characters before searching
  • The system SHALL display item number and description in autocomplete dropdown
  • The system SHALL prevent duplicate items in the filter list
  • The system SHALL support bulk upload via Excel file
  • The system SHALL support template download with current data

Requirement: Profit Center / Work Center / Operator Filter Panel Components

The system SHALL provide filter panels for Profit Center, Work Center, and Operator selection that follow the same pattern as the Item Number filter with autocomplete and grid.

Radzen Implementation Pattern

@* Components/ProfitCenterFilterPanel.razor - Similar structure *@
@inject ILookupService LookupService

<RadzenCard>
    <RadzenStack Gap="1rem">
        <RadzenStack Orientation="Orientation.Horizontal" JustifyContent="JustifyContent.SpaceBetween">
            <RadzenText TextStyle="TextStyle.H6" Text="Filter by profit center" />
            @if (!IsReadOnly)
            {
                <RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light"
                              Size="ButtonSize.Small" Click="@HandleClear" />
            }
        </RadzenStack>

        @if (!IsReadOnly)
        {
            <RadzenRow Gap="1rem" AlignItems="AlignItems.End">
                <RadzenColumn Size="8">
                    <RadzenAutoComplete @bind-Value="@selectedText" Data="@suggestions"
                                        LoadData="@LoadSuggestions" MinLength="3"
                                        TextProperty="DisplayText" Style="width: 100%;" />
                </RadzenColumn>
                <RadzenColumn Size="4">
                    <RadzenButton Text="Add to filter" Click="@HandleAdd"
                                  Visible="@(!string.IsNullOrEmpty(selectedText))" />
                </RadzenColumn>
            </RadzenRow>
        }

        <RadzenDataGrid Data="@ProfitCenters" TItem="ProfitCenterViewModel">
            <Columns>
                <RadzenDataGridColumn Property="Code" Title="Profit Center" />
                <RadzenDataGridColumn Property="Description" Title="Description" />
                @if (!IsReadOnly)
                {
                    <RadzenDataGridColumn Title="Actions" Width="100px">
                        <Template Context="item">
                            <RadzenButton Icon="delete" Size="ButtonSize.Small" Click="@(() => HandleDelete(item))" />
                        </Template>
                    </RadzenDataGridColumn>
                }
            </Columns>
        </RadzenDataGrid>
    </RadzenStack>
</RadzenCard>

@code {
    [Parameter] public List<ProfitCenterViewModel> ProfitCenters { get; set; } = new();
    [Parameter] public EventCallback<List<ProfitCenterViewModel>> ProfitCentersChanged { get; set; }
    [Parameter] public bool IsReadOnly { get; set; }

    private string? selectedText;
    private IEnumerable<CodeDescriptionSuggestion> suggestions = Enumerable.Empty<CodeDescriptionSuggestion>();

    private async Task LoadSuggestions(LoadDataArgs args)
    {
        var results = await LookupService.FindProfitCentersAsync(args.Filter);
        suggestions = results.Select(pc => new CodeDescriptionSuggestion
        {
            Code = pc.Code,
            Description = pc.Description,
            DisplayText = $"{pc.Code} - {pc.Description}"
        });
    }

    // Add, Delete, Clear handlers similar to ItemNumberFilterPanel
}

Requirement: Work Order Filter Panel Component

The system SHALL provide upload-only bulk entry for work orders.

Radzen Implementation

@* Components/WorkOrderFilterPanel.razor *@
<RadzenCard>
    <RadzenStack Gap="1rem">
        <RadzenStack Orientation="Orientation.Horizontal" JustifyContent="JustifyContent.SpaceBetween">
            <RadzenText TextStyle="TextStyle.H6" Text="Filter by work order" />
            @if (!IsReadOnly)
            {
                <RadzenStack Orientation="Orientation.Horizontal" Gap="0.5rem">
                    <RadzenButton Text="Download Template" Click="@HandleDownloadTemplate" />
                    <RadzenUpload Url="/api/file/work-orders/upload" Accept=".xlsx,.xls" Auto="true"
                                  Complete="@OnUploadComplete">
                        Upload Data
                    </RadzenUpload>
                    <RadzenButton Text="Clear Data" Click="@HandleClear" />
                </RadzenStack>
            }
        </RadzenStack>

        <RadzenDataGrid Data="@WorkOrders" TItem="WorkOrderViewModel">
            <Columns>
                <RadzenDataGridColumn Property="WorkOrderNumber" Title="Work Order Number" />
                <RadzenDataGridColumn Property="ItemNumber" Title="Item Number" />
            </Columns>
        </RadzenDataGrid>

        <RadzenText Text="@($"# of work orders: {WorkOrders.Count}")" Style="font-weight: bold;" />
    </RadzenStack>
</RadzenCard>

@code {
    [Parameter] public List<WorkOrderViewModel> WorkOrders { get; set; } = new();
    [Parameter] public EventCallback<List<WorkOrderViewModel>> WorkOrdersChanged { get; set; }
    [Parameter] public bool IsReadOnly { get; set; }
    [Parameter] public IFileService FileService { get; set; } = default!;

    // Upload, Download, Clear handlers
}

Requirement: Search Queue page

The system SHALL provide an admin view of all queued searches with processor status.

Layout

  • Page title
  • Processor Status panel (message, last update timestamp)
  • Data grid with columns: Owner, Name, Submitted, Started, Ended, Status

Radzen Implementation

@* Pages/SearchQueue.razor *@
@page "/search/queue"
@attribute [Authorize]
@inject ISearchService SearchService
@inject IHubConnectionService HubService
@implements IAsyncDisposable

<RadzenStack Gap="1rem">
    <RadzenText TextStyle="TextStyle.H4" Text="Search Queue" />

    @* Processor Status Panel *@
    <RadzenCard>
        <RadzenStack Gap="1rem">
            <RadzenText TextStyle="TextStyle.H6" Text="Search Processor Status" />
            <RadzenRow Gap="1rem">
                <RadzenColumn Size="8">
                    <RadzenStack Gap="0.25rem">
                        <RadzenLabel Text="Status Message" />
                        <RadzenTextBox Value="@statusMessage" ReadOnly="true" Style="width: 100%;" />
                    </RadzenStack>
                </RadzenColumn>
                <RadzenColumn Size="4">
                    <RadzenStack Gap="0.25rem">
                        <RadzenLabel Text="Last Update Timestamp" />
                        <RadzenTextBox Value="@statusTimestamp" ReadOnly="true" Style="width: 100%;" />
                    </RadzenStack>
                </RadzenColumn>
            </RadzenRow>
        </RadzenStack>
    </RadzenCard>

    @* Queue Grid *@
    <RadzenDataGrid @ref="grid" Data="@queuedSearches" TItem="SearchViewModel"
                    AllowPaging="true" PageSize="20" AllowSorting="true"
                    IsLoading="@isLoading" Style="min-height: 450px;">
        <Columns>
            <RadzenDataGridColumn TItem="SearchViewModel" Property="UserName" Title="Owner" />
            <RadzenDataGridColumn TItem="SearchViewModel" Property="Name" Title="Name" />
            <RadzenDataGridColumn TItem="SearchViewModel" Property="SubmitDT" Title="Submitted" Width="180px">
                <Template Context="search">
                    @(search.SubmitDT?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
                </Template>
            </RadzenDataGridColumn>
            <RadzenDataGridColumn TItem="SearchViewModel" Property="StartDT" Title="Started" Width="180px">
                <Template Context="search">
                    @(search.StartDT?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
                </Template>
            </RadzenDataGridColumn>
            <RadzenDataGridColumn TItem="SearchViewModel" Property="EndDT" Title="Ended" Width="180px">
                <Template Context="search">
                    @(search.EndDT?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
                </Template>
            </RadzenDataGridColumn>
            <RadzenDataGridColumn TItem="SearchViewModel" Property="Status" Title="Status" Width="100px">
                <Template Context="search">
                    <RadzenBadge BadgeStyle="@GetStatusStyle(search.Status)" Text="@search.Status.ToString()" />
                </Template>
            </RadzenDataGridColumn>
        </Columns>
    </RadzenDataGrid>
</RadzenStack>

@code {
    private RadzenDataGrid<SearchViewModel>? grid;
    private List<SearchViewModel> queuedSearches = new();
    private bool isLoading = true;
    private string statusMessage = "Unknown";
    private string statusTimestamp = "";

    protected override async Task OnInitializedAsync()
    {
        await LoadQueue();
        await SubscribeToUpdates();
    }

    private async Task LoadQueue()
    {
        isLoading = true;
        try
        {
            queuedSearches = await SearchService.GetQueueAsync();
        }
        finally
        {
            isLoading = false;
        }
    }

    private async Task SubscribeToUpdates()
    {
        await HubService.StartAsync();
        HubService.OnStatusUpdate += HandleStatusUpdate;
        HubService.OnSearchUpdate += HandleSearchUpdate;

        // Get cached status on connect
        var cached = await HubService.GetCachedStatusAsync();
        if (cached != null)
        {
            HandleStatusUpdate(cached);
        }
    }

    private async void HandleStatusUpdate(StatusUpdate update)
    {
        await InvokeAsync(() =>
        {
            statusMessage = update.Message;
            statusTimestamp = update.Timestamp.ToString("MM/dd/yyyy hh:mm:ss tt");
            StateHasChanged();
        });
    }

    private async void HandleSearchUpdate(SearchUpdate update)
    {
        await InvokeAsync(() =>
        {
            if (update.Status == SearchStatus.Ended || update.Status == SearchStatus.Error)
            {
                // Remove completed/errored searches from queue
                queuedSearches.RemoveAll(s => s.ID == update.ID);
            }
            else
            {
                var existing = queuedSearches.FirstOrDefault(s => s.ID == update.ID);
                if (existing != null)
                {
                    existing.Status = update.Status;
                    existing.StartDT = update.StartDT;
                    existing.EndDT = update.EndDT;
                }
                else
                {
                    queuedSearches.Add(new SearchViewModel
                    {
                        ID = update.ID,
                        UserName = update.UserName,
                        Name = update.Name,
                        Status = update.Status,
                        SubmitDT = update.SubmitDT
                    });
                }
            }
            StateHasChanged();
        });
    }

    private BadgeStyle GetStatusStyle(SearchStatus status) => status switch
    {
        SearchStatus.Submitted => BadgeStyle.Warning,
        SearchStatus.Started => BadgeStyle.Primary,
        _ => BadgeStyle.Light
    };

    public async ValueTask DisposeAsync()
    {
        HubService.OnStatusUpdate -= HandleStatusUpdate;
        HubService.OnSearchUpdate -= HandleSearchUpdate;
    }
}

Business Rules

  • The system SHALL display all searches in the queue (not filtered by user)
  • The system SHALL sort by SubmitDT ascending (oldest first, FIFO)
  • The system SHALL display processor status from SignalR statusUpdate events
  • The system SHALL remove completed/errored searches from the queue in real-time
  • The system SHALL request cached status on initial connection

Scenario: Queue receives status update

  • WHEN a SignalR statusUpdate event is received
  • THEN the processor status panel updates with the new message and timestamp

Scenario: Search completes

  • WHEN a SignalR searchUpdate event with Status = Ended is received
  • THEN the search is removed from the queue grid

Requirement: Refresh Status page

The system SHALL provide a data sync status dashboard showing cache update history.

Layout

  • Page title
  • Date range filter with Start/End time and Filter button
  • Data table with columns: Start, End, record counts per table, Success indicator

Radzen Implementation

@* Pages/RefreshStatus.razor *@
@page "/refresh-status"
@attribute [Authorize]
@inject IRefreshStatusService RefreshService

<RadzenStack Gap="1rem">
    <RadzenCard>
        <RadzenStack Gap="1rem">
            <RadzenText TextStyle="TextStyle.H6" Text="Cache Refresh Status" />

            <EditForm Model="@filterModel" OnValidSubmit="@HandleFilter">
                <RadzenRow Gap="1rem" AlignItems="AlignItems.End">
                    <RadzenColumn Size="4">
                        <RadzenStack Gap="0.25rem">
                            <RadzenLabel Text="Start Time" />
                            <RadzenDatePicker @bind-Value="@filterModel.MinDT" ShowTime="true"
                                              DateFormat="MM/dd/yyyy HH:mm" Style="width: 100%;" />
                        </RadzenStack>
                    </RadzenColumn>
                    <RadzenColumn Size="4">
                        <RadzenStack Gap="0.25rem">
                            <RadzenLabel Text="End Time" />
                            <RadzenDatePicker @bind-Value="@filterModel.MaxDT" ShowTime="true"
                                              DateFormat="MM/dd/yyyy HH:mm" Style="width: 100%;" />
                        </RadzenStack>
                    </RadzenColumn>
                    <RadzenColumn Size="2">
                        <RadzenButton Text="Filter" ButtonType="ButtonType.Submit"
                                      ButtonStyle="ButtonStyle.Primary" />
                    </RadzenColumn>
                </RadzenRow>
            </EditForm>
        </RadzenStack>
    </RadzenCard>

    <RadzenDataGrid Data="@results" TItem="DataUpdateViewModel" AllowSorting="true" IsLoading="@isLoading">
        <Columns>
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="StartDT" Title="Start" Width="150px">
                <Template Context="item">
                    @item.StartDT.ToString("MM/dd/yyyy HH:mm")
                </Template>
            </RadzenDataGridColumn>
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="EndDT" Title="End" Width="150px">
                <Template Context="item">
                    @item.EndDT.ToString("MM/dd/yyyy HH:mm")
                </Template>
            </RadzenDataGridColumn>
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="BranchRecords" Title="Branch" Width="80px" TextAlign="TextAlign.Center" />
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="ProfitCenterRecords" Title="Profit Center" Width="100px" TextAlign="TextAlign.Center" />
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkCenterRecords" Title="Work Center" Width="100px" TextAlign="TextAlign.Center" />
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="OrgHierarchyRecords" Title="Org Hierarchy" Width="100px" TextAlign="TextAlign.Center" />
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="StatusCodeRecords" Title="Status Code" Width="100px" TextAlign="TextAlign.Center" />
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="UserRecords" Title="User" Width="80px" TextAlign="TextAlign.Center" />
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="ItemRecords" Title="Item" Width="80px" TextAlign="TextAlign.Center" />
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="LotRecords" Title="Lot" Width="80px" TextAlign="TextAlign.Center" />
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkOrderRecords" Title="Work Order" Width="100px" TextAlign="TextAlign.Center" />
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkOrderStepRecords" Title="WO Step" Width="80px" TextAlign="TextAlign.Center" />
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkOrderComponentRecords" Title="WO Component" Width="100px" TextAlign="TextAlign.Center" />
            <RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WasSuccessful" Title="Was Successful?" Width="120px" TextAlign="TextAlign.Center">
                <Template Context="item">
                    @if (item.WasSuccessful)
                    {
                        <RadzenBadge BadgeStyle="BadgeStyle.Success" Text="YES" />
                    }
                    else
                    {
                        <RadzenBadge BadgeStyle="BadgeStyle.Danger" Text="NO" />
                    }
                </Template>
            </RadzenDataGridColumn>
        </Columns>
    </RadzenDataGrid>
</RadzenStack>

@code {
    private RefreshStatusFilterModel filterModel = new()
    {
        MinDT = DateTime.Today.AddDays(-7),
        MaxDT = DateTime.Today.AddDays(1)
    };
    private List<DataUpdateViewModel> results = new();
    private bool isLoading;

    protected override async Task OnInitializedAsync()
    {
        await LoadData();
    }

    private async Task HandleFilter()
    {
        await LoadData();
    }

    private async Task LoadData()
    {
        isLoading = true;
        try
        {
            results = await RefreshService.GetRefreshStatusAsync(filterModel.MinDT, filterModel.MaxDT);
            results = results.OrderByDescending(r => r.StartDT).ToList();
        }
        finally
        {
            isLoading = false;
        }
    }

    public class RefreshStatusFilterModel
    {
        public DateTime MinDT { get; set; }
        public DateTime MaxDT { get; set; }
    }
}

Business Rules

  • The system SHALL default to the last 7 days of data
  • The system SHALL sort results by StartDT descending (most recent first)
  • The system SHALL display record counts for each table type
  • The system SHALL display success/failure status with colored badge

Requirement: SignalR Hub Connection Service

The system SHALL provide a reusable service for managing SignalR connections.

Implementation

// Services/HubConnectionService.cs
public interface IHubConnectionService
{
    event Action<StatusUpdate>? OnStatusUpdate;
    event Action<SearchUpdate>? OnSearchUpdate;
    Task StartAsync();
    Task StopAsync();
    Task<StatusUpdate?> GetCachedStatusAsync();
}

public class HubConnectionService : IHubConnectionService, IAsyncDisposable
{
    private readonly HubConnection _hubConnection;
    private readonly ILogger<HubConnectionService> _logger;

    public event Action<StatusUpdate>? OnStatusUpdate;
    public event Action<SearchUpdate>? OnSearchUpdate;
    public event Action<bool>? OnConnectionStateChanged;  // F9.5: Connection state UI feedback

    public HubConnectionService(NavigationManager navigation, ILogger<HubConnectionService> logger)
    {
        _logger = logger;
        _hubConnection = new HubConnectionBuilder()
            .WithUrl(navigation.ToAbsoluteUri("/hubs/status"))
            .WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) })
            .Build();

        // F9.5: Log reconnection events
        _hubConnection.Reconnecting += error =>
        {
            _logger.LogWarning(error, "SignalR connection lost. Attempting to reconnect...");
            OnConnectionStateChanged?.Invoke(false);
            return Task.CompletedTask;
        };

        _hubConnection.Reconnected += connectionId =>
        {
            _logger.LogInformation("SignalR reconnected with connection ID: {ConnectionId}", connectionId);
            OnConnectionStateChanged?.Invoke(true);
            return Task.CompletedTask;
        };

        _hubConnection.Closed += error =>
        {
            _logger.LogError(error, "SignalR connection closed");
            OnConnectionStateChanged?.Invoke(false);
            return Task.CompletedTask;
        };

        _hubConnection.On<StatusUpdate>("statusUpdate", update =>
        {
            OnStatusUpdate?.Invoke(update);
        });

        _hubConnection.On<SearchUpdate>("searchUpdate", update =>
        {
            OnSearchUpdate?.Invoke(update);
        });
    }

    public async Task StartAsync()
    {
        if (_hubConnection.State == HubConnectionState.Disconnected)
        {
            await _hubConnection.StartAsync();
        }
    }

    public async Task StopAsync()
    {
        if (_hubConnection.State == HubConnectionState.Connected)
        {
            await _hubConnection.StopAsync();
        }
    }

    public async Task<StatusUpdate?> GetCachedStatusAsync()
    {
        if (_hubConnection.State == HubConnectionState.Connected)
        {
            return await _hubConnection.InvokeAsync<StatusUpdate>("GetCachedStatus");
        }
        return null;
    }

    public async ValueTask DisposeAsync()
    {
        await _hubConnection.DisposeAsync();
    }
}

Requirement: Valid Combination Model

The system SHALL provide search type definitions matching the legacy JavaScript ValidCombination class.

Implementation

// Models/ValidCombination.cs
public class ValidCombination
{
    public int ID { get; set; }
    public string Name { get; set; } = string.Empty;
    public bool Timespan { get; set; }
    public bool WorkOrder { get; set; }
    public bool ItemNumber { get; set; }
    public bool ProfitCenter { get; set; }
    public bool WorkCenter { get; set; }
    public bool ComponentLot { get; set; }
    public bool Operator { get; set; }
    public bool ItemOperationMis { get; set; }
    public bool ExtractMis { get; set; }

    public bool Matches(bool timespan, bool workOrder, bool itemNumber, bool profitCenter,
                        bool workCenter, bool componentLot, bool operatorFilter,
                        bool itemOperationMis, bool extractMis)
    {
        return Timespan == timespan && WorkOrder == workOrder && ItemNumber == itemNumber &&
               ProfitCenter == profitCenter && WorkCenter == workCenter &&
               ComponentLot == componentLot && Operator == operatorFilter &&
               ItemOperationMis == itemOperationMis && ExtractMis == extractMis;
    }

    public static List<ValidCombination> GetAll() => new()
    {
        new() { ID = 10, Name = "Work Order", WorkOrder = true },
        new() { ID = 20, Name = "Component Lot", ComponentLot = true },
        new() { ID = 30, Name = "Time Span + Profit Center", Timespan = true, ProfitCenter = true },
        new() { ID = 40, Name = "Time Span + Work Center", Timespan = true, WorkCenter = true },
        new() { ID = 50, Name = "Time Span + Operator", Timespan = true, Operator = true },
        new() { ID = 60, Name = "Time Span + Profit Center + Item Number", Timespan = true, ProfitCenter = true, ItemNumber = true },
        new() { ID = 70, Name = "Time Span + Profit Center + Item/Operation/MIS", Timespan = true, ProfitCenter = true, ItemOperationMis = true },
        new() { ID = 80, Name = "Time Span + Profit Center + Work Order + Item/Operation/MIS", Timespan = true, ProfitCenter = true, WorkOrder = true, ItemOperationMis = true },
        new() { ID = 90, Name = "Time Span + Profit Center + Extract MIS", Timespan = true, ProfitCenter = true, ExtractMis = true },
        new() { ID = 100, Name = "Time Span + Work Center + Item Number", Timespan = true, WorkCenter = true, ItemNumber = true },
        new() { ID = 110, Name = "Time Span + Work Center + Extract MIS", Timespan = true, WorkCenter = true, ExtractMis = true },
        new() { ID = 120, Name = "Time Span + Work Center + Item/Operation/MIS", Timespan = true, WorkCenter = true, ItemOperationMis = true },
        new() { ID = 130, Name = "Time Span + Work Center + Work Order + Item/Operation/MIS", Timespan = true, WorkCenter = true, WorkOrder = true, ItemOperationMis = true },
        new() { ID = 140, Name = "Time Span + Item Number", Timespan = true, ItemNumber = true },
        new() { ID = 150, Name = "Time Span + Work Center + Operator", Timespan = true, WorkCenter = true, Operator = true },
        new() { ID = 160, Name = "Time Span + Profit Center + Operator", Timespan = true, ProfitCenter = true, Operator = true }
    };
}

Migration Notes

Legacy Pattern New Pattern Rationale
Kendo UI Grid RadzenDataGrid Free OSS component, Blazor-native
Kendo DatePicker RadzenDatePicker Blazor-native binding
Kendo ComboBox RadzenAutoComplete Similar functionality
Kendo DropDownList RadzenDropDown Blazor-native binding
jQuery AJAX HttpClient Built-in .NET async HTTP
jQuery FileUpload RadzenUpload Blazor-native file handling
Kendo Validator DataAnnotationsValidator Standard Blazor validation
kendo.observable() Blazor @code Component state management
SignalR (jQuery) @microsoft/signalr Modern SignalR client
Bootstrap panels RadzenCard Consistent Radzen styling
Bootstrap alerts RadzenAlert Radzen component
kendo.ui.progress() RadzenProgressBarCircular Loading indicators
kendoAlert dialog DialogService.Confirm() Radzen dialog service
@Html.ActionLink RadzenLink / NavigationManager Blazor routing
Form POST API call + NavigateTo SPA navigation pattern

Open Questions

Question Status Notes
File download from WASM Requires JS interop Use IJSRuntime to trigger download
Component lot filter needs LotNumber + ItemNumber Use LotViewModel Two-column grid display
Part/Operation filter needs 4 fields Use PartOperationViewModel ItemNumber, OperationNumber, MisNumber, MisRevision
Operator autocomplete needs 3 fields Display all in dropdown AddressNumber, UserID, FullName
Date validation for min <= max Add custom validation Or use Radzen's built-in range support

Codex Review Findings

High Priority

  1. Missing pages: InvalidUserAgent.cshtml and Error.cshtml are in legacy but not documented. Decide if needed in new UI.
  2. Component Lot and Part/Operation filters incomplete: Legacy has full upload/download/clear implementations; spec only has placeholders.

Medium Priority

  1. Operator filter mapping inaccurate: Legacy uses AddressNumber/UserID/FullName; spec uses Code/Description pattern.
  2. Clear Data confirmations missing: Legacy requires confirmation dialogs before clearing filters; spec clears directly.
  3. Navigation behavior differs: Legacy opens Search List/Queue/Detail in new windows; spec uses same-window navigation.

Low Priority

  1. Refresh Status grid grouping: Legacy has grouped "Number Of Records Updated" header; spec uses flat grid.
  2. Component mapping incomplete: Missing Kendo DateTimePicker, TimePicker, NumericTextBox, DropDownList from editor templates.
  3. NotAuthorized page adds buttons not in legacy.

Decisions Made

  • Error pages: Drop InvalidUserAgent and Error pages - Blazor has built-in error handling
  • Navigation: Use same-window navigation (modern SPA behavior)
  • Confirmations: Keep confirmation dialogs for Clear Data actions
  • Refresh Status grouping: Not required - use flat grid

NuGet Packages Required

<PackageReference Include="Radzen.Blazor" Version="8.4.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="bunit" Version="1.*" PrivateAssets="all" />

Note: Package versions target .NET 10 release. Radzen 8.4.2 and SignalR 10.0.1 are compatible with .NET 10.

Service Registration

// Program.cs
builder.Services.AddRadzenComponents();
builder.Services.AddScoped<DialogService>();
builder.Services.AddScoped<NotificationService>();
builder.Services.AddScoped<IHubConnectionService, HubConnectionService>();
builder.Services.AddScoped<ISearchService, SearchService>();
builder.Services.AddScoped<ILookupService, LookupService>();
builder.Services.AddScoped<IFileService, FileService>();
builder.Services.AddScoped<IRefreshStatusService, RefreshStatusService>();
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, AuthStateProvider>();

Testing Support

Mock Services for Unit Testing (Q9.1)

For unit testing Blazor components, the system SHALL provide mock implementations of services:

// Test mocks for dependency injection in bUnit tests
public class MockSearchService : ISearchService
{
    public List<SearchViewModel> Searches { get; set; } = new();
    public Task<List<SearchViewModel>> GetUserSearchesAsync() => Task.FromResult(Searches);
    // ... other methods return configurable test data
}

public class MockHubConnectionService : IHubConnectionService
{
    public event Action<StatusUpdate>? OnStatusUpdate;
    public event Action<SearchUpdate>? OnSearchUpdate;
    public event Action<bool>? OnConnectionStateChanged;

    public Task StartAsync() => Task.CompletedTask;
    public Task StopAsync() => Task.CompletedTask;
    public Task<StatusUpdate?> GetCachedStatusAsync() => Task.FromResult<StatusUpdate?>(null);

    // Test helper methods
    public void SimulateSearchUpdate(SearchUpdate update) => OnSearchUpdate?.Invoke(update);
    public void SimulateStatusUpdate(StatusUpdate update) => OnStatusUpdate?.Invoke(update);
}

bUnit Component Testing (Q9.2)

The system SHALL include bUnit tests for Blazor components:

// Example: SearchList component test
[Fact]
public void SearchList_DisplaysUserSearches()
{
    // Arrange
    var mockSearchService = new MockSearchService
    {
        Searches = new List<SearchViewModel>
        {
            new() { ID = 1, Name = "Test Search", Status = SearchStatus.Completed }
        }
    };

    using var ctx = new TestContext();
    ctx.Services.AddSingleton<ISearchService>(mockSearchService);
    ctx.Services.AddSingleton<IHubConnectionService>(new MockHubConnectionService());

    // Act
    var cut = ctx.RenderComponent<Searches>();

    // Assert
    cut.Find("td").TextContent.Should().Contain("Test Search");
}