Compare commits

..

2 Commits

Author SHA1 Message Date
OlgunR
86feec930b Add Clock page with live DB time and TimeApiClient service
Introduced a new Clock page that displays and updates the current database server time every second by calling a backend API. Added the TimeApiClient service to handle API requests for the server time. Registered TimeApiClient in Program.cs and updated the navigation menu to include a link to the new Clock page. Includes error handling and custom UI styling for the clock display.
2026-03-30 15:16:33 +02:00
OlgunR
f5224e20f2 Add time record API endpoint and supporting infrastructure
Introduced a new TimeController with a POST endpoint to insert and retrieve the latest time record. Added ITimeRepository, TimeRepository, and TimeRecord entity. Implemented MediatR command and handler for time insertion. Updated ApplicationDbContext and DI configuration to support the new feature.
2026-03-30 15:16:03 +02:00
23 changed files with 843 additions and 1624 deletions

View File

@@ -0,0 +1,28 @@
using DbFirst.Application.Time.Commands;
using DbFirst.Domain.Entities;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DbFirst.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TimeController : ControllerBase
{
private readonly IMediator _mediator;
public TimeController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<ActionResult<TimeRecord>> InsertAndGetLast(CancellationToken cancellationToken)
{
var result = await _mediator.Send(new InsertTimeCommand(), cancellationToken);
if (result == null)
return NotFound();
return Ok(result);
}
}

View File

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

View File

