Compare commits

..

11 Commits

Author SHA1 Message Date
OlgunR
9d7b3591cc Update grid column captions to German
Changed "Added" to "Angelegt am" and "Changed" to "Geändert am" in the data grid column headers for improved localization. No other column settings were modified.
2026-02-05 14:17:29 +01:00
OlgunR
006ee78422 Refactor MassDataGrid editing and validation logic
- Switch to custom edit model (MassDataEditModel) for popup editing, enabling granular field validation and UI control
- Replace default editors with explicit DxTextBox/DxCheckBox bindings
- Add AmountText field for string input and validation; validate and convert in OnEditModelSaving
- Implement duplicate customer check via new GetByCustomerNameAsync API method
- Show ValidationSummary in popup; manage validation state with ValidationMessageStore and EditContext
- Make popup header and width dynamic; show procedure ComboBox only for existing records
- Restore "New" button in grid command column
- Refactor CatalogsGrid to handle validation clearing in OnEditFieldChanged instead of OnTitleChanged
- General improvements to real-time validation feedback
2026-02-05 14:11:27 +01:00
OlgunR
a52d615750 Update grid popup headers and edit form logic
CatalogsGrid now sets popup edit form header dynamically ("Neu" for new, "Edit" for existing) and only shows the "Update-Prozedur" field when editing. Added IsNew property to CatalogEditModel. MassDataGrid sets popup header to "Bearbeiten". Also standardized DateChanged event handler style in both components.
2026-02-05 12:52:44 +01:00
OlgunR
9bbe34dece Enhance grid filter UI with custom editors for each type
Replaced default filter row editors with custom templates in CatalogsGrid and MassDataGrid. Added DxTextBox for text fields, DxDateEdit for date fields, and a DxComboBox for boolean status filtering. Introduced BoolFilterOption class to support the status dropdown. These changes improve filter usability and data type handling.
2026-02-05 11:37:06 +01:00
OlgunR
05ea47f42c Improve title field validation feedback in catalog edit form
Title field now validates on input, not just on blur. Added OnTitleChanged handler to clear validation messages for CatTitle as the user types. OnFieldChanged now only clears messages for UpdateProcedure. This enhances real-time validation feedback for users.
2026-02-05 11:21:49 +01:00
OlgunR
945c8aaf4a Refactor grids to use DxGrid popup editing
Modernize CatalogsGrid.razor and MassDataGrid.razor to use DevExpress DxGrid's built-in popup editing with EditFormTemplate. Remove custom EditForm panels and manual editing state logic. Move CRUD operations and validation to grid event handlers. Add field-level validation and error display for catalogs. Update grid columns, add command columns, and set audit fields to read-only. Only editing is allowed in MassDataGrid; deletion is disabled. Streamline code and UI for improved maintainability and user experience.
2026-02-05 10:43:40 +01:00
OlgunR
4ef80ce875 Improve MassDataGrid UI: custom sort icons & filter inputs
Replaced default sort icons with custom SVGs for clarity.
Updated filter row to use styled DxTextBox inputs with search icons.
Ensured consistent and intuitive filtering experience across columns.
2026-02-05 08:54:21 +01:00
OlgunR
88c34ef94b Add MassData feature with API, paging, and Blazor grid
Introduces MassData management to backend and Blazor frontend:
- Adds API endpoint for MassData count and paging
- Updates repository and controller for count support
- Implements MediatR query/handler for count
- Adds Blazor page and grid for viewing/editing MassData
- Registers MassDataApiClient and integrates with DI
- Supports paging, upsert, and UI feedback in grid
2026-02-04 13:00:45 +01:00
OlgunR
85b9b0b51a Add MassData API with CQRS, repository, and DbContext
Introduce MassData feature with new API endpoints for querying and upserting records by customer name. Add DTOs, AutoMapper profile, MediatR CQRS handlers, repository pattern, and MassDataDbContext. Register new services in DI and add MassDataConnection to configuration. Upsert uses stored procedure. Enables full CRUD for Massdata via dedicated API.
2026-02-04 11:39:58 +01:00
OlgunR
013088a25f Add real-time dashboard updates with SignalR
Integrate SignalR to provide real-time dashboard update notifications.
- Added DashboardsHub and DashboardChangeNotifier on the backend.
- Modified SqlDashboardStorage to trigger notifications on changes.
- Registered SignalR services and mapped the hub endpoint.
- Updated Blazor clients to connect to the hub and refresh dashboards on change.
- Added SignalR client packages and necessary DI/configuration.
2026-02-04 09:01:28 +01:00
OlgunR
dbe1d9d206 Add mode param to dashboard links for designer/viewer mode
Dashboard navigation links now include a mode query parameter, set to "designer" or "viewer" based on the IsDesigner flag. This enables the app to distinguish between designer and viewer modes when navigating to dashboards.
2026-02-04 08:45:27 +01:00
45 changed files with 1924 additions and 207 deletions

View File

@@ -0,0 +1,53 @@
using DbFirst.Application.MassData;
using DbFirst.Application.MassData.Commands;
using DbFirst.Application.MassData.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DbFirst.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class MassDataController : ControllerBase
{
private readonly IMediator _mediator;
public MassDataController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("count")]
public async Task<ActionResult<int>> GetCount(CancellationToken cancellationToken)
{
var count = await _mediator.Send(new GetMassDataCountQuery(), cancellationToken);
return Ok(count);
}
[HttpGet]
public async Task<ActionResult<IEnumerable<MassDataReadDto>>> GetAll([FromQuery] int? skip, [FromQuery] int? take, CancellationToken cancellationToken)
{
var resolvedTake = take is null or <= 0 ? 200 : take;
var result = await _mediator.Send(new GetAllMassDataQuery(skip, resolvedTake), cancellationToken);
return Ok(result);
}
[HttpGet("{customerName}")]
public async Task<ActionResult<MassDataReadDto>> GetByCustomerName(string customerName, CancellationToken cancellationToken)
{
var result = await _mediator.Send(new GetMassDataByCustomerNameQuery(customerName), cancellationToken);
if (result == null)
{
return NotFound();
}
return Ok(result);
}
[HttpPost("upsert")]
public async Task<ActionResult<MassDataReadDto>> Upsert(MassDataWriteDto dto, CancellationToken cancellationToken)
{
var result = await _mediator.Send(new UpsertMassDataByCustomerNameCommand(dto), cancellationToken);
return Ok(result);
}
}

View File

@@ -0,0 +1,19 @@
using DbFirst.API.Hubs;
using Microsoft.AspNetCore.SignalR;
namespace DbFirst.API.Dashboards;
public class DashboardChangeNotifier : IDashboardChangeNotifier
{
private readonly IHubContext<DashboardsHub> _hubContext;
public DashboardChangeNotifier(IHubContext<DashboardsHub> hubContext)
{
_hubContext = hubContext;
}
public void NotifyChanged()
{
_ = _hubContext.Clients.All.SendAsync("DashboardsChanged");
}
}

View File

@@ -0,0 +1,6 @@
namespace DbFirst.API.Dashboards;
public interface IDashboardChangeNotifier
{
void NotifyChanged();
}

View File

@@ -11,12 +11,14 @@ public sealed class SqlDashboardStorage : IEditableDashboardStorage
private readonly string _connectionString;
private readonly string _tableName;
private readonly Func<string?>? _userProvider;
private readonly IDashboardChangeNotifier? _notifier;
public SqlDashboardStorage(string connectionString, string tableName = "TBDD_SMF_CONFIG", Func<string?>? userProvider = null)
public SqlDashboardStorage(string connectionString, string tableName = "TBDD_SMF_CONFIG", Func<string?>? userProvider = null, IDashboardChangeNotifier? notifier = null)
{
_connectionString = connectionString;
_tableName = tableName;
_userProvider = userProvider;
_notifier = notifier;
}
public IEnumerable<DashboardInfo> GetAvailableDashboardsInfo()
@@ -98,6 +100,7 @@ public sealed class SqlDashboardStorage : IEditableDashboardStorage
connection.Open();
command.ExecuteNonQuery();
_notifier?.NotifyChanged();
return id;
}
@@ -118,6 +121,8 @@ public sealed class SqlDashboardStorage : IEditableDashboardStorage
{
throw new ArgumentException($"Dashboard '{dashboardId}' not found.");
}
_notifier?.NotifyChanged();
}
public void DeleteDashboard(string dashboardId)
@@ -128,5 +133,6 @@ public sealed class SqlDashboardStorage : IEditableDashboardStorage
connection.Open();
command.ExecuteNonQuery();
_notifier?.NotifyChanged();
}
}

View File

@@ -0,0 +1,7 @@
using Microsoft.AspNetCore.SignalR;
namespace DbFirst.API.Hubs;
public class DashboardsHub : Hub
{
}

View File

