Compare commits

..

81 Commits

Author SHA1 Message Date
OlgunR
8933deec96 Add dark mode override for non-native DevExpress themes
Implements a dark mode override system for DevExpress Blazor themes lacking native dark support. Adds a JS function to toggle a dx-dark class on <html>, updates ThemeState to detect native dark themes, and applies targeted CSS variable overrides for consistent dark styling. Disables prerendering to ensure JS interop, and improves theme switching logic and documentation.
2026-05-05 16:41:15 +02:00
OlgunR
2010673eba Remove unused menu state and toggle logic from NavMenu
Removed the private menuOpen field and ToggleMenu() method from NavMenu.razor, as they are no longer needed for menu state management.
2026-04-30 15:41:05 +02:00
OlgunR
b75e7d730c Modernize sidebar with DxTreeView and new responsive styles
Refactor NavMenu to use DevExpress DxTreeView for navigation, replacing the old NavLink-based menu. Update sidebar and navigation row styling to use CSS variables, remove Bootstrap-specific and SVG icon CSS, and add a responsive hamburger menu for small screens. Improve dark mode support and overall maintainability.
2026-04-30 15:37:04 +02:00
OlgunR
075433c780 Improve spacing for Dark Mode button in top row
Wrapped the Dark Mode toggle button in a span with left margin
for better separation from the theme combo box. Added a new
.btn-gap CSS class to standardize button spacing in the top row.
2026-04-23 15:59:50 +02:00
OlgunR
35e39ff979 Add theme selection dropdown and refactor theme handling
Introduce a DxComboBox in MainLayout for selecting between multiple themes. Update ThemeState to manage the current theme, provide a list of available themes, and apply the selected theme via a new SetTheme method. Refactor dark mode handling to work with the new theme system, and ensure UI updates on theme or mode changes.
2026-04-23 15:44:26 +02:00
OlgunR
292ce02370 Refactor using statements for clearer layer boundaries
Clean up and reorganize using/import statements across the solution. Remove unnecessary DTO imports from Application and Infrastructure layers, and ensure Contracts DTOs are only referenced in API and BlazorWebApp layers. No business logic is changed; these updates improve code organization, reduce coupling, and clarify architectural separation between layers.
2026-04-23 13:55:05 +02:00
OlgunR
df4de5d5f5 Refactor imports and ensure enum type safety in Catalogs
Reorganize and deduplicate using directives in _Imports.razor for clarity and maintainability. Explicitly cast UpdateProcedure to CatalogUpdateProcedure in CatalogsGrid.razor to enforce type safety. Restore necessary DevExpress and DbFirst namespaces.
2026-04-23 13:54:50 +02:00
OlgunR
e4624c92ef Refactor DTOs: make public, add properties, clean up
Refactored DTO classes by removing unnecessary using statements and internal wrappers, making them public, and adding explicit properties with default values. Clarified namespaces and improved accessibility for use in API contracts or service layers. Added CatalogUpdateProcedure to CatalogWriteDto to specify update operation type.
2026-04-23 11:48:45 +02:00
OlgunR
b0d60461b4 Standardize DTO naming and namespaces for MassData
Refactored DTO class and namespace names from Massdata* to MassData* in DbFirst.Contracts. Updated all relevant application files to use the correct DTO namespaces for Catalogs and MassData. Adjusted _Imports.razor to include new DTO namespaces and removed unused usings. These changes improve code consistency and reduce namespace-related errors.
2026-04-23 11:41:10 +02:00
OlgunR
c45c1a69a7 Remove obsolete DTO classes and add Layouts folder
Deleted DTOs for catalogs, layouts, mass data, and dashboard info from both Application and BlazorWebApp.Models namespaces. Updated DbFirst.Application.csproj to include a new Layouts folder for future organization. No functional code changes in this commit.
2026-04-23 11:25:31 +02:00
OlgunR
b268e53e1f Add project references to Contracts and Domain projects
Added DbFirst.Contracts as a project reference to both DbFirst.Application and DbFirst.BlazorWebApp, and added DbFirst.Domain to DbFirst.Application. Also, a BOM was introduced in the BlazorWebApp project file. These changes enable shared use of contracts and domain types across projects.
2026-04-23 11:23:24 +02:00
OlgunR
a1fee6f5c0 Add initial empty DTO classes for Catalogs, Dashboards, etc.
Introduced six new internal DTO classes: CatalogReadDto, CatalogWriteDto, DashboardInfoDto, LayoutDto, MassdataReadDto, and MassdataWriteDto. Each class resides in its appropriate namespace and currently contains no properties or methods, serving as placeholders for future data transfer logic.
2026-04-23 11:22:58 +02:00
OlgunR
dbb39354ab Add DbFirst.Contracts project to solution
Added new DbFirst.Contracts project targeting .NET 8.0 with nullable and implicit usings enabled. Included a project reference to DbFirst.Domain and updated the solution file to register the new project with all build configurations.
2026-04-23 11:15:53 +02:00
OlgunR
2ffa389978 Update AutoMapper to 16.1.1 and adjust registration
Upgraded AutoMapper to version 16.1.1 across API, Application, and Infrastructure projects. Removed AutoMapper.Extensions.Microsoft.DependencyInjection where no longer needed. Updated AutoMapper registration in DependencyInjection.cs to use the new API. No other changes made.
2026-04-22 09:35:50 +02:00
OlgunR
f54de87ca2 Standardize string property initialization to string.Empty
Replaced String.Empty and null! with string.Empty for string properties
in CatalogReadDto, CatalogWriteDto, and VwmyCatalog classes. This
ensures consistent initialization and helps prevent null reference
issues across the codebase.
2026-04-22 09:25:03 +02:00
OlgunR
1b4205219f Set string defaults to String.Empty in CatalogReadDto
Changed default values of CatTitle, CatString, and AddedWho from null! to String.Empty to prevent potential null reference issues.
2026-04-21 17:01:44 +02:00
OlgunR
b35c167648 Add [FromBody] to DTO params in controller actions
Explicitly annotate DTO parameters with [FromBody] in CatalogsController (Create, Update) and MassDataController (Upsert) to ensure correct model binding from the request body and improve API clarity.
2026-04-21 15:22:33 +02:00
OlgunR
087708dcf7 Use array literal [] for empty CORS origins default
Replaces Array.Empty<string>() with the C# array literal [] when defaulting the CORS allowed origins array. This is a syntactic improvement with no change in behavior.
2026-04-21 15:22:19 +02:00
OlgunR
be40c39f47 Initialize VwmyCatalog string properties with String.Empty
Changed default values of CatTitle, CatString, and AddedWho from null! to String.Empty to prevent potential null reference issues and ensure safer property initialization.
2026-04-21 13:43:33 +02:00
OlgunR
4fa3bcae3d Make dashboard change notifications async and robust
Refactored DashboardChangeNotifier and IDashboardChangeNotifier to use async notification with improved error handling and logging. Updated SqlDashboardStorage to call the new async notification method after dashboard changes, ensuring non-blocking and reliable client updates.
2026-04-21 13:42:31 +02:00
OlgunR
612d8371d3 Improve exception handling with ProblemDetails responses
ExceptionHandlingMiddleware now returns structured ProblemDetails
responses for errors, including specific handling for
InvalidOperationException (400 Bad Request) and general exceptions
(500 Internal Server Error). Responses use "application/problem+json"
content type and include trace IDs. ProblemDetails support is also
registered in Program.cs.
2026-04-21 13:18:59 +02:00
OlgunR
b7e66ab5f9 Update MediatR and DI packages; refactor registration
Upgraded to MediatR 14.1.0 and updated registration to use the new syntax. Removed MediatR.Extensions.Microsoft.DependencyInjection where no longer needed. Bumped Microsoft.Extensions.DependencyInjection.Abstractions to 10.0.0 in Application and Infrastructure projects.
2026-04-21 12:01:23 +02:00
OlgunR
95a388015a Remove GetAllAsync method from MassDataRepository
The GetAllAsync method, which returned all Massdata records, has been removed from the MassDataRepository class. No other changes were made in this commit.
2026-04-21 10:30:10 +02:00
OlgunR
c16f0483c3 Remove unused using directives from API and client files
Cleaned up unnecessary System.Net and System.Net.Http.Json usings across multiple files, including controllers and API client classes, to reduce dependencies and improve code clarity.
2026-04-21 10:16:05 +02:00
OlgunR
83292f23f3 Refactor dashboard config to factory class
Move DashboardConfigurator setup from Program.cs to a new static DashboardConfiguratorFactory. This centralizes dashboard file creation, data source registration, and storage logic, improving code organization and maintainability. Unused usings are cleaned up accordingly.
2026-04-21 10:09:50 +02:00
OlgunR
c73c7e63fe Move LayoutDto to its own file and update namespace
Separated LayoutDto from LayoutsController.cs into LayoutDto.cs and set its namespace to DbFirst.Application.Layouts for better code organization and maintainability.
2026-04-20 16:51:57 +02:00
OlgunR
3e78e2e2cf Move catalog title update validation to handler
Validation preventing CatTitle changes during updates is now enforced in UpdateCatalogHandler instead of the controller. The handler throws an exception if a title change is attempted. Also, streamlined UpdateProcedure handling and removed redundant CatTitle mapping logic.
2026-04-20 16:38:29 +02:00
OlgunR
05825b6815 Refactor DI: move repository registrations to Infrastructure
Centralize repository service registrations in Infrastructure's
DependencyInjection.cs for better maintainability and separation
of concerns. Remove direct registrations from Program.cs and
add necessary using statements.
2026-04-20 16:28:37 +02:00
OlgunR
4720c8f87b Use UTC for AddedWhen timestamps in layout repository
Changed AddedWhen to use DateTime.UtcNow instead of DateTime.Now to ensure timestamps are stored in UTC, avoiding issues with local time zones.
2026-04-20 16:22:18 +02:00
OlgunR
0c936d0bf9 Adopt C# 12 collection expressions for empty lists/dicts
Refactored code to use C# 12 collection expressions ([]) for initializing empty lists and dictionaries instead of the older constructors. This change modernizes and simplifies collection initialization across models, services, and API clients without altering any logic.
2026-04-20 14:33:08 +02:00
OlgunR
fcbc66f8f5 Add CancellationToken support to all API client methods
All API client interfaces and implementations now accept an optional CancellationToken parameter for each method. This enables consumers to cancel HTTP requests, improving responsiveness and resource management. No changes were made to core logic or return types; only method signatures and HttpClient calls were updated to support cancellation.
2026-04-20 13:50:53 +02:00
OlgunR
aab6478f9a Refactor API clients to use interface-based DI
Introduce interfaces for all API clients and update dependency injection to use these interfaces. Refactor services and components to depend on abstractions instead of concrete implementations, improving testability and maintainability.
2026-04-20 13:23:16 +02:00
OlgunR
177d418ac3 Use InvokeAsync for async state updates in BandGridBase
Replaced direct StateHasChanged() calls with InvokeAsync(StateHasChanged) to ensure asynchronous state updates. This helps prevent issues that can arise from synchronous state changes during component lifecycle events or event callbacks in Blazor.
2026-04-20 12:59:27 +02:00
OlgunR
7cc88c13f3 Cache layout user to reduce localStorage access
Add a private _cachedLayoutUser field to BandLayoutService and update EnsureLayoutUserAsync to cache the layout user value after first retrieval or generation. This avoids repeated localStorage calls and improves performance.
2026-04-20 11:48:50 +02:00
OlgunR
8bf172755b Update using directives in _Imports.razor for clarity
Added @using DbFirst.BlazorWebApp and reorganized the placement of @using Microsoft.Extensions.Options to improve code organization and maintain consistent dependency order.
2026-04-20 11:34:15 +02:00
OlgunR
27c8f92a3b Refactor API base URL config to use AppSettings class
Replaced direct IConfiguration usage with a strongly-typed AppSettings class for accessing the API base URL. Registered AppSettings with DI and updated Dashboard.razor to use IOptions<AppSettings>. Updated using statements and DI setup for improved type safety and centralized configuration management.
2026-04-20 11:32:45 +02:00
OlgunR
cd0a824064 Refactor grid logic into BandGridBase<TItem> base class
Move shared state and methods from CatalogsGrid and MassDataGrid into BandGridBase<TItem>. This centralizes edit context handling, validation, popup header logic, row editing/deleting, and layout feedback, reducing duplication and improving maintainability. Individual grid components now only override OnEditFieldChanged for custom validation.
2026-04-20 10:52:05 +02:00
OlgunR
0008fac1d2 Refactor HTTP client registration for API clients
Simplified registration of CatalogApiClient, DashboardApiClient, MassDataApiClient, and LayoutApiClient by introducing a ConfigureClient method. This method sets the BaseAddress if ApiBaseUrl is configured, removing the previous if-else logic and reducing code duplication.
2026-04-20 10:29:17 +02:00
OlgunR
4659913711 Move ApiResult<T> to ApiResult.cs with proper namespace
ApiResult<T> and its static methods were relocated from CatalogApiClient.cs to a new ApiResult.cs file. The new file now includes the DbFirst.BlazorWebApp.Models namespace for better code organization.
2026-04-20 10:24:47 +02:00
OlgunR
bb23cb6629 Refactor error handling to use ApiClientHelper
Replaced the private ReadErrorAsync method in LayoutApiClient with calls to ApiClientHelper.ReadErrorAsync, centralizing error handling logic for improved consistency and reuse.
2026-04-20 10:19:40 +02:00
OlgunR
b3f7df6801 Initialize SignalR hub in OnAfterRenderAsync after first render
Moved SignalR hub connection setup from OnInitializedAsync to OnAfterRenderAsync, ensuring initialization occurs only after the component's first render. This prevents premature connection attempts and aligns with best practices for component lifecycle management.
2026-04-20 10:08:24 +02:00
OlgunR
3a1cb68cf0 Change OnCustomizeEditModel to synchronous void method
Converted OnCustomizeEditModel in MassDataGrid.razor from async Task to synchronous void, removing asynchronous support from this method.
2026-04-17 14:28:50 +02:00
OlgunR
c93518202b Add custom toolbar actions with icons to grids
Introduce Add, Edit, and Delete buttons with Bootstrap Icons in the toolbars of CatalogsGrid and MassDataGrid. Hide the default command column by overriding ShowCommandColumn. Update BandGridBase to conditionally render the command column. Track focused row index for toolbar actions, enabling row operations via toolbar instead of the grid's built-in command column. Add Bootstrap Icons stylesheet to App.razor.
2026-04-17 12:08:39 +02:00
OlgunR
6792b426ff Enhance grid UI: column chooser button & default widths
- Set default width and hide new button for DxGridBandColumn in BandGridBase.cs
- Always show column chooser button in CatalogsGrid and MassDataGrid
- Add "Spalten" toolbar button to open column chooser dialog
- Improves accessibility and consistency of grid column customization
2026-04-16 16:02:31 +02:00
OlgunR
6ac48a472d Simplify Fluent theme registration in App.razor
Replaced custom Fluent theme registration (with ApplyToPageElements set to true) with the default Fluent theme registration, removing unnecessary property customization for a cleaner setup.
2026-04-15 14:16:35 +02:00
OlgunR
96dfd5b3c6 Set default values for CarouselData string properties
Initialized Source and AlternateText with empty strings in the
CarouselData class to prevent potential null reference issues.
2026-04-15 14:16:03 +02:00
OlgunR
810771f385 Remove auto-refresh of dashboards in OnParametersSetAsync
Previously, RefreshDashboards() was called automatically if the dashboards list was empty during parameter setting. This logic has been removed, so dashboards will no longer refresh automatically in this scenario.
2026-04-15 14:08:14 +02:00
OlgunR
a0e0d7ed03 Centralize API error handling in ApiClientHelper
Refactored error handling logic for API responses into a new static ApiClientHelper class, consolidating the ReadErrorAsync method and ProblemDetailsDto. Updated CatalogApiClient and MassDataApiClient to use the shared helper, removing redundant local methods and classes. This change improves consistency, reduces duplication, and streamlines error message formatting across the application.
2026-04-15 13:55:57 +02:00
OlgunR
39cb63a78d Refactor UpsertAsync to use ApiResult for error handling
MassDataApiClient.UpsertAsync now returns ApiResult to standardize success and error reporting, including detailed error extraction from API responses. Updated MassDataGrid.razor to handle the new result type and display error messages accordingly. Removed obsolete try-catch logic in favor of the new pattern.
2026-04-15 11:27:48 +02:00
OlgunR
7d08923444 Remove unused bandEditorExpanded field from BandGridBase
The protected bool bandEditorExpanded, which tracked the expansion
state of the band editor, has been removed from BandGridBase<TItem>
as it is no longer used. This helps clean up unused state from
the component.
2026-04-15 11:01:22 +02:00
OlgunR
11374347d3 Refactor band editor into reusable BandEditor component
Extract band editor UI and logic from CatalogsGrid.razor and MassDataGrid.razor into a new BandEditor.razor component. This centralizes band management features (add/remove bands, edit captions, assign columns, save/reset layout) and reduces code duplication, improving maintainability and reusability. Existing UI and event handling remain functionally unchanged.
2026-04-15 10:57:28 +02:00
OlgunR
7552b34ced Refactor MassDataGrid to use BandGridBase for layout logic
Move band and column management to BandGridBase, reducing duplication in MassDataGrid. Column definitions and layout key are now provided via overridden properties. Band editor actions now use feedback methods for user messages. Grid layout application and error display are streamlined. Improves maintainability and enables reuse across grid components.
2026-04-14 16:55:14 +02:00
OlgunR
13d134df0e Refactor CatalogsGrid to use BandGridBase and async layout ops
Refactored CatalogsGrid.razor to inherit from BandGridBase<CatalogReadDto>, moving band layout and grid logic into the base class. Updated UI to use new async methods for saving and resetting the layout with user feedback. Removed redundant local fields and methods, centralizing layout management and improving maintainability and code reuse. Error and info messages are now shown after layout operations.
2026-04-14 16:37:59 +02:00
OlgunR
285f029311 Add BandGridBase<TItem> for reusable banded grid logic
Introduced an abstract base class BandGridBase<TItem> to centralize banded grid functionality in Blazor apps using DevExpress. This class manages band layouts, column definitions, user-specific layout persistence, grid size modes, and rendering logic. It integrates with BandLayoutService for layout storage and retrieval, and is intended for inheritance by specific grid components.
2026-04-14 16:10:14 +02:00
OlgunR
cdf225bad1 Add full project documentation (PROJEKTDOKUMENTATION.md)
Comprehensive documentation for the DbFirst solution, detailing project goals, architecture (Clean Architecture), technology stack, solution/project structure, Database-First approach, CQRS with MediatR, API endpoints, Blazor/DevExpress frontend, dashboard and layout persistence, SignalR integration, configuration, development setup, architecture decisions (ADRs), extensibility, and glossary. Includes diagrams, code snippets, and workflow guidance for the developer team.
2026-04-09 15:45:32 +02:00
OlgunR
59e051f162 Test push für Marvin No. 4 2026-03-31 11:52:42 +02:00
OlgunR
1352dda20e Test push für Marvin No. 3 2026-03-31 10:39:12 +02:00
OlgunR
33b5b03bf4 Test push für Marvin No. 2 2026-03-31 09:42:46 +02:00
OlgunR
6b986db62e Test push für Marvin 2026-03-31 09:33:08 +02:00
OlgunR
36ade1b26b Carousel - Home 2026-03-30 14:56:51 +02:00
OlgunR
d422d841ff Add zebra-striping to grid rows with theme support
Introduced --grid-stripe-bg CSS variable for both light and dark themes to enable zebra-striping in dxbl-grid components. Applied background color to even-numbered grid rows for improved readability.
2026-03-27 14:13:16 +01:00
OlgunR
ea1b2ea6e4 Update band editor label and move page size selector
Changed band editor toggle label to "Layout" in both CatalogsGrid.razor and MassDataGrid.razor. Relocated the page size selector in MassDataGrid.razor to appear after the band editor section for improved layout consistency.
2026-03-27 09:54:23 +01:00
OlgunR
6101561e72 Add collapsible band editor to grid components
Introduce a toggleable "Band-Layout konfigurieren" section in CatalogsGrid.razor and MassDataGrid.razor, allowing users to expand or collapse the band editor UI. Added bandEditorExpanded state to control visibility. Updated CSS to style the new toggle button and its expanded/collapsed states, improving usability and reducing UI clutter.
2026-03-27 09:46:15 +01:00
OlgunR
64fb76b9e6 Add theme variables for band editor background and border
Introduce CSS variables for band editor colors in light and dark themes. Update .band-editor to use these variables, enabling automatic adaptation to the active theme.
2026-03-27 09:11:56 +01:00
OlgunR
4ac8e94334 Refactor: centralize grid/editor CSS in app.css
Consolidate shared grid and band-editor styles from CatalogsGrid.razor.css and MassDataGrid.razor.css into app.css for improved maintainability and consistency. Only component-specific min-width rules remain in the respective files. Also includes minor formatting cleanups in app.css.
2026-03-25 17:15:55 +01:00
OlgunR
dc74d21426 Refactor: centralize grid band layout logic in service
Introduce BandLayoutService and shared models to manage grid band layouts across components. Refactor CatalogsGrid and MassDataGrid to use the new service, removing duplicated layout logic. Update _Imports.razor and register the service in Program.cs for improved maintainability and code reuse.
2026-03-25 17:04:19 +01:00
OlgunR
566c3b3276 Move dashboard styles to Dashboard.razor.css file
Separated CSS from Dashboard.razor by moving all dashboard-related styles into a new Dashboard.razor.css file. This improves maintainability and keeps styling concerns separate from markup and logic.
2026-03-25 13:34:52 +01:00
OlgunR
ac84866abe Extract inline styles to .razor.css files for grids
Moved CSS from inline <style> blocks in CatalogsGrid.razor and MassDataGrid.razor to dedicated CatalogsGrid.razor.css and MassDataGrid.razor.css files. This improves maintainability and keeps styling concerns separate from component logic. No changes to the actual styles were made.
2026-03-25 12:02:30 +01:00
OlgunR
d9ce4a5dca Move common usings to _Imports.razor for global access
Removed redundant using directives from CatalogsGrid.razor and MassDataGrid.razor. Added them to _Imports.razor to ensure global availability across components. Also added @using DbFirst.BlazorWebApp to _Imports.razor for consistency.
2026-03-25 10:49:51 +01:00
OlgunR
13617dde87 Remove duplicate DevExpress.Blazor package reference
Cleaned up the project file by removing a redundant <ItemGroup>
that contained a duplicate DevExpress.Blazor package reference.
Ensured that each package is referenced only once and that
Microsoft.AspNetCore.SignalR.Client remains in its own group.
2026-03-25 10:45:57 +01:00
OlgunR
789066a214 Replace HTML entities with Latin-1 bytes in UI strings
Replaced HTML-encoded German special characters with their Windows-1252 (Latin-1) encoded byte equivalents in user-facing strings in CatalogsGrid.razor and MassDataGrid.razor. Renamed and expanded the reset layout method for improved layout reset behavior. Updated button labels and info messages for consistency. These changes address encoding and rendering issues by shifting from HTML entities to direct byte encoding.
2026-02-23 09:13:12 +01:00
OlgunR
964d508630 Persist grid SizeMode in BandLayout for layout restore
Added SizeMode property to BandLayout in both CatalogsGrid.razor and MassDataGrid.razor. The grid's current SizeMode is now saved and restored with the layout, ensuring user preferences for grid sizing persist across sessions. This improves the consistency of the user experience when reloading or switching layouts.
2026-02-23 08:55:53 +01:00
OlgunR
c5ca9f0048 Add grid font size selector and fix German character display
Introduced font size adjustment for grids via dropdown toolbar and CSS variable. Added JavaScript for dynamic font size changes. Replaced German umlauts with HTML entities for proper rendering. Refactored grid code and improved error/info message display. Enhances accessibility and user experience.
2026-02-19 17:01:33 +01:00
OlgunR
e7aa41aa4d Add dark mode toggle with ThemeState service
Implement dark mode support using a new ThemeState service that manages theme switching and integrates with DevExpress Blazor theming. Update App.razor to apply the theme globally, enhance MainLayout with a toggle button and dynamic CSS classes, and add dark mode styles to CSS files. Register ThemeState as a scoped service in Program.cs.
2026-02-18 10:07:14 +01:00
OlgunR
9a4f189e4e Add row focusing support to CatalogsGrid and MassDataGrid
Enable row focusing in both grid components by introducing the focusedRowKey property and binding it to the grid. After create or update operations, set focusedRowKey to the affected item's key, ensuring the grid automatically focuses the relevant row. Improves user experience by highlighting newly created or updated items.
2026-02-17 12:40:37 +01:00
OlgunR
797dd44f25 Remove BlazorWasm project and all related frontend assets
This commit deletes the entire Blazor WebAssembly frontend project, including all source code, components, DTOs, services, CSS, configuration files, static assets (such as favicon and icons), and the main HTML entry point. All references to `DbFirst.BlazorWasm` have been removed from the solution file, and associated dependencies like Bootstrap CSS and its source map are also deleted. This effectively removes the BlazorWasm application from the repository.
2026-02-11 08:15:47 +01:00
OlgunR
0e313b2e07 Remove custom grid sort/filter CSS from MassDataGrid
Deleted styles for custom sort icons, filter row layout, and search input in MassDataGrid. Retained only general layout and popup/editor styles, reverting to default grid visuals.
2026-02-10 13:36:08 +01:00
OlgunR
db232914a9 Switch grids to built-in filter menu, remove custom filters
Removed custom filter row templates and operator logic for date and number columns in CatalogsGrid.razor and MassDataGrid.razor. Grids now use DevExpress's built-in filter menu (FilterMenuButtonDisplayMode.Always), simplifying code and UI. All related filter fields and methods were deleted.
2026-02-10 13:32:21 +01:00
OlgunR
f49db97147 Remove DevExpress theme CSS links from App.razor
Removed references to fluent-light.bs5.min.css and bootstrap-external.bs5.min.css from App.razor. These DevExpress Blazor theme stylesheets are no longer included in the application. All other stylesheets remain unchanged.
2026-02-10 13:17:29 +01:00
OlgunR
0876ebe999 Update DevExpress Blazor to use Bootstrap 5 theme
Replaced Fluent theme CSS with Bootstrap 5 Fluent Light theme. Updated DevExpress Blazor service registration to explicitly use BootstrapVersion.v5 for consistent styling. Cleaned up and reordered CSS includes.
2026-02-10 09:54:39 +01:00
OlgunR
74504c583e Improve loading UX with spinner and wider filter fields
Replaced plain loading text with a centered Bootstrap spinner in CatalogsGrid and MassDataGrid. Introduced hasLoaded flag for more accurate loading state handling. Increased min-width of filter input fields for better usability. Added .loading-container CSS for consistent spinner placement.
2026-02-09 16:40:09 +01:00
129 changed files with 2913 additions and 5050 deletions

