Compare commits

..

3 Commits

Author SHA1 Message Date
OlgunR
2a730ddfcc Refactor grid and band layout persistence logic
Unify band and grid layout saving into a single "Layout speichern" action and method. Store the full grid layout in BandLayout, and simplify column/band rendering logic. Apply saved layouts after render for consistency. Remove obsolete ordering logic and update messages for clarity. These changes improve robustness and maintainability of layout persistence in CatalogsGrid and MassDataGrid.
2026-02-09 11:36:56 +01:00
OlgunR
d78fd5e3d1 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.
2026-02-06 14:07:52 +01:00
OlgunR
52b2cf9a5b Add user-selectable page size to MassData grid
Users can now choose how many records to display per page in the MassData grid (100, 1,000, 10,000, 100,000, or all). The backend and API client are updated to support nullable skip/take parameters, allowing "all" records to be fetched when desired. The pager and page count calculations are updated to reflect the selected page size, and the pager is hidden if only one page exists. Additional UI and CSS changes provide a combo box for page size selection. The API controller now treats take <= 0 as "no limit."
2026-02-05 15:45:36 +01:00
19 changed files with 2292 additions and 243 deletions

View File

@@ -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<ActionResult<LayoutDto>> 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<ActionResult<LayoutDto>> 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<byte>()
: 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<IActionResult> 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;
}
}

View File

@@ -27,7 +27,12 @@ public class MassDataController : ControllerBase
[HttpGet] [HttpGet]
public async Task<ActionResult<IEnumerable<MassDataReadDto>>> GetAll([FromQuery] int? skip, [FromQuery] int? take, CancellationToken cancellationToken) public async Task<ActionResult<IEnumerable<MassDataReadDto>>> GetAll([FromQuery] int? skip, [FromQuery] int? take, CancellationToken cancellationToken)
{ {
var resolvedTake = take is null or <= 0 ? 200 : take; int? resolvedTake = take;
if (resolvedTake is <= 0)
{
resolvedTake = null;
}
var result = await _mediator.Send(new GetAllMassDataQuery(skip, resolvedTake), cancellationToken); var result = await _mediator.Send(new GetAllMassDataQuery(skip, resolvedTake), cancellationToken);
return Ok(result); return Ok(result);
} }

View File

@@ -53,6 +53,7 @@ builder.Services.AddApplication();
builder.Services.AddScoped<ICatalogRepository, CatalogRepository>(); builder.Services.AddScoped<ICatalogRepository, CatalogRepository>();
builder.Services.AddScoped<IMassDataRepository, MassDataRepository>(); builder.Services.AddScoped<IMassDataRepository, MassDataRepository>();
builder.Services.AddScoped<ILayoutRepository, LayoutRepository>();
builder.Services.AddDevExpressControls(); builder.Services.AddDevExpressControls();
builder.Services.AddSignalR(); builder.Services.AddSignalR();

View File

@@ -0,0 +1,10 @@
using DbFirst.Domain.Entities;
namespace DbFirst.Application.Repositories;
public interface ILayoutRepository
{
Task<SmfLayout?> GetAsync(string layoutType, string layoutKey, string userName, CancellationToken cancellationToken = default);
Task<SmfLayout> UpsertAsync(string layoutType, string layoutKey, string userName, byte[] layoutData, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string layoutType, string layoutKey, string userName, CancellationToken cancellationToken = default);
}

View File

