refactor: address code review findings across all projects

Apply comprehensive fixes from code reviews including:
- Extract shared utilities (SqlFormatHelper, CellValueConverter, DbDestinationBase)
- Add interface abstractions (IAuthenticationService, IDatabaseMigrator, IMisQueryBuilder)
- Implement SecureStore for encrypted secrets storage
- Fix error handling with proper HTTP status codes and logging
- Optimize double enumeration in DevEtlRegistry
- Add DataSync.Dev README for developer onboarding
- Extract filter panel base classes to reduce duplication
- Update code review docs to mark all issues as fixed
This commit is contained in:
Joseph Doherty
2026-01-19 11:05:36 -05:00
parent 08f5aa1447
commit 604bfe919c
148 changed files with 8696 additions and 1538 deletions
@@ -2,6 +2,7 @@ using System.Net.Http.Json;
using System.Security.Claims;
using JdeScoping.Core.Models.Auth;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Client.Auth;
@@ -10,16 +11,21 @@ namespace JdeScoping.Client.Auth;
/// Works with cookie-based authentication where the browser automatically
/// sends cookies with each request.
/// </summary>
public class AuthStateProvider : AuthenticationStateProvider
public class AuthStateProvider : AuthenticationStateProvider, IAuthStateProvider
{
private readonly IUserStorageService _userStorage;
private readonly HttpClient _httpClient;
private readonly ILogger<AuthStateProvider>? _logger;
private readonly ClaimsPrincipal _anonymous = new(new ClaimsIdentity());
public AuthStateProvider(IUserStorageService userStorage, HttpClient httpClient)
public AuthStateProvider(
IUserStorageService userStorage,
HttpClient httpClient,
ILogger<AuthStateProvider>? logger = null)
{
_userStorage = userStorage;
_httpClient = httpClient;
_logger = logger;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
@@ -56,9 +62,9 @@ public class AuthStateProvider : AuthenticationStateProvider
return await response.Content.ReadFromJsonAsync<UserInfoDto>();
}
}
catch
catch (Exception ex)
{
// Network error or other issue - treat as not authenticated
_logger?.LogWarning(ex, "Session validation failed, treating as unauthenticated");
}
return null;
@@ -0,0 +1,32 @@
using JdeScoping.Core.Models.Auth;
namespace JdeScoping.Client.Auth;
/// <summary>
/// Interface for managing authentication state in the Blazor client.
/// Extracted from AuthStateProvider for testability.
/// </summary>
public interface IAuthStateProvider
{
/// <summary>
/// Called after successful login to update auth state and persist user info.
/// </summary>
/// <param name="user">Authenticated user info from the server.</param>
Task MarkUserAsAuthenticated(UserInfoDto user);
/// <summary>
/// Notifies that authentication state has changed, triggering a re-evaluation.
/// </summary>
void NotifyAuthenticationStateChanged();
/// <summary>
/// Logs out the user by removing cached data and notifying of state change.
/// </summary>
Task LogoutAsync();
/// <summary>
/// Gets the current username from the cached user info.
/// </summary>
/// <returns>The username if authenticated, null otherwise.</returns>
Task<string?> GetUsernameAsync();
}
@@ -1,6 +1,7 @@
@namespace JdeScoping.Client.Components.Admin
@using JdeScoping.Core.Models.Pipelines
@using JdeScoping.Core.Models.Enums
@using JdeScoping.Client.Helpers
<RadzenCard class="rz-mb-4">
<RadzenRow AlignItems="AlignItems.Center" class="rz-mb-3">
@@ -65,7 +66,7 @@
@if (!string.IsNullOrWhiteSpace(Config.Query))
{
<RadzenText TextStyle="TextStyle.Subtitle1" class="rz-mb-2">Query</RadzenText>
<pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.875rem; line-height: 1.5; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 400px; overflow-y: auto;">@FormatSql(Config.Query)</pre>
<pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.875rem; line-height: 1.5; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 400px; overflow-y: auto;">@SqlFormatHelper.FormatSql(Config.Query)</pre>
}
@if (Config.PreScripts?.Count > 0)
@@ -75,7 +76,7 @@
{
var script = Config.PreScripts[i];
<RadzenText TextStyle="TextStyle.Body2" class="rz-mb-1"><strong>Script @(i + 1):</strong></RadzenText>
<pre style="background: #fff3cd; padding: 0.75rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.8rem; line-height: 1.4; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; margin-bottom: 0.5rem;">@FormatSql(script)</pre>
<pre style="background: #fff3cd; padding: 0.75rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.8rem; line-height: 1.4; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; margin-bottom: 0.5rem;">@SqlFormatHelper.FormatSql(script)</pre>
}
}
@@ -86,7 +87,7 @@
{
var script = Config.PostScripts[i];
<RadzenText TextStyle="TextStyle.Body2" class="rz-mb-1"><strong>Script @(i + 1):</strong></RadzenText>
<pre style="background: #d1ecf1; padding: 0.75rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.8rem; line-height: 1.4; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; margin-bottom: 0.5rem;">@FormatSql(script)</pre>
<pre style="background: #d1ecf1; padding: 0.75rem; border-radius: 4px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.8rem; line-height: 1.4; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; margin-bottom: 0.5rem;">@SqlFormatHelper.FormatSql(script)</pre>
}
}
}
@@ -112,50 +113,4 @@
return $"{minutes / 60} hour(s) ({minutes} min)";
return $"{minutes} minutes";
}
private static string FormatSql(string? sql)
{
if (string.IsNullOrWhiteSpace(sql))
return "";
// Format SELECT columns - put each column on its own line
var result = FormatSelectColumns(sql);
// Add line breaks before major clauses
result = result
.Replace(" FROM ", "\nFROM ")
.Replace(" WHERE ", "\nWHERE ")
.Replace(" AND ", "\n AND ")
.Replace(" OR ", "\n OR ")
.Replace(" LEFT ", "\nLEFT ")
.Replace(" RIGHT ", "\nRIGHT ")
.Replace(" INNER ", "\nINNER ")
.Replace(" OUTER ", "\nOUTER ")
.Replace(" JOIN ", " JOIN\n ")
.Replace(" ORDER BY ", "\nORDER BY ")
.Replace(" GROUP BY ", "\nGROUP BY ")
.Replace(" HAVING ", "\nHAVING ");
return result.Trim();
}
private static string FormatSelectColumns(string sql)
{
var selectIndex = sql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase);
var fromIndex = sql.IndexOf(" FROM ", StringComparison.OrdinalIgnoreCase);
if (selectIndex < 0 || fromIndex < 0 || fromIndex <= selectIndex)
return sql;
var beforeSelect = sql[..selectIndex];
var selectKeyword = sql.Substring(selectIndex, 6);
var columnsStart = selectIndex + 6;
var columns = sql[columnsStart..fromIndex];
var afterColumns = sql[fromIndex..];
var columnList = columns.Split(',');
var formattedColumns = string.Join(",\n ", columnList.Select(c => c.Trim()));
return $"{beforeSelect}{selectKeyword} {formattedColumns}{afterColumns}";
}
}
@@ -1,4 +1,5 @@
@namespace JdeScoping.Client.Components.Admin
@using JdeScoping.Client.Helpers
@inject IJSRuntime JS
@if (Visible)
@@ -83,55 +84,7 @@
[Parameter] public string? Title { get; set; }
[Parameter] public string? Sql { get; set; }
private string FormattedSql => FormatSql(Sql);
private static string FormatSql(string? sql)
{
if (string.IsNullOrWhiteSpace(sql))
return "";
// Format SELECT columns - put each column on its own line
var result = FormatSelectColumns(sql);
// Add line breaks before major clauses
result = result
.Replace(" FROM ", "\nFROM ")
.Replace(" WHERE ", "\nWHERE ")
.Replace(" AND ", "\n AND ")
.Replace(" OR ", "\n OR ")
.Replace(" LEFT ", "\nLEFT ")
.Replace(" RIGHT ", "\nRIGHT ")
.Replace(" INNER ", "\nINNER ")
.Replace(" OUTER ", "\nOUTER ")
.Replace(" JOIN ", " JOIN\n ")
.Replace(" ORDER BY ", "\nORDER BY ")
.Replace(" GROUP BY ", "\nGROUP BY ")
.Replace(" HAVING ", "\nHAVING ");
return result.Trim();
}
private static string FormatSelectColumns(string sql)
{
// Find SELECT and FROM positions (case-insensitive)
var selectIndex = sql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase);
var fromIndex = sql.IndexOf(" FROM ", StringComparison.OrdinalIgnoreCase);
if (selectIndex < 0 || fromIndex < 0 || fromIndex <= selectIndex)
return sql;
var beforeSelect = sql[..selectIndex];
var selectKeyword = sql.Substring(selectIndex, 6); // "SELECT"
var columnsStart = selectIndex + 6;
var columns = sql[columnsStart..fromIndex];
var afterColumns = sql[fromIndex..];
// Split columns by comma and rejoin with newlines
var columnList = columns.Split(',');
var formattedColumns = string.Join(",\n ", columnList.Select(c => c.Trim()));
return $"{beforeSelect}{selectKeyword} {formattedColumns}{afterColumns}";
}
private string FormattedSql => SqlFormatHelper.FormatSql(Sql);
private async Task CopyToClipboard()
{
@@ -0,0 +1,159 @@
using Microsoft.AspNetCore.Components;
using Radzen;
namespace JdeScoping.Client.Components.FilterPanels;
/// <summary>
/// Base class for filter panels that use autocomplete search to select items.
/// </summary>
/// <typeparam name="TItem">The type of items displayed in the grid.</typeparam>
public abstract class AutocompleteFilterPanelBase<TItem> : ComponentBase where TItem : class
{
[Inject]
protected DialogService DialogService { get; set; } = default!;
/// <summary>
/// The list of items displayed in the grid.
/// </summary>
[Parameter]
public List<TItem> Items { get; set; } = [];
/// <summary>
/// Callback when the items list changes.
/// </summary>
[Parameter]
public EventCallback<List<TItem>> ItemsChanged { get; set; }
/// <summary>
/// Whether the panel is in read-only mode.
/// </summary>
[Parameter]
public bool IsReadOnly { get; set; }
/// <summary>
/// The current search text.
/// </summary>
protected string SearchText { get; set; } = "";
/// <summary>
/// The search results from the API.
/// </summary>
protected List<TItem> SearchResults { get; set; } = [];
/// <summary>
/// The currently selected item from search results.
/// </summary>
protected TItem? SelectedItem { get; set; }
/// <summary>
/// Gets the title displayed in the panel header.
/// </summary>
protected abstract string PanelTitle { get; }
/// <summary>
/// Gets the placeholder text for the autocomplete input.
/// </summary>
protected abstract string SearchPlaceholder { get; }
/// <summary>
/// Gets the label for the autocomplete form field.
/// </summary>
protected abstract string SearchFieldLabel { get; }
/// <summary>
/// Gets the property name used for the autocomplete text display.
/// </summary>
protected abstract string AutocompleteTextProperty { get; }
/// <summary>
/// Gets the confirmation message for clearing data.
/// </summary>
protected abstract string ClearConfirmMessage { get; }
/// <summary>
/// Performs the search API call and returns matching items.
/// </summary>
protected abstract Task<List<TItem>> SearchApiAsync(string filter);
/// <summary>
/// Gets the unique key value for an item.
/// </summary>
protected abstract object GetItemKey(TItem item);
/// <summary>
/// Gets the display text value for an item (used for matching in autocomplete).
/// </summary>
protected abstract string GetDisplayText(TItem item);
/// <summary>
/// Handles the autocomplete search.
/// </summary>
protected async Task OnSearchAsync(LoadDataArgs args)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
SearchResults = await SearchApiAsync(args.Filter);
}
else
{
SearchResults = [];
}
}
/// <summary>
/// Handles selection from the autocomplete.
/// </summary>
protected void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
SelectedItem = SearchResults.FirstOrDefault(i => GetDisplayText(i) == text);
}
else
{
SelectedItem = null;
}
}
/// <summary>
/// Adds the selected item to the list.
/// </summary>
protected async Task AddItemAsync()
{
if (SelectedItem != null)
{
var selectedKey = GetItemKey(SelectedItem);
var isDuplicate = Items.Any(i => GetItemKey(i).Equals(selectedKey));
if (!isDuplicate)
{
Items.Add(SelectedItem);
await ItemsChanged.InvokeAsync(Items);
}
}
SearchText = "";
SelectedItem = null;
}
/// <summary>
/// Removes an item from the list.
/// </summary>
protected async Task DeleteItem(TItem item)
{
Items.Remove(item);
await ItemsChanged.InvokeAsync(Items);
}
/// <summary>
/// Clears all items after confirmation.
/// </summary>
protected async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm(ClearConfirmMessage, "Confirm Clear");
if (confirmed == true)
{
Items.Clear();
await ItemsChanged.InvokeAsync(Items);
}
}
}
@@ -1,26 +1,25 @@
@* Component lot filter panel with upload/download/clear functionality *@
@inherits FileUploadFilterPanelBase<ComponentLotViewModel>
@using JdeScoping.Core.ApiContracts
@using JdeScoping.Core.ViewModels
@inject IFileApiClient FileApi
@inject DialogService DialogService
@inject NotificationService NotificationService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Component Lot</RadzenText>
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
@if (!IsReadOnly)
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="componentLotFileInput" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
</RadzenStack>
<RadzenDataGrid Data="@ComponentLots" TItem="ComponentLotViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<RadzenDataGrid Data="@Items" TItem="ComponentLotViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="ComponentLotViewModel" Property="LotNumber" Title="Lot Number" />
<RadzenDataGridColumn TItem="ComponentLotViewModel" Property="ItemNumber" Title="Item Number" />
@@ -28,86 +27,46 @@
</RadzenDataGrid>
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
<strong># of component lots: @ComponentLots.Count</strong>
<strong>@CountLabel: @Items.Count</strong>
</RadzenText>
</RadzenCard>
@code {
[Parameter]
public List<ComponentLotViewModel> ComponentLots { get; set; } = [];
protected override string PanelTitle => "Filter by Component Lot";
protected override string CountLabel => "# of component lots";
protected override string FileInputId => "componentLotFileInput";
protected override string TemplateFilename => "componentlots_template.xlsx";
protected override string EntityName => "component lots";
protected override string ClearConfirmMessage => "Are you sure you want to clear all component lots?";
[Parameter]
public EventCallback<List<ComponentLotViewModel>> ComponentLotsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private bool _isUploading;
private async Task DownloadTemplateAsync()
protected override async Task<byte[]?> DownloadTemplateApiAsync()
{
var lotData = ComponentLots.Select(cl => new LotViewModel { LotNumber = cl.LotNumber, ItemNumber = cl.ItemNumber }).ToList().AsReadOnly();
var lotData = Items.Select(cl => new LotViewModel { LotNumber = cl.LotNumber, ItemNumber = cl.ItemNumber }).ToList().AsReadOnly();
var result = await FileApi.DownloadComponentLotsTemplateAsync(lotData);
byte[]? bytes = null;
result.Switch(
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "componentlots_template.xlsx", bytes); },
data => { bytes = data; },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
);
return bytes;
}
private async Task TriggerFileInput()
protected override async Task<List<ComponentLotViewModel>?> UploadFileApiAsync(Stream stream, string filename)
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('componentLotFileInput').click()");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var result = await FileApi.UploadComponentLotsAsync(stream, e.File.Name);
result.Switch(
lots =>
{
ComponentLots.Clear();
ComponentLots.AddRange(lots.Select(l => new ComponentLotViewModel { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber }));
_ = ComponentLotsChanged.InvokeAsync(ComponentLots);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {lots.Count} component lots.");
},
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
);
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
_isUploading = false;
}
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all component lots?", "Confirm Clear");
if (confirmed == true)
{
ComponentLots.Clear();
await ComponentLotsChanged.InvokeAsync(ComponentLots);
}
var result = await FileApi.UploadComponentLotsAsync(stream, filename);
List<ComponentLotViewModel>? items = null;
result.Switch(
lots => { items = lots.Select(l => new ComponentLotViewModel { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber }).ToList(); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
);
return items;
}
}
@@ -0,0 +1,149 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using Radzen;
namespace JdeScoping.Client.Components.FilterPanels;
/// <summary>
/// Base class for filter panels that use file upload for data input.
/// </summary>
/// <typeparam name="TItem">The type of items displayed in the grid.</typeparam>
public abstract class FileUploadFilterPanelBase<TItem> : ComponentBase where TItem : class
{
[Inject]
protected DialogService DialogService { get; set; } = default!;
[Inject]
protected NotificationService NotificationService { get; set; } = default!;
[Inject]
protected IJSRuntime JSRuntime { get; set; } = default!;
/// <summary>
/// The list of items displayed in the grid.
/// </summary>
[Parameter]
public List<TItem> Items { get; set; } = [];
/// <summary>
/// Callback when the items list changes.
/// </summary>
[Parameter]
public EventCallback<List<TItem>> ItemsChanged { get; set; }
/// <summary>
/// Whether the panel is in read-only mode.
/// </summary>
[Parameter]
public bool IsReadOnly { get; set; }
/// <summary>
/// Indicates whether a file upload is in progress.
/// </summary>
protected bool IsUploading { get; set; }
/// <summary>
/// Gets the title displayed in the panel header.
/// </summary>
protected abstract string PanelTitle { get; }
/// <summary>
/// Gets the count label text (e.g., "# of work orders").
/// </summary>
protected abstract string CountLabel { get; }
/// <summary>
/// Gets the HTML element ID for the file input.
/// </summary>
protected abstract string FileInputId { get; }
/// <summary>
/// Gets the filename for the downloaded template.
/// </summary>
protected abstract string TemplateFilename { get; }
/// <summary>
/// Gets the entity name for notifications (e.g., "work orders").
/// </summary>
protected abstract string EntityName { get; }
/// <summary>
/// Gets the confirmation message for clearing data.
/// </summary>
protected abstract string ClearConfirmMessage { get; }
/// <summary>
/// Downloads the template file from the API.
/// </summary>
protected abstract Task<byte[]?> DownloadTemplateApiAsync();
/// <summary>
/// Uploads the file and returns the parsed items.
/// </summary>
protected abstract Task<List<TItem>?> UploadFileApiAsync(Stream stream, string filename);
/// <summary>
/// Downloads the template.
/// </summary>
protected async Task DownloadTemplateAsync()
{
var bytes = await DownloadTemplateApiAsync();
if (bytes != null)
{
await JSRuntime.InvokeVoidAsync("downloadFile", TemplateFilename, bytes);
}
}
/// <summary>
/// Triggers the file input click.
/// </summary>
protected async Task TriggerFileInput()
{
await JSRuntime.InvokeVoidAsync("jdeScopingInterop.clickElementById", FileInputId);
}
/// <summary>
/// Handles file selection and upload.
/// </summary>
protected async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
IsUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var uploadedItems = await UploadFileApiAsync(stream, e.File.Name);
if (uploadedItems != null)
{
Items.Clear();
Items.AddRange(uploadedItems);
await ItemsChanged.InvokeAsync(Items);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {uploadedItems.Count} {EntityName}.");
}
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
IsUploading = false;
}
}
/// <summary>
/// Clears all items after confirmation.
/// </summary>
protected async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm(ClearConfirmMessage, "Confirm Clear");
if (confirmed == true)
{
Items.Clear();
await ItemsChanged.InvokeAsync(Items);
}
}
}
@@ -1,5 +1,6 @@
@* Item number filter panel with autocomplete and grid *@
@using JdeScoping.Core.ApiContracts
@using Microsoft.JSInterop
@inject ILookupApiClient LookupApi
@inject IFileApiClient FileApi
@inject DialogService DialogService
@@ -128,7 +129,21 @@
{
var result = await FileApi.DownloadItemsTemplateAsync(Items.AsReadOnly());
result.Switch(
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "items_template.xlsx", bytes); },
bytes =>
{
// Fire-and-forget with error handling to prevent silent failures
_ = Task.Run(async () =>
{
try
{
await JSRuntime.InvokeVoidAsync("downloadFile", "items_template.xlsx", bytes);
}
catch (JSException ex)
{
Console.WriteLine($"JS interop failed during template download: {ex.Message}");
}
});
},
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
@@ -139,7 +154,7 @@
private async Task TriggerFileInput()
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('itemNumberFileInput').click()");
await JSRuntime.InvokeVoidAsync("jdeScopingInterop.clickElementById", "itemNumberFileInput");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
@@ -157,7 +172,18 @@
{
Items.Clear();
Items.AddRange(items);
_ = ItemsChanged.InvokeAsync(Items);
// Fire-and-forget with error handling to prevent silent failures
_ = Task.Run(async () =>
{
try
{
await ItemsChanged.InvokeAsync(Items);
}
catch (Exception ex)
{
Console.WriteLine($"EventCallback invocation failed: {ex.Message}");
}
});
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {items.Count} items.");
},
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
@@ -1,12 +1,12 @@
@* Operator filter panel with autocomplete and grid *@
@inherits AutocompleteFilterPanelBase<OperatorViewModel>
@using JdeScoping.Core.ApiContracts
@using JdeScoping.Client.Extensions
@inject ILookupApiClient LookupApi
@inject DialogService DialogService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Operator</RadzenText>
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
@if (!IsReadOnly)
{
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
@@ -17,20 +17,20 @@
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Name" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="FullName"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search operators (3+ chars)..."
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@Operators" TItem="OperatorViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<RadzenDataGrid Data="@Items" TItem="OperatorViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="OperatorViewModel" Property="AddressNumber" Title="Address Number" Width="150px" />
<RadzenDataGridColumn TItem="OperatorViewModel" Property="UserID" Title="User Name" Width="150px" />
@@ -48,75 +48,27 @@
</RadzenCard>
@code {
[Parameter]
public List<OperatorViewModel> Operators { get; set; } = [];
protected override string PanelTitle => "Filter by Operator";
protected override string SearchPlaceholder => "Search operators (3+ chars)...";
protected override string SearchFieldLabel => "Name";
protected override string AutocompleteTextProperty => "FullName";
protected override string ClearConfirmMessage => "Are you sure you want to clear all operators?";
[Parameter]
public EventCallback<List<OperatorViewModel>> OperatorsChanged { get; set; }
protected override object GetItemKey(OperatorViewModel item) => item.UserId;
protected override string GetDisplayText(OperatorViewModel item) => item.FullName;
[Parameter]
public bool IsReadOnly { get; set; }
private string _searchText = "";
private List<OperatorViewModel> _searchResults = [];
private OperatorViewModel? _selectedItem;
private async Task OnSearchAsync(LoadDataArgs args)
protected override async Task<List<OperatorViewModel>> SearchApiAsync(string filter)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
var result = await LookupApi.FindOperatorsAsync(args.Filter);
result.Switch(
jdeUsers => { _searchResults = jdeUsers.ToClientOperatorList(); },
_ => { _searchResults = []; },
_ => { _searchResults = []; },
_ => { _searchResults = []; },
_ => { _searchResults = []; },
_ => { _searchResults = []; }
);
}
else
{
_searchResults = [];
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.FullName == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !Operators.Any(i => i.UserId == _selectedItem.UserId))
{
Operators.Add(_selectedItem);
await OperatorsChanged.InvokeAsync(Operators);
}
_searchText = "";
_selectedItem = null;
}
private async Task DeleteItem(OperatorViewModel item)
{
Operators.Remove(item);
await OperatorsChanged.InvokeAsync(Operators);
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all operators?", "Confirm Clear");
if (confirmed == true)
{
Operators.Clear();
await OperatorsChanged.InvokeAsync(Operators);
}
var result = await LookupApi.FindOperatorsAsync(filter);
List<OperatorViewModel> items = [];
result.Switch(
jdeUsers => { items = jdeUsers.ToClientOperatorList(); },
_ => { },
_ => { },
_ => { },
_ => { },
_ => { }
);
return items;
}
}
@@ -1,25 +1,24 @@
@* Part operation/MIS filter panel with upload/download/clear functionality *@
@inherits FileUploadFilterPanelBase<PartOperationViewModel>
@using JdeScoping.Core.ApiContracts
@inject IFileApiClient FileApi
@inject DialogService DialogService
@inject NotificationService NotificationService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter By Item/Operation/MIS</RadzenText>
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
@if (!IsReadOnly)
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="partOperationFileInput" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
</RadzenStack>
<RadzenDataGrid Data="@PartOperations" TItem="PartOperationViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<RadzenDataGrid Data="@Items" TItem="PartOperationViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="ItemNumber" Title="Item Number" />
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="OperationNumber" Title="Operation Step Number" />
@@ -29,85 +28,45 @@
</RadzenDataGrid>
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
<strong># of item / operations: @PartOperations.Count</strong>
<strong>@CountLabel: @Items.Count</strong>
</RadzenText>
</RadzenCard>
@code {
[Parameter]
public List<PartOperationViewModel> PartOperations { get; set; } = [];
protected override string PanelTitle => "Filter By Item/Operation/MIS";
protected override string CountLabel => "# of item / operations";
protected override string FileInputId => "partOperationFileInput";
protected override string TemplateFilename => "partoperations_template.xlsx";
protected override string EntityName => "part operations";
protected override string ClearConfirmMessage => "Are you sure you want to clear all item/operation/MIS entries?";
[Parameter]
public EventCallback<List<PartOperationViewModel>> PartOperationsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private bool _isUploading;
private async Task DownloadTemplateAsync()
protected override async Task<byte[]?> DownloadTemplateApiAsync()
{
var result = await FileApi.DownloadPartOperationsTemplateAsync(PartOperations.AsReadOnly());
var result = await FileApi.DownloadPartOperationsTemplateAsync(Items.AsReadOnly());
byte[]? bytes = null;
result.Switch(
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "partoperations_template.xlsx", bytes); },
data => { bytes = data; },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
);
return bytes;
}
private async Task TriggerFileInput()
protected override async Task<List<PartOperationViewModel>?> UploadFileApiAsync(Stream stream, string filename)
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('partOperationFileInput').click()");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var result = await FileApi.UploadPartOperationsAsync(stream, e.File.Name);
result.Switch(
partOperations =>
{
PartOperations.Clear();
PartOperations.AddRange(partOperations);
_ = PartOperationsChanged.InvokeAsync(PartOperations);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {partOperations.Count} part operations.");
},
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
);
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
_isUploading = false;
}
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all item/operation/MIS entries?", "Confirm Clear");
if (confirmed == true)
{
PartOperations.Clear();
await PartOperationsChanged.InvokeAsync(PartOperations);
}
var result = await FileApi.UploadPartOperationsAsync(stream, filename);
List<PartOperationViewModel>? items = null;
result.Switch(
partOperations => { items = partOperations.ToList(); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
);
return items;
}
}
@@ -1,11 +1,11 @@
@* Profit center filter panel with autocomplete and grid *@
@inherits AutocompleteFilterPanelBase<ProfitCenterViewModel>
@using JdeScoping.Core.ApiContracts
@inject ILookupApiClient LookupApi
@inject DialogService DialogService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Profit Center</RadzenText>
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
@if (!IsReadOnly)
{
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
@@ -16,20 +16,20 @@
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Profit Center" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="Code"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search profit centers (3+ chars)..."
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@ProfitCenters" TItem="ProfitCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<RadzenDataGrid Data="@Items" TItem="ProfitCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Code" Title="Profit Center" Width="150px" />
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Description" Title="Description" />
@@ -46,75 +46,27 @@
</RadzenCard>
@code {
[Parameter]
public List<ProfitCenterViewModel> ProfitCenters { get; set; } = [];
protected override string PanelTitle => "Filter by Profit Center";
protected override string SearchPlaceholder => "Search profit centers (3+ chars)...";
protected override string SearchFieldLabel => "Profit Center";
protected override string AutocompleteTextProperty => "Code";
protected override string ClearConfirmMessage => "Are you sure you want to clear all profit centers?";
[Parameter]
public EventCallback<List<ProfitCenterViewModel>> ProfitCentersChanged { get; set; }
protected override object GetItemKey(ProfitCenterViewModel item) => item.Code;
protected override string GetDisplayText(ProfitCenterViewModel item) => item.Code;
[Parameter]
public bool IsReadOnly { get; set; }
private string _searchText = "";
private List<ProfitCenterViewModel> _searchResults = [];
private ProfitCenterViewModel? _selectedItem;
private async Task OnSearchAsync(LoadDataArgs args)
protected override async Task<List<ProfitCenterViewModel>> SearchApiAsync(string filter)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
var result = await LookupApi.FindProfitCentersAsync(args.Filter);
result.Switch(
profitCenters => { _searchResults = profitCenters.ToList(); },
_ => { _searchResults = []; },
_ => { _searchResults = []; },
_ => { _searchResults = []; },
_ => { _searchResults = []; },
_ => { _searchResults = []; }
);
}
else
{
_searchResults = [];
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.Code == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !ProfitCenters.Any(i => i.Code == _selectedItem.Code))
{
ProfitCenters.Add(_selectedItem);
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
}
_searchText = "";
_selectedItem = null;
}
private async Task DeleteItem(ProfitCenterViewModel item)
{
ProfitCenters.Remove(item);
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all profit centers?", "Confirm Clear");
if (confirmed == true)
{
ProfitCenters.Clear();
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
}
var result = await LookupApi.FindProfitCentersAsync(filter);
List<ProfitCenterViewModel> items = [];
result.Switch(
profitCenters => { items = profitCenters.ToList(); },
_ => { },
_ => { },
_ => { },
_ => { },
_ => { }
);
return items;
}
}
@@ -1,11 +1,11 @@
@* Work center filter panel with autocomplete and grid *@
@inherits AutocompleteFilterPanelBase<WorkCenterViewModel>
@using JdeScoping.Core.ApiContracts
@inject ILookupApiClient LookupApi
@inject DialogService DialogService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Work Center</RadzenText>
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
@if (!IsReadOnly)
{
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
@@ -16,20 +16,20 @@
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Work Center" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="Code"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search work centers (3+ chars)..."
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@WorkCenters" TItem="WorkCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<RadzenDataGrid Data="@Items" TItem="WorkCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="WorkCenterViewModel" Property="Code" Title="Work Center" Width="150px" />
<RadzenDataGridColumn TItem="WorkCenterViewModel" Property="Description" Title="Description" />
@@ -46,75 +46,27 @@
</RadzenCard>
@code {
[Parameter]
public List<WorkCenterViewModel> WorkCenters { get; set; } = [];
protected override string PanelTitle => "Filter by Work Center";
protected override string SearchPlaceholder => "Search work centers (3+ chars)...";
protected override string SearchFieldLabel => "Work Center";
protected override string AutocompleteTextProperty => "Code";
protected override string ClearConfirmMessage => "Are you sure you want to clear all work centers?";
[Parameter]
public EventCallback<List<WorkCenterViewModel>> WorkCentersChanged { get; set; }
protected override object GetItemKey(WorkCenterViewModel item) => item.Code;
protected override string GetDisplayText(WorkCenterViewModel item) => item.Code;
[Parameter]
public bool IsReadOnly { get; set; }
private string _searchText = "";
private List<WorkCenterViewModel> _searchResults = [];
private WorkCenterViewModel? _selectedItem;
private async Task OnSearchAsync(LoadDataArgs args)
protected override async Task<List<WorkCenterViewModel>> SearchApiAsync(string filter)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
var result = await LookupApi.FindWorkCentersAsync(args.Filter);
result.Switch(
workCenters => { _searchResults = workCenters.ToList(); },
_ => { _searchResults = []; },
_ => { _searchResults = []; },
_ => { _searchResults = []; },
_ => { _searchResults = []; },
_ => { _searchResults = []; }
);
}
else
{
_searchResults = [];
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.Code == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !WorkCenters.Any(i => i.Code == _selectedItem.Code))
{
WorkCenters.Add(_selectedItem);
await WorkCentersChanged.InvokeAsync(WorkCenters);
}
_searchText = "";
_selectedItem = null;
}
private async Task DeleteItem(WorkCenterViewModel item)
{
WorkCenters.Remove(item);
await WorkCentersChanged.InvokeAsync(WorkCenters);
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all work centers?", "Confirm Clear");
if (confirmed == true)
{
WorkCenters.Clear();
await WorkCentersChanged.InvokeAsync(WorkCenters);
}
var result = await LookupApi.FindWorkCentersAsync(filter);
List<WorkCenterViewModel> items = [];
result.Switch(
workCenters => { items = workCenters.ToList(); },
_ => { },
_ => { },
_ => { },
_ => { },
_ => { }
);
return items;
}
}
@@ -1,25 +1,24 @@
@* Work order filter panel with upload/download/clear functionality *@
@inherits FileUploadFilterPanelBase<WorkOrderViewModel>
@using JdeScoping.Core.ApiContracts
@inject IFileApiClient FileApi
@inject DialogService DialogService
@inject NotificationService NotificationService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Work Order</RadzenText>
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">@PanelTitle</RadzenText>
@if (!IsReadOnly)
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="workOrderFileInput" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="@FileInputId" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
Click="@(() => TriggerFileInput())" IsBusy="@IsUploading" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
</RadzenStack>
<RadzenDataGrid Data="@WorkOrders" TItem="WorkOrderViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<RadzenDataGrid Data="@Items" TItem="WorkOrderViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="WorkOrderNumber" Title="Work Order Number" />
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="ItemNumber" Title="Item Number" />
@@ -27,85 +26,45 @@
</RadzenDataGrid>
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
<strong># of work orders: @WorkOrders.Count</strong>
<strong>@CountLabel: @Items.Count</strong>
</RadzenText>
</RadzenCard>
@code {
[Parameter]
public List<WorkOrderViewModel> WorkOrders { get; set; } = [];
protected override string PanelTitle => "Filter by Work Order";
protected override string CountLabel => "# of work orders";
protected override string FileInputId => "workOrderFileInput";
protected override string TemplateFilename => "workorders_template.xlsx";
protected override string EntityName => "work orders";
protected override string ClearConfirmMessage => "Are you sure you want to clear all work orders?";
[Parameter]
public EventCallback<List<WorkOrderViewModel>> WorkOrdersChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private bool _isUploading;
private async Task DownloadTemplateAsync()
protected override async Task<byte[]?> DownloadTemplateApiAsync()
{
var result = await FileApi.DownloadWorkOrdersTemplateAsync(WorkOrders.AsReadOnly());
var result = await FileApi.DownloadWorkOrdersTemplateAsync(Items.AsReadOnly());
byte[]? bytes = null;
result.Switch(
bytes => { _ = JSRuntime.InvokeVoidAsync("downloadFile", "workorders_template.xlsx", bytes); },
data => { bytes = data; },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Template not found."); },
validation => { NotificationService.Notify(NotificationSeverity.Error, "Error", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
);
return bytes;
}
private async Task TriggerFileInput()
protected override async Task<List<WorkOrderViewModel>?> UploadFileApiAsync(Stream stream, string filename)
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('workOrderFileInput').click()");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var result = await FileApi.UploadWorkOrdersAsync(stream, e.File.Name);
result.Switch(
workOrders =>
{
WorkOrders.Clear();
WorkOrders.AddRange(workOrders);
_ = WorkOrdersChanged.InvokeAsync(WorkOrders);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {workOrders.Count} work orders.");
},
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
);
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
_isUploading = false;
}
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all work orders?", "Confirm Clear");
if (confirmed == true)
{
WorkOrders.Clear();
await WorkOrdersChanged.InvokeAsync(WorkOrders);
}
var result = await FileApi.UploadWorkOrdersAsync(stream, filename);
List<WorkOrderViewModel>? items = null;
result.Switch(
workOrders => { items = workOrders.ToList(); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Upload endpoint not found."); },
validation => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", string.Join("; ", validation.FieldErrors.SelectMany(e => e.Value))); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
_ => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
error => { NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", error.Message); }
);
return items;
}
}
@@ -0,0 +1,126 @@
@*
FilterVisibilityManager.razor - Filter panel visibility controller.
Manages which filter panels are visible based on the selected search type.
Cascades itself to child components so they can check visibility.
*@
@namespace JdeScoping.Client.Components.Search
<CascadingValue Value="this">
@ChildContent
</CascadingValue>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public SearchCriteriaViewModel Criteria { get; set; } = new();
[Parameter]
public EventCallback<ValidCombination?> OnSearchTypeChanged { get; set; }
private IReadOnlyList<ValidCombination> _validCombinations = ValidCombination.GetAll();
private int? _selectedSearchType;
public int? SelectedSearchType
{
get => _selectedSearchType;
set
{
if (_selectedSearchType != value)
{
_selectedSearchType = value;
var combo = _validCombinations.FirstOrDefault(c => c.Id == value);
if (combo != null)
{
UpdateFilterVisibility(combo);
}
OnSearchTypeChanged.InvokeAsync(combo);
}
}
}
public IReadOnlyList<ValidCombination> ValidCombinations => _validCombinations;
// Filter visibility flags
public bool ShowTimespan { get; private set; }
public bool ShowWorkOrder { get; private set; }
public bool ShowItemNumber { get; private set; }
public bool ShowProfitCenter { get; private set; }
public bool ShowWorkCenter { get; private set; }
public bool ShowComponentLot { get; private set; }
public bool ShowOperator { get; private set; }
public bool ShowItemOperationMis { get; private set; }
public bool ShowExtractMis { get; private set; }
protected override void OnParametersSet()
{
DetectSearchType();
}
public void DetectSearchType()
{
bool hasTimespan = Criteria.MinimumDt.HasValue || Criteria.MaximumDt.HasValue;
bool hasWorkOrder = Criteria.WorkOrders.Count > 0;
bool hasItemNumber = Criteria.Items.Count > 0;
bool hasProfitCenter = Criteria.ProfitCenters.Count > 0;
bool hasWorkCenter = Criteria.WorkCenters.Count > 0;
bool hasComponentLot = Criteria.ComponentLots.Count > 0;
bool hasOperator = Criteria.Operators.Count > 0;
bool hasPartOperation = Criteria.PartOperations.Count > 0;
bool hasExtractMis = Criteria.ExtractMisData;
foreach (var combo in _validCombinations)
{
if (combo.Matches(hasTimespan, hasWorkOrder, hasItemNumber, hasProfitCenter, hasWorkCenter, hasComponentLot, hasOperator, hasPartOperation, hasExtractMis))
{
_selectedSearchType = combo.Id;
UpdateFilterVisibility(combo);
break;
}
}
}
private void UpdateFilterVisibility(ValidCombination combo)
{
ShowTimespan = combo.Timespan;
ShowWorkOrder = combo.WorkOrder;
ShowItemNumber = combo.ItemNumber;
ShowProfitCenter = combo.ProfitCenter;
ShowWorkCenter = combo.WorkCenter;
ShowComponentLot = combo.ComponentLot;
ShowOperator = combo.Operator;
ShowItemOperationMis = combo.ItemOperationMis;
ShowExtractMis = combo.ExtractMis;
// Set ExtractMisData flag based on combo
Criteria.ExtractMisData = combo.ExtractMis;
}
public string? ValidateFilters()
{
if (ShowWorkOrder && Criteria.WorkOrders.Count == 0)
return "At least one work order must be specified for the work order filter.";
if (ShowItemNumber && Criteria.Items.Count == 0)
return "At least one item number must be specified for the item number filter.";
if (ShowProfitCenter && Criteria.ProfitCenters.Count == 0)
return "At least one profit center must be specified for the profit center filter.";
if (ShowWorkCenter && Criteria.WorkCenters.Count == 0)
return "At least one work center must be specified for the work center filter.";
if (ShowComponentLot && Criteria.ComponentLots.Count == 0)
return "At least one component lot must be specified for the component lot filter.";
if (ShowOperator && Criteria.Operators.Count == 0)
return "At least one operator must be specified for the operator filter.";
if (ShowItemOperationMis && Criteria.PartOperations.Count == 0)
return "At least one item/operation/MIS entry must be specified for the MIS data filter.";
return null;
}
}
@@ -0,0 +1,90 @@
@*
SearchDetailsSection.razor - Search metadata input section.
Provides inputs for search name and type selection.
Integrates with FilterVisibilityManager to show/hide filter panels based on search type.
*@
@namespace JdeScoping.Client.Components.Search
<RadzenCard class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Details</RadzenText>
<RadzenRow Gap="1rem">
<RadzenColumn Size="12">
<RadzenFormField Text="Search Type" Style="width: 100%;">
<RadzenDropDown @bind-Value="SelectedSearchType" Data="@ValidCombinations" TextProperty="Name" ValueProperty="Id"
Placeholder="Select type" Disabled="@Search.IsReadOnly" Change="@OnSearchTypeChangedHandler" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="12">
<RadzenFormField Text="Name" Style="width: 100%;">
<RadzenTextBox @bind-Value="Search.Name" Disabled="@Search.IsReadOnly" Style="width: 100%;" />
</RadzenFormField>
<ValidationMessage For="@(() => Search.Name)" class="validation-message text-danger" />
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="4">
<RadzenFormField Text="Submitted At" Style="width: 100%;">
<RadzenTextBox Value="@(Search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Started At" Style="width: 100%;">
<RadzenTextBox Value="@(Search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Completed At" Style="width: 100%;">
<RadzenTextBox Value="@(Search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="4">
<RadzenFormField Text="User" Style="width: 100%;">
<RadzenTextBox Value="@Search.UserName" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Status" Style="width: 100%;">
<RadzenTextBox Value="@Search.Status" ReadOnly="true" Style="@($"width: 100%; background-color: {Search.StatusColor};")" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
@if (Search.HasResults)
{
<RadzenFormField Text=" " Style="width: 100%;">
<RadzenButton Text="Download Results" Icon="download" ButtonStyle="ButtonStyle.Success" Click="@OnDownloadResults" Style="width: 100%;" />
</RadzenFormField>
}
</RadzenColumn>
</RadzenRow>
</RadzenCard>
@code {
[Parameter]
public ClientSearchViewModel Search { get; set; } = new();
[Parameter]
public int? SelectedSearchType { get; set; }
[Parameter]
public EventCallback<int?> SelectedSearchTypeChanged { get; set; }
[Parameter]
public IReadOnlyList<ValidCombination> ValidCombinations { get; set; } = ValidCombination.GetAll();
[Parameter]
public EventCallback OnDownloadResults { get; set; }
private async Task OnSearchTypeChangedHandler()
{
await SelectedSearchTypeChanged.InvokeAsync(SelectedSearchType);
}
}
@@ -0,0 +1,40 @@
@*
SignalRStatusHandler.razor - Real-time search status handler.
Subscribes to SignalR hub for search status updates.
Filters updates by SearchId and raises OnStatusChanged callback.
*@
@namespace JdeScoping.Client.Components.Search
@inject IHubConnectionService HubConnection
@implements IDisposable
@code {
[Parameter]
public int SearchId { get; set; }
[Parameter]
public EventCallback<SearchUpdateViewModel> OnStatusChanged { get; set; }
protected override async Task OnInitializedAsync()
{
HubConnection.OnSearchUpdate += HandleSearchUpdate;
await HubConnection.StartAsync();
}
private void HandleSearchUpdate(SearchUpdateViewModel update)
{
if (update.Id == SearchId)
{
InvokeAsync(async () =>
{
await OnStatusChanged.InvokeAsync(update);
StateHasChanged();
});
}
}
public void Dispose()
{
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
}
}
@@ -1,4 +1,12 @@
@* Loading indicator component with optional message *@
@*
LoadingIndicator.razor - Reusable loading spinner component.
Displays a circular progress indicator with an optional message.
Used throughout the app during async data loading operations.
Parameters:
- Message: Optional text to display below the spinner.
*@
<div class="loading-container">
<RadzenProgressBarCircular ShowValue="false" Mode="ProgressBarMode.Indeterminate" Size="ProgressBarCircularSize.Large" />
@@ -1,3 +1,9 @@
@*
RedirectToLogin.razor - Authentication redirect component.
Automatically redirects unauthenticated users to the login page,
preserving the original URL as a return parameter.
*@
@inject NavigationManager NavigationManager
@code {
@@ -37,17 +37,22 @@ public static class ViewModelMappingExtensions
/// <summary>
/// Maps Client SearchViewModel to Core SearchViewModel.
/// </summary>
public static CoreSearch ToCore(this SearchViewModel vm) => new()
public static CoreSearch ToCore(this SearchViewModel vm)
{
Id = vm.Id,
Name = vm.Name,
UserName = vm.UserName,
Status = Enum.TryParse<SearchStatus>(vm.Status, out var status) ? status : SearchStatus.New,
SubmitDt = vm.SubmitDt,
StartDt = vm.StartDt,
EndDt = vm.EndDt,
Criteria = vm.Criteria.ToCoreCriteria()
};
ArgumentNullException.ThrowIfNull(vm);
return new CoreSearch
{
Id = vm.Id,
Name = vm.Name,
UserName = vm.UserName,
Status = Enum.TryParse<SearchStatus>(vm.Status, out var status) ? status : SearchStatus.New,
SubmitDt = vm.SubmitDt,
StartDt = vm.StartDt,
EndDt = vm.EndDt,
Criteria = vm.Criteria.ToCoreCriteria()
};
}
/// <summary>
/// Maps Core SearchCriteria to Client SearchCriteriaViewModel.
@@ -102,28 +107,33 @@ public static class ViewModelMappingExtensions
/// Maps Client SearchCriteriaViewModel to Core SearchCriteria.
/// Client uses full view model objects; Core uses primitive lists.
/// </summary>
public static SearchCriteria ToCoreCriteria(this SearchCriteriaViewModel criteria) => new()
public static SearchCriteria ToCoreCriteria(this SearchCriteriaViewModel criteria)
{
MinimumDt = criteria.MinimumDt,
MaximumDt = criteria.MaximumDt,
ExtractMisData = criteria.ExtractMisData,
WorkOrderNumbers = criteria.WorkOrders.Select(wo => wo.WorkOrderNumber).ToList(),
ItemNumbers = criteria.Items.Select(i => i.ItemNumber).ToList(),
ProfitCenters = criteria.ProfitCenters.Select(pc => pc.Code).ToList(),
WorkCenters = criteria.WorkCenters.Select(wc => wc.Code).ToList(),
OperatorIDs = criteria.Operators.Select(o => o.UserId).ToList(),
ComponentLotNumbers = criteria.ComponentLots
.Select(l => new CoreLot { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber })
.ToList(),
PartOperations = criteria.PartOperations.ToList()
};
ArgumentNullException.ThrowIfNull(criteria);
return new SearchCriteria
{
MinimumDt = criteria.MinimumDt,
MaximumDt = criteria.MaximumDt,
ExtractMisData = criteria.ExtractMisData,
WorkOrderNumbers = criteria.WorkOrders.Select(wo => wo.WorkOrderNumber).ToList(),
ItemNumbers = criteria.Items.Select(i => i.ItemNumber).ToList(),
ProfitCenters = criteria.ProfitCenters.Select(pc => pc.Code).ToList(),
WorkCenters = criteria.WorkCenters.Select(wc => wc.Code).ToList(),
OperatorIDs = criteria.Operators.Select(o => o.UserId).ToList(),
ComponentLotNumbers = criteria.ComponentLots
.Select(l => new CoreLot { LotNumber = l.LotNumber, ItemNumber = l.ItemNumber })
.ToList(),
PartOperations = criteria.PartOperations.ToList()
};
}
/// <summary>
/// Maps Core JdeUserViewModel to Client OperatorViewModel.
/// </summary>
public static OperatorViewModel ToClientOperator(this CoreJdeUser vm) => new()
{
AddressNumber = (int)vm.AddressNumber,
AddressNumber = vm.AddressNumber,
UserId = vm.UserId,
FullName = vm.FullName
};
@@ -131,12 +141,18 @@ public static class ViewModelMappingExtensions
/// <summary>
/// Maps a collection of Core SearchViewModels to Client SearchViewModels.
/// </summary>
public static List<SearchViewModel> ToClientList(this IEnumerable<CoreSearch> list) =>
list.Select(s => s.ToClient()).ToList();
public static List<SearchViewModel> ToClientList(this IEnumerable<CoreSearch> list)
{
ArgumentNullException.ThrowIfNull(list);
return list.Select(s => s.ToClient()).ToList();
}
/// <summary>
/// Maps a collection of Core JdeUserViewModels to Client OperatorViewModels.
/// </summary>
public static List<OperatorViewModel> ToClientOperatorList(this IEnumerable<CoreJdeUser> list) =>
list.Select(u => u.ToClientOperator()).ToList();
public static List<OperatorViewModel> ToClientOperatorList(this IEnumerable<CoreJdeUser> list)
{
ArgumentNullException.ThrowIfNull(list);
return list.Select(u => u.ToClientOperator()).ToList();
}
}
@@ -0,0 +1,61 @@
namespace JdeScoping.Client.Helpers;
/// <summary>
/// Utility class for formatting SQL queries for display.
/// </summary>
public static class SqlFormatHelper
{
/// <summary>
/// Formats a SQL query string for readable display by adding line breaks
/// before major SQL clauses and formatting SELECT columns.
/// </summary>
/// <param name="sql">The raw SQL query string.</param>
/// <returns>A formatted SQL string with line breaks for readability.</returns>
public static string FormatSql(string? sql)
{
if (string.IsNullOrWhiteSpace(sql))
return "";
// Format SELECT columns - put each column on its own line
var result = FormatSelectColumns(sql);
// Add line breaks before major clauses
result = result
.Replace(" FROM ", "\nFROM ")
.Replace(" WHERE ", "\nWHERE ")
.Replace(" AND ", "\n AND ")
.Replace(" OR ", "\n OR ")
.Replace(" LEFT ", "\nLEFT ")
.Replace(" RIGHT ", "\nRIGHT ")
.Replace(" INNER ", "\nINNER ")
.Replace(" OUTER ", "\nOUTER ")
.Replace(" JOIN ", " JOIN\n ")
.Replace(" ORDER BY ", "\nORDER BY ")
.Replace(" GROUP BY ", "\nGROUP BY ")
.Replace(" HAVING ", "\nHAVING ");
return result.Trim();
}
private static string FormatSelectColumns(string sql)
{
// Find SELECT and FROM positions (case-insensitive)
var selectIndex = sql.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase);
var fromIndex = sql.IndexOf(" FROM ", StringComparison.OrdinalIgnoreCase);
if (selectIndex < 0 || fromIndex < 0 || fromIndex <= selectIndex)
return sql;
var beforeSelect = sql[..selectIndex];
var selectKeyword = sql.Substring(selectIndex, 6); // "SELECT"
var columnsStart = selectIndex + 6;
var columns = sql[columnsStart..fromIndex];
var afterColumns = sql[fromIndex..];
// Split columns by comma and rejoin with newlines
var columnList = columns.Split(',');
var formattedColumns = string.Join(",\n ", columnList.Select(c => c.Trim()));
return $"{beforeSelect}{selectKeyword} {formattedColumns}{afterColumns}";
}
}
@@ -1,3 +1,9 @@
@*
PipelineViewer.razor - ETL pipeline configuration viewer (admin).
Displays all configured data sync pipelines with their schedules, queries, and mappings.
Read-only view for inspecting pipeline configuration without modifying.
*@
@page "/admin/pipeline-viewer"
@attribute [Authorize]
@using JdeScoping.Core.ApiContracts
+20 -3
View File
@@ -1,9 +1,16 @@
@*
Login.razor - User authentication page.
Handles LDAP authentication with RSA-encrypted credential transmission.
Redirects to the home page on successful login.
*@
@page "/login"
@using JdeScoping.Core.Models.Auth
@using JdeScoping.Core.ApiContracts
@using JdeScoping.Client.Auth
@inject IAuthApiClient AuthApi
@inject ICryptoService CryptoService
@inject AuthStateProvider AuthStateProvider
@inject IAuthStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<PageTitle>Login - JDE Scoping Tool</PageTitle>
@@ -73,8 +80,18 @@
{
if (loginResult.Success && loginResult.User is not null)
{
// Notify auth state provider of successful login
_ = AuthStateProvider.MarkUserAsAuthenticated(loginResult.User);
// Fire-and-forget with error handling to prevent silent failures
_ = Task.Run(async () =>
{
try
{
await AuthStateProvider.MarkUserAsAuthenticated(loginResult.User);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to mark user as authenticated: {ex.Message}");
}
});
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
NavigationManager.NavigateTo(returnUrl);
@@ -1,3 +1,9 @@
@*
RefreshStatus.razor - Data cache refresh status dashboard.
Shows the status of JDE/CMS data synchronization jobs (hourly, daily, mass).
Allows filtering by date range and entity name.
*@
@page "/refresh-status"
@attribute [Authorize]
@inject IRefreshStatusService RefreshStatusService
+105 -264
View File
@@ -1,11 +1,22 @@
@*
SearchEdit.razor - Main search creation and editing page.
Handles creating new searches, editing existing drafts, and viewing completed searches.
Integrates with SignalR for real-time status updates during search execution.
*@
@page "/search"
@page "/search/{Id:int}"
@attribute [Authorize]
@using JdeScoping.Core.ApiContracts
@using JdeScoping.Client.Extensions
@using JdeScoping.Client.Auth
@using JdeScoping.Client.Components.Search
@using Microsoft.JSInterop
@inject ISearchApiClient SearchApi
@inject IHubConnectionService HubConnection
@inject AuthStateProvider AuthStateProvider
@inject IAuthStateProvider AuthStateProvider
@inject ISearchValidationService ValidationService
@inject ISearchSubmissionService SubmissionService
@inject NavigationManager NavigationManager
@inject DialogService DialogService
@inject NotificationService NotificationService
@@ -34,133 +45,79 @@ else if (!string.IsNullOrEmpty(_errorMessage))
}
else
{
<EditForm Model="@_search" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<SignalRStatusHandler SearchId="@_search.Id" OnStatusChanged="HandleSearchUpdate" />
@if (_search.IsReadOnly)
{
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
<strong>Note:</strong> Search is read-only because it has already been submitted. To change or re-run the search again click the Copy button.
<RadzenButton Text="Copy" ButtonStyle="ButtonStyle.Secondary" Size="ButtonSize.Small" Click="@CopySearchAsync" class="rz-ml-2" />
</RadzenAlert>
}
<FilterVisibilityManager @ref="_visibilityManager" Criteria="@_search.Criteria" OnSearchTypeChanged="OnSearchTypeChanged">
<EditForm Model="@_search" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
<!-- Validation Summary -->
<ValidationSummary class="rz-mb-4" />
@if (_search.IsReadOnly)
{
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
<strong>Note:</strong> Search is read-only because it has already been submitted. To change or re-run the search again click the Copy button.
<RadzenButton Text="Copy" ButtonStyle="ButtonStyle.Secondary" Size="ButtonSize.Small" Click="@CopySearchAsync" class="rz-ml-2" />
</RadzenAlert>
}
<!-- Search Details Panel -->
<RadzenCard class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Details</RadzenText>
<ValidationSummary class="rz-mb-4" />
<RadzenRow Gap="1rem">
<RadzenColumn Size="12">
<RadzenFormField Text="Search Type" Style="width: 100%;">
<RadzenDropDown @bind-Value="_selectedSearchType" Data="@_validCombinations" TextProperty="Name" ValueProperty="Id"
Placeholder="Select type" Disabled="@_search.IsReadOnly" Change="@OnSearchTypeChanged" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
<SearchDetailsSection
Search="@_search"
@bind-SelectedSearchType="@_visibilityManager.SelectedSearchType"
ValidCombinations="@_visibilityManager.ValidCombinations"
OnDownloadResults="@DownloadResultsAsync" />
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="12">
<RadzenFormField Text="Name" Style="width: 100%;">
<RadzenTextBox @bind-Value="_search.Name" Disabled="@_search.IsReadOnly" Style="width: 100%;" />
</RadzenFormField>
<ValidationMessage For="@(() => _search.Name)" class="validation-message text-danger" />
</RadzenColumn>
</RadzenRow>
@if (_visibilityManager.ShowTimespan)
{
<TimeSpanFilterPanel @bind-MinimumDT="_search.Criteria.MinimumDt" @bind-MaximumDT="_search.Criteria.MaximumDt" IsReadOnly="@_search.IsReadOnly" />
}
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="4">
<RadzenFormField Text="Submitted At" Style="width: 100%;">
<RadzenTextBox Value="@(_search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Started At" Style="width: 100%;">
<RadzenTextBox Value="@(_search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Completed At" Style="width: 100%;">
<RadzenTextBox Value="@(_search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
@if (_visibilityManager.ShowWorkOrder)
{
<WorkOrderFilterPanel @bind-WorkOrders="_search.Criteria.WorkOrders" IsReadOnly="@_search.IsReadOnly" />
}
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="4">
<RadzenFormField Text="User" Style="width: 100%;">
<RadzenTextBox Value="@_search.UserName" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Status" Style="width: 100%;">
<RadzenTextBox Value="@_search.Status" ReadOnly="true" Style="@($"width: 100%; background-color: {_search.StatusColor};")" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
@if (_search.HasResults)
{
<RadzenFormField Text=" " Style="width: 100%;">
<RadzenButton Text="Download Results" Icon="download" ButtonStyle="ButtonStyle.Success" Click="@DownloadResultsAsync" Style="width: 100%;" />
</RadzenFormField>
}
</RadzenColumn>
</RadzenRow>
</RadzenCard>
@if (_visibilityManager.ShowItemNumber)
{
<ItemNumberFilterPanel @bind-Items="_search.Criteria.Items" IsReadOnly="@_search.IsReadOnly" />
}
<!-- Filter Panels -->
@if (_showTimespan)
{
<TimeSpanFilterPanel @bind-MinimumDT="_search.Criteria.MinimumDt" @bind-MaximumDT="_search.Criteria.MaximumDt" IsReadOnly="@_search.IsReadOnly" />
}
@if (_visibilityManager.ShowProfitCenter)
{
<ProfitCenterFilterPanel @bind-ProfitCenters="_search.Criteria.ProfitCenters" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showWorkOrder)
{
<WorkOrderFilterPanel @bind-WorkOrders="_search.Criteria.WorkOrders" IsReadOnly="@_search.IsReadOnly" />
}
@if (_visibilityManager.ShowWorkCenter)
{
<WorkCenterFilterPanel @bind-WorkCenters="_search.Criteria.WorkCenters" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showItemNumber)
{
<ItemNumberFilterPanel @bind-Items="_search.Criteria.Items" IsReadOnly="@_search.IsReadOnly" />
}
@if (_visibilityManager.ShowComponentLot)
{
<ComponentLotFilterPanel @bind-ComponentLots="_search.Criteria.ComponentLots" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showProfitCenter)
{
<ProfitCenterFilterPanel @bind-ProfitCenters="_search.Criteria.ProfitCenters" IsReadOnly="@_search.IsReadOnly" />
}
@if (_visibilityManager.ShowOperator)
{
<OperatorFilterPanel @bind-Operators="_search.Criteria.Operators" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showWorkCenter)
{
<WorkCenterFilterPanel @bind-WorkCenters="_search.Criteria.WorkCenters" IsReadOnly="@_search.IsReadOnly" />
}
@if (_visibilityManager.ShowItemOperationMis)
{
<PartOperationFilterPanel @bind-PartOperations="_search.Criteria.PartOperations" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showComponentLot)
{
<ComponentLotFilterPanel @bind-ComponentLots="_search.Criteria.ComponentLots" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showOperator)
{
<OperatorFilterPanel @bind-Operators="_search.Criteria.Operators" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showItemOperationMis)
{
<PartOperationFilterPanel @bind-PartOperations="_search.Criteria.PartOperations" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showExtractMis)
{
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
<RadzenCheckBox @bind-Value="_search.Criteria.ExtractMisData" Disabled="true" />
<RadzenText TextStyle="TextStyle.Body1">Extract MIS data</RadzenText>
</RadzenStack>
</RadzenCard>
}
</EditForm>
@if (_visibilityManager.ShowExtractMis)
{
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
<RadzenCheckBox @bind-Value="_search.Criteria.ExtractMisData" Disabled="true" />
<RadzenText TextStyle="TextStyle.Body1">Extract MIS data</RadzenText>
</RadzenStack>
</RadzenCard>
}
</EditForm>
</FilterVisibilityManager>
}
@code {
@@ -171,28 +128,15 @@ else
public int? CopySearchId { get; set; }
private ClientSearchViewModel _search = new() { Criteria = new() };
private IReadOnlyList<ValidCombination> _validCombinations = ValidCombination.GetAll();
private int? _selectedSearchType;
private FilterVisibilityManager _visibilityManager = null!;
private bool _isLoading = true;
private bool _isSubmitting;
private string? _errorMessage;
// Filter visibility flags
private bool _showTimespan;
private bool _showWorkOrder;
private bool _showItemNumber;
private bool _showProfitCenter;
private bool _showWorkCenter;
private bool _showComponentLot;
private bool _showOperator;
private bool _showItemOperationMis;
private bool _showExtractMis;
protected override async Task OnInitializedAsync()
{
await LoadSearchAsync();
await SetupSignalRAsync();
}
private async Task LoadSearchAsync()
@@ -232,7 +176,6 @@ else
}
else
{
// New search
_search = new ClientSearchViewModel
{
Status = "New",
@@ -240,12 +183,6 @@ else
Criteria = new SearchCriteriaViewModel()
};
}
// Detect search type from criteria (only if no error)
if (string.IsNullOrEmpty(_errorMessage))
{
DetectSearchType();
}
}
finally
{
@@ -259,92 +196,26 @@ else
return string.Join(" ", messages);
}
private void DetectSearchType()
private void OnSearchTypeChanged(ValidCombination? combo)
{
var criteria = _search.Criteria;
bool hasTimespan = criteria.MinimumDt.HasValue || criteria.MaximumDt.HasValue;
bool hasWorkOrder = criteria.WorkOrders.Count > 0;
bool hasItemNumber = criteria.Items.Count > 0;
bool hasProfitCenter = criteria.ProfitCenters.Count > 0;
bool hasWorkCenter = criteria.WorkCenters.Count > 0;
bool hasComponentLot = criteria.ComponentLots.Count > 0;
bool hasOperator = criteria.Operators.Count > 0;
bool hasPartOperation = criteria.PartOperations.Count > 0;
bool hasExtractMis = criteria.ExtractMisData;
foreach (var combo in _validCombinations)
{
if (combo.Matches(hasTimespan, hasWorkOrder, hasItemNumber, hasProfitCenter, hasWorkCenter, hasComponentLot, hasOperator, hasPartOperation, hasExtractMis))
{
_selectedSearchType = combo.Id;
UpdateFilterVisibility(combo);
break;
}
}
}
private void OnSearchTypeChanged()
{
var combo = _validCombinations.FirstOrDefault(c => c.Id == _selectedSearchType);
if (combo != null)
{
UpdateFilterVisibility(combo);
}
}
private void UpdateFilterVisibility(ValidCombination combo)
{
_showTimespan = combo.Timespan;
_showWorkOrder = combo.WorkOrder;
_showItemNumber = combo.ItemNumber;
_showProfitCenter = combo.ProfitCenter;
_showWorkCenter = combo.WorkCenter;
_showComponentLot = combo.ComponentLot;
_showOperator = combo.Operator;
_showItemOperationMis = combo.ItemOperationMis;
_showExtractMis = combo.ExtractMis;
// Set ExtractMisData flag based on combo
_search.Criteria.ExtractMisData = combo.ExtractMis;
}
private async Task SetupSignalRAsync()
{
HubConnection.OnSearchUpdate += HandleSearchUpdate;
await HubConnection.StartAsync();
StateHasChanged();
}
private void HandleSearchUpdate(SearchUpdateViewModel update)
{
if (update.Id == _search.Id)
{
InvokeAsync(() =>
{
_search.Status = update.Status;
_search.SubmitDt = update.SubmitDt;
_search.StartDt = update.StartDt;
_search.EndDt = update.EndDt;
StateHasChanged();
});
}
_search.Status = update.Status;
_search.SubmitDt = update.SubmitDt;
_search.StartDt = update.StartDt;
_search.EndDt = update.EndDt;
StateHasChanged();
}
private async Task HandleValidSubmit()
{
// DataAnnotationsValidator has already validated the model
// Now perform additional custom validation
if (_selectedSearchType == null)
{
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
return;
}
// Validate filter data based on search type
var validationError = ValidateFilters();
var validationError = ValidationService.Validate(_search, _visibilityManager.SelectedSearchType, _visibilityManager);
if (!string.IsNullOrEmpty(validationError))
{
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", validationError);
return;
}
@@ -353,24 +224,10 @@ else
private async Task SubmitSearchAsync()
{
// Manual submit button handler - validate and submit
if (string.IsNullOrWhiteSpace(_search.Name))
{
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Name is required.");
return;
}
if (_selectedSearchType == null)
{
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
return;
}
// Validate filter data based on search type
var validationError = ValidateFilters();
var validationError = ValidationService.Validate(_search, _visibilityManager.SelectedSearchType, _visibilityManager);
if (!string.IsNullOrEmpty(validationError))
{
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", validationError);
return;
}
@@ -393,15 +250,15 @@ else
_isSubmitting = true;
try
{
var result = await SearchApi.CreateSearchAsync(_search.ToCore());
result.Switch(
id => { NavigationManager.NavigateTo($"/search/{id}"); },
notFound => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Search not found."); },
validation => { NotificationService.Notify(NotificationSeverity.Error, "Validation Error", FormatValidationErrors(validation.FieldErrors)); },
unauthorized => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Session expired."); },
forbidden => { NotificationService.Notify(NotificationSeverity.Error, "Error", "Access denied."); },
error => { NotificationService.Notify(NotificationSeverity.Error, "Error", error.Message); }
);
var result = await SubmissionService.SubmitAsync(_search);
if (result.IsSuccess)
{
NavigationManager.NavigateTo($"/search/{result.SearchId}");
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Error", result.ErrorMessage);
}
}
finally
{
@@ -409,32 +266,6 @@ else
}
}
private string? ValidateFilters()
{
if (_showWorkOrder && _search.Criteria.WorkOrders.Count == 0)
return "At least one work order must be specified for the work order filter.";
if (_showItemNumber && _search.Criteria.Items.Count == 0)
return "At least one item number must be specified for the item number filter.";
if (_showProfitCenter && _search.Criteria.ProfitCenters.Count == 0)
return "At least one profit center must be specified for the profit center filter.";
if (_showWorkCenter && _search.Criteria.WorkCenters.Count == 0)
return "At least one work center must be specified for the work center filter.";
if (_showComponentLot && _search.Criteria.ComponentLots.Count == 0)
return "At least one component lot must be specified for the component lot filter.";
if (_showOperator && _search.Criteria.Operators.Count == 0)
return "At least one operator must be specified for the operator filter.";
if (_showItemOperationMis && _search.Criteria.PartOperations.Count == 0)
return "At least one item/operation/MIS entry must be specified for the MIS data filter.";
return null;
}
private void CopySearchAsync()
{
NavigationManager.NavigateTo($"/search?copySearchId={_search.Id}");
@@ -448,7 +279,17 @@ else
{
if (bytes.Length > 0)
{
_ = JSRuntime.InvokeVoidAsync("downloadFile", $"search_results_{_search.Id}.xlsx", bytes);
_ = Task.Run(async () =>
{
try
{
await JSRuntime.InvokeVoidAsync("downloadFile", $"search_results_{_search.Id}.xlsx", bytes);
}
catch (JSException ex)
{
Console.WriteLine($"JS interop failed during file download: {ex.Message}");
}
});
NotificationService.Notify(NotificationSeverity.Success, "Download", "Results downloaded successfully.");
}
else
@@ -466,6 +307,6 @@ else
public void Dispose()
{
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
// SignalRStatusHandler handles its own disposal
}
}
@@ -1,3 +1,9 @@
@*
SearchQueue.razor - Real-time search processing queue.
Displays all queued and running searches with live status updates via SignalR.
Shows progress indicators and allows users to monitor their search execution.
*@
@page "/search/queue"
@attribute [Authorize]
@using JdeScoping.Core.ApiContracts
@@ -1,3 +1,9 @@
@*
Searches.razor - Search list dashboard (home page).
Displays all searches for the current user with filtering, sorting, and pagination.
Allows creating new searches, viewing/editing drafts, and downloading completed results.
*@
@page "/"
@page "/searches"
@attribute [Authorize]
+5
View File
@@ -37,6 +37,7 @@ builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<IUserStorageService, UserStorageService>();
builder.Services.AddScoped<AuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
builder.Services.AddScoped<IAuthStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
// Crypto service for login encryption
builder.Services.AddScoped<ICryptoService, CryptoService>();
@@ -56,4 +57,8 @@ builder.Services.AddScoped<IAuthApiClient, AuthApiClient>();
builder.Services.AddScoped<IFileApiClient, FileApiClient>();
builder.Services.AddScoped<IPipelineApiClient, PipelineApiClient>();
// Search services
builder.Services.AddScoped<ISearchValidationService, SearchValidationService>();
builder.Services.AddScoped<ISearchSubmissionService, SearchSubmissionService>();
await builder.Build().RunAsync();
@@ -11,12 +11,12 @@ public class AuthService : IAuthService
{
private readonly HttpClient _httpClient;
private readonly ICryptoService _cryptoService;
private readonly AuthStateProvider _authStateProvider;
private readonly IAuthStateProvider _authStateProvider;
public AuthService(
HttpClient httpClient,
ICryptoService cryptoService,
AuthStateProvider authStateProvider)
IAuthStateProvider authStateProvider)
{
_httpClient = httpClient;
_cryptoService = cryptoService;
@@ -9,12 +9,13 @@ namespace JdeScoping.Client.Services;
/// Encrypts login credentials using Web Crypto API via JavaScript interop.
/// Uses RSA-OAEP with SHA-256 to encrypt credentials before transmission.
/// </summary>
public class CryptoService : ICryptoService
public class CryptoService : ICryptoService, IAsyncDisposable
{
private readonly HttpClient _httpClient;
private readonly IJSRuntime _jsRuntime;
private string? _cachedPublicKeyPem;
private readonly SemaphoreSlim _keyLock = new(1, 1);
private bool _disposed;
public CryptoService(HttpClient httpClient, IJSRuntime jsRuntime)
{
@@ -40,6 +41,8 @@ public class CryptoService : ICryptoService
private async Task<string> GetOrFetchPublicKeyAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_cachedPublicKeyPem is not null)
return _cachedPublicKeyPem;
@@ -60,4 +63,18 @@ public class CryptoService : ICryptoService
_keyLock.Release();
}
}
/// <summary>
/// Disposes the semaphore used for thread-safe key caching.
/// </summary>
public ValueTask DisposeAsync()
{
if (!_disposed)
{
_keyLock.Dispose();
_disposed = true;
}
GC.SuppressFinalize(this);
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,47 @@
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for handling search submission operations.
/// </summary>
public interface ISearchSubmissionService
{
/// <summary>
/// Submits a search and returns the result.
/// </summary>
/// <param name="search">The search view model to submit.</param>
/// <returns>The submission result containing the search ID on success, or error information on failure.</returns>
Task<SearchSubmissionResult> SubmitAsync(SearchViewModel search);
}
/// <summary>
/// Result of a search submission operation.
/// </summary>
public class SearchSubmissionResult
{
/// <summary>
/// Gets the search ID if submission was successful.
/// </summary>
public int? SearchId { get; init; }
/// <summary>
/// Gets the error message if submission failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Gets whether the submission was successful.
/// </summary>
public bool IsSuccess => SearchId.HasValue;
/// <summary>
/// Creates a successful result.
/// </summary>
public static SearchSubmissionResult Success(int searchId) => new() { SearchId = searchId };
/// <summary>
/// Creates a failure result.
/// </summary>
public static SearchSubmissionResult Failure(string error) => new() { ErrorMessage = error };
}
@@ -0,0 +1,19 @@
using JdeScoping.Client.Components.Search;
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for validating search criteria before submission.
/// </summary>
public interface ISearchValidationService
{
/// <summary>
/// Validates that a search is ready for submission.
/// </summary>
/// <param name="search">The search view model to validate.</param>
/// <param name="selectedSearchType">The selected search type ID.</param>
/// <param name="visibilityManager">The filter visibility manager with current filter state.</param>
/// <returns>A validation error message, or null if valid.</returns>
string? Validate(SearchViewModel search, int? selectedSearchType, FilterVisibilityManager visibilityManager);
}
@@ -0,0 +1,43 @@
using JdeScoping.Client.Extensions;
using JdeScoping.Client.Models;
using JdeScoping.Core.ApiContracts;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for handling search submission operations.
/// </summary>
public class SearchSubmissionService : ISearchSubmissionService
{
private readonly ISearchApiClient _searchApi;
public SearchSubmissionService(ISearchApiClient searchApi)
{
_searchApi = searchApi;
}
/// <inheritdoc />
public async Task<SearchSubmissionResult> SubmitAsync(SearchViewModel search)
{
var result = await _searchApi.CreateSearchAsync(search.ToCore());
SearchSubmissionResult? submissionResult = null;
result.Switch(
id => { submissionResult = SearchSubmissionResult.Success(id); },
notFound => { submissionResult = SearchSubmissionResult.Failure("Search not found."); },
validation => { submissionResult = SearchSubmissionResult.Failure(FormatValidationErrors(validation.FieldErrors)); },
unauthorized => { submissionResult = SearchSubmissionResult.Failure("Session expired."); },
forbidden => { submissionResult = SearchSubmissionResult.Failure("Access denied."); },
error => { submissionResult = SearchSubmissionResult.Failure(error.Message); }
);
return submissionResult ?? SearchSubmissionResult.Failure("Unknown error occurred.");
}
private static string FormatValidationErrors(IReadOnlyDictionary<string, string[]> fieldErrors)
{
var messages = fieldErrors.SelectMany(kv => kv.Value);
return string.Join(" ", messages);
}
}
@@ -0,0 +1,29 @@
using JdeScoping.Client.Components.Search;
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for validating search criteria before submission.
/// </summary>
public class SearchValidationService : ISearchValidationService
{
/// <inheritdoc />
public string? Validate(SearchViewModel search, int? selectedSearchType, FilterVisibilityManager visibilityManager)
{
// Validate name
if (string.IsNullOrWhiteSpace(search.Name))
{
return "Name is required.";
}
// Validate search type
if (selectedSearchType == null)
{
return "Search type is required.";
}
// Validate filters based on search type
return visibilityManager.ValidateFilters();
}
}
+1
View File
@@ -16,6 +16,7 @@
@using JdeScoping.Client.Components.Admin
@using JdeScoping.Client.Components.FilterPanels
@using JdeScoping.Client.Extensions
@using JdeScoping.Client.Components.Search
@using JdeScoping.Client.Components.Shared
@using JdeScoping.Client.Layout
@using JdeScoping.Client.Models
@@ -13,6 +13,14 @@ window.downloadFile = function (fileName, byteArray) {
};
window.jdeScopingInterop = {
// Programmatically click an element by its ID (used for triggering file inputs)
clickElementById: function (elementId) {
const element = document.getElementById(elementId);
if (element) {
element.click();
}
},
// Download file from a byte array stream
downloadFileFromStream: async function (fileName, contentStreamReference) {
const arrayBuffer = await contentStreamReference.arrayBuffer();