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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user