# Search Creation Page - New Implementation Guide This document provides the implementation specification for the new search creation page (`Search.razor` / `SearchCriteriaForm.razor`) based on the legacy functionality analysis and the project's architecture choices. ## Technology Stack | Legacy | New | Notes | |--------|-----|-------| | Kendo UI | **Radzen Blazor** | Free MIT license, replaces all Kendo components | | jQuery FileUpload | **Blazor InputFile** | Native Blazor file upload component | | EPPlus | **ClosedXML** | Free MIT license for Excel generation | | SignalR 2.2.1 | **ASP.NET Core SignalR** | Modern SignalR with `WithAutomaticReconnect()` | | Kendo Observable | **Blazor Component State** | Standard Blazor state management | | jQuery | N/A | Not needed in Blazor | | .NET Framework 4.8 | **.NET 10** | Target framework | --- ## Project Structure Based on `BlazorClient.md`, the search functionality spans these files: ``` JdeScoping.Client/ ├── Pages/ │ └── Search.razor # Main search page (routes to /search, /search/{id}) ├── Components/ │ ├── SearchCriteriaForm.razor # Complex search form with all filter panels │ ├── SearchStatusCard.razor # Real-time status display │ └── LookupDropdown.razor # Reusable autocomplete wrapper ├── Services/ │ ├── SearchApiClient.cs # HTTP calls to SearchController │ ├── LookupApiClient.cs # HTTP calls to LookupController │ └── StatusHubClient.cs # SignalR connection └── Models/ ├── SearchViewModel.cs # Search details model ├── SearchCriteriaViewModel.cs # Filter criteria model └── ValidCombination.cs # Search type definitions ``` --- ## Radzen Component Mapping | Legacy Kendo | Radzen Replacement | Usage | |--------------|-------------------|-------| | `DropDownList` | `RadzenDropDown` | Search Type selection | | `ComboBox` (autocomplete) | `RadzenAutoComplete` | Item, Profit Center, Work Center, Operator lookup | | `DatePicker` | `RadzenDatePicker` | Min/Max date selection | | `Grid` | `RadzenDataGrid` | Display filter data collections | | `Button` | `RadzenButton` | Submit, Clear, Add, Delete actions | | `Alert` | `RadzenNotification` | Validation error messages | | `Window` (confirm) | `RadzenDialog` | Confirmation dialogs | | `Validator` | `EditForm` + `DataAnnotationsValidator` | Form validation | | `ProgressBar` | `RadzenProgressBar` | Loading indicators | --- ## Page Structure ### Search.razor (Page) ```razor @page "/search" @page "/search/{Id:int?}" @inject SearchApiClient SearchApi @inject StatusHubClient StatusHub @inject NavigationManager Navigation Search

Search @if (!IsReadOnly) { }