@@ -1,5 +1,11 @@
@using System.Text.Json
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Rendering
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using DevExpress.Blazor
@inject CatalogApiClient Api @inject CatalogApiClient Api
@inject LayoutApiClient LayoutApi
@inject IJSRuntime JsRuntime
<style> <style>
.action-panel { margin-bottom: 16px; } .action-panel { margin-bottom: 16px; }
@@ -48,6 +54,25 @@
.catalog-edit-popup { .catalog-edit-popup {
min-width: 720px; min-width: 720px;
} }
.band-editor {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
.band-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.band-row {
display: flex;
gap: 8px;
align-items: center;
}
.band-columns {
max-width: 720px;
}
</style> </style>
@if (!string.IsNullOrWhiteSpace(errorMessage)) @if (!string.IsNullOrWhiteSpace(errorMessage))
@@ -69,11 +94,44 @@ else if (items.Count == 0)
} }
else else
{ {
<div class="band-editor">
<div class="band-controls">
<DxButton Text="Band hinzufügen" Click="AddBand" />
<DxButton Text="Layout speichern" Click="SaveLayoutAsync" Enabled="@CanSaveBandLayout" />
<DxButton Text="Band-Layout zurücksetzen" Click="ResetBandLayoutAsync" />
</div>
@foreach (var band in bandLayout.Bands)
{
<div class="band-row">
<DxTextBox Text="@band.Caption" TextChanged="@(value => UpdateBandCaption(band, value))" />
<DxButton Text="Entfernen" Click="@(() => RemoveBand(band))" />
</div>
}
<DxFormLayout CssClass="band-columns" ColCount="2">
@foreach (var column in columnDefinitions)
{
<DxFormLayoutItem Caption="@column.Caption">
<DxComboBox Data="@bandOptions"
TData="BandOption"
TValue="string"
TextFieldName="Caption"
ValueFieldName="Id"
Value="@GetColumnBand(column.FieldName)"
ValueChanged="@(value => UpdateColumnBand(column.FieldName, value))"
Width="100%" />
</DxFormLayoutItem>
}
</DxFormLayout>
</div>
<div class="grid-section"> <div class="grid-section">
<DxGrid Data="@items" <DxGrid Data="@items"
TItem="CatalogReadDto" TItem="CatalogReadDto"
KeyFieldName="@nameof(CatalogReadDto.Guid)" KeyFieldName="@nameof(CatalogReadDto.Guid)"
ShowFilterRow="true" ShowFilterRow="true"
AllowColumnResize="true"
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
AllowColumnReorder="true"
PageSize="10" PageSize="10"
CssClass="mb-4 catalog-grid" CssClass="mb-4 catalog-grid"
EditMode="GridEditMode.PopupEditForm" EditMode="GridEditMode.PopupEditForm"
@@ -81,58 +139,10 @@ else
PopupEditFormHeaderText="@popupHeaderText" PopupEditFormHeaderText="@popupHeaderText"
CustomizeEditModel="OnCustomizeEditModel" CustomizeEditModel="OnCustomizeEditModel"
EditModelSaving="OnEditModelSaving" EditModelSaving="OnEditModelSaving"
DataItemDeleting="OnDataItemDeleting"> DataItemDeleting="OnDataItemDeleting"
@ref="gridRef">
<Columns> <Columns>
<DxGridCommandColumn Width="120px" /> @RenderColumns()
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.Guid)" Caption="Id" Width="140px" SortIndex="0" SortOrder="GridColumnSortOrder.Ascending">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue?.ToString())"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.CatTitle)" Caption="Titel">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.CatString)" Caption="String">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.AddedWho)" Caption="Angelegt von" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.AddedWhen)" Caption="Angelegt am" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxDateEdit Date="@((DateTime?)filter.FilterRowValue)"
DateChanged="@((DateTime? value) => { filter.FilterRowValue = value; })"
Width="100%" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.ChangedWho)" Caption="Geändert von" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.ChangedWhen)" Caption="Geändert am" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxDateEdit Date="@((DateTime?)filter.FilterRowValue)"
DateChanged="@((DateTime? value) => { filter.FilterRowValue = value; })"
Width="100%" />
</FilterRowCellTemplate>
</DxGridDataColumn>
</Columns> </Columns>
<EditFormTemplate Context="editFormContext"> <EditFormTemplate Context="editFormContext">
@{ SetEditContext(editFormContext.EditContext); var editModel = (CatalogEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); } @{ SetEditContext(editFormContext.EditContext); var editModel = (CatalogEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); }
@@ -171,7 +181,27 @@ else
private string? infoMessage; private string? infoMessage;
private EditContext? editContext; private EditContext? editContext;
private ValidationMessageStore? validationMessageStore; private ValidationMessageStore? validationMessageStore;
private IGrid? gridRef;
private string popupHeaderText = "Edit"; 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<string, string> columnBandAssignments = new();
private List<BandOption> bandOptions = new();
private Dictionary<string, ColumnDefinition> columnLookup = new();
private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
private List<ColumnDefinition> 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<ProcedureOption> procedureOptions = new() private readonly List<ProcedureOption> procedureOptions = new()
{ {
@@ -179,11 +209,28 @@ else
new() { Value = 1, Text = "PRTBMY_CATALOG_SAVE" } new() { Value = 1, Text = "PRTBMY_CATALOG_SAVE" }
}; };
private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser);
private bool gridLayoutApplied;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase);
await EnsureLayoutUserAsync();
await LoadBandLayoutAsync();
await LoadCatalogs(); await LoadCatalogs();
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!gridLayoutApplied && gridRef != null && bandLayout.GridLayout != null)
{
gridRef.LoadLayout(bandLayout.GridLayout);
gridLayoutApplied = true;
await InvokeAsync(StateHasChanged);
}
}
private void SetEditContext(EditContext context) private void SetEditContext(EditContext context)
{ {
if (editContext == context) if (editContext == context)
@@ -223,6 +270,11 @@ else
} }
} }
private void SetPopupHeaderText(bool isNew)
{
popupHeaderText = isNew ? "Neu" : "Edit";
}
private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e) private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
{ {
popupHeaderText = e.IsNew ? "Neu" : "Edit"; popupHeaderText = e.IsNew ? "Neu" : "Edit";
@@ -385,9 +437,379 @@ else
} }
} }
private void SetPopupHeaderText(bool isNew) private async Task EnsureLayoutUserAsync()
{ {
popupHeaderText = isNew ? "Neu" : "Edit"; layoutUser = await JsRuntime.InvokeAsync<string?>("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<BandLayout>(stored.LayoutData, jsonOptions);
bandLayout = NormalizeBandLayout(parsed);
}
else
{
bandLayout = new BandLayout();
}
columnBandAssignments = BuildAssignmentsFromLayout(bandLayout);
ApplyColumnLayoutFromStorage();
UpdateBandOptions();
}
private async Task SaveLayoutAsync()
{
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 = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
private void CaptureColumnLayoutFromGrid()
{
if (gridRef == null)
{
return;
}
var layout = gridRef.SaveLayout();
bandLayout.GridLayout = layout;
var orderedColumns = layout.Columns
.Where(column => !string.IsNullOrWhiteSpace(column.FieldName))
.OrderBy(column => column.VisibleIndex)
.ToList();
bandLayout.ColumnOrder = orderedColumns
.Select(column => column.FieldName)
.ToList();
bandLayout.ColumnWidths = orderedColumns
.Where(column => !string.IsNullOrWhiteSpace(column.Width))
.ToDictionary(column => column.FieldName, column => column.Width, StringComparer.OrdinalIgnoreCase);
}
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()
{
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 ApplyBandOrderingFromColumnOrder()
{
if (bandLayout.ColumnOrder.Count == 0)
{
return;
}
var bandById = bandLayout.Bands.ToDictionary(band => band.Id, StringComparer.OrdinalIgnoreCase);
var orderedBandIds = new List<string>();
var orderedColumnsByBand = bandLayout.Bands.ToDictionary(
band => band.Id,
_ => new List<string>(),
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<BandOption> { 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<BandDefinition>();
layout.ColumnOrder ??= new List<string>();
layout.ColumnWidths ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
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<string>();
}
return layout;
}
private Dictionary<string, string> BuildAssignmentsFromLayout(BandLayout layout)
{
var assignments = new Dictionary<string, string>(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<DxGridCommandColumn>(seq++);
builder.AddAttribute(seq++, "Width", "120px");
builder.CloseComponent();
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<DxGridBandColumn>(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<DxGridDataColumn>(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<BandDefinition> Bands { get; set; } = new();
public List<string> ColumnOrder { get; set; } = new();
public Dictionary<string, string?> ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public GridPersistentLayout? GridLayout { get; set; }
}
private sealed class BandDefinition
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
public List<string> 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 private sealed class CatalogEditModel

View File

@@ -1,5 +1,11 @@
@using System.Text.Json
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Rendering
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using DevExpress.Blazor
@inject MassDataApiClient Api @inject MassDataApiClient Api
@inject LayoutApiClient LayoutApi
@inject IJSRuntime JsRuntime
<style> <style>
.action-panel { margin-bottom: 16px; } .action-panel { margin-bottom: 16px; }
@@ -10,6 +16,23 @@
margin-top: 12px; margin-top: 12px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.page-size-selector {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.page-size-label {
white-space: nowrap;
}
.page-size-combo {
width: 13ch;
min-width: 13ch;
max-width: 13ch;
}
.page-size-combo input {
text-align: left;
}
.massdata-grid .dxbl-grid-sort-asc, .massdata-grid .dxbl-grid-sort-asc,
.massdata-grid .dxbl-grid-sort-desc { .massdata-grid .dxbl-grid-sort-desc {
display: none; display: none;
@@ -54,6 +77,25 @@
.massdata-edit-popup { .massdata-edit-popup {
min-width: 720px; min-width: 720px;
} }
.band-editor {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
.band-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.band-row {
display: flex;
gap: 8px;
align-items: center;
}
.band-columns {
max-width: 720px;
}
</style> </style>
@if (!string.IsNullOrWhiteSpace(errorMessage)) @if (!string.IsNullOrWhiteSpace(errorMessage))
@@ -75,78 +117,67 @@ else if (items.Count == 0)
} }
else else
{ {
<div class="mb-3 page-size-selector">
<span class="page-size-label">Datensätze je Seite:</span>
<DxComboBox Data="@pageSizeOptions"
TData="PageSizeOption"
TValue="int?"
TextFieldName="Text"
ValueFieldName="Value"
Value="@pageSize"
ValueChanged="OnPageSizeChanged"
CssClass="page-size-combo" />
</div>
<div class="band-editor">
<div class="band-controls">
<DxButton Text="Band hinzufügen" Click="AddBand" />
<DxButton Text="Layout speichern" Click="SaveLayoutAsync" Enabled="@CanSaveBandLayout" />
<DxButton Text="Band-Layout zurücksetzen" Click="ResetBandLayoutAsync" />
</div>
@foreach (var band in bandLayout.Bands)
{
<div class="band-row">
<DxTextBox Text="@band.Caption" TextChanged="@(value => UpdateBandCaption(band, value))" />
<DxButton Text="Entfernen" Click="@(() => RemoveBand(band))" />
</div>
}
<DxFormLayout CssClass="band-columns" ColCount="2">
@foreach (var column in columnDefinitions)
{
<DxFormLayoutItem Caption="@column.Caption">
<DxComboBox Data="@bandOptions"
TData="BandOption"
TValue="string"
TextFieldName="Caption"
ValueFieldName="Id"
Value="@GetColumnBand(column.FieldName)"
ValueChanged="@(value => UpdateColumnBand(column.FieldName, value))"
Width="100%" />
</DxFormLayoutItem>
}
</DxFormLayout>
</div>
<div class="grid-section"> <div class="grid-section">
<DxGrid Data="@items" <DxGrid Data="@items"
TItem="MassDataReadDto" TItem="MassDataReadDto"
KeyFieldName="@nameof(MassDataReadDto.Id)" KeyFieldName="@nameof(MassDataReadDto.Id)"
ShowFilterRow="true" ShowFilterRow="true"
ShowGroupPanel="true"
AllowColumnResize="true" AllowColumnResize="true"
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
AllowColumnReorder="true"
PagerVisible="false" PagerVisible="false"
PageSize="100" PageSize="@(pageSize ?? 100)"
CssClass="mb-3 massdata-grid" CssClass="mb-3 massdata-grid"
EditMode="GridEditMode.PopupEditForm" EditMode="GridEditMode.PopupEditForm"
PopupEditFormCssClass="massdata-edit-popup"
PopupEditFormHeaderText="@popupHeaderText" PopupEditFormHeaderText="@popupHeaderText"
CustomizeEditModel="OnCustomizeEditModel" CustomizeEditModel="OnCustomizeEditModel"
EditModelSaving="OnEditModelSaving" EditModelSaving="OnEditModelSaving"
DataItemDeleting="OnDataItemDeleting"> DataItemDeleting="OnDataItemDeleting"
@ref="gridRef">
<Columns> <Columns>
<DxGridCommandColumn Width="120px" /> @RenderColumns()
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.Id)" Caption="Id" Width="90px" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue?.ToString())"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.CustomerName)" Caption="CustomerName">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.Amount)" Caption="Amount" DisplayFormat="c2">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue?.ToString())"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.Category)" Caption="Category" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.StatusFlag)" Caption="Status" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxComboBox Data="@statusFilterOptions"
TData="BoolFilterOption"
TValue="bool?"
TextFieldName="Text"
ValueFieldName="Value"
Value="@(filter.FilterRowValue as bool?)"
ValueChanged="@(value => filter.FilterRowValue = value)"
Width="100%" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.AddedWhen)" Caption="Angelegt am" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxDateEdit Date="@((DateTime?)filter.FilterRowValue)"
DateChanged="@((DateTime? value) => { filter.FilterRowValue = value; })"
Width="100%" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.ChangedWhen)" Caption="Geändert am" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxDateEdit Date="@((DateTime?)filter.FilterRowValue)"
DateChanged="@((DateTime? value) => { filter.FilterRowValue = value; })"
Width="100%" />
</FilterRowCellTemplate>
</DxGridDataColumn>
</Columns> </Columns>
<EditFormTemplate Context="editFormContext"> <EditFormTemplate Context="editFormContext">
@{ SetEditContext(editFormContext.EditContext); var editModel = (MassDataEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); } @{ SetEditContext(editFormContext.EditContext); var editModel = (MassDataEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); }
@@ -182,23 +213,57 @@ else
</EditFormTemplate> </EditFormTemplate>
</DxGrid> </DxGrid>
<div class="pager-container"> @if (pageCount > 1)
<DxPager PageCount="@pageCount" ActivePageIndex="@pageIndex" ActivePageIndexChanged="OnPageChanged" /> {
</div> <div class="pager-container">
<DxPager PageCount="@pageCount" ActivePageIndex="@pageIndex" ActivePageIndexChanged="OnPageChanged" />
</div>
}
</div> </div>
} }
@code { @code {
private const int PageSize = 100;
private List<MassDataReadDto> items = new(); private List<MassDataReadDto> items = new();
private bool isLoading; private bool isLoading;
private string? errorMessage; private string? errorMessage;
private string? infoMessage; private string? infoMessage;
private int pageIndex; private int pageIndex;
private int pageCount = 1; private int pageCount = 1;
private int? pageSize = 100;
private string popupHeaderText = "Edit"; private string popupHeaderText = "Edit";
private EditContext? editContext; private EditContext? editContext;
private ValidationMessageStore? validationMessageStore; 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<string, string> columnBandAssignments = new();
private List<BandOption> bandOptions = new();
private Dictionary<string, ColumnDefinition> columnLookup = new();
private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
private List<ColumnDefinition> 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<PageSizeOption> pageSizeOptions = new()
{
new() { Value = 100, Text = "100" },
new() { Value = 1000, Text = "1.000" },
new() { Value = 10000, Text = "10.000" },
new() { Value = 100000, Text = "100.000" },
new() { Value = null, Text = "Alle" }
};
private readonly List<BoolFilterOption> statusFilterOptions = new() private readonly List<BoolFilterOption> statusFilterOptions = new()
{ {
@@ -214,6 +279,9 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase);
await EnsureLayoutUserAsync();
await LoadBandLayoutAsync();
await LoadPage(0); await LoadPage(0);
} }
@@ -224,11 +292,12 @@ else
try try
{ {
var total = await Api.GetCountAsync(); var total = await Api.GetCountAsync();
pageCount = Math.Max(1, (int)Math.Ceiling(total / (double)PageSize)); var effectivePageSize = pageSize ?? (total == 0 ? 1 : total);
pageCount = Math.Max(1, (int)Math.Ceiling(total / (double)effectivePageSize));
pageIndex = Math.Clamp(page, 0, pageCount - 1); pageIndex = Math.Clamp(page, 0, pageCount - 1);
var skip = pageIndex * PageSize; var skip = pageSize.HasValue ? pageIndex * pageSize.Value : 0;
items = await Api.GetAllAsync(skip, PageSize); items = await Api.GetAllAsync(skip, pageSize);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -246,6 +315,292 @@ else
await LoadPage(index); await LoadPage(index);
} }
private async Task OnPageSizeChanged(int? size)
{
pageSize = size;
await LoadPage(0);
}
private async Task EnsureLayoutUserAsync()
{
layoutUser = await JsRuntime.InvokeAsync<string?>("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<BandLayout>(stored.LayoutData, jsonOptions);
bandLayout = NormalizeBandLayout(parsed);
}
else
{
bandLayout = new BandLayout();
}
columnBandAssignments = BuildAssignmentsFromLayout(bandLayout);
ApplyColumnLayoutFromStorage();
UpdateBandOptions();
}
private async Task SaveLayoutAsync()
{
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 = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
private void CaptureColumnLayoutFromGrid()
{
if (gridRef == null)
{
return;
}
var layout = gridRef.SaveLayout();
bandLayout.GridLayout = layout;
var orderedColumns = layout.Columns
.Where(column => !string.IsNullOrWhiteSpace(column.FieldName))
.OrderBy(column => column.VisibleIndex)
.ToList();
bandLayout.ColumnOrder = orderedColumns
.Select(column => column.FieldName)
.ToList();
bandLayout.ColumnWidths = orderedColumns
.Where(column => !string.IsNullOrWhiteSpace(column.Width))
.ToDictionary(column => column.FieldName, column => column.Width, StringComparer.OrdinalIgnoreCase);
}
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()
{
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<BandOption> { new() { Id = string.Empty, Caption = "Ohne Band" } };
bandOptions.AddRange(bandLayout.Bands.Select(band => new BandOption { Id = band.Id, Caption = band.Caption }));
}
private Dictionary<string, string> BuildAssignmentsFromLayout(BandLayout layout)
{
var assignments = new Dictionary<string, string>(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<BandDefinition>();
layout.ColumnOrder ??= new List<string>();
layout.ColumnWidths ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
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<string>();
}
return layout;
}
private RenderFragment RenderColumns() => builder =>
{
var seq = 0;
builder.OpenComponent<DxGridCommandColumn>(seq++);
builder.AddAttribute(seq++, "Width", "120px");
builder.CloseComponent();
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<DxGridBandColumn>(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<DxGridDataColumn>(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) private void SetEditContext(EditContext context)
{ {
if (editContext == context) if (editContext == context)
@@ -290,7 +645,7 @@ else
popupHeaderText = isNew ? "Neu" : "Edit"; popupHeaderText = isNew ? "Neu" : "Edit";
} }
private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e) private async Task OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
{ {
if (e.IsNew) if (e.IsNew)
{ {
@@ -382,6 +737,44 @@ else
return Task.CompletedTask; return Task.CompletedTask;
} }
private sealed class BandLayout
{
public List<BandDefinition> Bands { get; set; } = new();
public List<string> ColumnOrder { get; set; } = new();
public Dictionary<string, string?> ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public GridPersistentLayout? GridLayout { get; set; }
}
private sealed class BandDefinition
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
public List<string> 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 private sealed class MassDataEditModel
{ {
public int Id { get; set; } public int Id { get; set; }
@@ -405,4 +798,22 @@ else
public bool? Value { get; set; } public bool? Value { get; set; }
public string Text { get; set; } = string.Empty; public string Text { get; set; } = string.Empty;
} }
private sealed class PageSizeOption
{
public int? Value { get; set; }
public string Text { get; set; } = string.Empty;
}
private bool gridLayoutApplied;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!gridLayoutApplied && gridRef != null && bandLayout.GridLayout != null)
{
gridRef.LoadLayout(bandLayout.GridLayout);
gridLayoutApplied = true;
await InvokeAsync(StateHasChanged);
}
}
} }

View File

@@ -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;
}

View File

@@ -19,5 +19,6 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBaseU
builder.Services.AddScoped<CatalogApiClient>(); builder.Services.AddScoped<CatalogApiClient>();
builder.Services.AddScoped<DashboardApiClient>(); builder.Services.AddScoped<DashboardApiClient>();
builder.Services.AddScoped<MassDataApiClient>(); builder.Services.AddScoped<MassDataApiClient>();
builder.Services.AddScoped<LayoutApiClient>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();

View File

@@ -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<LayoutDto?> 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<LayoutDto>();
}
public async Task<LayoutDto> 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<LayoutDto>();
return payload ?? dto;
}
private static async Task<string> 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();
}
}

