Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
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
Scenario: Create new search
- WHEN user navigates to /search/create
- THEN an empty form is displayed with Search Type dropdown enabled
Scenario: View existing search
- WHEN user navigates to /search/123 where search has status "Submitted"
- THEN the form loads in read-only mode with the Copy button visible
Scenario: Copy existing search
- 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
- Missing pages: InvalidUserAgent.cshtml and Error.cshtml are in legacy but not documented. Decide if needed in new UI.
- Component Lot and Part/Operation filters incomplete: Legacy has full upload/download/clear implementations; spec only has placeholders.
Medium Priority
- Operator filter mapping inaccurate: Legacy uses AddressNumber/UserID/FullName; spec uses Code/Description pattern.
- Clear Data confirmations missing: Legacy requires confirmation dialogs before clearing filters; spec clears directly.
- Navigation behavior differs: Legacy opens Search List/Queue/Detail in new windows; spec uses same-window navigation.
Low Priority
- Refresh Status grid grouping: Legacy has grouped "Number Of Records Updated" header; spec uses flat grid.
- Component mapping incomplete: Missing Kendo DateTimePicker, TimePicker, NumericTextBox, DropDownList from editor templates.
- 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");
}