@@ -0,0 +1,9 @@
using DbFirst.Domain.Entities;
namespace DbFirst.Application.Repositories;
public interface ITimeRepository
{
Task InsertAsync(CancellationToken cancellationToken = default);
Task<TimeRecord?> GetLastAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,6 @@
using DbFirst.Domain.Entities;
using MediatR;
namespace DbFirst.Application.Time.Commands;
public record InsertTimeCommand : IRequest<TimeRecord?>;

View File

@@ -0,0 +1,21 @@
using DbFirst.Application.Repositories;
using DbFirst.Domain.Entities;
using MediatR;
namespace DbFirst.Application.Time.Commands;
public class InsertTimeHandler : IRequestHandler<InsertTimeCommand, TimeRecord?>
{
private readonly ITimeRepository _repository;
public InsertTimeHandler(ITimeRepository repository)
{
_repository = repository;
}
public async Task<TimeRecord?> Handle(InsertTimeCommand request, CancellationToken cancellationToken)
{
await _repository.InsertAsync(cancellationToken);
return await _repository.GetLastAsync(cancellationToken);
}
}

View File

@@ -5,7 +5,10 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
@DxResourceManager.RegisterTheme(Themes.Fluent)
@DxResourceManager.RegisterTheme(Themes.Fluent.Clone(properties =>
{
properties.ApplyToPageElements = true;
}))
@DxResourceManager.RegisterScripts()
<link href="_content/DevExpress.Blazor.Dashboard/ace.css" rel="stylesheet" />

View File

@@ -1,55 +0,0 @@
<div class="band-editor">
<button class="band-editor-toggle" @onclick="() => IsExpanded = !IsExpanded">
<span class="band-editor-toggle-icon @(IsExpanded ? "expanded" : "")">&#9658;</span>
<span>Layout</span>
</button>
@if (IsExpanded)
{
<div class="band-editor-body">
<div class="band-controls">
<DxButton Text="Band hinzufügen" Click="OnAddBand" />
<DxButton Text="Layout speichern" Click="OnSaveLayout" Enabled="@CanSave" />
<DxButton Text="Layout zurücksetzen" Click="OnResetLayout" />
</div>
@foreach (var band in Bands)
{
<div class="band-row">
<DxTextBox Text="@band.Caption" TextChanged="@(value => OnBandCaptionChanged.InvokeAsync((band, value)))" />
<DxButton Text="Entfernen" Click="@(() => OnRemoveBand.InvokeAsync(band))" />
</div>
}
<DxFormLayout CssClass="band-columns" ColCount="2">
@foreach (var column in Columns)
{
<DxFormLayoutItem Caption="@column.Caption">
<DxComboBox Data="@BandOptions"
TData="BandOption"
TValue="string"
TextFieldName="Caption"
ValueFieldName="Id"
Value="@GetColumnBand(column.FieldName)"
ValueChanged="@(value => OnColumnBandChanged.InvokeAsync((column.FieldName, value)))"
Width="100%" />
</DxFormLayoutItem>
}
</DxFormLayout>
</div>
}
</div>
@code {
private bool IsExpanded { get; set; }
[Parameter, EditorRequired] public List<BandDefinition> Bands { get; set; } = new();
[Parameter, EditorRequired] public List<BandOption> BandOptions { get; set; } = new();
[Parameter, EditorRequired] public List<ColumnDefinition> Columns { get; set; } = new();
[Parameter, EditorRequired] public Func<string, string> GetColumnBand { get; set; } = _ => string.Empty;
[Parameter, EditorRequired] public bool CanSave { get; set; }
[Parameter] public EventCallback OnAddBand { get; set; }
[Parameter] public EventCallback OnSaveLayout { get; set; }
[Parameter] public EventCallback OnResetLayout { get; set; }
[Parameter] public EventCallback<BandDefinition> OnRemoveBand { get; set; }
[Parameter] public EventCallback<(BandDefinition Band, string Value)> OnBandCaptionChanged { get; set; }
[Parameter] public EventCallback<(string FieldName, string? BandId)> OnColumnBandChanged { get; set; }
}

View File

@@ -1,216 +0,0 @@
using DbFirst.BlazorWebApp.Models.Grid;
using DbFirst.BlazorWebApp.Services;
using DevExpress.Blazor;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
namespace DbFirst.BlazorWebApp.Components;
public abstract class BandGridBase<TItem> : ComponentBase
{
[Inject] protected BandLayoutService BandLayoutService { get; set; } = default!;
// --- Abstract: jedes Grid definiert diese selbst ---
protected abstract string LayoutKey { get; }
protected abstract List<ColumnDefinition> ColumnDefinitions { get; }
// --- Band-Layout Felder ---
protected BandLayout bandLayout = new();
protected Dictionary<string, string> columnBandAssignments = new();
protected List<BandOption> bandOptions = new();
protected Dictionary<string, ColumnDefinition> columnLookup = new();
protected string? layoutUser;
protected bool gridLayoutApplied;
protected IGrid? gridRef;
// --- SizeMode ---
protected SizeMode _sizeMode = SizeMode.Medium;
protected static readonly List<SizeMode> _sizeModes = Enum.GetValues<SizeMode>().ToList();
private const string LayoutType = "GRID_BANDS";
// --- Lifecycle ---
protected async Task InitializeBandLayoutAsync()
{
columnLookup = ColumnDefinitions.ToDictionary(c => c.FieldName, StringComparer.OrdinalIgnoreCase);
layoutUser = await BandLayoutService.EnsureLayoutUserAsync();
bandLayout = await BandLayoutService.LoadBandLayoutAsync(LayoutType, LayoutKey, layoutUser, columnLookup);
columnBandAssignments = BandLayoutService.BuildAssignmentsFromLayout(bandLayout);
ApplyColumnLayoutFromStorage();
_sizeMode = bandLayout.SizeMode;
UpdateBandOptions();
}
protected async Task ApplyGridLayoutAfterRenderAsync()
{
if (!gridLayoutApplied && gridRef != null && bandLayout.GridLayout != null)
{
gridRef.LoadLayout(bandLayout.GridLayout);
gridLayoutApplied = true;
await InvokeAsync(StateHasChanged);
}
}
// --- Layout speichern / zurücksetzen ---
protected async Task SaveLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser)) return;
CaptureColumnLayoutFromGrid();
await BandLayoutService.SaveBandLayoutAsync(LayoutType, LayoutKey, layoutUser, bandLayout);
}
protected async Task ResetLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser)) return;
await BandLayoutService.ResetBandLayoutAsync(LayoutType, LayoutKey, layoutUser);
bandLayout = new BandLayout();
columnBandAssignments.Clear();
UpdateBandOptions();
foreach (var column in ColumnDefinitions)
column.Width = null;
columnLookup = ColumnDefinitions.ToDictionary(c => c.FieldName, StringComparer.OrdinalIgnoreCase);
_sizeMode = SizeMode.Medium;
gridRef?.LoadLayout(new GridPersistentLayout());
gridLayoutApplied = false;
}
private void CaptureColumnLayoutFromGrid()
{
if (gridRef == null) return;
var layout = gridRef.SaveLayout();
bandLayout.GridLayout = layout;
bandLayout.SizeMode = _sizeMode;
var orderedColumns = layout.Columns
.Where(c => !string.IsNullOrWhiteSpace(c.FieldName))
.OrderBy(c => c.VisibleIndex)
.ToList();
bandLayout.ColumnOrder = orderedColumns.Select(c => c.FieldName).ToList();
bandLayout.ColumnWidths = orderedColumns
.Where(c => !string.IsNullOrWhiteSpace(c.Width))
.ToDictionary(c => c.FieldName, c => c.Width, StringComparer.OrdinalIgnoreCase);
}
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(c => c.FieldName, StringComparer.OrdinalIgnoreCase);
}
// --- Band-Methoden ---
protected bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser);
protected void AddBand()
{
bandLayout.Bands.Add(new BandDefinition { Id = Guid.NewGuid().ToString("N"), Caption = "Band" });
UpdateBandOptions();
}
protected void RemoveBand(BandDefinition band)
{
bandLayout.Bands.Remove(band);
foreach (var key in columnBandAssignments.Where(p => p.Value == band.Id).Select(p => p.Key).ToList())
columnBandAssignments.Remove(key);
UpdateBandOptions();
SyncBandsFromAssignments();
}
protected void UpdateBandCaption(BandDefinition band, string value)
{
band.Caption = value;
UpdateBandOptions();
}
protected void UpdateColumnBand(string fieldName, string? bandId)
{
if (string.IsNullOrWhiteSpace(bandId))
columnBandAssignments.Remove(fieldName);
else
columnBandAssignments[fieldName] = bandId;
SyncBandsFromAssignments();
}
protected string GetColumnBand(string fieldName)
=> columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty;
protected void SyncBandsFromAssignments()
{
foreach (var band in bandLayout.Bands)
{
band.Columns = ColumnDefinitions
.Where(c => columnBandAssignments.TryGetValue(c.FieldName, out var id) && id == band.Id)
.Select(c => c.FieldName)
.ToList();
}
StateHasChanged();
}
protected void UpdateBandOptions()
{
bandOptions = new List<BandOption> { new() { Id = string.Empty, Caption = "Ohne Band" } };
bandOptions.AddRange(bandLayout.Bands.Select(b => new BandOption { Id = b.Id, Caption = b.Caption }));
}
// --- SizeMode ---
protected string FormatSizeText(SizeMode size) => size switch
{
SizeMode.Small => "Klein",
SizeMode.Medium => "Mittel",
SizeMode.Large => "Groß",
_ => size.ToString()
};
protected void OnSizeChange(DropDownButtonItemClickEventArgs args)
{
_sizeMode = Enum.Parse<SizeMode>(args.ItemInfo.Id);
}
// --- RenderColumns / BuildDataColumn ---
protected RenderFragment RenderColumns() => builder =>
{
var seq = 0;
builder.OpenComponent<DxGridCommandColumn>(seq++);
builder.AddAttribute(seq++, "Width", "120px");
builder.CloseComponent();
var grouped = bandLayout.Bands.SelectMany(b => b.Columns).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var column in ColumnDefinitions.Where(c => !grouped.Contains(c.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++, "Width", "120px");
builder.AddAttribute(seq++, "NewButtonVisible", false); // falls noch nicht vorhanden
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();
}
};
protected 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();
}
}

