From d78fd5e3d14f9c4b726f1fedacf71e8cb67d1504 Mon Sep 17 00:00:00 2001 From: OlgunR Date: Fri, 6 Feb 2026 14:07:52 +0100 Subject: [PATCH] Add user-specific persistent grid/band layouts support Implemented user-customizable, persistent grid and band layouts for CatalogsGrid and MassDataGrid. Added backend API, database entity, and repository for storing layouts per user. Refactored grids to support dynamic band/column rendering, layout management UI, and per-user storage via localStorage and the new API. Registered all necessary services and updated data context. Enables flexible, user-specific grid experiences with saved layouts. --- DbFirst.API/Controllers/LayoutsController.cs | 94 +++ DbFirst.API/Program.cs | 1 + .../Repositories/ILayoutRepository.cs | 10 + .../Components/CatalogsGrid.razor | 603 +++++++++++++++-- .../Components/MassDataGrid.razor | 592 +++++++++++++++-- DbFirst.BlazorWasm/Models/LayoutDto.cs | 9 + DbFirst.BlazorWasm/Program.cs | 1 + .../Services/LayoutApiClient.cs | 64 ++ .../Components/CatalogsGrid.razor | 584 +++++++++++++++-- .../Components/MassDataGrid.razor | 610 ++++++++++++++++-- DbFirst.BlazorWebApp/Models/LayoutDto.cs | 9 + DbFirst.BlazorWebApp/Program.cs | 5 + .../Services/LayoutApiClient.cs | 64 ++ DbFirst.Domain/Entities/SmfLayout.cs | 15 + .../ApplicationDbContext.cs | 32 + .../Repositories/LayoutRepository.cs | 66 ++ 16 files changed, 2530 insertions(+), 229 deletions(-) create mode 100644 DbFirst.API/Controllers/LayoutsController.cs create mode 100644 DbFirst.Application/Repositories/ILayoutRepository.cs create mode 100644 DbFirst.BlazorWasm/Models/LayoutDto.cs create mode 100644 DbFirst.BlazorWasm/Services/LayoutApiClient.cs create mode 100644 DbFirst.BlazorWebApp/Models/LayoutDto.cs create mode 100644 DbFirst.BlazorWebApp/Services/LayoutApiClient.cs create mode 100644 DbFirst.Domain/Entities/SmfLayout.cs create mode 100644 DbFirst.Infrastructure/Repositories/LayoutRepository.cs diff --git a/DbFirst.API/Controllers/LayoutsController.cs b/DbFirst.API/Controllers/LayoutsController.cs new file mode 100644 index 0000000..7eb3d98 --- /dev/null +++ b/DbFirst.API/Controllers/LayoutsController.cs @@ -0,0 +1,94 @@ +using System.Text; +using DbFirst.Application.Repositories; +using DbFirst.Domain.Entities; +using Microsoft.AspNetCore.Mvc; + +namespace DbFirst.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class LayoutsController : ControllerBase +{ + private readonly ILayoutRepository _repository; + + public LayoutsController(ILayoutRepository repository) + { + _repository = repository; + } + + [HttpGet] + public async Task> Get([FromQuery] string layoutType, [FromQuery] string layoutKey, [FromQuery] string userName, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(layoutType) || string.IsNullOrWhiteSpace(layoutKey) || string.IsNullOrWhiteSpace(userName)) + { + return BadRequest("layoutType, layoutKey und userName sind erforderlich."); + } + + var entity = await _repository.GetAsync(layoutType, layoutKey, userName, cancellationToken); + if (entity == null) + { + return NotFound(); + } + + return Ok(Map(entity)); + } + + [HttpPost] + public async Task> Upsert(LayoutDto dto, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(dto.LayoutType) || string.IsNullOrWhiteSpace(dto.LayoutKey) || string.IsNullOrWhiteSpace(dto.UserName)) + { + return BadRequest("LayoutType, LayoutKey und UserName sind erforderlich."); + } + + var data = string.IsNullOrWhiteSpace(dto.LayoutData) + ? Array.Empty() + : Encoding.UTF8.GetBytes(dto.LayoutData); + + try + { + var entity = await _repository.UpsertAsync(dto.LayoutType, dto.LayoutKey, dto.UserName, data, cancellationToken); + return Ok(Map(entity)); + } + catch (Exception ex) + { + var detail = ex.InnerException?.Message ?? ex.Message; + return Problem(detail: detail, statusCode: StatusCodes.Status500InternalServerError); + } + } + + [HttpDelete] + public async Task Delete([FromQuery] string layoutType, [FromQuery] string layoutKey, [FromQuery] string userName, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(layoutType) || string.IsNullOrWhiteSpace(layoutKey) || string.IsNullOrWhiteSpace(userName)) + { + return BadRequest("layoutType, layoutKey und userName sind erforderlich."); + } + + var deleted = await _repository.DeleteAsync(layoutType, layoutKey, userName, cancellationToken); + return deleted ? NoContent() : NotFound(); + } + + private static LayoutDto Map(SmfLayout entity) + { + var layoutData = entity.LayoutData.Length == 0 + ? string.Empty + : Encoding.UTF8.GetString(entity.LayoutData); + + return new LayoutDto + { + LayoutType = entity.LayoutType, + LayoutKey = entity.LayoutKey, + UserName = entity.UserName, + LayoutData = layoutData + }; + } + + public sealed class LayoutDto + { + public string LayoutType { get; set; } = string.Empty; + public string LayoutKey { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; + public string LayoutData { get; set; } = string.Empty; + } +} diff --git a/DbFirst.API/Program.cs b/DbFirst.API/Program.cs index 98c6e0b..bfbc792 100644 --- a/DbFirst.API/Program.cs +++ b/DbFirst.API/Program.cs @@ -53,6 +53,7 @@ builder.Services.AddApplication(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddDevExpressControls(); builder.Services.AddSignalR(); diff --git a/DbFirst.Application/Repositories/ILayoutRepository.cs b/DbFirst.Application/Repositories/ILayoutRepository.cs new file mode 100644 index 0000000..741c88b --- /dev/null +++ b/DbFirst.Application/Repositories/ILayoutRepository.cs @@ -0,0 +1,10 @@ +using DbFirst.Domain.Entities; + +namespace DbFirst.Application.Repositories; + +public interface ILayoutRepository +{ + Task GetAsync(string layoutType, string layoutKey, string userName, CancellationToken cancellationToken = default); + Task UpsertAsync(string layoutType, string layoutKey, string userName, byte[] layoutData, CancellationToken cancellationToken = default); + Task DeleteAsync(string layoutType, string layoutKey, string userName, CancellationToken cancellationToken = default); +} diff --git a/DbFirst.BlazorWasm/Components/CatalogsGrid.razor b/DbFirst.BlazorWasm/Components/CatalogsGrid.razor index 704be66..360d39b 100644 --- a/DbFirst.BlazorWasm/Components/CatalogsGrid.razor +++ b/DbFirst.BlazorWasm/Components/CatalogsGrid.razor @@ -1,5 +1,10 @@ +@using System.Text.Json +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Rendering @using Microsoft.AspNetCore.Components.Forms @inject CatalogApiClient Api +@inject LayoutApiClient LayoutApi +@inject IJSRuntime JsRuntime @if (!string.IsNullOrWhiteSpace(errorMessage)) @@ -69,11 +93,45 @@ else if (items.Count == 0) } else { +
+
+ + + + +
+ @foreach (var band in bandLayout.Bands) + { +
+ + +
+ } + + @foreach (var column in columnDefinitions) + { + + + + } + +
+
+ DataItemDeleting="OnDataItemDeleting" + @ref="gridRef"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @RenderColumns() @{ SetEditContext(editFormContext.EditContext); var editModel = (CatalogEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); } @@ -171,7 +181,27 @@ else private string? infoMessage; private EditContext? editContext; private ValidationMessageStore? validationMessageStore; + private IGrid? gridRef; private string popupHeaderText = "Edit"; + private const string LayoutType = "GRID_BANDS"; + private const string LayoutKey = "CatalogsGrid"; + private const string LayoutUserStorageKey = "layoutUser"; + private string? layoutUser; + private BandLayout bandLayout = new(); + private Dictionary columnBandAssignments = new(); + private List bandOptions = new(); + private Dictionary columnLookup = new(); + private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web); + private List columnDefinitions = new() + { + new() { FieldName = nameof(CatalogReadDto.Guid), Caption = "Id", Width = "140px", FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(CatalogReadDto.CatTitle), Caption = "Titel", FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(CatalogReadDto.CatString), Caption = "String", FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(CatalogReadDto.AddedWho), Caption = "Angelegt von", ReadOnly = true, FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(CatalogReadDto.AddedWhen), Caption = "Angelegt am", ReadOnly = true, FilterType = ColumnFilterType.Date }, + new() { FieldName = nameof(CatalogReadDto.ChangedWho), Caption = "Geändert von", ReadOnly = true, FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(CatalogReadDto.ChangedWhen), Caption = "Geändert am", ReadOnly = true, FilterType = ColumnFilterType.Date } + }; private readonly List procedureOptions = new() { @@ -179,8 +209,13 @@ else new() { Value = 1, Text = "PRTBMY_CATALOG_SAVE" } }; + private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser); + protected override async Task OnInitializedAsync() { + columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase); + await EnsureLayoutUserAsync(); + await LoadBandLayoutAsync(); await LoadCatalogs(); } @@ -223,6 +258,11 @@ else } } + private void SetPopupHeaderText(bool isNew) + { + popupHeaderText = isNew ? "Neu" : "Edit"; + } + private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e) { popupHeaderText = e.IsNew ? "Neu" : "Edit"; @@ -385,9 +425,466 @@ else } } - private void SetPopupHeaderText(bool isNew) + private async Task EnsureLayoutUserAsync() { - popupHeaderText = isNew ? "Neu" : "Edit"; + layoutUser = await JsRuntime.InvokeAsync("localStorage.getItem", LayoutUserStorageKey); + if (string.IsNullOrWhiteSpace(layoutUser)) + { + layoutUser = Guid.NewGuid().ToString("N"); + await JsRuntime.InvokeVoidAsync("localStorage.setItem", LayoutUserStorageKey, layoutUser); + } + } + + private async Task LoadBandLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + bandLayout = new BandLayout(); + UpdateBandOptions(); + return; + } + + var stored = await LayoutApi.GetAsync(LayoutType, LayoutKey, layoutUser); + if (stored != null && !string.IsNullOrWhiteSpace(stored.LayoutData)) + { + var parsed = JsonSerializer.Deserialize(stored.LayoutData, jsonOptions); + bandLayout = NormalizeBandLayout(parsed); + } + else + { + bandLayout = new BandLayout(); + } + + columnBandAssignments = BuildAssignmentsFromLayout(bandLayout); + ApplyColumnLayoutFromStorage(); + ApplyBandOrderingFromColumnOrder(); + UpdateBandOptions(); + } + + private async Task SaveBandLayoutAsync() + { + await SaveGridLayoutAsync(); + } + + private async Task SaveGridLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + return; + } + + try + { + CaptureColumnLayoutFromGrid(); + + var layoutData = JsonSerializer.Serialize(bandLayout, jsonOptions); + await LayoutApi.UpsertAsync(new LayoutDto + { + LayoutType = LayoutType, + LayoutKey = LayoutKey, + UserName = layoutUser, + LayoutData = layoutData + }); + infoMessage = "Grid-Layout gespeichert."; + errorMessage = null; + } + catch (Exception ex) + { + errorMessage = $"Grid-Layout konnte nicht gespeichert werden: {ex.Message}"; + } + } + + private async Task ResetBandLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + return; + } + + await LayoutApi.DeleteAsync(LayoutType, LayoutKey, layoutUser); + bandLayout = new BandLayout(); + columnBandAssignments.Clear(); + UpdateBandOptions(); + infoMessage = "Band-Layout zurückgesetzt."; + } + + private void ApplyColumnLayoutFromStorage() + { + if (bandLayout.ColumnOrder.Count > 0) + { + var ordered = bandLayout.ColumnOrder + .Where(columnLookup.ContainsKey) + .Select(field => columnLookup[field]) + .ToList(); + + ordered.AddRange(columnDefinitions.Where(column => !ordered.Contains(column))); + columnDefinitions = ordered; + } + + foreach (var column in columnDefinitions) + { + if (bandLayout.ColumnWidths.TryGetValue(column.FieldName, out var width) && !string.IsNullOrWhiteSpace(width)) + { + column.Width = width; + } + } + + columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase); + } + + private void CaptureColumnLayoutFromGrid() + { + if (gridRef == null) + { + return; + } + + var gridColumns = gridRef.GetColumns() + .OfType() + .Where(column => !string.IsNullOrWhiteSpace(column.FieldName)) + .ToList(); + + bandLayout.ColumnOrder = gridColumns + .OrderBy(column => column.VisibleIndex) + .Select(column => column.FieldName) + .ToList(); + + bandLayout.ColumnWidths = gridColumns + .Where(column => !string.IsNullOrWhiteSpace(column.Width)) + .ToDictionary(column => column.FieldName, column => column.Width, StringComparer.OrdinalIgnoreCase); + + ApplyBandOrderingFromColumnOrder(); + } + + private void ApplyBandOrderingFromColumnOrder() + { + if (bandLayout.ColumnOrder.Count == 0) + { + return; + } + + var bandById = bandLayout.Bands.ToDictionary(band => band.Id, StringComparer.OrdinalIgnoreCase); + var orderedBandIds = new List(); + var orderedColumnsByBand = bandLayout.Bands.ToDictionary( + band => band.Id, + _ => new List(), + StringComparer.OrdinalIgnoreCase); + + foreach (var field in bandLayout.ColumnOrder) + { + if (columnBandAssignments.TryGetValue(field, out var bandId) && bandById.ContainsKey(bandId)) + { + if (!orderedBandIds.Contains(bandId, StringComparer.OrdinalIgnoreCase)) + { + orderedBandIds.Add(bandId); + } + + orderedColumnsByBand[bandId].Add(field); + } + } + + foreach (var band in bandLayout.Bands) + { + var orderedColumns = orderedColumnsByBand[band.Id]; + orderedColumns.AddRange(band.Columns.Where(column => !orderedColumns.Contains(column, StringComparer.OrdinalIgnoreCase))); + band.Columns = orderedColumns; + } + + if (orderedBandIds.Count > 0) + { + bandLayout.Bands = orderedBandIds + .Select(id => bandById[id]) + .Concat(bandLayout.Bands.Where(band => !orderedBandIds.Contains(band.Id, StringComparer.OrdinalIgnoreCase))) + .ToList(); + } + } + + private void AddBand() + { + bandLayout.Bands.Add(new BandDefinition + { + Id = Guid.NewGuid().ToString("N"), + Caption = "Band" + }); + UpdateBandOptions(); + } + + private void RemoveBand(BandDefinition band) + { + bandLayout.Bands.Remove(band); + var removedColumns = columnBandAssignments.Where(pair => pair.Value == band.Id) + .Select(pair => pair.Key) + .ToList(); + foreach (var column in removedColumns) + { + columnBandAssignments.Remove(column); + } + UpdateBandOptions(); + SyncBandsFromAssignments(); + } + + private void UpdateBandCaption(BandDefinition band, string value) + { + band.Caption = value; + UpdateBandOptions(); + } + + private void UpdateColumnBand(string fieldName, string? bandId) + { + if (string.IsNullOrWhiteSpace(bandId)) + { + columnBandAssignments.Remove(fieldName); + } + else + { + columnBandAssignments[fieldName] = bandId; + } + + SyncBandsFromAssignments(); + } + + private string GetColumnBand(string fieldName) + { + return columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty; + } + + private void SyncBandsFromAssignments() + { + foreach (var band in bandLayout.Bands) + { + band.Columns = columnDefinitions + .Where(column => columnBandAssignments.TryGetValue(column.FieldName, out var bandId) && bandId == band.Id) + .Select(column => column.FieldName) + .ToList(); + } + + StateHasChanged(); + } + + private void UpdateBandOptions() + { + bandOptions = new List { new() { Id = string.Empty, Caption = "Ohne Band" } }; + bandOptions.AddRange(bandLayout.Bands.Select(band => new BandOption { Id = band.Id, Caption = band.Caption })); + } + + private BandLayout NormalizeBandLayout(BandLayout? layout) + { + layout ??= new BandLayout(); + layout.Bands ??= new List(); + foreach (var band in layout.Bands) + { + if (string.IsNullOrWhiteSpace(band.Id)) + { + band.Id = Guid.NewGuid().ToString("N"); + } + + if (string.IsNullOrWhiteSpace(band.Caption)) + { + band.Caption = "Band"; + } + + band.Columns = band.Columns?.Where(columnLookup.ContainsKey).ToList() ?? new List(); + } + + return layout; + } + + private Dictionary BuildAssignmentsFromLayout(BandLayout layout) + { + var assignments = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var band in layout.Bands) + { + foreach (var column in band.Columns) + { + assignments[column] = band.Id; + } + } + + return assignments; + } + + private RenderFragment RenderColumns() => builder => + { + var seq = 0; + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Width", "120px"); + builder.CloseComponent(); + + var bandLookup = bandLayout.Bands.ToDictionary(band => band.Id, StringComparer.OrdinalIgnoreCase); + var renderedBands = new HashSet(StringComparer.OrdinalIgnoreCase); + var orderedFields = bandLayout.ColumnOrder + .Where(columnLookup.ContainsKey) + .ToList(); + + if (orderedFields.Count == 0) + { + var grouped = bandLayout.Bands.SelectMany(band => band.Columns).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var column in columnDefinitions.Where(column => !grouped.Contains(column.FieldName))) + { + BuildDataColumn(builder, ref seq, column); + } + + foreach (var band in bandLayout.Bands) + { + if (band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + + return; + } + + foreach (var fieldName in orderedFields) + { + if (columnBandAssignments.TryGetValue(fieldName, out var bandId) && bandLookup.TryGetValue(bandId, out var band)) + { + if (!renderedBands.Add(bandId) || band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + else if (columnLookup.TryGetValue(fieldName, out var column)) + { + BuildDataColumn(builder, ref seq, column); + } + } + + foreach (var column in columnDefinitions) + { + if (!orderedFields.Contains(column.FieldName, StringComparer.OrdinalIgnoreCase) && + (!columnBandAssignments.TryGetValue(column.FieldName, out var bandId) || !bandLookup.ContainsKey(bandId))) + { + BuildDataColumn(builder, ref seq, column); + } + } + + foreach (var band in bandLayout.Bands) + { + if (renderedBands.Contains(band.Id) || band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + }; + + private void BuildDataColumn(RenderTreeBuilder builder, ref int seq, ColumnDefinition column) + { + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "FieldName", column.FieldName); + builder.AddAttribute(seq++, "Caption", column.Caption); + if (!string.IsNullOrWhiteSpace(column.Width)) + { + builder.AddAttribute(seq++, "Width", column.Width); + } + + if (!string.IsNullOrWhiteSpace(column.DisplayFormat)) + { + builder.AddAttribute(seq++, "DisplayFormat", column.DisplayFormat); + } + + if (column.ReadOnly) + { + builder.AddAttribute(seq++, "ReadOnly", true); + } + + builder.CloseComponent(); + } + + private RenderFragment? BuildFilterTemplate(ColumnDefinition column) + { + return null; + } + + private RenderFragment? BuildTextFilterTemplate() + { + return null; + } + + private RenderFragment? BuildDateFilterTemplate() + { + return null; + } + + private sealed class BandLayout + { + public List Bands { get; set; } = new(); + public List ColumnOrder { get; set; } = new(); + public Dictionary ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class BandDefinition + { + public string Id { get; set; } = string.Empty; + public string Caption { get; set; } = string.Empty; + public List Columns { get; set; } = new(); + } + + private sealed class BandOption + { + public string Id { get; set; } = string.Empty; + public string Caption { get; set; } = string.Empty; + } + + private sealed class ColumnDefinition + { + public string FieldName { get; init; } = string.Empty; + public string Caption { get; init; } = string.Empty; + public string? Width { get; set; } + public string? DisplayFormat { get; init; } + public bool ReadOnly { get; init; } + public ColumnFilterType FilterType { get; init; } + } + + private enum ColumnFilterType + { + Text, + Date } private sealed class CatalogEditModel diff --git a/DbFirst.BlazorWasm/Components/MassDataGrid.razor b/DbFirst.BlazorWasm/Components/MassDataGrid.razor index 48240e4..e4d9634 100644 --- a/DbFirst.BlazorWasm/Components/MassDataGrid.razor +++ b/DbFirst.BlazorWasm/Components/MassDataGrid.razor @@ -1,5 +1,10 @@ +@using System.Text.Json +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Rendering @using Microsoft.AspNetCore.Components.Forms @inject MassDataApiClient Api +@inject LayoutApiClient LayoutApi +@inject IJSRuntime JsRuntime @if (!string.IsNullOrWhiteSpace(errorMessage)) @@ -104,13 +128,45 @@ else CssClass="page-size-combo" />
+
+
+ + + + +
+ @foreach (var band in bandLayout.Bands) + { +
+ + +
+ } + + @foreach (var column in columnDefinitions) + { + + + + } + +
+
+ DataItemDeleting="OnDataItemDeleting" + @ref="gridRef"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @RenderColumns() @{ SetEditContext(editFormContext.EditContext); var editModel = (MassDataEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); } @@ -230,6 +233,28 @@ else private string popupHeaderText = "Edit"; private EditContext? editContext; private ValidationMessageStore? validationMessageStore; + private IGrid? gridRef; + private const string LayoutType = "GRID_BANDS"; + private const string LayoutKey = "MassDataGrid"; + private const string LayoutUserStorageKey = "layoutUser"; + private string? layoutUser; + private BandLayout bandLayout = new(); + private Dictionary columnBandAssignments = new(); + private List bandOptions = new(); + private Dictionary columnLookup = new(); + private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web); + private List columnDefinitions = new() + { + new() { FieldName = nameof(MassDataReadDto.Id), Caption = "Id", Width = "90px", ReadOnly = true, FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(MassDataReadDto.CustomerName), Caption = "CustomerName", FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(MassDataReadDto.Amount), Caption = "Amount", DisplayFormat = "c2", FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(MassDataReadDto.Category), Caption = "Category", ReadOnly = true, FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(MassDataReadDto.StatusFlag), Caption = "Status", ReadOnly = true, FilterType = ColumnFilterType.Bool }, + new() { FieldName = nameof(MassDataReadDto.AddedWhen), Caption = "Angelegt am", ReadOnly = true, FilterType = ColumnFilterType.Date }, + new() { FieldName = nameof(MassDataReadDto.ChangedWhen), Caption = "Geändert am", ReadOnly = true, FilterType = ColumnFilterType.Date } + }; + + private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser); private readonly List pageSizeOptions = new() { @@ -254,6 +279,9 @@ else protected override async Task OnInitializedAsync() { + columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase); + await EnsureLayoutUserAsync(); + await LoadBandLayoutAsync(); await LoadPage(0); } @@ -293,6 +321,417 @@ else await LoadPage(0); } + private async Task EnsureLayoutUserAsync() + { + layoutUser = await JsRuntime.InvokeAsync("localStorage.getItem", LayoutUserStorageKey); + if (string.IsNullOrWhiteSpace(layoutUser)) + { + layoutUser = Guid.NewGuid().ToString("N"); + await JsRuntime.InvokeVoidAsync("localStorage.setItem", LayoutUserStorageKey, layoutUser); + } + } + + private async Task LoadBandLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + bandLayout = new BandLayout(); + UpdateBandOptions(); + return; + } + + var stored = await LayoutApi.GetAsync(LayoutType, LayoutKey, layoutUser); + if (stored != null && !string.IsNullOrWhiteSpace(stored.LayoutData)) + { + var parsed = JsonSerializer.Deserialize(stored.LayoutData, jsonOptions); + bandLayout = NormalizeBandLayout(parsed); + } + else + { + bandLayout = new BandLayout(); + } + + columnBandAssignments = BuildAssignmentsFromLayout(bandLayout); + ApplyColumnLayoutFromStorage(); + ApplyBandOrderingFromColumnOrder(); + UpdateBandOptions(); + } + + private async Task SaveBandLayoutAsync() + { + await SaveGridLayoutAsync(); + } + + private async Task SaveGridLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + return; + } + + try + { + CaptureColumnLayoutFromGrid(); + + var layoutData = JsonSerializer.Serialize(bandLayout, jsonOptions); + await LayoutApi.UpsertAsync(new LayoutDto + { + LayoutType = LayoutType, + LayoutKey = LayoutKey, + UserName = layoutUser, + LayoutData = layoutData + }); + infoMessage = "Grid-Layout gespeichert."; + errorMessage = null; + } + catch (Exception ex) + { + errorMessage = $"Grid-Layout konnte nicht gespeichert werden: {ex.Message}"; + } + } + + private async Task ResetBandLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + return; + } + + await LayoutApi.DeleteAsync(LayoutType, LayoutKey, layoutUser); + bandLayout = new BandLayout(); + columnBandAssignments.Clear(); + UpdateBandOptions(); + infoMessage = "Band-Layout zurückgesetzt."; + } + + private void ApplyColumnLayoutFromStorage() + { + if (bandLayout.ColumnOrder.Count > 0) + { + var ordered = bandLayout.ColumnOrder + .Where(columnLookup.ContainsKey) + .Select(field => columnLookup[field]) + .ToList(); + + ordered.AddRange(columnDefinitions.Where(column => !ordered.Contains(column))); + columnDefinitions = ordered; + } + + foreach (var column in columnDefinitions) + { + if (bandLayout.ColumnWidths.TryGetValue(column.FieldName, out var width) && !string.IsNullOrWhiteSpace(width)) + { + column.Width = width; + } + } + + columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase); + } + + private void CaptureColumnLayoutFromGrid() + { + if (gridRef == null) + { + return; + } + + var gridColumns = gridRef.GetColumns() + .OfType() + .Where(column => !string.IsNullOrWhiteSpace(column.FieldName)) + .ToList(); + + bandLayout.ColumnOrder = gridColumns + .OrderBy(column => column.VisibleIndex) + .Select(column => column.FieldName) + .ToList(); + + bandLayout.ColumnWidths = gridColumns + .Where(column => !string.IsNullOrWhiteSpace(column.Width)) + .ToDictionary(column => column.FieldName, column => column.Width, StringComparer.OrdinalIgnoreCase); + + ApplyBandOrderingFromColumnOrder(); + } + + private void ApplyBandOrderingFromColumnOrder() + { + if (bandLayout.ColumnOrder.Count == 0) + { + return; + } + + var bandById = bandLayout.Bands.ToDictionary(band => band.Id, StringComparer.OrdinalIgnoreCase); + var orderedBandIds = new List(); + var orderedColumnsByBand = bandLayout.Bands.ToDictionary( + band => band.Id, + _ => new List(), + StringComparer.OrdinalIgnoreCase); + + foreach (var field in bandLayout.ColumnOrder) + { + if (columnBandAssignments.TryGetValue(field, out var bandId) && bandById.ContainsKey(bandId)) + { + if (!orderedBandIds.Contains(bandId, StringComparer.OrdinalIgnoreCase)) + { + orderedBandIds.Add(bandId); + } + + orderedColumnsByBand[bandId].Add(field); + } + } + + foreach (var band in bandLayout.Bands) + { + var orderedColumns = orderedColumnsByBand[band.Id]; + orderedColumns.AddRange(band.Columns.Where(column => !orderedColumns.Contains(column, StringComparer.OrdinalIgnoreCase))); + band.Columns = orderedColumns; + } + + if (orderedBandIds.Count > 0) + { + bandLayout.Bands = orderedBandIds + .Select(id => bandById[id]) + .Concat(bandLayout.Bands.Where(band => !orderedBandIds.Contains(band.Id, StringComparer.OrdinalIgnoreCase))) + .ToList(); + } + } + + private void AddBand() + { + bandLayout.Bands.Add(new BandDefinition + { + Id = Guid.NewGuid().ToString("N"), + Caption = "Band" + }); + UpdateBandOptions(); + } + + private void RemoveBand(BandDefinition band) + { + bandLayout.Bands.Remove(band); + var removedColumns = columnBandAssignments.Where(pair => pair.Value == band.Id) + .Select(pair => pair.Key) + .ToList(); + foreach (var column in removedColumns) + { + columnBandAssignments.Remove(column); + } + UpdateBandOptions(); + SyncBandsFromAssignments(); + } + + private void UpdateBandCaption(BandDefinition band, string value) + { + band.Caption = value; + UpdateBandOptions(); + } + + private void UpdateColumnBand(string fieldName, string? bandId) + { + if (string.IsNullOrWhiteSpace(bandId)) + { + columnBandAssignments.Remove(fieldName); + } + else + { + columnBandAssignments[fieldName] = bandId; + } + + SyncBandsFromAssignments(); + } + + private string GetColumnBand(string fieldName) + { + return columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty; + } + + private void SyncBandsFromAssignments() + { + foreach (var band in bandLayout.Bands) + { + band.Columns = columnDefinitions + .Where(column => columnBandAssignments.TryGetValue(column.FieldName, out var bandId) && bandId == band.Id) + .Select(column => column.FieldName) + .ToList(); + } + + StateHasChanged(); + } + + private void UpdateBandOptions() + { + bandOptions = new List { new() { Id = string.Empty, Caption = "Ohne Band" } }; + bandOptions.AddRange(bandLayout.Bands.Select(band => new BandOption { Id = band.Id, Caption = band.Caption })); + } + + private Dictionary BuildAssignmentsFromLayout(BandLayout layout) + { + var assignments = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var band in layout.Bands) + { + foreach (var column in band.Columns) + { + assignments[column] = band.Id; + } + } + + return assignments; + } + + private BandLayout NormalizeBandLayout(BandLayout? layout) + { + layout ??= new BandLayout(); + layout.Bands ??= new List(); + foreach (var band in layout.Bands) + { + if (string.IsNullOrWhiteSpace(band.Id)) + { + band.Id = Guid.NewGuid().ToString("N"); + } + + if (string.IsNullOrWhiteSpace(band.Caption)) + { + band.Caption = "Band"; + } + + band.Columns = band.Columns?.Where(columnLookup.ContainsKey).ToList() ?? new List(); + } + + return layout; + } + + private RenderFragment RenderColumns() => builder => + { + var seq = 0; + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Width", "120px"); + builder.CloseComponent(); + + var bandLookup = bandLayout.Bands.ToDictionary(band => band.Id, StringComparer.OrdinalIgnoreCase); + var renderedBands = new HashSet(StringComparer.OrdinalIgnoreCase); + var orderedFields = bandLayout.ColumnOrder + .Where(columnLookup.ContainsKey) + .ToList(); + + if (orderedFields.Count == 0) + { + var grouped = bandLayout.Bands.SelectMany(band => band.Columns).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var column in columnDefinitions.Where(column => !grouped.Contains(column.FieldName))) + { + BuildDataColumn(builder, ref seq, column); + } + + foreach (var band in bandLayout.Bands) + { + if (band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + + return; + } + + foreach (var fieldName in orderedFields) + { + if (columnBandAssignments.TryGetValue(fieldName, out var bandId) && bandLookup.TryGetValue(bandId, out var band)) + { + if (!renderedBands.Add(bandId) || band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + else if (columnLookup.TryGetValue(fieldName, out var column)) + { + BuildDataColumn(builder, ref seq, column); + } + } + + foreach (var column in columnDefinitions) + { + if (!orderedFields.Contains(column.FieldName, StringComparer.OrdinalIgnoreCase) && + (!columnBandAssignments.TryGetValue(column.FieldName, out var bandId) || !bandLookup.ContainsKey(bandId))) + { + BuildDataColumn(builder, ref seq, column); + } + } + + foreach (var band in bandLayout.Bands) + { + if (renderedBands.Contains(band.Id) || band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + }; + + private void BuildDataColumn(RenderTreeBuilder builder, ref int seq, ColumnDefinition column) + { + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "FieldName", column.FieldName); + builder.AddAttribute(seq++, "Caption", column.Caption); + if (!string.IsNullOrWhiteSpace(column.Width)) + { + builder.AddAttribute(seq++, "Width", column.Width); + } + + if (!string.IsNullOrWhiteSpace(column.DisplayFormat)) + { + builder.AddAttribute(seq++, "DisplayFormat", column.DisplayFormat); + } + + if (column.ReadOnly) + { + builder.AddAttribute(seq++, "ReadOnly", true); + } + + builder.CloseComponent(); + } + private void SetEditContext(EditContext context) { if (editContext == context) @@ -337,7 +776,7 @@ else popupHeaderText = isNew ? "Neu" : "Edit"; } - private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e) + private async Task OnCustomizeEditModel(GridCustomizeEditModelEventArgs e) { if (e.IsNew) { @@ -429,6 +868,43 @@ else return Task.CompletedTask; } + private sealed class BandLayout + { + public List Bands { get; set; } = new(); + public List ColumnOrder { get; set; } = new(); + public Dictionary ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class BandDefinition + { + public string Id { get; set; } = string.Empty; + public string Caption { get; set; } = string.Empty; + public List Columns { get; set; } = new(); + } + + private sealed class BandOption + { + public string Id { get; set; } = string.Empty; + public string Caption { get; set; } = string.Empty; + } + + private sealed class ColumnDefinition + { + public string FieldName { get; init; } = string.Empty; + public string Caption { get; init; } = string.Empty; + public string? Width { get; set; } + public string? DisplayFormat { get; init; } + public bool ReadOnly { get; init; } + public ColumnFilterType FilterType { get; init; } + } + + private enum ColumnFilterType + { + Text, + Bool, + Date + } + private sealed class MassDataEditModel { public int Id { get; set; } diff --git a/DbFirst.BlazorWasm/Models/LayoutDto.cs b/DbFirst.BlazorWasm/Models/LayoutDto.cs new file mode 100644 index 0000000..5088009 --- /dev/null +++ b/DbFirst.BlazorWasm/Models/LayoutDto.cs @@ -0,0 +1,9 @@ +namespace DbFirst.BlazorWasm.Models; + +public class LayoutDto +{ + public string LayoutType { get; set; } = string.Empty; + public string LayoutKey { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; + public string LayoutData { get; set; } = string.Empty; +} diff --git a/DbFirst.BlazorWasm/Program.cs b/DbFirst.BlazorWasm/Program.cs index bb2ee57..d93dc44 100644 --- a/DbFirst.BlazorWasm/Program.cs +++ b/DbFirst.BlazorWasm/Program.cs @@ -19,5 +19,6 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBaseU builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); await builder.Build().RunAsync(); diff --git a/DbFirst.BlazorWasm/Services/LayoutApiClient.cs b/DbFirst.BlazorWasm/Services/LayoutApiClient.cs new file mode 100644 index 0000000..aef8468 --- /dev/null +++ b/DbFirst.BlazorWasm/Services/LayoutApiClient.cs @@ -0,0 +1,64 @@ +using System.Net.Http.Json; +using DbFirst.BlazorWasm.Models; + +namespace DbFirst.BlazorWasm.Services; + +public class LayoutApiClient +{ + private readonly HttpClient _httpClient; + private const string Endpoint = "api/layouts"; + + public LayoutApiClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task GetAsync(string layoutType, string layoutKey, string userName) + { + var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}"; + var response = await _httpClient.GetAsync(url); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task UpsertAsync(LayoutDto dto) + { + var response = await _httpClient.PostAsJsonAsync(Endpoint, dto); + if (!response.IsSuccessStatusCode) + { + var detail = await ReadErrorAsync(response); + throw new InvalidOperationException(detail); + } + + var payload = await response.Content.ReadFromJsonAsync(); + return payload ?? dto; + } + + private static async Task ReadErrorAsync(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); + if (!string.IsNullOrWhiteSpace(body)) + { + return body; + } + + return $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim(); + } + + public async Task DeleteAsync(string layoutType, string layoutKey, string userName) + { + var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}"; + var response = await _httpClient.DeleteAsync(url); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return; + } + + response.EnsureSuccessStatusCode(); + } +} diff --git a/DbFirst.BlazorWebApp/Components/CatalogsGrid.razor b/DbFirst.BlazorWebApp/Components/CatalogsGrid.razor index 87c0fbe..0b108f2 100644 --- a/DbFirst.BlazorWebApp/Components/CatalogsGrid.razor +++ b/DbFirst.BlazorWebApp/Components/CatalogsGrid.razor @@ -1,5 +1,10 @@ +@using System.Text.Json +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Rendering @using Microsoft.AspNetCore.Components.Forms @inject CatalogApiClient Api +@inject LayoutApiClient LayoutApi +@inject IJSRuntime JsRuntime @if (!string.IsNullOrWhiteSpace(errorMessage)) @@ -28,11 +52,45 @@ else if (items.Count == 0) } else { +
+
+ + + + +
+ @foreach (var band in bandLayout.Bands) + { +
+ + +
+ } + + @foreach (var column in columnDefinitions) + { + + + + } + +
+
+ DataItemDeleting="OnDataItemDeleting" + @ref="gridRef"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @RenderColumns() @{ SetEditContext(editFormContext.EditContext); var editModel = (CatalogEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); } @@ -130,7 +140,27 @@ else private string? infoMessage; private EditContext? editContext; private ValidationMessageStore? validationMessageStore; + private IGrid? gridRef; private string popupHeaderText = "Edit"; + private const string LayoutType = "GRID_BANDS"; + private const string LayoutKey = "CatalogsGrid"; + private const string LayoutUserStorageKey = "layoutUser"; + private string? layoutUser; + private BandLayout bandLayout = new(); + private Dictionary columnBandAssignments = new(); + private List bandOptions = new(); + private Dictionary columnLookup = new(); + private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web); + private List columnDefinitions = new() + { + new() { FieldName = nameof(CatalogReadDto.Guid), Caption = "Id", Width = "140px", FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(CatalogReadDto.CatTitle), Caption = "Titel", FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(CatalogReadDto.CatString), Caption = "String", FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(CatalogReadDto.AddedWho), Caption = "Angelegt von", ReadOnly = true, FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(CatalogReadDto.AddedWhen), Caption = "Angelegt am", ReadOnly = true, FilterType = ColumnFilterType.Date }, + new() { FieldName = nameof(CatalogReadDto.ChangedWho), Caption = "Geändert von", ReadOnly = true, FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(CatalogReadDto.ChangedWhen), Caption = "Geändert am", ReadOnly = true, FilterType = ColumnFilterType.Date } + }; private readonly List procedureOptions = new() { @@ -138,8 +168,13 @@ else new() { Value = 1, Text = "PRTBMY_CATALOG_SAVE" } }; + private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser); + protected override async Task OnInitializedAsync() { + columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase); + await EnsureLayoutUserAsync(); + await LoadBandLayoutAsync(); await LoadCatalogs(); } @@ -349,6 +384,415 @@ else } } + private async Task EnsureLayoutUserAsync() + { + layoutUser = await JsRuntime.InvokeAsync("localStorage.getItem", LayoutUserStorageKey); + if (string.IsNullOrWhiteSpace(layoutUser)) + { + layoutUser = Guid.NewGuid().ToString("N"); + await JsRuntime.InvokeVoidAsync("localStorage.setItem", LayoutUserStorageKey, layoutUser); + } + } + + private async Task SaveBandLayoutAsync() + { + await SaveGridLayoutAsync(); + } + + private async Task SaveGridLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + return; + } + + try + { + CaptureColumnLayoutFromGrid(); + + var layoutData = JsonSerializer.Serialize(bandLayout, jsonOptions); + await LayoutApi.UpsertAsync(new LayoutDto + { + LayoutType = LayoutType, + LayoutKey = LayoutKey, + UserName = layoutUser, + LayoutData = layoutData + }); + infoMessage = "Grid-Layout gespeichert."; + errorMessage = null; + } + catch (Exception ex) + { + errorMessage = $"Grid-Layout konnte nicht gespeichert werden: {ex.Message}"; + } + } + + private async Task LoadBandLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + bandLayout = new BandLayout(); + UpdateBandOptions(); + return; + } + + var stored = await LayoutApi.GetAsync(LayoutType, LayoutKey, layoutUser); + if (stored != null && !string.IsNullOrWhiteSpace(stored.LayoutData)) + { + var parsed = JsonSerializer.Deserialize(stored.LayoutData, jsonOptions); + bandLayout = NormalizeBandLayout(parsed); + } + else + { + bandLayout = new BandLayout(); + } + + columnBandAssignments = BuildAssignmentsFromLayout(bandLayout); + ApplyColumnLayoutFromStorage(); + ApplyBandOrderingFromColumnOrder(); + UpdateBandOptions(); + } + + private void CaptureColumnLayoutFromGrid() + { + if (gridRef == null) + { + return; + } + + var gridColumns = gridRef.GetColumns() + .OfType() + .Where(column => !string.IsNullOrWhiteSpace(column.FieldName)) + .ToList(); + + bandLayout.ColumnOrder = gridColumns + .OrderBy(column => column.VisibleIndex) + .Select(column => column.FieldName) + .ToList(); + + bandLayout.ColumnWidths = gridColumns + .Where(column => !string.IsNullOrWhiteSpace(column.Width)) + .ToDictionary(column => column.FieldName, column => column.Width, StringComparer.OrdinalIgnoreCase); + + ApplyBandOrderingFromColumnOrder(); + } + + private void ApplyBandOrderingFromColumnOrder() + { + if (bandLayout.ColumnOrder.Count == 0) + { + return; + } + + var bandById = bandLayout.Bands.ToDictionary(band => band.Id, StringComparer.OrdinalIgnoreCase); + var orderedBandIds = new List(); + var orderedColumnsByBand = bandLayout.Bands.ToDictionary( + band => band.Id, + _ => new List(), + StringComparer.OrdinalIgnoreCase); + + foreach (var field in bandLayout.ColumnOrder) + { + if (columnBandAssignments.TryGetValue(field, out var bandId) && bandById.ContainsKey(bandId)) + { + if (!orderedBandIds.Contains(bandId, StringComparer.OrdinalIgnoreCase)) + { + orderedBandIds.Add(bandId); + } + + orderedColumnsByBand[bandId].Add(field); + } + } + + foreach (var band in bandLayout.Bands) + { + var orderedColumns = orderedColumnsByBand[band.Id]; + orderedColumns.AddRange(band.Columns.Where(column => !orderedColumns.Contains(column, StringComparer.OrdinalIgnoreCase))); + band.Columns = orderedColumns; + } + + if (orderedBandIds.Count > 0) + { + bandLayout.Bands = orderedBandIds + .Select(id => bandById[id]) + .Concat(bandLayout.Bands.Where(band => !orderedBandIds.Contains(band.Id, StringComparer.OrdinalIgnoreCase))) + .ToList(); + } + } + + private void AddBand() + { + bandLayout.Bands.Add(new BandDefinition + { + Id = Guid.NewGuid().ToString("N"), + Caption = "Band" + }); + UpdateBandOptions(); + } + + private void RemoveBand(BandDefinition band) + { + bandLayout.Bands.Remove(band); + var removedColumns = columnBandAssignments.Where(pair => pair.Value == band.Id) + .Select(pair => pair.Key) + .ToList(); + foreach (var column in removedColumns) + { + columnBandAssignments.Remove(column); + } + UpdateBandOptions(); + SyncBandsFromAssignments(); + } + + private void UpdateBandCaption(BandDefinition band, string value) + { + band.Caption = value; + UpdateBandOptions(); + } + + private void UpdateColumnBand(string fieldName, string? bandId) + { + if (string.IsNullOrWhiteSpace(bandId)) + { + columnBandAssignments.Remove(fieldName); + } + else + { + columnBandAssignments[fieldName] = bandId; + } + + SyncBandsFromAssignments(); + } + + private string GetColumnBand(string fieldName) + { + return columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty; + } + + private void SyncBandsFromAssignments() + { + foreach (var band in bandLayout.Bands) + { + band.Columns = columnDefinitions + .Where(column => columnBandAssignments.TryGetValue(column.FieldName, out var bandId) && bandId == band.Id) + .Select(column => column.FieldName) + .ToList(); + } + + StateHasChanged(); + } + + private void UpdateBandOptions() + { + bandOptions = new List { new() { Id = string.Empty, Caption = "Ohne Band" } }; + bandOptions.AddRange(bandLayout.Bands.Select(band => new BandOption { Id = band.Id, Caption = band.Caption })); + } + + private BandLayout NormalizeBandLayout(BandLayout? layout) + { + layout ??= new BandLayout(); + layout.Bands ??= new List(); + foreach (var band in layout.Bands) + { + if (string.IsNullOrWhiteSpace(band.Id)) + { + band.Id = Guid.NewGuid().ToString("N"); + } + + if (string.IsNullOrWhiteSpace(band.Caption)) + { + band.Caption = "Band"; + } + + band.Columns = band.Columns?.Where(columnLookup.ContainsKey).ToList() ?? new List(); + } + + return layout; + } + + private Dictionary BuildAssignmentsFromLayout(BandLayout layout) + { + var assignments = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var band in layout.Bands) + { + foreach (var column in band.Columns) + { + assignments[column] = band.Id; + } + } + + return assignments; + } + + private RenderFragment RenderColumns() => builder => + { + var seq = 0; + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Width", "120px"); + builder.CloseComponent(); + + var bandLookup = bandLayout.Bands.ToDictionary(band => band.Id, StringComparer.OrdinalIgnoreCase); + var renderedBands = new HashSet(StringComparer.OrdinalIgnoreCase); + var orderedFields = bandLayout.ColumnOrder + .Where(columnLookup.ContainsKey) + .ToList(); + + if (orderedFields.Count == 0) + { + var grouped = bandLayout.Bands.SelectMany(band => band.Columns).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var column in columnDefinitions.Where(column => !grouped.Contains(column.FieldName))) + { + BuildDataColumn(builder, ref seq, column); + } + + foreach (var band in bandLayout.Bands) + { + if (band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + + return; + } + + foreach (var fieldName in orderedFields) + { + if (columnBandAssignments.TryGetValue(fieldName, out var bandId) && bandLookup.TryGetValue(bandId, out var band)) + { + if (!renderedBands.Add(bandId) || band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + else if (columnLookup.TryGetValue(fieldName, out var column)) + { + BuildDataColumn(builder, ref seq, column); + } + } + + foreach (var column in columnDefinitions) + { + if (!orderedFields.Contains(column.FieldName, StringComparer.OrdinalIgnoreCase) && + (!columnBandAssignments.TryGetValue(column.FieldName, out var bandId) || !bandLookup.ContainsKey(bandId))) + { + BuildDataColumn(builder, ref seq, column); + } + } + + foreach (var band in bandLayout.Bands) + { + if (renderedBands.Contains(band.Id) || band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + }; + + private void BuildDataColumn(RenderTreeBuilder builder, ref int seq, ColumnDefinition column) + { + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "FieldName", column.FieldName); + builder.AddAttribute(seq++, "Caption", column.Caption); + if (!string.IsNullOrWhiteSpace(column.Width)) + { + builder.AddAttribute(seq++, "Width", column.Width); + } + + if (!string.IsNullOrWhiteSpace(column.DisplayFormat)) + { + builder.AddAttribute(seq++, "DisplayFormat", column.DisplayFormat); + } + + if (column.ReadOnly) + { + builder.AddAttribute(seq++, "ReadOnly", true); + } + + builder.CloseComponent(); + } + + private sealed class BandLayout + { + public List Bands { get; set; } = new(); + public List ColumnOrder { get; set; } = new(); + public Dictionary ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class BandDefinition + { + public string Id { get; set; } = string.Empty; + public string Caption { get; set; } = string.Empty; + public List Columns { get; set; } = new(); + } + + private sealed class BandOption + { + public string Id { get; set; } = string.Empty; + public string Caption { get; set; } = string.Empty; + } + + private sealed class ColumnDefinition + { + public string FieldName { get; init; } = string.Empty; + public string Caption { get; init; } = string.Empty; + public string? Width { get; set; } + public string? DisplayFormat { get; init; } + public bool ReadOnly { get; init; } + public ColumnFilterType FilterType { get; init; } + } + + private enum ColumnFilterType + { + Text, + Date + } + private sealed class CatalogEditModel { public int Guid { get; set; } @@ -364,4 +808,42 @@ else public int Value { get; set; } public string Text { get; set; } = string.Empty; } + + private async Task ResetBandLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + return; + } + + await LayoutApi.DeleteAsync(LayoutType, LayoutKey, layoutUser); + bandLayout = new BandLayout(); + columnBandAssignments.Clear(); + UpdateBandOptions(); + infoMessage = "Band-Layout zurückgesetzt."; + } + + private void ApplyColumnLayoutFromStorage() + { + if (bandLayout.ColumnOrder.Count > 0) + { + var ordered = bandLayout.ColumnOrder + .Where(columnLookup.ContainsKey) + .Select(field => columnLookup[field]) + .ToList(); + + ordered.AddRange(columnDefinitions.Where(column => !ordered.Contains(column))); + columnDefinitions = ordered; + } + + foreach (var column in columnDefinitions) + { + if (bandLayout.ColumnWidths.TryGetValue(column.FieldName, out var width) && !string.IsNullOrWhiteSpace(width)) + { + column.Width = width; + } + } + + columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase); + } } diff --git a/DbFirst.BlazorWebApp/Components/MassDataGrid.razor b/DbFirst.BlazorWebApp/Components/MassDataGrid.razor index aaf3d0e..3cefaf7 100644 --- a/DbFirst.BlazorWebApp/Components/MassDataGrid.razor +++ b/DbFirst.BlazorWebApp/Components/MassDataGrid.razor @@ -1,5 +1,10 @@ +@using System.Text.Json +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Rendering @using Microsoft.AspNetCore.Components.Forms @inject MassDataApiClient Api +@inject LayoutApiClient LayoutApi +@inject IJSRuntime JsRuntime @if (!string.IsNullOrWhiteSpace(errorMessage)) @@ -104,13 +128,45 @@ else CssClass="page-size-combo" />
+
+
+ + + + +
+ @foreach (var band in bandLayout.Bands) + { +
+ + +
+ } + + @foreach (var column in columnDefinitions) + { + + + + } + +
+
+ DataItemDeleting="OnDataItemDeleting" + @ref="gridRef"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @RenderColumns() @{ SetEditContext(editFormContext.EditContext); var editModel = (MassDataEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); } @@ -230,18 +233,28 @@ else private string popupHeaderText = "Edit"; private EditContext? editContext; private ValidationMessageStore? validationMessageStore; - - private readonly List statusFilterOptions = new() + private IGrid? gridRef; + private const string LayoutType = "GRID_BANDS"; + private const string LayoutKey = "MassDataGrid"; + private const string LayoutUserStorageKey = "layoutUser"; + private string? layoutUser; + private BandLayout bandLayout = new(); + private Dictionary columnBandAssignments = new(); + private List bandOptions = new(); + private Dictionary columnLookup = new(); + private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web); + private List columnDefinitions = new() { - new() { Value = null, Text = "Alle" }, - new() { Value = true, Text = "True" }, - new() { Value = false, Text = "False" } + new() { FieldName = nameof(MassDataReadDto.Id), Caption = "Id", Width = "90px", ReadOnly = true, FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(MassDataReadDto.CustomerName), Caption = "CustomerName", FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(MassDataReadDto.Amount), Caption = "Amount", DisplayFormat = "c2", FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(MassDataReadDto.Category), Caption = "Category", ReadOnly = true, FilterType = ColumnFilterType.Text }, + new() { FieldName = nameof(MassDataReadDto.StatusFlag), Caption = "Status", ReadOnly = true, FilterType = ColumnFilterType.Bool }, + new() { FieldName = nameof(MassDataReadDto.AddedWhen), Caption = "Added", ReadOnly = true, FilterType = ColumnFilterType.Date }, + new() { FieldName = nameof(MassDataReadDto.ChangedWhen), Caption = "Changed", ReadOnly = true, FilterType = ColumnFilterType.Date } }; - private readonly List procedureOptions = new() - { - new() { Value = 0, Text = "PRMassdata_UpsertByCustomerName" } - }; + private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser); private readonly List pageSizeOptions = new() { @@ -252,8 +265,23 @@ else new() { Value = null, Text = "Alle" } }; + private readonly List statusFilterOptions = new() + { + new() { Value = null, Text = "Alle" }, + new() { Value = true, Text = "True" }, + new() { Value = false, Text = "False" } + }; + + private readonly List procedureOptions = new() + { + new() { Value = 0, Text = "PRMassdata_UpsertByCustomerName" } + }; + protected override async Task OnInitializedAsync() { + columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase); + await EnsureLayoutUserAsync(); + await LoadBandLayoutAsync(); await LoadPage(0); } @@ -293,6 +321,417 @@ else await LoadPage(0); } + private async Task EnsureLayoutUserAsync() + { + layoutUser = await JsRuntime.InvokeAsync("localStorage.getItem", LayoutUserStorageKey); + if (string.IsNullOrWhiteSpace(layoutUser)) + { + layoutUser = Guid.NewGuid().ToString("N"); + await JsRuntime.InvokeVoidAsync("localStorage.setItem", LayoutUserStorageKey, layoutUser); + } + } + + private async Task LoadBandLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + bandLayout = new BandLayout(); + UpdateBandOptions(); + return; + } + + var stored = await LayoutApi.GetAsync(LayoutType, LayoutKey, layoutUser); + if (stored != null && !string.IsNullOrWhiteSpace(stored.LayoutData)) + { + var parsed = JsonSerializer.Deserialize(stored.LayoutData, jsonOptions); + bandLayout = NormalizeBandLayout(parsed); + } + else + { + bandLayout = new BandLayout(); + } + + columnBandAssignments = BuildAssignmentsFromLayout(bandLayout); + ApplyColumnLayoutFromStorage(); + ApplyBandOrderingFromColumnOrder(); + UpdateBandOptions(); + } + + private async Task SaveBandLayoutAsync() + { + await SaveGridLayoutAsync(); + } + + private async Task SaveGridLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + return; + } + + try + { + CaptureColumnLayoutFromGrid(); + + var layoutData = JsonSerializer.Serialize(bandLayout, jsonOptions); + await LayoutApi.UpsertAsync(new LayoutDto + { + LayoutType = LayoutType, + LayoutKey = LayoutKey, + UserName = layoutUser, + LayoutData = layoutData + }); + infoMessage = "Grid-Layout gespeichert."; + errorMessage = null; + } + catch (Exception ex) + { + errorMessage = $"Grid-Layout konnte nicht gespeichert werden: {ex.Message}"; + } + } + + private async Task ResetBandLayoutAsync() + { + if (string.IsNullOrWhiteSpace(layoutUser)) + { + return; + } + + await LayoutApi.DeleteAsync(LayoutType, LayoutKey, layoutUser); + bandLayout = new BandLayout(); + columnBandAssignments.Clear(); + UpdateBandOptions(); + infoMessage = "Band-Layout zurückgesetzt."; + } + + private void CaptureColumnLayoutFromGrid() + { + if (gridRef == null) + { + return; + } + + var gridColumns = gridRef.GetColumns() + .OfType() + .Where(column => !string.IsNullOrWhiteSpace(column.FieldName)) + .ToList(); + + bandLayout.ColumnOrder = gridColumns + .OrderBy(column => column.VisibleIndex) + .Select(column => column.FieldName) + .ToList(); + + bandLayout.ColumnWidths = gridColumns + .Where(column => !string.IsNullOrWhiteSpace(column.Width)) + .ToDictionary(column => column.FieldName, column => column.Width, StringComparer.OrdinalIgnoreCase); + + ApplyBandOrderingFromColumnOrder(); + } + + private void ApplyBandOrderingFromColumnOrder() + { + if (bandLayout.ColumnOrder.Count == 0) + { + return; + } + + var bandById = bandLayout.Bands.ToDictionary(band => band.Id, StringComparer.OrdinalIgnoreCase); + var orderedBandIds = new List(); + var orderedColumnsByBand = bandLayout.Bands.ToDictionary( + band => band.Id, + _ => new List(), + StringComparer.OrdinalIgnoreCase); + + foreach (var field in bandLayout.ColumnOrder) + { + if (columnBandAssignments.TryGetValue(field, out var bandId) && bandById.ContainsKey(bandId)) + { + if (!orderedBandIds.Contains(bandId, StringComparer.OrdinalIgnoreCase)) + { + orderedBandIds.Add(bandId); + } + + orderedColumnsByBand[bandId].Add(field); + } + } + + foreach (var band in bandLayout.Bands) + { + var orderedColumns = orderedColumnsByBand[band.Id]; + orderedColumns.AddRange(band.Columns.Where(column => !orderedColumns.Contains(column, StringComparer.OrdinalIgnoreCase))); + band.Columns = orderedColumns; + } + + if (orderedBandIds.Count > 0) + { + bandLayout.Bands = orderedBandIds + .Select(id => bandById[id]) + .Concat(bandLayout.Bands.Where(band => !orderedBandIds.Contains(band.Id, StringComparer.OrdinalIgnoreCase))) + .ToList(); + } + } + + private void ApplyColumnLayoutFromStorage() + { + if (bandLayout.ColumnOrder.Count > 0) + { + var ordered = bandLayout.ColumnOrder + .Where(columnLookup.ContainsKey) + .Select(field => columnLookup[field]) + .ToList(); + + ordered.AddRange(columnDefinitions.Where(column => !ordered.Contains(column))); + columnDefinitions = ordered; + } + + foreach (var column in columnDefinitions) + { + if (bandLayout.ColumnWidths.TryGetValue(column.FieldName, out var width) && !string.IsNullOrWhiteSpace(width)) + { + column.Width = width; + } + } + + columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase); + } + + private void AddBand() + { + bandLayout.Bands.Add(new BandDefinition + { + Id = Guid.NewGuid().ToString("N"), + Caption = "Band" + }); + UpdateBandOptions(); + } + + private void RemoveBand(BandDefinition band) + { + bandLayout.Bands.Remove(band); + var removedColumns = columnBandAssignments.Where(pair => pair.Value == band.Id) + .Select(pair => pair.Key) + .ToList(); + foreach (var column in removedColumns) + { + columnBandAssignments.Remove(column); + } + UpdateBandOptions(); + SyncBandsFromAssignments(); + } + + private void UpdateBandCaption(BandDefinition band, string value) + { + band.Caption = value; + UpdateBandOptions(); + } + + private void UpdateColumnBand(string fieldName, string? bandId) + { + if (string.IsNullOrWhiteSpace(bandId)) + { + columnBandAssignments.Remove(fieldName); + } + else + { + columnBandAssignments[fieldName] = bandId; + } + + SyncBandsFromAssignments(); + } + + private string GetColumnBand(string fieldName) + { + return columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty; + } + + private void SyncBandsFromAssignments() + { + foreach (var band in bandLayout.Bands) + { + band.Columns = columnDefinitions + .Where(column => columnBandAssignments.TryGetValue(column.FieldName, out var bandId) && bandId == band.Id) + .Select(column => column.FieldName) + .ToList(); + } + + StateHasChanged(); + } + + private void UpdateBandOptions() + { + bandOptions = new List { new() { Id = string.Empty, Caption = "Ohne Band" } }; + bandOptions.AddRange(bandLayout.Bands.Select(band => new BandOption { Id = band.Id, Caption = band.Caption })); + } + + private Dictionary BuildAssignmentsFromLayout(BandLayout layout) + { + var assignments = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var band in layout.Bands) + { + foreach (var column in band.Columns) + { + assignments[column] = band.Id; + } + } + + return assignments; + } + + private BandLayout NormalizeBandLayout(BandLayout? layout) + { + layout ??= new BandLayout(); + layout.Bands ??= new List(); + foreach (var band in layout.Bands) + { + if (string.IsNullOrWhiteSpace(band.Id)) + { + band.Id = Guid.NewGuid().ToString("N"); + } + + if (string.IsNullOrWhiteSpace(band.Caption)) + { + band.Caption = "Band"; + } + + band.Columns = band.Columns?.Where(columnLookup.ContainsKey).ToList() ?? new List(); + } + + return layout; + } + + private RenderFragment RenderColumns() => builder => + { + var seq = 0; + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Width", "120px"); + builder.CloseComponent(); + + var bandLookup = bandLayout.Bands.ToDictionary(band => band.Id, StringComparer.OrdinalIgnoreCase); + var renderedBands = new HashSet(StringComparer.OrdinalIgnoreCase); + var orderedFields = bandLayout.ColumnOrder + .Where(columnLookup.ContainsKey) + .ToList(); + + if (orderedFields.Count == 0) + { + var grouped = bandLayout.Bands.SelectMany(band => band.Columns).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var column in columnDefinitions.Where(column => !grouped.Contains(column.FieldName))) + { + BuildDataColumn(builder, ref seq, column); + } + + foreach (var band in bandLayout.Bands) + { + if (band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + + return; + } + + foreach (var fieldName in orderedFields) + { + if (columnBandAssignments.TryGetValue(fieldName, out var bandId) && bandLookup.TryGetValue(bandId, out var band)) + { + if (!renderedBands.Add(bandId) || band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + else if (columnLookup.TryGetValue(fieldName, out var column)) + { + BuildDataColumn(builder, ref seq, column); + } + } + + foreach (var column in columnDefinitions) + { + if (!orderedFields.Contains(column.FieldName, StringComparer.OrdinalIgnoreCase) && + (!columnBandAssignments.TryGetValue(column.FieldName, out var bandId) || !bandLookup.ContainsKey(bandId))) + { + BuildDataColumn(builder, ref seq, column); + } + } + + foreach (var band in bandLayout.Bands) + { + if (renderedBands.Contains(band.Id) || band.Columns.Count == 0) + { + continue; + } + + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "Caption", band.Caption); + builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder => + { + var bandSeq = 0; + foreach (var columnName in band.Columns) + { + if (columnLookup.TryGetValue(columnName, out var column)) + { + BuildDataColumn(bandBuilder, ref bandSeq, column); + } + } + })); + builder.CloseComponent(); + } + }; + + private void BuildDataColumn(RenderTreeBuilder builder, ref int seq, ColumnDefinition column) + { + builder.OpenComponent(seq++); + builder.AddAttribute(seq++, "FieldName", column.FieldName); + builder.AddAttribute(seq++, "Caption", column.Caption); + if (!string.IsNullOrWhiteSpace(column.Width)) + { + builder.AddAttribute(seq++, "Width", column.Width); + } + + if (!string.IsNullOrWhiteSpace(column.DisplayFormat)) + { + builder.AddAttribute(seq++, "DisplayFormat", column.DisplayFormat); + } + + if (column.ReadOnly) + { + builder.AddAttribute(seq++, "ReadOnly", true); + } + + builder.CloseComponent(); + } + private void SetEditContext(EditContext context) { if (editContext == context) @@ -337,7 +776,7 @@ else popupHeaderText = isNew ? "Neu" : "Edit"; } - private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e) + private async Task OnCustomizeEditModel(GridCustomizeEditModelEventArgs e) { if (e.IsNew) { @@ -429,6 +868,43 @@ else return Task.CompletedTask; } + private sealed class BandLayout + { + public List Bands { get; set; } = new(); + public List ColumnOrder { get; set; } = new(); + public Dictionary ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class BandDefinition + { + public string Id { get; set; } = string.Empty; + public string Caption { get; set; } = string.Empty; + public List Columns { get; set; } = new(); + } + + private sealed class BandOption + { + public string Id { get; set; } = string.Empty; + public string Caption { get; set; } = string.Empty; + } + + private sealed class ColumnDefinition + { + public string FieldName { get; init; } = string.Empty; + public string Caption { get; init; } = string.Empty; + public string? Width { get; set; } + public string? DisplayFormat { get; init; } + public bool ReadOnly { get; init; } + public ColumnFilterType FilterType { get; init; } + } + + private enum ColumnFilterType + { + Text, + Bool, + Date + } + private sealed class MassDataEditModel { public int Id { get; set; } diff --git a/DbFirst.BlazorWebApp/Models/LayoutDto.cs b/DbFirst.BlazorWebApp/Models/LayoutDto.cs new file mode 100644 index 0000000..3cacdb8 --- /dev/null +++ b/DbFirst.BlazorWebApp/Models/LayoutDto.cs @@ -0,0 +1,9 @@ +namespace DbFirst.BlazorWebApp.Models; + +public class LayoutDto +{ + public string LayoutType { get; set; } = string.Empty; + public string LayoutKey { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; + public string LayoutData { get; set; } = string.Empty; +} diff --git a/DbFirst.BlazorWebApp/Program.cs b/DbFirst.BlazorWebApp/Program.cs index c838164..f7d20c9 100644 --- a/DbFirst.BlazorWebApp/Program.cs +++ b/DbFirst.BlazorWebApp/Program.cs @@ -25,12 +25,17 @@ if (!string.IsNullOrWhiteSpace(apiBaseUrl)) { client.BaseAddress = new Uri(apiBaseUrl); }); + builder.Services.AddHttpClient(client => + { + client.BaseAddress = new Uri(apiBaseUrl); + }); } else { builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); + builder.Services.AddHttpClient(); } var app = builder.Build(); diff --git a/DbFirst.BlazorWebApp/Services/LayoutApiClient.cs b/DbFirst.BlazorWebApp/Services/LayoutApiClient.cs new file mode 100644 index 0000000..ad77fe0 --- /dev/null +++ b/DbFirst.BlazorWebApp/Services/LayoutApiClient.cs @@ -0,0 +1,64 @@ +using System.Net.Http.Json; +using DbFirst.BlazorWebApp.Models; + +namespace DbFirst.BlazorWebApp.Services; + +public class LayoutApiClient +{ + private readonly HttpClient _httpClient; + private const string Endpoint = "api/layouts"; + + public LayoutApiClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task GetAsync(string layoutType, string layoutKey, string userName) + { + var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}"; + var response = await _httpClient.GetAsync(url); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task UpsertAsync(LayoutDto dto) + { + var response = await _httpClient.PostAsJsonAsync(Endpoint, dto); + if (!response.IsSuccessStatusCode) + { + var detail = await ReadErrorAsync(response); + throw new InvalidOperationException(detail); + } + + var payload = await response.Content.ReadFromJsonAsync(); + return payload ?? dto; + } + + private static async Task ReadErrorAsync(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); + if (!string.IsNullOrWhiteSpace(body)) + { + return body; + } + + return $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim(); + } + + public async Task DeleteAsync(string layoutType, string layoutKey, string userName) + { + var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}"; + var response = await _httpClient.DeleteAsync(url); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return; + } + + response.EnsureSuccessStatusCode(); + } +} diff --git a/DbFirst.Domain/Entities/SmfLayout.cs b/DbFirst.Domain/Entities/SmfLayout.cs new file mode 100644 index 0000000..312b99a --- /dev/null +++ b/DbFirst.Domain/Entities/SmfLayout.cs @@ -0,0 +1,15 @@ +namespace DbFirst.Domain.Entities; + +public class SmfLayout +{ + public long Guid { get; set; } + public bool Active { get; set; } + public string LayoutType { get; set; } = string.Empty; + public string LayoutKey { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; + public byte[] LayoutData { get; set; } = Array.Empty(); + public string AddedWho { get; set; } = string.Empty; + public DateTime AddedWhen { get; set; } + public string? ChangedWho { get; set; } + public DateTime? ChangedWhen { get; set; } +} diff --git a/DbFirst.Infrastructure/ApplicationDbContext.cs b/DbFirst.Infrastructure/ApplicationDbContext.cs index dc3cc30..24608c5 100644 --- a/DbFirst.Infrastructure/ApplicationDbContext.cs +++ b/DbFirst.Infrastructure/ApplicationDbContext.cs @@ -15,6 +15,7 @@ public partial class ApplicationDbContext : DbContext } public virtual DbSet VwmyCatalogs { get; set; } + public virtual DbSet SmfLayouts { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -51,6 +52,37 @@ public partial class ApplicationDbContext : DbContext .HasColumnName(catCfg.ChangedWhoColumnName); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Guid); + entity.ToTable("TBDD_SMF_LAYOUT", tb => tb.HasTrigger("TBDD_SMF_LAYOUT_AFT_UPD")); + + entity.Property(e => e.Guid).HasColumnName("GUID"); + entity.Property(e => e.Active).HasColumnName("ACTIVE"); + entity.Property(e => e.LayoutType) + .HasMaxLength(50) + .HasColumnName("LAYOUT_TYPE"); + entity.Property(e => e.LayoutKey) + .HasMaxLength(150) + .HasColumnName("LAYOUT_KEY"); + entity.Property(e => e.UserName) + .HasMaxLength(50) + .HasColumnName("USER_NAME"); + entity.Property(e => e.LayoutData).HasColumnName("LAYOUT_DATA"); + entity.Property(e => e.AddedWho) + .HasMaxLength(50) + .HasColumnName("ADDED_WHO"); + entity.Property(e => e.AddedWhen) + .HasColumnType("datetime") + .HasColumnName("ADDED_WHEN"); + entity.Property(e => e.ChangedWho) + .HasMaxLength(50) + .HasColumnName("CHANGED_WHO"); + entity.Property(e => e.ChangedWhen) + .HasColumnType("datetime") + .HasColumnName("CHANGED_WHEN"); + }); + OnModelCreatingPartial(modelBuilder); } diff --git a/DbFirst.Infrastructure/Repositories/LayoutRepository.cs b/DbFirst.Infrastructure/Repositories/LayoutRepository.cs new file mode 100644 index 0000000..e957e23 --- /dev/null +++ b/DbFirst.Infrastructure/Repositories/LayoutRepository.cs @@ -0,0 +1,66 @@ +using DbFirst.Application.Repositories; +using DbFirst.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace DbFirst.Infrastructure.Repositories; + +public class LayoutRepository : ILayoutRepository +{ + private readonly ApplicationDbContext _db; + + public LayoutRepository(ApplicationDbContext db) + { + _db = db; + } + + public async Task GetAsync(string layoutType, string layoutKey, string userName, CancellationToken cancellationToken = default) + { + return await _db.SmfLayouts.AsNoTracking() + .FirstOrDefaultAsync(x => x.LayoutType == layoutType && x.LayoutKey == layoutKey && x.UserName == userName, cancellationToken); + } + + public async Task UpsertAsync(string layoutType, string layoutKey, string userName, byte[] layoutData, CancellationToken cancellationToken = default) + { + var entity = await _db.SmfLayouts + .FirstOrDefaultAsync(x => x.LayoutType == layoutType && x.LayoutKey == layoutKey && x.UserName == userName, cancellationToken); + + if (entity == null) + { + entity = new SmfLayout + { + Active = true, + LayoutType = layoutType, + LayoutKey = layoutKey, + UserName = userName, + LayoutData = layoutData, + AddedWho = userName, + AddedWhen = DateTime.Now + }; + _db.SmfLayouts.Add(entity); + } + else + { + entity.Active = true; + entity.LayoutData = layoutData; + entity.ChangedWho = userName; + } + + await _db.SaveChangesAsync(cancellationToken); + return entity; + } + + public async Task DeleteAsync(string layoutType, string layoutKey, string userName, CancellationToken cancellationToken = default) + { + var entity = await _db.SmfLayouts + .FirstOrDefaultAsync(x => x.LayoutType == layoutType && x.LayoutKey == layoutKey && x.UserName == userName, cancellationToken); + + if (entity == null) + { + return false; + } + + _db.SmfLayouts.Remove(entity); + await _db.SaveChangesAsync(cancellationToken); + return true; + } +}