View File

@@ -19,9 +19,20 @@ public class MassDataApiClient
return result ?? 0; return result ?? 0;
} }
public async Task<List<MassDataReadDto>> GetAllAsync(int skip, int take) public async Task<List<MassDataReadDto>> GetAllAsync(int? skip, int? take)
{ {
var result = await _httpClient.GetFromJsonAsync<List<MassDataReadDto>>($"{Endpoint}?skip={skip}&take={take}"); var query = new List<string>();
if (skip.HasValue)
{
query.Add($"skip={skip.Value}");
}
if (take.HasValue)
{
query.Add($"take={take.Value}");
}
var url = query.Count == 0 ? Endpoint : $"{Endpoint}?{string.Join("&", query)}";
var result = await _httpClient.GetFromJsonAsync<List<MassDataReadDto>>(url);
return result ?? new List<MassDataReadDto>(); return result ?? new List<MassDataReadDto>();
} }

View File

@@ -1,5 +1,11 @@
@using System.Text.Json
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Rendering
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using DevExpress.Blazor
@inject CatalogApiClient Api @inject CatalogApiClient Api
@inject LayoutApiClient LayoutApi
@inject IJSRuntime JsRuntime
<style> <style>
.action-panel { margin-bottom: 16px; } .action-panel { margin-bottom: 16px; }
@@ -7,6 +13,25 @@
.catalog-edit-popup { .catalog-edit-popup {
min-width: 720px; min-width: 720px;
} }
.band-editor {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
.band-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.band-row {
display: flex;
gap: 8px;
align-items: center;
}
.band-columns {
max-width: 720px;
}
</style> </style>
@if (!string.IsNullOrWhiteSpace(errorMessage)) @if (!string.IsNullOrWhiteSpace(errorMessage))
@@ -28,11 +53,44 @@ else if (items.Count == 0)
} }
else else
{ {
<div class="band-editor">
<div class="band-controls">
<DxButton Text="Band hinzufügen" Click="AddBand" />
<DxButton Text="Layout speichern" Click="SaveLayoutAsync" Enabled="@CanSaveBandLayout" />
<DxButton Text="Band-Layout zurücksetzen" Click="ResetBandLayoutAsync" />
</div>
@foreach (var band in bandLayout.Bands)
{
<div class="band-row">
<DxTextBox Text="@band.Caption" TextChanged="@(value => UpdateBandCaption(band, value))" />
<DxButton Text="Entfernen" Click="@(() => RemoveBand(band))" />
</div>
}
<DxFormLayout CssClass="band-columns" ColCount="2">
@foreach (var column in columnDefinitions)
{
<DxFormLayoutItem Caption="@column.Caption">
<DxComboBox Data="@bandOptions"
TData="BandOption"
TValue="string"
TextFieldName="Caption"
ValueFieldName="Id"
Value="@GetColumnBand(column.FieldName)"
ValueChanged="@(value => UpdateColumnBand(column.FieldName, value))"
Width="100%" />
</DxFormLayoutItem>
}
</DxFormLayout>
</div>
<div class="grid-section"> <div class="grid-section">
<DxGrid Data="@items" <DxGrid Data="@items"
TItem="CatalogReadDto" TItem="CatalogReadDto"
KeyFieldName="@nameof(CatalogReadDto.Guid)" KeyFieldName="@nameof(CatalogReadDto.Guid)"
ShowFilterRow="true" ShowFilterRow="true"
AllowColumnResize="true"
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
AllowColumnReorder="true"
PageSize="10" PageSize="10"
CssClass="mb-4 catalog-grid" CssClass="mb-4 catalog-grid"
EditMode="GridEditMode.PopupEditForm" EditMode="GridEditMode.PopupEditForm"
@@ -40,58 +98,10 @@ else
PopupEditFormHeaderText="@popupHeaderText" PopupEditFormHeaderText="@popupHeaderText"
CustomizeEditModel="OnCustomizeEditModel" CustomizeEditModel="OnCustomizeEditModel"
EditModelSaving="OnEditModelSaving" EditModelSaving="OnEditModelSaving"
DataItemDeleting="OnDataItemDeleting"> DataItemDeleting="OnDataItemDeleting"
@ref="gridRef">
<Columns> <Columns>
<DxGridCommandColumn Width="120px" /> @RenderColumns()
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.Guid)" Caption="Id" Width="140px" SortIndex="0" SortOrder="GridColumnSortOrder.Ascending">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue?.ToString())"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.CatTitle)" Caption="Titel">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.CatString)" Caption="String">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.AddedWho)" Caption="Angelegt von" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.AddedWhen)" Caption="Angelegt am" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxDateEdit Date="@((DateTime?)filter.FilterRowValue)"
DateChanged="@((DateTime? value) => { filter.FilterRowValue = value; })"
Width="100%" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.ChangedWho)" Caption="Geändert von" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.ChangedWhen)" Caption="Geändert am" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxDateEdit Date="@((DateTime?)filter.FilterRowValue)"
DateChanged="@((DateTime? value) => { filter.FilterRowValue = value; })"
Width="100%" />
</FilterRowCellTemplate>
</DxGridDataColumn>
</Columns> </Columns>
<EditFormTemplate Context="editFormContext"> <EditFormTemplate Context="editFormContext">
@{ SetEditContext(editFormContext.EditContext); var editModel = (CatalogEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); } @{ SetEditContext(editFormContext.EditContext); var editModel = (CatalogEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); }
@@ -130,7 +140,27 @@ else
private string? infoMessage; private string? infoMessage;
private EditContext? editContext; private EditContext? editContext;
private ValidationMessageStore? validationMessageStore; private ValidationMessageStore? validationMessageStore;
private IGrid? gridRef;
private string popupHeaderText = "Edit"; 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<string, string> columnBandAssignments = new();
private List<BandOption> bandOptions = new();
private Dictionary<string, ColumnDefinition> columnLookup = new();
private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
private List<ColumnDefinition> 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<ProcedureOption> procedureOptions = new() private readonly List<ProcedureOption> procedureOptions = new()
{ {
@@ -138,8 +168,13 @@ else
new() { Value = 1, Text = "PRTBMY_CATALOG_SAVE" } new() { Value = 1, Text = "PRTBMY_CATALOG_SAVE" }
}; };
private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser);
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase);
await EnsureLayoutUserAsync();
await LoadBandLayoutAsync();
await LoadCatalogs(); await LoadCatalogs();
} }
@@ -349,6 +384,296 @@ else
} }
} }
private async Task EnsureLayoutUserAsync()
{
layoutUser = await JsRuntime.InvokeAsync<string?>("localStorage.getItem", LayoutUserStorageKey);
if (string.IsNullOrWhiteSpace(layoutUser))
{
layoutUser = Guid.NewGuid().ToString("N");
await JsRuntime.InvokeVoidAsync("localStorage.setItem", LayoutUserStorageKey, layoutUser);
}
}
private async Task SaveLayoutAsync()
{
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 = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
private void CaptureColumnLayoutFromGrid()
{
if (gridRef == null)
{
return;
}
var layout = gridRef.SaveLayout();
bandLayout.GridLayout = layout;
var orderedColumns = layout.Columns
.Where(column => !string.IsNullOrWhiteSpace(column.FieldName))
.OrderBy(column => column.VisibleIndex)
.ToList();
bandLayout.ColumnOrder = orderedColumns
.Select(column => column.FieldName)
.ToList();
bandLayout.ColumnWidths = orderedColumns
.Where(column => !string.IsNullOrWhiteSpace(column.Width))
.ToDictionary(column => column.FieldName, column => column.Width, StringComparer.OrdinalIgnoreCase);
}
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<BandLayout>(stored.LayoutData, jsonOptions);
bandLayout = NormalizeBandLayout(parsed);
}
else
{
bandLayout = new BandLayout();
}
columnBandAssignments = BuildAssignmentsFromLayout(bandLayout);
ApplyColumnLayoutFromStorage();
UpdateBandOptions();
}
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<BandOption> { 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<BandDefinition>();
layout.ColumnOrder ??= new List<string>();
layout.ColumnWidths ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
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<string>();
}
return layout;
}
private Dictionary<string, string> BuildAssignmentsFromLayout(BandLayout layout)
{
var assignments = new Dictionary<string, string>(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<DxGridCommandColumn>(seq++);
builder.AddAttribute(seq++, "Width", "120px");
builder.CloseComponent();
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<DxGridBandColumn>(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<DxGridDataColumn>(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<BandDefinition> Bands { get; set; } = new();
public List<string> ColumnOrder { get; set; } = new();
public Dictionary<string, string?> ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public GridPersistentLayout? GridLayout { get; set; }
}
private sealed class BandDefinition
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
public List<string> 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 private sealed class CatalogEditModel
{ {
public int Guid { get; set; } public int Guid { get; set; }
@@ -364,4 +689,43 @@ else
public int Value { get; set; } public int Value { get; set; }
public string Text { get; set; } = string.Empty; 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()
{
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 bool gridLayoutApplied;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!gridLayoutApplied && gridRef != null && bandLayout.GridLayout != null)
{
gridRef.LoadLayout(bandLayout.GridLayout);
gridLayoutApplied = true;
await InvokeAsync(StateHasChanged);
}
}
} }

View File

@@ -1,5 +1,11 @@
@using System.Text.Json
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Rendering
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using DevExpress.Blazor
@inject MassDataApiClient Api @inject MassDataApiClient Api
@inject LayoutApiClient LayoutApi
@inject IJSRuntime JsRuntime
<style> <style>
.action-panel { margin-bottom: 16px; } .action-panel { margin-bottom: 16px; }
@@ -10,6 +16,23 @@
margin-top: 12px; margin-top: 12px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.page-size-selector {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.page-size-label {
white-space: nowrap;
}
.page-size-combo {
width: 13ch;
min-width: 13ch;
max-width: 13ch;
}
.page-size-combo input {
text-align: left;
}
.massdata-grid .dxbl-grid-sort-asc, .massdata-grid .dxbl-grid-sort-asc,
.massdata-grid .dxbl-grid-sort-desc { .massdata-grid .dxbl-grid-sort-desc {
display: none; display: none;
@@ -54,6 +77,25 @@
.massdata-edit-popup { .massdata-edit-popup {
min-width: 720px; min-width: 720px;
} }
.band-editor {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
.band-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.band-row {
display: flex;
gap: 8px;
align-items: center;
}
.band-columns {
max-width: 720px;
}
</style> </style>
@if (!string.IsNullOrWhiteSpace(errorMessage)) @if (!string.IsNullOrWhiteSpace(errorMessage))
@@ -75,78 +117,67 @@ else if (items.Count == 0)
} }
else else
{ {
<div class="mb-3 page-size-selector">
<span class="page-size-label">Datensätze je Seite:</span>
<DxComboBox Data="@pageSizeOptions"
TData="PageSizeOption"
TValue="int?"
TextFieldName="Text"
ValueFieldName="Value"
Value="@pageSize"
ValueChanged="OnPageSizeChanged"
CssClass="page-size-combo" />
</div>
<div class="band-editor">
<div class="band-controls">
<DxButton Text="Band hinzufügen" Click="AddBand" />
<DxButton Text="Layout speichern" Click="SaveLayoutAsync" Enabled="@CanSaveBandLayout" />
<DxButton Text="Band-Layout zurücksetzen" Click="ResetBandLayoutAsync" />
</div>
@foreach (var band in bandLayout.Bands)
{
<div class="band-row">
<DxTextBox Text="@band.Caption" TextChanged="@(value => UpdateBandCaption(band, value))" />
<DxButton Text="Entfernen" Click="@(() => RemoveBand(band))" />
</div>
}
<DxFormLayout CssClass="band-columns" ColCount="2">
@foreach (var column in columnDefinitions)
{
<DxFormLayoutItem Caption="@column.Caption">
<DxComboBox Data="@bandOptions"
TData="BandOption"
TValue="string"
TextFieldName="Caption"
ValueFieldName="Id"
Value="@GetColumnBand(column.FieldName)"
ValueChanged="@(value => UpdateColumnBand(column.FieldName, value))"
Width="100%" />
</DxFormLayoutItem>
}
</DxFormLayout>
</div>
<div class="grid-section"> <div class="grid-section">
<DxGrid Data="@items" <DxGrid Data="@items"
TItem="MassDataReadDto" TItem="MassDataReadDto"
KeyFieldName="@nameof(MassDataReadDto.Id)" KeyFieldName="@nameof(MassDataReadDto.Id)"
ShowFilterRow="true" ShowFilterRow="true"
ShowGroupPanel="true"
AllowColumnResize="true" AllowColumnResize="true"
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
AllowColumnReorder="true"
PagerVisible="false" PagerVisible="false"
PageSize="100" PageSize="@(pageSize ?? 100)"
CssClass="mb-3 massdata-grid" CssClass="mb-3 massdata-grid"
EditMode="GridEditMode.PopupEditForm" EditMode="GridEditMode.PopupEditForm"
PopupEditFormCssClass="massdata-edit-popup"
PopupEditFormHeaderText="@popupHeaderText" PopupEditFormHeaderText="@popupHeaderText"
CustomizeEditModel="OnCustomizeEditModel" CustomizeEditModel="OnCustomizeEditModel"
EditModelSaving="OnEditModelSaving" EditModelSaving="OnEditModelSaving"
DataItemDeleting="OnDataItemDeleting"> DataItemDeleting="OnDataItemDeleting"
@ref="gridRef">
<Columns> <Columns>
<DxGridCommandColumn Width="120px" /> @RenderColumns()
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.Id)" Caption="Id" Width="90px" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue?.ToString())"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.CustomerName)" Caption="CustomerName">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.Amount)" Caption="Amount" DisplayFormat="c2">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue?.ToString())"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.Category)" Caption="Category" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue as string)"
TextChanged="@(value => filter.FilterRowValue = value)"
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.StatusFlag)" Caption="Status" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxComboBox Data="@statusFilterOptions"
TData="BoolFilterOption"
TValue="bool?"
TextFieldName="Text"
ValueFieldName="Value"
Value="@(filter.FilterRowValue as bool?)"
ValueChanged="@(value => filter.FilterRowValue = value)"
Width="100%" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.AddedWhen)" Caption="Added" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxDateEdit Date="@((DateTime?)filter.FilterRowValue)"
DateChanged="@((DateTime? value) => { filter.FilterRowValue = value; })"
Width="100%" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(MassDataReadDto.ChangedWhen)" Caption="Changed" ReadOnly="true">
<FilterRowCellTemplate Context="filter">
<DxDateEdit Date="@((DateTime?)filter.FilterRowValue)"
DateChanged="@((DateTime? value) => { filter.FilterRowValue = value; })"
Width="100%" />
</FilterRowCellTemplate>
</DxGridDataColumn>
</Columns> </Columns>
<EditFormTemplate Context="editFormContext"> <EditFormTemplate Context="editFormContext">
@{ SetEditContext(editFormContext.EditContext); var editModel = (MassDataEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); } @{ SetEditContext(editFormContext.EditContext); var editModel = (MassDataEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); }
@@ -182,23 +213,57 @@ else
</EditFormTemplate> </EditFormTemplate>
</DxGrid> </DxGrid>
<div class="pager-container"> @if (pageCount > 1)
<DxPager PageCount="@pageCount" ActivePageIndex="@pageIndex" ActivePageIndexChanged="OnPageChanged" /> {
</div> <div class="pager-container">
<DxPager PageCount="@pageCount" ActivePageIndex="@pageIndex" ActivePageIndexChanged="OnPageChanged" />
</div>
}
</div> </div>
} }
@code { @code {
private const int PageSize = 100;
private List<MassDataReadDto> items = new(); private List<MassDataReadDto> items = new();
private bool isLoading; private bool isLoading;
private string? errorMessage; private string? errorMessage;
private string? infoMessage; private string? infoMessage;
private int pageIndex; private int pageIndex;
private int pageCount = 1; private int pageCount = 1;
private int? pageSize = 100;
private string popupHeaderText = "Edit"; private string popupHeaderText = "Edit";
private EditContext? editContext; private EditContext? editContext;
private ValidationMessageStore? validationMessageStore; 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<string, string> columnBandAssignments = new();
private List<BandOption> bandOptions = new();
private Dictionary<string, ColumnDefinition> columnLookup = new();
private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
private List<ColumnDefinition> 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 = "Added", ReadOnly = true, FilterType = ColumnFilterType.Date },
new() { FieldName = nameof(MassDataReadDto.ChangedWhen), Caption = "Changed", ReadOnly = true, FilterType = ColumnFilterType.Date }
};
private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser);
private readonly List<PageSizeOption> pageSizeOptions = new()
{
new() { Value = 100, Text = "100" },
new() { Value = 1000, Text = "1.000" },
new() { Value = 10000, Text = "10.000" },
new() { Value = 100000, Text = "100.000" },
new() { Value = null, Text = "Alle" }
};
private readonly List<BoolFilterOption> statusFilterOptions = new() private readonly List<BoolFilterOption> statusFilterOptions = new()
{ {
@@ -214,6 +279,9 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase);
await EnsureLayoutUserAsync();
await LoadBandLayoutAsync();
await LoadPage(0); await LoadPage(0);
} }
@@ -224,11 +292,12 @@ else
try try
{ {
var total = await Api.GetCountAsync(); var total = await Api.GetCountAsync();
pageCount = Math.Max(1, (int)Math.Ceiling(total / (double)PageSize)); var effectivePageSize = pageSize ?? (total == 0 ? 1 : total);
pageCount = Math.Max(1, (int)Math.Ceiling(total / (double)effectivePageSize));
pageIndex = Math.Clamp(page, 0, pageCount - 1); pageIndex = Math.Clamp(page, 0, pageCount - 1);
var skip = pageIndex * PageSize; var skip = pageSize.HasValue ? pageIndex * pageSize.Value : 0;
items = await Api.GetAllAsync(skip, PageSize); items = await Api.GetAllAsync(skip, pageSize);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -246,6 +315,336 @@ else
await LoadPage(index); await LoadPage(index);
} }
private async Task OnPageSizeChanged(int? size)
{
pageSize = size;
await LoadPage(0);
}
private async Task EnsureLayoutUserAsync()
{
layoutUser = await JsRuntime.InvokeAsync<string?>("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<BandLayout>(stored.LayoutData, jsonOptions);
bandLayout = NormalizeBandLayout(parsed);
}
else
{
bandLayout = new BandLayout();
}
columnBandAssignments = BuildAssignmentsFromLayout(bandLayout);
ApplyColumnLayoutFromStorage();
//ApplyBandOrderingFromColumnOrder();
UpdateBandOptions();
}
private async Task SaveLayoutAsync()
{
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 = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
private void CaptureColumnLayoutFromGrid()
{
if (gridRef == null)
{
return;
}
var layout = gridRef.SaveLayout();
bandLayout.GridLayout = layout;
var orderedColumns = layout.Columns
.Where(column => !string.IsNullOrWhiteSpace(column.FieldName))
.OrderBy(column => column.VisibleIndex)
.ToList();
bandLayout.ColumnOrder = orderedColumns
.Select(column => column.FieldName)
.ToList();
bandLayout.ColumnWidths = orderedColumns
.Where(column => !string.IsNullOrWhiteSpace(column.Width))
.ToDictionary(column => column.FieldName, column => column.Width, StringComparer.OrdinalIgnoreCase);
}
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 ApplyBandOrderingFromColumnOrder()
{
if (bandLayout.ColumnOrder.Count == 0)
{
return;
}
var bandById = bandLayout.Bands.ToDictionary(band => band.Id, StringComparer.OrdinalIgnoreCase);
var orderedBandIds = new List<string>();
var orderedColumnsByBand = bandLayout.Bands.ToDictionary(
band => band.Id,
_ => new List<string>(),
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()
{
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<BandOption> { new() { Id = string.Empty, Caption = "Ohne Band" } };
bandOptions.AddRange(bandLayout.Bands.Select(band => new BandOption { Id = band.Id, Caption = band.Caption }));
}
private Dictionary<string, string> BuildAssignmentsFromLayout(BandLayout layout)
{
var assignments = new Dictionary<string, string>(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<BandDefinition>();
layout.ColumnOrder ??= new List<string>();
layout.ColumnWidths ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
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<string>();
}
return layout;
}
private RenderFragment RenderColumns() => builder =>
{
var seq = 0;
builder.OpenComponent<DxGridCommandColumn>(seq++);
builder.AddAttribute(seq++, "Width", "120px");
builder.CloseComponent();
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<DxGridBandColumn>(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<DxGridDataColumn>(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) private void SetEditContext(EditContext context)
{ {
if (editContext == context) if (editContext == context)
@@ -290,7 +689,7 @@ else
popupHeaderText = isNew ? "Neu" : "Edit"; popupHeaderText = isNew ? "Neu" : "Edit";
} }
private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e) private async Task OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
{ {
if (e.IsNew) if (e.IsNew)
{ {
@@ -382,6 +781,44 @@ else
return Task.CompletedTask; return Task.CompletedTask;
} }
private sealed class BandLayout
{
public List<BandDefinition> Bands { get; set; } = new();
public List<string> ColumnOrder { get; set; } = new();
public Dictionary<string, string?> ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public GridPersistentLayout? GridLayout { get; set; }
}
private sealed class BandDefinition
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
public List<string> 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 private sealed class MassDataEditModel
{ {
public int Id { get; set; } public int Id { get; set; }
@@ -405,4 +842,22 @@ else
public bool? Value { get; set; } public bool? Value { get; set; }
public string Text { get; set; } = string.Empty; public string Text { get; set; } = string.Empty;
} }
private sealed class PageSizeOption
{
public int? Value { get; set; }
public string Text { get; set; } = string.Empty;
}
private bool gridLayoutApplied;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!gridLayoutApplied && gridRef != null && bandLayout.GridLayout != null)
{
gridRef.LoadLayout(bandLayout.GridLayout);
gridLayoutApplied = true;
await InvokeAsync(StateHasChanged);
}
}
} }

View File

@@ -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;
}

View File

@@ -25,12 +25,17 @@ if (!string.IsNullOrWhiteSpace(apiBaseUrl))
{ {
client.BaseAddress = new Uri(apiBaseUrl); client.BaseAddress = new Uri(apiBaseUrl);
}); });
builder.Services.AddHttpClient<LayoutApiClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
});
} }
else else
{ {
builder.Services.AddHttpClient<CatalogApiClient>(); builder.Services.AddHttpClient<CatalogApiClient>();
builder.Services.AddHttpClient<DashboardApiClient>(); builder.Services.AddHttpClient<DashboardApiClient>();
builder.Services.AddHttpClient<MassDataApiClient>(); builder.Services.AddHttpClient<MassDataApiClient>();
builder.Services.AddHttpClient<LayoutApiClient>();
} }
var app = builder.Build(); var app = builder.Build();

