diff --git a/MESS/MESS.Blazor/Components/Navigator/NavigationMenu.razor b/MESS/MESS.Blazor/Components/Navigator/NavigationMenu.razor index bcd29e6b..40668f5f 100644 --- a/MESS/MESS.Blazor/Components/Navigator/NavigationMenu.razor +++ b/MESS/MESS.Blazor/Components/Navigator/NavigationMenu.razor @@ -25,6 +25,10 @@ + + My Profile + My Production @@ -58,6 +62,10 @@ href="/tags" @onclick="OnToggleClicked"> Tags + + Defects + diff --git a/MESS/MESS.Blazor/Components/Pages/AdminLogViewer/LogArchive.razor b/MESS/MESS.Blazor/Components/Pages/AdminLogViewer/LogArchive.razor index 3fd55e17..20c682cb 100644 --- a/MESS/MESS.Blazor/Components/Pages/AdminLogViewer/LogArchive.razor +++ b/MESS/MESS.Blazor/Components/Pages/AdminLogViewer/LogArchive.razor @@ -2,21 +2,119 @@ @using MESS.Blazor.Components.Layout @using MESS.Blazor.Components.Dialogs @using MESS.Services.CRUD.ProductionLogs -@using MESS.Services.DTOs.ProductionLogs.Summary +@using MESS.Services.DTOs.ProductionLogs.Archive @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization -@using SortDirection = MudBlazor.SortDirection -@using HorizontalAlignment = MudBlazor.HorizontalAlignment +@using Microsoft.AspNetCore.WebUtilities @using DialogParameters = Microsoft.FluentUI.AspNetCore.Components.DialogParameters @using DialogResult = Microsoft.FluentUI.AspNetCore.Components.DialogResult @layout PhoebeLayout @inject NavigationManager NavManager @inject IProductionLogService ProductionLogService @inject Microsoft.FluentUI.AspNetCore.Components.IDialogService DialogService +@inject IJSRuntime JS @attribute [Authorize(Roles = "Administrator")] Log Archives + + @@ -34,57 +132,179 @@ - @if (!IsLoading) - { -
-

Log Archive

-
- -
- - - - - Product - Work Instruction - Created By - Created On - Last Modified On - Actions - - - @(productionLog.ProductName ?? NoProduct) - @(productionLog.WorkInstructionName ?? NoWorkInstruction) - @productionLog.CreatedBy - @productionLog.CreatedOn.LocalDateTime.ToString("yyyy-MM-dd HH:mm") - @productionLog.LastModifiedOn.LocalDateTime.ToString("yyyy-MM-dd HH:mm") - - - - - - - - - } - else - { -
- Loading... +
+

Log Archive

+
+ @if (HasActiveFilters) + { + + } + +
+ + @if (_isColumnsPanelOpen) + { +
+
+ @foreach (var column in HideableColumns) + { +
+ + +
+ } + +
+
+ }
- } +
+ +
+ @if (IsLoading) + { +
+
+
+
Loading logs...
+
+
+ } + + @if (!string.IsNullOrWhiteSpace(_errorMessage)) + { +
+ @_errorMessage + +
+ } + + + + + @if (IsColumnVisible(ColumnLogId)) { } + @if (IsColumnVisible(ColumnAttemptId)) { } + @if (IsColumnVisible(ColumnStatus)) { } + @if (IsColumnVisible(ColumnProduct)) { } + @if (IsColumnVisible(ColumnWorkInstruction)) { } + @if (IsColumnVisible(ColumnPartProduced)) { } + @if (IsColumnVisible(ColumnProducedPartSn)) { } + @if (IsColumnVisible(ColumnCreatedOn)) { } + @if (IsColumnVisible(ColumnCreatedBy)) { } + @if (IsColumnVisible(ColumnModifiedOn)) { } + @if (IsColumnVisible(ColumnModifiedBy)) { } + @if (IsColumnVisible(ColumnActions)) { } + + + @if (IsColumnVisible(ColumnLogId)) + { + + } + @if (IsColumnVisible(ColumnAttemptId)) + { + + } + @if (IsColumnVisible(ColumnStatus)) + { + + } + @if (IsColumnVisible(ColumnProduct)) { } + @if (IsColumnVisible(ColumnWorkInstruction)) { } + @if (IsColumnVisible(ColumnPartProduced)) { } + @if (IsColumnVisible(ColumnProducedPartSn)) { } + @if (IsColumnVisible(ColumnCreatedOn)) { } + @if (IsColumnVisible(ColumnCreatedBy)) { } + @if (IsColumnVisible(ColumnModifiedOn)) { } + @if (IsColumnVisible(ColumnModifiedBy)) { } + @if (IsColumnVisible(ColumnActions)) { } + + + + @if (_rows.Count == 0 && !IsLoading && string.IsNullOrWhiteSpace(_errorMessage)) + { + + + + } + @foreach (var log in _rows) + { + + @if (IsColumnVisible(ColumnLogId)) { } + @if (IsColumnVisible(ColumnAttemptId)) { } + @if (IsColumnVisible(ColumnStatus)) { } + @if (IsColumnVisible(ColumnProduct)) { } + @if (IsColumnVisible(ColumnWorkInstruction)) { } + @if (IsColumnVisible(ColumnPartProduced)) { } + @if (IsColumnVisible(ColumnProducedPartSn)) { } + @if (IsColumnVisible(ColumnCreatedOn)) { } + @if (IsColumnVisible(ColumnCreatedBy)) { } + @if (IsColumnVisible(ColumnModifiedOn)) { } + @if (IsColumnVisible(ColumnModifiedBy)) { } + @if (IsColumnVisible(ColumnActions)) + { + + } + + } + +
@SortButton("Log #", "logId")@SortButton("Attempt ID", "attemptId")@SortButton("Status", "status")@SortButton("Product", "product")@SortButton("Work Instruction", "workInstruction")@SortButton("Part Produced", "partProduced")@SortButton("Produced Part SN", "producedPartSerialNumber")@SortButton("Created On", "createdOn")@SortButton("Created By", "createdBy")@SortButton("Modified On", "modifiedOn")@SortButton("Modified By", "modifiedBy")Actions
+ + + + + + @DropdownFilter(nameof(FilterModel.Product), _filters.Product, _filterOptions.Products)@DropdownFilter(nameof(FilterModel.WorkInstruction), _filters.WorkInstruction, _filterOptions.WorkInstructions)@DropdownFilter(nameof(FilterModel.PartProduced), _filters.PartProduced, _filterOptions.PartProduced)@DateFilter(nameof(FilterModel.CreatedOnFrom), nameof(FilterModel.CreatedOnTo), _filters.CreatedOnFrom, _filters.CreatedOnTo)@DropdownFilter(nameof(FilterModel.CreatedBy), _filters.CreatedBy, _filterOptions.CreatedBy)@DateFilter(nameof(FilterModel.ModifiedOnFrom), nameof(FilterModel.ModifiedOnTo), _filters.ModifiedOnFrom, _filters.ModifiedOnTo)@DropdownFilter(nameof(FilterModel.ModifiedBy), _filters.ModifiedBy, _filterOptions.ModifiedBy)
No logs found.
@log.ProductionLogId@(log.AttemptId?.ToString() ?? "-")@log.Status@EmptyFallback(log.ProductName)@EmptyFallback(log.WorkInstructionName)@EmptyFallback(log.PartProducedName)@EmptyFallback(log.ProducedPartSerialNumber)@FormatDate(log.CreatedOn)@EmptyFallback(log.CreatedBy)@FormatDate(log.LastModifiedOn)@EmptyFallback(log.LastModifiedBy) + +
+
+ +
+ + Page + + of @_totalPages + + @_total.ToString("N0") records + + +
@@ -97,152 +317,347 @@ @code { - private List ProductionLogs { get; set; } = new(); - private IEnumerable pagedData = new List(); + private const string ColumnLogId = "logId"; + private const string ColumnAttemptId = "attemptId"; + private const string ColumnStatus = "status"; + private const string ColumnProduct = "product"; + private const string ColumnWorkInstruction = "workInstruction"; + private const string ColumnPartProduced = "partProduced"; + private const string ColumnProducedPartSn = "producedPartSerialNumber"; + private const string ColumnCreatedOn = "createdOn"; + private const string ColumnCreatedBy = "createdBy"; + private const string ColumnModifiedOn = "modifiedOn"; + private const string ColumnModifiedBy = "modifiedBy"; + private const string ColumnActions = "actions"; + private const string ColumnVisibilityStorageKey = "logArchive.columnVisibility.v2"; + + private static readonly int[] PageSizes = [25, 50, 100, 250]; + private static readonly ColumnDefinition[] HideableColumns = + [ + new(ColumnLogId, "Log #"), + new(ColumnAttemptId, "Attempt ID"), + new(ColumnStatus, "Status"), + new(ColumnProduct, "Product"), + new(ColumnWorkInstruction, "Work Instruction"), + new(ColumnPartProduced, "Part Produced"), + new(ColumnProducedPartSn, "Produced Part SN"), + new(ColumnCreatedOn, "Created On"), + new(ColumnCreatedBy, "Created By"), + new(ColumnModifiedOn, "Modified On"), + new(ColumnModifiedBy, "Modified By"), + new(ColumnActions, "Actions") + ]; + + private readonly Dictionary _columnVisibility = HideableColumns.ToDictionary(c => c.Key, _ => true); + private readonly FilterModel _filters = new(); + private ProductionLogArchiveFilterOptionsDTO _filterOptions = new(); + private List _rows = []; + private ElementReference _tableWrapper; + private CancellationTokenSource? _debounceCts; + private bool _isColumnsPanelOpen; private bool IsLoading { get; set; } = true; + private string? _errorMessage; + private string SearchString { get; set; } = ""; + private int _page = 1; + private int _pageSize = 50; + private int _total; + private int _totalPages = 1; + private string _pageInput = "1"; + private string _sortBy = "createdOn"; + private string _sortDir = "desc"; - private MudTable _table = new MudTable(); + private int VisibleColumnCount => Math.Max(1, _columnVisibility.Count(kvp => kvp.Value)); + private bool HasActiveFilters => !string.IsNullOrWhiteSpace(SearchString) || _filters.HasAny; - private int totalItems; - - private Dictionary SearchHits { get; } = []; - private int MaxHits => SearchHits.Values.Max(); - - private const string NoProduct = "(No Product)"; - private const string NoWorkInstruction = "(No Work Instruction)"; - - private string _searchString = ""; - private string SearchString + /// + protected override async Task OnInitializedAsync() { - get => _searchString; - set - { - _searchString = value; - _table.ReloadServerData(); - } + ReadUrlState(); + _filterOptions = await ProductionLogService.GetArchiveFilterOptionsAsync(); + await LoadPageAsync(updateUrl: false); } - /// - protected override async Task OnInitializedAsync() + protected override async Task OnAfterRenderAsync(bool firstRender) { - await GrabServerData(); + if (!firstRender) + return; + + var saved = await JS.InvokeAsync("localStorage.getItem", ColumnVisibilityStorageKey); + if (!string.IsNullOrWhiteSpace(saved)) + { + foreach (var key in HideableColumns.Select(c => c.Key)) + { + _columnVisibility[key] = !saved.Contains($"\"{key}\":false", StringComparison.OrdinalIgnoreCase); + } + + StateHasChanged(); + } } - private async Task GrabServerData() + private async Task LoadPageAsync(bool updateUrl = true) { IsLoading = true; - ProductionLogs = await ProductionLogService.GetAllSummariesAsync() ?? []; - foreach (var log in ProductionLogs) + _errorMessage = null; + StateHasChanged(); + + try + { + var result = await ProductionLogService.GetArchivePageAsync(BuildQuery()); + _rows = result.Data; + _total = result.Total; + _page = result.Page; + _pageSize = result.PageSize; + _totalPages = result.TotalPages; + _pageInput = _page.ToString(); + + if (updateUrl) + UpdateUrl(); + + await JS.InvokeVoidAsync("eval", "document.querySelector('.log-archive-table-wrap')?.scrollTo({ top: 0, left: 0 });"); + } + catch (Exception ex) + { + _errorMessage = $"Could not load log archives: {ex.Message}"; + } + finally { - SearchHits.Add(log, 0); + IsLoading = false; } - - IsLoading = false; } - private async Task> ServerReload(TableState state, CancellationToken token) + private ProductionLogArchiveQuery BuildQuery() { - IEnumerable data = ProductionLogs; - await Task.Delay(20, token); + return new ProductionLogArchiveQuery + { + Page = _page, + PageSize = _pageSize, + Search = SearchString, + SortBy = _sortBy, + SortDir = _sortDir, + FilterLogId = int.TryParse(_filters.LogId, out var logId) ? logId : null, + FilterAttemptId = int.TryParse(_filters.AttemptId, out var attemptId) ? attemptId : null, + FilterStatus = _filters.Status, + FilterProduct = _filters.Product, + FilterWorkInstruction = _filters.WorkInstruction, + FilterPartProduced = _filters.PartProduced, + FilterProducedPartSerialNumber = _filters.ProducedPartSerialNumber, + FilterCreatedOnFrom = ParseDateStart(_filters.CreatedOnFrom), + FilterCreatedOnTo = ParseDateEnd(_filters.CreatedOnTo), + FilterCreatedBy = _filters.CreatedBy, + FilterModifiedOnFrom = ParseDateStart(_filters.ModifiedOnFrom), + FilterModifiedOnTo = ParseDateEnd(_filters.ModifiedOnTo), + FilterModifiedBy = _filters.ModifiedBy + }; + } + + private async Task OnSearchInput(ChangeEventArgs e) + { + SearchString = e.Value?.ToString() ?? ""; + await DebouncedReloadAsync(); + } + + private async Task OnFilterInput(string field, string? value) + { + _filters.Set(field, value); + await DebouncedReloadAsync(); + } + + private async Task OnImmediateFilterChanged(string field, string? value) + { + _filters.Set(field, value); + _page = 1; + await LoadPageAsync(); + } + + private async Task DebouncedReloadAsync() + { + _debounceCts?.Cancel(); + var cts = _debounceCts = new CancellationTokenSource(); - foreach (var log in data) + try { - SearchHits[log] = FilterHits(log, SearchString); + await Task.Delay(300, cts.Token); + _page = 1; + await LoadPageAsync(); } - - data = data.Where(log => - (SearchHits[log] >= MaxHits) - ).ToArray(); - totalItems = data.Count(); - - switch (state.SortLabel) + catch (TaskCanceledException) { - case "product_field": - data = data.OrderByDirection(state.SortDirection, o => o.ProductName); - break; - case "instruction_field": - data = data.OrderByDirection(state.SortDirection, o => o.WorkInstructionName); - break; - case "worker_field": - data = data.OrderByDirection(state.SortDirection, o => o.CreatedBy); - break; - case "created_field": - data = data.OrderByDirection(state.SortDirection, o => o.CreatedOn.LocalDateTime.ToString("yyyy-MM-dd HH:mm")); - break; - case "modified_field": - data = data.OrderByDirection(state.SortDirection, o => o.LastModifiedOn.LocalDateTime.ToString("yyyy-MM-dd HH:mm")); - break; } - pagedData = data.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArray(); - return new TableData() {TotalItems = totalItems, Items = pagedData}; } - - private int FilterHits(ProductionLogSummaryDTO log, string search) + + private async Task SetSortAsync(string sortKey) { - var hits = 0; - if (string.IsNullOrWhiteSpace(search)) - return hits; - - var searchPhrase = search.Trim(); - - if (SatisfiesFilter(log.ProductName, searchPhrase)) - hits++; - - if (SatisfiesFilter(log.WorkInstructionName, searchPhrase)) - hits++; - - if (SatisfiesFilter(log.CreatedBy, searchPhrase)) - hits++; - - if (SatisfiesFilter(log.CreatedOn.LocalDateTime.ToString("yyyy-MM-dd HH:mm"), searchPhrase)) - hits++; - - if (SatisfiesFilter(log.LastModifiedOn.LocalDateTime.ToString("yyyy-MM-dd HH:mm"), searchPhrase)) - hits++; - - return hits; + if (_sortBy == sortKey) + _sortDir = _sortDir == "asc" ? "desc" : "asc"; + else + { + _sortBy = sortKey; + _sortDir = "asc"; + } + + _page = 1; + await LoadPageAsync(); } - - private bool SatisfiesFilter(string? columnPhrase, string searchPhrase) + + private RenderFragment SortButton(string label, string sortKey) => builder => { - if (!string.IsNullOrWhiteSpace(columnPhrase)) + builder.OpenElement(0, "button"); + builder.AddAttribute(1, "type", "button"); + builder.AddAttribute(2, "class", "log-sort-button"); + builder.AddAttribute(3, "disabled", IsLoading); + builder.AddAttribute(4, "onclick", EventCallback.Factory.Create(this, () => SetSortAsync(sortKey))); + builder.AddContent(5, label); + if (_sortBy == sortKey) + builder.AddContent(6, _sortDir == "asc" ? " ▲" : " ▼"); + builder.CloseElement(); + }; + + private RenderFragment DropdownFilter(string field, string? value, List options) => builder => + { + builder.OpenElement(0, "select"); + builder.AddAttribute(1, "class", "form-select form-select-sm"); + builder.AddAttribute(2, "value", value ?? ""); + builder.AddAttribute(3, "disabled", IsLoading); + builder.AddAttribute(4, "onchange", EventCallback.Factory.Create(this, e => OnImmediateFilterChanged(field, e.Value?.ToString()))); + builder.OpenElement(5, "option"); + builder.AddAttribute(6, "value", ""); + builder.AddContent(7, "All"); + builder.CloseElement(); + var sequence = 8; + foreach (var option in options) { - var trimmedColumnPhrase = columnPhrase.Trim(); - - // intended for columns to catch smaller search phrases - if (trimmedColumnPhrase.Contains(searchPhrase, StringComparison.OrdinalIgnoreCase)) - return true; - - // for multi worded search phrase - foreach (var columnPhraseWord in trimmedColumnPhrase.Split(" ")) - { - foreach (var searchPhraseWord in searchPhrase.Split(" ")) - { - if (!string.IsNullOrWhiteSpace(searchPhraseWord) && - (searchPhrase.Contains(columnPhraseWord, StringComparison.OrdinalIgnoreCase) - || columnPhrase.Contains(searchPhraseWord, StringComparison.OrdinalIgnoreCase))) - return true; - } - } + builder.OpenElement(sequence++, "option"); + builder.AddAttribute(sequence++, "value", option); + builder.AddContent(sequence++, option); + builder.CloseElement(); } + builder.CloseElement(); + }; - return false; + private RenderFragment DateFilter(string fromField, string toField, string? fromValue, string? toValue) => builder => + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "d-flex flex-column gap-1"); + builder.OpenElement(2, "input"); + builder.AddAttribute(3, "type", "date"); + builder.AddAttribute(4, "class", "form-control form-control-sm"); + builder.AddAttribute(5, "value", fromValue ?? ""); + builder.AddAttribute(6, "disabled", IsLoading); + builder.AddAttribute(7, "onchange", EventCallback.Factory.Create(this, e => OnImmediateFilterChanged(fromField, e.Value?.ToString()))); + builder.CloseElement(); + builder.OpenElement(8, "input"); + builder.AddAttribute(9, "type", "date"); + builder.AddAttribute(10, "class", "form-control form-control-sm"); + builder.AddAttribute(11, "value", toValue ?? ""); + builder.AddAttribute(12, "disabled", IsLoading); + builder.AddAttribute(13, "onchange", EventCallback.Factory.Create(this, e => OnImmediateFilterChanged(toField, e.Value?.ToString()))); + builder.CloseElement(); + builder.CloseElement(); + }; + + private async Task PreviousPageAsync() + { + if (_page <= 1) + return; + + _page--; + await LoadPageAsync(); } - private string InfoFormat() => $"Page {_table.CurrentPage + 1} of {(_table.GetFilteredItemsCount() / _table.RowsPerPage)+1}"; - - private async Task OpenDialogAsync(ProductionLogSummaryDTO log) + private async Task NextPageAsync() + { + if (_page >= _totalPages) + return; + + _page++; + await LoadPageAsync(); + } + + private async Task OnPageInputChanged(ChangeEventArgs e) + { + await JumpToPageAsync(e.Value?.ToString()); + } + + private async Task OnPageInputKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") + await JumpToPageAsync(_pageInput); + } + + private async Task JumpToPageAsync(string? raw) + { + if (!int.TryParse(raw, out var page)) + { + _pageInput = _page.ToString(); + return; + } + + _page = Math.Clamp(page, 1, _totalPages); + await LoadPageAsync(); + } + + private async Task OnPageSizeChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var pageSize)) + { + _pageSize = pageSize; + _page = 1; + await LoadPageAsync(); + } + } + + private async Task ClearAllFiltersAsync() + { + SearchString = ""; + _filters.Clear(); + _page = 1; + await LoadPageAsync(); + } + + private void ToggleColumnsPanel() => _isColumnsPanelOpen = !_isColumnsPanelOpen; + + private bool IsColumnVisible(string key) => !_columnVisibility.TryGetValue(key, out var visible) || visible; + + private async Task SetColumnVisibilityAsync(string key, bool visible) + { + _columnVisibility[key] = visible; + if (!visible) + _filters.ClearForColumn(key); + + await SaveColumnVisibilityAsync(); + _page = 1; + await LoadPageAsync(); + } + + private async Task ResetColumnsAsync() { - var detail = await ProductionLogService.GetDetailByIdAsync(log.Id); + foreach (var key in HideableColumns.Select(c => c.Key)) + _columnVisibility[key] = true; + + await SaveColumnVisibilityAsync(); + await LoadPageAsync(); + } + + private async Task SaveColumnVisibilityAsync() + { + var json = "{" + string.Join(",", _columnVisibility.Select(kvp => $"\"{kvp.Key}\":{kvp.Value.ToString().ToLowerInvariant()}")) + "}"; + await JS.InvokeVoidAsync("localStorage.setItem", ColumnVisibilityStorageKey, json); + } + + private async Task RetryAsync() => await LoadPageAsync(updateUrl: false); + + private async Task OpenDialogAsync(ProductionLogArchiveRowDTO log) + { + var detail = await ProductionLogService.GetDetailByIdAsync(log.ProductionLogId); if (detail == null) return; var data = (detail, false); await DialogService.ShowDialogAsync(data, new DialogParameters() { - Title = $"Log #{log.Id}", + Title = $"Log #{log.ProductionLogId}", OnDialogResult = DialogService.CreateDialogCallback(this, HandleDialogAsync), Width = "85%", PreventScroll = true, @@ -253,8 +668,155 @@ { if (!result.Cancelled) { - await GrabServerData(); - await _table.ReloadServerData(); + await LoadPageAsync(updateUrl: false); + } + } + + private void ReadUrlState() + { + var query = QueryHelpers.ParseQuery(new Uri(NavManager.Uri).Query); + _page = ReadInt(query, "page", 1); + _pageSize = PageSizes.Contains(ReadInt(query, "pageSize", 50)) ? ReadInt(query, "pageSize", 50) : 50; + SearchString = ReadString(query, "search"); + _sortBy = ReadString(query, "sortBy", "createdOn"); + _sortDir = ReadString(query, "sortDir", "desc"); + _filters.AttemptId = ReadString(query, "filterAttemptId"); + _filters.LogId = ReadString(query, "filterLogId"); + _filters.Status = ReadString(query, "filterStatus"); + _filters.Product = ReadString(query, "filterProduct"); + _filters.WorkInstruction = ReadString(query, "filterWorkInstruction"); + _filters.PartProduced = ReadString(query, "filterPartProduced"); + _filters.ProducedPartSerialNumber = ReadString(query, "filterProducedPartSerialNumber"); + _filters.CreatedOnFrom = ReadString(query, "filterCreatedOnFrom"); + _filters.CreatedOnTo = ReadString(query, "filterCreatedOnTo"); + _filters.CreatedBy = ReadString(query, "filterCreatedBy"); + _filters.ModifiedOnFrom = ReadString(query, "filterModifiedOnFrom"); + _filters.ModifiedOnTo = ReadString(query, "filterModifiedOnTo"); + _filters.ModifiedBy = ReadString(query, "filterModifiedBy"); + } + + private void UpdateUrl() + { + var values = new Dictionary + { + ["page"] = _page.ToString(), + ["pageSize"] = _pageSize.ToString(), + ["search"] = NullIfEmpty(SearchString), + ["sortBy"] = _sortBy, + ["sortDir"] = _sortDir, + ["filterLogId"] = NullIfEmpty(_filters.LogId), + ["filterAttemptId"] = NullIfEmpty(_filters.AttemptId), + ["filterStatus"] = NullIfEmpty(_filters.Status), + ["filterProduct"] = NullIfEmpty(_filters.Product), + ["filterWorkInstruction"] = NullIfEmpty(_filters.WorkInstruction), + ["filterPartProduced"] = NullIfEmpty(_filters.PartProduced), + ["filterProducedPartSerialNumber"] = NullIfEmpty(_filters.ProducedPartSerialNumber), + ["filterCreatedOnFrom"] = NullIfEmpty(_filters.CreatedOnFrom), + ["filterCreatedOnTo"] = NullIfEmpty(_filters.CreatedOnTo), + ["filterCreatedBy"] = NullIfEmpty(_filters.CreatedBy), + ["filterModifiedOnFrom"] = NullIfEmpty(_filters.ModifiedOnFrom), + ["filterModifiedOnTo"] = NullIfEmpty(_filters.ModifiedOnTo), + ["filterModifiedBy"] = NullIfEmpty(_filters.ModifiedBy) + }; + + var uri = QueryHelpers.AddQueryString("/log-archive", values.Where(kvp => kvp.Value != null)!); + NavManager.NavigateTo(uri, replace: true); + } + + private static string FormatDate(DateTimeOffset value) => value.LocalDateTime.ToString("yyyy-MM-dd HH:mm"); + + private static string EmptyFallback(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value; + + private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + + private static int ReadInt(IDictionary query, string key, int fallback) + => query.TryGetValue(key, out var value) && int.TryParse(value, out var parsed) ? Math.Max(1, parsed) : fallback; + + private static string ReadString(IDictionary query, string key, string fallback = "") + => query.TryGetValue(key, out var value) ? value.ToString() : fallback; + + private static DateTimeOffset? ParseDateStart(string? value) + => DateTime.TryParse(value, out var date) ? new DateTimeOffset(date.Date) : null; + + private static DateTimeOffset? ParseDateEnd(string? value) + => DateTime.TryParse(value, out var date) ? new DateTimeOffset(date.Date.AddDays(1).AddTicks(-1)) : null; + + private sealed record ColumnDefinition(string Key, string Label); + + private sealed class FilterModel + { + public string? LogId { get; set; } + public string? AttemptId { get; set; } + public string? Status { get; set; } + public string? Product { get; set; } + public string? WorkInstruction { get; set; } + public string? PartProduced { get; set; } + public string? ProducedPartSerialNumber { get; set; } + public string? CreatedOnFrom { get; set; } + public string? CreatedOnTo { get; set; } + public string? CreatedBy { get; set; } + public string? ModifiedOnFrom { get; set; } + public string? ModifiedOnTo { get; set; } + public string? ModifiedBy { get; set; } + + public bool HasAny => new[] + { + LogId, AttemptId, Status, Product, WorkInstruction, PartProduced, ProducedPartSerialNumber, + CreatedOnFrom, CreatedOnTo, CreatedBy, ModifiedOnFrom, ModifiedOnTo, ModifiedBy + }.Any(value => !string.IsNullOrWhiteSpace(value)); + + public void Clear() + { + LogId = AttemptId = Status = Product = WorkInstruction = PartProduced = ProducedPartSerialNumber = null; + CreatedOnFrom = CreatedOnTo = CreatedBy = ModifiedOnFrom = ModifiedOnTo = ModifiedBy = null; + } + + public void Set(string field, string? value) + { + value = string.IsNullOrWhiteSpace(value) ? null : value; + GetType().GetProperty(field)?.SetValue(this, value); + } + + public void ClearForColumn(string key) + { + switch (key) + { + case ColumnLogId: + LogId = null; + break; + case ColumnAttemptId: + AttemptId = null; + break; + case ColumnStatus: + Status = null; + break; + case ColumnProduct: + Product = null; + break; + case ColumnWorkInstruction: + WorkInstruction = null; + break; + case ColumnPartProduced: + PartProduced = null; + break; + case ColumnProducedPartSn: + ProducedPartSerialNumber = null; + break; + case ColumnCreatedOn: + CreatedOnFrom = null; + CreatedOnTo = null; + break; + case ColumnCreatedBy: + CreatedBy = null; + break; + case ColumnModifiedOn: + ModifiedOnFrom = null; + ModifiedOnTo = null; + break; + case ColumnModifiedBy: + ModifiedBy = null; + break; + } } } } \ No newline at end of file diff --git a/MESS/MESS.Blazor/Components/Pages/Auth/Login.razor b/MESS/MESS.Blazor/Components/Pages/Auth/Login.razor index 6c836d8b..8dd32a99 100644 --- a/MESS/MESS.Blazor/Components/Pages/Auth/Login.razor +++ b/MESS/MESS.Blazor/Components/Pages/Auth/Login.razor @@ -1,14 +1,18 @@ @page "/" +@page "/auth/Login" @using MESS.Data.Models @using MESS.Services.CRUD.ApplicationUser @using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.WebUtilities @inject NavigationManager Navigation @inject IApplicationUserService ApplicationUserService @inject AuthenticationStateProvider AuthStateProvider +@inject IConfiguration Configuration Login -
+
@code { @@ -89,14 +126,45 @@ private string SearchFilter { get; set; } = ""; private bool IsDropdownVisible { get; set; } private ElementReference _dropdownContainer; + private bool _isMouseOverDropdown; + private string? _alertMessage; + private bool _microsoftAuthEnabled; /// protected override async Task OnInitializedAsync() { ApplicationUsers = await ApplicationUserService.GetUsersForLoginDropdownAsync(); - } - private bool _isMouseOverDropdown; + _microsoftAuthEnabled = MicrosoftEntraAuthConfiguration.TryGetMicrosoftEntraCredentials( + Configuration, + out _, + out _, + out _); + + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + if (query.TryGetValue("registered", out var reg) && reg == "1") + { + _alertMessage = "Account created. You can sign in below (leave password blank unless you set one)."; + } + else if (query.TryGetValue("failed", out var failed) && failed == "1") + { + _alertMessage = "Sign-in failed. Check your username and password. For legacy accounts without a password, leave password blank."; + } + else if (query.TryGetValue("external", out var ext)) + { + _alertMessage = ext.ToString() switch + { + "disabled" => "Microsoft sign-in is not configured for this environment.", + "noinfo" => "Microsoft sign-in did not return login information. Try again.", + "noemail" => "Microsoft account did not include an email. Ask an administrator to verify directory claims.", + "nouser" => "No MESS user matches your Microsoft identity. Ask an administrator to create your account with the same email.", + "locked" => "This account is locked.", + "linkfailed" => "Could not link Microsoft sign-in to your account.", + _ => null + }; + } + } private void OnDropdownMouseEnter() { @@ -115,16 +183,19 @@ IsDropdownVisible = false; } } + private void ShowDropdown() { IsDropdownVisible = true; } - private void SelectOperator(ApplicationUser user) { - if (user.UserName != null) SearchFilter = user.UserName; + if (user.UserName != null) + { + SearchFilter = user.UserName; + } + IsDropdownVisible = false; } - -} \ No newline at end of file +} diff --git a/MESS/MESS.Blazor/Components/Pages/Auth/Profile.razor b/MESS/MESS.Blazor/Components/Pages/Auth/Profile.razor new file mode 100644 index 00000000..41d7fc78 --- /dev/null +++ b/MESS/MESS.Blazor/Components/Pages/Auth/Profile.razor @@ -0,0 +1,256 @@ +@page "/profile" +@attribute [Authorize] +@using MESS.Data.Models +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Identity +@inject AuthenticationStateProvider AuthStateProvider +@inject UserManager UserManager +@inject IToastService ToastService + +My Profile + +
+

My Profile

+ + @if (_isLoading) + { +
+ Loading... +
+ } + else if (_user == null) + { +
Unable to load your profile.
+ } + else + { +
+
+ Profile Information +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ +
+
+ Password +
+
+

+ @(_hasPassword + ? "Enter your current password before setting a new password." + : "This account does not have a password yet. Set one below.") +

+ + + + + @if (_hasPassword) + { +
+ + +
+ } + +
+ + +
+
+ + +
+ + + + +
+
+
+ } +
+ +@code { + private ApplicationUser? _user; + private readonly ProfileFormModel _profileModel = new(); + private readonly PasswordFormModel _passwordModel = new(); + private bool _hasPassword; + private bool _isLoading = true; + private bool _isSavingProfile; + private bool _isSavingPassword; + + /// + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + _user = await UserManager.GetUserAsync(authState.User); + + if (_user != null) + { + _profileModel.FirstName = _user.FirstName; + _profileModel.LastName = _user.LastName; + _profileModel.Email = _user.Email; + _profileModel.UserName = _user.UserName; + _profileModel.PhoneNumber = _user.PhoneNumber; + _hasPassword = await UserManager.HasPasswordAsync(_user); + } + + _isLoading = false; + } + + private async Task SaveProfileAsync() + { + if (_user == null) + return; + + if (string.IsNullOrWhiteSpace(_profileModel.UserName) && string.IsNullOrWhiteSpace(_profileModel.Email)) + { + ToastService.ShowError("A profile requires either a username or an email address."); + return; + } + + _isSavingProfile = true; + try + { + _user.FirstName = _profileModel.FirstName; + _user.LastName = _profileModel.LastName; + _user.Email = _profileModel.Email; + _user.UserName = string.IsNullOrWhiteSpace(_profileModel.UserName) + ? _profileModel.Email + : _profileModel.UserName; + _user.PhoneNumber = _profileModel.PhoneNumber; + + var result = await UserManager.UpdateAsync(_user); + if (!result.Succeeded) + { + ShowIdentityErrors(result); + return; + } + + ToastService.ShowSuccess("Profile updated successfully."); + } + finally + { + _isSavingProfile = false; + } + } + + private async Task SavePasswordAsync() + { + if (_user == null) + return; + + if (_passwordModel.NewPassword != _passwordModel.ConfirmPassword) + { + ToastService.ShowError("New password and confirm password must match."); + return; + } + + if (string.IsNullOrWhiteSpace(_passwordModel.NewPassword)) + { + ToastService.ShowError("New password is required."); + return; + } + + _isSavingPassword = true; + try + { + IdentityResult result; + if (_hasPassword) + { + if (string.IsNullOrWhiteSpace(_passwordModel.CurrentPassword)) + { + ToastService.ShowError("Current password is required."); + return; + } + + result = await UserManager.ChangePasswordAsync( + _user, + _passwordModel.CurrentPassword, + _passwordModel.NewPassword); + } + else + { + result = await UserManager.AddPasswordAsync(_user, _passwordModel.NewPassword); + } + + if (!result.Succeeded) + { + ShowIdentityErrors(result); + return; + } + + _passwordModel.CurrentPassword = ""; + _passwordModel.NewPassword = ""; + _passwordModel.ConfirmPassword = ""; + _hasPassword = true; + + ToastService.ShowSuccess("Password saved successfully."); + } + finally + { + _isSavingPassword = false; + } + } + + private void ShowIdentityErrors(IdentityResult result) + { + foreach (var error in result.Errors) + { + ToastService.ShowError(error.Description); + } + } + + private sealed class ProfileFormModel + { + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? Email { get; set; } + public string? UserName { get; set; } + public string? PhoneNumber { get; set; } + } + + private sealed class PasswordFormModel + { + public string? CurrentPassword { get; set; } + public string? NewPassword { get; set; } + public string? ConfirmPassword { get; set; } + } +} diff --git a/MESS/MESS.Blazor/Components/Pages/Auth/Register.razor b/MESS/MESS.Blazor/Components/Pages/Auth/Register.razor index 44fd0e47..f0cec79c 100644 --- a/MESS/MESS.Blazor/Components/Pages/Auth/Register.razor +++ b/MESS/MESS.Blazor/Components/Pages/Auth/Register.razor @@ -8,7 +8,8 @@ Register -
+ - + +
+ + +
diff --git a/MESS/MESS.Blazor/Components/Pages/Phoebe/WorkInstruction/WorkInstructionNodes/StepNodeView.razor.cs b/MESS/MESS.Blazor/Components/Pages/Phoebe/WorkInstruction/WorkInstructionNodes/StepNodeView.razor.cs index 9cdbf263..8e88ea1e 100644 --- a/MESS/MESS.Blazor/Components/Pages/Phoebe/WorkInstruction/WorkInstructionNodes/StepNodeView.razor.cs +++ b/MESS/MESS.Blazor/Components/Pages/Phoebe/WorkInstruction/WorkInstructionNodes/StepNodeView.razor.cs @@ -307,6 +307,15 @@ private async Task OnNameChanged(ChangeEventArgs e) Step.Name = e.Value?.ToString() ?? ""; await NotifyChangeAsync(); } + + private async Task OnNotesConfigurationChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var v) && Enum.IsDefined(typeof(StepNotesConfiguration), v)) + { + Step.NotesConfiguration = (StepNotesConfiguration)v; + await NotifyChangeAsync(); + } + } private async Task NotifyChangeAsync() { diff --git a/MESS/MESS.Blazor/Components/Pages/ProductionLog/RedTagDialog.razor b/MESS/MESS.Blazor/Components/Pages/ProductionLog/RedTagDialog.razor index 88d902eb..be23ba6d 100644 --- a/MESS/MESS.Blazor/Components/Pages/ProductionLog/RedTagDialog.razor +++ b/MESS/MESS.Blazor/Components/Pages/ProductionLog/RedTagDialog.razor @@ -20,12 +20,38 @@
Work Instruction: @ProductionLogEventService.CurrentWorkInstructionName
Step: @StepName
Operator: @OperatorName
+
Defect location: @DefectLocationName
+
Defect: @DefectAdjectiveName
Failure Note: @FailureNotes
+
+
+

+ Red Tag: @(AttemptId?.ToString() ?? "None") +  |  + @OperatorName +  |  + @(AttemptTime?.LocalDateTime.ToString("MM/dd/yyyy HH:mm") ?? "N/A") +

+

+ Location: + @ProductionLogEventService.CurrentProductName > @ProductionLogEventService.CurrentWorkInstructionName > @StepName +

+

+ Defect: + @DefectLocationName > @DefectAdjectiveName +

+

+ Note: + @FailureNotes +

+
+
+
@@ -50,6 +76,18 @@ [Parameter] public string StepName { get; set; } = string.Empty; + /// + /// Selected defect location (failure noun) label for print. + /// + [Parameter] + public string DefectLocationName { get; set; } = ""; + + /// + /// Selected defect description (failure adjective) label for print. + /// + [Parameter] + public string DefectAdjectiveName { get; set; } = ""; + /// /// Notes entered by the operator describing the failure condition. /// Displayed in the red tag. @@ -155,7 +193,7 @@ { try { - await _jsModule.InvokeVoidAsync("printRedTagById", "red-tag-printable"); + await _jsModule.InvokeVoidAsync("printRedTagById", "red-tag-printable-linear"); } catch (JSException ex) { diff --git a/MESS/MESS.Blazor/Components/Pages/ProductionLog/RedTagDialog.razor.css b/MESS/MESS.Blazor/Components/Pages/ProductionLog/RedTagDialog.razor.css index cb0233aa..8fb89e56 100644 --- a/MESS/MESS.Blazor/Components/Pages/ProductionLog/RedTagDialog.razor.css +++ b/MESS/MESS.Blazor/Components/Pages/ProductionLog/RedTagDialog.razor.css @@ -182,3 +182,7 @@ font-weight: bold; margin-right: 4px; } + +.red-tag-print-source { + display: none; +} diff --git a/MESS/MESS.Blazor/Components/Pages/ProductionLog/WorkInstructionNodes/StepNodeListItem.razor b/MESS/MESS.Blazor/Components/Pages/ProductionLog/WorkInstructionNodes/StepNodeListItem.razor index a3e6709e..4cd03286 100644 --- a/MESS/MESS.Blazor/Components/Pages/ProductionLog/WorkInstructionNodes/StepNodeListItem.razor +++ b/MESS/MESS.Blazor/Components/Pages/ProductionLog/WorkInstructionNodes/StepNodeListItem.razor @@ -1,11 +1,16 @@ @using System.Text.RegularExpressions @using MESS.Data.Models @using MESS.Blazor.Components.Carousel +@using MESS.Services.CRUD.Defects +@using MESS.Services.DTOs.Defects @using MESS.Services.DTOs.ProductionLogs.LogSteps.Attempts.Form @using MESS.Services.DTOs.ProductionLogs.LogSteps.Form @using MESS.Services.UI.ProductionLogEvent +@using Microsoft.FluentUI.AspNetCore.Components +@inject IDefectCodeService DefectCodeService @inject IProductionLogEventService ProductionLogEventService @inject IJSRuntime JsRuntime +@inject IToastService ToastService
@@ -36,6 +41,8 @@ - - - - +
+
+ @if (Step.NotesConfiguration == StepNotesConfiguration.Required) + { + + } +