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.
This commit is contained in:
@@ -0,0 +1,859 @@
|
||||
# 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
|
||||
|
||||
<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
|
||||
|
||||
```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<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
|
||||
|
||||
```razor
|
||||
@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)
|
||||
|
||||
```razor
|
||||
@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)
|
||||
|
||||
```razor
|
||||
@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)
|
||||
|
||||
```razor
|
||||
@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)
|
||||
|
||||
```razor
|
||||
@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)
|
||||
|
||||
```razor
|
||||
@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)
|
||||
|
||||
```razor
|
||||
@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
|
||||
|
||||
```razor
|
||||
@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
|
||||
|
||||
```csharp
|
||||
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
|
||||
|
||||
```csharp
|
||||
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)
|
||||
|
||||
```csharp
|
||||
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
|
||||
|
||||
```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<FileUploadResult<WorkOrderViewModel>>();
|
||||
|
||||
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<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
|
||||
|
||||
```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
|
||||
<EditForm Model="@ViewModel" OnValidSubmit="@OnValidSubmit">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary />
|
||||
|
||||
<!-- Form content -->
|
||||
</EditForm>
|
||||
```
|
||||
|
||||
### 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)
|
||||
Reference in New Issue
Block a user