View File

@@ -1,7 +1,7 @@
using DbFirst.Application.Catalogs;
using DbFirst.Application.Catalogs.Commands;
using DbFirst.Application.Catalogs.Queries;
using DbFirst.Domain;
using DbFirst.Contracts.Catalogs;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@@ -37,7 +37,7 @@ public class CatalogsController : ControllerBase
}
[HttpPost]
public async Task<ActionResult<CatalogReadDto>> Create(CatalogWriteDto dto, CancellationToken cancellationToken)
public async Task<ActionResult<CatalogReadDto>> Create([FromBody]CatalogWriteDto dto, CancellationToken cancellationToken)
{
var created = await _mediator.Send(new CreateCatalogCommand(dto), cancellationToken);
if (created == null)
@@ -48,19 +48,8 @@ public class CatalogsController : ControllerBase
}
[HttpPut("{id:int}")]
public async Task<ActionResult<CatalogReadDto>> Update(int id, CatalogWriteDto dto, CancellationToken cancellationToken)
public async Task<ActionResult<CatalogReadDto>> Update(int id, [FromBody] CatalogWriteDto dto, CancellationToken cancellationToken)
{
var current = await _mediator.Send(new GetCatalogByIdQuery(id), cancellationToken);
if (current == null)
{
return NotFound();
}
if (dto.UpdateProcedure == CatalogUpdateProcedure.Update &&
!string.Equals(current.CatTitle, dto.CatTitle, StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Titel kann nicht geändert werden.");
}
var updated = await _mediator.Send(new UpdateCatalogCommand(id, dto), cancellationToken);
if (updated == null)
{

View File

@@ -1,7 +1,8 @@
using System.Text;
using DbFirst.Application.Repositories;
using DbFirst.Contracts.Layouts;
using DbFirst.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using System.Text;
namespace DbFirst.API.Controllers;
@@ -83,12 +84,4 @@ public class LayoutsController : ControllerBase
LayoutData = layoutData
};
}
public sealed class LayoutDto
{
public string LayoutType { get; set; } = string.Empty;
public string LayoutKey { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string LayoutData { get; set; } = string.Empty;
}
}

View File

@@ -1,6 +1,6 @@
using DbFirst.Application.MassData;
using DbFirst.Application.MassData.Commands;
using DbFirst.Application.MassData.Queries;
using DbFirst.Contracts.MassData;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@@ -50,7 +50,7 @@ public class MassDataController : ControllerBase
}
[HttpPost("upsert")]
public async Task<ActionResult<MassDataReadDto>> Upsert(MassDataWriteDto dto, CancellationToken cancellationToken)
public async Task<ActionResult<MassDataReadDto>> Upsert([FromBody]MassDataWriteDto dto, CancellationToken cancellationToken)
{
var result = await _mediator.Send(new UpsertMassDataByCustomerNameCommand(dto), cancellationToken);
return Ok(result);

View File

@@ -6,14 +6,23 @@ namespace DbFirst.API.Dashboards;
public class DashboardChangeNotifier : IDashboardChangeNotifier
{
private readonly IHubContext<DashboardsHub> _hubContext;
private readonly ILogger<DashboardChangeNotifier> _logger;
public DashboardChangeNotifier(IHubContext<DashboardsHub> hubContext)
public DashboardChangeNotifier(IHubContext<DashboardsHub> hubContext, ILogger<DashboardChangeNotifier> logger)
{
_hubContext = hubContext;
_logger = logger;
}
public void NotifyChanged()
public async Task NotifyChangedAsync()
{
_ = _hubContext.Clients.All.SendAsync("DashboardsChanged");
try
{
await _hubContext.Clients.All.SendAsync("DashboardsChanged");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to notify dashboard clients.");
}
}
}

View File

@@ -0,0 +1,115 @@
using DbFirst.Domain.Entities;
using DevExpress.DashboardCommon;
using DevExpress.DashboardWeb;
using DevExpress.DataAccess.Json;
using System.Xml.Linq;
namespace DbFirst.API.Dashboards;
public static class DashboardConfiguratorFactory
{
public static DashboardConfigurator Create(
IServiceProvider serviceProvider,
IConfiguration configuration,
IWebHostEnvironment environment)
{
// Den gesamten Inhalt des Lambdas hierher verschieben
// serviceProvider, configuration, environment statt builder.* verwenden
var dashboardsPath = Path.Combine(environment.ContentRootPath, "Data", "Dashboards");
Directory.CreateDirectory(dashboardsPath);
var defaultDashboardPath = Path.Combine(dashboardsPath, "DefaultDashboard.xml");
if (!File.Exists(defaultDashboardPath))
{
var defaultDashboard = new Dashboard();
defaultDashboard.Title.Text = "Default Dashboard";
defaultDashboard.SaveToXml(defaultDashboardPath);
}
var dashboardBaseUrl = configuration["Dashboard:BaseUrl"]
?? configuration["ApiBaseUrl"]
?? configuration["ASPNETCORE_URLS"]?.Split(';', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()
?? "https://localhost:7204";
dashboardBaseUrl = dashboardBaseUrl.TrimEnd('/');
var catalogsGridDashboardPath = Path.Combine(dashboardsPath, "CatalogsGrid.xml");
if (!File.Exists(catalogsGridDashboardPath))
{
var dashboard = new Dashboard();
dashboard.Title.Text = "Catalogs (Dashboard Grid)";
var catalogDataSource = new DashboardJsonDataSource("Catalogs (API)")
{
ComponentName = "catalogsDataSource",
JsonSource = new UriJsonSource(new Uri($"{dashboardBaseUrl}/api/catalogs"))
};
dashboard.DataSources.Add(catalogDataSource);
var grid = new GridDashboardItem
{
DataSource = catalogDataSource,
Name = "Catalogs"
};
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.Guid))) { Name = "Id" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.CatTitle))) { Name = "Titel" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.CatString))) { Name = "String" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.AddedWho))) { Name = "Angelegt von" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.AddedWhen))) { Name = "Angelegt am" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.ChangedWho))) { Name = "Geändert von" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.ChangedWhen))) { Name = "Geändert am" });
dashboard.Items.Add(grid);
var layoutGroup = new DashboardLayoutGroup { Orientation = DashboardLayoutGroupOrientation.Vertical };
layoutGroup.ChildNodes.Add(new DashboardLayoutItem(grid));
dashboard.LayoutRoot = layoutGroup;
dashboard.SaveToXml(catalogsGridDashboardPath);
}
DashboardConfigurator configurator = new DashboardConfigurator();
var connectionString = configuration.GetConnectionString("DefaultConnection") ?? string.Empty;
var notifier = serviceProvider.GetRequiredService<IDashboardChangeNotifier>();
var dashboardStorage = new SqlDashboardStorage(connectionString, "TBDD_SMF_CONFIG", notifier: notifier);
configurator.SetDashboardStorage(dashboardStorage);
DataSourceInMemoryStorage dataSourceStorage = new DataSourceInMemoryStorage();
DashboardJsonDataSource jsonDataSourceUrl = new DashboardJsonDataSource("JSON Data Source (URL)");
jsonDataSourceUrl.JsonSource = new UriJsonSource(
new Uri("https://raw.githubusercontent.com/DevExpress-Examples/DataSources/master/JSON/customers.json"));
jsonDataSourceUrl.RootElement = "Customers";
dataSourceStorage.RegisterDataSource("jsonDataSourceUrl", jsonDataSourceUrl.SaveToXml());
var catalogsJsonDataSource = new DashboardJsonDataSource("Catalogs (API)")
{
ComponentName = "catalogsDataSource",
JsonSource = new UriJsonSource(new Uri($"{dashboardBaseUrl}/api/catalogs"))
};
dataSourceStorage.RegisterDataSource(catalogsJsonDataSource.ComponentName, catalogsJsonDataSource.SaveToXml());
dataSourceStorage.RegisterDataSource(catalogsJsonDataSource.Name, catalogsJsonDataSource.SaveToXml());
configurator.SetDataSourceStorage(dataSourceStorage);
EnsureDashboardInStorage(dashboardStorage, "DefaultDashboard", defaultDashboardPath);
EnsureDashboardInStorage(dashboardStorage, "CatalogsGrid", catalogsGridDashboardPath);
return configurator;
}
private static void EnsureDashboardInStorage(IEditableDashboardStorage storage, string id, string filePath)
{
var exists = storage.GetAvailableDashboardsInfo().Any(info => string.Equals(info.ID, id, StringComparison.OrdinalIgnoreCase));
if (exists || !File.Exists(filePath))
{
return;
}
var doc = XDocument.Load(filePath);
storage.AddDashboard(doc, id);
}
}

View File

@@ -2,5 +2,5 @@ namespace DbFirst.API.Dashboards;
public interface IDashboardChangeNotifier
{
void NotifyChanged();
Task NotifyChangedAsync();
}

View File

@@ -1,8 +1,8 @@
using DevExpress.DashboardWeb;
using Microsoft.Data.SqlClient;
using System.Data;
using System.Text;
using System.Xml.Linq;
using DevExpress.DashboardWeb;
using Microsoft.Data.SqlClient;
namespace DbFirst.API.Dashboards;
@@ -100,7 +100,7 @@ public sealed class SqlDashboardStorage : IEditableDashboardStorage
connection.Open();
command.ExecuteNonQuery();
_notifier?.NotifyChanged();
_ = _notifier?.NotifyChangedAsync();
return id;
}
@@ -122,7 +122,7 @@ public sealed class SqlDashboardStorage : IEditableDashboardStorage
throw new ArgumentException($"Dashboard '{dashboardId}' not found.");
}
_notifier?.NotifyChanged();
_ = _notifier?.NotifyChangedAsync();
}
public void DeleteDashboard(string dashboardId)
@@ -133,6 +133,6 @@ public sealed class SqlDashboardStorage : IEditableDashboardStorage
connection.Open();
command.ExecuteNonQuery();
_notifier?.NotifyChanged();
_ = _notifier?.NotifyChangedAsync();
}
}

View File

