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:
Joseph Doherty
2026-01-02 07:43:29 -05:00
commit 26ff8d9b4f
1761 changed files with 596509 additions and 0 deletions
@@ -0,0 +1,693 @@
# Blazor UI Design
## Overview
This document describes the architecture and implementation approach for the Blazor WebAssembly user interface, including the component structure, state management, SignalR integration, and service layer design.
## Architecture
### High-Level Component Diagram
```
+------------------------------------------------------------------+
| App.razor |
| - Router |
| - CascadingAuthenticationState |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| MainLayout.razor |
| - RadzenLayout (Header, Body, Footer) |
| - AuthorizeView for user display |
| - Navigation links |
+------------------------------------------------------------------+
|
+----------------------+----------------------+
| | |
v v v
+----------------+ +------------------+ +------------------+
| Login.razor | | Searches.razor | | RefreshStatus |
| (anonymous) | | SearchEdit.razor | | .razor |
| | | SearchQueue.razor| | |
+----------------+ +------------------+ +------------------+
|
+----------+-----------+
| |
v v
+------------------+ +------------------+
| Filter Panels | | Services |
| (components) | | (DI injected) |
+------------------+ +------------------+
```
### Project Structure
```
NEW/src/JdeScoping.Client/
├── wwwroot/
│ ├── index.html
│ ├── css/
│ │ └── app.css
│ └── js/
│ └── interop.js # File download helper
├── Layout/
│ └── MainLayout.razor
├── Pages/
│ ├── Login.razor
│ ├── NotAuthorized.razor
│ ├── Searches.razor # Home/Search list
│ ├── SearchEdit.razor # Create/Edit search
│ ├── SearchQueue.razor
│ └── RefreshStatus.razor
├── Components/
│ ├── FilterPanels/
│ │ ├── TimeSpanFilterPanel.razor
│ │ ├── WorkOrderFilterPanel.razor
│ │ ├── ItemNumberFilterPanel.razor
│ │ ├── ProfitCenterFilterPanel.razor
│ │ ├── WorkCenterFilterPanel.razor
│ │ ├── ComponentLotFilterPanel.razor
│ │ ├── OperatorFilterPanel.razor
│ │ └── PartOperationFilterPanel.razor
│ └── Shared/
│ └── LoadingIndicator.razor
├── Services/
│ ├── IAuthService.cs
│ ├── AuthService.cs
│ ├── ISearchService.cs
│ ├── SearchService.cs
│ ├── ILookupService.cs
│ ├── LookupService.cs
│ ├── IFileService.cs
│ ├── FileService.cs
│ ├── IRefreshStatusService.cs
│ ├── RefreshStatusService.cs
│ ├── IHubConnectionService.cs
│ └── HubConnectionService.cs
├── Auth/
│ ├── AuthStateProvider.cs
│ └── TokenStorageService.cs
├── Models/
│ ├── ValidCombination.cs
│ ├── LoginModel.cs
│ ├── SearchViewModel.cs
│ ├── SearchCriteriaViewModel.cs
│ ├── ItemViewModel.cs
│ ├── ProfitCenterViewModel.cs
│ ├── WorkCenterViewModel.cs
│ ├── OperatorViewModel.cs
│ ├── WorkOrderViewModel.cs
│ ├── ComponentLotViewModel.cs
│ ├── PartOperationViewModel.cs
│ ├── DataUpdateViewModel.cs
│ ├── SearchUpdate.cs
│ └── StatusUpdate.cs
├── _Imports.razor
├── App.razor
└── Program.cs
```
## Blazor WebAssembly Configuration
### Program.cs Service Registration
```csharp
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
// Configure HttpClient for API calls
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
// Radzen services
builder.Services.AddRadzenComponents();
builder.Services.AddScoped<DialogService>();
builder.Services.AddScoped<NotificationService>();
// Authentication
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, AuthStateProvider>();
builder.Services.AddScoped<AuthStateProvider>();
builder.Services.AddScoped<ITokenStorageService, TokenStorageService>();
// Application services
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ISearchService, SearchService>();
builder.Services.AddScoped<ILookupService, LookupService>();
builder.Services.AddScoped<IFileService, FileService>();
builder.Services.AddScoped<IRefreshStatusService, RefreshStatusService>();
builder.Services.AddScoped<IHubConnectionService, HubConnectionService>();
// Logging
builder.Logging.SetMinimumLevel(LogLevel.Information);
await builder.Build().RunAsync();
```
### App.razor Router Configuration
```razor
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
```
## State Management
### AuthStateProvider
Custom authentication state provider for JWT token-based auth:
```csharp
public class AuthStateProvider : AuthenticationStateProvider
{
private readonly ITokenStorageService _tokenStorage;
private readonly HttpClient _httpClient;
public AuthStateProvider(ITokenStorageService tokenStorage, HttpClient httpClient)
{
_tokenStorage = tokenStorage;
_httpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var token = await _tokenStorage.GetTokenAsync();
if (string.IsNullOrEmpty(token))
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var claims = ParseClaimsFromJwt(token);
var identity = new ClaimsIdentity(claims, "jwt");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
public async Task NotifyAuthenticationStateChangedAsync()
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public async Task LogoutAsync()
{
await _tokenStorage.RemoveTokenAsync();
_httpClient.DefaultRequestHeaders.Authorization = null;
NotifyAuthenticationStateChanged(Task.FromResult(
new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))));
}
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
var payload = jwt.Split('.')[1];
var jsonBytes = ParseBase64WithoutPadding(payload);
var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
return keyValuePairs!.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()!));
}
private byte[] ParseBase64WithoutPadding(string base64)
{
switch (base64.Length % 4)
{
case 2: base64 += "=="; break;
case 3: base64 += "="; break;
}
return Convert.FromBase64String(base64);
}
}
```
### Token Storage Service
Uses browser localStorage via JS interop:
```csharp
public interface ITokenStorageService
{
Task<string?> GetTokenAsync();
Task SetTokenAsync(string token);
Task RemoveTokenAsync();
}
public class TokenStorageService : ITokenStorageService
{
private readonly IJSRuntime _jsRuntime;
private const string TokenKey = "authToken";
public TokenStorageService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<string?> GetTokenAsync()
{
return await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", TokenKey);
}
public async Task SetTokenAsync(string token)
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", TokenKey, token);
}
public async Task RemoveTokenAsync()
{
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", TokenKey);
}
}
```
## SignalR Client Integration
### IHubConnectionService
```csharp
public interface IHubConnectionService
{
HubConnectionState State { get; }
event Action<SearchUpdate>? OnSearchUpdate;
event Action<StatusUpdate>? OnStatusUpdate;
Task StartAsync(CancellationToken ct = default);
Task StopAsync(CancellationToken ct = default);
Task<StatusUpdate?> GetCachedStatusAsync(CancellationToken ct = default);
}
```
### HubConnectionService Implementation
```csharp
public class HubConnectionService : IHubConnectionService, IAsyncDisposable
{
private readonly HubConnection _hubConnection;
private readonly ILogger<HubConnectionService> _logger;
public HubConnectionState State => _hubConnection.State;
public event Action<SearchUpdate>? OnSearchUpdate;
public event Action<StatusUpdate>? OnStatusUpdate;
public HubConnectionService(
NavigationManager navigation,
ILogger<HubConnectionService> logger)
{
_logger = logger;
_hubConnection = new HubConnectionBuilder()
.WithUrl(navigation.ToAbsoluteUri("/hubs/status"))
.WithAutomaticReconnect(new[]
{
TimeSpan.Zero,
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
})
.ConfigureLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Information);
})
.Build();
// Register message handlers
_hubConnection.On<SearchUpdate>("searchUpdate", update =>
{
_logger.LogDebug("Received searchUpdate: {SearchId} - {Status}",
update.ID, update.Status);
OnSearchUpdate?.Invoke(update);
});
_hubConnection.On<StatusUpdate>("statusUpdate", update =>
{
_logger.LogDebug("Received statusUpdate: {Message}", update.Message);
OnStatusUpdate?.Invoke(update);
});
// Handle reconnection events
_hubConnection.Reconnected += connectionId =>
{
_logger.LogInformation("SignalR reconnected: {ConnectionId}", connectionId);
return Task.CompletedTask;
};
_hubConnection.Reconnecting += error =>
{
_logger.LogWarning(error, "SignalR reconnecting...");
return Task.CompletedTask;
};
_hubConnection.Closed += error =>
{
_logger.LogWarning(error, "SignalR connection closed");
return Task.CompletedTask;
};
}
public async Task StartAsync(CancellationToken ct = default)
{
if (_hubConnection.State == HubConnectionState.Disconnected)
{
_logger.LogInformation("Starting SignalR connection...");
await _hubConnection.StartAsync(ct);
_logger.LogInformation("SignalR connected");
}
}
public async Task StopAsync(CancellationToken ct = default)
{
if (_hubConnection.State == HubConnectionState.Connected)
{
await _hubConnection.StopAsync(ct);
_logger.LogInformation("SignalR disconnected");
}
}
public async Task<StatusUpdate?> GetCachedStatusAsync(CancellationToken ct = default)
{
if (_hubConnection.State == HubConnectionState.Connected)
{
return await _hubConnection.InvokeAsync<StatusUpdate?>("GetCachedStatus", ct);
}
return null;
}
public async ValueTask DisposeAsync()
{
await _hubConnection.DisposeAsync();
}
}
```
### SignalR Message Types
```csharp
public record SearchUpdate(
int ID,
string Name,
string UserName,
SearchStatus Status,
DateTime? SubmitDT,
DateTime? StartDT,
DateTime? EndDT);
public record StatusUpdate(
string Message,
DateTime Timestamp);
```
## API Client Services
### ISearchService
```csharp
public interface ISearchService
{
Task<List<SearchViewModel>> GetUserSearchesAsync(CancellationToken ct = default);
Task<SearchViewModel> GetSearchAsync(int id, CancellationToken ct = default);
Task<SearchViewModel> CopySearchAsync(int id, CancellationToken ct = default);
Task<int> SaveSearchAsync(SearchViewModel search, CancellationToken ct = default);
Task<List<SearchViewModel>> GetQueueAsync(CancellationToken ct = default);
Task DownloadResultsAsync(int searchId, CancellationToken ct = default);
}
```
### ILookupService
```csharp
public interface ILookupService
{
Task<IEnumerable<ItemViewModel>> FindItemsAsync(string query, CancellationToken ct = default);
Task<IEnumerable<ProfitCenterViewModel>> FindProfitCentersAsync(string query, CancellationToken ct = default);
Task<IEnumerable<WorkCenterViewModel>> FindWorkCentersAsync(string query, CancellationToken ct = default);
Task<IEnumerable<OperatorViewModel>> FindOperatorsAsync(string query, CancellationToken ct = default);
}
```
### IFileService
```csharp
public interface IFileService
{
Task DownloadTemplateAsync(string templateType, CancellationToken ct = default);
Task<FileUploadResult<T>> UploadAsync<T>(IBrowserFile file, string endpoint, CancellationToken ct = default);
}
public record FileUploadResult<T>(
bool WasSuccessful,
string? ErrorMessage,
List<T>? Data);
```
## File Download via JS Interop
### wwwroot/js/interop.js
```javascript
window.downloadFileFromStream = async (fileName, streamRef) => {
const arrayBuffer = await streamRef.arrayBuffer();
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName ?? '';
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
window.downloadFileFromUrl = (url, fileName) => {
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName ?? '';
anchor.click();
anchor.remove();
};
```
### FileService Download Implementation
```csharp
public async Task DownloadResultsAsync(int searchId, CancellationToken ct)
{
var response = await _httpClient.GetAsync($"/api/search/{searchId}/results", ct);
response.EnsureSuccessStatusCode();
var fileName = $"Search_{searchId}_Results.xlsx";
var content = await response.Content.ReadAsStreamAsync(ct);
using var streamRef = new DotNetStreamReference(content);
await _jsRuntime.InvokeVoidAsync("downloadFileFromStream", fileName, streamRef);
}
```
## Filter Panel Component Pattern
### Base Structure
All filter panels follow a consistent pattern with:
- Card container with title
- Optional toolbar (Download Template, Upload Data, Clear Data)
- Input controls (autocomplete, date picker, etc.)
- Data grid of selected items
- Count display
### Two-Way Binding Pattern
```razor
@* ItemNumberFilterPanel.razor - simplified *@
<RadzenCard>
<RadzenStack Gap="1rem">
<RadzenText TextStyle="TextStyle.H6" Text="Filter by item number" />
@if (!IsReadOnly)
{
<RadzenRow Gap="1rem" AlignItems="AlignItems.End">
<RadzenColumn Size="8">
<RadzenAutoComplete @bind-Value="@selectedItemText"
Data="@itemSuggestions"
LoadData="@LoadItemSuggestions"
MinLength="3" />
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenButton Text="Add to filter" Click="@HandleAddItem" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@Items" TItem="ItemViewModel">
<Columns>
<RadzenDataGridColumn Property="ItemNumber" Title="Item Number" />
<RadzenDataGridColumn Property="Description" Title="Description" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn Title="Actions" Width="100px">
<Template Context="item">
<RadzenButton Icon="delete" Click="@(() => HandleDeleteItem(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
<RadzenText Text="@($"# of item numbers: {Items.Count}")" />
</RadzenStack>
</RadzenCard>
@code {
[Parameter] public List<ItemViewModel> Items { get; set; } = new();
[Parameter] public EventCallback<List<ItemViewModel>> ItemsChanged { get; set; }
[Parameter] public bool IsReadOnly { get; set; }
private async Task HandleAddItem()
{
// Add item and notify parent
Items.Add(new ItemViewModel { ... });
await ItemsChanged.InvokeAsync(Items);
}
private async Task HandleDeleteItem(ItemViewModel item)
{
Items.Remove(item);
await ItemsChanged.InvokeAsync(Items);
}
}
```
## Error Handling
### Global Error Boundary
```razor
@* In MainLayout.razor *@
<ErrorBoundary @ref="errorBoundary">
<ChildContent>
@Body
</ChildContent>
<ErrorContent Context="exception">
<RadzenAlert AlertStyle="AlertStyle.Danger" ShowIcon="true">
An error occurred. Please try again or contact support.
@if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development")
{
<pre>@exception.Message</pre>
}
</RadzenAlert>
</ErrorContent>
</ErrorBoundary>
@code {
private ErrorBoundary? errorBoundary;
protected override void OnParametersSet()
{
errorBoundary?.Recover();
}
}
```
### Service-Level Error Handling
```csharp
public class SearchService : ISearchService
{
private readonly HttpClient _httpClient;
private readonly NotificationService _notificationService;
private readonly ILogger<SearchService> _logger;
public async Task<List<SearchViewModel>> GetUserSearchesAsync(CancellationToken ct)
{
try
{
return await _httpClient.GetFromJsonAsync<List<SearchViewModel>>(
"/api/search", ct) ?? new();
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch user searches");
_notificationService.Notify(NotificationSeverity.Error,
"Error", "Failed to load searches. Please try again.");
return new();
}
}
}
```
## Loading States
### Component-Level Loading
```razor
@if (isLoading)
{
<RadzenStack AlignItems="AlignItems.Center" JustifyContent="JustifyContent.Center"
Style="min-height: 200px;">
<RadzenProgressBarCircular Mode="ProgressBarMode.Indeterminate" Size="60" />
<RadzenText Text="Loading..." />
</RadzenStack>
}
else
{
@* Content *@
}
```
### Grid-Level Loading
```razor
<RadzenDataGrid TItem="SearchViewModel" Data="@searches"
IsLoading="@isLoading" Style="min-height: 450px;">
```
## Radzen Component Reference
| UI Element | Component | Key Properties |
|------------|-----------|----------------|
| Layout container | RadzenLayout | RadzenHeader, RadzenBody, RadzenFooter |
| Navigation | RadzenLink, NavigationManager | Path, NavigateTo |
| Data grid | RadzenDataGrid<T> | AllowPaging, AllowSorting, IsLoading |
| Dropdown | RadzenDropDown<T> | Data, TextProperty, ValueProperty |
| Autocomplete | RadzenAutoComplete | LoadData, MinLength |
| Date picker | RadzenDatePicker | DateFormat, Min, Max |
| Text input | RadzenTextBox | Placeholder, Disabled |
| Password | RadzenPassword | Placeholder |
| Button | RadzenButton | Text, Icon, ButtonStyle, IsBusy |
| Card | RadzenCard | - |
| Alert | RadzenAlert | AlertStyle, ShowIcon |
| Badge | RadzenBadge | BadgeStyle, Text |
| Progress | RadzenProgressBarCircular | Mode="Indeterminate" |
| Dialog | DialogService | Confirm, Alert |
| Notification | NotificationService | Notify |
| File upload | RadzenUpload | Url, Accept, Complete |
| Checkbox | RadzenCheckBox | @bind-Value |
| Validation | EditForm + DataAnnotationsValidator | OnValidSubmit |
## NuGet Dependencies
The JdeScoping.Client project already includes required packages:
```xml
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.*" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.*" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.*" />
<PackageReference Include="Radzen.Blazor" Version="8.*" />
</ItemGroup>
```
@@ -0,0 +1,111 @@
# Implement Blazor UI
## Summary
Implement the Blazor WebAssembly user interface for the JDE Scoping Tool, migrating the legacy ASP.NET MVC 5 / Kendo UI implementation to modern Radzen Blazor components. This phase creates all pages (Login, Search List, Search Create/Edit, Search Queue, Refresh Status) and integrates SignalR for real-time status updates.
## Scope
### In Scope
- Blazor WebAssembly project setup and configuration (already exists as JdeScoping.Client)
- Radzen Blazor component library integration (already referenced)
- Main layout with navigation header and footer
- Authentication pages:
- Login page with LDAP authentication form
- Not Authorized page
- Search pages:
- Search List page (user's searches with status badges)
- Search Create/Edit page with filter panels
- Search Queue page (all queued searches)
- Refresh Status page (data sync dashboard)
- Filter panel components:
- TimeSpan Filter Panel
- Work Order Filter Panel
- Item Number Filter Panel
- Profit Center Filter Panel
- Work Center Filter Panel
- Component Lot Filter Panel
- Operator Filter Panel
- Part/Operation/MIS Filter Panel
- SignalR client service for real-time updates
- API client services:
- IAuthService
- ISearchService
- ILookupService
- IFileService
- IRefreshStatusService
- ValidCombination model (search type definitions)
- State management via AuthStateProvider
### Out of Scope
- Backend API implementation (Phase 8: web-api-auth)
- SignalR hub implementation (Phase 8: web-api-auth)
- Unit tests for Blazor components (bUnit testing deferred)
- Mobile-responsive design optimizations
- Offline/PWA functionality
- Dark mode theming
## Motivation
The legacy ASP.NET MVC 5 / Kendo UI implementation has several limitations:
- **License cost**: Kendo UI requires commercial license
- **Framework obsolescence**: ASP.NET MVC 5 is not actively developed
- **JavaScript complexity**: Heavy jQuery-based scripting
- **Page reload model**: Full page reloads for navigation
Blazor WebAssembly with Radzen provides:
- **Free tier**: Radzen Blazor community edition is open source
- **Modern framework**: .NET 10 with full C# component model
- **Type safety**: C# throughout, no JavaScript required for core functionality
- **SPA experience**: No page reloads, smooth navigation
- **SignalR integration**: Native .NET SignalR client
## Acceptance Criteria
1. All pages render correctly in modern browsers (Chrome, Firefox, Edge, Safari)
2. Login page authenticates via backend API (mocked initially)
3. Search List page displays user's searches with real-time status updates
4. Search Create/Edit page:
- Search type dropdown controls filter panel visibility
- All 8 filter panel types functional
- Form validation prevents submission without required filters
- Read-only mode for submitted searches with Copy button
5. Search Queue page displays all queued searches with processor status
6. Refresh Status page displays data sync history with date filtering
7. SignalR connection:
- Connects on page load
- Receives searchUpdate and statusUpdate events
- Reconnects automatically on disconnect
8. All file upload/download flows functional (templates, data upload, results download)
9. Navigation is same-window (no popups or new tabs)
10. `openspec validate implement-blazor-ui --strict` passes
## Dependencies
- **Phase 8**: implement-web-api-auth - Backend API endpoints for authentication, search operations, file handling
- **JdeScoping.Client project**: Already created with Blazor WASM and Radzen packages
- **NuGet packages**:
- `Radzen.Blazor` Version="8.*" (already referenced)
- `Microsoft.AspNetCore.SignalR.Client` Version="10.*" (already referenced)
- `Microsoft.AspNetCore.Components.WebAssembly` Version="10.*" (already referenced)
## Risks
| Risk | Mitigation |
|------|------------|
| API not ready | Mock services return static/test data until Phase 8 complete |
| SignalR connection issues | Implement robust reconnection with exponential backoff |
| File download in WASM | Use JS interop for browser download trigger |
| Large filter lists | Implement virtualization for autocomplete dropdowns |
| Component complexity | Extract reusable filter panel base component |
| Radzen version changes | Pin to specific minor version, test upgrades |
## Related Specs
- `web-ui` - Base specification for UI components and pages
- `web-api-auth` - API endpoints that UI will consume
- `domain-models` - ViewModels used in UI components
@@ -0,0 +1,328 @@
# Web UI Specification Delta
## Purpose
This document captures ADDED and MODIFIED requirements for the Blazor WebAssembly user interface specific to the .NET 10 migration. It supplements the base specification at `openspec/specs/web-ui/spec.md`.
## ADDED Requirements
### Requirement: Blazor WebAssembly Hosting Model
The system SHALL use Blazor WebAssembly (WASM) as the client-side hosting model.
#### Business Rules
- The client application SHALL run entirely in the browser via WebAssembly
- The application SHALL communicate with the server exclusively via HTTP APIs and SignalR
- The application SHALL NOT use server-side Blazor (SignalR for DOM updates)
- Initial load SHALL include the .NET runtime and application assemblies
- Subsequent navigation SHALL NOT require page reloads
#### Scenario: Initial application load
- **WHEN** a user navigates to the application URL
- **THEN** the browser downloads the .NET WASM runtime
- **AND** the Blazor application initializes in the browser
- **AND** subsequent interactions do not trigger server-side rendering
---
### Requirement: JWT Token Authentication
The system SHALL use JWT tokens for API authentication stored in browser localStorage.
#### Inputs
- Login credentials (username, password)
- LDAP authentication endpoint
#### Outputs
- JWT token stored in localStorage
- Claims extracted for AuthenticationState
#### Business Rules
- Tokens SHALL be stored in browser localStorage via JS interop
- Tokens SHALL be automatically attached to outgoing HTTP requests
- Token expiration SHALL trigger re-authentication prompt
- Logout SHALL remove token from localStorage and clear auth state
#### Scenario: Login stores JWT token
- **WHEN** user submits valid credentials
- **THEN** the API returns a JWT token
- **AND** the token is stored in localStorage under key "authToken"
- **AND** AuthStateProvider parses claims from the token
- **AND** subsequent API requests include Authorization header
#### Scenario: Token expiration
- **WHEN** a stored token has expired
- **AND** the user attempts an API call
- **THEN** the API returns 401 Unauthorized
- **AND** the user is redirected to the login page
---
### Requirement: Custom AuthenticationStateProvider
The system SHALL implement a custom AuthenticationStateProvider for JWT-based authentication state.
#### Business Rules
- AuthStateProvider SHALL parse JWT claims without server round-trip
- Claims SHALL include username, roles, and expiration
- State changes SHALL notify Blazor components via NotifyAuthenticationStateChanged
- Invalid/expired tokens SHALL result in anonymous state
#### Scenario: Parse claims from JWT
- **WHEN** AuthStateProvider initializes with a stored token
- **THEN** it parses the token payload (base64-decoded JSON)
- **AND** extracts claims into ClaimsPrincipal
- **AND** sets authentication type to "jwt"
---
### Requirement: SignalR Auto-Reconnect
The system SHALL implement automatic reconnection for SignalR connections with exponential backoff.
#### Reconnection Schedule
| Attempt | Delay |
|---------|-------|
| 1 | 0 seconds |
| 2 | 2 seconds |
| 3 | 5 seconds |
| 4 | 10 seconds |
| 5 | 30 seconds |
#### Business Rules
- SignalR client SHALL use WithAutomaticReconnect configuration
- Reconnection attempts SHALL follow exponential backoff schedule
- UI SHALL indicate connection state during reconnection
- Events received during reconnection SHALL be delivered after reconnect
#### Scenario: Network interruption recovery
- **WHEN** the SignalR connection is lost
- **THEN** the client attempts reconnection per the backoff schedule
- **AND** logs reconnection attempts to console
- **AND** upon successful reconnection, resumes receiving events
---
### Requirement: File Download via JS Interop
The system SHALL use JavaScript interop for triggering browser file downloads.
#### Business Rules
- Excel result files SHALL be downloaded via JS interop function
- Template files SHALL be downloaded via direct URL navigation
- Downloaded files SHALL prompt browser save dialog
- File names SHALL be specified by the server response headers
#### JavaScript Functions
| Function | Purpose |
|----------|---------|
| `downloadFileFromStream` | Download file from DotNetStreamReference |
| `downloadFileFromUrl` | Download file from URL with filename |
#### Scenario: Download search results
- **WHEN** user clicks Download Results button
- **THEN** API request fetches file as stream
- **AND** JS interop triggers browser download dialog
- **AND** file is saved with name "Search_{id}_Results.xlsx"
---
### Requirement: Radzen Component Library Integration
The system SHALL use Radzen Blazor (free tier) for UI components.
#### Service Registration
- DialogService SHALL be registered for confirmation dialogs
- NotificationService SHALL be registered for toast notifications
- RadzenComponents SHALL be registered via AddRadzenComponents()
#### CSS and JavaScript
- Radzen CSS SHALL be included in index.html
- No additional Radzen JavaScript required for core components
#### Scenario: Register Radzen services
- **WHEN** the application starts
- **THEN** DialogService is available for injection
- **AND** NotificationService is available for injection
- **AND** Radzen component styles are applied
---
### Requirement: Async-First Service Design
The system SHALL use async methods for all service operations.
#### Business Rules
- All HTTP client calls SHALL use async methods (GetFromJsonAsync, PostAsJsonAsync)
- All service interfaces SHALL return Task or Task<T>
- Cancellation tokens SHALL be accepted on all service methods
- UI SHALL remain responsive during API calls
#### Scenario: Async API call with loading state
- **WHEN** user triggers a data load operation
- **THEN** loading indicator displays immediately
- **AND** API call executes asynchronously
- **AND** UI updates when data arrives
- **AND** loading indicator hides
---
### Requirement: ILogger Client-Side Logging
The system SHALL use Microsoft.Extensions.Logging for client-side logging.
#### Business Rules
- All services SHALL accept ILogger<T> via constructor injection
- Log levels SHALL be configurable in Program.cs
- Logs SHALL output to browser console in development
- Error logs SHALL include exception details
#### Log Levels by Category
| Category | Minimum Level |
|----------|---------------|
| Default | Information |
| Microsoft.AspNetCore | Warning |
| SignalR | Debug (dev) / Information (prod) |
---
### Requirement: Same-Window Navigation
The system SHALL use same-window navigation for all internal links (no popups or new tabs).
#### Business Rules
- All internal navigation SHALL use NavigationManager.NavigateTo
- Search detail links SHALL NOT open new windows
- Queue links SHALL NOT open new tabs
- Only external links MAY open new tabs (if any exist)
#### Scenario: Navigate to search detail
- **WHEN** user clicks View button on search grid row
- **THEN** NavigationManager.NavigateTo("/search/{id}") is called
- **AND** current window navigates to search detail
- **AND** no new window or tab opens
---
## MODIFIED Requirements
### Requirement: Clear Data Confirmation
The system SHALL display confirmation dialogs before clearing filter data.
#### Inputs
- User clicks Clear Data button on any filter panel
#### Outputs
- Confirmation dialog with OK/Cancel buttons
- Data cleared only if user confirms
#### Business Rules
- DialogService.Confirm SHALL be used for confirmation dialogs
- Dialog title SHALL be "Confirm Clear"
- Dialog message SHALL be "Are you sure you want to clear all items?"
- Cancel SHALL leave data unchanged
#### Scenario: Clear filter with confirmation
- **WHEN** user clicks Clear Data button
- **THEN** confirmation dialog appears
- **AND** if user clicks OK, filter list is cleared
- **AND** if user clicks Cancel, filter list is unchanged
---
### Requirement: Operator Filter Display
The system SHALL display Operator filter entries with AddressNumber, UserID, and FullName properties.
#### Display Format
| Column | Property | Width |
|--------|----------|-------|
| Address Number | AddressNumber | 120px |
| User ID | UserID | 100px |
| Full Name | FullName | Auto |
#### Business Rules
- Autocomplete dropdown SHALL display all three properties
- Format: "{AddressNumber} - {UserID} - {FullName}"
- Grid SHALL display all three properties in separate columns
#### Scenario: Select operator from autocomplete
- **WHEN** user types in operator autocomplete
- **THEN** dropdown shows results with format "12345 - JSMITH - John Smith"
- **AND** selecting adds entry with all three properties to grid
---
### Requirement: Error Handling Without Custom Error Pages
The system SHALL use Blazor's built-in error handling instead of custom error pages.
#### Business Rules
- ErrorBoundary component SHALL wrap page content in MainLayout
- Unhandled exceptions SHALL display inline error message
- Error recovery SHALL be automatic on next navigation
- Development mode SHALL show exception details
- Production mode SHALL show generic error message
#### Scenario: Unhandled exception in component
- **WHEN** an unhandled exception occurs in a Blazor component
- **THEN** ErrorBoundary catches the exception
- **AND** error content displays instead of the faulted component
- **AND** other components remain functional
- **AND** navigating away recovers the error boundary
---
## Migration Notes
| Legacy Pattern | New Pattern | Status |
|----------------|-------------|--------|
| Kendo UI Grid | RadzenDataGrid | ADDED |
| Kendo DatePicker | RadzenDatePicker | ADDED |
| Kendo ComboBox | RadzenAutoComplete | ADDED |
| jQuery AJAX | HttpClient | ADDED |
| Forms Authentication | JWT with localStorage | MODIFIED |
| SignalR (jQuery) | SignalR (.NET client) | ADDED |
| New window navigation | Same-window navigation | MODIFIED |
| Custom error pages | ErrorBoundary | MODIFIED |
| Clear without confirm | Clear with DialogService.Confirm | MODIFIED |
---
## Open Questions
None - all design decisions resolved per best practice recommendations.
@@ -0,0 +1,307 @@
# Tasks: Implement Blazor UI
## Phase 1: Project Configuration
- [x] 001: Verify JdeScoping.Client project configuration
- Location: `NEW/src/JdeScoping.Client/JdeScoping.Client.csproj`
- Verify: Radzen.Blazor, SignalR.Client packages referenced
- Validation: `dotnet build` succeeds
- [x] 002: Configure _Imports.razor
- Location: `NEW/src/JdeScoping.Client/_Imports.razor`
- Add: Radzen, SignalR, Models, Services namespaces
- Validation: Namespace imports resolve
- [x] 003: Update App.razor with routing
- Location: `NEW/src/JdeScoping.Client/App.razor`
- Add: CascadingAuthenticationState, AuthorizeRouteView
- Validation: Router configuration compiles
- [x] 004: Configure Program.cs service registration
- Location: `NEW/src/JdeScoping.Client/Program.cs`
- Add: Radzen services, Auth services, Application services
- Validation: Application starts without DI errors
- [x] 005: Add JS interop file for file downloads
- Location: `NEW/src/JdeScoping.Client/wwwroot/js/interop.js`
- Add: downloadFileFromStream, downloadFileFromUrl functions
- Validation: JS file loads in browser
## Phase 2: Models
- [x] 006: Create ValidCombination model
- Location: `NEW/src/JdeScoping.Client/Models/ValidCombination.cs`
- Source: `OLD/WebInterface/Scripts/model/models.js` (ValidCombination definitions)
- Include: All 16 search type combinations with filter flags
- Validation: GetAll() returns 16 items
- [x] 007: Create LoginModel
- Location: `NEW/src/JdeScoping.Client/Models/LoginModel.cs`
- Properties: Username (required), Password (required)
- Include: DataAnnotations validation attributes
- Validation: Validation fails for empty fields
- [x] 008: Create SearchViewModel
- Location: `NEW/src/JdeScoping.Client/Models/SearchViewModel.cs`
- Properties: ID, Name, UserName, Status, SubmitDT, StartDT, EndDT, Criteria
- Validation: Model compiles with all properties
- [x] 009: Create SearchCriteriaViewModel
- Location: `NEW/src/JdeScoping.Client/Models/SearchCriteriaViewModel.cs`
- Properties: MinimumDT, MaximumDT, all filter collections, ExtractMisData
- Validation: Model compiles with all properties
- [x] 010: Create filter item ViewModels
- Location: `NEW/src/JdeScoping.Client/Models/`
- Files: ItemViewModel, ProfitCenterViewModel, WorkCenterViewModel, OperatorViewModel, WorkOrderViewModel, ComponentLotViewModel, PartOperationViewModel
- Validation: All models compile
- [x] 011: Create SignalR message models
- Location: `NEW/src/JdeScoping.Client/Models/`
- Files: SearchUpdate.cs, StatusUpdate.cs
- Validation: Records compile
- [x] 012: Create DataUpdateViewModel
- Location: `NEW/src/JdeScoping.Client/Models/DataUpdateViewModel.cs`
- Properties: StartDT, EndDT, record counts for each table, WasSuccessful
- Validation: Model compiles
## Phase 3: Authentication Services
- [x] 013: Create ITokenStorageService interface
- Location: `NEW/src/JdeScoping.Client/Auth/ITokenStorageService.cs`
- Methods: GetTokenAsync, SetTokenAsync, RemoveTokenAsync
- Validation: Interface compiles
- [x] 014: Create TokenStorageService
- Location: `NEW/src/JdeScoping.Client/Auth/TokenStorageService.cs`
- Implementation: localStorage via IJSRuntime
- Validation: Can store/retrieve/remove token
- [x] 015: Create AuthStateProvider
- Location: `NEW/src/JdeScoping.Client/Auth/AuthStateProvider.cs`
- Implementation: Parse JWT claims, manage auth state
- Methods: GetAuthenticationStateAsync, NotifyAuthenticationStateChangedAsync, LogoutAsync
- Validation: Auth state changes propagate to components
- [x] 016: Create IAuthService interface
- Location: `NEW/src/JdeScoping.Client/Services/IAuthService.cs`
- Methods: LoginAsync, LogoutAsync
- Validation: Interface compiles
- [x] 017: Create AuthService
- Location: `NEW/src/JdeScoping.Client/Services/AuthService.cs`
- Implementation: Call /api/auth/login, store token
- Returns: AuthResult with Success, ErrorMessage, Token
- Validation: Login flow works with mock endpoint
## Phase 4: SignalR Service
- [x] 018: Create IHubConnectionService interface
- Location: `NEW/src/JdeScoping.Client/Services/IHubConnectionService.cs`
- Methods: StartAsync, StopAsync, GetCachedStatusAsync
- Events: OnSearchUpdate, OnStatusUpdate
- Validation: Interface compiles
- [x] 019: Create HubConnectionService
- Location: `NEW/src/JdeScoping.Client/Services/HubConnectionService.cs`
- Implementation: HubConnectionBuilder with auto-reconnect
- Subscribe: searchUpdate, statusUpdate events
- Validation: Connects to /hubs/status, receives events
## Phase 5: API Client Services
- [x] 020: Create ISearchService interface
- Location: `NEW/src/JdeScoping.Client/Services/ISearchService.cs`
- Methods: GetUserSearchesAsync, GetSearchAsync, CopySearchAsync, SaveSearchAsync, GetQueueAsync, DownloadResultsAsync
- Validation: Interface compiles
- [x] 021: Create SearchService
- Location: `NEW/src/JdeScoping.Client/Services/SearchService.cs`
- Implementation: HttpClient calls to /api/search endpoints
- Include: Error handling, logging
- Validation: Service compiles, handles errors gracefully
- [x] 022: Create ILookupService interface
- Location: `NEW/src/JdeScoping.Client/Services/ILookupService.cs`
- Methods: FindItemsAsync, FindProfitCentersAsync, FindWorkCentersAsync, FindOperatorsAsync
- Validation: Interface compiles
- [x] 023: Create LookupService
- Location: `NEW/src/JdeScoping.Client/Services/LookupService.cs`
- Implementation: HttpClient calls to /api/lookup endpoints
- Validation: Service compiles
- [x] 024: Create IFileService interface
- Location: `NEW/src/JdeScoping.Client/Services/IFileService.cs`
- Methods: DownloadTemplateAsync, DownloadPartNumberTemplateAsync, UploadAsync
- Validation: Interface compiles
- [x] 025: Create FileService
- Location: `NEW/src/JdeScoping.Client/Services/FileService.cs`
- Implementation: File download via JS interop, upload via RadzenUpload
- Validation: Download triggers browser save dialog
- [x] 026: Create IRefreshStatusService interface
- Location: `NEW/src/JdeScoping.Client/Services/IRefreshStatusService.cs`
- Methods: GetRefreshStatusAsync(minDT, maxDT)
- Validation: Interface compiles
- [x] 027: Create RefreshStatusService
- Location: `NEW/src/JdeScoping.Client/Services/RefreshStatusService.cs`
- Implementation: HttpClient calls to /api/refresh-status
- Validation: Service compiles
## Phase 6: Layout Components
- [x] 028: Create MainLayout.razor
- Location: `NEW/src/JdeScoping.Client/Layout/MainLayout.razor`
- Structure: RadzenLayout with Header, Body, Footer
- Include: AuthorizeView for user display, logout button
- Validation: Layout renders header, content, footer
- [x] 029: Create LoadingIndicator component
- Location: `NEW/src/JdeScoping.Client/Components/Shared/LoadingIndicator.razor`
- Structure: RadzenProgressBarCircular with optional message
- Validation: Component renders centered spinner
## Phase 7: Authentication Pages
- [x] 030: Create Login.razor
- Location: `NEW/src/JdeScoping.Client/Pages/Login.razor`
- Route: /login
- Structure: RadzenCard with EditForm, username/password fields
- Features: Validation, error display, loading state, redirect on success
- Validation: Login form submits and redirects
- [x] 031: Create NotAuthorized.razor
- Location: `NEW/src/JdeScoping.Client/Pages/NotAuthorized.razor`
- Route: /not-authorized
- Structure: RadzenAlert with error message, navigation buttons
- Validation: Page displays resource URL from query string
- [x] 032: Create RedirectToLogin component
- Location: `NEW/src/JdeScoping.Client/Components/Shared/RedirectToLogin.razor`
- Implementation: NavigateTo /login with returnUrl
- Validation: Unauthorized access redirects to login
## Phase 8: Search List Page
- [x] 033: Create Searches.razor (Search List)
- Location: `NEW/src/JdeScoping.Client/Pages/Searches.razor`
- Routes: / and /searches
- Structure: RadzenDataGrid with Name, Submitted, Status columns
- Features: New Search button, Queue button, status badges
- SignalR: Subscribe to searchUpdate, update grid in real-time
- Validation: Grid displays user's searches, updates on SignalR events
## Phase 9: Search Create/Edit Page
- [x] 034: Create SearchEdit.razor (main page)
- Location: `NEW/src/JdeScoping.Client/Pages/SearchEdit.razor`
- Routes: /search/create, /search/{Id:int}
- Structure: Search details panel, conditional filter panels
- Features: Search type dropdown, read-only mode, Copy button, Submit button
- SignalR: Subscribe to searchUpdate for current search
- Validation: Form loads, validates, submits correctly
## Phase 10: Filter Panel Components
- [x] 035: Create TimeSpanFilterPanel.razor
- Location: `NEW/src/JdeScoping.Client/Components/FilterPanels/TimeSpanFilterPanel.razor`
- Structure: Min/Max date pickers
- Business rules: Min >= 2002-11-01, Max <= today, Max >= Min
- Validation: Date constraints enforced
- [x] 036: Create WorkOrderFilterPanel.razor
- Location: `NEW/src/JdeScoping.Client/Components/FilterPanels/WorkOrderFilterPanel.razor`
- Structure: Upload/Download/Clear buttons, data grid
- Features: Excel upload parsing, template download
- Validation: Upload populates grid, Clear empties grid
- [x] 037: Create ItemNumberFilterPanel.razor
- Location: `NEW/src/JdeScoping.Client/Components/FilterPanels/ItemNumberFilterPanel.razor`
- Structure: Autocomplete with Add button, data grid
- Features: Search with 3+ chars, prevent duplicates
- Validation: Autocomplete returns results, Add works
- [x] 038: Create ProfitCenterFilterPanel.razor
- Location: `NEW/src/JdeScoping.Client/Components/FilterPanels/ProfitCenterFilterPanel.razor`
- Structure: Autocomplete with Add button, data grid
- Validation: Same pattern as ItemNumber panel
- [x] 039: Create WorkCenterFilterPanel.razor
- Location: `NEW/src/JdeScoping.Client/Components/FilterPanels/WorkCenterFilterPanel.razor`
- Structure: Autocomplete with Add button, data grid
- Validation: Same pattern as ItemNumber panel
- [x] 040: Create OperatorFilterPanel.razor
- Location: `NEW/src/JdeScoping.Client/Components/FilterPanels/OperatorFilterPanel.razor`
- Structure: Autocomplete with Add button, data grid
- Properties: AddressNumber, UserID, FullName
- Validation: Displays all three properties in dropdown
- [x] 041: Create ComponentLotFilterPanel.razor
- Location: `NEW/src/JdeScoping.Client/Components/FilterPanels/ComponentLotFilterPanel.razor`
- Structure: Upload/Download/Clear buttons, data grid
- Properties: LotNumber, ItemNumber
- Validation: Two-column display in grid
- [x] 042: Create PartOperationFilterPanel.razor
- Location: `NEW/src/JdeScoping.Client/Components/FilterPanels/PartOperationFilterPanel.razor`
- Structure: Upload/Download/Clear buttons, data grid
- Properties: ItemNumber, OperationNumber, MisNumber, MisRevision
- Validation: Four-column display in grid
## Phase 11: Search Queue Page
- [x] 043: Create SearchQueue.razor
- Location: `NEW/src/JdeScoping.Client/Pages/SearchQueue.razor`
- Route: /search/queue
- Structure: Processor status panel, data grid of all queued searches
- SignalR: Subscribe to statusUpdate, searchUpdate
- Features: Remove completed searches from grid
- Validation: Grid shows all users' searches, status panel updates
## Phase 12: Refresh Status Page
- [x] 044: Create RefreshStatus.razor
- Location: `NEW/src/JdeScoping.Client/Pages/RefreshStatus.razor`
- Route: /refresh-status
- Structure: Date filter panel, data grid with record counts
- Features: Default to last 7 days, Filter button, WasSuccessful badges
- Validation: Grid displays sync history, filtering works
## Phase 13: Styling and Polish
- [x] 045: Update wwwroot/css/app.css
- Location: `NEW/src/JdeScoping.Client/wwwroot/css/app.css`
- Add: Custom styles for badges, cards, loading states
- Validation: Styles applied consistently
- [x] 046: Update index.html
- Location: `NEW/src/JdeScoping.Client/wwwroot/index.html`
- Add: Radzen CSS reference, JS interop script reference
- Validation: Radzen styles load correctly
## Phase 14: Verification
- [x] 047: Verify all pages render
- Navigate to each route, verify content loads
- Validation: No console errors, all pages accessible
- [x] 048: Verify form validation
- Test Login form, Search Create form with invalid data
- Validation: Validation messages display correctly
- [x] 049: Verify SignalR connection
- Check browser console for connection logs
- Validation: Connection established, reconnects on disconnect
- [x] 050: Run solution build
- Command: `dotnet build NEW/JdeScoping.sln`
- Validation: No errors or warnings
- [x] 051: Run OpenSpec validation
- Command: `openspec validate implement-blazor-ui --strict`
- Validation: No validation errors