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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user