@@ -7,8 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="AutoMapper" Version="16.1.1" />
<PackageReference Include="DevExpress.AspNetCore.Dashboard" Version="25.2.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.22" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.22" />
@@ -17,7 +16,6 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,5 +1,4 @@
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
namespace DbFirst.API.Middleware;
@@ -20,33 +19,35 @@ public class ExceptionHandlingMiddleware
{
await _next(context);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Domain validation error");
await WriteProblemAsync(context, StatusCodes.Status400BadRequest, "Eingabe ungültig", ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
await WriteProblemDetailsAsync(context, ex);
await WriteProblemAsync(context, StatusCodes.Status500InternalServerError, "Serverfehler", ex.Message);
}
}
private static async Task WriteProblemDetailsAsync(HttpContext context, Exception ex)
private static async Task WriteProblemAsync(HttpContext context, int status, string title, string detail)
{
if (context.Response.HasStarted)
{
throw ex;
}
if (context.Response.HasStarted) return;
context.Response.Clear();
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/json";
context.Response.StatusCode = status;
context.Response.ContentType = "application/problem+json";
var problem = new
var problem = new ProblemDetails
{
type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
title = "Serverfehler",
status = context.Response.StatusCode,
detail = ex.Message,
traceId = context.TraceIdentifier
Type = $"https://tools.ietf.org/html/rfc9110#section-{(status == 400 ? "15.5.1" : "15.6.1")}",
Title = title,
Status = status,
Detail = detail,
Extensions = { ["traceId"] = context.TraceIdentifier }
};
await context.Response.WriteAsync(JsonSerializer.Serialize(problem));
await context.Response.WriteAsJsonAsync(problem);
}
}

View File

@@ -1,18 +1,11 @@
using DbFirst.API.Middleware;
using DbFirst.API.Dashboards;
using DbFirst.API.Hubs;
using DbFirst.API.Middleware;
using DbFirst.Application;
using DbFirst.Application.Repositories;
using DbFirst.Domain;
using DbFirst.Domain.Entities;
using DbFirst.Infrastructure;
using DbFirst.Infrastructure.Repositories;
using DevExpress.AspNetCore;
using DevExpress.DashboardAspNetCore;
using DevExpress.DashboardCommon;
using DevExpress.DashboardWeb;
using DevExpress.DataAccess.Json;
using System.Xml.Linq;
var builder = WebApplication.CreateBuilder(args);
@@ -20,6 +13,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddProblemDetails();
// TODO: allow listed origins configured in appsettings.json
// In any case, dont let them to free to use without cors. if there is no origin specified, block all.
@@ -36,7 +30,7 @@ builder.Services.AddCors(options =>
}
else
{
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
if (origins.Length > 0)
{
policy.WithOrigins(origins)
@@ -51,98 +45,11 @@ builder.Services.AddCors(options =>
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication();
builder.Services.AddScoped<ICatalogRepository, CatalogRepository>();
builder.Services.AddScoped<IMassDataRepository, MassDataRepository>();
builder.Services.AddScoped<ILayoutRepository, LayoutRepository>();
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);
var defaultDashboardPath = Path.Combine(dashboardsPath, "DefaultDashboard.xml");
if (!File.Exists(defaultDashboardPath))
{
var defaultDashboard = new Dashboard();
defaultDashboard.Title.Text = "Default Dashboard";
defaultDashboard.SaveToXml(defaultDashboardPath);
}
var dashboardBaseUrl = builder.Configuration["Dashboard:BaseUrl"]
?? builder.Configuration["ApiBaseUrl"]
?? builder.Configuration["ASPNETCORE_URLS"]?.Split(';', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()
?? "https://localhost:7204";
dashboardBaseUrl = dashboardBaseUrl.TrimEnd('/');
var catalogsGridDashboardPath = Path.Combine(dashboardsPath, "CatalogsGrid.xml");
if (!File.Exists(catalogsGridDashboardPath))
{
var dashboard = new Dashboard();
dashboard.Title.Text = "Catalogs (Dashboard Grid)";
var catalogDataSource = new DashboardJsonDataSource("Catalogs (API)")
{
ComponentName = "catalogsDataSource",
JsonSource = new UriJsonSource(new Uri($"{dashboardBaseUrl}/api/catalogs"))
};
dashboard.DataSources.Add(catalogDataSource);
var grid = new GridDashboardItem
{
DataSource = catalogDataSource,
Name = "Catalogs"
};
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.Guid))) { Name = "Id" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.CatTitle))) { Name = "Titel" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.CatString))) { Name = "String" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.AddedWho))) { Name = "Angelegt von" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.AddedWhen))) { Name = "Angelegt am" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.ChangedWho))) { Name = "Geändert von" });
grid.Columns.Add(new GridDimensionColumn(new Dimension(nameof(VwmyCatalog.ChangedWhen))) { Name = "Geändert am" });
dashboard.Items.Add(grid);
var layoutGroup = new DashboardLayoutGroup { Orientation = DashboardLayoutGroupOrientation.Vertical };
layoutGroup.ChildNodes.Add(new DashboardLayoutItem(grid));
dashboard.LayoutRoot = layoutGroup;
dashboard.SaveToXml(catalogsGridDashboardPath);
}
DashboardConfigurator configurator = new DashboardConfigurator();
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? string.Empty;
var notifier = serviceProvider.GetRequiredService<IDashboardChangeNotifier>();
var dashboardStorage = new SqlDashboardStorage(connectionString, "TBDD_SMF_CONFIG", notifier: notifier);
configurator.SetDashboardStorage(dashboardStorage);
DataSourceInMemoryStorage dataSourceStorage = new DataSourceInMemoryStorage();
DashboardJsonDataSource jsonDataSourceUrl = new DashboardJsonDataSource("JSON Data Source (URL)");
jsonDataSourceUrl.JsonSource = new UriJsonSource(
new Uri("https://raw.githubusercontent.com/DevExpress-Examples/DataSources/master/JSON/customers.json"));
jsonDataSourceUrl.RootElement = "Customers";
dataSourceStorage.RegisterDataSource("jsonDataSourceUrl", jsonDataSourceUrl.SaveToXml());
var catalogsJsonDataSource = new DashboardJsonDataSource("Catalogs (API)")
{
ComponentName = "catalogsDataSource",
JsonSource = new UriJsonSource(new Uri($"{dashboardBaseUrl}/api/catalogs"))
};
dataSourceStorage.RegisterDataSource(catalogsJsonDataSource.ComponentName, catalogsJsonDataSource.SaveToXml());
dataSourceStorage.RegisterDataSource(catalogsJsonDataSource.Name, catalogsJsonDataSource.SaveToXml());
configurator.SetDataSourceStorage(dataSourceStorage);
EnsureDashboardInStorage(dashboardStorage, "DefaultDashboard", defaultDashboardPath);
EnsureDashboardInStorage(dashboardStorage, "CatalogsGrid", catalogsGridDashboardPath);
return configurator;
});
builder.Services.AddScoped<DashboardConfigurator>(sp =>
DashboardConfiguratorFactory.Create(sp, builder.Configuration, builder.Environment));
var app = builder.Build();
@@ -164,16 +71,4 @@ app.MapDashboardRoute("api/dashboard", "DefaultDashboard");
app.MapHub<DashboardsHub>("/hubs/dashboards");
app.MapControllers();
app.Run();
static void EnsureDashboardInStorage(IEditableDashboardStorage storage, string id, string filePath)
{
var exists = storage.GetAvailableDashboardsInfo().Any(info => string.Equals(info.ID, id, StringComparison.OrdinalIgnoreCase));
if (exists || !File.Exists(filePath))
{
return;
}
var doc = XDocument.Load(filePath);
storage.AddDashboard(doc, id);
}
app.Run();

View File

@@ -1,4 +1,5 @@
using AutoMapper;
using DbFirst.Contracts.Catalogs;
using DbFirst.Domain.Entities;
namespace DbFirst.Application.Catalogs;

View File

@@ -1,12 +0,0 @@
namespace DbFirst.Application.Catalogs;
public class CatalogReadDto
{
public int Guid { get; set; }
public string CatTitle { get; set; } = null!;
public string CatString { get; set; } = null!;
public string AddedWho { get; set; } = null!;
public DateTime AddedWhen { get; set; }
public string? ChangedWho { get; set; }
public DateTime? ChangedWhen { get; set; }
}

View File

@@ -1,10 +0,0 @@
using DbFirst.Domain;
namespace DbFirst.Application.Catalogs;
public class CatalogWriteDto
{
public string CatTitle { get; set; } = null!;
public string CatString { get; set; } = null!;
public CatalogUpdateProcedure UpdateProcedure { get; set; } = CatalogUpdateProcedure.Update;
}

View File

@@ -1,3 +1,4 @@
using DbFirst.Contracts.Catalogs;
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;

View File

@@ -1,5 +1,6 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using DbFirst.Contracts.Catalogs;
using DbFirst.Domain.Entities;
using MediatR;

View File

@@ -1,3 +1,4 @@
using DbFirst.Contracts.Catalogs;
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;

View File

@@ -1,7 +1,8 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using DbFirst.Domain.Entities;
using DbFirst.Contracts.Catalogs;
using DbFirst.Domain;
using DbFirst.Domain.Entities;
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
@@ -25,18 +26,20 @@ public class UpdateCatalogHandler : IRequestHandler<UpdateCatalogCommand, Catalo
return null;
}
if (request.Dto.UpdateProcedure == CatalogUpdateProcedure.Update &&
!string.Equals(existing.CatTitle, request.Dto.CatTitle, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Titel kann nicht geändert werden.");
}
var entity = _mapper.Map<VwmyCatalog>(request.Dto);
entity.Guid = request.Id;
entity.CatTitle = request.Dto.UpdateProcedure == CatalogUpdateProcedure.Update
? existing.CatTitle
: request.Dto.CatTitle;
entity.AddedWho = existing.AddedWho;
entity.AddedWhen = existing.AddedWhen;
entity.ChangedWho = "system";
entity.ChangedWhen = DateTime.UtcNow;
var procedure = request.Dto.UpdateProcedure;
var updated = await _repository.UpdateAsync(request.Id, entity, procedure, cancellationToken);
var updated = await _repository.UpdateAsync(request.Id, entity, request.Dto.UpdateProcedure, cancellationToken);
return updated == null ? null : _mapper.Map<CatalogReadDto>(updated);
}
}

View File

@@ -1,5 +1,6 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using DbFirst.Contracts.Catalogs;
using MediatR;
namespace DbFirst.Application.Catalogs.Queries;

View File

@@ -1,3 +1,4 @@
using DbFirst.Contracts.Catalogs;
using MediatR;
namespace DbFirst.Application.Catalogs.Queries;

View File

@@ -1,5 +1,6 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using DbFirst.Contracts.Catalogs;
using MediatR;
namespace DbFirst.Application.Catalogs.Queries;

View File

@@ -1,3 +1,4 @@
using DbFirst.Contracts.Catalogs;
using MediatR;
namespace DbFirst.Application.Catalogs.Queries;

View File

@@ -7,14 +7,18 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
<PackageReference Include="AutoMapper" Version="16.1.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="MediatR" Version="14.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DbFirst.Contracts\DbFirst.Contracts.csproj" />
<ProjectReference Include="..\DbFirst.Domain\DbFirst.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Layouts\" />
</ItemGroup>
</Project>

View File

@@ -1,5 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using MediatR;
namespace DbFirst.Application;
@@ -7,8 +6,9 @@ public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddAutoMapper(typeof(DependencyInjection).Assembly);
services.AddMediatR(typeof(DependencyInjection).Assembly);
services.AddAutoMapper(cfg => cfg.AddMaps(typeof(DependencyInjection).Assembly));
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
return services;
}
}

View File

@@ -1,3 +1,4 @@
using DbFirst.Contracts.MassData;
using MediatR;
namespace DbFirst.Application.MassData.Commands;

View File

@@ -1,5 +1,6 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using DbFirst.Contracts.MassData;
using MediatR;
namespace DbFirst.Application.MassData.Commands;

View File

@@ -1,4 +1,5 @@
using AutoMapper;
using DbFirst.Contracts.MassData;
using DbFirst.Domain.Entities;
namespace DbFirst.Application.MassData;

View File

@@ -1,12 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,5 +1,6 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using DbFirst.Contracts.MassData;
using MediatR;
namespace DbFirst.Application.MassData.Queries;

View File

@@ -1,3 +1,4 @@
using DbFirst.Contracts.MassData;
using MediatR;
namespace DbFirst.Application.MassData.Queries;

View File

@@ -1,5 +1,6 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using DbFirst.Contracts.MassData;
using MediatR;
namespace DbFirst.Application.MassData.Queries;

View File

@@ -1,3 +1,4 @@
using DbFirst.Contracts.MassData;
using MediatR;
namespace DbFirst.Application.MassData.Queries;

View File

@@ -1,60 +0,0 @@
/*
Ablauf und die Rolle jeder Datei in der Blazor WebAssembly-Anwendung:
1.index.html
• Reihenfolge: Wird als erstes geladen, wenn die Anwendung im Browser geöffnet wird.
• Purpose:
• Lädt die Blazor WebAssembly-Umgebung (blazor.webassembly.js).
• Definiert den Platzhalter <div id="app">, in dem die Blazor-Komponenten gerendert werden.
• Bindet Stylesheets und Skripte ein, die für das Styling und die Funktionalität der Anwendung benötigt werden.
2. Program.cs
• Reihenfolge: Wird nach index.html ausgeführt, sobald die Blazor-Umgebung initialisiert ist.
• Purpose:
• Initialisiert die Blazor WebAssembly-Anwendung.
• Registriert Root-Komponenten (App) und Abhängigkeiten (z. B. HttpClient, CatalogApiClient).
• Konfiguriert die Basis-URL für API-Aufrufe.
3. App.razor
• Reihenfolge: Wird als nächstes geladen, nachdem die Anwendung initialisiert wurde.
• Purpose:
• Definiert die Routing-Logik der Anwendung.
• Entscheidet, welche Komponente basierend auf der URL gerendert wird.
• Stellt sicher, dass ein Standardlayout (MainLayout) verwendet wird.
4.MainLayout.razor
• Reihenfolge: Wird geladen, wenn eine Seite gerendert wird, da es das Standardlayout ist.
• Purpose:
• Definiert das Hauptlayout der Anwendung.
• Enthält die Navigationsleiste (NavMenu) und den Platzhalter für den Seiteninhalt (@Body).
5. NavMenu.razor
• Reihenfolge: Wird als Teil des Layouts (MainLayout) geladen.
• Purpose:
• Stellt die Navigationsleiste bereit.
• Enthält Links zu verschiedenen Seiten der Anwendung (z. B. Home, Catalogs).
• Ermöglicht das Ein- und Ausklappen des Menüs.
6. Catalogs.razor
• Reihenfolge: Wird geladen, wenn der Benutzer die URL /catalogs aufruft.
• Purpose:
• Stellt die Benutzeroberfläche für die Verwaltung von Katalogen bereit.
• Nutzt CatalogApiClient, um Daten von der API zu laden, zu erstellen, zu aktualisieren oder zu löschen.
• Verwendet DevExpress-Komponenten für ein modernes UI.
7. CatalogApiClient.cs
• Reihenfolge: Wird verwendet, wenn Catalogs.razor API-Aufrufe ausführt.
• Purpose:
• Kapselt die Kommunikation mit der API.
• Bietet Methoden für CRUD-Operationen (Create, Read, Update, Delete) auf Katalog-Daten.
• Behandelt Fehler und gibt benutzerfreundliche Fehlermeldungen zurück.
Zusammenfassung des Ablaufs:
1.index.html: Lädt die Blazor-Umgebung und startet die Anwendung.
2. Program.cs: Initialisiert die Anwendung und registriert Abhängigkeiten.
3. App.razor: Definiert die Routing-Logik und lädt das Standardlayout.
4. MainLayout.razor: Stellt das Hauptlayout bereit.
5. NavMenu.razor: Lädt die Navigationsleiste.
6. Seiten wie Catalogs.razor: Werden basierend auf der URL gerendert.
7. CatalogApiClient.cs: Führt API-Aufrufe aus, wenn die Seite Daten benötigt.
*/

View File

@@ -1,20 +0,0 @@
@*
• Ist der logische Einstiegspunkt der Blazor-Anwendung.
• Sie definiert die Routing-Logik und das Standardlayout der Anwendung.
• Der Router-Komponent in App.razor entscheidet, welche Blazor-Komponente basierend auf der URL geladen wird.
kurz: Steuert die Navigation und das Rendering der Blazor-Komponenten.
*@
@DxResourceManager.RegisterTheme(Themes.Fluent)
@DxResourceManager.RegisterScripts()
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>

View File