View File

@@ -1,5 +1,5 @@
@inherits BandGridBase<CatalogReadDto>
@inject CatalogApiClient Api
@inject BandLayoutService BandLayoutService
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
@@ -24,21 +24,47 @@ else if (items.Count == 0)
}
else
{
<BandEditor Bands="@bandLayout.Bands"
BandOptions="@bandOptions"
Columns="@ColumnDefinitions"
GetColumnBand="GetColumnBand"
CanSave="@CanSaveBandLayout"
OnAddBand="AddBand"
OnSaveLayout="SaveLayoutWithFeedbackAsync"
OnResetLayout="ResetLayoutWithFeedbackAsync"
OnRemoveBand="RemoveBand"
OnBandCaptionChanged="@(args => UpdateBandCaption(args.Band, args.Value))"
OnColumnBandChanged="@(args => UpdateColumnBand(args.FieldName, args.BandId))" />
<div class="band-editor">
<button class="band-editor-toggle" @onclick="() => bandEditorExpanded = !bandEditorExpanded">
<span class="band-editor-toggle-icon @(bandEditorExpanded ? "expanded" : "")">&#9658;</span>
<span>Layout</span>
</button>
@if (bandEditorExpanded)
{
<div class="band-editor-body">
<div class="band-controls">
<DxButton Text="Band hinzufügen" Click="AddBand" />
<DxButton Text="Layout speichern" Click="SaveLayoutAsync" Enabled="@CanSaveBandLayout" />
<DxButton Text="Layout zurücksetzen" Click="ResetLayoutAsync" />
</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>
<div class="grid-section">
<DxGrid Data="@items"
ColumnChooserButtonDisplayMode="GridColumnChooserButtonDisplayMode.Always"
TItem="CatalogReadDto"
KeyFieldName="@nameof(CatalogReadDto.Guid)"
SizeMode="@_sizeMode"
@@ -62,14 +88,6 @@ else
@ref="gridRef">
<ToolbarTemplate>
<DxToolbar>
<DxToolbarItem Alignment="ToolbarItemAlignment.Right">
<Template Context="_">
<DxButton Text="Spalten"
RenderStyle="ButtonRenderStyle.Secondary"
RenderStyleMode="ButtonRenderStyleMode.Text"
Click="@(() => gridRef!.ShowColumnChooser())" />
</Template>
</DxToolbarItem>
<DxToolbarItem Alignment="ToolbarItemAlignment.Right">
<Template Context="_">
<DxDropDownButton Text="@FormatSizeText(_sizeMode)"
@@ -130,21 +148,29 @@ else
private string? infoMessage;
private EditContext? editContext;
private ValidationMessageStore? validationMessageStore;
private IGrid? gridRef;
private int? focusedRowKey;
private string popupHeaderText = "Edit";
private const string LayoutType = "GRID_BANDS";
private const string LayoutKey = "CatalogsGrid";
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 bool gridLayoutApplied;
private bool bandEditorExpanded;
protected override string LayoutKey => "CatalogsGrid";
protected override List<ColumnDefinition> ColumnDefinitions { get; } = 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 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()
{
@@ -152,15 +178,44 @@ else
new() { Value = 1, Text = "PRTBMY_CATALOG_SAVE" }
};
private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser);
private SizeMode _sizeMode = SizeMode.Medium;
private static readonly List<SizeMode> _sizeModes = Enum.GetValues<SizeMode>().ToList();
private string FormatSizeText(SizeMode size) => size switch
{
SizeMode.Small => "Klein",
SizeMode.Medium => "Mittel",
SizeMode.Large => "Groß",
_ => size.ToString()
};
private void OnSizeChange(DropDownButtonItemClickEventArgs args)
{
_sizeMode = Enum.Parse<SizeMode>(args.ItemInfo.Id);
}
protected override async Task OnInitializedAsync()
{
await InitializeBandLayoutAsync();
columnLookup = columnDefinitions.ToDictionary(c => c.FieldName, StringComparer.OrdinalIgnoreCase);
layoutUser = await BandLayoutService.EnsureLayoutUserAsync();
bandLayout = await BandLayoutService.LoadBandLayoutAsync(LayoutType, LayoutKey, layoutUser, columnLookup);
columnBandAssignments = BandLayoutService.BuildAssignmentsFromLayout(bandLayout);
ApplyColumnLayoutFromStorage();
_sizeMode = bandLayout.SizeMode;
UpdateBandOptions();
await LoadCatalogs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await ApplyGridLayoutAfterRenderAsync();
if (!gridLayoutApplied && gridRef != null && bandLayout.GridLayout != null)
{
gridRef.LoadLayout(bandLayout.GridLayout);
gridLayoutApplied = true;
await InvokeAsync(StateHasChanged);
}
}
private async Task LoadCatalogs()
@@ -183,6 +238,174 @@ else
}
}
private async Task SaveLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
return;
try
{
CaptureColumnLayoutFromGrid();
await BandLayoutService.SaveBandLayoutAsync(LayoutType, LayoutKey, layoutUser, bandLayout);
infoMessage = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
private async Task ResetLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
return;
await BandLayoutService.ResetBandLayoutAsync(LayoutType, LayoutKey, layoutUser);
bandLayout = new BandLayout();
columnBandAssignments.Clear();
UpdateBandOptions();
foreach (var column in columnDefinitions)
column.Width = null;
columnLookup = columnDefinitions.ToDictionary(c => c.FieldName, StringComparer.OrdinalIgnoreCase);
_sizeMode = SizeMode.Medium;
if (gridRef != null)
gridRef.LoadLayout(new GridPersistentLayout());
gridLayoutApplied = false;
infoMessage = "Layout zurückgesetzt.";
errorMessage = null;
}
private void CaptureColumnLayoutFromGrid()
{
if (gridRef == null)
return;
var layout = gridRef.SaveLayout();
bandLayout.GridLayout = layout;
bandLayout.SizeMode = _sizeMode;
var orderedColumns = layout.Columns
.Where(c => !string.IsNullOrWhiteSpace(c.FieldName))
.OrderBy(c => c.VisibleIndex)
.ToList();
bandLayout.ColumnOrder = orderedColumns.Select(c => c.FieldName).ToList();
bandLayout.ColumnWidths = orderedColumns
.Where(c => !string.IsNullOrWhiteSpace(c.Width))
.ToDictionary(c => c.FieldName, c => c.Width, StringComparer.OrdinalIgnoreCase);
}
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(c => c.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);
foreach (var key in columnBandAssignments.Where(p => p.Value == band.Id).Select(p => p.Key).ToList())
columnBandAssignments.Remove(key);
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)
=> columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty;
private void SyncBandsFromAssignments()
{
foreach (var band in bandLayout.Bands)
{
band.Columns = columnDefinitions
.Where(c => columnBandAssignments.TryGetValue(c.FieldName, out var id) && id == band.Id)
.Select(c => c.FieldName)
.ToList();
}
StateHasChanged();
}
private void UpdateBandOptions()
{
bandOptions = new List<BandOption> { new() { Id = string.Empty, Caption = "Ohne Band" } };
bandOptions.AddRange(bandLayout.Bands.Select(b => new BandOption { Id = b.Id, Caption = b.Caption }));
}
private RenderFragment RenderColumns() => builder =>
{
var seq = 0;
builder.OpenComponent<DxGridCommandColumn>(seq++);
builder.AddAttribute(seq++, "Width", "120px");
builder.CloseComponent();
var grouped = bandLayout.Bands.SelectMany(b => b.Columns).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var column in columnDefinitions.Where(c => !grouped.Contains(c.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)
{
if (editContext == context) return;
@@ -352,25 +575,4 @@ else
public int Value { get; set; }
public string Text { get; set; } = string.Empty;
}
private async Task SaveLayoutWithFeedbackAsync()
{
try
{
await SaveLayoutAsync();
infoMessage = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
private async Task ResetLayoutWithFeedbackAsync()
{
await ResetLayoutAsync();
infoMessage = "Layout zurückgesetzt.";
errorMessage = null;
}
}

View File

@@ -31,6 +31,12 @@
<span class="bi bi-table-nav-menu" aria-hidden="true"></span> MassData
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="clock">
<span class="bi bi-clock-nav-menu" aria-hidden="true"></span> Clock
</NavLink>
</div>
</nav>
</div>

View File

@@ -1,5 +1,5 @@
@inherits BandGridBase<MassDataReadDto>
@inject MassDataApiClient Api
@inject BandLayoutService BandLayoutService
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
@@ -25,17 +25,44 @@ else if (items.Count == 0)
else
{
<BandEditor Bands="@bandLayout.Bands"
BandOptions="@bandOptions"
Columns="@ColumnDefinitions"
GetColumnBand="GetColumnBand"
CanSave="@CanSaveBandLayout"
OnAddBand="AddBand"
OnSaveLayout="SaveLayoutWithFeedbackAsync"
OnResetLayout="ResetLayoutWithFeedbackAsync"
OnRemoveBand="RemoveBand"
OnBandCaptionChanged="@(args => UpdateBandCaption(args.Band, args.Value))"
OnColumnBandChanged="@(args => UpdateColumnBand(args.FieldName, args.BandId))" />
<div class="band-editor">
<button class="band-editor-toggle" @onclick="() => bandEditorExpanded = !bandEditorExpanded">
<span class="band-editor-toggle-icon @(bandEditorExpanded ? "expanded" : "")">&#9658;</span>
<span>Layout</span>
</button>
@if (bandEditorExpanded)
{
<div class="band-editor-body">
<div class="band-controls">
<DxButton Text="Band hinzufügen" Click="AddBand" />
<DxButton Text="Layout speichern" Click="SaveLayoutAsync" Enabled="@CanSaveBandLayout" />
<DxButton Text="Layout zurücksetzen" Click="ResetLayoutAsync" />
</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>
<div class="mb-3 page-size-selector">
<span class="page-size-label">Datensätze je Seite:</span>
@@ -51,7 +78,6 @@ else
<div class="grid-section">
<DxGrid Data="@items"
ColumnChooserButtonDisplayMode="GridColumnChooserButtonDisplayMode.Always"
TItem="MassDataReadDto"
KeyFieldName="@nameof(MassDataReadDto.Id)"
SizeMode="@_sizeMode"
@@ -75,14 +101,6 @@ else
@ref="gridRef">
<ToolbarTemplate>
<DxToolbar>
<DxToolbarItem Alignment="ToolbarItemAlignment.Right">
<Template Context="_">
<DxButton Text="Spalten"
RenderStyle="ButtonRenderStyle.Secondary"
RenderStyleMode="ButtonRenderStyleMode.Text"
Click="@(() => gridRef!.ShowColumnChooser())" />
</Template>
</DxToolbarItem>
<DxToolbarItem Alignment="ToolbarItemAlignment.Right">
<Template Context="_">
<DxDropDownButton Text="@FormatSizeText(_sizeMode)"
@@ -160,20 +178,28 @@ else
private string popupHeaderText = "Edit";
private EditContext? editContext;
private ValidationMessageStore? validationMessageStore;
private IGrid? gridRef;
private int? focusedRowKey;
private const string LayoutType = "GRID_BANDS";
private const string LayoutKey = "MassDataGrid";
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 bool gridLayoutApplied;
private bool bandEditorExpanded;
protected override string LayoutKey => "MassDataGrid";
protected override List<ColumnDefinition> ColumnDefinitions { get; } = 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 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 readonly List<PageSizeOption> pageSizeOptions = new()
{
@@ -189,15 +215,44 @@ else
new() { Value = 0, Text = "PRMassdata_UpsertByCustomerName" }
};
private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser);
private SizeMode _sizeMode = SizeMode.Medium;
private static readonly List<SizeMode> _sizeModes = Enum.GetValues<SizeMode>().ToList();
private string FormatSizeText(SizeMode size) => size switch
{
SizeMode.Small => "Klein",
SizeMode.Medium => "Mittel",
SizeMode.Large => "Groß",
_ => size.ToString()
};
private void OnSizeChange(DropDownButtonItemClickEventArgs args)
{
_sizeMode = Enum.Parse<SizeMode>(args.ItemInfo.Id);
}
protected override async Task OnInitializedAsync()
{
await InitializeBandLayoutAsync();
columnLookup = columnDefinitions.ToDictionary(c => c.FieldName, StringComparer.OrdinalIgnoreCase);
layoutUser = await BandLayoutService.EnsureLayoutUserAsync();
bandLayout = await BandLayoutService.LoadBandLayoutAsync(LayoutType, LayoutKey, layoutUser, columnLookup);
columnBandAssignments = BandLayoutService.BuildAssignmentsFromLayout(bandLayout);
ApplyColumnLayoutFromStorage();
_sizeMode = bandLayout.SizeMode;
UpdateBandOptions();
await LoadPage(0);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await ApplyGridLayoutAfterRenderAsync();
if (!gridLayoutApplied && gridRef != null && bandLayout.GridLayout != null)
{
gridRef.LoadLayout(bandLayout.GridLayout);
gridLayoutApplied = true;
await InvokeAsync(StateHasChanged);
}
}
private async Task LoadPage(int page)
@@ -233,6 +288,160 @@ else
await LoadPage(0);
}
private async Task SaveLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
return;
try
{
CaptureColumnLayoutFromGrid();
await BandLayoutService.SaveBandLayoutAsync(LayoutType, LayoutKey, layoutUser, bandLayout);
infoMessage = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
private async Task ResetLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
return;
await BandLayoutService.ResetBandLayoutAsync(LayoutType, LayoutKey, layoutUser);
bandLayout = new BandLayout();
columnBandAssignments.Clear();
UpdateBandOptions();
foreach (var column in columnDefinitions)
column.Width = null;
columnLookup = columnDefinitions.ToDictionary(c => c.FieldName, StringComparer.OrdinalIgnoreCase);
_sizeMode = SizeMode.Medium;
if (gridRef != null)
gridRef.LoadLayout(new GridPersistentLayout());
gridLayoutApplied = false;
infoMessage = "Layout zurückgesetzt.";
errorMessage = null;
}
private void CaptureColumnLayoutFromGrid()
{
if (gridRef == null) return;
var layout = gridRef.SaveLayout();
bandLayout.GridLayout = layout;
bandLayout.SizeMode = _sizeMode;
var orderedColumns = layout.Columns
.Where(c => !string.IsNullOrWhiteSpace(c.FieldName))
.OrderBy(c => c.VisibleIndex)
.ToList();
bandLayout.ColumnOrder = orderedColumns.Select(c => c.FieldName).ToList();
bandLayout.ColumnWidths = orderedColumns
.Where(c => !string.IsNullOrWhiteSpace(c.Width))
.ToDictionary(c => c.FieldName, c => c.Width, StringComparer.OrdinalIgnoreCase);
}
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(c => c.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);
foreach (var key in columnBandAssignments.Where(p => p.Value == band.Id).Select(p => p.Key).ToList())
columnBandAssignments.Remove(key);
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)
=> columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty;
private void SyncBandsFromAssignments()
{
foreach (var band in bandLayout.Bands)
{
band.Columns = columnDefinitions
.Where(c => columnBandAssignments.TryGetValue(c.FieldName, out var id) && id == band.Id)
.Select(c => c.FieldName)
.ToList();
}
StateHasChanged();
}
private void UpdateBandOptions()
{
bandOptions = new List<BandOption> { new() { Id = string.Empty, Caption = "Ohne Band" } };
bandOptions.AddRange(bandLayout.Bands.Select(b => new BandOption { Id = b.Id, Caption = b.Caption }));
}
private RenderFragment RenderColumns() => builder =>
{
var seq = 0;
builder.OpenComponent<DxGridCommandColumn>(seq++);
builder.AddAttribute(seq++, "Width", "120px");
builder.CloseComponent();
var grouped = bandLayout.Bands.SelectMany(b => b.Columns).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var column in columnDefinitions.Where(c => !grouped.Contains(c.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)
{
if (editContext == context) return;
@@ -314,17 +523,18 @@ else
Category = editModel.Category,
StatusFlag = editModel.StatusFlag
};
var result = await Api.UpsertAsync(dto);
if (!result.Success)
try
{
errorMessage = result.Error ?? "Speichern fehlgeschlagen.";
e.Cancel = true;
return;
var saved = await Api.UpsertAsync(dto);
infoMessage = editModel.IsNew ? "MassData angelegt." : "MassData aktualisiert.";
focusedRowKey = saved.Id;
await LoadPage(pageIndex);
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Speichern: {ex.Message}";
e.Cancel = true;
}
infoMessage = editModel.IsNew ? "MassData angelegt." : "MassData aktualisiert.";
focusedRowKey = result.Value?.Id;
await LoadPage(pageIndex);
}
private void AddValidationError(MassDataEditModel editModel, string fieldName, string message)
@@ -365,25 +575,4 @@ else
public int? Value { get; set; }
public string Text { get; set; } = string.Empty;
}
private async Task SaveLayoutWithFeedbackAsync()
{
try
{
await SaveLayoutAsync();
infoMessage = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
private async Task ResetLayoutWithFeedbackAsync()
{
await ResetLayoutAsync();
infoMessage = "Layout zurückgesetzt.";
errorMessage = null;
}
}

View File

@@ -0,0 +1,100 @@
@rendermode InteractiveServer
@page "/clock"
@inject TimeApiClient TimeApi
@implements IAsyncDisposable
<PageTitle>Clock</PageTitle>
<h3>DB Server Clock</h3>
<div class="clock-wrapper">
<div class="clock-display @(_error != null ? "clock-error" : "")">
@if (_dbTime.HasValue)
{
<span class="clock-time">@_dbTime.Value.ToString("HH:mm:ss")</span>
<span class="clock-date">@_dbTime.Value.ToString("dd.MM.yyyy")</span>
}
else if (_error != null)
{
<span class="clock-time">--:--:--</span>
<span class="clock-date text-danger">@_error</span>
}
else
{
<span class="clock-time">...</span>
}
</div>
</div>
<style>
.clock-wrapper {
display: flex;
justify-content: center;
align-items: center;
height: 40vh;
}
.clock-display {
display: flex;
flex-direction: column;
align-items: center;
background: var(--bs-body-bg, #1e1e2e);
border: 2px solid var(--bs-border-color, #444);
border-radius: 1rem;
padding: 2rem 4rem;
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
}
.clock-time {
font-size: 5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.1em;
color: var(--bs-primary, #0d6efd);
}
.clock-date {
font-size: 1.4rem;
margin-top: 0.5rem;
opacity: 0.75;
}
.clock-error .clock-time {
color: var(--bs-danger, #dc3545);
}
</style>
@code {
private DateTime? _dbTime;
private string? _error;
private Timer? _timer;
protected override async Task OnInitializedAsync()
{
await TickAsync();
_timer = new Timer(async _ =>
{
await TickAsync();
await InvokeAsync(StateHasChanged);
}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
private async Task TickAsync()
{
try
{
_dbTime = await TimeApi.InsertAndGetLastAsync();
_error = null;
}
catch (Exception ex)
{
_error = ex.Message;
}
}
public async ValueTask DisposeAsync()
{
if (_timer != null)
await _timer.DisposeAsync();
}
}

View File

@@ -67,6 +67,11 @@
protected override async Task OnParametersSetAsync()
{
if (dashboards.Count == 0)
{
await RefreshDashboards();
}
var requestedId = string.IsNullOrWhiteSpace(DashboardId) || string.Equals(DashboardId, "default", StringComparison.OrdinalIgnoreCase)
? null
: DashboardId;

View File

@@ -31,8 +31,8 @@
public class CarouselData
{
public string Source { get; set; } = string.Empty;
public string AlternateText { get; set; } = string.Empty;
public string Source { get; set; }
public string AlternateText { get; set; }
public CarouselData(string source, string alt)
{

View File

@@ -31,6 +31,10 @@ if (!string.IsNullOrWhiteSpace(apiBaseUrl))
{
client.BaseAddress = new Uri(apiBaseUrl);
});
builder.Services.AddHttpClient<TimeApiClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
});
}
else
{
@@ -38,6 +42,7 @@ else
builder.Services.AddHttpClient<DashboardApiClient>();
builder.Services.AddHttpClient<MassDataApiClient>();
builder.Services.AddHttpClient<LayoutApiClient>();
builder.Services.AddHttpClient<TimeApiClient>();
}
var app = builder.Build();

View File

@@ -1,60 +0,0 @@
using System.Net;
using System.Net.Http.Json;
namespace DbFirst.BlazorWebApp.Services;
internal sealed class ProblemDetailsDto
{
public string? Type { get; set; }
public string? Title { get; set; }
public string? Detail { get; set; }
}
internal static class ApiClientHelper
{
public static async Task<string> ReadErrorAsync(HttpResponseMessage response)
{
string? problemTitle = null;
string? problemDetail = null;
try
{
var problem = await response.Content.ReadFromJsonAsync<ProblemDetailsDto>();
if (problem != null)
{
problemTitle = problem.Title;
problemDetail = problem.Detail ?? problem.Type;
}
}
catch { }
var status = response.StatusCode;
var reason = response.ReasonPhrase;
var body = await response.Content.ReadAsStringAsync();
string? detail = problemDetail;
if (string.IsNullOrWhiteSpace(detail) && !string.IsNullOrWhiteSpace(body))
detail = body;
return status switch
{
HttpStatusCode.BadRequest => $"Eingabe ungültig{FormatSuffix(problemTitle, detail, reason)}",
HttpStatusCode.NotFound => $"Nicht gefunden{FormatSuffix(problemTitle, detail, reason)}",
HttpStatusCode.Conflict => $"Konflikt{FormatSuffix(problemTitle, detail, reason)}",
HttpStatusCode.Unauthorized => $"Nicht autorisiert{FormatSuffix(problemTitle, detail, reason)}",
HttpStatusCode.Forbidden => $"Nicht erlaubt{FormatSuffix(problemTitle, detail, reason)}",
HttpStatusCode.InternalServerError => $"Serverfehler{FormatSuffix(problemTitle, detail, reason)}",
_ => $"Fehler {(int)status} {reason ?? string.Empty}{FormatSuffix(problemTitle, detail, reason)}"
};
}
private static string FormatSuffix(string? title, string? detail, string? reason)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(title)) parts.Add(title);
if (!string.IsNullOrWhiteSpace(detail)) parts.Add(detail);
if (parts.Count == 0 && !string.IsNullOrWhiteSpace(reason)) parts.Add(reason);
if (parts.Count == 0) return string.Empty;
return ": " + string.Join(" | ", parts);
}
}

View File

@@ -34,7 +34,7 @@ public class CatalogApiClient
return ApiResult<CatalogReadDto?>.Ok(payload);
}
var error = await ApiClientHelper.ReadErrorAsync(response);
var error = await ReadErrorAsync(response);
return ApiResult<CatalogReadDto?>.Fail(error);
}
@@ -46,7 +46,7 @@ public class CatalogApiClient
return ApiResult<bool>.Ok(true);
}
var error = await ApiClientHelper.ReadErrorAsync(response);
var error = await ReadErrorAsync(response);
return ApiResult<bool>.Fail(error);
}
@@ -58,9 +58,68 @@ public class CatalogApiClient
return ApiResult<bool>.Ok(true);
}
var error = await ApiClientHelper.ReadErrorAsync(response);
var error = await ReadErrorAsync(response);
return ApiResult<bool>.Fail(error);
}
private static async Task<string> ReadErrorAsync(HttpResponseMessage response)
{
string? problemTitle = null;
string? problemDetail = null;
try
{
var problem = await response.Content.ReadFromJsonAsync<ProblemDetailsDto>();
if (problem != null)
{
problemTitle = problem.Title;
problemDetail = problem.Detail ?? problem.Type;
}
}
catch
{
}
var status = response.StatusCode;
var reason = response.ReasonPhrase;
var body = await response.Content.ReadAsStringAsync();
string? detail = problemDetail;
if (string.IsNullOrWhiteSpace(detail) && !string.IsNullOrWhiteSpace(body))
{
detail = body;
}
if (status == HttpStatusCode.Conflict)
{
return "Datensatz existiert bereits. Bitte wählen Sie einen anderen Titel.";
}
if (status == HttpStatusCode.BadRequest && (detail?.Contains("CatTitle cannot be changed", StringComparison.OrdinalIgnoreCase) ?? false))
{
return "Titel kann nicht geändert werden.";
}
return status switch
{
HttpStatusCode.BadRequest => $"Eingabe ungültig{FormatSuffix(problemTitle, detail, reason)}",
HttpStatusCode.NotFound => $"Nicht gefunden{FormatSuffix(problemTitle, detail, reason)}",
HttpStatusCode.Conflict => $"Konflikt{FormatSuffix(problemTitle, detail, reason)}",
HttpStatusCode.Unauthorized => $"Nicht autorisiert{FormatSuffix(problemTitle, detail, reason)}",
HttpStatusCode.Forbidden => $"Nicht erlaubt{FormatSuffix(problemTitle, detail, reason)}",
HttpStatusCode.InternalServerError => $"Serverfehler{FormatSuffix(problemTitle, detail, reason)}",
_ => $"Fehler {(int)status} {reason ?? string.Empty}{FormatSuffix(problemTitle, detail, reason)}"
};
}
private static string FormatSuffix(string? title, string? detail, string? reason)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(title)) parts.Add(title);
if (!string.IsNullOrWhiteSpace(detail)) parts.Add(detail);
if (parts.Count == 0 && !string.IsNullOrWhiteSpace(reason)) parts.Add(reason);
if (parts.Count == 0) return string.Empty;
return ": " + string.Join(" | ", parts);
}
}
public record ApiResult<T>(bool Success, T? Value, string? Error)
@@ -69,3 +128,9 @@ public record ApiResult<T>(bool Success, T? Value, string? Error)
public static ApiResult<T> Fail(string? error) => new(false, default, error);
}
internal sealed class ProblemDetailsDto
{
public string? Type { get; set; }
public string? Title { get; set; }
public string? Detail { get; set; }
}

