26ff8d9b4f
Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
1986 lines
77 KiB
Markdown
1986 lines
77 KiB
Markdown
# 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
|
|
|
|
```razor
|
|
@* 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
|
|
|
|
```razor
|
|
@* 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
|
|
|
|
```razor
|
|
@* 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
|
|
|
|
```razor
|
|
@* 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
|
|
|
|
```razor
|
|
@* 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
|
|
|
|
```razor
|
|
@* 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
|
|
|
|
```razor
|
|
@* 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
|
|
|
|
```razor
|
|
@* 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
|
|
|
|
```razor
|
|
@* 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
|
|
|
|
```razor
|
|
@* 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
|
|
|
|
```razor
|
|
@* 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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```xml
|
|
<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
|
|
|
|
```csharp
|
|
// 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:
|
|
|
|
```csharp
|
|
// 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:
|
|
|
|
```csharp
|
|
// 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");
|
|
}
|