@@ -1,897 +0,0 @@
@using System.Text.Json
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Rendering
@using Microsoft.AspNetCore.Components.Forms
@using DevExpress.Blazor
@using DevExpress.Data.Filtering
@inject CatalogApiClient Api
@inject LayoutApiClient LayoutApi
@inject IJSRuntime JsRuntime
<style>
.action-panel { margin-bottom: 16px; }
.grid-section { margin-top: 12px; }
.catalog-grid .dxbl-grid-sort-asc,
.catalog-grid .dxbl-grid-sort-desc {
display: none;
}
.catalog-grid th.dxbl-grid-header-sortable {
position: relative;
padding-right: 1.5rem;
}
.catalog-grid th.dxbl-grid-header-sortable::before,
.catalog-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;
}
.catalog-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");
}
.catalog-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");
}
.catalog-grid th.dxbl-grid-header-sortable[aria-sort="ascending"]::after {
opacity: 0;
}
.catalog-grid th.dxbl-grid-header-sortable[aria-sort="descending"]::before {
opacity: 0;
}
.catalog-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;
}
.catalog-edit-popup {
min-width: 720px;
}
.band-editor {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
.band-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.band-row {
display: flex;
gap: 8px;
align-items: center;
}
.band-columns {
max-width: 720px;
}
.filter-row-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.filter-operator {
width: 52px;
min-width: 52px;
flex: 0 0 52px;
}
.filter-value {
min-width: 140px;
flex: 1 1 140px;
}
</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="band-editor">
<div class="band-controls">
<DxButton Text="Band hinzufügen" Click="AddBand" />
<DxButton Text="Layout speichern" Click="SaveLayoutAsync" Enabled="@CanSaveBandLayout" />
<DxButton Text="Band-Layout zurücksetzen" Click="ResetBandLayoutAsync" />
</div>
@foreach (var band in bandLayout.Bands)
{
<div class="band-row">
<DxTextBox Text="@band.Caption" TextChanged="@(value => UpdateBandCaption(band, value))" />
<DxButton Text="Entfernen" Click="@(() => RemoveBand(band))" />
</div>
}
<DxFormLayout CssClass="band-columns" ColCount="2">
@foreach (var column in columnDefinitions)
{
<DxFormLayoutItem Caption="@column.Caption">
<DxComboBox Data="@bandOptions"
TData="BandOption"
TValue="string"
TextFieldName="Caption"
ValueFieldName="Id"
Value="@GetColumnBand(column.FieldName)"
ValueChanged="@(value => UpdateColumnBand(column.FieldName, value))"
Width="100%" />
</DxFormLayoutItem>
}
</DxFormLayout>
</div>
<div class="grid-section">
<DxGrid Data="@items"
TItem="CatalogReadDto"
KeyFieldName="@nameof(CatalogReadDto.Guid)"
ShowGroupPanel="true"
ShowGroupedColumns="true"
AllowGroup="true"
ShowFilterRow="true"
AllowColumnResize="true"
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
AllowColumnReorder="true"
PageSize="10"
CssClass="mb-4 catalog-grid"
EditMode="GridEditMode.PopupEditForm"
PopupEditFormCssClass="catalog-edit-popup"
PopupEditFormHeaderText="@popupHeaderText"
CustomizeEditModel="OnCustomizeEditModel"
EditModelSaving="OnEditModelSaving"
DataItemDeleting="OnDataItemDeleting"
@ref="gridRef">
<Columns>
@RenderColumns()
</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 bool isLoading;
private string? errorMessage;
private string? infoMessage;
private EditContext? editContext;
private ValidationMessageStore? validationMessageStore;
private IGrid? gridRef;
private string popupHeaderText = "Edit";
private DateTime? addedWhenFilterValue;
private string addedWhenFilterOperator = "=";
private DateTime? changedWhenFilterValue;
private string changedWhenFilterOperator = "=";
private const string LayoutType = "GRID_BANDS";
private const string LayoutKey = "CatalogsGrid";
private const string LayoutUserStorageKey = "layoutUser";
private string? layoutUser;
private BandLayout bandLayout = new();
private Dictionary<string, string> columnBandAssignments = new();
private List<BandOption> bandOptions = new();
private Dictionary<string, ColumnDefinition> columnLookup = new();
private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
private List<ColumnDefinition> columnDefinitions = new()
{
new() { FieldName = nameof(CatalogReadDto.Guid), Caption = "Id", Width = "140px", FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(CatalogReadDto.CatTitle), Caption = "Titel", FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(CatalogReadDto.CatString), Caption = "String", FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(CatalogReadDto.AddedWho), Caption = "Angelegt von", ReadOnly = true, FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(CatalogReadDto.AddedWhen), Caption = "Angelegt am", ReadOnly = true, FilterType = ColumnFilterType.Date },
new() { FieldName = nameof(CatalogReadDto.ChangedWho), Caption = "Geändert von", ReadOnly = true, FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(CatalogReadDto.ChangedWhen), Caption = "Geändert am", ReadOnly = true, FilterType = ColumnFilterType.Date }
};
private readonly List<ProcedureOption> procedureOptions = new()
{
new() { Value = 0, Text = "PRTBMY_CATALOG_UPDATE" },
new() { Value = 1, Text = "PRTBMY_CATALOG_SAVE" }
};
private readonly List<FilterOperatorOption> filterOperators = new()
{
new() { Value = "=", Text = "=" },
new() { Value = "<", Text = "<" },
new() { Value = ">", Text = ">" },
new() { Value = "<=", Text = "<=" },
new() { Value = ">=", Text = ">=" }
};
private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser);
private bool gridLayoutApplied;
protected override async Task OnInitializedAsync()
{
columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase);
await EnsureLayoutUserAsync();
await LoadBandLayoutAsync();
await LoadCatalogs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!gridLayoutApplied && gridRef != null && bandLayout.GridLayout != null)
{
gridRef.LoadLayout(bandLayout.GridLayout);
gridLayoutApplied = true;
await InvokeAsync(StateHasChanged);
}
}
private void SetEditContext(EditContext context)
{
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;
errorMessage = null;
try
{
items = await Api.GetAllAsync();
}
catch (Exception ex)
{
errorMessage = $"Kataloge konnten nicht geladen werden: {ex.Message}";
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task OnEditModelSaving(GridEditModelSavingEventArgs e)
{
errorMessage = null;
infoMessage = null;
validationMessageStore?.Clear();
editContext?.NotifyValidationStateChanged();
var editModel = (CatalogEditModel)e.EditModel;
if (!ValidateEditModel(editModel, e.IsNew))
{
e.Cancel = true;
return;
}
var dto = new CatalogWriteDto
{
CatTitle = editModel.CatTitle,
CatString = editModel.CatString,
UpdateProcedure = editModel.UpdateProcedure
};
try
{
if (e.IsNew)
{
var created = await Api.CreateAsync(dto);
if (!created.Success || created.Value == null)
{
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.";
}
await LoadCatalogs();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Speichern: {ex.Message}";
e.Cancel = true;
}
}
private void AddValidationError(CatalogEditModel editModel, string fieldName, string message)
{
if (editContext == null || validationMessageStore == null)
{
return;
}
var field = new FieldIdentifier(editModel, fieldName);
validationMessageStore.Add(field, message);
editContext.NotifyValidationStateChanged();
}
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(item.Guid);
if (!deleted.Success)
{
errorMessage = deleted.Error ?? "Löschen fehlgeschlagen.";
e.Cancel = true;
return;
}
infoMessage = "Katalog gelöscht.";
await LoadCatalogs();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Löschen: {ex.Message}";
e.Cancel = true;
}
}
private async Task EnsureLayoutUserAsync()
{
layoutUser = await JsRuntime.InvokeAsync<string?>("localStorage.getItem", LayoutUserStorageKey);
if (string.IsNullOrWhiteSpace(layoutUser))
{
layoutUser = Guid.NewGuid().ToString("N");
await JsRuntime.InvokeVoidAsync("localStorage.setItem", LayoutUserStorageKey, layoutUser);
}
}
private async Task LoadBandLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
{
bandLayout = new BandLayout();
UpdateBandOptions();
return;
}
var stored = await LayoutApi.GetAsync(LayoutType, LayoutKey, layoutUser);
if (stored != null && !string.IsNullOrWhiteSpace(stored.LayoutData))
{
var parsed = JsonSerializer.Deserialize<BandLayout>(stored.LayoutData, jsonOptions);
bandLayout = NormalizeBandLayout(parsed);
}
else
{
bandLayout = new BandLayout();
}
columnBandAssignments = BuildAssignmentsFromLayout(bandLayout);
ApplyColumnLayoutFromStorage();
UpdateBandOptions();
}
private async Task SaveLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
{
return;
}
try
{
CaptureColumnLayoutFromGrid();
var layoutData = JsonSerializer.Serialize(bandLayout, jsonOptions);
await LayoutApi.UpsertAsync(new LayoutDto
{
LayoutType = LayoutType,
LayoutKey = LayoutKey,
UserName = layoutUser,
LayoutData = layoutData
});
infoMessage = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
private void CaptureColumnLayoutFromGrid()
{
if (gridRef == null)
{
return;
}
var layout = gridRef.SaveLayout();
bandLayout.GridLayout = layout;
var orderedColumns = layout.Columns
.Where(column => !string.IsNullOrWhiteSpace(column.FieldName))
.OrderBy(column => column.VisibleIndex)
.ToList();
bandLayout.ColumnOrder = orderedColumns
.Select(column => column.FieldName)
.ToList();
bandLayout.ColumnWidths = orderedColumns
.Where(column => !string.IsNullOrWhiteSpace(column.Width))
.ToDictionary(column => column.FieldName, column => column.Width, StringComparer.OrdinalIgnoreCase);
}
private async Task ResetBandLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
{
return;
}
await LayoutApi.DeleteAsync(LayoutType, LayoutKey, layoutUser);
bandLayout = new BandLayout();
columnBandAssignments.Clear();
UpdateBandOptions();
infoMessage = "Band-Layout zurückgesetzt.";
}
private void ApplyColumnLayoutFromStorage()
{
foreach (var column in columnDefinitions)
{
if (bandLayout.ColumnWidths.TryGetValue(column.FieldName, out var width) && !string.IsNullOrWhiteSpace(width))
{
column.Width = width;
}
}
columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase);
}
private void AddBand()
{
bandLayout.Bands.Add(new BandDefinition
{
Id = Guid.NewGuid().ToString("N"),
Caption = "Band"
});
UpdateBandOptions();
}
private void RemoveBand(BandDefinition band)
{
bandLayout.Bands.Remove(band);
var removedColumns = columnBandAssignments.Where(pair => pair.Value == band.Id)
.Select(pair => pair.Key)
.ToList();
foreach (var column in removedColumns)
{
columnBandAssignments.Remove(column);
}
UpdateBandOptions();
SyncBandsFromAssignments();
}
private void UpdateBandCaption(BandDefinition band, string value)
{
band.Caption = value;
UpdateBandOptions();
}
private void UpdateColumnBand(string fieldName, string? bandId)
{
if (string.IsNullOrWhiteSpace(bandId))
{
columnBandAssignments.Remove(fieldName);
}
else
{
columnBandAssignments[fieldName] = bandId;
}
SyncBandsFromAssignments();
}
private string GetColumnBand(string fieldName)
{
return columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty;
}
private void SyncBandsFromAssignments()
{
foreach (var band in bandLayout.Bands)
{
band.Columns = columnDefinitions
.Where(column => columnBandAssignments.TryGetValue(column.FieldName, out var bandId) && bandId == band.Id)
.Select(column => column.FieldName)
.ToList();
}
StateHasChanged();
}
private void UpdateBandOptions()
{
bandOptions = new List<BandOption> { new() { Id = string.Empty, Caption = "Ohne Band" } };
bandOptions.AddRange(bandLayout.Bands.Select(band => new BandOption { Id = band.Id, Caption = band.Caption }));
}
private BandLayout NormalizeBandLayout(BandLayout? layout)
{
layout ??= new BandLayout();
layout.Bands ??= new List<BandDefinition>();
layout.ColumnOrder ??= new List<string>();
layout.ColumnWidths ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var band in layout.Bands)
{
if (string.IsNullOrWhiteSpace(band.Id))
{
band.Id = Guid.NewGuid().ToString("N");
}
if (string.IsNullOrWhiteSpace(band.Caption))
{
band.Caption = "Band";
}
band.Columns = band.Columns?.Where(columnLookup.ContainsKey).ToList() ?? new List<string>();
}
return layout;
}
private Dictionary<string, string> BuildAssignmentsFromLayout(BandLayout layout)
{
var assignments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var band in layout.Bands)
{
foreach (var column in band.Columns)
{
assignments[column] = band.Id;
}
}
return assignments;
}
private RenderFragment RenderColumns() => builder =>
{
var seq = 0;
builder.OpenComponent<DxGridCommandColumn>(seq++);
builder.AddAttribute(seq++, "Width", "120px");
builder.CloseComponent();
var grouped = bandLayout.Bands.SelectMany(band => band.Columns).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var column in columnDefinitions.Where(column => !grouped.Contains(column.FieldName)))
{
BuildDataColumn(builder, ref seq, column);
}
foreach (var band in bandLayout.Bands)
{
if (band.Columns.Count == 0)
{
continue;
}
builder.OpenComponent<DxGridBandColumn>(seq++);
builder.AddAttribute(seq++, "Caption", band.Caption);
builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder =>
{
var bandSeq = 0;
foreach (var columnName in band.Columns)
{
if (columnLookup.TryGetValue(columnName, out var column))
{
BuildDataColumn(bandBuilder, ref bandSeq, column);
}
}
}));
builder.CloseComponent();
}
};
private void BuildDataColumn(RenderTreeBuilder builder, ref int seq, ColumnDefinition column)
{
builder.OpenComponent<DxGridDataColumn>(seq++);
builder.AddAttribute(seq++, "FieldName", column.FieldName);
builder.AddAttribute(seq++, "Caption", column.Caption);
if (!string.IsNullOrWhiteSpace(column.Width))
{
builder.AddAttribute(seq++, "Width", column.Width);
}
if (!string.IsNullOrWhiteSpace(column.DisplayFormat))
{
builder.AddAttribute(seq++, "DisplayFormat", column.DisplayFormat);
}
if (column.ReadOnly)
{
builder.AddAttribute(seq++, "ReadOnly", true);
}
if (string.Equals(column.FieldName, nameof(CatalogReadDto.AddedWhen), StringComparison.OrdinalIgnoreCase))
{
builder.AddAttribute(seq++, "FilterRowCellTemplate", BuildAddedWhenFilterTemplate());
}
else if (string.Equals(column.FieldName, nameof(CatalogReadDto.ChangedWhen), StringComparison.OrdinalIgnoreCase))
{
builder.AddAttribute(seq++, "FilterRowCellTemplate", BuildChangedWhenFilterTemplate());
}
builder.CloseComponent();
}
private RenderFragment<GridDataColumnFilterRowCellTemplateContext> BuildAddedWhenFilterTemplate()
{
return BuildDateFilterTemplate(
() => addedWhenFilterValue,
value => addedWhenFilterValue = value,
() => addedWhenFilterOperator,
value => addedWhenFilterOperator = value);
}
private RenderFragment<GridDataColumnFilterRowCellTemplateContext> BuildChangedWhenFilterTemplate()
{
return BuildDateFilterTemplate(
() => changedWhenFilterValue,
value => changedWhenFilterValue = value,
() => changedWhenFilterOperator,
value => changedWhenFilterOperator = value);
}
private RenderFragment<GridDataColumnFilterRowCellTemplateContext> BuildDateFilterTemplate(
Func<DateTime?> getValue,
Action<DateTime?> setValue,
Func<string> getOperator,
Action<string> setOperator)
{
return context => builder =>
{
var seq = 0;
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", "filter-row-cell");
builder.OpenComponent<DxComboBox<FilterOperatorOption, string>>(seq++);
builder.AddAttribute(seq++, "Data", filterOperators);
builder.AddAttribute(seq++, "TextFieldName", "Text");
builder.AddAttribute(seq++, "ValueFieldName", "Value");
builder.AddAttribute(seq++, "Value", getOperator());
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<string>(this, value =>
{
setOperator(value);
UpdateFilterCriteria(context, value, getValue());
}));
builder.AddAttribute(seq++, "CssClass", "filter-operator");
builder.CloseComponent();
builder.OpenComponent<DxDateEdit<DateTime?>>(seq++);
builder.AddAttribute(seq++, "Date", getValue());
builder.AddAttribute(seq++, "DateChanged", EventCallback.Factory.Create<DateTime?>(this, value =>
{
setValue(value);
UpdateFilterCriteria(context, getOperator(), value);
}));
builder.AddAttribute(seq++, "ClearButtonDisplayMode", DataEditorClearButtonDisplayMode.Auto);
builder.AddAttribute(seq++, "CssClass", "filter-value");
builder.CloseComponent();
builder.CloseElement();
};
}
private void UpdateFilterCriteria(GridDataColumnFilterRowCellTemplateContext context, string op, DateTime? value)
{
if (!value.HasValue)
{
context.FilterCriteria = null;
return;
}
var prop = new OperandProperty(context.DataColumn.FieldName);
var date = value.Value.Date;
context.FilterCriteria = op switch
{
"=" => new GroupOperator(GroupOperatorType.And, prop >= date, prop < date.AddDays(1)),
"<" => prop < date,
"<=" => prop < date.AddDays(1),
">" => prop >= date.AddDays(1),
">=" => prop >= date,
_ => prop == date
};
}
private sealed class BandLayout
{
public List<BandDefinition> Bands { get; set; } = new();
public List<string> ColumnOrder { get; set; } = new();
public Dictionary<string, string?> ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public GridPersistentLayout? GridLayout { get; set; }
}
private sealed class BandDefinition
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
public List<string> Columns { get; set; } = new();
}
private sealed class BandOption
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
}
private sealed class ColumnDefinition
{
public string FieldName { get; init; } = string.Empty;
public string Caption { get; init; } = string.Empty;
public string? Width { get; set; }
public string? DisplayFormat { get; init; }
public bool ReadOnly { get; init; }
public ColumnFilterType FilterType { get; init; }
}
private enum ColumnFilterType
{
Text,
Date
}
private sealed class CatalogEditModel
{
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; }
public string Text { get; set; } = string.Empty;
}
private sealed class FilterOperatorOption
{
public string Value { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Blazor" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.Dashboard" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.Themes" Version="25.2.3" />
<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>
<Folder Include="wwwroot\sample-data\" />
</ItemGroup>
</Project>

View File

@@ -1,19 +0,0 @@
@* Definiert das Hauptlayout der Anwendung.
Enthält die Navigationsleiste und den Hauptinhalt. *@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu /> <!-- Einbindung der Navigationsleiste -->
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body <!-- Platzhalter für den Seiteninhalt -->
</article>
</main>
</div>

View File

@@ -1,77 +0,0 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}

View File

@@ -1,48 +0,0 @@
@* Definiert die Navigationsleiste, die Links zu verschiedenen Seiten der Anwendung enthält. *@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">DbFirst.BlazorWasm</a>
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="catalogs">
<span class="bi bi-collection-nav-menu" aria-hidden="true"></span> Catalogs
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="dashboards">
<span class="bi bi-speedometer-nav-menu" aria-hidden="true"></span> Dashboards
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="massdata">
<span class="bi bi-table-nav-menu" aria-hidden="true"></span> MassData
</NavLink>
</div>
</nav>
</div>
@code {
private bool collapseNavMenu = true;
// CSS-Klasse für die Navigation, die den Zustand (eingeklappt/ausgeklappt) steuert.
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
// Methode zum Umschalten des Navigationsmenüs.
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}

View File

@@ -1,95 +0,0 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.bi-collection-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M2 3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 .5.5v1H2V3z'/%3E%3Cpath d='M2 5h12v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5z'/%3E%3C/svg%3E");
}
.bi-speedometer-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M1 11a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v4zm5 0a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v8zm5 0a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v2z'/%3E%3C/svg%3E");
}
.bi-table-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M1 2a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2zm1 1v2h12V3H2zm12 3H2v2h12V6zm0 3H2v2h12V9zm0 3H2v1h12v-1z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
.nav-scrollable {
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -1,8 +0,0 @@
namespace DbFirst.BlazorWasm.Models;
public class CatalogWriteDto
{
public string CatTitle { get; set; } = string.Empty;
public string CatString { get; set; } = string.Empty;
public int UpdateProcedure { get; set; } = 0; // 0 = Update, 1 = Save
}

View File

@@ -1,7 +0,0 @@
namespace DbFirst.BlazorWasm.Models;
public class DashboardInfoDto
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}

View File

@@ -1,12 +0,0 @@
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

@@ -1,7 +0,0 @@
@page "/catalogs"
<PageTitle>Catalogs</PageTitle>
<h1>Catalogs</h1>
<CatalogsGrid />

View File