View File

@@ -36,17 +36,12 @@ public class MassDataApiClient
return result ?? new List<MassDataReadDto>();
}
public async Task<ApiResult<MassDataReadDto?>> UpsertAsync(MassDataWriteDto dto)
public async Task<MassDataReadDto> UpsertAsync(MassDataWriteDto dto)
{
var response = await _httpClient.PostAsJsonAsync($"{Endpoint}/upsert", dto);
if (response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadFromJsonAsync<MassDataReadDto>();
return ApiResult<MassDataReadDto?>.Ok(payload);
}
var error = await ApiClientHelper.ReadErrorAsync(response);
return ApiResult<MassDataReadDto?>.Fail(error);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<MassDataReadDto>();
return payload ?? new MassDataReadDto();
}
public async Task<MassDataReadDto?> GetByCustomerNameAsync(string customerName)

View File

@@ -0,0 +1,27 @@
using System.Net.Http.Json;
namespace DbFirst.BlazorWebApp.Services;
public class TimeApiClient
{
private readonly HttpClient _httpClient;
private const string Endpoint = "api/time";
public TimeApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<DateTime?> InsertAndGetLastAsync()
{
var response = await _httpClient.PostAsync(Endpoint, null);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<TimeResponse>();
return result?.Now;
}
private sealed class TimeResponse
{
public DateTime? Now { get; set; }
}
}

