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

30 KiB

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)

@page "/search"
@page "/search/{Id:int?}"
@inject SearchApiClient SearchApi
@inject StatusHubClient StatusHub
@inject NavigationManager Navigation

<PageTitle>Search</PageTitle>

<RadzenCard>
    <h2>
        Search
        @if (!IsReadOnly)
        {
            <RadzenButton Text="Submit"
                          Click="@OnSubmit"
                          ButtonStyle="ButtonStyle.Primary"
                          Size="ButtonSize.Small" />
        }
    </h2>

    @if (IsReadOnly)
    {
        <RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true">
            Search is read-only because it has already been submitted.
            <RadzenButton Text="Copy" Click="@OnCopy" />
        </RadzenAlert>
    }

    <SearchCriteriaForm @ref="criteriaForm"
                        ViewModel="@viewModel"
                        IsReadOnly="@IsReadOnly"
                        OnValidSubmit="@OnValidSubmit" />
</RadzenCard>

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<ValidCombination> @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

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:

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<ValidCombination> 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

@if (ViewModel.SearchType?.Timespan == true)
{
    <RadzenCard>
        <RadzenText TextStyle="TextStyle.H6">Filter by timespan</RadzenText>

        <div class="row">
            <div class="col-md-5">
                <RadzenLabel Text="Min Date" />
                <RadzenDatePicker @bind-Value="ViewModel.MinimumDT"
                                  Min="@(new DateTime(2002, 11, 1))"
                                  Max="@(ViewModel.MaximumDT ?? DateTime.Today)"
                                  Disabled="@IsReadOnly" />
            </div>
            <div class="col-md-5 offset-md-1">
                <RadzenLabel Text="Max Date" />
                <RadzenDatePicker @bind-Value="ViewModel.MaximumDT"
                                  Min="@(ViewModel.MinimumDT ?? new DateTime(2002, 11, 1))"
                                  Max="@DateTime.Today"
                                  Disabled="@IsReadOnly" />
            </div>
        </div>
    </RadzenCard>
}

2. Work Order Filter (with file upload/download)

@if (ViewModel.SearchType?.WorkOrder == true)
{
    <RadzenCard>
        <div class="d-flex justify-content-between align-items-center">
            <RadzenText TextStyle="TextStyle.H6">Filter by work order</RadzenText>

            @if (!IsReadOnly)
            {
                <div class="btn-group">
                    <RadzenButton Text="Download Template"
                                  Click="@DownloadWorkOrderTemplate"
                                  ButtonStyle="ButtonStyle.Light" />
                    <InputFile OnChange="@UploadWorkOrders" accept=".xlsx" />
                    <RadzenButton Text="Clear Data"
                                  Click="@ClearWorkOrders"
                                  ButtonStyle="ButtonStyle.Light" />
                </div>
            }
        </div>

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

        <RadzenText><strong># of work orders:</strong> @ViewModel.WorkOrders.Count</RadzenText>
    </RadzenCard>
}

3. Item Number Filter (with autocomplete + file upload)

@if (ViewModel.SearchType?.ItemNumber == true)
{
    <RadzenCard>
        <div class="d-flex justify-content-between align-items-center">
            <RadzenText TextStyle="TextStyle.H6">Filter by item number</RadzenText>

            @if (!IsReadOnly)
            {
                <div class="btn-group">
                    <RadzenButton Text="Download Template" Click="@DownloadItemTemplate" />
                    <InputFile OnChange="@UploadItems" accept=".xlsx" />
                    <RadzenButton Text="Clear Data" Click="@ClearItems" />
                </div>
            }
        </div>

        @if (!IsReadOnly)
        {
            <div class="form-group">
                <RadzenLabel Text="Item Number" />
                <RadzenAutoComplete @bind-Value="selectedItemText"
                                    Data="@itemSearchResults"
                                    TextProperty="ItemNumber"
                                    MinLength="3"
                                    LoadData="@SearchItems"
                                    Placeholder="Type 3+ characters to search..."
                                    Style="width: 550px;" />
                <RadzenButton Text="Add to filter"
                              Click="@AddSelectedItem"
                              Visible="@(selectedItem != null)" />
            </div>
        }

        <RadzenDataGrid Data="@ViewModel.Items" TItem="ItemViewModel">
            <Columns>
                <RadzenDataGridColumn TItem="ItemViewModel" Property="ItemNumber" Title="Item Number" />
                <RadzenDataGridColumn TItem="ItemViewModel" Property="Description" Title="Description" />
                @if (!IsReadOnly)
                {
                    <RadzenDataGridColumn TItem="ItemViewModel" Width="100px" Title="Actions">
                        <Template Context="item">
                            <RadzenButton Text="Delete" Click="@(() => DeleteItem(item))" />
                        </Template>
                    </RadzenDataGridColumn>
                }
            </Columns>
        </RadzenDataGrid>

        <RadzenText><strong># of item numbers:</strong> @ViewModel.Items.Count</RadzenText>
    </RadzenCard>
}

4-5. Profit Center / Work Center Filters (autocomplete only, no file upload)