@@ -1,163 +0,0 @@
@page "/dashboard"
@page "/dashboards/{DashboardId?}"
@implements IAsyncDisposable
@inject Microsoft.Extensions.Configuration.IConfiguration Configuration
@inject NavigationManager Navigation
@inject DashboardApiClient DashboardApi
<style>
.dashboard-shell {
display: flex;
gap: 0;
min-height: 800px;
border: 1px solid #e6e6e6;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.dashboard-nav {
width: 220px;
border-right: 1px solid #e6e6e6;
background: #fafafa;
}
.dashboard-nav-title {
padding: 0.75rem 1rem 0.5rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6c757d;
font-weight: 600;
}
.dashboard-nav-link {
display: block;
padding: 0.55rem 1rem;
color: inherit;
text-decoration: none;
}
.dashboard-nav-link.active {
background: #e9ecef;
font-weight: 600;
}
.dashboard-content {
flex: 1;
min-width: 0;
padding: 1rem;
}
</style>
<PageTitle>Dashboards</PageTitle>
<div class="dashboard-shell">
<aside class="dashboard-nav">
<div class="dashboard-nav-title">Dashboards</div>
@if (dashboards.Count == 0)
{
<div class="px-3 py-2 text-muted">Keine Dashboards vorhanden.</div>
}
else
{
@foreach (var dashboard in dashboards)
{
<NavLink class="dashboard-nav-link" href="@($"dashboards/{dashboard.Id}?mode={(IsDesigner ? "designer" : "viewer")}")">@dashboard.Name</NavLink>
}
}
</aside>
<section class="dashboard-content">
<div class="mb-3">
<DxButton RenderStyle="ButtonRenderStyle.Primary" Click="@ToggleMode">
@(IsDesigner ? "Zum Viewer wechseln" : "Zum Designer wechseln")
</DxButton>
</div>
<DxDashboard @key="DashboardKey" Endpoint="@DashboardEndpoint" InitialDashboardId="@SelectedDashboardId" WorkingMode="@CurrentMode" style="width: 100%; height: 800px;">
</DxDashboard>
</section>
</div>
@code {
[Parameter] public string? DashboardId { get; set; }
[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; } = 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)
{
await RefreshDashboards();
}
var requestedId = string.IsNullOrWhiteSpace(DashboardId) || string.Equals(DashboardId, "default", StringComparison.OrdinalIgnoreCase)
? null
: DashboardId;
var resolved = !string.IsNullOrWhiteSpace(requestedId)
? dashboards.FirstOrDefault(d => string.Equals(d.Id, requestedId, StringComparison.OrdinalIgnoreCase))
: dashboards.FirstOrDefault(d => string.Equals(d.Id, "DefaultDashboard", StringComparison.OrdinalIgnoreCase))
?? dashboards.FirstOrDefault();
if (resolved == null)
{
return;
}
SelectedDashboardId = resolved.Id;
if (!string.Equals(DashboardId, resolved.Id, StringComparison.OrdinalIgnoreCase))
{
Navigation.NavigateTo($"dashboards/{resolved.Id}?mode={(IsDesigner ? "designer" : "viewer")}", replace: true);
}
}
private void ToggleMode()
{
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

@@ -1,7 +0,0 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Db First approach</h1>
This is a Blazor WebAssembly application demonstrating the Database First approach using DevExpress components.

View File

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

View File

@@ -1,24 +0,0 @@
/* Initialisiert die Blazor WebAssembly-Anwendung.
Registriert Root-Komponenten
Konfiguriert Abhängigkeiten */
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using DbFirst.BlazorWasm;
using DbFirst.BlazorWasm.Services;
using DevExpress.Blazor;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddDevExpressBlazor();
var apiBaseUrl = builder.Configuration["ApiBaseUrl"] ?? builder.HostEnvironment.BaseAddress;
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBaseUrl) });
builder.Services.AddScoped<CatalogApiClient>();
builder.Services.AddScoped<DashboardApiClient>();
builder.Services.AddScoped<MassDataApiClient>();
builder.Services.AddScoped<LayoutApiClient>();
await builder.Build().RunAsync();

View File

@@ -1,41 +0,0 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:12804",
"sslPort": 44394
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5101",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7276;http://localhost:5101",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,148 +0,0 @@
/* Kapselt die Kommunikation mit der API für den Catalog-Endpunkt.
Bietet Methoden für CRUD-Operationen auf Catalog-Daten */
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using DbFirst.BlazorWasm.Models;
namespace DbFirst.BlazorWasm.Services;
public class CatalogApiClient
{
private readonly HttpClient _httpClient;
private const string Endpoint = "api/catalogs";
public CatalogApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<CatalogReadDto>> GetAllAsync()
{
var result = await _httpClient.GetFromJsonAsync<List<CatalogReadDto>>(Endpoint);
return result ?? new List<CatalogReadDto>();
}
public async Task<CatalogReadDto?> GetByIdAsync(int id)
{
return await _httpClient.GetFromJsonAsync<CatalogReadDto>($"{Endpoint}/{id}");
}
public async Task<ApiResult<CatalogReadDto?>> CreateAsync(CatalogWriteDto dto)
{
var response = await _httpClient.PostAsJsonAsync(Endpoint, dto);
if (response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadFromJsonAsync<CatalogReadDto>();
return ApiResult<CatalogReadDto?>.Ok(payload);
}
var error = await ReadErrorAsync(response);
return ApiResult<CatalogReadDto?>.Fail(error);
}
public async Task<ApiResult<bool>> UpdateAsync(int id, CatalogWriteDto dto)
{
var response = await _httpClient.PutAsJsonAsync($"{Endpoint}/{id}", dto);
if (response.IsSuccessStatusCode)
{
return ApiResult<bool>.Ok(true);
}
var error = await ReadErrorAsync(response);
return ApiResult<bool>.Fail(error);
}
public async Task<ApiResult<bool>> DeleteAsync(int id)
{
var response = await _httpClient.DeleteAsync($"{Endpoint}/{id}");
if (response.IsSuccessStatusCode)
{
return ApiResult<bool>.Ok(true);
}
var error = await ReadErrorAsync(response);
return ApiResult<bool>.Fail(error);
}
private static async Task<string> ReadErrorAsync(HttpResponseMessage response)
{
// Liest und analysiert Fehlerdetails aus der API-Antwort.
// Gibt eine benutzerfreundliche Fehlermeldung zurück.
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
{
// ignore parse errors
}
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;
}
// Friendly overrides
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)
{
// Formatiert zusätzliche Informationen für Fehlermeldungen.
// Kombiniert Titel, Details und Grund in einer lesbaren Form.
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)
{
public static ApiResult<T> Ok(T? value) => new(true, value, null);
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

@@ -1,21 +0,0 @@
using System.Net.Http.Json;
using DbFirst.BlazorWasm.Models;
namespace DbFirst.BlazorWasm.Services;
public class DashboardApiClient
{
private readonly HttpClient _httpClient;
private const string Endpoint = "api/dashboard/dashboards";
public DashboardApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<DashboardInfoDto>> GetAllAsync()
{
var result = await _httpClient.GetFromJsonAsync<List<DashboardInfoDto>>(Endpoint);
return result ?? new List<DashboardInfoDto>();
}
}

View File

@@ -1,64 +0,0 @@
using System.Net.Http.Json;
using DbFirst.BlazorWasm.Models;
namespace DbFirst.BlazorWasm.Services;
public class LayoutApiClient
{
private readonly HttpClient _httpClient;
private const string Endpoint = "api/layouts";
public LayoutApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<LayoutDto?> GetAsync(string layoutType, string layoutKey, string userName)
{
var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}";
var response = await _httpClient.GetAsync(url);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<LayoutDto>();
}
public async Task<LayoutDto> UpsertAsync(LayoutDto dto)
{
var response = await _httpClient.PostAsJsonAsync(Endpoint, dto);
if (!response.IsSuccessStatusCode)
{
var detail = await ReadErrorAsync(response);
throw new InvalidOperationException(detail);
}
var payload = await response.Content.ReadFromJsonAsync<LayoutDto>();
return payload ?? dto;
}
private static async Task<string> ReadErrorAsync(HttpResponseMessage response)
{
var body = await response.Content.ReadAsStringAsync();
if (!string.IsNullOrWhiteSpace(body))
{
return body;
}
return $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
}
public async Task DeleteAsync(string layoutType, string layoutKey, string userName)
{
var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}";
var response = await _httpClient.DeleteAsync(url);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return;
}
response.EnsureSuccessStatusCode();
}
}

View File

@@ -1,63 +0,0 @@
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 query = new List<string>();
if (skip.HasValue)
{
query.Add($"skip={skip.Value}");
}
if (take.HasValue)
{
query.Add($"take={take.Value}");
}
var url = query.Count == 0 ? Endpoint : $"{Endpoint}?{string.Join("&", query)}";
var result = await _httpClient.GetFromJsonAsync<List<MassDataReadDto>>(url);
return result ?? new List<MassDataReadDto>();
}
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

@@ -1,17 +0,0 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@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
@using DbFirst.BlazorWasm.Services
@using DbFirst.BlazorWasm.Components
@using DevExpress.Blazor
@using DevExpress.DashboardBlazor
@using DevExpress.DashboardWeb

View File

@@ -1,3 +0,0 @@
{
"ApiBaseUrl": "https://localhost:7204/"
}

View File

