26ff8d9b4f
Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
860 lines
30 KiB
Markdown
860 lines
30 KiB
Markdown
# 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)
|