@@ -1,5 +1,6 @@
using DbFirst.API.Middleware;
using DbFirst.API.Dashboards;
using DbFirst.API.Hubs;
using DbFirst.Application;
using DbFirst.Application.Repositories;
using DbFirst.Domain;
@@ -51,8 +52,11 @@ builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication();
builder.Services.AddScoped<ICatalogRepository, CatalogRepository>();
builder.Services.AddScoped<IMassDataRepository, MassDataRepository>();
builder.Services.AddDevExpressControls();
builder.Services.AddSignalR();
builder.Services.AddSingleton<IDashboardChangeNotifier, DashboardChangeNotifier>();
builder.Services.AddScoped<DashboardConfigurator>((IServiceProvider serviceProvider) => {
var dashboardsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "Dashboards");
Directory.CreateDirectory(dashboardsPath);
@@ -112,7 +116,8 @@ builder.Services.AddScoped<DashboardConfigurator>((IServiceProvider serviceProvi
DashboardConfigurator configurator = new DashboardConfigurator();
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? string.Empty;
var dashboardStorage = new SqlDashboardStorage(connectionString, "TBDD_SMF_CONFIG");
var notifier = serviceProvider.GetRequiredService<IDashboardChangeNotifier>();
var dashboardStorage = new SqlDashboardStorage(connectionString, "TBDD_SMF_CONFIG", notifier: notifier);
configurator.SetDashboardStorage(dashboardStorage);
DataSourceInMemoryStorage dataSourceStorage = new DataSourceInMemoryStorage();
@@ -155,7 +160,7 @@ app.UseCors();
app.UseAuthorization();
app.MapDashboardRoute("api/dashboard", "DefaultDashboard");
app.MapHub<DashboardsHub>("/hubs/dashboards");
app.MapControllers();
app.Run();

View File

@@ -1,6 +1,7 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;"
"DefaultConnection": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;",
"MassDataConnection": "Server=SDD-VMP04-SQL19\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;"
},
"Dashboard": {
"BaseUrl": "https://localhost:7204"

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace DbFirst.Application.MassData.Commands;
public record UpsertMassDataByCustomerNameCommand(MassDataWriteDto Dto) : IRequest<MassDataReadDto>;

View File

@@ -0,0 +1,24 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using MediatR;
namespace DbFirst.Application.MassData.Commands;
public class UpsertMassDataByCustomerNameHandler : IRequestHandler<UpsertMassDataByCustomerNameCommand, MassDataReadDto>
{
private readonly IMassDataRepository _repository;
private readonly IMapper _mapper;
public UpsertMassDataByCustomerNameHandler(IMassDataRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<MassDataReadDto> Handle(UpsertMassDataByCustomerNameCommand request, CancellationToken cancellationToken)
{
var dto = request.Dto;
var updated = await _repository.UpsertByCustomerNameAsync(dto.CustomerName, dto.Amount, dto.StatusFlag, dto.Category, cancellationToken);
return _mapper.Map<MassDataReadDto>(updated);
}
}

View File

@@ -0,0 +1,12 @@
using AutoMapper;
using DbFirst.Domain.Entities;
namespace DbFirst.Application.MassData;
public class MassDataProfile : Profile
{
public MassDataProfile()
{
CreateMap<Massdata, MassDataReadDto>();
}
}

View File

@@ -0,0 +1,12 @@
namespace DbFirst.Application.MassData;
public class MassDataReadDto
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Category { get; set; } = string.Empty;
public bool StatusFlag { get; set; }
public DateTime AddedWhen { get; set; }
public DateTime? ChangedWhen { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace DbFirst.Application.MassData;
public class MassDataWriteDto
{
public string CustomerName { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Category { get; set; } = string.Empty;
public bool StatusFlag { get; set; }
}

View File

@@ -0,0 +1,23 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using MediatR;
namespace DbFirst.Application.MassData.Queries;
public class GetAllMassDataHandler : IRequestHandler<GetAllMassDataQuery, List<MassDataReadDto>>
{
private readonly IMassDataRepository _repository;
private readonly IMapper _mapper;
public GetAllMassDataHandler(IMassDataRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<List<MassDataReadDto>> Handle(GetAllMassDataQuery request, CancellationToken cancellationToken)
{
var items = await _repository.GetAllAsync(request.Skip, request.Take, cancellationToken);
return _mapper.Map<List<MassDataReadDto>>(items);
}
}

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace DbFirst.Application.MassData.Queries;
public record GetAllMassDataQuery(int? Skip, int? Take) : IRequest<List<MassDataReadDto>>;

View File

@@ -0,0 +1,23 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using MediatR;
namespace DbFirst.Application.MassData.Queries;
public class GetMassDataByCustomerNameHandler : IRequestHandler<GetMassDataByCustomerNameQuery, MassDataReadDto?>
{
private readonly IMassDataRepository _repository;
private readonly IMapper _mapper;
public GetMassDataByCustomerNameHandler(IMassDataRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<MassDataReadDto?> Handle(GetMassDataByCustomerNameQuery request, CancellationToken cancellationToken)
{
var item = await _repository.GetByCustomerNameAsync(request.CustomerName, cancellationToken);
return item == null ? null : _mapper.Map<MassDataReadDto>(item);
}
}

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace DbFirst.Application.MassData.Queries;
public record GetMassDataByCustomerNameQuery(string CustomerName) : IRequest<MassDataReadDto?>;

View File

@@ -0,0 +1,19 @@
using DbFirst.Application.Repositories;
using MediatR;
namespace DbFirst.Application.MassData.Queries;
public class GetMassDataCountHandler : IRequestHandler<GetMassDataCountQuery, int>
{
private readonly IMassDataRepository _repository;
public GetMassDataCountHandler(IMassDataRepository repository)
{
_repository = repository;
}
public async Task<int> Handle(GetMassDataCountQuery request, CancellationToken cancellationToken)
{
return await _repository.GetCountAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace DbFirst.Application.MassData.Queries;
public record GetMassDataCountQuery : IRequest<int>;

View File

@@ -0,0 +1,11 @@
using DbFirst.Domain.Entities;
namespace DbFirst.Application.Repositories;
public interface IMassDataRepository
{
Task<int> GetCountAsync(CancellationToken cancellationToken = default);
Task<List<Massdata>> GetAllAsync(int? skip = null, int? take = null, CancellationToken cancellationToken = default);
Task<Massdata?> GetByCustomerNameAsync(string customerName, CancellationToken cancellationToken = default);
Task<Massdata> UpsertByCustomerNameAsync(string customerName, decimal amount, bool statusFlag, string category, CancellationToken cancellationToken = default);
}

View File

@@ -1,3 +1,4 @@
@using Microsoft.AspNetCore.Components.Forms
@inject CatalogApiClient Api
<style>
@@ -44,6 +45,9 @@
background-position: right 0.5rem center;
background-size: 0.9rem;
}
.catalog-edit-popup {
min-width: 720px;
}
</style>
@if (!string.IsNullOrWhiteSpace(errorMessage))
@@ -55,41 +59,6 @@ else if (!string.IsNullOrWhiteSpace(infoMessage))
<div class="alert alert-success" role="alert">@infoMessage</div>
}
<div class="mb-3">
<DxButton RenderStyle="ButtonRenderStyle.Primary" Click="@StartCreate">Neuen Eintrag anlegen</DxButton>
</div>
@if (showForm)
{
<div class="action-panel">
<EditForm Model="formModel" OnValidSubmit="HandleSubmit" Context="editCtx">
<DxFormLayout ColCount="2">
<DxFormLayoutItem Caption="Titel" Context="itemCtx">
<DxTextBox @bind-Text="formModel.CatTitle" Enabled="@(isEditing ? formModel.UpdateProcedure != 0 : true)" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Kennung" Context="itemCtx">
<DxTextBox @bind-Text="formModel.CatString" />
</DxFormLayoutItem>
@if (isEditing)
{
<DxFormLayoutItem Caption="Update-Prozedur" Context="itemCtx">
<DxComboBox Data="@procedureOptions"
TextFieldName="Text"
ValueFieldName="Value"
@bind-Value="formModel.UpdateProcedure" />
</DxFormLayoutItem>
}
<DxFormLayoutItem Caption=" " Context="itemCtx">
<DxStack Orientation="Orientation.Horizontal" Spacing="8">
<DxButton RenderStyle="ButtonRenderStyle.Success" ButtonType="ButtonType.Submit" SubmitFormOnClick="true" Context="btnCtx">@((isEditing ? "Speichern" : "Anlegen"))</DxButton>
<DxButton RenderStyle="ButtonRenderStyle.Secondary" Click="@CancelEdit" Context="btnCtx">Abbrechen</DxButton>
</DxStack>
</DxFormLayoutItem>
</DxFormLayout>
</EditForm>
</div>
}
@if (isLoading)
{
<p><em>Lade Daten...</em></p>
@@ -101,8 +70,20 @@ else if (items.Count == 0)
else
{
<div class="grid-section">
<DxGrid Data="@items" TItem="CatalogReadDto" KeyFieldName="@nameof(CatalogReadDto.Guid)" ShowFilterRow="true" PageSize="10" CssClass="mb-4 catalog-grid">
<DxGrid Data="@items"
TItem="CatalogReadDto"
KeyFieldName="@nameof(CatalogReadDto.Guid)"
ShowFilterRow="true"
PageSize="10"
CssClass="mb-4 catalog-grid"
EditMode="GridEditMode.PopupEditForm"
PopupEditFormCssClass="catalog-edit-popup"
PopupEditFormHeaderText="@popupHeaderText"
CustomizeEditModel="OnCustomizeEditModel"
EditModelSaving="OnEditModelSaving"
DataItemDeleting="OnDataItemDeleting">
<Columns>
<DxGridCommandColumn Width="120px" />
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.Guid)" Caption="Id" Width="140px" SortIndex="0" SortOrder="GridColumnSortOrder.Ascending">
<FilterRowCellTemplate Context="filter">
<DxTextBox Text="@(filter.FilterRowValue?.ToString())"
@@ -124,45 +105,73 @@ else
CssClass="filter-search-input" />
</FilterRowCellTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.AddedWho)" Caption="Angelegt von">
<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" />
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.ChangedWho)" Caption="Geändert von">
<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" />
<DxGridDataColumn Caption="" Width="220px" AllowSort="false">
<CellDisplayTemplate Context="cell">
@{ var item = (CatalogReadDto)cell.DataItem; }
<div style="white-space: nowrap;">
<DxButton RenderStyle="ButtonRenderStyle.Secondary" Size="ButtonSize.Small" Click="@(() => StartEdit(item))">Bearbeiten</DxButton>
<DxButton RenderStyle="ButtonRenderStyle.Danger" Size="ButtonSize.Small" Click="@(() => DeleteCatalog(item.Guid))">Löschen</DxButton>
</div>
</CellDisplayTemplate>
<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>
<EditFormTemplate Context="editFormContext">
@{ SetEditContext(editFormContext.EditContext); var editModel = (CatalogEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); }
<DxFormLayout ColCount="2">
<DxFormLayoutItem Caption="Titel">
<DxTextBox @bind-Text="editModel.CatTitle" Width="100%" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Kennung">
<DxTextBox @bind-Text="editModel.CatString" Width="100%" />
</DxFormLayoutItem>
@if (!editModel.IsNew)
{
<DxFormLayoutItem Caption="Update-Prozedur">
<DxComboBox Data="@procedureOptions"
TData="ProcedureOption"
TValue="int"
TextFieldName="Text"
ValueFieldName="Value"
@bind-Value="editModel.UpdateProcedure"
Width="100%" />
</DxFormLayoutItem>
}
<DxFormLayoutItem ColSpanMd="12">
<ValidationSummary />
</DxFormLayoutItem>
</DxFormLayout>
</EditFormTemplate>
</DxGrid>
</div>
}
@code {
private List<CatalogReadDto> items = new();
private CatalogWriteDto formModel = new();
private int editingId;
private bool isLoading;
private bool isEditing;
private bool showForm;
private string? errorMessage;
private string? infoMessage;
private EditContext? editContext;
private ValidationMessageStore? validationMessageStore;
private string popupHeaderText = "Edit";
private readonly List<ProcedureOption> procedureOptions = new()
{
@@ -175,6 +184,66 @@ else
await LoadCatalogs();
}
private void SetEditContext(EditContext context)
{
if (editContext == context)
{
return;
}
if (editContext != null)
{
editContext.OnFieldChanged -= OnEditFieldChanged;
}
editContext = context;
validationMessageStore = new ValidationMessageStore(editContext);
editContext.OnFieldChanged += OnEditFieldChanged;
}
private void OnEditFieldChanged(object? sender, FieldChangedEventArgs e)
{
if (validationMessageStore == null || editContext == null)
{
return;
}
if (e.FieldIdentifier.FieldName == nameof(CatalogEditModel.UpdateProcedure))
{
validationMessageStore.Clear();
editContext.NotifyValidationStateChanged();
return;
}
if (e.FieldIdentifier.FieldName == nameof(CatalogEditModel.CatTitle))
{
var field = new FieldIdentifier(editContext.Model, nameof(CatalogEditModel.CatTitle));
validationMessageStore.Clear(field);
editContext.NotifyValidationStateChanged();
}
}
private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
{
popupHeaderText = e.IsNew ? "Neu" : "Edit";
if (e.IsNew)
{
e.EditModel = new CatalogEditModel { IsNew = true };
return;
}
var item = (CatalogReadDto)e.DataItem;
e.EditModel = new CatalogEditModel
{
Guid = item.Guid,
CatTitle = item.CatTitle,
CatString = item.CatString,
UpdateProcedure = 0,
OriginalCatTitle = item.CatTitle,
IsNew = false
};
}
private async Task LoadCatalogs()
{
isLoading = true;
@@ -194,88 +263,115 @@ else
}
}
private void StartCreate()
private async Task OnEditModelSaving(GridEditModelSavingEventArgs e)
{
formModel = new CatalogWriteDto();
editingId = 0;
isEditing = false;
showForm = true;
infoMessage = null;
errorMessage = null;
}
infoMessage = null;
private void StartEdit(CatalogReadDto item)
{
formModel = new CatalogWriteDto
validationMessageStore?.Clear();
editContext?.NotifyValidationStateChanged();
var editModel = (CatalogEditModel)e.EditModel;
if (!ValidateEditModel(editModel, e.IsNew))
{
CatTitle = item.CatTitle,
CatString = item.CatString,
UpdateProcedure = 0
};
editingId = item.Guid;
isEditing = true;
showForm = true;
infoMessage = null;
errorMessage = null;
}
e.Cancel = true;
return;
}
private async Task HandleSubmit()
{
errorMessage = null;
infoMessage = null;
var dto = new CatalogWriteDto
{
CatTitle = editModel.CatTitle,
CatString = editModel.CatString,
UpdateProcedure = editModel.UpdateProcedure
};
try
{
if (isEditing)
if (e.IsNew)
{
var updated = await Api.UpdateAsync(editingId, formModel);
if (!updated.Success)
{
errorMessage = updated.Error ?? "Aktualisierung fehlgeschlagen.";
return;
}
infoMessage = "Katalog aktualisiert.";
}
else
{
var created = await Api.CreateAsync(formModel);
var created = await Api.CreateAsync(dto);
if (!created.Success || created.Value == null)
{
errorMessage = created.Error ?? "Anlegen fehlgeschlagen.";
if (!string.IsNullOrWhiteSpace(created.Error))
{
AddValidationError(editModel, nameof(CatalogEditModel.CatTitle), created.Error);
}
else
{
errorMessage = "Anlegen fehlgeschlagen.";
}
e.Cancel = true;
return;
}
infoMessage = "Katalog angelegt.";
}
else
{
var updated = await Api.UpdateAsync(editModel.Guid, dto);
if (!updated.Success)
{
errorMessage = updated.Error ?? "Aktualisierung fehlgeschlagen.";
e.Cancel = true;
return;
}
infoMessage = "Katalog aktualisiert.";
}
showForm = false;
await LoadCatalogs();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Speichern: {ex.Message}";
e.Cancel = true;
}
}
private void CancelEdit()
private void AddValidationError(CatalogEditModel editModel, string fieldName, string message)
{
showForm = false;
infoMessage = null;
errorMessage = null;
if (editContext == null || validationMessageStore == null)
{
return;
}
var field = new FieldIdentifier(editModel, fieldName);
validationMessageStore.Add(field, message);
editContext.NotifyValidationStateChanged();
}
private async Task DeleteCatalog(int id)
private bool ValidateEditModel(CatalogEditModel editModel, bool isNew)
{
if (isNew)
{
return true;
}
if (editModel.UpdateProcedure == 0 &&
!string.Equals(editModel.CatTitle, editModel.OriginalCatTitle, StringComparison.OrdinalIgnoreCase))
{
AddValidationError(editModel, nameof(CatalogEditModel.CatTitle), "Titel kann nicht geändert werden.");
return false;
}
return true;
}
private async Task OnDataItemDeleting(GridDataItemDeletingEventArgs e)
{
errorMessage = null;
infoMessage = null;
var item = (CatalogReadDto)e.DataItem;
try
{
var deleted = await Api.DeleteAsync(id);
var deleted = await Api.DeleteAsync(item.Guid);
if (!deleted.Success)
{
errorMessage = deleted.Error ?? "Löschen fehlgeschlagen.";
e.Cancel = true;
return;
}
@@ -285,9 +381,25 @@ else
catch (Exception ex)
{
errorMessage = $"Fehler beim Löschen: {ex.Message}";
e.Cancel = true;
}
}
private void SetPopupHeaderText(bool isNew)
{
popupHeaderText = isNew ? "Neu" : "Edit";
}
private sealed class CatalogEditModel
{
public int Guid { get; set; }
public string CatTitle { get; set; } = string.Empty;
public string CatString { get; set; } = string.Empty;
public int UpdateProcedure { get; set; }
public string OriginalCatTitle { get; set; } = string.Empty;
public bool IsNew { get; set; }
}
private sealed class ProcedureOption
{
public int Value { get; set; }

View File

@@ -0,0 +1,408 @@
@using Microsoft.AspNetCore.Components.Forms
@inject MassDataApiClient Api
<style>
.action-panel { margin-bottom: 16px; }
.grid-section { margin-top: 12px; }
.pager-container {
display: flex;
justify-content: center;
margin-top: 12px;
margin-bottom: 16px;
}
.massdata-grid .dxbl-grid-sort-asc,
.massdata-grid .dxbl-grid-sort-desc {
display: none;
}
.massdata-grid th.dxbl-grid-header-sortable {
position: relative;
padding-right: 1.5rem;
}
.massdata-grid th.dxbl-grid-header-sortable::before,
.massdata-grid th.dxbl-grid-header-sortable::after {
content: "";
position: absolute;
right: 0.45rem;
width: 0.7rem;
height: 0.7rem;
background-repeat: no-repeat;
background-size: 0.7rem 0.7rem;
opacity: 0.35;
pointer-events: none;
}
.massdata-grid th.dxbl-grid-header-sortable::before {
top: 38%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.957 10.999a1 1 0 0 1-.821-1.571l2.633-3.785a1.5 1.5 0 0 1 2.462 0l2.633 3.785a1 1 0 0 1-.821 1.57H4.957Z' fill='%23888888'/%3E%3C/svg%3E");
}
.massdata-grid th.dxbl-grid-header-sortable::after {
top: 58%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.957 5a1 1 0 0 0-.821 1.571l2.633 3.784a1.5 1.5 0 0 0 2.462 0l2.633-3.784A1 1 0 0 0 11.043 5H4.957Z' fill='%23888888'/%3E%3C/svg%3E");
}
.massdata-grid th.dxbl-grid-header-sortable[aria-sort="ascending"]::after {
opacity: 0;
}
.massdata-grid th.dxbl-grid-header-sortable[aria-sort="descending"]::before {
opacity: 0;
}
.massdata-grid .filter-search-input input {
padding-right: 1.75rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M9.309 10.016a4.5 4.5 0 1 1 .707-.707l3.838 3.837a.5.5 0 0 1-.708.708L9.31 10.016ZM10 6.5a3.5 3.5 0 1 0-7 0 3.5 3.5 0 0 0 7 0Z' fill='%23666666'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 0.9rem;
}
.massdata-edit-popup {
min-width: 720px;
}
</style>
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
<div class="alert alert-danger" role="alert">@errorMessage</div>
}
else if (!string.IsNullOrWhiteSpace(infoMessage))
{
<div class="alert alert-success" role="alert">@infoMessage</div>
}
@if (isLoading)
{
<p><em>Lade Daten...</em></p>
}
else if (items.Count == 0)
{
<p>Keine Einträge vorhanden.</p>
}
else
{
<div class="grid-section">
<DxGrid Data="@items"
TItem="MassDataReadDto"
KeyFieldName="@nameof(MassDataReadDto.Id)"
ShowFilterRow="true"
ShowGroupPanel="true"
AllowColumnResize="true"
PagerVisible="false"
PageSize="100"
CssClass="mb-3 massdata-grid"
EditMode="GridEditMode.PopupEditForm"
PopupEditFormCssClass="massdata-edit-popup"
PopupEditFormHeaderText="@popupHeaderText"
CustomizeEditModel="OnCustomizeEditModel"
EditModelSaving="OnEditModelSaving"
DataItemDeleting="OnDataItemDeleting">
<Columns>
<DxGridCommandColumn Width="120px" />
<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>
<EditFormTemplate Context="editFormContext">
@{ SetEditContext(editFormContext.EditContext); var editModel = (MassDataEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); }
<DxFormLayout ColCount="2">
<DxFormLayoutItem Caption="CustomerName">
<DxTextBox @bind-Text="editModel.CustomerName" Width="100%" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Amount">
<DxTextBox @bind-Text="editModel.AmountText" Width="100%" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Category">
<DxTextBox @bind-Text="editModel.Category" Width="100%" ReadOnly="@(!editModel.IsNew)" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Status">
<DxCheckBox @bind-Checked="editModel.StatusFlag" ReadOnly="@(!editModel.IsNew)" />
</DxFormLayoutItem>
@if (!editModel.IsNew)
{
<DxFormLayoutItem Caption="Prozedur">
<DxComboBox Data="@procedureOptions"
TData="ProcedureOption"
TValue="int"
TextFieldName="Text"
ValueFieldName="Value"
@bind-Value="editModel.UpdateProcedure"
Width="100%" />
</DxFormLayoutItem>
}
<DxFormLayoutItem ColSpanMd="12">
<ValidationSummary />
</DxFormLayoutItem>
</DxFormLayout>
</EditFormTemplate>
</DxGrid>
<div class="pager-container">
<DxPager PageCount="@pageCount" ActivePageIndex="@pageIndex" ActivePageIndexChanged="OnPageChanged" />
</div>
</div>
}
@code {
private const int PageSize = 100;
private List<MassDataReadDto> items = new();
private bool isLoading;
private string? errorMessage;
private string? infoMessage;
private int pageIndex;
private int pageCount = 1;
private string popupHeaderText = "Edit";
private EditContext? editContext;
private ValidationMessageStore? validationMessageStore;
private readonly List<BoolFilterOption> statusFilterOptions = new()
{
new() { Value = null, Text = "Alle" },
new() { Value = true, Text = "True" },
new() { Value = false, Text = "False" }
};
private readonly List<ProcedureOption> procedureOptions = new()
{
new() { Value = 0, Text = "PRMassdata_UpsertByCustomerName" }
};
protected override async Task OnInitializedAsync()
{
await LoadPage(0);
}
private async Task LoadPage(int page)
{
isLoading = true;
errorMessage = null;
try
{
var total = await Api.GetCountAsync();
pageCount = Math.Max(1, (int)Math.Ceiling(total / (double)PageSize));
pageIndex = Math.Clamp(page, 0, pageCount - 1);
var skip = pageIndex * PageSize;
items = await Api.GetAllAsync(skip, PageSize);
}
catch (Exception ex)
{
errorMessage = $"MassData konnten nicht geladen werden: {ex.Message}";
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task OnPageChanged(int index)
{
await LoadPage(index);
}
private void SetEditContext(EditContext context)
{
if (editContext == context)
{
return;
}
if (editContext != null)
{
editContext.OnFieldChanged -= OnEditFieldChanged;
}
editContext = context;
validationMessageStore = new ValidationMessageStore(editContext);
editContext.OnFieldChanged += OnEditFieldChanged;
}
private void OnEditFieldChanged(object? sender, FieldChangedEventArgs e)
{
if (validationMessageStore == null || editContext == null)
{
return;
}
if (e.FieldIdentifier.FieldName == nameof(MassDataEditModel.UpdateProcedure))
{
validationMessageStore.Clear();
editContext.NotifyValidationStateChanged();
return;
}
if (e.FieldIdentifier.FieldName == nameof(MassDataEditModel.CustomerName))
{
var field = new FieldIdentifier(editContext.Model, nameof(MassDataEditModel.CustomerName));
validationMessageStore.Clear(field);
editContext.NotifyValidationStateChanged();
}
}
private void SetPopupHeaderText(bool isNew)
{
popupHeaderText = isNew ? "Neu" : "Edit";
}
private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
{
if (e.IsNew)
{
e.EditModel = new MassDataEditModel { IsNew = true, UpdateProcedure = procedureOptions[0].Value };
SetPopupHeaderText(true);
return;
}
var item = (MassDataReadDto)e.DataItem;
e.EditModel = new MassDataEditModel
{
Id = item.Id,
CustomerName = item.CustomerName,
AmountText = item.Amount.ToString("0.00"),
Category = item.Category,
StatusFlag = item.StatusFlag,
UpdateProcedure = procedureOptions[0].Value,
IsNew = false,
OriginalCustomerName = item.CustomerName
};
SetPopupHeaderText(false);
}
private async Task OnEditModelSaving(GridEditModelSavingEventArgs e)
{
errorMessage = null;
infoMessage = null;
validationMessageStore?.Clear();
editContext?.NotifyValidationStateChanged();
var editModel = (MassDataEditModel)e.EditModel;
if (!decimal.TryParse(editModel.AmountText, out var amount))
{
AddValidationError(editModel, nameof(MassDataEditModel.AmountText), "Amount ist ungültig.");
e.Cancel = true;
return;
}
if (editModel.IsNew)
{
var existing = await Api.GetByCustomerNameAsync(editModel.CustomerName);
if (existing != null)
{
AddValidationError(editModel, nameof(MassDataEditModel.CustomerName), "Kunde existiert bereits.");
e.Cancel = true;
return;
}
}
var dto = new MassDataWriteDto
{
CustomerName = editModel.CustomerName,
Amount = amount,
Category = editModel.Category,
StatusFlag = editModel.StatusFlag
};
try
{
await Api.UpsertAsync(dto);
infoMessage = editModel.IsNew ? "MassData angelegt." : "MassData aktualisiert.";
await LoadPage(pageIndex);
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Speichern: {ex.Message}";
e.Cancel = true;
}
}
private void AddValidationError(MassDataEditModel editModel, string fieldName, string message)
{
if (editContext == null || validationMessageStore == null)
{
return;
}
var field = new FieldIdentifier(editModel, fieldName);
validationMessageStore.Add(field, message);
editContext.NotifyValidationStateChanged();
}
private Task OnDataItemDeleting(GridDataItemDeletingEventArgs e)
{
errorMessage = null;
infoMessage = "Löschen ist aktuell noch nicht verfügbar.";
e.Cancel = true;
return Task.CompletedTask;
}
private sealed class MassDataEditModel
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string AmountText { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public bool StatusFlag { get; set; }
public int UpdateProcedure { get; set; }
public bool IsNew { get; set; }
public string OriginalCustomerName { get; set; } = string.Empty;
}
private sealed class ProcedureOption
{
public int Value { get; set; }
public string Text { get; set; } = string.Empty;
}
private sealed class BoolFilterOption
{
public bool? Value { get; set; }
public string Text { get; set; } = string.Empty;
}
}

View File

@@ -13,6 +13,7 @@
<PackageReference Include="DevExpress.Blazor.Themes.Fluent" Version="25.2.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.22" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.22" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.22" />
</ItemGroup>
<ItemGroup>

View File

@@ -26,6 +26,11 @@
<span class="oi oi-list-rich" aria-hidden="true"></span> Dashboards
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="massdata">
<span class="bi bi-table" aria-hidden="true"></span> MassData
</NavLink>
</div>
</nav>
</div>

View File

@@ -0,0 +1,12 @@
namespace DbFirst.BlazorWasm.Models;
public class MassDataReadDto
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Category { get; set; } = string.Empty;
public bool StatusFlag { get; set; }
public DateTime AddedWhen { get; set; }
public DateTime? ChangedWhen { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace DbFirst.BlazorWasm.Models;
public class MassDataWriteDto
{
public string CustomerName { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Category { get; set; } = string.Empty;
public bool StatusFlag { get; set; }
}

View File

@@ -1,5 +1,6 @@
@page "/dashboard"
@page "/dashboards/{DashboardId?}"
@implements IAsyncDisposable
@inject Microsoft.Extensions.Configuration.IConfiguration Configuration
@inject NavigationManager Navigation
@inject DashboardApiClient DashboardApi
@@ -57,7 +58,7 @@
{
@foreach (var dashboard in dashboards)
{
<NavLink class="dashboard-nav-link" href="@($"dashboards/{dashboard.Id}")">@dashboard.Name</NavLink>
<NavLink class="dashboard-nav-link" href="@($"dashboards/{dashboard.Id}?mode={(IsDesigner ? "designer" : "viewer")}")">@dashboard.Name</NavLink>
}
}
</aside>
@@ -77,19 +78,38 @@
[SupplyParameterFromQuery] public string? Mode { get; set; }
private readonly List<DashboardInfoDto> dashboards = new();
private HubConnection? _hubConnection;
private bool IsDesigner => !string.Equals(Mode, "viewer", StringComparison.OrdinalIgnoreCase);
private WorkingMode CurrentMode => IsDesigner ? WorkingMode.Designer : WorkingMode.ViewerOnly;
private string SelectedDashboardId { get; set; } = "";
private string SelectedDashboardId { get; set; } = string.Empty;
private string DashboardKey => $"{SelectedDashboardId}-{(IsDesigner ? "designer" : "viewer")}";
private string DashboardEndpoint => $"{Configuration["ApiBaseUrl"]?.TrimEnd('/')}/api/dashboard";
private string HubEndpoint => $"{Configuration["ApiBaseUrl"]?.TrimEnd('/')}/hubs/dashboards";
protected override async Task OnInitializedAsync()
{
await RefreshDashboards();
_hubConnection = new HubConnectionBuilder()
.WithUrl(HubEndpoint)
.WithAutomaticReconnect()
.Build();
_hubConnection.On("DashboardsChanged", async () =>
{
await RefreshDashboards();
});
await _hubConnection.StartAsync();
}
protected override async Task OnParametersSetAsync()
{
if (dashboards.Count == 0)
{
dashboards.AddRange(await DashboardApi.GetAllAsync());
await RefreshDashboards();
}
var requestedId = string.IsNullOrWhiteSpace(DashboardId) || string.Equals(DashboardId, "default", StringComparison.OrdinalIgnoreCase)
@@ -119,4 +139,25 @@
var targetMode = IsDesigner ? "viewer" : "designer";
Navigation.NavigateTo($"dashboards/{SelectedDashboardId}?mode={targetMode}", replace: true);
}
private async Task RefreshDashboards()
{
var latest = await DashboardApi.GetAllAsync();
if (latest.Count == dashboards.Count && latest.All(d => dashboards.Any(x => x.Id == d.Id && x.Name == d.Name)))
{
return;
}
dashboards.Clear();
dashboards.AddRange(latest);
await InvokeAsync(StateHasChanged);
}
public async ValueTask DisposeAsync()
{
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
}
}
}

View File

@@ -0,0 +1,7 @@
@page "/massdata"
<PageTitle>MassData</PageTitle>
<h1>MassData</h1>
<MassDataGrid />

View File

@@ -18,5 +18,6 @@ var apiBaseUrl = builder.Configuration["ApiBaseUrl"] ?? builder.HostEnvironment.
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBaseUrl) });
builder.Services.AddScoped<CatalogApiClient>();
builder.Services.AddScoped<DashboardApiClient>();
builder.Services.AddScoped<MassDataApiClient>();
await builder.Build().RunAsync();

View File

@@ -0,0 +1,52 @@
using System.Net.Http.Json;
using DbFirst.BlazorWasm.Models;
namespace DbFirst.BlazorWasm.Services;
public class MassDataApiClient
{
private readonly HttpClient _httpClient;
private const string Endpoint = "api/massdata";
public MassDataApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<int> GetCountAsync()
{
var result = await _httpClient.GetFromJsonAsync<int?>("api/massdata/count");
return result ?? 0;
}
public async Task<List<MassDataReadDto>> GetAllAsync(int skip, int take)
{
var result = await _httpClient.GetFromJsonAsync<List<MassDataReadDto>>($"{Endpoint}?skip={skip}&take={take}");
return result ?? new List<MassDataReadDto>();
}
public async Task<MassDataReadDto> UpsertAsync(MassDataWriteDto dto)
{
var response = await _httpClient.PostAsJsonAsync($"{Endpoint}/upsert", dto);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<MassDataReadDto>();
return payload ?? new MassDataReadDto();
}
public async Task<MassDataReadDto?> GetByCustomerNameAsync(string customerName)
{
if (string.IsNullOrWhiteSpace(customerName))
{
return null;
}
var response = await _httpClient.GetAsync($"{Endpoint}/{Uri.EscapeDataString(customerName)}");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<MassDataReadDto>();
}
}

View File

@@ -6,6 +6,7 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using Microsoft.AspNetCore.SignalR.Client
@using DbFirst.BlazorWasm
@using DbFirst.BlazorWasm.Layout
@using DbFirst.BlazorWasm.Models

View File

@@ -1,8 +1,12 @@
@using Microsoft.AspNetCore.Components.Forms
@inject CatalogApiClient Api
<style>
.action-panel { margin-bottom: 16px; }
.grid-section { margin-top: 12px; }
.catalog-edit-popup {
min-width: 720px;
}
</style>
@if (!string.IsNullOrWhiteSpace(errorMessage))
@@ -14,41 +18,6 @@ else if (!string.IsNullOrWhiteSpace(infoMessage))
<div class="alert alert-success" role="alert">@infoMessage</div>
}
<div class="mb-3">
<DxButton RenderStyle="ButtonRenderStyle.Primary" Click="@StartCreate">Neuen Eintrag anlegen</DxButton>
</div>
@if (showForm)
{
<div class="action-panel">
<EditForm Model="formModel" OnValidSubmit="HandleSubmit" Context="editCtx">
<DxFormLayout ColCount="2">
<DxFormLayoutItem Caption="Titel" Context="itemCtx">
<DxTextBox @bind-Text="formModel.CatTitle" Enabled="@(isEditing ? formModel.UpdateProcedure != 0 : true)" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Kennung" Context="itemCtx">
<DxTextBox @bind-Text="formModel.CatString" />
</DxFormLayoutItem>
@if (isEditing)
{
<DxFormLayoutItem Caption="Update-Prozedur" Context="itemCtx">
<DxComboBox Data="@procedureOptions"
TextFieldName="Text"
ValueFieldName="Value"
@bind-Value="formModel.UpdateProcedure" />
</DxFormLayoutItem>
}
<DxFormLayoutItem Caption=" " Context="itemCtx">
<DxStack Orientation="Orientation.Horizontal" Spacing="8">
<DxButton RenderStyle="ButtonRenderStyle.Success" ButtonType="ButtonType.Submit" SubmitFormOnClick="true" Context="btnCtx">@((isEditing ? "Speichern" : "Anlegen"))</DxButton>
<DxButton RenderStyle="ButtonRenderStyle.Secondary" Click="@CancelEdit" Context="btnCtx">Abbrechen</DxButton>
</DxStack>
</DxFormLayoutItem>
</DxFormLayout>
</EditForm>
</div>
}
@if (isLoading)
{
<p><em>Lade Daten...</em></p>
@@ -60,38 +29,108 @@ else if (items.Count == 0)
else
{
<div class="grid-section">
<DxGrid Data="@items" TItem="CatalogReadDto" KeyFieldName="@nameof(CatalogReadDto.Guid)" ShowFilterRow="true" PageSize="10" CssClass="mb-4 catalog-grid">
<DxGrid Data="@items"
TItem="CatalogReadDto"
KeyFieldName="@nameof(CatalogReadDto.Guid)"
ShowFilterRow="true"
PageSize="10"
CssClass="mb-4 catalog-grid"
EditMode="GridEditMode.PopupEditForm"
PopupEditFormCssClass="catalog-edit-popup"
PopupEditFormHeaderText="@popupHeaderText"
CustomizeEditModel="OnCustomizeEditModel"
EditModelSaving="OnEditModelSaving"
DataItemDeleting="OnDataItemDeleting">
<Columns>
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.Guid)" Caption="Id" Width="140px" SortIndex="0" SortOrder="GridColumnSortOrder.Ascending" />
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.CatTitle)" Caption="Titel" />
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.CatString)" Caption="String" />
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.AddedWho)" Caption="Angelegt von" />
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.AddedWhen)" Caption="Angelegt am" />
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.ChangedWho)" Caption="Geändert von" />
<DxGridDataColumn FieldName="@nameof(CatalogReadDto.ChangedWhen)" Caption="Geändert am" />
<DxGridDataColumn Caption="" Width="220px" AllowSort="false">
<CellDisplayTemplate Context="cell">
@{ var item = (CatalogReadDto)cell.DataItem; }
<div style="white-space: nowrap;">
<DxButton RenderStyle="ButtonRenderStyle.Secondary" Size="ButtonSize.Small" Click="@(() => StartEdit(item))">Bearbeiten</DxButton>
<DxButton RenderStyle="ButtonRenderStyle.Danger" Size="ButtonSize.Small" Click="@(() => DeleteCatalog(item.Guid))">Löschen</DxButton>
</div>
</CellDisplayTemplate>
<DxGridCommandColumn Width="120px" />
<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>
<EditFormTemplate Context="editFormContext">
@{ SetEditContext(editFormContext.EditContext); var editModel = (CatalogEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); }
<DxFormLayout ColCount="2">
<DxFormLayoutItem Caption="Titel">
<DxTextBox @bind-Text="editModel.CatTitle" Width="100%" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Kennung">
<DxTextBox @bind-Text="editModel.CatString" Width="100%" />
</DxFormLayoutItem>
@if (!editModel.IsNew)
{
<DxFormLayoutItem Caption="Update-Prozedur">
<DxComboBox Data="@procedureOptions"
TData="ProcedureOption"
TValue="int"
TextFieldName="Text"
ValueFieldName="Value"
@bind-Value="editModel.UpdateProcedure"
Width="100%" />
</DxFormLayoutItem>
}
<DxFormLayoutItem ColSpanMd="12">
<ValidationSummary />
</DxFormLayoutItem>
</DxFormLayout>
</EditFormTemplate>
</DxGrid>
</div>
}
@code {
private List<CatalogReadDto> items = new();
private CatalogWriteDto formModel = new();
private int editingId;
private bool isLoading;
private bool isEditing;
private bool showForm;
private string? errorMessage;
private string? infoMessage;
private EditContext? editContext;
private ValidationMessageStore? validationMessageStore;
private string popupHeaderText = "Edit";
private readonly List<ProcedureOption> procedureOptions = new()
{
@@ -104,6 +143,71 @@ else
await LoadCatalogs();
}
private void SetEditContext(EditContext context)
{
if (editContext == context)
{
return;
}
if (editContext != null)
{
editContext.OnFieldChanged -= OnEditFieldChanged;
}
editContext = context;
validationMessageStore = new ValidationMessageStore(editContext);
editContext.OnFieldChanged += OnEditFieldChanged;
}
private void OnEditFieldChanged(object? sender, FieldChangedEventArgs e)
{
if (validationMessageStore == null || editContext == null)
{
return;
}
if (e.FieldIdentifier.FieldName == nameof(CatalogEditModel.UpdateProcedure))
{
validationMessageStore.Clear();
editContext.NotifyValidationStateChanged();
return;
}
if (e.FieldIdentifier.FieldName == nameof(CatalogEditModel.CatTitle))
{
var field = new FieldIdentifier(editContext.Model, nameof(CatalogEditModel.CatTitle));
validationMessageStore.Clear(field);
editContext.NotifyValidationStateChanged();
}
}
private void SetPopupHeaderText(bool isNew)
{
popupHeaderText = isNew ? "Neu" : "Edit";
}
private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
{
popupHeaderText = e.IsNew ? "Neu" : "Edit";
if (e.IsNew)
{
e.EditModel = new CatalogEditModel { IsNew = true };
return;
}
var item = (CatalogReadDto)e.DataItem;
e.EditModel = new CatalogEditModel
{
Guid = item.Guid,
CatTitle = item.CatTitle,
CatString = item.CatString,
UpdateProcedure = 0,
OriginalCatTitle = item.CatTitle,
IsNew = false
};
}
private async Task LoadCatalogs()
{
isLoading = true;
@@ -123,88 +227,115 @@ else
}
}
private void StartCreate()
private async Task OnEditModelSaving(GridEditModelSavingEventArgs e)
{
formModel = new CatalogWriteDto();
editingId = 0;
isEditing = false;
showForm = true;
infoMessage = null;
errorMessage = null;
}
infoMessage = null;
private void StartEdit(CatalogReadDto item)
{
formModel = new CatalogWriteDto
validationMessageStore?.Clear();
editContext?.NotifyValidationStateChanged();
var editModel = (CatalogEditModel)e.EditModel;
if (!ValidateEditModel(editModel, e.IsNew))
{
CatTitle = item.CatTitle,
CatString = item.CatString,
UpdateProcedure = 0
};
editingId = item.Guid;
isEditing = true;
showForm = true;
infoMessage = null;
errorMessage = null;
}
e.Cancel = true;
return;
}
private async Task HandleSubmit()
{
errorMessage = null;
infoMessage = null;
var dto = new CatalogWriteDto
{
CatTitle = editModel.CatTitle,
CatString = editModel.CatString,
UpdateProcedure = editModel.UpdateProcedure
};
try
{
if (isEditing)
if (e.IsNew)
{
var updated = await Api.UpdateAsync(editingId, formModel);
if (!updated.Success)
{
errorMessage = updated.Error ?? "Aktualisierung fehlgeschlagen.";
return;
}
infoMessage = "Katalog aktualisiert.";
}
else
{
var created = await Api.CreateAsync(formModel);
var created = await Api.CreateAsync(dto);
if (!created.Success || created.Value == null)
{
errorMessage = created.Error ?? "Anlegen fehlgeschlagen.";
if (!string.IsNullOrWhiteSpace(created.Error))
{
AddValidationError(editModel, nameof(CatalogEditModel.CatTitle), created.Error);
}
else
{
errorMessage = "Anlegen fehlgeschlagen.";
}
e.Cancel = true;
return;
}
infoMessage = "Katalog angelegt.";
}
else
{
var updated = await Api.UpdateAsync(editModel.Guid, dto);
if (!updated.Success)
{
errorMessage = updated.Error ?? "Aktualisierung fehlgeschlagen.";
e.Cancel = true;
return;
}
infoMessage = "Katalog aktualisiert.";
}
showForm = false;
await LoadCatalogs();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Speichern: {ex.Message}";
e.Cancel = true;
}
}
private void CancelEdit()
private void AddValidationError(CatalogEditModel editModel, string fieldName, string message)
{
showForm = false;
infoMessage = null;
errorMessage = null;
if (editContext == null || validationMessageStore == null)
{
return;
}
var field = new FieldIdentifier(editModel, fieldName);
validationMessageStore.Add(field, message);
editContext.NotifyValidationStateChanged();
}
private async Task DeleteCatalog(int id)
private bool ValidateEditModel(CatalogEditModel editModel, bool isNew)
{
if (isNew)
{
return true;
}
if (editModel.UpdateProcedure == 0 &&
!string.Equals(editModel.CatTitle, editModel.OriginalCatTitle, StringComparison.OrdinalIgnoreCase))
{
AddValidationError(editModel, nameof(CatalogEditModel.CatTitle), "Titel kann nicht geändert werden.");
return false;
}
return true;
}
private async Task OnDataItemDeleting(GridDataItemDeletingEventArgs e)
{
errorMessage = null;
infoMessage = null;
var item = (CatalogReadDto)e.DataItem;
try
{
var deleted = await Api.DeleteAsync(id);
var deleted = await Api.DeleteAsync(item.Guid);
if (!deleted.Success)
{
errorMessage = deleted.Error ?? "Löschen fehlgeschlagen.";
e.Cancel = true;
return;
}
@@ -214,9 +345,20 @@ else
catch (Exception ex)
{
errorMessage = $"Fehler beim Löschen: {ex.Message}";
e.Cancel = true;
}
}
private sealed class CatalogEditModel
{
public int Guid { get; set; }
public string CatTitle { get; set; } = string.Empty;
public string CatString { get; set; } = string.Empty;
public int UpdateProcedure { get; set; }
public string OriginalCatTitle { get; set; } = string.Empty;
public bool IsNew { get; set; }
}
private sealed class ProcedureOption
{
public int Value { get; set; }

View File

@@ -36,6 +36,12 @@
<span class="oi oi-list-rich" aria-hidden="true"></span> Dashboards
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="massdata">
<span class="bi bi-table" aria-hidden="true"></span> MassData
</NavLink>
</div>
</nav>
</div>

View File

@@ -0,0 +1,408 @@
@using Microsoft.AspNetCore.Components.Forms
@inject MassDataApiClient Api
<style>
.action-panel { margin-bottom: 16px; }
.grid-section { margin-top: 12px; }
.pager-container {
display: flex;
justify-content: center;
margin-top: 12px;
margin-bottom: 16px;
}
.massdata-grid .dxbl-grid-sort-asc,
.massdata-grid .dxbl-grid-sort-desc {
display: none;
}
.massdata-grid th.dxbl-grid-header-sortable {
position: relative;
padding-right: 1.5rem;
}
.massdata-grid th.dxbl-grid-header-sortable::before,
.massdata-grid th.dxbl-grid-header-sortable::after {
content: "";
position: absolute;
right: 0.45rem;
width: 0.7rem;
height: 0.7rem;
background-repeat: no-repeat;
background-size: 0.7rem 0.7rem;
opacity: 0.35;
pointer-events: none;
}
.massdata-grid th.dxbl-grid-header-sortable::before {
top: 38%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.957 10.999a1 1 0 0 1-.821-1.571l2.633-3.785a1.5 1.5 0 0 1 2.462 0l2.633 3.785a1 1 0 0 1-.821 1.57H4.957Z' fill='%23888888'/%3E%3C/svg%3E");
}
.massdata-grid th.dxbl-grid-header-sortable::after {
top: 58%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.957 5a1 1 0 0 0-.821 1.571l2.633 3.784a1.5 1.5 0 0 0 2.462 0l2.633-3.784A1 1 0 0 0 11.043 5H4.957Z' fill='%23888888'/%3E%3C/svg%3E");
}
.massdata-grid th.dxbl-grid-header-sortable[aria-sort="ascending"]::after {
opacity: 0;
}
.massdata-grid th.dxbl-grid-header-sortable[aria-sort="descending"]::before {
opacity: 0;
}
.massdata-grid .filter-search-input input {
padding-right: 1.75rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M9.309 10.016a4.5 4.5 0 1 1 .707-.707l3.838 3.837a.5.5 0 0 1-.708.708L9.31 10.016ZM10 6.5a3.5 3.5 0 1 0-7 0 3.5 3.5 0 0 0 7 0Z' fill='%23666666'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 0.9rem;
}
.massdata-edit-popup {
min-width: 720px;
}
</style>
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
<div class="alert alert-danger" role="alert">@errorMessage</div>
}
else if (!string.IsNullOrWhiteSpace(infoMessage))
{
<div class="alert alert-success" role="alert">@infoMessage</div>
}
@if (isLoading)
{
<p><em>Lade Daten...</em></p>
}
else if (items.Count == 0)
{
<p>Keine Einträge vorhanden.</p>
}
else
{
<div class="grid-section">
<DxGrid Data="@items"
TItem="MassDataReadDto"
KeyFieldName="@nameof(MassDataReadDto.Id)"
ShowFilterRow="true"
ShowGroupPanel="true"
AllowColumnResize="true"
PagerVisible="false"
PageSize="100"
CssClass="mb-3 massdata-grid"
EditMode="GridEditMode.PopupEditForm"
PopupEditFormCssClass="massdata-edit-popup"
PopupEditFormHeaderText="@popupHeaderText"
CustomizeEditModel="OnCustomizeEditModel"
EditModelSaving="OnEditModelSaving"
DataItemDeleting="OnDataItemDeleting">
<Columns>
<DxGridCommandColumn Width="120px" />
<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>
<EditFormTemplate Context="editFormContext">
@{ SetEditContext(editFormContext.EditContext); var editModel = (MassDataEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); }
<DxFormLayout ColCount="2">
<DxFormLayoutItem Caption="CustomerName">
<DxTextBox @bind-Text="editModel.CustomerName" Width="100%" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Amount">
<DxTextBox @bind-Text="editModel.AmountText" Width="100%" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Category">
<DxTextBox @bind-Text="editModel.Category" Width="100%" ReadOnly="@(!editModel.IsNew)" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Status">
<DxCheckBox @bind-Checked="editModel.StatusFlag" ReadOnly="@(!editModel.IsNew)" />
</DxFormLayoutItem>
@if (!editModel.IsNew)
{
<DxFormLayoutItem Caption="Prozedur">
<DxComboBox Data="@procedureOptions"
TData="ProcedureOption"
TValue="int"
TextFieldName="Text"
ValueFieldName="Value"
@bind-Value="editModel.UpdateProcedure"
Width="100%" />
</DxFormLayoutItem>
}
<DxFormLayoutItem ColSpanMd="12">
<ValidationSummary />
</DxFormLayoutItem>
</DxFormLayout>
</EditFormTemplate>
</DxGrid>
<div class="pager-container">
<DxPager PageCount="@pageCount" ActivePageIndex="@pageIndex" ActivePageIndexChanged="OnPageChanged" />
</div>
</div>
}
@code {
private const int PageSize = 100;
private List<MassDataReadDto> items = new();
private bool isLoading;
private string? errorMessage;
private string? infoMessage;
private int pageIndex;
private int pageCount = 1;
private string popupHeaderText = "Edit";
private EditContext? editContext;
private ValidationMessageStore? validationMessageStore;
private readonly List<BoolFilterOption> statusFilterOptions = new()
{
new() { Value = null, Text = "Alle" },
new() { Value = true, Text = "True" },
new() { Value = false, Text = "False" }
};
private readonly List<ProcedureOption> procedureOptions = new()
{
new() { Value = 0, Text = "PRMassdata_UpsertByCustomerName" }
};
protected override async Task OnInitializedAsync()
{
await LoadPage(0);
}
private async Task LoadPage(int page)
{
isLoading = true;
errorMessage = null;
try
{
var total = await Api.GetCountAsync();
pageCount = Math.Max(1, (int)Math.Ceiling(total / (double)PageSize));
pageIndex = Math.Clamp(page, 0, pageCount - 1);
var skip = pageIndex * PageSize;
items = await Api.GetAllAsync(skip, PageSize);
}
catch (Exception ex)
{
errorMessage = $"MassData konnten nicht geladen werden: {ex.Message}";
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task OnPageChanged(int index)
{
await LoadPage(index);
}
private void SetEditContext(EditContext context)
{
if (editContext == context)
{
return;
}
if (editContext != null)
{
editContext.OnFieldChanged -= OnEditFieldChanged;
}
editContext = context;
validationMessageStore = new ValidationMessageStore(editContext);
editContext.OnFieldChanged += OnEditFieldChanged;
}
private void OnEditFieldChanged(object? sender, FieldChangedEventArgs e)
{
if (validationMessageStore == null || editContext == null)
{
return;
}
if (e.FieldIdentifier.FieldName == nameof(MassDataEditModel.UpdateProcedure))
{
validationMessageStore.Clear();
editContext.NotifyValidationStateChanged();
return;
}
if (e.FieldIdentifier.FieldName == nameof(MassDataEditModel.CustomerName))
{
var field = new FieldIdentifier(editContext.Model, nameof(MassDataEditModel.CustomerName));
validationMessageStore.Clear(field);
editContext.NotifyValidationStateChanged();
}
}
private void SetPopupHeaderText(bool isNew)
{
popupHeaderText = isNew ? "Neu" : "Edit";
}
private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
{
if (e.IsNew)
{
e.EditModel = new MassDataEditModel { IsNew = true, UpdateProcedure = procedureOptions[0].Value };
SetPopupHeaderText(true);
return;
}
var item = (MassDataReadDto)e.DataItem;
e.EditModel = new MassDataEditModel
{
Id = item.Id,
CustomerName = item.CustomerName,
AmountText = item.Amount.ToString("0.00"),
Category = item.Category,
StatusFlag = item.StatusFlag,
UpdateProcedure = procedureOptions[0].Value,
IsNew = false,
OriginalCustomerName = item.CustomerName
};
SetPopupHeaderText(false);
}
private async Task OnEditModelSaving(GridEditModelSavingEventArgs e)
{
errorMessage = null;
infoMessage = null;
validationMessageStore?.Clear();
editContext?.NotifyValidationStateChanged();
var editModel = (MassDataEditModel)e.EditModel;
if (!decimal.TryParse(editModel.AmountText, out var amount))
{
AddValidationError(editModel, nameof(MassDataEditModel.AmountText), "Amount ist ungültig.");
e.Cancel = true;
return;
}
if (editModel.IsNew)
{
var existing = await Api.GetByCustomerNameAsync(editModel.CustomerName);
if (existing != null)
{
AddValidationError(editModel, nameof(MassDataEditModel.CustomerName), "Kunde existiert bereits.");
e.Cancel = true;
return;
}
}
var dto = new MassDataWriteDto
{
CustomerName = editModel.CustomerName,
Amount = amount,
Category = editModel.Category,
StatusFlag = editModel.StatusFlag
};
try
{
await Api.UpsertAsync(dto);
infoMessage = editModel.IsNew ? "MassData angelegt." : "MassData aktualisiert.";
await LoadPage(pageIndex);
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Speichern: {ex.Message}";
e.Cancel = true;
}
}
private void AddValidationError(MassDataEditModel editModel, string fieldName, string message)
{
if (editContext == null || validationMessageStore == null)
{
return;
}
var field = new FieldIdentifier(editModel, fieldName);
validationMessageStore.Add(field, message);
editContext.NotifyValidationStateChanged();
}
private Task OnDataItemDeleting(GridDataItemDeletingEventArgs e)
{
errorMessage = null;
infoMessage = "Löschen ist aktuell noch nicht verfügbar.";
e.Cancel = true;
return Task.CompletedTask;
}
private sealed class MassDataEditModel
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string AmountText { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public bool StatusFlag { get; set; }
public int UpdateProcedure { get; set; }
public bool IsNew { get; set; }
public string OriginalCustomerName { get; set; } = string.Empty;
}
private sealed class ProcedureOption
{
public int Value { get; set; }
public string Text { get; set; } = string.Empty;
}
private sealed class BoolFilterOption
{
public bool? Value { get; set; }
public string Text { get; set; } = string.Empty;
}
}

View File

@@ -1,5 +1,6 @@
@page "/dashboard"
@page "/dashboards/{DashboardId?}"
@implements IAsyncDisposable
@inject Microsoft.Extensions.Configuration.IConfiguration Configuration
@inject NavigationManager Navigation
@inject DashboardApiClient DashboardApi
@@ -57,7 +58,7 @@
{
@foreach (var dashboard in dashboards)
{
<NavLink class="dashboard-nav-link" href="@($"dashboards/{dashboard.Id}")">@dashboard.Name</NavLink>
<NavLink class="dashboard-nav-link" href="@($"dashboards/{dashboard.Id}?mode={(IsDesigner ? "designer" : "viewer")}")">@dashboard.Name</NavLink>
}
}
</aside>
@@ -77,19 +78,38 @@
[SupplyParameterFromQuery] public string? Mode { get; set; }
private readonly List<DashboardInfoDto> dashboards = new();
private HubConnection? _hubConnection;
private bool IsDesigner => !string.Equals(Mode, "viewer", StringComparison.OrdinalIgnoreCase);
private WorkingMode CurrentMode => IsDesigner ? WorkingMode.Designer : WorkingMode.ViewerOnly;
private string SelectedDashboardId { get; set; } = "";
private string SelectedDashboardId { get; set; } = string.Empty;
private string DashboardKey => $"{SelectedDashboardId}-{(IsDesigner ? "designer" : "viewer")}";
private string DashboardEndpoint => $"{Configuration["ApiBaseUrl"]?.TrimEnd('/')}/api/dashboard";
private string HubEndpoint => $"{Configuration["ApiBaseUrl"]?.TrimEnd('/')}/hubs/dashboards";
protected override async Task OnInitializedAsync()
{
await RefreshDashboards();
_hubConnection = new HubConnectionBuilder()
.WithUrl(HubEndpoint)
.WithAutomaticReconnect()
.Build();
_hubConnection.On("DashboardsChanged", async () =>
{
await RefreshDashboards();
});
await _hubConnection.StartAsync();
}
protected override async Task OnParametersSetAsync()
{
if (dashboards.Count == 0)
{
dashboards.AddRange(await DashboardApi.GetAllAsync());
await RefreshDashboards();
}
var requestedId = string.IsNullOrWhiteSpace(DashboardId) || string.Equals(DashboardId, "default", StringComparison.OrdinalIgnoreCase)
@@ -119,4 +139,25 @@
var targetMode = IsDesigner ? "viewer" : "designer";
Navigation.NavigateTo($"dashboards/{SelectedDashboardId}?mode={targetMode}", replace: true);
}
private async Task RefreshDashboards()
{
var latest = await DashboardApi.GetAllAsync();
if (latest.Count == dashboards.Count && latest.All(d => dashboards.Any(x => x.Id == d.Id && x.Name == d.Name)))
{
return;
}
dashboards.Clear();
dashboards.AddRange(latest);
await InvokeAsync(StateHasChanged);
}
public async ValueTask DisposeAsync()
{
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
}
}
}

View File

@@ -0,0 +1,7 @@
@page "/massdata"
<PageTitle>MassData</PageTitle>
<h1>MassData</h1>
<MassDataGrid />

View File

@@ -6,6 +6,7 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Microsoft.AspNetCore.SignalR.Client
@using DbFirst.BlazorWebApp
@using DbFirst.BlazorWebApp.Components
@using DbFirst.BlazorWebApp.Models

View File

@@ -12,5 +12,9 @@
<PackageReference Include="DevExpress.Blazor.Themes" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.Themes.Fluent" Version="25.2.3" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Blazor" Version="25.2.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.22" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
namespace DbFirst.BlazorWebApp.Models;
public class MassDataReadDto
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Category { get; set; } = string.Empty;
public bool StatusFlag { get; set; }
public DateTime AddedWhen { get; set; }
public DateTime? ChangedWhen { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace DbFirst.BlazorWebApp.Models;
public class MassDataWriteDto
{
public string CustomerName { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Category { get; set; } = string.Empty;
public bool StatusFlag { get; set; }
}

View File

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

View File

@@ -0,0 +1,52 @@
using System.Net.Http.Json;
using DbFirst.BlazorWebApp.Models;
namespace DbFirst.BlazorWebApp.Services;
public class MassDataApiClient
{
private readonly HttpClient _httpClient;
private const string Endpoint = "api/massdata";
public MassDataApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<int> GetCountAsync()
{
var result = await _httpClient.GetFromJsonAsync<int?>("api/massdata/count");
return result ?? 0;
}
public async Task<List<MassDataReadDto>> GetAllAsync(int skip, int take)
{
var result = await _httpClient.GetFromJsonAsync<List<MassDataReadDto>>($"{Endpoint}?skip={skip}&take={take}");
return result ?? new List<MassDataReadDto>();
}
public async Task<MassDataReadDto> UpsertAsync(MassDataWriteDto dto)
{
var response = await _httpClient.PostAsJsonAsync($"{Endpoint}/upsert", dto);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<MassDataReadDto>();
return payload ?? new MassDataReadDto();
}
public async Task<MassDataReadDto?> GetByCustomerNameAsync(string customerName)
{
if (string.IsNullOrWhiteSpace(customerName))
{
return null;
}
var response = await _httpClient.GetAsync($"{Endpoint}/{Uri.EscapeDataString(customerName)}");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<MassDataReadDto>();
}
}

View File

@@ -0,0 +1,12 @@
namespace DbFirst.Domain.Entities;
public class Massdata
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Category { get; set; } = string.Empty;
public bool StatusFlag { get; set; }
public DateTime AddedWhen { get; set; }
public DateTime? ChangedWhen { get; set; }
}

View File

@@ -11,6 +11,10 @@ public static class DependencyInjection
services.Configure<TableConfigurations>(configuration.GetSection("TableConfigurations"));
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
services.AddDbContext<MassDataDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("MassDataConnection")));
return services;
}
}