@@ -1,103 +0,0 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1:focus {
outline: none;
}
a, .btn-link {
color: #0071c1;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.loading-progress {
position: relative;
display: block;
width: 8rem;
height: 8rem;
margin: 20vh auto 1rem auto;
}
.loading-progress circle {
fill: none;
stroke: #e0e0e0;
stroke-width: 0.6rem;
transform-origin: 50% 50%;
transform: rotate(-90deg);
}
.loading-progress circle:last-child {
stroke: #1b6ec2;
stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
transition: stroke-dasharray 0.05s ease-in-out;
}
.loading-progress-text {
position: absolute;
text-align: center;
font-weight: bold;
inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
}
.loading-progress-text:after {
content: var(--blazor-load-percentage-text, "Loading");
}
code {
color: #c02d76;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,58 +0,0 @@
<!--
• Ist der technische Einstiegspunkt der Blazor WebAssembly-Anwendung.
• Sie lädt die notwendigen Ressourcen (z. B. das Blazor-Skript blazor.webassembly.js)
und definiert den Platzhalter <div id="app">, in dem die Blazor-Komponenten gerendert werden.
• Ohne diese Datei könnte die Blazor-Anwendung nicht starten, da sie die Verbindung
zwischen der statischen HTML-Welt und der Blazor-Welt herstellt.
kurz: Startet die Anwendung und lädt die Blazor-Umgebung.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DbFirst.BlazorWasm</title>
<base href="/" />
<!-- Stylesheets für DevExpress und Bootstrap -->
<link href="_content/DevExpress.Blazor.Dashboard/ace.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Dashboard/ace-theme-dreamweaver.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Dashboard/ace-theme-ambiance.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Dashboard/dx.light.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Dashboard/dx-analytics.common.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Dashboard/dx-analytics.light.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Dashboard/dx-querybuilder.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Dashboard/dx-dashboard.light.min.css" rel="stylesheet" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="DbFirst.BlazorWasm.styles.css" rel="stylesheet" />
</head>
<body>
<!-- Einstiegspunkt der Blazor-Anwendung -->
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<!-- Fehler-UI für unvorhergesehene Fehler -->
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<!-- Blazor WebAssembly-Skript -->
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
namespace DbFirst.BlazorWebApp;
public class AppSettings
{
public string ApiBaseUrl { get; set; } = string.Empty;
}

View File

@@ -17,17 +17,21 @@
<link href="_content/DevExpress.Blazor.Dashboard/dx-querybuilder.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Dashboard/dx-dashboard.light.min.css" rel="stylesheet" />
<link rel="stylesheet" href="_content/DevExpress.Blazor.Themes/bootstrap-external.bs5.min.css" />
<link rel="stylesheet" href="_content/DevExpress.Blazor.Themes.Fluent/core.min.css" />
<link rel="stylesheet" href="_content/DevExpress.Blazor.Themes.Fluent/global.min.css" />
<link rel="stylesheet" href="_content/DevExpress.Blazor.Themes.Fluent/modes/light.min.css" />
<link rel="stylesheet" href="_content/DevExpress.Blazor.Themes.Fluent/accents/blue.min.css" />
<link rel="stylesheet" href="_content/DevExpress.Blazor.Themes.Fluent/bootstrap/fluent-light.bs5.min.css" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="DbFirst.BlazorWebApp.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<script src="js/size-manager.js"></script>
<script>
window.setDxDarkOverride = function (enabled) {
if (enabled)
document.documentElement.classList.add('dx-dark');
else
document.documentElement.classList.remove('dx-dark');
};
</script>
<HeadOutlet />
</head>

View File

@@ -0,0 +1,55 @@
<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

@@ -0,0 +1,278 @@
using DbFirst.BlazorWebApp.Models.Grid;
using DbFirst.BlazorWebApp.Services;
using DevExpress.Blazor;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
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();
protected string? errorMessage;
protected string? infoMessage;
protected bool isLoading;
protected bool hasLoaded;
protected EditContext? editContext;
protected ValidationMessageStore? validationMessageStore;
protected string popupHeaderText = "Edit";
protected int _focusedVisibleIndex;
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 virtual bool ShowCommandColumn => true;
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();
}
_ = InvokeAsync(StateHasChanged);
}
protected void UpdateBandOptions()
{
bandOptions = [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;
if (ShowCommandColumn)
{
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();
}
};
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();
}
protected void SetEditContext(EditContext context)
{
if (editContext == context) return;
if (editContext != null)
editContext.OnFieldChanged -= OnEditFieldChanged;
editContext = context;
validationMessageStore = new ValidationMessageStore(editContext);
editContext.OnFieldChanged += OnEditFieldChanged;
}
protected virtual void OnEditFieldChanged(object? sender, FieldChangedEventArgs e)
{
validationMessageStore?.Clear();
editContext?.NotifyValidationStateChanged();
}
protected void SetPopupHeaderText(bool isNew) => popupHeaderText = isNew ? "Neu" : "Edit";
protected async Task EditFocusedRow()
=> await gridRef!.StartEditRowAsync(_focusedVisibleIndex);
protected Task DeleteFocusedRow()
{
gridRef!.ShowRowDeleteConfirmation(_focusedVisibleIndex);
return Task.CompletedTask;
}
protected async Task SaveLayoutWithFeedbackAsync()
{
try
{
await SaveLayoutAsync();
infoMessage = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
protected async Task ResetLayoutWithFeedbackAsync()
{
await ResetLayoutAsync();
infoMessage = "Layout zurückgesetzt.";
errorMessage = null;
}
}

View File

@@ -1,54 +1,5 @@
@using System.Text.Json
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Rendering
@using Microsoft.AspNetCore.Components.Forms
@using DevExpress.Blazor
@using DevExpress.Data.Filtering
@inject CatalogApiClient Api
@inject LayoutApiClient LayoutApi
@inject IJSRuntime JsRuntime
<style>
.action-panel { margin-bottom: 16px; }
.grid-section { margin-top: 12px; }
.catalog-edit-popup {
min-width: 720px;
}
.band-editor {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
.band-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.band-row {
display: flex;
gap: 8px;
align-items: center;
}
.band-columns {
max-width: 720px;
}
.filter-row-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.filter-operator {
width: 52px;
min-width: 52px;
flex: 0 0 52px;
}
.filter-value {
min-width: 140px;
flex: 1 1 140px;
}
</style>
@inherits BandGridBase<CatalogReadDto>
@inject ICatalogApiClient Api
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
@@ -59,9 +10,13 @@ else if (!string.IsNullOrWhiteSpace(infoMessage))
<div class="alert alert-success" role="alert">@infoMessage</div>
}
@if (isLoading)
@if (!hasLoaded || isLoading)
{
<p><em>Lade Daten...</em></p>
<div class="loading-container">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Lade...</span>
</div>
</div>
}
else if (items.Count == 0)
{
@@ -69,44 +24,28 @@ else if (items.Count == 0)
}
else
{
<div class="band-editor">
<div class="band-controls">
<DxButton Text="Band hinzufügen" Click="AddBand" />
<DxButton Text="Layout speichern" Click="SaveLayoutAsync" Enabled="@CanSaveBandLayout" />
<DxButton Text="Band-Layout zurücksetzen" Click="ResetBandLayoutAsync" />
</div>
@foreach (var band in bandLayout.Bands)
{
<div class="band-row">
<DxTextBox Text="@band.Caption" TextChanged="@(value => UpdateBandCaption(band, value))" />
<DxButton Text="Entfernen" Click="@(() => RemoveBand(band))" />
</div>
}
<DxFormLayout CssClass="band-columns" ColCount="2">
@foreach (var column in columnDefinitions)
{
<DxFormLayoutItem Caption="@column.Caption">
<DxComboBox Data="@bandOptions"
TData="BandOption"
TValue="string"
TextFieldName="Caption"
ValueFieldName="Id"
Value="@GetColumnBand(column.FieldName)"
ValueChanged="@(value => UpdateColumnBand(column.FieldName, value))"
Width="100%" />
</DxFormLayoutItem>
}
</DxFormLayout>
</div>
<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="grid-section">
<DxGrid Data="@items"
ColumnChooserButtonDisplayMode="GridColumnChooserButtonDisplayMode.Always"
TItem="CatalogReadDto"
KeyFieldName="@nameof(CatalogReadDto.Guid)"
SizeMode="@_sizeMode"
ShowGroupPanel="true"
ShowGroupedColumns="true"
AllowGroup="true"
ShowFilterRow="true"
FilterMenuButtonDisplayMode="GridFilterMenuButtonDisplayMode.Always"
AllowColumnResize="true"
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
AllowColumnReorder="true"
@@ -118,12 +57,68 @@ else
CustomizeEditModel="OnCustomizeEditModel"
EditModelSaving="OnEditModelSaving"
DataItemDeleting="OnDataItemDeleting"
FocusedRowEnabled="true"
@bind-FocusedRowKey="focusedRowKey"
RowClick="@(args => _focusedVisibleIndex = args.VisibleIndex)"
@ref="gridRef">
<ToolbarTemplate>
<DxToolbar>
<DxToolbarItem Alignment="ToolbarItemAlignment.Left">
<Template Context="_">
<DxButton IconCssClass="bi bi-plus-lg"
RenderStyle="ButtonRenderStyle.Secondary"
RenderStyleMode="ButtonRenderStyleMode.Text"
Click="@(() => gridRef!.StartEditNewRowAsync())" />
</Template>
</DxToolbarItem>
<DxToolbarItem Alignment="ToolbarItemAlignment.Left">
<Template Context="_">
<DxButton IconCssClass="bi bi-pencil"
RenderStyle="ButtonRenderStyle.Secondary"
RenderStyleMode="ButtonRenderStyleMode.Text"
Click="EditFocusedRow" />
</Template>
</DxToolbarItem>
<DxToolbarItem Alignment="ToolbarItemAlignment.Left">
<Template Context="_">
<DxButton IconCssClass="bi bi-trash"
RenderStyle="ButtonRenderStyle.Secondary"
RenderStyleMode="ButtonRenderStyleMode.Text"
Click="DeleteFocusedRow" />
</Template>
</DxToolbarItem>
<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)"
RenderStyle="ButtonRenderStyle.Secondary"
RenderStyleMode="ButtonRenderStyleMode.Text"
ItemClick="OnSizeChange">
<Items>
@foreach (var size in _sizeModes)
{
<DxDropDownButtonItem Text="@FormatSizeText(size)" Id="@size.ToString()" />
}
</Items>
</DxDropDownButton>
</Template>
</DxToolbarItem>
</DxToolbar>
</ToolbarTemplate>
<Columns>
@RenderColumns()
</Columns>
<EditFormTemplate Context="editFormContext">
@{ SetEditContext(editFormContext.EditContext); var editModel = (CatalogEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); }
@{
SetEditContext(editFormContext.EditContext); var editModel = (CatalogEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew);
}
<DxFormLayout ColCount="2">
<DxFormLayoutItem Caption="Titel">
<DxTextBox @bind-Text="editModel.CatTitle" Width="100%" />
@@ -154,36 +149,21 @@ else
@code {
private List<CatalogReadDto> items = new();
private bool isLoading;
private string? errorMessage;
private string? infoMessage;
private EditContext? editContext;
private ValidationMessageStore? validationMessageStore;
private IGrid? gridRef;
private string popupHeaderText = "Edit";
private DateTime? addedWhenFilterValue;
private string addedWhenFilterOperator = "=";
private DateTime? changedWhenFilterValue;
private string changedWhenFilterOperator = "=";
private const string LayoutType = "GRID_BANDS";
private const string LayoutKey = "CatalogsGrid";
private const string LayoutUserStorageKey = "layoutUser";
private string? layoutUser;
private BandLayout bandLayout = new();
private Dictionary<string, string> columnBandAssignments = new();
private List<BandOption> bandOptions = new();
private Dictionary<string, ColumnDefinition> columnLookup = new();
private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
private List<ColumnDefinition> columnDefinitions = new()
{
new() { FieldName = nameof(CatalogReadDto.Guid), Caption = "Id", Width = "140px", FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(CatalogReadDto.CatTitle), Caption = "Titel", FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(CatalogReadDto.CatString), Caption = "String", FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(CatalogReadDto.AddedWho), Caption = "Angelegt von", ReadOnly = true, FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(CatalogReadDto.AddedWhen), Caption = "Angelegt am", ReadOnly = true, FilterType = ColumnFilterType.Date },
new() { FieldName = nameof(CatalogReadDto.ChangedWho), Caption = "Geändert von", ReadOnly = true, FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(CatalogReadDto.ChangedWhen), Caption = "Geändert am", ReadOnly = true, FilterType = ColumnFilterType.Date }
};
private int? focusedRowKey;
protected override string LayoutKey => "CatalogsGrid";
protected override bool ShowCommandColumn => false;
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 readonly List<ProcedureOption> procedureOptions = new()
{
@@ -191,59 +171,40 @@ else
new() { Value = 1, Text = "PRTBMY_CATALOG_SAVE" }
};
private readonly List<FilterOperatorOption> filterOperators = new()
{
new() { Value = "=", Text = "=" },
new() { Value = "<", Text = "<" },
new() { Value = ">", Text = ">" },
new() { Value = "<=", Text = "<=" },
new() { Value = ">=", Text = ">=" }
};
private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser);
private bool gridLayoutApplied;
protected override async Task OnInitializedAsync()
{
columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase);
await EnsureLayoutUserAsync();
await LoadBandLayoutAsync();
await InitializeBandLayoutAsync();
await LoadCatalogs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!gridLayoutApplied && gridRef != null && bandLayout.GridLayout != null)
await ApplyGridLayoutAfterRenderAsync();
}
private async Task LoadCatalogs()
{
isLoading = true;
errorMessage = null;
try
{
gridRef.LoadLayout(bandLayout.GridLayout);
gridLayoutApplied = true;
await InvokeAsync(StateHasChanged);
items = await Api.GetAllAsync();
}
catch (Exception ex)
{
errorMessage = $"Kataloge konnten nicht geladen werden: {ex.Message}";
}
finally
{
isLoading = false;
hasLoaded = true;
StateHasChanged();
}
}
private void SetEditContext(EditContext context)
protected override void OnEditFieldChanged(object? sender, FieldChangedEventArgs e)
{
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 (validationMessageStore == null || editContext == null) return;
if (e.FieldIdentifier.FieldName == nameof(CatalogEditModel.UpdateProcedure))
{
@@ -254,17 +215,11 @@ else
if (e.FieldIdentifier.FieldName == nameof(CatalogEditModel.CatTitle))
{
var field = new FieldIdentifier(editContext.Model, nameof(CatalogEditModel.CatTitle));
validationMessageStore.Clear(field);
validationMessageStore.Clear(new FieldIdentifier(editContext.Model, nameof(CatalogEditModel.CatTitle)));
editContext.NotifyValidationStateChanged();
}
}
private void SetPopupHeaderText(bool isNew)
{
popupHeaderText = isNew ? "Neu" : "Edit";
}
private void OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
{
popupHeaderText = e.IsNew ? "Neu" : "Edit";
@@ -286,30 +241,10 @@ else
};
}
private async Task LoadCatalogs()
{
isLoading = true;
errorMessage = null;
try
{
items = await Api.GetAllAsync();
}
catch (Exception ex)
{
errorMessage = $"Kataloge konnten nicht geladen werden: {ex.Message}";
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task OnEditModelSaving(GridEditModelSavingEventArgs e)
{
errorMessage = null;
infoMessage = null;
validationMessageStore?.Clear();
editContext?.NotifyValidationStateChanged();
@@ -324,7 +259,7 @@ else
{
CatTitle = editModel.CatTitle,
CatString = editModel.CatString,
UpdateProcedure = editModel.UpdateProcedure
UpdateProcedure = (CatalogUpdateProcedure)editModel.UpdateProcedure
};
try
@@ -335,19 +270,14 @@ else
if (!created.Success || created.Value == null)
{
if (!string.IsNullOrWhiteSpace(created.Error))
{
AddValidationError(editModel, nameof(CatalogEditModel.CatTitle), created.Error);
}
else
{
errorMessage = "Anlegen fehlgeschlagen.";
}
e.Cancel = true;
return;
}
infoMessage = "Katalog angelegt.";
focusedRowKey = created.Value.Guid;
}
else
{
@@ -358,8 +288,8 @@ else
e.Cancel = true;
return;
}
infoMessage = "Katalog aktualisiert.";
focusedRowKey = editModel.Guid;
}
await LoadCatalogs();
@@ -373,30 +303,20 @@ else
private void AddValidationError(CatalogEditModel editModel, string fieldName, string message)
{
if (editContext == null || validationMessageStore == null)
{
return;
}
var field = new FieldIdentifier(editModel, fieldName);
validationMessageStore.Add(field, message);
if (editContext == null || validationMessageStore == null) return;
validationMessageStore.Add(new FieldIdentifier(editModel, fieldName), message);
editContext.NotifyValidationStateChanged();
}
private bool ValidateEditModel(CatalogEditModel editModel, bool isNew)
{
if (isNew)
{
return true;
}
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;
}
@@ -404,9 +324,7 @@ else
{
errorMessage = null;
infoMessage = null;
var item = (CatalogReadDto)e.DataItem;
try
{
var deleted = await Api.DeleteAsync(item.Guid);
@@ -416,7 +334,6 @@ else
e.Cancel = true;
return;
}
infoMessage = "Katalog gelöscht.";
await LoadCatalogs();
}
@@ -427,411 +344,6 @@ else
}
}
private async Task EnsureLayoutUserAsync()
{
layoutUser = await JsRuntime.InvokeAsync<string?>("localStorage.getItem", LayoutUserStorageKey);
if (string.IsNullOrWhiteSpace(layoutUser))
{
layoutUser = Guid.NewGuid().ToString("N");
await JsRuntime.InvokeVoidAsync("localStorage.setItem", LayoutUserStorageKey, layoutUser);
}
}
private async Task SaveLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
{
return;
}
try
{
CaptureColumnLayoutFromGrid();
var layoutData = JsonSerializer.Serialize(bandLayout, jsonOptions);
await LayoutApi.UpsertAsync(new LayoutDto
{
LayoutType = LayoutType,
LayoutKey = LayoutKey,
UserName = layoutUser,
LayoutData = layoutData
});
infoMessage = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
private void CaptureColumnLayoutFromGrid()
{
if (gridRef == null)
{
return;
}
var layout = gridRef.SaveLayout();
bandLayout.GridLayout = layout;
var orderedColumns = layout.Columns
.Where(column => !string.IsNullOrWhiteSpace(column.FieldName))
.OrderBy(column => column.VisibleIndex)
.ToList();
bandLayout.ColumnOrder = orderedColumns
.Select(column => column.FieldName)
.ToList();
bandLayout.ColumnWidths = orderedColumns
.Where(column => !string.IsNullOrWhiteSpace(column.Width))
.ToDictionary(column => column.FieldName, column => column.Width, StringComparer.OrdinalIgnoreCase);
}
private async Task LoadBandLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
{
bandLayout = new BandLayout();
UpdateBandOptions();
return;
}
var stored = await LayoutApi.GetAsync(LayoutType, LayoutKey, layoutUser);
if (stored != null && !string.IsNullOrWhiteSpace(stored.LayoutData))
{
var parsed = JsonSerializer.Deserialize<BandLayout>(stored.LayoutData, jsonOptions);
bandLayout = NormalizeBandLayout(parsed);
}
else
{
bandLayout = new BandLayout();
}
columnBandAssignments = BuildAssignmentsFromLayout(bandLayout);
ApplyColumnLayoutFromStorage();
UpdateBandOptions();
}
private async Task ResetBandLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
{
return;
}
await LayoutApi.DeleteAsync(LayoutType, LayoutKey, layoutUser);
bandLayout = new BandLayout();
columnBandAssignments.Clear();
UpdateBandOptions();
infoMessage = "Band-Layout zurückgesetzt.";
}
private void ApplyColumnLayoutFromStorage()
{
foreach (var column in columnDefinitions)
{
if (bandLayout.ColumnWidths.TryGetValue(column.FieldName, out var width) && !string.IsNullOrWhiteSpace(width))
{
column.Width = width;
}
}
columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase);
}
private void AddBand()
{
bandLayout.Bands.Add(new BandDefinition
{
Id = Guid.NewGuid().ToString("N"),
Caption = "Band"
});
UpdateBandOptions();
}
private void RemoveBand(BandDefinition band)
{
bandLayout.Bands.Remove(band);
var removedColumns = columnBandAssignments.Where(pair => pair.Value == band.Id)
.Select(pair => pair.Key)
.ToList();
foreach (var column in removedColumns)
{
columnBandAssignments.Remove(column);
}
UpdateBandOptions();
SyncBandsFromAssignments();
}
private void UpdateBandCaption(BandDefinition band, string value)
{
band.Caption = value;
UpdateBandOptions();
}
private void UpdateColumnBand(string fieldName, string? bandId)
{
if (string.IsNullOrWhiteSpace(bandId))
{
columnBandAssignments.Remove(fieldName);
}
else
{
columnBandAssignments[fieldName] = bandId;
}
SyncBandsFromAssignments();
}
private string GetColumnBand(string fieldName)
{
return columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty;
}
private void SyncBandsFromAssignments()
{
foreach (var band in bandLayout.Bands)
{
band.Columns = columnDefinitions
.Where(column => columnBandAssignments.TryGetValue(column.FieldName, out var bandId) && bandId == band.Id)
.Select(column => column.FieldName)
.ToList();
}
StateHasChanged();
}
private void UpdateBandOptions()
{
bandOptions = new List<BandOption> { new() { Id = string.Empty, Caption = "Ohne Band" } };
bandOptions.AddRange(bandLayout.Bands.Select(band => new BandOption { Id = band.Id, Caption = band.Caption }));
}
private BandLayout NormalizeBandLayout(BandLayout? layout)
{
layout ??= new BandLayout();
layout.Bands ??= new List<BandDefinition>();
layout.ColumnOrder ??= new List<string>();
layout.ColumnWidths ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var band in layout.Bands)
{
if (string.IsNullOrWhiteSpace(band.Id))
{
band.Id = Guid.NewGuid().ToString("N");
}
if (string.IsNullOrWhiteSpace(band.Caption))
{
band.Caption = "Band";
}
band.Columns = band.Columns?.Where(columnLookup.ContainsKey).ToList() ?? new List<string>();
}
return layout;
}
private Dictionary<string, string> BuildAssignmentsFromLayout(BandLayout layout)
{
var assignments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var band in layout.Bands)
{
foreach (var column in band.Columns)
{
assignments[column] = band.Id;
}
}
return assignments;
}
private RenderFragment RenderColumns() => builder =>
{
var seq = 0;
builder.OpenComponent<DxGridCommandColumn>(seq++);
builder.AddAttribute(seq++, "Width", "120px");
builder.CloseComponent();
var grouped = bandLayout.Bands.SelectMany(band => band.Columns).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var column in columnDefinitions.Where(column => !grouped.Contains(column.FieldName)))
{
BuildDataColumn(builder, ref seq, column);
}
foreach (var band in bandLayout.Bands)
{
if (band.Columns.Count == 0)
{
continue;
}
builder.OpenComponent<DxGridBandColumn>(seq++);
builder.AddAttribute(seq++, "Caption", band.Caption);
builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder =>
{
var bandSeq = 0;
foreach (var columnName in band.Columns)
{
if (columnLookup.TryGetValue(columnName, out var column))
{
BuildDataColumn(bandBuilder, ref bandSeq, column);
}
}
}));
builder.CloseComponent();
}
};
private void BuildDataColumn(RenderTreeBuilder builder, ref int seq, ColumnDefinition column)
{
builder.OpenComponent<DxGridDataColumn>(seq++);
builder.AddAttribute(seq++, "FieldName", column.FieldName);
builder.AddAttribute(seq++, "Caption", column.Caption);
if (!string.IsNullOrWhiteSpace(column.Width))
{
builder.AddAttribute(seq++, "Width", column.Width);
}
if (!string.IsNullOrWhiteSpace(column.DisplayFormat))
{
builder.AddAttribute(seq++, "DisplayFormat", column.DisplayFormat);
}
if (column.ReadOnly)
{
builder.AddAttribute(seq++, "ReadOnly", true);
}
if (string.Equals(column.FieldName, nameof(CatalogReadDto.AddedWhen), StringComparison.OrdinalIgnoreCase))
{
builder.AddAttribute(seq++, "FilterRowCellTemplate", BuildAddedWhenFilterTemplate());
}
else if (string.Equals(column.FieldName, nameof(CatalogReadDto.ChangedWhen), StringComparison.OrdinalIgnoreCase))
{
builder.AddAttribute(seq++, "FilterRowCellTemplate", BuildChangedWhenFilterTemplate());
}
builder.CloseComponent();
}
private RenderFragment<GridDataColumnFilterRowCellTemplateContext> BuildAddedWhenFilterTemplate()
{
return BuildDateFilterTemplate(
() => addedWhenFilterValue,
value => addedWhenFilterValue = value,
() => addedWhenFilterOperator,
value => addedWhenFilterOperator = value);
}
private RenderFragment<GridDataColumnFilterRowCellTemplateContext> BuildChangedWhenFilterTemplate()
{
return BuildDateFilterTemplate(
() => changedWhenFilterValue,
value => changedWhenFilterValue = value,
() => changedWhenFilterOperator,
value => changedWhenFilterOperator = value);
}
private RenderFragment<GridDataColumnFilterRowCellTemplateContext> BuildDateFilterTemplate(
Func<DateTime?> getValue,
Action<DateTime?> setValue,
Func<string> getOperator,
Action<string> setOperator)
{
return context => builder =>
{
var seq = 0;
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", "filter-row-cell");
builder.OpenComponent<DxComboBox<FilterOperatorOption, string>>(seq++);
builder.AddAttribute(seq++, "Data", filterOperators);
builder.AddAttribute(seq++, "TextFieldName", "Text");
builder.AddAttribute(seq++, "ValueFieldName", "Value");
builder.AddAttribute(seq++, "Value", getOperator());
builder.AddAttribute(seq++, "ValueChanged", EventCallback.Factory.Create<string>(this, value =>
{
setOperator(value);
UpdateFilterCriteria(context, value, getValue());
}));
builder.AddAttribute(seq++, "CssClass", "filter-operator");
builder.CloseComponent();
builder.OpenComponent<DxDateEdit<DateTime?>>(seq++);
builder.AddAttribute(seq++, "Date", getValue());
builder.AddAttribute(seq++, "DateChanged", EventCallback.Factory.Create<DateTime?>(this, value =>
{
setValue(value);
UpdateFilterCriteria(context, getOperator(), value);
}));
builder.AddAttribute(seq++, "ClearButtonDisplayMode", DataEditorClearButtonDisplayMode.Auto);
builder.AddAttribute(seq++, "CssClass", "filter-value");
builder.CloseComponent();
builder.CloseElement();
};
}
private void UpdateFilterCriteria(GridDataColumnFilterRowCellTemplateContext context, string op, DateTime? value)
{
if (!value.HasValue)
{
context.FilterCriteria = null;
return;
}
var prop = new OperandProperty(context.DataColumn.FieldName);
var date = value.Value.Date;
context.FilterCriteria = op switch
{
"=" => new GroupOperator(GroupOperatorType.And, prop >= date, prop < date.AddDays(1)),
"<" => prop < date,
"<=" => prop < date.AddDays(1),
">" => prop >= date.AddDays(1),
">=" => prop >= date,
_ => prop == date
};
}
private sealed class BandLayout
{
public List<BandDefinition> Bands { get; set; } = new();
public List<string> ColumnOrder { get; set; } = new();
public Dictionary<string, string?> ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public GridPersistentLayout? GridLayout { get; set; }
}
private sealed class BandDefinition
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
public List<string> Columns { get; set; } = new();
}
private sealed class BandOption
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
}
private sealed class ColumnDefinition
{
public string FieldName { get; init; } = string.Empty;
public string Caption { get; init; } = string.Empty;
public string? Width { get; set; }
public string? DisplayFormat { get; init; }
public bool ReadOnly { get; init; }
public ColumnFilterType FilterType { get; init; }
}
private enum ColumnFilterType
{
Text,
Date
}
private sealed class CatalogEditModel
{
public int Guid { get; set; }
@@ -847,10 +359,4 @@ else
public int Value { get; set; }
public string Text { get; set; } = string.Empty;
}
private sealed class FilterOperatorOption
{
public string Value { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
}
}
}

View File

@@ -0,0 +1,3 @@
.catalog-edit-popup {
min-width: 720px;
}

View File