@if (ViewModel.SearchType?.ProfitCenter == true)
{
    <RadzenCard>
        <div class="d-flex justify-content-between align-items-center">
            <RadzenText TextStyle="TextStyle.H6">Filter by profit center</RadzenText>
            @if (!IsReadOnly)
            {
                <RadzenButton Text="Clear Data" Click="@ClearProfitCenters" />
            }
        </div>

        @if (!IsReadOnly)
        {
            <LookupDropdown TItem="ProfitCenterViewModel"
                            TextProperty="Code"
                            SearchEndpoint="@LookupApi.SearchProfitCentersAsync"
                            OnItemSelected="@AddProfitCenter"
                            Placeholder="Type 3+ characters to search..." />
        }

        <RadzenDataGrid Data="@ViewModel.ProfitCenters" TItem="ProfitCenterViewModel">
            <Columns>
                <RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Code" Title="Profit Center" />
                <RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Description" Title="Description" />
                @if (!IsReadOnly)
                {
                    <RadzenDataGridColumn TItem="ProfitCenterViewModel" Width="100px" Title="Actions">
                        <Template Context="item">
                            <RadzenButton Text="Delete" Click="@(() => DeleteProfitCenter(item))" />
                        </Template>
                    </RadzenDataGridColumn>
                }
            </Columns>
        </RadzenDataGrid>
    </RadzenCard>
}

Work Center filter follows the same pattern with WorkCenterViewModel.


6. Component Lot Filter (file upload only)

@if (ViewModel.SearchType?.ComponentLot == true)
{
    <RadzenCard>
        <div class="d-flex justify-content-between align-items-center">
            <RadzenText TextStyle="TextStyle.H6">Filter by component lot</RadzenText>
            @if (!IsReadOnly)
            {
                <div class="btn-group">
                    <RadzenButton Text="Download Template" Click="@DownloadComponentLotTemplate" />
                    <InputFile OnChange="@UploadComponentLots" accept=".xlsx" />
                    <RadzenButton Text="Clear Data" Click="@ClearComponentLots" />
                </div>
            }
        </div>

        <RadzenDataGrid Data="@ViewModel.ComponentLots" TItem="LotViewModel">
            <Columns>
                <RadzenDataGridColumn TItem="LotViewModel" Property="LotNumber" Title="Lot Number" />
                <RadzenDataGridColumn TItem="LotViewModel" Property="ItemNumber" Title="Item Number" />
            </Columns>
        </RadzenDataGrid>

        <RadzenText><strong># of component lots:</strong> @ViewModel.ComponentLots.Count</RadzenText>
    </RadzenCard>
}

7. Operator Filter (autocomplete only)

@if (ViewModel.SearchType?.Operator == true)
{
    <RadzenCard>
        <div class="d-flex justify-content-between align-items-center">
            <RadzenText TextStyle="TextStyle.H6">Filter by operator</RadzenText>
            @if (!IsReadOnly)
            {
                <RadzenButton Text="Clear Data" Click="@ClearOperators" />
            }
        </div>

        @if (!IsReadOnly)
        {
            <LookupDropdown TItem="JdeUserViewModel"
                            TextProperty="FullName"
                            SearchEndpoint="@LookupApi.SearchOperatorsAsync"
                            OnItemSelected="@AddOperator"
                            Placeholder="Type 3+ characters to search..." />
        }

        <RadzenDataGrid Data="@ViewModel.Operators" TItem="JdeUserViewModel">
            <Columns>
                <RadzenDataGridColumn TItem="JdeUserViewModel" Property="AddressNumber" Title="Address Number" />
                <RadzenDataGridColumn TItem="JdeUserViewModel" Property="UserID" Title="User Name" />
                <RadzenDataGridColumn TItem="JdeUserViewModel" Property="FullName" Title="Full Name" />
                @if (!IsReadOnly)
                {
                    <RadzenDataGridColumn TItem="JdeUserViewModel" Width="100px" Title="Actions">
                        <Template Context="item">
                            <RadzenButton Text="Delete" Click="@(() => DeleteOperator(item))" />
                        </Template>
                    </RadzenDataGridColumn>
                }
            </Columns>
        </RadzenDataGrid>
    </RadzenCard>
}

8. Item/Operation/MIS Filter (file upload only)

@if (ViewModel.SearchType?.ItemOperationMis == true)
{
    <RadzenCard>
        <div class="d-flex justify-content-between align-items-center">
            <RadzenText TextStyle="TextStyle.H6">Filter By Item/Operation/MIS</RadzenText>
            @if (!IsReadOnly)
            {
                <div class="btn-group">
                    <RadzenButton Text="Download Template" Click="@DownloadPartOperationTemplate" />
                    <InputFile OnChange="@UploadPartOperations" accept=".xlsx" />
                    <RadzenButton Text="Clear Data" Click="@ClearPartOperations" />
                </div>
            }
        </div>

        <RadzenDataGrid Data="@ViewModel.PartOperations" TItem="PartOperationViewModel">
            <Columns>
                <RadzenDataGridColumn TItem="PartOperationViewModel" Property="ItemNumber" Title="Item Number" />
                <RadzenDataGridColumn TItem="PartOperationViewModel" Property="OperationNumber" Title="Operation Step Number" />
                <RadzenDataGridColumn TItem="PartOperationViewModel" Property="MisNumber" Title="MIS Number" />
                <RadzenDataGridColumn TItem="PartOperationViewModel" Property="MisRevision" Title="MIS Revision" />
            </Columns>
        </RadzenDataGrid>

        <RadzenText><strong># of item / operations:</strong> @ViewModel.PartOperations.Count</RadzenText>
    </RadzenCard>
}

