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