@@ -1,12 +1,23 @@
@inherits LayoutComponentBase
@implements IDisposable
@inject ThemeState ThemeState
@inject IJSRuntime JS
<div class="page">
<div class="page @(ThemeState.IsDarkMode ? "app-dark" : "app-light") @(ThemeState.IsNativeDarkTheme ? "native-dark" : "")">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<DxComboBox Data="@ThemeState.AvailableThemes"
Value="@ThemeState.CurrentThemeName"
ValueChanged="@((string t) => ThemeState.SetTheme(t))"
style="width: 130px;" />
<span style="margin-left: 12px;">
<DxButton Text="@(ThemeState.IsDarkMode ? "Dark Mode aus" : "Dark Mode an")"
Click="ToggleTheme" />
</span>
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
@@ -21,3 +32,54 @@
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@code {
private bool _isInteractive;
protected override void OnInitialized()
{
ThemeState.OnChange += OnThemeChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_isInteractive = true;
}
await ApplyDxDarkOverrideAsync();
}
private async void OnThemeChanged()
{
StateHasChanged();
if (_isInteractive)
{
await ApplyDxDarkOverrideAsync();
}
}
private async Task ApplyDxDarkOverrideAsync()
{
if (!_isInteractive) return;
try
{
bool needsOverride = ThemeState.IsDarkMode && !ThemeState.IsNativeDarkTheme;
await JS.InvokeVoidAsync("setDxDarkOverride", needsOverride);
}
catch (JSException)
{
// JS-Funktion noch nicht verfügbar kein Circuit-Crash
}
}
private void ToggleTheme()
{
ThemeState.SetDarkMode(!ThemeState.IsDarkMode);
}
public void Dispose()
{
ThemeState.OnChange -= OnThemeChanged;
}
}

View File

@@ -4,12 +4,27 @@
flex-direction: column;
}
.page.app-dark {
background-color: #1b1b1b;
color: #f1f1f1;
}
main {
flex: 1;
}
.page.app-dark main {
background-color: #1b1b1b;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
background-color: var(--dx-color-surface-container, #f4f4f4);
border-right: 1px solid var(--dx-color-outline-variant, #e0e0e0);
}
.page.app-dark .sidebar {
background-color: var(--dx-color-surface-container, #1e1e1e);
border-right-color: var(--dx-color-outline-variant, #333);
}
.top-row {
@@ -21,6 +36,11 @@ main {
align-items: center;
}
.page.app-dark .top-row {
background-color: #222;
border-bottom-color: #3d3d3d;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;

View File

@@ -1,36 +1,24 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">DbFirst.BlazorWebApp</a>
</div>
<div class="nav-brand-row">
<a class="nav-brand-link" href="">DbFirst</a>
<button class="nav-toggle-btn" @onclick="ToggleMenu" title="Navigation menu">&#9776;</button>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="catalogs">
<span class="bi bi-collection-nav-menu" aria-hidden="true"></span> Catalogs
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="dashboards">
<span class="bi bi-speedometer-nav-menu" aria-hidden="true"></span> Dashboards
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="massdata">
<span class="bi bi-table-nav-menu" aria-hidden="true"></span> MassData
</NavLink>
</div>
</nav>
<div class="nav-scrollable @(menuOpen ? "nav-open" : "")">
<DxTreeView CssClass="sidebar-tree">
<Nodes>
<DxTreeViewNode Text="Home" NavigateUrl="/" IconCssClass="dxi dxi-home" />
<DxTreeViewNode Text="Data Management" Expanded="true">
<Nodes>
<DxTreeViewNode Text="Catalogs" NavigateUrl="/catalogs" IconCssClass="dxi dxi-folder" />
<DxTreeViewNode Text="Dashboards" NavigateUrl="/dashboards" IconCssClass="dxi dxi-chart-bar" />
<DxTreeViewNode Text="Mass Data" NavigateUrl="/massdata" IconCssClass="dxi dxi-table" />
</Nodes>
</DxTreeViewNode>
</Nodes>
</DxTreeView>
</div>
@code {
private bool menuOpen = false;
private void ToggleMenu() => menuOpen = !menuOpen;
}

View File

@@ -1,117 +1,52 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
.nav-brand-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
}
.navbar-brand {
font-size: 1.1rem;
.nav-brand-link {
font-size: 1.05rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.bi-collection-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M2 3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 .5.5v1H2V3z'/%3E%3Cpath d='M2 5h12v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5z'/%3E%3C/svg%3E");
}
.bi-speedometer-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M1 11a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v4zm5 0a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v8zm5 0a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v2z'/%3E%3C/svg%3E");
}
.bi-table-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M1 2a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2zm1 1v2h12V3H2zm12 3H2v2h12V6zm0 3H2v2h12V9zm0 3H2v1h12v-1z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
.nav-toggle-btn {
display: block;
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
line-height: 1;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
.nav-scrollable.nav-open {
display: block;
}
.sidebar-tree {
width: 100%;
}
@media (min-width: 641px) {
.navbar-toggler {
.nav-toggle-btn {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
.massdata-edit-popup {
min-width: 720px;
}

View File

@@ -1,49 +1,9 @@
@page "/dashboard"
@page "/dashboards/{DashboardId?}"
@implements IAsyncDisposable
@inject Microsoft.Extensions.Configuration.IConfiguration Configuration
@inject IOptions<AppSettings> AppSettingsOptions
@inject NavigationManager Navigation
@inject DashboardApiClient DashboardApi
<style>
.dashboard-shell {
display: flex;
gap: 0;
min-height: 800px;
border: 1px solid #e6e6e6;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.dashboard-nav {
width: 220px;
border-right: 1px solid #e6e6e6;
background: #fafafa;
}
.dashboard-nav-title {
padding: 0.75rem 1rem 0.5rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6c757d;
font-weight: 600;
}
.dashboard-nav-link {
display: block;
padding: 0.55rem 1rem;
color: inherit;
text-decoration: none;
}
.dashboard-nav-link.active {
background: #e9ecef;
font-weight: 600;
}
.dashboard-content {
flex: 1;
min-width: 0;
padding: 1rem;
}
</style>
@inject IDashboardApiClient DashboardApi
<PageTitle>Dashboards</PageTitle>
@@ -85,12 +45,17 @@
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";
private string DashboardEndpoint => $"{AppSettingsOptions.Value.ApiBaseUrl.TrimEnd('/')}/api/dashboard";
private string HubEndpoint => $"{AppSettingsOptions.Value.ApiBaseUrl.TrimEnd('/')}/hubs/dashboards";
protected override async Task OnInitializedAsync()
{
await RefreshDashboards();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_hubConnection = new HubConnectionBuilder()
.WithUrl(HubEndpoint)
@@ -107,11 +72,6 @@
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

@@ -0,0 +1,42 @@
.dashboard-shell {
display: flex;
gap: 0;
min-height: 800px;
border: 1px solid #e6e6e6;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.dashboard-nav {
width: 220px;
border-right: 1px solid #e6e6e6;
background: #fafafa;
}
.dashboard-nav-title {
padding: 0.75rem 1rem 0.5rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6c757d;
font-weight: 600;
}
.dashboard-nav-link {
display: block;
padding: 0.55rem 1rem;
color: inherit;
text-decoration: none;
}
.dashboard-nav-link.active {
background: #e9ecef;
font-weight: 600;
}
.dashboard-content {
flex: 1;
min-width: 0;
padding: 1rem;
}

View File

@@ -1,7 +1,43 @@
@page "/"
@rendermode InteractiveServer
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
<h1>Database first</h1>
Welcome to your new app.
<DxCarousel Width="100%"
Height="calc(100vh - 9rem)"
Data="@GetCarouselData()"
ImageSrcField="Source"
ImageAltField="AlternateText"
LoopNavigationEnabled="true"
SlideShowEnabled="true"
SlideShowDelay="3000"
PauseSlideShowOnHover="true"
ImageSizeMode="CarouselImageSizeMode.FitProportional">
</DxCarousel>
@code {
List<CarouselData> GetCarouselData()
{
return new List<CarouselData>
{
new CarouselData("/images/DbFirstBefehl.png", "DbFirstBefehl"),
new CarouselData("/images/CQRS - Katalog-Datenfluss.png", "CQRS - Katalog-Datenfluss"),
new CarouselData("/images/CQRS - Catalog Create, Update, Delete.png", "CQRS - Catalog Create, Update, Delete"),
};
}
public class CarouselData
{
public string Source { get; set; } = string.Empty;
public string AlternateText { get; set; } = string.Empty;
public CarouselData(string source, string alt)
{
Source = source;
AlternateText = alt;
}
}
}

View File

@@ -1,4 +1,5 @@
@rendermode InteractiveServer
@rendermode @(new InteractiveServerRenderMode(prerender: false))
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">

View File

@@ -1,17 +1,28 @@
@using System.Net.Http
@using System.Net.Http.Json
@using System.Text.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Rendering
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Microsoft.AspNetCore.SignalR.Client
@using Microsoft.JSInterop
@using Microsoft.Extensions.Options
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using DbFirst.BlazorWebApp
@using DbFirst.BlazorWebApp.Components
@using DbFirst.BlazorWebApp.Models
@using DbFirst.BlazorWebApp.Models.Grid
@using DbFirst.BlazorWebApp.Services
@using DbFirst.Contracts.Catalogs
@using DbFirst.Contracts.Dashboards
@using DbFirst.Contracts.MassData
@using DbFirst.Contracts.Layouts
@using DbFirst.Domain
@using DevExpress.Blazor
@using DevExpress.DashboardBlazor
@using DevExpress.DashboardWeb
@using DbFirst.BlazorWebApp
@using DevExpress.Data.Filtering

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -11,10 +11,15 @@
<PackageReference Include="DevExpress.Blazor.Dashboard" Version="25.2.3" />
<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>
<ItemGroup>
<Folder Include="wwwroot\images\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DbFirst.Contracts\DbFirst.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
namespace DbFirst.BlazorWebApp.Models;
public record ApiResult<T>(bool Success, T? Value, string? Error)
{
public static ApiResult<T> Ok(T? value) => new(true, value, null);
public static ApiResult<T> Fail(string? error) => new(false, default, error);
}

View File

@@ -1,12 +0,0 @@
namespace DbFirst.BlazorWebApp.Models;
public class CatalogReadDto
{
public int Guid { get; set; }
public string CatTitle { get; set; } = null!;
public string CatString { get; set; } = null!;
public string AddedWho { get; set; } = null!;
public DateTime AddedWhen { get; set; }
public string? ChangedWho { get; set; }
public DateTime? ChangedWhen { get; set; }
}

View File

@@ -1,8 +0,0 @@
namespace DbFirst.BlazorWebApp.Models;
public class CatalogWriteDto
{
public string CatTitle { get; set; } = string.Empty;
public string CatString { get; set; } = string.Empty;
public int UpdateProcedure { get; set; }
}

View File

@@ -0,0 +1,43 @@
using DevExpress.Blazor;
namespace DbFirst.BlazorWebApp.Models.Grid
{
public class BandLayout
{
public List<BandDefinition> Bands { get; set; } = [];
public List<string> ColumnOrder { get; set; } = [];
public Dictionary<string, string?> ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public GridPersistentLayout? GridLayout { get; set; }
public SizeMode SizeMode { get; set; } = SizeMode.Medium;
}
public class BandDefinition
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
public List<string> Columns { get; set; } = [];
}
public class BandOption
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
}
public class ColumnDefinition
{
public string FieldName { get; init; } = string.Empty;
public string Caption { get; init; } = string.Empty;
public string? Width { get; set; }
public string? DisplayFormat { get; init; }
public bool ReadOnly { get; init; }
public ColumnFilterType FilterType { get; init; }
}
public enum ColumnFilterType
{
Text,
Bool,
Date
}
}

View File

@@ -1,9 +0,0 @@
namespace DbFirst.BlazorWebApp.Models;
public class LayoutDto
{
public string LayoutType { get; set; } = string.Empty;
public string LayoutKey { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string LayoutData { get; set; } = string.Empty;
}

View File

@@ -1,9 +0,0 @@
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

@@ -1,3 +1,4 @@
using DbFirst.BlazorWebApp;
using DbFirst.BlazorWebApp.Components;
using DbFirst.BlazorWebApp.Services;
using DevExpress.Blazor;
@@ -8,36 +9,23 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddDevExpressBlazor();
builder.Services.AddDevExpressBlazor(options => options.BootstrapVersion = BootstrapVersion.v5);
builder.Services.AddScoped<ThemeState>();
builder.Services.AddScoped<BandLayoutService>();
var apiBaseUrl = builder.Configuration["ApiBaseUrl"];
if (!string.IsNullOrWhiteSpace(apiBaseUrl))
builder.Services.Configure<AppSettings>(builder.Configuration);
void ConfigureClient(HttpClient client)
{
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
if (!string.IsNullOrWhiteSpace(apiBaseUrl))
client.BaseAddress = new Uri(apiBaseUrl);
});
builder.Services.AddHttpClient<DashboardApiClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
});
builder.Services.AddHttpClient<MassDataApiClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
});
builder.Services.AddHttpClient<LayoutApiClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
});
}
else
{
builder.Services.AddHttpClient<CatalogApiClient>();
builder.Services.AddHttpClient<DashboardApiClient>();
builder.Services.AddHttpClient<MassDataApiClient>();
builder.Services.AddHttpClient<LayoutApiClient>();
}
builder.Services.AddHttpClient<ICatalogApiClient, CatalogApiClient>(ConfigureClient);
builder.Services.AddHttpClient<IDashboardApiClient, DashboardApiClient>(ConfigureClient);
builder.Services.AddHttpClient<IMassDataApiClient, MassDataApiClient>(ConfigureClient);
builder.Services.AddHttpClient<ILayoutApiClient, LayoutApiClient>(ConfigureClient);
var app = builder.Build();
// Configure the HTTP request pipeline.

View File

@@ -0,0 +1,59 @@
using System.Net;
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

@@ -0,0 +1,109 @@
using DbFirst.BlazorWebApp.Models.Grid;
using DbFirst.Contracts.Layouts;
using Microsoft.JSInterop;
using System.Text.Json;
namespace DbFirst.BlazorWebApp.Services
{
public class BandLayoutService(ILayoutApiClient layoutApi, IJSRuntime jsRuntime)
{
private const string LayoutUserStorageKey = "layoutUser";
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private string? _cachedLayoutUser;
public async Task<string> EnsureLayoutUserAsync()
{
if (!string.IsNullOrWhiteSpace(_cachedLayoutUser))
return _cachedLayoutUser;
var layoutUser = await jsRuntime.InvokeAsync<string?>("localStorage.getItem", LayoutUserStorageKey);
if (string.IsNullOrWhiteSpace(layoutUser))
{
layoutUser = Guid.NewGuid().ToString("N");
await jsRuntime.InvokeVoidAsync("localStorage.setItem", LayoutUserStorageKey, layoutUser);
}
_cachedLayoutUser = layoutUser;
return _cachedLayoutUser;
}
public async Task<BandLayout> LoadBandLayoutAsync(
string layoutType,
string layoutKey,
string layoutUser,
Dictionary<string, ColumnDefinition> columnLookup)
{
if (string.IsNullOrWhiteSpace(layoutUser))
return new BandLayout();
var stored = await layoutApi.GetAsync(layoutType, layoutKey, layoutUser);
if (stored != null && !string.IsNullOrWhiteSpace(stored.LayoutData))
{
var parsed = JsonSerializer.Deserialize<BandLayout>(stored.LayoutData, _jsonOptions);
return NormalizeBandLayout(parsed, columnLookup);
}
return new BandLayout();
}
public async Task SaveBandLayoutAsync(
string layoutType,
string layoutKey,
string layoutUser,
BandLayout bandLayout)
{
var layoutData = JsonSerializer.Serialize(bandLayout, _jsonOptions);
await layoutApi.UpsertAsync(new LayoutDto
{
LayoutType = layoutType,
LayoutKey = layoutKey,
UserName = layoutUser,
LayoutData = layoutData
});
}
public async Task ResetBandLayoutAsync(
string layoutType,
string layoutKey,
string layoutUser)
{
await layoutApi.DeleteAsync(layoutType, layoutKey, layoutUser);
}
public Dictionary<string, string> BuildAssignmentsFromLayout(BandLayout layout)
{
var assignments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var band in layout.Bands)
{
foreach (var column in band.Columns)
{
assignments[column] = band.Id;
}
}
return assignments;
}
public BandLayout NormalizeBandLayout(
BandLayout? layout,
Dictionary<string, ColumnDefinition> columnLookup)
{
layout ??= new BandLayout();
layout.Bands ??= [];
layout.ColumnOrder ??= [];
layout.ColumnWidths ??= [];
foreach (var band in layout.Bands)
{
if (string.IsNullOrWhiteSpace(band.Id))
band.Id = Guid.NewGuid().ToString("N");
if (string.IsNullOrWhiteSpace(band.Caption))
band.Caption = "Band";
band.Columns = band.Columns?.Where(columnLookup.ContainsKey).ToList() ?? new List<string>();
}
return layout;
}
}
}

View File

@@ -1,10 +1,9 @@
using System.Net;
using System.Net.Http.Json;
using DbFirst.BlazorWebApp.Models;
using DbFirst.Contracts.Catalogs;
namespace DbFirst.BlazorWebApp.Services;
public class CatalogApiClient
public class CatalogApiClient : ICatalogApiClient
{
private readonly HttpClient _httpClient;
private const string Endpoint = "api/catalogs";
@@ -14,123 +13,51 @@ public class CatalogApiClient
_httpClient = httpClient;
}
public async Task<List<CatalogReadDto>> GetAllAsync()
public async Task<List<CatalogReadDto>> GetAllAsync(CancellationToken ct = default)
{
var result = await _httpClient.GetFromJsonAsync<List<CatalogReadDto>>(Endpoint);
return result ?? new List<CatalogReadDto>();
var result = await _httpClient.GetFromJsonAsync<List<CatalogReadDto>>(Endpoint, ct);
return result ?? [];
}
public async Task<CatalogReadDto?> GetByIdAsync(int id)
public async Task<CatalogReadDto?> GetByIdAsync(int id, CancellationToken ct = default)
{
return await _httpClient.GetFromJsonAsync<CatalogReadDto>($"{Endpoint}/{id}");
return await _httpClient.GetFromJsonAsync<CatalogReadDto>($"{Endpoint}/{id}", ct);
}
public async Task<ApiResult<CatalogReadDto?>> CreateAsync(CatalogWriteDto dto)
public async Task<ApiResult<CatalogReadDto?>> CreateAsync(CatalogWriteDto dto, CancellationToken ct = default)
{
var response = await _httpClient.PostAsJsonAsync(Endpoint, dto);
var response = await _httpClient.PostAsJsonAsync(Endpoint, dto, ct);
if (response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadFromJsonAsync<CatalogReadDto>();
return ApiResult<CatalogReadDto?>.Ok(payload);
}
var error = await ReadErrorAsync(response);
var error = await ApiClientHelper.ReadErrorAsync(response);
return ApiResult<CatalogReadDto?>.Fail(error);
}
public async Task<ApiResult<bool>> UpdateAsync(int id, CatalogWriteDto dto)
public async Task<ApiResult<bool>> UpdateAsync(int id, CatalogWriteDto dto, CancellationToken ct = default)
{
var response = await _httpClient.PutAsJsonAsync($"{Endpoint}/{id}", dto);
var response = await _httpClient.PutAsJsonAsync($"{Endpoint}/{id}", dto, ct);
if (response.IsSuccessStatusCode)
{
return ApiResult<bool>.Ok(true);
}
var error = await ReadErrorAsync(response);
var error = await ApiClientHelper.ReadErrorAsync(response);
return ApiResult<bool>.Fail(error);
}
public async Task<ApiResult<bool>> DeleteAsync(int id)
public async Task<ApiResult<bool>> DeleteAsync(int id, CancellationToken ct = default)
{
var response = await _httpClient.DeleteAsync($"{Endpoint}/{id}");
var response = await _httpClient.DeleteAsync($"{Endpoint}/{id}", ct);
if (response.IsSuccessStatusCode)
{
return ApiResult<bool>.Ok(true);
}
var error = await ReadErrorAsync(response);
var error = await ApiClientHelper.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)
{
public static ApiResult<T> Ok(T? value) => new(true, value, null);
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

@@ -1,9 +1,8 @@
using System.Net.Http.Json;
using DbFirst.BlazorWebApp.Models;
using DbFirst.Contracts.Dashboards;
namespace DbFirst.BlazorWebApp.Services;
public class DashboardApiClient
public class DashboardApiClient : IDashboardApiClient
{
private readonly HttpClient _httpClient;
private const string Endpoint = "api/dashboard/dashboards";
@@ -13,9 +12,9 @@ public class DashboardApiClient
_httpClient = httpClient;
}
public async Task<List<DashboardInfoDto>> GetAllAsync()
public async Task<List<DashboardInfoDto>> GetAllAsync(CancellationToken ct = default)
{
var result = await _httpClient.GetFromJsonAsync<List<DashboardInfoDto>>(Endpoint);
return result ?? new List<DashboardInfoDto>();
var result = await _httpClient.GetFromJsonAsync<List<DashboardInfoDto>>(Endpoint, ct);
return result ?? [];
}
}