9. Extract MIS Data Option

@if (ViewModel.SearchType?.ExtractMis == true)
{
    <RadzenCard>
        <RadzenCheckBox @bind-Value="@extractMisChecked"
                        Disabled="true"
                        Name="ExtractMisData" />
        <RadzenLabel Text="Extract MIS data" Component="ExtractMisData" />
    </RadzenCard>
}

@code {
    private bool extractMisChecked = true; // Always true when this panel is visible
}

API Clients

SearchApiClient.cs

public class SearchApiClient
{
    private readonly HttpClient _http;

    public SearchApiClient(HttpClient http) => _http = http;

    public async Task<SearchViewModel?> GetSearchAsync(int? id) =>
        await _http.GetFromJsonAsync<SearchViewModel>($"api/search/{id}");

    public async Task<SearchViewModel?> CopySearchAsync(int id) =>
        await _http.GetFromJsonAsync<SearchViewModel>($"api/search/{id}/copy");

    public async Task<int> SaveAsync(SearchViewModel viewModel) =>
        await _http.PostAsJsonAsync("api/search", viewModel)
                   .Result.Content.ReadFromJsonAsync<int>();

    public async Task<byte[]> GetResultsAsync(int id) =>
        await _http.GetByteArrayAsync($"api/search/{id}/results");
}

LookupApiClient.cs

public class LookupApiClient
{
    private readonly HttpClient _http;

    public LookupApiClient(HttpClient http) => _http = http;

    public async Task<IEnumerable<ItemViewModel>> SearchItemsAsync(string query) =>
        await _http.GetFromJsonAsync<IEnumerable<ItemViewModel>>($"api/lookup/items?q={query}")
        ?? Enumerable.Empty<ItemViewModel>();

    public async Task<IEnumerable<ProfitCenterViewModel>> SearchProfitCentersAsync(string query) =>
        await _http.GetFromJsonAsync<IEnumerable<ProfitCenterViewModel>>($"api/lookup/profitcenters?q={query}")
        ?? Enumerable.Empty<ProfitCenterViewModel>();

    public async Task<IEnumerable<WorkCenterViewModel>> SearchWorkCentersAsync(string query) =>
        await _http.GetFromJsonAsync<IEnumerable<WorkCenterViewModel>>($"api/lookup/workcenters?q={query}")
        ?? Enumerable.Empty<WorkCenterViewModel>();

    public async Task<IEnumerable<JdeUserViewModel>> SearchOperatorsAsync(string query) =>
        await _http.GetFromJsonAsync<IEnumerable<JdeUserViewModel>>($"api/lookup/operators?q={query}")
        ?? Enumerable.Empty<JdeUserViewModel>();
}

File I/O with ClosedXML

FileIOController.cs (Server-side)

using ClosedXML.Excel;

[ApiController]
[Route("api/fileio")]
public class FileIOController : ControllerBase
{
    [HttpPost("workorders/upload")]
    public async Task<ActionResult<FileUploadResult<WorkOrderViewModel>>> UploadWorkOrders(IFormFile file)
    {
        using var stream = file.OpenReadStream();
        using var workbook = new XLWorkbook(stream);
        var worksheet = workbook.Worksheet(1);

        var workOrderNumbers = new List<long>();
        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<WorkOrderViewModel>
        {
            WasSuccessful = true,
            Data = validated
        });
    }

    [HttpPost("workorders/download")]
    public IActionResult DownloadWorkOrders([FromBody] List<long> 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

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<FileUploadResult<WorkOrderViewModel>>();

    if (result?.WasSuccessful == true)
    {
        ViewModel.WorkOrders = result.Data.ToList();
    }
    else
    {
        NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", result?.ErrorMessage);
    }
}

Blazor File Download Handler

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:

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

public class StatusHubClient : IAsyncDisposable
{
    private HubConnection? _connection;

    public event Action<SearchStatusUpdate>? OnStatusChanged;

    public async Task ConnectAsync(string baseUrl)
    {
        _connection = new HubConnectionBuilder()
            .WithUrl($"{baseUrl}/hubs/status")
            .WithAutomaticReconnect()
            .Build();

        _connection.On<SearchStatusUpdate>("StatusChanged", update =>
        {
            OnStatusChanged?.Invoke(update);
        });

        await _connection.StartAsync();
    }

    public async ValueTask DisposeAsync()
    {
        if (_connection != null)
            await _connection.DisposeAsync();
    }
}

Usage in Search.razor

@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

<EditForm Model="@ViewModel" OnValidSubmit="@OnValidSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <!-- Form content -->
</EditForm>

SearchViewModel Validation Attributes

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)

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:

@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