@if (IsReadOnly) { Search is read-only because it has already been submitted. }
``` ### SearchCriteriaForm.razor (Component) Contains all filter panels with conditional visibility based on selected search type. --- ## Search Details Panel | Field | Radzen Component | Binding | |-------|------------------|---------| | Search Type | `RadzenDropDown` | `@bind-Value="ViewModel.SearchType"` with `Change` event | | Name | `RadzenTextBox` | `@bind-Value="ViewModel.Name"` | | Submitted At | `RadzenTextBox` (ReadOnly) | `Value="@ViewModel.SubmitDT?.ToString("MM/dd/yyyy hh:mm:ss tt")"` | | Started At | `RadzenTextBox` (ReadOnly) | `Value="@ViewModel.StartDT?.ToString(...)"` | | Completed At | `RadzenTextBox` (ReadOnly) | `Value="@ViewModel.EndDT?.ToString(...)"` | | User | `RadzenTextBox` (ReadOnly) | `Value="@ViewModel.UserName"` | | Status | `RadzenTextBox` (ReadOnly) | `Value="@ViewModel.Status"` with conditional `Style` | | Download Results | `RadzenButton` | `Visible="@ViewModel.HasResults"` | ### Status Styling ```csharp private string GetStatusStyle() => ViewModel.Status == SearchStatus.Error ? "background-color: #FF6347;" : "background-color: #eee;"; ``` --- ## Valid Search Type Combinations Defined in `ValidCombination.cs` as a static list: ```csharp public record ValidCombination( int Id, string Name, bool Timespan, bool WorkOrder, bool ItemNumber, bool ProfitCenter, bool WorkCenter, bool ComponentLot, bool Operator, bool ItemOperationMis, bool ExtractMis); public static class ValidCombinations { public static readonly List All = new() { new(10, "Work Order", false, true, false, false, false, false, false, false, false), new(20, "Component Lot", false, false, false, false, false, true, false, false, false), new(30, "Time Span + Profit Center", true, false, false, true, false, false, false, false, false), new(40, "Time Span + Work Center", true, false, false, false, true, false, false, false, false), new(50, "Time Span + Operator", true, false, false, false, false, false, true, false, false), new(60, "Time Span + Profit Center + Item Number", true, false, true, true, false, false, false, false, false), new(70, "Time Span + Profit Center + Item/Operation/MIS", true, false, false, true, false, false, false, true, false), new(80, "Time Span + Profit Center + Work Order + Item/Operation/MIS", true, true, false, true, false, false, false, true, false), new(90, "Time Span + Profit Center + Extract MIS", true, false, false, true, false, false, false, false, true), new(100, "Time Span + Work Center + Item Number", true, false, true, false, true, false, false, false, false), new(110, "Time Span + Work Center + Extract MIS", true, false, false, false, true, false, false, false, true), new(120, "Time Span + Work Center + Item/Operation/MIS", true, false, false, false, true, false, false, true, false), new(130, "Time Span + Work Center + Work Order + Item/Operation/MIS", true, true, false, false, true, false, false, true, false), new(140, "Time Span + Item Number", true, false, true, false, false, false, false, false, false), new(150, "Time Span + Work Center + Operator", true, false, false, false, true, false, true, false, false), new(160, "Time Span + Profit Center + Operator", true, false, false, true, false, false, true, false, false) }; } ``` --- ## Filter Panels ### 1. Time Span Filter ```razor @if (ViewModel.SearchType?.Timespan == true) { Filter by timespan
} ``` --- ### 2. Work Order Filter (with file upload/download) ```razor @if (ViewModel.SearchType?.WorkOrder == true) {
Filter by work order @if (!IsReadOnly) {
}
# of work orders: @ViewModel.WorkOrders.Count
} ``` --- ### 3. Item Number Filter (with autocomplete + file upload) ```razor @if (ViewModel.SearchType?.ItemNumber == true) {
Filter by item number @if (!IsReadOnly) {
}
@if (!IsReadOnly) {
} @if (!IsReadOnly) { } # of item numbers: @ViewModel.Items.Count
} ``` --- ### 4-5. Profit Center / Work Center Filters (autocomplete only, no file upload) ```razor @if (ViewModel.SearchType?.ProfitCenter == true) {
Filter by profit center @if (!IsReadOnly) { }
@if (!IsReadOnly) { } @if (!IsReadOnly) { }
} ``` Work Center filter follows the same pattern with `WorkCenterViewModel`. --- ### 6. Component Lot Filter (file upload only) ```razor @if (ViewModel.SearchType?.ComponentLot == true) {
Filter by component lot @if (!IsReadOnly) {
}
# of component lots: @ViewModel.ComponentLots.Count
} ``` --- ### 7. Operator Filter (autocomplete only) ```razor @if (ViewModel.SearchType?.Operator == true) {
Filter by operator @if (!IsReadOnly) { }
@if (!IsReadOnly) { } @if (!IsReadOnly) { }
} ``` --- ### 8. Item/Operation/MIS Filter (file upload only) ```razor @if (ViewModel.SearchType?.ItemOperationMis == true) {
Filter By Item/Operation/MIS @if (!IsReadOnly) {
}
# of item / operations: @ViewModel.PartOperations.Count
} ``` --- ### 9. Extract MIS Data Option ```razor @if (ViewModel.SearchType?.ExtractMis == true) { } @code { private bool extractMisChecked = true; // Always true when this panel is visible } ``` --- ## API Clients ### SearchApiClient.cs ```csharp public class SearchApiClient { private readonly HttpClient _http; public SearchApiClient(HttpClient http) => _http = http; public async Task GetSearchAsync(int? id) => await _http.GetFromJsonAsync($"api/search/{id}"); public async Task CopySearchAsync(int id) => await _http.GetFromJsonAsync($"api/search/{id}/copy"); public async Task SaveAsync(SearchViewModel viewModel) => await _http.PostAsJsonAsync("api/search", viewModel) .Result.Content.ReadFromJsonAsync(); public async Task GetResultsAsync(int id) => await _http.GetByteArrayAsync($"api/search/{id}/results"); } ``` ### LookupApiClient.cs ```csharp public class LookupApiClient { private readonly HttpClient _http; public LookupApiClient(HttpClient http) => _http = http; public async Task> SearchItemsAsync(string query) => await _http.GetFromJsonAsync>($"api/lookup/items?q={query}") ?? Enumerable.Empty(); public async Task> SearchProfitCentersAsync(string query) => await _http.GetFromJsonAsync>($"api/lookup/profitcenters?q={query}") ?? Enumerable.Empty(); public async Task> SearchWorkCentersAsync(string query) => await _http.GetFromJsonAsync>($"api/lookup/workcenters?q={query}") ?? Enumerable.Empty(); public async Task> SearchOperatorsAsync(string query) => await _http.GetFromJsonAsync>($"api/lookup/operators?q={query}") ?? Enumerable.Empty(); } ``` --- ## File I/O with ClosedXML ### FileIOController.cs (Server-side) ```csharp using ClosedXML.Excel; [ApiController] [Route("api/fileio")] public class FileIOController : ControllerBase { [HttpPost("workorders/upload")] public async Task>> UploadWorkOrders(IFormFile file) { using var stream = file.OpenReadStream(); using var workbook = new XLWorkbook(stream); var worksheet = workbook.Worksheet(1); var workOrderNumbers = new List(); foreach (var row in worksheet.RowsUsed().Skip(1)) // Skip header { if (long.TryParse(row.Cell(1).GetString().Trim(), out var num)) workOrderNumbers.Add(num); } var validated = await _db.LookupWorkOrdersAsync(workOrderNumbers); return Ok(new FileUploadResult { WasSuccessful = true, Data = validated }); } [HttpPost("workorders/download")] public IActionResult DownloadWorkOrders([FromBody] List workOrders) { using var workbook = new XLWorkbook(); var worksheet = workbook.Worksheets.Add("Work Orders"); worksheet.Cell(1, 1).Value = "Work Order Number"; for (int i = 0; i < workOrders.Count; i++) worksheet.Cell(i + 2, 1).Value = workOrders[i]; using var stream = new MemoryStream(); workbook.SaveAs(stream); return File(stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "work_order_template.xlsx"); } // Similar methods for PartNumbers, ComponentLots, PartOperations... } ``` ### Blazor File Upload Handler ```csharp private async Task UploadWorkOrders(InputFileChangeEventArgs e) { var file = e.File; using var content = new MultipartFormDataContent(); using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max content.Add(new StreamContent(stream), "file", file.Name); var response = await Http.PostAsync("api/fileio/workorders/upload", content); var result = await response.Content.ReadFromJsonAsync>(); if (result?.WasSuccessful == true) { ViewModel.WorkOrders = result.Data.ToList(); } else { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", result?.ErrorMessage); } } ``` ### Blazor File Download Handler ```csharp private async Task DownloadWorkOrderTemplate() { var workOrderNumbers = ViewModel.WorkOrders.Select(w => w.WorkOrderNumber).ToList(); var response = await Http.PostAsJsonAsync("api/fileio/workorders/download", workOrderNumbers); var bytes = await response.Content.ReadAsByteArrayAsync(); // Use JS interop to trigger download await JSRuntime.InvokeVoidAsync("downloadFile", "work_order_template.xlsx", bytes); } ``` **wwwroot/js/download.js:** ```javascript window.downloadFile = (fileName, byteArray) => { const blob = new Blob([new Uint8Array(byteArray)]); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; a.click(); URL.revokeObjectURL(url); }; ``` --- ## SignalR Integration ### StatusHubClient.cs ```csharp public class StatusHubClient : IAsyncDisposable { private HubConnection? _connection; public event Action? OnStatusChanged; public async Task ConnectAsync(string baseUrl) { _connection = new HubConnectionBuilder() .WithUrl($"{baseUrl}/hubs/status") .WithAutomaticReconnect() .Build(); _connection.On("StatusChanged", update => { OnStatusChanged?.Invoke(update); }); await _connection.StartAsync(); } public async ValueTask DisposeAsync() { if (_connection != null) await _connection.DisposeAsync(); } } ``` ### Usage in Search.razor ```csharp @implements IAsyncDisposable @code { protected override async Task OnInitializedAsync() { StatusHub.OnStatusChanged += HandleStatusChanged; await StatusHub.ConnectAsync(Navigation.BaseUri); } private void HandleStatusChanged(SearchStatusUpdate update) { if (update.Id == viewModel?.ID) { viewModel.SubmitDT = update.SubmitDT; viewModel.StartDT = update.StartDT; viewModel.EndDT = update.EndDT; viewModel.Status = update.Status; viewModel.HasResults = update.Status == SearchStatus.Ended; StateHasChanged(); } } public async ValueTask DisposeAsync() { StatusHub.OnStatusChanged -= HandleStatusChanged; await StatusHub.DisposeAsync(); } } ``` --- ## Validation ### Form-Level Validation with EditForm ```razor ``` ### SearchViewModel Validation Attributes ```csharp public class SearchViewModel { public int? ID { get; set; } [Required(ErrorMessage = "Name is required.")] public string? Name { get; set; } public string? UserName { get; set; } [Required(ErrorMessage = "Search Type is required.")] public ValidCombination? SearchType { get; set; } public SearchStatus Status { get; set; } // ... other properties } ``` ### Custom Filter Validation (Submit Handler) ```csharp private async Task OnValidSubmit() { // Filter-level validation if (ViewModel.SearchType?.WorkOrder == true && !ViewModel.WorkOrders.Any()) { NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "At least one work order must be specified for the work order filter."); return; } if (ViewModel.SearchType?.ItemNumber == true && !ViewModel.Items.Any()) { NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "At least one item number must be specified for the item number filter."); return; } // Similar checks for ProfitCenters, WorkCenters, ComponentLots, Operators, PartOperations // Confirmation dialog 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) { await SubmitSearch(); } } ``` --- ## Confirmation Dialogs Using `RadzenDialog` service: ```csharp @inject DialogService DialogService private async Task ClearWorkOrders() { var confirmed = await DialogService.Confirm( "Are you sure you want to clear all work orders?", "Action Confirmation", new ConfirmOptions { OkButtonText = "OK", CancelButtonText = "Cancel" }); if (confirmed == true) { ViewModel.WorkOrders.Clear(); } } ``` --- ## API Endpoints Summary (New) ### Search Operations | Endpoint | Method | Purpose | |----------|--------|---------| | `api/search/{id?}` | GET | Get search by ID or create blank | | `api/search/{id}/copy` | GET | Copy existing search | | `api/search` | POST | Save search criteria | | `api/search/{id}/results` | GET | Download Excel results | ### Lookup/Autocomplete | Endpoint | Method | Purpose | |----------|--------|---------| | `api/lookup/items?q=` | GET | Search items | | `api/lookup/profitcenters?q=` | GET | Search profit centers | | `api/lookup/workcenters?q=` | GET | Search work centers | | `api/lookup/operators?q=` | GET | Search operators | ### File I/O | Endpoint | Method | Purpose | |----------|--------|---------| | `api/fileio/workorders/upload` | POST | Upload work order Excel | | `api/fileio/workorders/download` | POST | Download work order template | | `api/fileio/items/upload` | POST | Upload item Excel | | `api/fileio/items/download` | POST | Download item template | | `api/fileio/componentlots/upload` | POST | Upload component lot Excel | | `api/fileio/componentlots/download` | POST | Download component lot template | | `api/fileio/partoperations/upload` | POST | Upload part operation Excel | | `api/fileio/partoperations/download` | POST | Download part operation template | --- ## Status Values | Status | Description | UI Behavior | |--------|-------------|-------------| | `New` | Not yet submitted | Editable mode | | `Queued` | Waiting to be processed | Read-only mode | | `Running` | Currently processing | Read-only mode | | `Ended` | Completed successfully | Read-only mode, Download Results visible | | `Error` | Failed | Read-only mode, Status field has red background | --- ## Removed/Deprecated - **CheckCamstar_Flag**: Not migrated (dead code in legacy) - **jQuery FileUpload**: Replaced with Blazor `InputFile` - **Kendo Observable**: Replaced with Blazor component state - **EPPlus**: Replaced with **ClosedXML** (MIT license) - **iframe download trick**: Replaced with JS interop blob download --- ## Related Documentation - [Architecture Overview](./Architecture/Overview.md) - [Blazor Client](./Architecture/BlazorClient.md) - [Dependencies](./Architecture/Dependencies.md) - [Data Flow](./Architecture/DataFlow.md)