View File

@@ -0,0 +1,6 @@
namespace DbFirst.Domain.Entities;
public class TimeRecord
{
public DateTime? Now { get; set; }
}

View File

@@ -16,6 +16,7 @@ public partial class ApplicationDbContext : DbContext
public virtual DbSet<VwmyCatalog> VwmyCatalogs { get; set; }
public virtual DbSet<SmfLayout> SmfLayouts { get; set; }
public virtual DbSet<TimeRecord> Times { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -83,6 +84,16 @@ public partial class ApplicationDbContext : DbContext
.HasColumnName("CHANGED_WHEN");
});
modelBuilder.Entity<TimeRecord>(entity =>
{
entity.HasNoKey();
entity.ToTable("TIME");
entity.Property(e => e.Now)
.HasColumnType("datetime")
.HasColumnName("NOW");
});
OnModelCreatingPartial(modelBuilder);
}

View File

@@ -0,0 +1,28 @@
using DbFirst.Application.Repositories;
using DbFirst.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace DbFirst.Infrastructure.Repositories;
public class TimeRepository : ITimeRepository
{
private readonly ApplicationDbContext _db;
public TimeRepository(ApplicationDbContext db)
{
_db = db;
}
public async Task InsertAsync(CancellationToken cancellationToken = default)
{
await _db.Database.ExecuteSqlRawAsync("INSERT INTO [TIME] (NOW) VALUES (GETDATE())", cancellationToken);
}
public async Task<TimeRecord?> GetLastAsync(CancellationToken cancellationToken = default)
{
return await _db.Times
.AsNoTracking()
.OrderByDescending(t => t.Now)
.FirstOrDefaultAsync(cancellationToken);
}
}

File diff suppressed because it is too large Load Diff