View File

@@ -0,0 +1,44 @@
using DbFirst.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace DbFirst.Infrastructure;
public class MassDataDbContext : DbContext
{
public MassDataDbContext(DbContextOptions<MassDataDbContext> options)
: base(options)
{
}
public virtual DbSet<Massdata> Massdata { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Massdata>(entity =>
{
entity.HasKey(e => e.Id);
entity.ToTable("MASSDATA");
entity.Property(e => e.Id).HasColumnName("ID");
entity.Property(e => e.CustomerName)
.HasMaxLength(200)
.IsUnicode(false)
.HasColumnName("CustomerName");
entity.Property(e => e.Amount)
.HasColumnType("decimal(12,2)")
.HasColumnName("Amount");
entity.Property(e => e.Category)
.HasMaxLength(100)
.IsUnicode(false)
.HasColumnName("Category");
entity.Property(e => e.StatusFlag)
.HasColumnName("StatusFlag");
entity.Property(e => e.AddedWhen)
.HasColumnType("datetime")
.HasColumnName("ADDED_WHEN");
entity.Property(e => e.ChangedWhen)
.HasColumnType("datetime")
.HasColumnName("CHANGED_WHEN");
});
}
}

View File

@@ -0,0 +1,71 @@
using System.Data;
using DbFirst.Application.Repositories;
using DbFirst.Domain.Entities;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
namespace DbFirst.Infrastructure.Repositories;
public class MassDataRepository : IMassDataRepository
{
private readonly MassDataDbContext _db;
public MassDataRepository(MassDataDbContext db)
{
_db = db;
}
public async Task<int> GetCountAsync(CancellationToken cancellationToken = default)
{
return await _db.Massdata.AsNoTracking().CountAsync(cancellationToken);
}
public async Task<List<Massdata>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _db.Massdata.AsNoTracking().ToListAsync(cancellationToken);
}
public async Task<Massdata?> GetByCustomerNameAsync(string customerName, CancellationToken cancellationToken = default)
{
return await _db.Massdata.AsNoTracking()
.FirstOrDefaultAsync(x => x.CustomerName == customerName, cancellationToken);
}
public async Task<List<Massdata>> GetAllAsync(int? skip = null, int? take = null, CancellationToken cancellationToken = default)
{
var query = _db.Massdata.AsNoTracking().OrderBy(x => x.Id).AsQueryable();
if (skip.HasValue)
{
query = query.Skip(skip.Value);
}
if (take.HasValue)
{
query = query.Take(take.Value);
}
return await query.ToListAsync(cancellationToken);
}
public async Task<Massdata> UpsertByCustomerNameAsync(string customerName, decimal amount, bool statusFlag, string category, CancellationToken cancellationToken = default)
{
var customerParam = new SqlParameter("@CustomerName", SqlDbType.VarChar, 200) { Value = customerName };
var amountParam = new SqlParameter("@Amount", SqlDbType.Decimal) { Value = amount, Precision = 12, Scale = 2 };
var statusParam = new SqlParameter("@StatusFlag", SqlDbType.Bit) { Value = statusFlag };
var categoryParam = new SqlParameter("@Category", SqlDbType.VarChar, 100) { Value = category };
await _db.Database.ExecuteSqlRawAsync(
"EXEC dbo.PRMassdata_UpsertByCustomerName @CustomerName, @Amount, @StatusFlag, @Category",
parameters: new[] { customerParam, amountParam, statusParam, categoryParam },
cancellationToken: cancellationToken);
var updated = await GetByCustomerNameAsync(customerName, cancellationToken);
if (updated == null)
{
throw new InvalidOperationException("Upsert completed but record could not be loaded.");
}
return updated;
}
}