View File

@@ -0,0 +1,13 @@
using DbFirst.BlazorWebApp.Models;
using DbFirst.Contracts.Catalogs;
namespace DbFirst.BlazorWebApp.Services;
public interface ICatalogApiClient
{
Task<List<CatalogReadDto>> GetAllAsync(CancellationToken ct = default);
Task<CatalogReadDto?> GetByIdAsync(int id, CancellationToken ct = default);
Task<ApiResult<CatalogReadDto?>> CreateAsync(CatalogWriteDto dto, CancellationToken ct = default);
Task<ApiResult<bool>> UpdateAsync(int id, CatalogWriteDto dto, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteAsync(int id, CancellationToken ct = default);
}

View File

@@ -0,0 +1,9 @@
using DbFirst.Contracts.Dashboards;
namespace DbFirst.BlazorWebApp.Services
{
public interface IDashboardApiClient
{
Task<List<DashboardInfoDto>> GetAllAsync(CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,11 @@
using DbFirst.Contracts.Layouts;
namespace DbFirst.BlazorWebApp.Services
{
public interface ILayoutApiClient
{
Task<LayoutDto?> GetAsync(string layoutType, string layoutKey, string userName, CancellationToken ct = default);
Task<LayoutDto> UpsertAsync(LayoutDto dto, CancellationToken ct = default);
Task DeleteAsync(string layoutType, string layoutKey, string userName, CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,13 @@
using DbFirst.BlazorWebApp.Models;
using DbFirst.Contracts.MassData;
namespace DbFirst.BlazorWebApp.Services
{
public interface IMassDataApiClient
{
Task<int> GetCountAsync(CancellationToken ct = default);
Task<List<MassDataReadDto>> GetAllAsync(int? skip, int? take, CancellationToken ct = default);
Task<ApiResult<MassDataReadDto?>> UpsertAsync(MassDataWriteDto dto, CancellationToken ct = default);
Task<MassDataReadDto?> GetByCustomerNameAsync(string customerName, CancellationToken ct = default);
}
}

View File

@@ -1,9 +1,8 @@
using System.Net.Http.Json;
using DbFirst.BlazorWebApp.Models;
using DbFirst.Contracts.Layouts;
namespace DbFirst.BlazorWebApp.Services;
public class LayoutApiClient
public class LayoutApiClient : ILayoutApiClient
{
private readonly HttpClient _httpClient;
private const string Endpoint = "api/layouts";
@@ -13,10 +12,10 @@ public class LayoutApiClient
_httpClient = httpClient;
}
public async Task<LayoutDto?> GetAsync(string layoutType, string layoutKey, string userName)
public async Task<LayoutDto?> GetAsync(string layoutType, string layoutKey, string userName, CancellationToken ct = default)
{
var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}";
var response = await _httpClient.GetAsync(url);
var response = await _httpClient.GetAsync(url, ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
@@ -26,12 +25,12 @@ public class LayoutApiClient
return await response.Content.ReadFromJsonAsync<LayoutDto>();
}
public async Task<LayoutDto> UpsertAsync(LayoutDto dto)
public async Task<LayoutDto> UpsertAsync(LayoutDto dto, CancellationToken ct = default)
{
var response = await _httpClient.PostAsJsonAsync(Endpoint, dto);
var response = await _httpClient.PostAsJsonAsync(Endpoint, dto, ct);
if (!response.IsSuccessStatusCode)
{
var detail = await ReadErrorAsync(response);
var detail = await ApiClientHelper.ReadErrorAsync(response);
throw new InvalidOperationException(detail);
}
@@ -39,21 +38,10 @@ public class LayoutApiClient
return payload ?? dto;
}
private static async Task<string> ReadErrorAsync(HttpResponseMessage response)
{
var body = await response.Content.ReadAsStringAsync();
if (!string.IsNullOrWhiteSpace(body))
{
return body;
}
return $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
}
public async Task DeleteAsync(string layoutType, string layoutKey, string userName)
public async Task DeleteAsync(string layoutType, string layoutKey, string userName, CancellationToken ct = default)
{
var url = $"{Endpoint}?layoutType={Uri.EscapeDataString(layoutType)}&layoutKey={Uri.EscapeDataString(layoutKey)}&userName={Uri.EscapeDataString(userName)}";
var response = await _httpClient.DeleteAsync(url);
var response = await _httpClient.DeleteAsync(url, ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return;

View File

@@ -1,9 +1,9 @@
using System.Net.Http.Json;
using DbFirst.BlazorWebApp.Models;
using DbFirst.Contracts.MassData;
namespace DbFirst.BlazorWebApp.Services;
public class MassDataApiClient
public class MassDataApiClient : IMassDataApiClient
{
private readonly HttpClient _httpClient;
private const string Endpoint = "api/massdata";
@@ -13,13 +13,13 @@ public class MassDataApiClient
_httpClient = httpClient;
}
public async Task<int> GetCountAsync()
public async Task<int> GetCountAsync(CancellationToken ct = default)
{
var result = await _httpClient.GetFromJsonAsync<int?>("api/massdata/count");
var result = await _httpClient.GetFromJsonAsync<int?>("api/massdata/count", ct);
return result ?? 0;
}
public async Task<List<MassDataReadDto>> GetAllAsync(int? skip, int? take)
public async Task<List<MassDataReadDto>> GetAllAsync(int? skip, int? take, CancellationToken ct = default)
{
var query = new List<string>();
if (skip.HasValue)
@@ -32,26 +32,31 @@ public class MassDataApiClient
}
var url = query.Count == 0 ? Endpoint : $"{Endpoint}?{string.Join("&", query)}";
var result = await _httpClient.GetFromJsonAsync<List<MassDataReadDto>>(url);
return result ?? new List<MassDataReadDto>();
var result = await _httpClient.GetFromJsonAsync<List<MassDataReadDto>>(url, ct);
return result ?? [];
}
public async Task<MassDataReadDto> UpsertAsync(MassDataWriteDto dto)
public async Task<ApiResult<MassDataReadDto?>> UpsertAsync(MassDataWriteDto dto, CancellationToken ct = default)
{
var response = await _httpClient.PostAsJsonAsync($"{Endpoint}/upsert", dto);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<MassDataReadDto>();
return payload ?? new MassDataReadDto();
var response = await _httpClient.PostAsJsonAsync($"{Endpoint}/upsert", dto, ct);
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);
}
public async Task<MassDataReadDto?> GetByCustomerNameAsync(string customerName)
public async Task<MassDataReadDto?> GetByCustomerNameAsync(string customerName, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(customerName))
{
return null;
}
var response = await _httpClient.GetAsync($"{Endpoint}/{Uri.EscapeDataString(customerName)}");
var response = await _httpClient.GetAsync($"{Endpoint}/{Uri.EscapeDataString(customerName)}", ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;

View File

@@ -0,0 +1,65 @@
using DevExpress.Blazor;
namespace DbFirst.BlazorWebApp.Services;
public class ThemeState
{
private readonly IThemeChangeService themeChangeService;
public ThemeState(IThemeChangeService themeChangeService)
{
this.themeChangeService = themeChangeService;
}
public bool IsDarkMode { get; private set; }
public string CurrentThemeName { get; private set; } = "Fluent";
/// <summary>
/// Themes die eine native DevExpress Dark-Variante besitzen:
/// - Fluent ? Themes.Fluent.Clone(ThemeMode.Dark), verwendet --DS-* Token-System
/// - BlazingBerry ? Themes.BlazingDark
/// Alle anderen Themes (Purple, OfficeWhite, BootstrapExternal) haben keine offizielle
/// Dark-Variante; dort übernehmen CSS-Overrides auf --dxbl-grid-* Variablen die Arbeit.
/// </summary>
public bool IsNativeDarkTheme => IsDarkMode &&
(CurrentThemeName == "Fluent" || CurrentThemeName == "BlazingBerry");
public static readonly List<string> AvailableThemes = ["Fluent", "BlazingBerry", "Purple", "OfficeWhite", "BootstrapExternal"];
public event Action? OnChange;
public void SetTheme(string themeName)
{
if (CurrentThemeName == themeName) return;
CurrentThemeName = themeName;
ApplyTheme();
OnChange?.Invoke();
}
public void SetDarkMode(bool isDarkMode)
{
if (IsDarkMode == isDarkMode) return;
IsDarkMode = isDarkMode;
ApplyTheme();
OnChange?.Invoke();
}
private void ApplyTheme()
{
if (CurrentThemeName == "Fluent")
{
var theme = Themes.Fluent.Clone(properties =>
{
properties.Mode = IsDarkMode ? ThemeMode.Dark : ThemeMode.Light;
properties.ApplyToPageElements = true;
});
themeChangeService.SetTheme(theme);
}
else if (CurrentThemeName == "BlazingBerry") themeChangeService.SetTheme(IsDarkMode ? Themes.BlazingDark : Themes.BlazingBerry);
else if (CurrentThemeName == "Purple") themeChangeService.SetTheme(Themes.Purple);
else if (CurrentThemeName == "OfficeWhite") themeChangeService.SetTheme(Themes.OfficeWhite);
else if (CurrentThemeName == "BootstrapExternal") themeChangeService.SetTheme(Themes.BootstrapExternal);
else
themeChangeService.SetTheme(Themes.Fluent);
}
}

View File

@@ -5,5 +5,11 @@
"Microsoft.AspNetCore": "Warning"
}
},
"ApiBaseUrl": "https://localhost:7204/"
"ApiBaseUrl": "https://localhost:7204/",
"BrowserLink": {
"Enabled": false
},
"DetailedErrors": true
}

View File

@@ -1,11 +1,37 @@
:root {
--global-size: 16px;
}
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: var(--global-size);
}
/* Theme-Variablen */
.app-light {
--band-editor-bg: #f8f9fa;
--band-editor-border: #dee2e6;
--band-toggle-hover-bg: #e9ecef;
--grid-stripe-bg: rgba(0, 0, 0, 0.03);
}
.app-dark {
background-color: #1b1b1b;
color: #f1f1f1;
--band-editor-bg: #2d2d2d;
--band-editor-border: #444444;
--band-toggle-hover-bg: #3a3a3a;
--grid-stripe-bg: rgba(255, 255, 255, 0.04);
}
a, .btn-link {
color: #006bb7;
}
.app-dark a, .app-dark .btn-link {
color: #6cb6ff;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
@@ -13,7 +39,7 @@ a, .btn-link {
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
@@ -37,7 +63,7 @@ h1:focus {
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDg2IDY2LjAxODMgMjYzLjU4NiA2Ni4wMTgzWk0yNjMuNTc2IDg2LjA1NDdDMjYxLjA0OSA4Ni4wNTQ3IDI1OS43ODUgODcuMzAwNSAyNTkuNzg2IDg5Ljc5MjEgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
@@ -49,3 +75,216 @@ h1:focus {
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
/* Grid Band-Editor */
.band-editor {
margin-top: 4px;
margin-bottom: 16px;
border: 1px solid var(--band-editor-border);
border-radius: 4px;
background-color: var(--band-editor-bg);
overflow: hidden;
}
.band-editor-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
cursor: pointer;
font-size: inherit;
color: inherit;
text-align: left;
}
.band-editor-toggle:hover {
background-color: var(--band-toggle-hover-bg);
}
.band-editor-toggle-icon {
font-size: 0.7rem;
transition: transform 0.2s ease;
display: inline-block;
}
.band-editor-toggle-icon.expanded {
transform: rotate(90deg);
}
.band-editor-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 12px 12px 12px;
}
.band-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.band-row {
display: flex;
gap: 8px;
align-items: center;
}
.band-columns {
max-width: 720px;
margin-top: 4px;
}
.grid-section {
margin-top: 4px;
}
/* Grid Zebra-Striping */
dxbl-grid tbody tr:nth-child(even) td {
background-color: var(--grid-stripe-bg) !important;
}
/* ?? Dark-Mode-Overrides für nicht-native Themes ?????????????????????????????
Strategie: CSS-Custom-Properties werden von DevExpress DIREKT auf den
Komponenten-Elementen definiert (z. B. --dxbl-popup-bg:#fff auf .dxbl-modal).
Eine geerbte Variable aus html.dx-dark würde durch die direkte Zuweisung
überschrieben. Deshalb targeten wir exakt dieselben Elemente, aber mit einem
zusätzlichen Vorfahren-Selektor (html.dx-dark) für höhere Spezifizität:
html.dx-dark .dxbl-modal = (0,2,1) > .dxbl-modal = (0,1,0) ?
html.dx-dark wird per JS gesetzt, wenn IsDarkMode && !IsNativeDarkTheme.
?? */
/* Popup / Modal (CRUD-Dialoge) Variablen-Quelle: .dxbl-modal */
html.dx-dark .dxbl-modal {
--dxbl-popup-bg: #2d2d2d;
--dxbl-popup-color: #e8e8e8;
--dxbl-popup-border-color: #555;
--dxbl-popup-header-bg: #333;
--dxbl-popup-header-color: #e8e8e8;
--dxbl-popup-footer-bg: #333;
--dxbl-popup-footer-color: #e8e8e8;
}
/* Flyout (Column Chooser, Filter-Panel) Variablen-Quelle: .dxbl-flyout */
html.dx-dark .dxbl-flyout {
--dxbl-flyout-bg: #2d2d2d;
--dxbl-flyout-color: #e8e8e8;
--dxbl-flyout-border-color: #555;
--dxbl-flyout-header-bg: #333;
--dxbl-flyout-header-color: #e8e8e8;
--dxbl-flyout-footer-bg: #333;
}
/* Dropdown (ComboBox-Klappliste, Band-Dropdowns) Quelle: .dxbl-dropdown */
html.dx-dark .dxbl-dropdown,
html.dx-dark .dxbl-itemlist-dropdown {
--dxbl-dropdown-bg: #2d2d2d;
--dxbl-dropdown-color: #e8e8e8;
--dxbl-dropdown-border-color: #555;
--dxbl-dropdown-header-bg: #333;
--dxbl-dropdown-footer-bg: #333;
}
/* Edit-Dropdown (ComboBox-Popup wenn als Modal gerendert) Quelle: .dxbl-edit-dropdown */
html.dx-dark .dxbl-edit-dropdown {
--dxbl-edit-dropdown-bg: #2d2d2d;
--dxbl-edit-dropdown-color: #e8e8e8;
--dxbl-edit-dropdown-border-color: #555;
}
/* ListBox (Einträge in Dropdowns) Quelle: .dxbl-list-box */
html.dx-dark .dxbl-list-box,
html.dx-dark .dxbl-list-box-render-container {
--dxbl-list-box-bg: #2d2d2d;
--dxbl-list-box-color: #e8e8e8;
--dxbl-list-box-border-color: #555;
--dxbl-list-box-item-hover-bg: #3a3a3a;
--dxbl-list-box-item-hover-color: #e8e8e8;
}
/* TextEdit / ComboBox Eingabefeld Quelle: .dxbl-text-edit */
html.dx-dark .dxbl-text-edit {
--dxbl-text-edit-bg: #2d2d2d;
--dxbl-text-edit-color: #e8e8e8;
--dxbl-text-edit-border-color: #555;
--dxbl-text-edit-btn-bg: #3a3a3a;
--dxbl-text-edit-btn-color: #e8e8e8;
--dxbl-text-edit-btn-hover-bg: #444;
--dxbl-text-edit-btn-hover-color: #e8e8e8;
}
/* Buttons */
html.dx-dark .dxbl-btn {
--dxbl-btn-color: #e8e8e8;
--dxbl-btn-bg: #3a3a3a;
--dxbl-btn-border-color: #555;
--dxbl-btn-hover-bg: #444;
--dxbl-btn-hover-color: #e8e8e8;
--dxbl-btn-hover-border-color: #666;
}
/* FormLayout */
html.dx-dark .dxbl-fl {
--dxbl-fl-caption-color: #bbb;
--dxbl-fl-group-bg: #242424;
--dxbl-fl-group-color: #e8e8e8;
}
/* Grid */
html.dx-dark .dxbl-grid {
background-color: #242424;
color: #e8e8e8;
border-color: #444;
}
html.dx-dark .dxbl-grid > .dxbl-scroll-viewer,
html.dx-dark .dxbl-grid > .dxbl-grid-top-panel {
background-color: #242424;
color: #e8e8e8;
}
/* MassData-spezifisch */
.page-size-selector {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
margin-bottom: 12px;
}
.page-size-label {
white-space: nowrap;
}
.page-size-combo {
width: 13ch;
min-width: 13ch;
max-width: 13ch;
}
.page-size-combo input {
text-align: left;
}
.pager-container {
display: flex;
justify-content: center;
margin-top: 12px;
margin-bottom: 16px;
}
/* Lade-Spinner */
.loading-container {
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
.top-row .btn-gap {
margin-left: 8px;
}

Some files were not shown because too many files have changed in this diff Show More