View File

@@ -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<LayoutDto?> 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<LayoutDto>();
}
public async Task<LayoutDto> 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<LayoutDto>();
return payload ?? dto;
}
private static async Task<string> 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();
}
}

View File

@@ -19,9 +19,20 @@ public class MassDataApiClient
return result ?? 0; return result ?? 0;
} }
public async Task<List<MassDataReadDto>> GetAllAsync(int skip, int take) public async Task<List<MassDataReadDto>> GetAllAsync(int? skip, int? take)
{ {
var result = await _httpClient.GetFromJsonAsync<List<MassDataReadDto>>($"{Endpoint}?skip={skip}&take={take}"); var query = new List<string>();
if (skip.HasValue)
{
query.Add($"skip={skip.Value}");
}
if (take.HasValue)
{
query.Add($"take={take.Value}");
}
var url = query.Count == 0 ? Endpoint : $"{Endpoint}?{string.Join("&", query)}";
var result = await _httpClient.GetFromJsonAsync<List<MassDataReadDto>>(url);
return result ?? new List<MassDataReadDto>(); return result ?? new List<MassDataReadDto>();
} }

View File

@@ -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<byte>();
public string AddedWho { get; set; } = string.Empty;
public DateTime AddedWhen { get; set; }
public string? ChangedWho { get; set; }
public DateTime? ChangedWhen { get; set; }
}

View File

@@ -15,6 +15,7 @@ public partial class ApplicationDbContext : DbContext
} }
public virtual DbSet<VwmyCatalog> VwmyCatalogs { get; set; } public virtual DbSet<VwmyCatalog> VwmyCatalogs { get; set; }
public virtual DbSet<SmfLayout> SmfLayouts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -51,6 +52,37 @@ public partial class ApplicationDbContext : DbContext
.HasColumnName(catCfg.ChangedWhoColumnName); .HasColumnName(catCfg.ChangedWhoColumnName);
}); });
modelBuilder.Entity<SmfLayout>(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); OnModelCreatingPartial(modelBuilder);
} }

View File

@@ -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<SmfLayout?> 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<SmfLayout> 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<bool> 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;
}
}