Compare commits

...

67 Commits

Author SHA1 Message Date
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
OlgunR
8387b71676 Add operator selection to grid filter rows for dates/numbers
Introduce custom filter row cell templates for date and numeric columns in CatalogsGrid and MassDataGrid. Users can now select a filter operator (e.g., =, <, >, <=, >=) alongside the filter value for columns like AddedWhen, ChangedWhen, and Amount. Updated filter criteria logic to support these operators using DevExpress expressions. Added new CSS for filter row alignment and appearance. Removed obsolete methods and improved layout logic. This enhances filtering flexibility and user experience in both grids.
2026-02-09 16:10:16 +01:00
OlgunR
8824492057 Enable grouping features in DxGrid components
Added ShowGroupPanel, ShowGroupedColumns, and AllowGroup to DxGrid in CatalogsGrid.razor and MassDataGrid.razor for enhanced data grouping. Also set PagerVisible="false" in MassDataGrid.razor to hide pager controls.
2026-02-09 14:03:47 +01:00
OlgunR
59f22be405 Update nav menu with new custom SVG icons
Replaced icons for "Catalogs", "Dashboards", and "MassData" in NavMenu.razor with new custom Bootstrap-style SVG icons. Added corresponding CSS classes using data URIs to embed the new white icons, ensuring consistent appearance in the navigation menu.
2026-02-09 13:41:11 +01:00
OlgunR
23865aefb6 Remove Counter and Weather pages and nav links
Removed the Counter and Weather pages by deleting their component files. Also updated the navigation menu to remove links to these pages, leaving only Home and Catalogs. This simplifies the app's navigation and available features.
2026-02-09 13:22:04 +01:00
OlgunR
2a730ddfcc Refactor grid and band layout persistence logic
Unify band and grid layout saving into a single "Layout speichern" action and method. Store the full grid layout in BandLayout, and simplify column/band rendering logic. Apply saved layouts after render for consistency. Remove obsolete ordering logic and update messages for clarity. These changes improve robustness and maintainability of layout persistence in CatalogsGrid and MassDataGrid.
2026-02-09 11:36:56 +01:00
OlgunR
d78fd5e3d1 Add user-specific persistent grid/band layouts support
Implemented user-customizable, persistent grid and band layouts for CatalogsGrid and MassDataGrid. Added backend API, database entity, and repository for storing layouts per user. Refactored grids to support dynamic band/column rendering, layout management UI, and per-user storage via localStorage and the new API. Registered all necessary services and updated data context. Enables flexible, user-specific grid experiences with saved layouts.
2026-02-06 14:07:52 +01:00
OlgunR
52b2cf9a5b Add user-selectable page size to MassData grid
Users can now choose how many records to display per page in the MassData grid (100, 1,000, 10,000, 100,000, or all). The backend and API client are updated to support nullable skip/take parameters, allowing "all" records to be fetched when desired. The pager and page count calculations are updated to reflect the selected page size, and the pager is hidden if only one page exists. Additional UI and CSS changes provide a combo box for page size selection. The API controller now treats take <= 0 as "no limit."
2026-02-05 15:45:36 +01:00
OlgunR
9d7b3591cc Update grid column captions to German
Changed "Added" to "Angelegt am" and "Changed" to "Geändert am" in the data grid column headers for improved localization. No other column settings were modified.
2026-02-05 14:17:29 +01:00
OlgunR
006ee78422 Refactor MassDataGrid editing and validation logic
- Switch to custom edit model (MassDataEditModel) for popup editing, enabling granular field validation and UI control
- Replace default editors with explicit DxTextBox/DxCheckBox bindings
- Add AmountText field for string input and validation; validate and convert in OnEditModelSaving
- Implement duplicate customer check via new GetByCustomerNameAsync API method
- Show ValidationSummary in popup; manage validation state with ValidationMessageStore and EditContext
- Make popup header and width dynamic; show procedure ComboBox only for existing records
- Restore "New" button in grid command column
- Refactor CatalogsGrid to handle validation clearing in OnEditFieldChanged instead of OnTitleChanged
- General improvements to real-time validation feedback
2026-02-05 14:11:27 +01:00
OlgunR
a52d615750 Update grid popup headers and edit form logic
CatalogsGrid now sets popup edit form header dynamically ("Neu" for new, "Edit" for existing) and only shows the "Update-Prozedur" field when editing. Added IsNew property to CatalogEditModel. MassDataGrid sets popup header to "Bearbeiten". Also standardized DateChanged event handler style in both components.
2026-02-05 12:52:44 +01:00
OlgunR
9bbe34dece Enhance grid filter UI with custom editors for each type
Replaced default filter row editors with custom templates in CatalogsGrid and MassDataGrid. Added DxTextBox for text fields, DxDateEdit for date fields, and a DxComboBox for boolean status filtering. Introduced BoolFilterOption class to support the status dropdown. These changes improve filter usability and data type handling.
2026-02-05 11:37:06 +01:00
OlgunR
05ea47f42c Improve title field validation feedback in catalog edit form
Title field now validates on input, not just on blur. Added OnTitleChanged handler to clear validation messages for CatTitle as the user types. OnFieldChanged now only clears messages for UpdateProcedure. This enhances real-time validation feedback for users.
2026-02-05 11:21:49 +01:00
OlgunR
945c8aaf4a Refactor grids to use DxGrid popup editing
Modernize CatalogsGrid.razor and MassDataGrid.razor to use DevExpress DxGrid's built-in popup editing with EditFormTemplate. Remove custom EditForm panels and manual editing state logic. Move CRUD operations and validation to grid event handlers. Add field-level validation and error display for catalogs. Update grid columns, add command columns, and set audit fields to read-only. Only editing is allowed in MassDataGrid; deletion is disabled. Streamline code and UI for improved maintainability and user experience.
2026-02-05 10:43:40 +01:00
OlgunR
4ef80ce875 Improve MassDataGrid UI: custom sort icons & filter inputs
Replaced default sort icons with custom SVGs for clarity.
Updated filter row to use styled DxTextBox inputs with search icons.
Ensured consistent and intuitive filtering experience across columns.
2026-02-05 08:54:21 +01:00
OlgunR
88c34ef94b Add MassData feature with API, paging, and Blazor grid
Introduces MassData management to backend and Blazor frontend:
- Adds API endpoint for MassData count and paging
- Updates repository and controller for count support
- Implements MediatR query/handler for count
- Adds Blazor page and grid for viewing/editing MassData
- Registers MassDataApiClient and integrates with DI
- Supports paging, upsert, and UI feedback in grid
2026-02-04 13:00:45 +01:00
OlgunR
85b9b0b51a Add MassData API with CQRS, repository, and DbContext
Introduce MassData feature with new API endpoints for querying and upserting records by customer name. Add DTOs, AutoMapper profile, MediatR CQRS handlers, repository pattern, and MassDataDbContext. Register new services in DI and add MassDataConnection to configuration. Upsert uses stored procedure. Enables full CRUD for Massdata via dedicated API.
2026-02-04 11:39:58 +01:00
OlgunR
013088a25f Add real-time dashboard updates with SignalR
Integrate SignalR to provide real-time dashboard update notifications.
- Added DashboardsHub and DashboardChangeNotifier on the backend.
- Modified SqlDashboardStorage to trigger notifications on changes.
- Registered SignalR services and mapped the hub endpoint.
- Updated Blazor clients to connect to the hub and refresh dashboards on change.
- Added SignalR client packages and necessary DI/configuration.
2026-02-04 09:01:28 +01:00
OlgunR
dbe1d9d206 Add mode param to dashboard links for designer/viewer mode
Dashboard navigation links now include a mode query parameter, set to "designer" or "viewer" based on the IsDesigner flag. This enables the app to distinguish between designer and viewer modes when navigating to dashboards.
2026-02-04 08:45:27 +01:00
OlgunR
dc2cccac1f Update dashboard navigation and dynamic loading
- Changed NavMenu to link to /dashboards instead of /dashboards/default
- Refactored Dashboard.razor to list dashboards from API
- Dashboard viewer/designer now loads by selected dashboard ID
- Mode toggle preserves selected dashboard and mode
- Added DashboardApiClient and DashboardInfoDto for API integration
- Registered DashboardApiClient for DI and HTTP client setup in Program.cs
2026-02-03 17:23:04 +01:00
OlgunR
32b6d30ba1 Add toggle for Designer/Viewer modes on dashboard page
Added a button to switch between Designer and Viewer modes for the dashboard. The mode is controlled via a query parameter and updates the dashboard's WorkingMode accordingly. The dashboard component is re-rendered when the mode changes. Also updated the navigation link label to remove the "(Designer)" suffix.
2026-02-03 16:43:16 +01:00
OlgunR
940df826f7 Normalize dashboard date dimensions, update grid UI
Added normalization for date dimensions in SqlDashboardStorage to ensure consistent grouping for "AddedWhen" and "ChangedWhen" fields. Refactored CatalogsGrid.razor to use custom sort icons and default DxGrid filter row UI, simplifying markup and improving visual consistency. Updated related CSS for sortable headers and filter inputs.
2026-02-03 13:43:23 +01:00
OlgunR
7ca37dbfca Add Catalogs page and simplify dashboard navigation
- Added a new Catalogs.razor page with a CatalogsGrid component and navigation link.
- Simplified Dashboard.razor to only show the default dashboard in designer mode; removed catalog grid options.
- Updated dashboard parameter logic to always redirect to "dashboards/default" unless already selected.
2026-02-03 12:51:20 +01:00
OlgunR
9db55fd2fd Switch dashboard storage to SQL Server
Replaced file-based dashboard storage with SQL Server-backed storage using the new SqlDashboardStorage class and TBDD_SMF_CONFIG table. Updated Program.cs to use the new storage and ensure default dashboards are loaded into the database. Simplified DefaultDashboard.xml to remove old items. Added SQL script for the dashboard storage table. Cleaned up unused folder references in the project file. This centralizes dashboard management and supports multi-instance scenarios.
2026-02-03 11:56:26 +01:00
OlgunR
70e5cbc19f Refactor dashboard navigation, catalog grid, and API client
- Add sidebar dashboard navigation and support multiple dashboards
- Extract catalog grid/form logic to reusable CatalogsGrid component
- Add CatalogApiClient and DTOs for catalog CRUD operations
- Define dashboards with JSON data sources (Default, CatalogsGrid)
- Update configuration for dashboard and API endpoints
- Improve styling and imports for modularity and maintainability
2026-02-02 17:03:23 +01:00
OlgunR
1667788558 Enhance Catalogs grid UI and filter row experience
Added custom CSS for sortable headers and search inputs. Replaced default filter row inputs with styled DxTextBox components. Set default sort on "Id" column and disabled sorting for action column. Updated grid to use new catalog-grid styles.
2026-02-02 13:38:44 +01:00
OlgunR
b09ee6dc8d Remove DevExpress Fluent and external theme CSS files
Removed several DevExpress Blazor Fluent theme stylesheets and the bootstrap-external.bs5.min.css from index.html. Retained only default Bootstrap, app-specific, and DevExpress Dashboard styles to simplify and standardize application theming.
2026-02-02 12:54:08 +01:00
OlgunR
0213834858 Update to DevExpress Fluent theme and enable SSR
Switched from old DevExpress Blazor theme and icons to Fluent theme stylesheets in index.html and App.razor. Added @rendermode InteractiveServer to Routes.razor to enable interactive server-side rendering.
2026-02-02 10:04:50 +01:00
OlgunR
38baf9f749 Integrate DevExpress Web Dashboard into API and Blazor
Added DevExpress Dashboard to ASP.NET Core API and both Blazor WASM/Server frontends. Configured dashboard storage, sample data source, and API endpoint. Updated Blazor projects with dashboard packages, styles, and a new dashboard page. Navigation and configuration updated to support dashboard integration.
2026-02-02 09:01:08 +01:00
OlgunR
0532cbb329 Add DevExpress.Blazor package reference
Added DevExpress.Blazor v25.1.3 to the project file to enable usage of DevExpress Blazor UI components in the application.
2026-01-30 11:16:06 +01:00
OlgunR
f9a6341b41 Add BlazorWebApp project and Bootstrap source map
Added new Blazor Server project `DbFirst.BlazorWebApp` with core UI components, pages, and static assets. Updated solution file to include the project. Also added `bootstrap.min.css.map` to support CSS debugging in browser developer tools.
2026-01-30 11:12:55 +01:00
OlgunR
98b841196e Add detailed comments and app flow documentation for BlazorWasm
Added comprehensive inline comments (mainly in German) to key files (index.html, Program.cs, App.razor, MainLayout.razor, NavMenu.razor, Catalogs.razor, CatalogApiClient.cs) to clarify their roles and the overall application flow. Updated Home.razor with a clearer heading and intro. Introduced Ablauf.cs, which documents the loading order and responsibilities of each major component. These changes enhance codebase clarity and maintainability, especially for German-speaking developers.
2026-01-28 15:03:52 +01:00
OlgunR
05964eb02e Improve Catalogs page layout with spacing and styling
Added CSS classes for better spacing between form and grid.
Wrapped form in .action-panel and grid in .grid-section divs
to enhance visual separation. No functional changes made.
2026-01-21 10:26:12 +01:00
OlgunR
e55f215210 Integrate DevExpress Blazor UI components throughout app
Replaced Bootstrap UI with DevExpress components in Catalogs.razor, including forms and data grid. Added DevExpress.Blazor package, styles, and service registration. Updated _Imports.razor for global DevExpress usage. Modernizes UI and improves user experience.
2026-01-21 10:21:51 +01:00
OlgunR
6b89f7bd72 Improve CORS config: block all if no origins specified
Refined CORS policy in Program.cs for better security. In development, all origins are allowed. In production, only configured origins are allowed; if none are specified, all cross-origin requests are blocked by default. Switched to Array.Empty<string>() for clarity.
2026-01-19 17:08:55 +01:00
OlgunR
7a78a48d03 Remove Repositories folder reference from project file
Removed the <ItemGroup> entry for the "Repositories" folder in DbFirst.Domain.csproj. This change only affects the project file and does not delete the folder or its contents from the file system.
2026-01-19 17:06:11 +01:00
OlgunR
0b3249cb46 Remove CatalogService and ICatalogService implementations
Eliminated the catalog service layer by deleting both CatalogService.cs and ICatalogService.cs. This removes all catalog-related CRUD operations, mapping logic, repository interactions, and domain-specific checks. Also removed related comments and TODOs regarding generic services and CQRS.
2026-01-19 17:05:36 +01:00
OlgunR
17fdb6ed51 Move repository interfaces to Application layer
Refactored IRepository<T> and ICatalogRepository to reside in the DbFirst.Application layer instead of Domain. Updated namespaces, using statements, and all references in services and handlers. Adjusted csproj dependencies to reflect the new structure. Updated comments to clarify Clean Architecture rationale and improved separation of concerns.
2026-01-19 16:42:48 +01:00
OlgunR
166acea8b1 Introduce generic IRepository<T> and refactor repositories
Added a generic IRepository<T> interface for common CRUD operations and updated ICatalogRepository to inherit from it, removing redundant methods. Updated CatalogRepository to implement the new interface. Cleaned up DbFirst.Domain.csproj by removing an unused folder reference. These changes improve code reuse and align with clean architecture practices.
2026-01-19 16:36:08 +01:00
OlgunR
6c2b1884d2 Configurable EF view/column mapping via appsettings
Refactor ApplicationDbContext to use a configuration-driven approach for mapping view and column names, enabling dynamic mapping through appsettings.json. Add TableConfigurations classes, update DI registration, and include the necessary options package for configuration binding. This improves maintainability and flexibility for schema changes.
2026-01-19 16:25:08 +01:00
OlgunR
3653def773 Clean up and organize using statements across files
Removed unused and redundant using/import statements from multiple files, including command, repository, and Program.cs. No functional changes; this commit improves code clarity and organization.
2026-01-19 14:56:55 +01:00
OlgunR
8d3783cfec Update CORS config; add architecture discussion comments
- Make CORS policy environment-aware: allow any origin in development, restrict to configured origins in production.
- Add detailed comments in CatalogService.cs and ICatalogRepository.cs discussing generic CRUD services, CQRS with MediatR, and repository interface placement, including both Copilot's and Hakan's perspectives.
- No functional changes to service or repository logic.
2026-01-19 14:48:55 +01:00
OlgunR
0af0c4589d Restrict CatTitle editing based on UpdateProcedure
Enforce business rules for catalog title changes: only allow CatTitle to be edited when UpdateProcedure permits, with checks in the API, service, handler, and UI. This ensures consistent validation and user experience across backend and frontend.
2026-01-19 14:44:55 +01:00
OlgunR
26f783e835 Prevent catalog title changes during edit
Enforce immutability of CatTitle on updates: backend now rejects title changes with a BadRequest, and frontend disables the title input field when editing.
2026-01-19 11:21:33 +01:00
d608ab1a6d Refactor CORS config; add architectural commentary
Refactored CORS setup to be environment-aware, restricting origins in production and relaxing in development. Added extensive comments and discussion on service and repository layer design, including clean architecture best practices and CQRS/MediatR considerations. No changes to business logic; documentation and intent clarified for maintainers.
2026-01-19 11:17:36 +01:00
OlgunR
4fbcd0dc11 Support selecting update procedure for catalog updates
Added CatalogUpdateProcedure enum to domain. CatalogWriteDto now includes UpdateProcedure property in both application and BlazorWasm layers. Catalogs.razor form allows users to choose between PRTBMY_CATALOG_UPDATE and PRTBMY_CATALOG_SAVE when editing. Repository, service, and handler layers updated to pass and use the selected procedure. Default remains Update. Updated comments and TODOs for clarity and future refactoring.
2026-01-19 11:10:19 +01:00
OlgunR
45e5327148 Add ICatalogRepository interface and design comments
Added ICatalogRepository with async catalog retrieval methods. Included detailed comments on repository design patterns and clean architecture layer placement, recommending interface stays in Domain. Fixed file encoding (added BOM).
2026-01-19 09:12:16 +01:00
OlgunR
9387db9824 Add comments on generic repo pattern limitations
Added Copilot comments explaining why a generic repository is unsuitable due to stored procedure complexities and asymmetric CRUD operations. Suggested keeping specialized repositories and extracting helpers for reuse. Also added a TODO to move the interface to the application layer for clean architecture.
2026-01-19 09:07:41 +01:00
OlgunR
ef76599bce Document rationale against generic CRUD/service pattern
Added detailed comments in CatalogService and ICatalogService explaining why a generic CRUD base service or repository is not suitable for this solution, due to entity-specific domain logic and stored procedure usage. Removed the previous Copilot comment from CatalogRepository. No functional changes; updates are for architectural clarity.
2026-01-19 09:02:29 +01:00
OlgunR
870b10779e Refactor Catalogs to use MediatR and CQRS pattern
Replaced direct service usage in CatalogsController with MediatR-based commands and queries for all CRUD operations. Added command/query and handler classes for Catalog operations. Updated dependency injection to register MediatR and removed ICatalogService. Improved code maintainability and testability by adopting CQRS architecture.
2026-01-19 09:00:06 +01:00
OlgunR
c8c75b1dc5 Expand usings; add note on generic vs per-entity repos
Expanded using directives to support additional dependencies in CatalogRepository.cs. Added a detailed comment explaining why per-entity repository implementations are preferred over a generic CRUD base service in this context, due to unique domain logic and stored procedure requirements. No functional code changes were made.
2026-01-19 08:53:25 +01:00
OlgunR
8c31784a5a Refactor DI setup with extension methods for modularity
Refactored dependency injection by introducing AddApplication and AddInfrastructure extension methods for service registration. Moved DbContext and AutoMapper setup out of Program.cs to improve modularity and reusability. Added required NuGet packages to .csproj files.
2026-01-19 08:46:52 +01:00
OlgunR
28bab05980 Add configurable CORS support via appsettings.json
Introduce CORS configuration using allowed origins from appsettings.json. Updated Program.cs to read allowed origins from configuration and apply them to the CORS policy, defaulting to AllowAnyOrigin if none are specified. Also made minor formatting and comment improvements.
2026-01-19 08:34:40 +01:00
OlgunR
289dba9b16 Add global exception handling middleware to API
Introduced ExceptionHandlingMiddleware to catch and log unhandled exceptions, returning standardized JSON error responses. Registered the middleware in the request pipeline. Also made minor formatting and comment improvements in Program.cs and ICatalogRepository.cs.
2026-01-19 08:31:10 +01:00
OlgunR
353611d400 Merge branch 'main' of http://git.dd:3000/AppStd/DbFirst 2026-01-16 14:10:59 +01:00
OlgunR
8c175de953 Refactor API client for richer error handling
Refactored CatalogApiClient methods to return ApiResult<T> for create, update, and delete operations, enabling more detailed error reporting. Introduced ApiResult<T> and ProblemDetailsDto types, and added logic to parse and display informative error messages. Updated Catalogs.razor to use the new pattern and show user-friendly error feedback. Added necessary using directives.
2026-01-16 14:10:56 +01:00
OlgunR
1fd776bc29 Prevent CatTitle changes in catalog update endpoint
The Update method now checks if CatTitle is being changed and returns a 400 Bad Request if so. It also returns 404 Not Found if the catalog does not exist before attempting an update. This ensures CatTitle remains immutable during updates.
2026-01-16 13:55:43 +01:00
OlgunR
904e6e20f0 Enforce unique catalog titles on creation
Added a uniqueness check for catalog titles in the creation flow. The service now prevents creating catalogs with duplicate titles by checking for existing entries before insertion. If a duplicate is detected, the API returns a 409 Conflict response. Updated interfaces and repository to support title-based lookups.
2026-01-16 13:42:46 +01:00
OlgunR
215e526230 Update catalog update to use OUTPUT GUID from stored proc
Refactored CatalogRepository to set @GUID as an OUTPUT parameter when calling PRTBMY_CATALOG_UPDATE. Now, after execution, the code checks the returned GUID value and uses it to fetch the updated catalog entry, handling cases where the GUID is null or zero. This ensures the repository returns the correct catalog record as modified by the stored procedure.
2026-01-16 13:18:56 +01:00
107 changed files with 4048 additions and 680 deletions

View File

@@ -1,4 +1,8 @@
using DbFirst.Application.Catalogs; using DbFirst.Application.Catalogs;
using DbFirst.Application.Catalogs.Commands;
using DbFirst.Application.Catalogs.Queries;
using DbFirst.Domain;
using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DbFirst.API.Controllers; namespace DbFirst.API.Controllers;
@@ -7,24 +11,24 @@ namespace DbFirst.API.Controllers;
[Route("api/[controller]")] [Route("api/[controller]")]
public class CatalogsController : ControllerBase public class CatalogsController : ControllerBase
{ {
private readonly ICatalogService _service; private readonly IMediator _mediator;
public CatalogsController(ICatalogService service) public CatalogsController(IMediator mediator)
{ {
_service = service; _mediator = mediator;
} }
[HttpGet] [HttpGet]
public async Task<ActionResult<IEnumerable<CatalogReadDto>>> GetAll(CancellationToken cancellationToken) public async Task<ActionResult<IEnumerable<CatalogReadDto>>> GetAll(CancellationToken cancellationToken)
{ {
var result = await _service.GetAllAsync(cancellationToken); var result = await _mediator.Send(new GetAllCatalogsQuery(), cancellationToken);
return Ok(result); return Ok(result);
} }
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
public async Task<ActionResult<CatalogReadDto>> GetById(int id, CancellationToken cancellationToken) public async Task<ActionResult<CatalogReadDto>> GetById(int id, CancellationToken cancellationToken)
{ {
var result = await _service.GetByIdAsync(id, cancellationToken); var result = await _mediator.Send(new GetCatalogByIdQuery(id), cancellationToken);
if (result == null) if (result == null)
{ {
return NotFound(); return NotFound();
@@ -35,14 +39,29 @@ public class CatalogsController : ControllerBase
[HttpPost] [HttpPost]
public async Task<ActionResult<CatalogReadDto>> Create(CatalogWriteDto dto, CancellationToken cancellationToken) public async Task<ActionResult<CatalogReadDto>> Create(CatalogWriteDto dto, CancellationToken cancellationToken)
{ {
var created = await _service.CreateAsync(dto, cancellationToken); var created = await _mediator.Send(new CreateCatalogCommand(dto), cancellationToken);
if (created == null)
{
return Conflict();
}
return CreatedAtAction(nameof(GetById), new { id = created.Guid }, created); return CreatedAtAction(nameof(GetById), new { id = created.Guid }, created);
} }
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
public async Task<ActionResult<CatalogReadDto>> Update(int id, CatalogWriteDto dto, CancellationToken cancellationToken) public async Task<ActionResult<CatalogReadDto>> Update(int id, CatalogWriteDto dto, CancellationToken cancellationToken)
{ {
var updated = await _service.UpdateAsync(id, dto, 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) if (updated == null)
{ {
return NotFound(); return NotFound();
@@ -53,7 +72,7 @@ public class CatalogsController : ControllerBase
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken) public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{ {
var deleted = await _service.DeleteAsync(id, cancellationToken); var deleted = await _mediator.Send(new DeleteCatalogCommand(id), cancellationToken);
if (!deleted) if (!deleted)
{ {
return NotFound(); return NotFound();

View File

@@ -0,0 +1,14 @@
using DevExpress.DashboardAspNetCore;
using DevExpress.DashboardWeb;
using Microsoft.AspNetCore.DataProtection;
namespace BlazorDashboardApp.Server
{
public class DefaultDashboardController : DashboardController
{
public DefaultDashboardController(DashboardConfigurator configurator, IDataProtectionProvider? dataProtectionProvider = null)
: base(configurator, dataProtectionProvider)
{
}
}
}

View File

@@ -0,0 +1,94 @@
using System.Text;
using DbFirst.Application.Repositories;
using DbFirst.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
namespace DbFirst.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class LayoutsController : ControllerBase
{
private readonly ILayoutRepository _repository;
public LayoutsController(ILayoutRepository repository)
{
_repository = repository;
}
[HttpGet]
public async Task<ActionResult<LayoutDto>> Get([FromQuery] string layoutType, [FromQuery] string layoutKey, [FromQuery] string userName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(layoutType) || string.IsNullOrWhiteSpace(layoutKey) || string.IsNullOrWhiteSpace(userName))
{
return BadRequest("layoutType, layoutKey und userName sind erforderlich.");
}
var entity = await _repository.GetAsync(layoutType, layoutKey, userName, cancellationToken);
if (entity == null)
{
return NotFound();
}
return Ok(Map(entity));
}
[HttpPost]
public async Task<ActionResult<LayoutDto>> Upsert(LayoutDto dto, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(dto.LayoutType) || string.IsNullOrWhiteSpace(dto.LayoutKey) || string.IsNullOrWhiteSpace(dto.UserName))
{
return BadRequest("LayoutType, LayoutKey und UserName sind erforderlich.");
}
var data = string.IsNullOrWhiteSpace(dto.LayoutData)
? Array.Empty<byte>()
: Encoding.UTF8.GetBytes(dto.LayoutData);
try
{
var entity = await _repository.UpsertAsync(dto.LayoutType, dto.LayoutKey, dto.UserName, data, cancellationToken);
return Ok(Map(entity));
}
catch (Exception ex)
{
var detail = ex.InnerException?.Message ?? ex.Message;
return Problem(detail: detail, statusCode: StatusCodes.Status500InternalServerError);
}
}
[HttpDelete]
public async Task<IActionResult> Delete([FromQuery] string layoutType, [FromQuery] string layoutKey, [FromQuery] string userName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(layoutType) || string.IsNullOrWhiteSpace(layoutKey) || string.IsNullOrWhiteSpace(userName))
{
return BadRequest("layoutType, layoutKey und userName sind erforderlich.");
}
var deleted = await _repository.DeleteAsync(layoutType, layoutKey, userName, cancellationToken);
return deleted ? NoContent() : NotFound();
}
private static LayoutDto Map(SmfLayout entity)
{
var layoutData = entity.LayoutData.Length == 0
? string.Empty
: Encoding.UTF8.GetString(entity.LayoutData);
return new LayoutDto
{
LayoutType = entity.LayoutType,
LayoutKey = entity.LayoutKey,
UserName = entity.UserName,
LayoutData = layoutData
};
}
public sealed class LayoutDto
{
public string LayoutType { get; set; } = string.Empty;
public string LayoutKey { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string LayoutData { get; set; } = string.Empty;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,138 @@
using System.Data;
using System.Text;
using System.Xml.Linq;
using DevExpress.DashboardWeb;
using Microsoft.Data.SqlClient;
namespace DbFirst.API.Dashboards;
public sealed class SqlDashboardStorage : IEditableDashboardStorage
{
private readonly string _connectionString;
private readonly string _tableName;
private readonly Func<string?>? _userProvider;
private readonly IDashboardChangeNotifier? _notifier;
public SqlDashboardStorage(string connectionString, string tableName = "TBDD_SMF_CONFIG", Func<string?>? userProvider = null, IDashboardChangeNotifier? notifier = null)
{
_connectionString = connectionString;
_tableName = tableName;
_userProvider = userProvider;
_notifier = notifier;
}
public IEnumerable<DashboardInfo> GetAvailableDashboardsInfo()
{
var dashboards = new List<DashboardInfo>();
using var connection = new SqlConnection(_connectionString);
using var command = new SqlCommand($"SELECT DashboardId, DashboardName FROM dbo.[{_tableName}] WHERE ACTIVE = 1 ORDER BY DashboardName", connection);
connection.Open();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var id = reader.GetString(0);
var name = reader.GetString(1);
dashboards.Add(new DashboardInfo { ID = id, Name = name });
}
return dashboards;
}
public XDocument LoadDashboard(string dashboardId)
{
using var connection = new SqlConnection(_connectionString);
using var command = new SqlCommand($"SELECT DashboardData FROM dbo.[{_tableName}] WHERE DashboardId = @Id AND ACTIVE = 1", connection);
command.Parameters.Add(new SqlParameter("@Id", SqlDbType.NVarChar, 128) { Value = dashboardId });
connection.Open();
var data = command.ExecuteScalar() as byte[];
if (data == null)
{
throw new ArgumentException($"Dashboard '{dashboardId}' not found.");
}
var xml = Encoding.UTF8.GetString(data);
var doc = XDocument.Parse(xml);
NormalizeCatalogDateDimensions(doc);
return doc;
}
private static void NormalizeCatalogDateDimensions(XDocument doc)
{
var dateMembers = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"AddedWhen",
"ChangedWhen"
};
foreach (var dimension in doc.Descendants("Dimension"))
{
var member = dimension.Attribute("DataMember")?.Value;
if (member == null || !dateMembers.Contains(member))
{
continue;
}
var interval = dimension.Attribute("DateTimeGroupInterval")?.Value;
if (string.IsNullOrWhiteSpace(interval) || string.Equals(interval, "Year", StringComparison.OrdinalIgnoreCase))
{
dimension.SetAttributeValue("DateTimeGroupInterval", "DayMonthYear");
}
}
}
public string AddDashboard(XDocument dashboard, string dashboardName)
{
var id = string.IsNullOrWhiteSpace(dashboardName)
? Guid.NewGuid().ToString("N")
: dashboardName;
var payload = Encoding.UTF8.GetBytes(dashboard.ToString(SaveOptions.DisableFormatting));
var userName = _userProvider?.Invoke();
using var connection = new SqlConnection(_connectionString);
using var command = new SqlCommand($"INSERT INTO dbo.[{_tableName}] (ACTIVE, DashboardId, DashboardName, DashboardData, ADDED_WHO, ADDED_WHEN) VALUES (1, @Id, @Name, @Data, COALESCE(@User, SUSER_SNAME()), SYSUTCDATETIME())", connection);
command.Parameters.Add(new SqlParameter("@Id", SqlDbType.NVarChar, 128) { Value = id });
command.Parameters.Add(new SqlParameter("@Name", SqlDbType.NVarChar, 256) { Value = string.IsNullOrWhiteSpace(dashboardName) ? id : dashboardName });
command.Parameters.Add(new SqlParameter("@Data", SqlDbType.VarBinary, -1) { Value = payload });
command.Parameters.Add(new SqlParameter("@User", SqlDbType.NVarChar, 50) { Value = (object?)userName ?? DBNull.Value });
connection.Open();
command.ExecuteNonQuery();
_notifier?.NotifyChanged();
return id;
}
public void SaveDashboard(string dashboardId, XDocument dashboard)
{
var payload = Encoding.UTF8.GetBytes(dashboard.ToString(SaveOptions.DisableFormatting));
var userName = _userProvider?.Invoke();
using var connection = new SqlConnection(_connectionString);
using var command = new SqlCommand($"UPDATE dbo.[{_tableName}] SET DashboardData = @Data, CHANGED_WHO = COALESCE(@User, SUSER_SNAME()), CHANGED_WHEN = SYSUTCDATETIME() WHERE DashboardId = @Id", connection);
command.Parameters.Add(new SqlParameter("@Id", SqlDbType.NVarChar, 128) { Value = dashboardId });
command.Parameters.Add(new SqlParameter("@Data", SqlDbType.VarBinary, -1) { Value = payload });
command.Parameters.Add(new SqlParameter("@User", SqlDbType.NVarChar, 50) { Value = (object?)userName ?? DBNull.Value });
connection.Open();
var rows = command.ExecuteNonQuery();
if (rows == 0)
{
throw new ArgumentException($"Dashboard '{dashboardId}' not found.");
}
_notifier?.NotifyChanged();
}
public void DeleteDashboard(string dashboardId)
{
using var connection = new SqlConnection(_connectionString);
using var command = new SqlCommand($"DELETE FROM dbo.[{_tableName}] WHERE DashboardId = @Id", connection);
command.Parameters.Add(new SqlParameter("@Id", SqlDbType.NVarChar, 128) { Value = dashboardId });
connection.Open();
command.ExecuteNonQuery();
_notifier?.NotifyChanged();
}
}

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<Dashboard CurrencyCulture="de-DE" RequestParameters="false">
<Title Text="Catalogs (Dashboard Grid)" />
<DataSources>
<JsonDataSource Name="Catalogs (API)" ComponentName="catalogsDataSource">
<Source SourceType="DevExpress.DataAccess.Json.UriJsonSource" Uri="https://localhost:7204/api/catalogs" />
</JsonDataSource>
</DataSources>
<Items>
<Grid ComponentName="gridDashboardItem1" Name="Catalogs" DataSource="catalogsDataSource">
<DataItems>
<Dimension DataMember="Guid" DefaultId="DataItem0" />
<Dimension DataMember="CatTitle" DefaultId="DataItem1" />
<Dimension DataMember="CatString" DefaultId="DataItem2" />
<Dimension DataMember="AddedWho" DefaultId="DataItem3" />
<Dimension DataMember="AddedWhen" DefaultId="DataItem4" />
<Dimension DataMember="ChangedWho" DefaultId="DataItem5" />
<Dimension DataMember="ChangedWhen" DefaultId="DataItem6" />
</DataItems>
<GridColumns>
<GridDimensionColumn Name="Id">
<Dimension DefaultId="DataItem0" />
</GridDimensionColumn>
<GridDimensionColumn Name="Titel">
<Dimension DefaultId="DataItem1" />
</GridDimensionColumn>
<GridDimensionColumn Name="String">
<Dimension DefaultId="DataItem2" />
</GridDimensionColumn>
<GridDimensionColumn Name="Angelegt von">
<Dimension DefaultId="DataItem3" />
</GridDimensionColumn>
<GridDimensionColumn Name="Angelegt am">
<Dimension DefaultId="DataItem4" />
</GridDimensionColumn>
<GridDimensionColumn Name="Geändert von">
<Dimension DefaultId="DataItem5" />
</GridDimensionColumn>
<GridDimensionColumn Name="Geändert am">
<Dimension DefaultId="DataItem6" />
</GridDimensionColumn>
</GridColumns>
<GridOptions />
<ColumnFilterOptions />
</Grid>
</Items>
<LayoutTree>
<LayoutGroup Orientation="Vertical">
<LayoutItem DashboardItem="gridDashboardItem1" />
</LayoutGroup>
</LayoutTree>
</Dashboard>

View File

@@ -0,0 +1,17 @@
CREATE TABLE dbo.TBDD_SMF_CONFIG (
[GUID] [bigint] IDENTITY(1,1) NOT NULL,
[ACTIVE] [bit] NOT NULL,
DashboardId NVARCHAR(128) NOT NULL,
DashboardName NVARCHAR(256) NOT NULL,
DashboardData VARBINARY(MAX) NOT NULL,
--- INSERT YOUR COLUMNS HERE ---
[ADDED_WHO] [nvarchar](50) NOT NULL,
[ADDED_WHEN] [datetime] NOT NULL,
[CHANGED_WHO] [nvarchar](50) NULL,
[CHANGED_WHEN] [datetime] NULL,
CONSTRAINT [PK_TBDD_SMF_CONFIG_DashboardStorage] PRIMARY KEY CLUSTERED
(
[GUID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = ON, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, FILLFACTOR = 80) ON [PRIMARY]
) ON [PRIMARY]
GO

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<Dashboard CurrencyCulture="de-DE" RequestParameters="false">
<Title Text="Default Dashboard" />
<DataSources>
<JsonDataSource Name="JSON Data Source (URL)" RootElement="Customers" ComponentName="jsonDataSource1">
<Source SourceType="DevExpress.DataAccess.Json.UriJsonSource" Uri="https://raw.githubusercontent.com/DevExpress-Examples/DataSources/master/JSON/customers.json" />
</JsonDataSource>
<JsonDataSource Name="Catalogs (API)" ComponentName="catalogsDataSource">
<Source SourceType="DevExpress.DataAccess.Json.UriJsonSource" Uri="https://localhost:7204/api/catalogs" />
</JsonDataSource>
</DataSources>
<Items>
<Grid ComponentName="gridDashboardItem2" Name="Grid 2" DataSource="catalogsDataSource">
<DataItems>
<Measure DataMember="guid" DefaultId="DataItem0" />
<Dimension DataMember="catTitle" DefaultId="DataItem1" />
<Dimension DataMember="catString" DefaultId="DataItem2" />
<Dimension DataMember="addedWhen" DefaultId="DataItem3" />
<Dimension DataMember="addedWho" DefaultId="DataItem4" />
<Dimension DataMember="changedWhen" DefaultId="DataItem5" />
<Dimension DataMember="changedWho" DefaultId="DataItem6" />
</DataItems>
<GridColumns>
<GridMeasureColumn>
<Measure DefaultId="DataItem0" />
</GridMeasureColumn>
<GridDimensionColumn>
<Dimension DefaultId="DataItem1" />
</GridDimensionColumn>
<GridDimensionColumn>
<Dimension DefaultId="DataItem2" />
</GridDimensionColumn>
<GridDimensionColumn>
<Dimension DefaultId="DataItem3" />
</GridDimensionColumn>
<GridDimensionColumn>
<Dimension DefaultId="DataItem4" />
</GridDimensionColumn>
<GridDimensionColumn>
<Dimension DefaultId="DataItem5" />
</GridDimensionColumn>
<GridDimensionColumn>
<Dimension DefaultId="DataItem6" />
</GridDimensionColumn>
</GridColumns>
<GridOptions />
<ColumnFilterOptions />
</Grid>
</Items>
<LayoutTree>
<LayoutGroup>
<LayoutItem DashboardItem="gridDashboardItem2" />
</LayoutGroup>
</LayoutTree>
</Dashboard>

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
using System.Net;
using System.Text.Json;
namespace DbFirst.API.Middleware;
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
await WriteProblemDetailsAsync(context, ex);
}
}
private static async Task WriteProblemDetailsAsync(HttpContext context, Exception ex)
{
if (context.Response.HasStarted)
{
throw ex;
}
context.Response.Clear();
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/json";
var problem = new
{
type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
title = "Serverfehler",
status = context.Response.StatusCode,
detail = ex.Message,
traceId = context.TraceIdentifier
};
await context.Response.WriteAsync(JsonSerializer.Serialize(problem));
}
}

View File

@@ -1,10 +1,18 @@
using DbFirst.Application.Catalogs; using DbFirst.API.Middleware;
using DbFirst.Domain.Repositories; using DbFirst.API.Dashboards;
using DbFirst.API.Hubs;
using DbFirst.Application;
using DbFirst.Application.Repositories;
using DbFirst.Domain;
using DbFirst.Domain.Entities;
using DbFirst.Infrastructure; using DbFirst.Infrastructure;
using DbFirst.Infrastructure.Repositories; using DbFirst.Infrastructure.Repositories;
using Microsoft.EntityFrameworkCore; using DevExpress.AspNetCore;
using DevExpress.DashboardAspNetCore;
//TODO: create and add exception handling middleware using DevExpress.DashboardCommon;
using DevExpress.DashboardWeb;
using DevExpress.DataAccess.Json;
using System.Xml.Linq;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -14,25 +22,127 @@ builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
// TODO: allow listed origins configured in appsettings.json // 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.
// In development you can keep it easy.
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddDefaultPolicy(policy => options.AddDefaultPolicy(policy =>
{ {
policy.AllowAnyOrigin() if (builder.Environment.IsDevelopment())
.AllowAnyHeader() {
.AllowAnyMethod(); policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
}
else
{
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
if (origins.Length > 0)
{
policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod();
}
// if no origins configured, deny all by leaving policy without allowances
}
}); });
}); });
// TODO: Create extension method for this in Infrastructure layer in case of using in multiple projects builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddDbContext<ApplicationDbContext>(options => builder.Services.AddApplication();
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// TODO: Create extension method for this in Application layer in case of using in multiple projects
builder.Services.AddAutoMapper(typeof(CatalogProfile).Assembly, typeof(ApplicationDbContext).Assembly);
builder.Services.AddScoped<ICatalogRepository, CatalogRepository>(); builder.Services.AddScoped<ICatalogRepository, CatalogRepository>();
builder.Services.AddScoped<ICatalogService, CatalogService>(); 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;
});
var app = builder.Build(); var app = builder.Build();
@@ -43,10 +153,27 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseDevExpressControls();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseCors(); app.UseCors();
app.UseAuthorization(); app.UseAuthorization();
app.MapDashboardRoute("api/dashboard", "DefaultDashboard");
app.MapHub<DashboardsHub>("/hubs/dashboards");
app.MapControllers(); app.MapControllers();
app.Run(); 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);
}

View File

@@ -1,6 +1,28 @@
{ {
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;" "DefaultConnection": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;",
"MassDataConnection": "Server=SDD-VMP04-SQL19\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;TrustServerCertificate=True;"
},
"Dashboard": {
"BaseUrl": "https://localhost:7204"
},
"Cors": {
"AllowedOrigins": [
"https://localhost:7276",
"http://localhost:5101"
]
},
"TableConfigurations": {
"VwmyCatalog": {
"ViewName": "VWMY_CATALOG",
"GuidColumnName": "GUID",
"CatTitleColumnName": "CAT_TITLE",
"CatStringColumnName": "CAT_STRING",
"AddedWhoColumnName": "ADDED_WHO",
"AddedWhenColumnName": "ADDED_WHEN",
"ChangedWhoColumnName": "CHANGED_WHO",
"ChangedWhenColumnName": "CHANGED_WHEN"
}
}, },
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {

View File

@@ -1,67 +0,0 @@
using AutoMapper;
using DbFirst.Domain.Repositories;
using DbFirst.Domain.Entities;
namespace DbFirst.Application.Catalogs;
//TODO: create generic service to reduce code duplication
//TODO: implement CQRS pattern with MediatR
public class CatalogService : ICatalogService
{
private readonly ICatalogRepository _repository;
private readonly IMapper _mapper;
public CatalogService(ICatalogRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<List<CatalogReadDto>> GetAllAsync(CancellationToken cancellationToken = default)
{
var items = await _repository.GetAllAsync(cancellationToken);
return _mapper.Map<List<CatalogReadDto>>(items);
}
public async Task<CatalogReadDto?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
var item = await _repository.GetByIdAsync(id, cancellationToken);
return item == null ? null : _mapper.Map<CatalogReadDto>(item);
}
public async Task<CatalogReadDto> CreateAsync(CatalogWriteDto dto, CancellationToken cancellationToken = default)
{
var entity = _mapper.Map<VwmyCatalog>(dto);
entity.AddedWho = "system";
entity.AddedWhen = DateTime.UtcNow;
entity.ChangedWho = "system";
entity.ChangedWhen = DateTime.UtcNow;
var created = await _repository.InsertAsync(entity, cancellationToken);
return _mapper.Map<CatalogReadDto>(created);
}
public async Task<CatalogReadDto?> UpdateAsync(int id, CatalogWriteDto dto, CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(id, cancellationToken);
if (existing == null)
{
return null;
}
var entity = _mapper.Map<VwmyCatalog>(dto);
entity.Guid = id;
entity.AddedWho = existing.AddedWho;
entity.AddedWhen = existing.AddedWhen;
entity.ChangedWho = "system";
entity.ChangedWhen = DateTime.UtcNow;
var updated = await _repository.UpdateAsync(id, entity, cancellationToken);
return updated == null ? null : _mapper.Map<CatalogReadDto>(updated);
}
public async Task<bool> DeleteAsync(int id, CancellationToken cancellationToken = default)
{
return await _repository.DeleteAsync(id, cancellationToken);
}
}

View File

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

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public record CreateCatalogCommand(CatalogWriteDto Dto) : IRequest<CatalogReadDto?>;

View File

@@ -0,0 +1,36 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using DbFirst.Domain.Entities;
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public class CreateCatalogHandler : IRequestHandler<CreateCatalogCommand, CatalogReadDto?>
{
private readonly ICatalogRepository _repository;
private readonly IMapper _mapper;
public CreateCatalogHandler(ICatalogRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<CatalogReadDto?> Handle(CreateCatalogCommand request, CancellationToken cancellationToken)
{
var existing = await _repository.GetByTitleAsync(request.Dto.CatTitle, cancellationToken);
if (existing != null)
{
return null;
}
var entity = _mapper.Map<VwmyCatalog>(request.Dto);
entity.AddedWho = "system";
entity.AddedWhen = DateTime.UtcNow;
entity.ChangedWho = "system";
entity.ChangedWhen = DateTime.UtcNow;
var created = await _repository.InsertAsync(entity, cancellationToken);
return _mapper.Map<CatalogReadDto>(created);
}
}

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public record DeleteCatalogCommand(int Id) : IRequest<bool>;

View File

@@ -0,0 +1,19 @@
using DbFirst.Application.Repositories;
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public class DeleteCatalogHandler : IRequestHandler<DeleteCatalogCommand, bool>
{
private readonly ICatalogRepository _repository;
public DeleteCatalogHandler(ICatalogRepository repository)
{
_repository = repository;
}
public async Task<bool> Handle(DeleteCatalogCommand request, CancellationToken cancellationToken)
{
return await _repository.DeleteAsync(request.Id, cancellationToken);
}
}

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public record UpdateCatalogCommand(int Id, CatalogWriteDto Dto) : IRequest<CatalogReadDto?>;

View File

@@ -0,0 +1,42 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using DbFirst.Domain.Entities;
using DbFirst.Domain;
using MediatR;
namespace DbFirst.Application.Catalogs.Commands;
public class UpdateCatalogHandler : IRequestHandler<UpdateCatalogCommand, CatalogReadDto?>
{
private readonly ICatalogRepository _repository;
private readonly IMapper _mapper;
public UpdateCatalogHandler(ICatalogRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<CatalogReadDto?> Handle(UpdateCatalogCommand request, CancellationToken cancellationToken)
{
var existing = await _repository.GetByIdAsync(request.Id, cancellationToken);
if (existing == null)
{
return null;
}
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);
return updated == null ? null : _mapper.Map<CatalogReadDto>(updated);
}
}

View File

@@ -1,11 +0,0 @@
namespace DbFirst.Application.Catalogs;
//TODO: create generic service to reduce code duplication
public interface ICatalogService
{
Task<List<CatalogReadDto>> GetAllAsync(CancellationToken cancellationToken = default);
Task<CatalogReadDto?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<CatalogReadDto> CreateAsync(CatalogWriteDto dto, CancellationToken cancellationToken = default);
Task<CatalogReadDto?> UpdateAsync(int id, CatalogWriteDto dto, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(int id, CancellationToken cancellationToken = default);
}

View File

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

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace DbFirst.Application.Catalogs.Queries;
public record GetAllCatalogsQuery : IRequest<List<CatalogReadDto>>;

View File

@@ -0,0 +1,23 @@
using AutoMapper;
using DbFirst.Application.Repositories;
using MediatR;
namespace DbFirst.Application.Catalogs.Queries;
public class GetCatalogByIdHandler : IRequestHandler<GetCatalogByIdQuery, CatalogReadDto?>
{
private readonly ICatalogRepository _repository;
private readonly IMapper _mapper;
public GetCatalogByIdHandler(ICatalogRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<CatalogReadDto?> Handle(GetCatalogByIdQuery request, CancellationToken cancellationToken)
{
var item = await _repository.GetByIdAsync(request.Id, cancellationToken);
return item == null ? null : _mapper.Map<CatalogReadDto>(item);
}
}

View File

@@ -0,0 +1,5 @@
using MediatR;
namespace DbFirst.Application.Catalogs.Queries;
public record GetCatalogByIdQuery(int Id) : IRequest<CatalogReadDto?>;

View File

@@ -8,6 +8,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoMapper" Version="12.0.1" /> <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" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,14 @@
using Microsoft.Extensions.DependencyInjection;
using MediatR;
namespace DbFirst.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddAutoMapper(typeof(DependencyInjection).Assembly);
services.AddMediatR(typeof(DependencyInjection).Assembly);
return services;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
using DbFirst.Domain;
using DbFirst.Domain.Entities;
namespace DbFirst.Application.Repositories;
public interface ICatalogRepository : IRepository<VwmyCatalog>
{
Task<VwmyCatalog?> GetByTitleAsync(string title, CancellationToken cancellationToken = default);
Task<VwmyCatalog?> UpdateAsync(int id, VwmyCatalog catalog, CatalogUpdateProcedure procedure, CancellationToken cancellationToken = default);
}

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
namespace DbFirst.Application.Repositories;
public interface IRepository<T>
{
Task<List<T>> GetAllAsync(CancellationToken cancellationToken = default);
Task<T?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<T> InsertAsync(T entity, CancellationToken cancellationToken = default);
Task<T?> UpdateAsync(int id, T entity, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(int id, CancellationToken cancellationToken = default);
}

View File

@@ -1,12 +0,0 @@
<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,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.22" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.22" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\sample-data\" />
</ItemGroup>
</Project>

View File

@@ -1,16 +0,0 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</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
</article>
</main>
</div>

View File

@@ -1,34 +0,0 @@
<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>
</nav>
</div>
@code {
private bool collapseNavMenu = true;
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}

View File

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

View File

@@ -1,212 +0,0 @@
@page "/catalogs"
@inject CatalogApiClient Api
<PageTitle>Catalogs</PageTitle>
<h1>Catalogs</h1>
@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>
}
<div class="mb-3">
<button class="btn btn-primary" @onclick="StartCreate">Neuen Eintrag anlegen</button>
</div>
@if (showForm)
{
<EditForm Model="formModel" OnValidSubmit="HandleSubmit">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Titel</label>
<InputText class="form-control" @bind-Value="formModel.CatTitle" />
</div>
<div class="col-md-6">
<label class="form-label">Kennung</label>
<InputText class="form-control" @bind-Value="formModel.CatString" />
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-success">@((isEditing ? "Speichern" : "Anlegen"))</button>
<button type="button" class="btn btn-secondary" @onclick="CancelEdit">Abbrechen</button>
</div>
</EditForm>
}
@if (isLoading)
{
<p><em>Lade Daten...</em></p>
}
else if (items.Count == 0)
{
<p>Keine Einträge vorhanden.</p>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>Id</th>
<th>Titel</th>
<th>String</th>
<th>Angelegt von</th>
<th>Angelegt am</th>
<th>Geändert von</th>
<th>Geändert am</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in items)
{
<tr>
<td>@item.Guid</td>
<td>@item.CatTitle</td>
<td>@item.CatString</td>
<td>@item.AddedWho</td>
<td>@item.AddedWhen.ToString("g")</td>
<td>@item.ChangedWho</td>
<td>@(item.ChangedWhen.HasValue ? item.ChangedWhen.Value.ToString("g") : string.Empty)</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary me-2" @onclick="(() => StartEdit(item))">Bearbeiten</button>
<button class="btn btn-sm btn-outline-danger" @onclick="(() => DeleteCatalog(item.Guid))">Löschen</button>
</td>
</tr>
}
</tbody>
</table>
}
@code {
private List<CatalogReadDto> items = new();
private CatalogWriteDto formModel = new();
private int editingId;
private bool isLoading;
private bool isEditing;
private bool showForm;
private string? errorMessage;
private string? infoMessage;
protected override async Task OnInitializedAsync()
{
await LoadCatalogs();
}
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 void StartCreate()
{
formModel = new CatalogWriteDto();
editingId = 0;
isEditing = false;
showForm = true;
infoMessage = null;
errorMessage = null;
}
private void StartEdit(CatalogReadDto item)
{
formModel = new CatalogWriteDto
{
CatTitle = item.CatTitle,
CatString = item.CatString
};
editingId = item.Guid;
isEditing = true;
showForm = true;
infoMessage = null;
errorMessage = null;
}
private async Task HandleSubmit()
{
errorMessage = null;
infoMessage = null;
try
{
if (isEditing)
{
var updated = await Api.UpdateAsync(editingId, formModel);
if (!updated)
{
errorMessage = "Aktualisierung fehlgeschlagen.";
return;
}
infoMessage = "Katalog aktualisiert.";
}
else
{
var created = await Api.CreateAsync(formModel);
if (created == null)
{
errorMessage = "Anlegen fehlgeschlagen.";
return;
}
infoMessage = "Katalog angelegt.";
}
showForm = false;
await LoadCatalogs();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Speichern: {ex.Message}";
}
}
private void CancelEdit()
{
showForm = false;
infoMessage = null;
errorMessage = null;
}
private async Task DeleteCatalog(int id)
{
errorMessage = null;
infoMessage = null;
try
{
var deleted = await Api.DeleteAsync(id);
if (!deleted)
{
errorMessage = "Löschen fehlgeschlagen.";
return;
}
infoMessage = "Katalog gelöscht.";
await LoadCatalogs();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Löschen: {ex.Message}";
}
}
}

View File

@@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using DbFirst.BlazorWasm;
using DbFirst.BlazorWasm.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
var apiBaseUrl = builder.Configuration["ApiBaseUrl"] ?? builder.HostEnvironment.BaseAddress;
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBaseUrl) });
builder.Services.AddScoped<CatalogApiClient>();
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,49 +0,0 @@
using System.Net.Http.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<CatalogReadDto?> CreateAsync(CatalogWriteDto dto)
{
var response = await _httpClient.PostAsJsonAsync(Endpoint, dto);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadFromJsonAsync<CatalogReadDto>();
}
public async Task<bool> UpdateAsync(int id, CatalogWriteDto dto)
{
var response = await _httpClient.PutAsJsonAsync($"{Endpoint}/{id}", dto);
return response.IsSuccessStatusCode;
}
public async Task<bool> DeleteAsync(int id)
{
var response = await _httpClient.DeleteAsync($"{Endpoint}/{id}");
return response.IsSuccessStatusCode;
}
}

View File

@@ -1,12 +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 DbFirst.BlazorWasm
@using DbFirst.BlazorWasm.Layout
@using DbFirst.BlazorWasm.Models
@using DbFirst.BlazorWasm.Services

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,32 +0,0 @@
<!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="/" />
<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>
<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>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>

View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
@DxResourceManager.RegisterTheme(Themes.Fluent.Clone(properties =>
{
properties.ApplyToPageElements = true;
}))
@DxResourceManager.RegisterScripts()
<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="bootstrap/bootstrap.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>
<HeadOutlet />
</head>
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -0,0 +1,809 @@
@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: 160px;
flex: 1 1 160px;
}
.loading-container {
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
</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 (!hasLoaded || isLoading)
{
<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)
{
<p>Keine Eintr&#228;ge vorhanden.</p>
}
else
{
<div class="band-editor">
<div class="band-controls">
<DxButton Text="Band hinzuf&#252;gen" Click="AddBand" />
<DxButton Text="Layout speichern" Click="SaveLayoutAsync" Enabled="@CanSaveBandLayout" />
<DxButton Text="Band-Layout zur&#252;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)"
SizeMode="@_sizeMode"
ShowGroupPanel="true"
ShowGroupedColumns="true"
AllowGroup="true"
FilterMenuButtonDisplayMode="GridFilterMenuButtonDisplayMode.Always"
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"
FocusedRowEnabled="true"
@bind-FocusedRowKey="focusedRowKey"
@ref="gridRef">
<ToolbarTemplate>
<DxToolbar>
<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); }
<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 bool hasLoaded;
private string? errorMessage;
private string? infoMessage;
private EditContext? editContext;
private ValidationMessageStore? validationMessageStore;
private IGrid? gridRef;
private int? focusedRowKey;
private string popupHeaderText = "Edit";
private const string LayoutType = "GRID_BANDS";
private const string LayoutKey = "CatalogsGrid";
private 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&#228;ndert von", ReadOnly = true, FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(CatalogReadDto.ChangedWhen), Caption = "Ge&#228;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 bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser);
private bool gridLayoutApplied;
private DevExpress.Blazor.SizeMode _sizeMode = DevExpress.Blazor.SizeMode.Medium;
private static readonly List<DevExpress.Blazor.SizeMode> _sizeModes =
Enum.GetValues<DevExpress.Blazor.SizeMode>().ToList();
private string FormatSizeText(DevExpress.Blazor.SizeMode size) => size switch
{
DevExpress.Blazor.SizeMode.Small => "Klein",
DevExpress.Blazor.SizeMode.Medium => "Mittel",
DevExpress.Blazor.SizeMode.Large => "Groß",
_ => size.ToString()
};
private void OnSizeChange(DropDownButtonItemClickEventArgs args)
{
_sizeMode = Enum.Parse<DevExpress.Blazor.SizeMode>(args.ItemInfo.Id);
}
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;
hasLoaded = true;
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.";
focusedRowKey = created.Value.Guid;
}
else
{
var updated = await Api.UpdateAsync(editModel.Guid, dto);
if (!updated.Success)
{
errorMessage = updated.Error ?? "Aktualisierung fehlgeschlagen.";
e.Cancel = true;
return;
}
infoMessage = "Katalog aktualisiert.";
focusedRowKey = editModel.Guid;
}
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&#228;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&#246;schen fehlgeschlagen.";
e.Cancel = true;
return;
}
infoMessage = "Katalog gel&#246;scht.";
await LoadCatalogs();
}
catch (Exception ex)
{
errorMessage = $"Fehler beim L&#246;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 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&#252;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);
}
builder.CloseComponent();
}
private sealed class BandLayout
{
public List<BandDefinition> Bands { get; set; } = new();
public List<string> ColumnOrder { get; set; } = new();
public Dictionary<string, string?> ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public GridPersistentLayout? GridLayout { get; set; }
}
private sealed class BandDefinition
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
public List<string> Columns { get; set; } = new();
}
private sealed class BandOption
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
}
private sealed class ColumnDefinition
{
public string FieldName { get; init; } = string.Empty;
public string Caption { get; init; } = string.Empty;
public string? Width { get; set; }
public string? DisplayFormat { get; init; }
public bool ReadOnly { get; init; }
public ColumnFilterType FilterType { get; init; }
}
private enum ColumnFilterType
{
Text,
Date
}
private sealed class CatalogEditModel
{
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;
}
}

View File

@@ -0,0 +1,43 @@
@inherits LayoutComponentBase
@implements IDisposable
@inject ThemeState ThemeState
<div class="page @(ThemeState.IsDarkMode ? "app-dark" : "app-light")">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<DxButton Text="@(ThemeState.IsDarkMode ? "Dark Mode aus" : "Dark Mode an")" Click="ToggleTheme" />
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@code {
protected override void OnInitialized()
{
ThemeState.OnChange += StateHasChanged;
}
private void ToggleTheme()
{
ThemeState.SetDarkMode(!ThemeState.IsDarkMode);
}
public void Dispose()
{
ThemeState.OnChange -= StateHasChanged;
}
}

View File

@@ -4,14 +4,27 @@
flex-direction: column; flex-direction: column;
} }
.page.app-dark {
background-color: #1b1b1b;
color: #f1f1f1;
}
main { main {
flex: 1; flex: 1;
} }
.page.app-dark main {
background-color: #1b1b1b;
}
.sidebar { .sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
} }
.page.app-dark .sidebar {
background-image: linear-gradient(180deg, #171717 0%, #0f2a46 70%);
}
.top-row { .top-row {
background-color: #f7f7f7; background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5; border-bottom: 1px solid #d6d5d5;
@@ -21,6 +34,11 @@ main {
align-items: center; align-items: center;
} }
.page.app-dark .top-row {
background-color: #222;
border-bottom-color: #3d3d3d;
}
.top-row ::deep a, .top-row ::deep .btn-link { .top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap; white-space: nowrap;
margin-left: 1.5rem; margin-left: 1.5rem;
@@ -75,3 +93,22 @@ main {
padding-right: 1.5rem !important; padding-right: 1.5rem !important;
} }
} }
#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;
}

View File

@@ -0,0 +1,36 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">DbFirst.BlazorWebApp</a>
</div>
</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>

View File

@@ -1,5 +1,18 @@
.navbar-toggler { .navbar-toggler {
background-color: rgba(255, 255, 255, 0.1); 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 { .top-row {
@@ -33,6 +46,18 @@
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"); 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 { .nav-item {
font-size: 0.9rem; font-size: 0.9rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -46,13 +71,16 @@
padding-bottom: 1rem; padding-bottom: 1rem;
} }
.nav-item ::deep a { .nav-item ::deep .nav-link {
color: #d7d7d7; color: #d7d7d7;
background: none;
border: none;
border-radius: 4px; border-radius: 4px;
height: 3rem; height: 3rem;
display: flex; display: flex;
align-items: center; align-items: center;
line-height: 3rem; line-height: 3rem;
width: 100%;
} }
.nav-item ::deep a.active { .nav-item ::deep a.active {
@@ -60,22 +88,28 @@
color: white; color: white;
} }
.nav-item ::deep a:hover { .nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1); background-color: rgba(255,255,255,0.1);
color: white; color: white;
} }
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) { @media (min-width: 641px) {
.navbar-toggler { .navbar-toggler {
display: none; display: none;
} }
.collapse { .nav-scrollable {
/* Never collapse the sidebar for wide screens */ /* Never collapse the sidebar for wide screens */
display: block; display: block;
}
.nav-scrollable {
/* Allow sidebar to scroll for tall menus */ /* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem); height: calc(100vh - 3.5rem);
overflow-y: auto; overflow-y: auto;

View File

@@ -0,0 +1,834 @@
@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 MassDataApiClient Api
@inject LayoutApiClient LayoutApi
@inject IJSRuntime JsRuntime
<style>
.action-panel { margin-bottom: 16px; }
.grid-section { margin-top: 12px; }
.pager-container {
display: flex;
justify-content: center;
margin-top: 12px;
margin-bottom: 16px;
}
.page-size-selector {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.page-size-label {
white-space: nowrap;
}
.page-size-combo {
width: 13ch;
min-width: 13ch;
max-width: 13ch;
}
.page-size-combo input {
text-align: left;
}
.massdata-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;
}
.loading-container {
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
</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 (!hasLoaded || isLoading)
{
<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)
{
<p>Keine Eintr&#228;ge vorhanden.</p>
}
else
{
<div class="mb-3 page-size-selector">
<span class="page-size-label">Datens&#228;tze je Seite:</span>
<DxComboBox Data="@pageSizeOptions"
TData="PageSizeOption"
TValue="int?"
TextFieldName="Text"
ValueFieldName="Value"
Value="@pageSize"
ValueChanged="OnPageSizeChanged"
CssClass="page-size-combo" />
</div>
<div class="band-editor">
<div class="band-controls">
<DxButton Text="Band hinzuf&#252;gen" Click="AddBand" />
<DxButton Text="Layout speichern" Click="SaveLayoutAsync" Enabled="@CanSaveBandLayout" />
<DxButton Text="Band-Layout zur&#252;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="MassDataReadDto"
KeyFieldName="@nameof(MassDataReadDto.Id)"
SizeMode="@_sizeMode"
ShowGroupPanel="true"
ShowGroupedColumns="true"
AllowGroup="true"
FilterMenuButtonDisplayMode="GridFilterMenuButtonDisplayMode.Always"
AllowColumnResize="true"
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
AllowColumnReorder="true"
PagerVisible="false"
PageSize="@(pageSize ?? 100)"
CssClass="mb-3 massdata-grid"
EditMode="GridEditMode.PopupEditForm"
PopupEditFormHeaderText="@popupHeaderText"
CustomizeEditModel="OnCustomizeEditModel"
EditModelSaving="OnEditModelSaving"
DataItemDeleting="OnDataItemDeleting"
FocusedRowEnabled="true"
@bind-FocusedRowKey="focusedRowKey"
@ref="gridRef">
<ToolbarTemplate>
<DxToolbar>
<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 = (MassDataEditModel)editFormContext.EditModel; SetPopupHeaderText(editModel.IsNew); }
<DxFormLayout ColCount="2">
<DxFormLayoutItem Caption="CustomerName">
<DxTextBox @bind-Text="editModel.CustomerName" Width="100%" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Amount">
<DxTextBox @bind-Text="editModel.AmountText" Width="100%" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Category">
<DxTextBox @bind-Text="editModel.Category" Width="100%" ReadOnly="@(!editModel.IsNew)" />
</DxFormLayoutItem>
<DxFormLayoutItem Caption="Status">
<DxCheckBox @bind-Checked="editModel.StatusFlag" ReadOnly="@(!editModel.IsNew)" />
</DxFormLayoutItem>
@if (!editModel.IsNew)
{
<DxFormLayoutItem Caption="Prozedur">
<DxComboBox Data="@procedureOptions"
TData="ProcedureOption"
TValue="int"
TextFieldName="Text"
ValueFieldName="Value"
@bind-Value="editModel.UpdateProcedure"
Width="100%" />
</DxFormLayoutItem>
}
<DxFormLayoutItem ColSpanMd="12">
<ValidationSummary />
</DxFormLayoutItem>
</DxFormLayout>
</EditFormTemplate>
</DxGrid>
@if (pageCount > 1)
{
<div class="pager-container">
<DxPager PageCount="@pageCount" ActivePageIndex="@pageIndex" ActivePageIndexChanged="OnPageChanged" />
</div>
}
</div>
}
@code {
private List<MassDataReadDto> items = new();
private bool isLoading;
private bool hasLoaded;
private string? errorMessage;
private string? infoMessage;
private int pageIndex;
private int pageCount = 1;
private int? pageSize = 100;
private string popupHeaderText = "Edit";
private EditContext? editContext;
private ValidationMessageStore? validationMessageStore;
private IGrid? gridRef;
private int? focusedRowKey;
private const string LayoutType = "GRID_BANDS";
private const string LayoutKey = "MassDataGrid";
private const string LayoutUserStorageKey = "layoutUser";
private string? layoutUser;
private BandLayout bandLayout = new();
private Dictionary<string, string> columnBandAssignments = new();
private List<BandOption> bandOptions = new();
private Dictionary<string, ColumnDefinition> columnLookup = new();
private readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);
private List<ColumnDefinition> columnDefinitions = new()
{
new() { FieldName = nameof(MassDataReadDto.Id), Caption = "Id", Width = "90px", ReadOnly = true, FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(MassDataReadDto.CustomerName), Caption = "CustomerName", FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(MassDataReadDto.Amount), Caption = "Amount", DisplayFormat = "c2", FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(MassDataReadDto.Category), Caption = "Category", ReadOnly = true, FilterType = ColumnFilterType.Text },
new() { FieldName = nameof(MassDataReadDto.StatusFlag), Caption = "Status", ReadOnly = true, FilterType = ColumnFilterType.Bool },
new() { FieldName = nameof(MassDataReadDto.AddedWhen), Caption = "Added", ReadOnly = true, FilterType = ColumnFilterType.Date },
new() { FieldName = nameof(MassDataReadDto.ChangedWhen), Caption = "Changed", ReadOnly = true, FilterType = ColumnFilterType.Date }
};
private bool CanSaveBandLayout => !string.IsNullOrWhiteSpace(layoutUser);
private readonly List<PageSizeOption> pageSizeOptions = new()
{
new() { Value = 100, Text = "100" },
new() { Value = 1000, Text = "1.000" },
new() { Value = 10000, Text = "10.000" },
new() { Value = 100000, Text = "100.000" },
new() { Value = null, Text = "Alle" }
};
private readonly List<ProcedureOption> procedureOptions = new()
{
new() { Value = 0, Text = "PRMassdata_UpsertByCustomerName" }
};
private bool gridLayoutApplied;
private DevExpress.Blazor.SizeMode _sizeMode = DevExpress.Blazor.SizeMode.Medium;
private static readonly List<DevExpress.Blazor.SizeMode> _sizeModes =
Enum.GetValues<DevExpress.Blazor.SizeMode>().ToList();
private string FormatSizeText(DevExpress.Blazor.SizeMode size) => size switch
{
DevExpress.Blazor.SizeMode.Small => "Klein",
DevExpress.Blazor.SizeMode.Medium => "Mittel",
DevExpress.Blazor.SizeMode.Large => "Groß",
_ => size.ToString()
};
private void OnSizeChange(DropDownButtonItemClickEventArgs args)
{
_sizeMode = Enum.Parse<DevExpress.Blazor.SizeMode>(args.ItemInfo.Id);
}
protected override async Task OnInitializedAsync()
{
columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase);
await EnsureLayoutUserAsync();
await LoadBandLayoutAsync();
await LoadPage(0);
}
private async Task LoadPage(int page)
{
isLoading = true;
errorMessage = null;
try
{
var total = await Api.GetCountAsync();
var effectivePageSize = pageSize ?? (total == 0 ? 1 : total);
pageCount = Math.Max(1, (int)Math.Ceiling(total / (double)effectivePageSize));
pageIndex = Math.Clamp(page, 0, pageCount - 1);
var skip = pageSize.HasValue ? pageIndex * pageSize.Value : 0;
items = await Api.GetAllAsync(skip, pageSize);
}
catch (Exception ex)
{
errorMessage = $"MassData konnten nicht geladen werden: {ex.Message}";
}
finally
{
isLoading = false;
hasLoaded = true;
StateHasChanged();
}
}
private async Task OnPageChanged(int index)
{
await LoadPage(index);
}
private async Task OnPageSizeChanged(int? size)
{
pageSize = size;
await LoadPage(0);
}
private async Task EnsureLayoutUserAsync()
{
layoutUser = await JsRuntime.InvokeAsync<string?>("localStorage.getItem", LayoutUserStorageKey);
if (string.IsNullOrWhiteSpace(layoutUser))
{
layoutUser = Guid.NewGuid().ToString("N");
await JsRuntime.InvokeVoidAsync("localStorage.setItem", LayoutUserStorageKey, layoutUser);
}
}
private async Task LoadBandLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
{
bandLayout = new BandLayout();
UpdateBandOptions();
return;
}
var stored = await LayoutApi.GetAsync(LayoutType, LayoutKey, layoutUser);
if (stored != null && !string.IsNullOrWhiteSpace(stored.LayoutData))
{
var parsed = JsonSerializer.Deserialize<BandLayout>(stored.LayoutData, jsonOptions);
bandLayout = NormalizeBandLayout(parsed);
}
else
{
bandLayout = new BandLayout();
}
columnBandAssignments = BuildAssignmentsFromLayout(bandLayout);
ApplyColumnLayoutFromStorage();
UpdateBandOptions();
}
private async Task SaveLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
{
return;
}
try
{
CaptureColumnLayoutFromGrid();
var layoutData = JsonSerializer.Serialize(bandLayout, jsonOptions);
await LayoutApi.UpsertAsync(new LayoutDto
{
LayoutType = LayoutType,
LayoutKey = LayoutKey,
UserName = layoutUser,
LayoutData = layoutData
});
infoMessage = "Layout gespeichert.";
errorMessage = null;
}
catch (Exception ex)
{
errorMessage = $"Layout konnte nicht gespeichert werden: {ex.Message}";
}
}
private void CaptureColumnLayoutFromGrid()
{
if (gridRef == null)
{
return;
}
var layout = gridRef.SaveLayout();
bandLayout.GridLayout = layout;
var orderedColumns = layout.Columns
.Where(column => !string.IsNullOrWhiteSpace(column.FieldName))
.OrderBy(column => column.VisibleIndex)
.ToList();
bandLayout.ColumnOrder = orderedColumns
.Select(column => column.FieldName)
.ToList();
bandLayout.ColumnWidths = orderedColumns
.Where(column => !string.IsNullOrWhiteSpace(column.Width))
.ToDictionary(column => column.FieldName, column => column.Width, StringComparer.OrdinalIgnoreCase);
}
private async Task ResetBandLayoutAsync()
{
if (string.IsNullOrWhiteSpace(layoutUser))
{
return;
}
await LayoutApi.DeleteAsync(LayoutType, LayoutKey, layoutUser);
bandLayout = new BandLayout();
columnBandAssignments.Clear();
UpdateBandOptions();
infoMessage = "Band-Layout zur&#252;ckgesetzt.";
}
private void ApplyColumnLayoutFromStorage()
{
foreach (var column in columnDefinitions)
{
if (bandLayout.ColumnWidths.TryGetValue(column.FieldName, out var width) && !string.IsNullOrWhiteSpace(width))
{
column.Width = width;
}
}
columnLookup = columnDefinitions.ToDictionary(column => column.FieldName, StringComparer.OrdinalIgnoreCase);
}
private void AddBand()
{
bandLayout.Bands.Add(new BandDefinition
{
Id = Guid.NewGuid().ToString("N"),
Caption = "Band"
});
UpdateBandOptions();
}
private void RemoveBand(BandDefinition band)
{
bandLayout.Bands.Remove(band);
var removedColumns = columnBandAssignments.Where(pair => pair.Value == band.Id)
.Select(pair => pair.Key)
.ToList();
foreach (var column in removedColumns)
{
columnBandAssignments.Remove(column);
}
UpdateBandOptions();
SyncBandsFromAssignments();
}
private void UpdateBandCaption(BandDefinition band, string value)
{
band.Caption = value;
UpdateBandOptions();
}
private void UpdateColumnBand(string fieldName, string? bandId)
{
if (string.IsNullOrWhiteSpace(bandId))
{
columnBandAssignments.Remove(fieldName);
}
else
{
columnBandAssignments[fieldName] = bandId;
}
SyncBandsFromAssignments();
}
private string GetColumnBand(string fieldName)
{
return columnBandAssignments.TryGetValue(fieldName, out var bandId) ? bandId : string.Empty;
}
private void SyncBandsFromAssignments()
{
foreach (var band in bandLayout.Bands)
{
band.Columns = columnDefinitions
.Where(column => columnBandAssignments.TryGetValue(column.FieldName, out var bandId) && bandId == band.Id)
.Select(column => column.FieldName)
.ToList();
}
StateHasChanged();
}
private void UpdateBandOptions()
{
bandOptions = new List<BandOption> { new() { Id = string.Empty, Caption = "Ohne Band" } };
bandOptions.AddRange(bandLayout.Bands.Select(band => new BandOption { Id = band.Id, Caption = band.Caption }));
}
private Dictionary<string, string> BuildAssignmentsFromLayout(BandLayout layout)
{
var assignments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var band in layout.Bands)
{
foreach (var column in band.Columns)
{
assignments[column] = band.Id;
}
}
return assignments;
}
private BandLayout NormalizeBandLayout(BandLayout? layout)
{
layout ??= new BandLayout();
layout.Bands ??= new List<BandDefinition>();
layout.ColumnOrder ??= new List<string>();
layout.ColumnWidths ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var band in layout.Bands)
{
if (string.IsNullOrWhiteSpace(band.Id))
{
band.Id = Guid.NewGuid().ToString("N");
}
if (string.IsNullOrWhiteSpace(band.Caption))
{
band.Caption = "Band";
}
band.Columns = band.Columns?.Where(columnLookup.ContainsKey).ToList() ?? new List<string>();
}
return layout;
}
private RenderFragment RenderColumns() => builder =>
{
var seq = 0;
builder.OpenComponent<DxGridCommandColumn>(seq++);
builder.AddAttribute(seq++, "Width", "120px");
builder.CloseComponent();
var grouped = bandLayout.Bands.SelectMany(band => band.Columns).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var column in columnDefinitions.Where(column => !grouped.Contains(column.FieldName)))
{
BuildDataColumn(builder, ref seq, column);
}
foreach (var band in bandLayout.Bands)
{
if (band.Columns.Count == 0)
{
continue;
}
builder.OpenComponent<DxGridBandColumn>(seq++);
builder.AddAttribute(seq++, "Caption", band.Caption);
builder.AddAttribute(seq++, "Columns", (RenderFragment)(bandBuilder =>
{
var bandSeq = 0;
foreach (var columnName in band.Columns)
{
if (columnLookup.TryGetValue(columnName, out var column))
{
BuildDataColumn(bandBuilder, ref bandSeq, column);
}
}
}));
builder.CloseComponent();
}
};
private void BuildDataColumn(RenderTreeBuilder builder, ref int seq, ColumnDefinition column)
{
builder.OpenComponent<DxGridDataColumn>(seq++);
builder.AddAttribute(seq++, "FieldName", column.FieldName);
builder.AddAttribute(seq++, "Caption", column.Caption);
if (!string.IsNullOrWhiteSpace(column.Width))
{
builder.AddAttribute(seq++, "Width", column.Width);
}
if (!string.IsNullOrWhiteSpace(column.DisplayFormat))
{
builder.AddAttribute(seq++, "DisplayFormat", column.DisplayFormat);
}
if (column.ReadOnly)
{
builder.AddAttribute(seq++, "ReadOnly", true);
}
builder.CloseComponent();
}
private void SetEditContext(EditContext context)
{
if (editContext == context)
{
return;
}
if (editContext != null)
{
editContext.OnFieldChanged -= OnEditFieldChanged;
}
editContext = context;
validationMessageStore = new ValidationMessageStore(editContext);
editContext.OnFieldChanged += OnEditFieldChanged;
}
private void OnEditFieldChanged(object? sender, FieldChangedEventArgs e)
{
if (validationMessageStore == null || editContext == null)
{
return;
}
if (e.FieldIdentifier.FieldName == nameof(MassDataEditModel.UpdateProcedure))
{
validationMessageStore.Clear();
editContext.NotifyValidationStateChanged();
return;
}
if (e.FieldIdentifier.FieldName == nameof(MassDataEditModel.CustomerName))
{
var field = new FieldIdentifier(editContext.Model, nameof(MassDataEditModel.CustomerName));
validationMessageStore.Clear(field);
editContext.NotifyValidationStateChanged();
}
}
private void SetPopupHeaderText(bool isNew)
{
popupHeaderText = isNew ? "Neu" : "Edit";
}
private async Task OnCustomizeEditModel(GridCustomizeEditModelEventArgs e)
{
if (e.IsNew)
{
e.EditModel = new MassDataEditModel { IsNew = true, UpdateProcedure = procedureOptions[0].Value };
SetPopupHeaderText(true);
return;
}
var item = (MassDataReadDto)e.DataItem;
e.EditModel = new MassDataEditModel
{
Id = item.Id,
CustomerName = item.CustomerName,
AmountText = item.Amount.ToString("0.00"),
Category = item.Category,
StatusFlag = item.StatusFlag,
UpdateProcedure = procedureOptions[0].Value,
IsNew = false,
OriginalCustomerName = item.CustomerName
};
SetPopupHeaderText(false);
}
private async Task OnEditModelSaving(GridEditModelSavingEventArgs e)
{
errorMessage = null;
infoMessage = null;
validationMessageStore?.Clear();
editContext?.NotifyValidationStateChanged();
var editModel = (MassDataEditModel)e.EditModel;
if (!decimal.TryParse(editModel.AmountText, out var amount))
{
AddValidationError(editModel, nameof(MassDataEditModel.AmountText), "Amount ist ung&#252;ltig.");
e.Cancel = true;
return;
}
if (editModel.IsNew)
{
var existing = await Api.GetByCustomerNameAsync(editModel.CustomerName);
if (existing != null)
{
AddValidationError(editModel, nameof(MassDataEditModel.CustomerName), "Kunde existiert bereits.");
e.Cancel = true;
return;
}
}
var dto = new MassDataWriteDto
{
CustomerName = editModel.CustomerName,
Amount = amount,
Category = editModel.Category,
StatusFlag = editModel.StatusFlag
};
try
{
var saved = await Api.UpsertAsync(dto);
infoMessage = editModel.IsNew ? "MassData angelegt." : "MassData aktualisiert.";
focusedRowKey = saved.Id;
await LoadPage(pageIndex);
}
catch (Exception ex)
{
errorMessage = $"Fehler beim Speichern: {ex.Message}";
e.Cancel = true;
}
}
private void AddValidationError(MassDataEditModel editModel, string fieldName, string message)
{
if (editContext == null || validationMessageStore == null)
{
return;
}
var field = new FieldIdentifier(editModel, fieldName);
validationMessageStore.Add(field, message);
editContext.NotifyValidationStateChanged();
}
private Task OnDataItemDeleting(GridDataItemDeletingEventArgs e)
{
errorMessage = null;
infoMessage = "L&#246;schen ist aktuell noch nicht verf&#252;gbar.";
e.Cancel = true;
return Task.CompletedTask;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!gridLayoutApplied && gridRef != null && bandLayout.GridLayout != null)
{
gridRef.LoadLayout(bandLayout.GridLayout);
gridLayoutApplied = true;
await InvokeAsync(StateHasChanged);
}
}
private sealed class BandLayout
{
public List<BandDefinition> Bands { get; set; } = new();
public List<string> ColumnOrder { get; set; } = new();
public Dictionary<string, string?> ColumnWidths { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public GridPersistentLayout? GridLayout { get; set; }
}
private sealed class BandDefinition
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
public List<string> Columns { get; set; } = new();
}
private sealed class BandOption
{
public string Id { get; set; } = string.Empty;
public string Caption { get; set; } = string.Empty;
}
private sealed class ColumnDefinition
{
public string FieldName { get; init; } = string.Empty;
public string Caption { get; init; } = string.Empty;
public string? Width { get; set; }
public string? DisplayFormat { get; init; }
public bool ReadOnly { get; init; }
public ColumnFilterType FilterType { get; init; }
}
private enum ColumnFilterType
{
Text,
Bool,
Date
}
private sealed class MassDataEditModel
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string AmountText { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public bool StatusFlag { get; set; }
public int UpdateProcedure { get; set; }
public bool IsNew { get; set; }
public string OriginalCustomerName { get; set; } = string.Empty;
}
private sealed class ProcedureOption
{
public int Value { get; set; }
public string Text { get; set; } = string.Empty;
}
private sealed class BoolFilterOption
{
public bool? Value { get; set; }
public string Text { get; set; } = string.Empty;
}
private sealed class PageSizeOption
{
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,7 @@
@page "/catalogs"
<PageTitle>Catalogs</PageTitle>
<h1>Catalogs</h1>
<CatalogsGrid />

View File

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

@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

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

View File

@@ -0,0 +1,8 @@
@rendermode InteractiveServer
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

View File

@@ -0,0 +1,17 @@
@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 static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Microsoft.AspNetCore.SignalR.Client
@using DbFirst.BlazorWebApp
@using DbFirst.BlazorWebApp.Components
@using DbFirst.BlazorWebApp.Models
@using DbFirst.BlazorWebApp.Services
@using DevExpress.Blazor
@using DevExpress.DashboardBlazor
@using DevExpress.DashboardWeb
@using DbFirst.BlazorWebApp

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<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" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Blazor" Version="25.2.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.22" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
namespace DbFirst.BlazorWebApp.Models;
public class 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,7 +1,8 @@
namespace DbFirst.BlazorWasm.Models; namespace DbFirst.BlazorWebApp.Models;
public class CatalogWriteDto public class CatalogWriteDto
{ {
public string CatTitle { get; set; } = string.Empty; public string CatTitle { get; set; } = string.Empty;
public string CatString { get; set; } = string.Empty; public string CatString { get; set; } = string.Empty;
public int UpdateProcedure { get; set; }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
using DbFirst.BlazorWebApp.Components;
using DbFirst.BlazorWebApp.Services;
using DevExpress.Blazor;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddDevExpressBlazor(options => options.BootstrapVersion = BootstrapVersion.v5);
builder.Services.AddScoped<ThemeState>();
var apiBaseUrl = builder.Configuration["ApiBaseUrl"];
if (!string.IsNullOrWhiteSpace(apiBaseUrl))
{
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
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>();
}
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();

View File

@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:7440",
"sslPort": 44343
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5096",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7191;http://localhost:5096",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,136 @@
using System.Net;
using System.Net.Http.Json;
using DbFirst.BlazorWebApp.Models;
namespace DbFirst.BlazorWebApp.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)
{
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

@@ -0,0 +1,21 @@
using System.Net.Http.Json;
using DbFirst.BlazorWebApp.Models;
namespace DbFirst.BlazorWebApp.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

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

View File

@@ -0,0 +1,63 @@
using System.Net.Http.Json;
using DbFirst.BlazorWebApp.Models;
namespace DbFirst.BlazorWebApp.Services;
public class MassDataApiClient
{
private readonly HttpClient _httpClient;
private const string Endpoint = "api/massdata";
public MassDataApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<int> GetCountAsync()
{
var result = await _httpClient.GetFromJsonAsync<int?>("api/massdata/count");
return result ?? 0;
}
public async Task<List<MassDataReadDto>> GetAllAsync(int? skip, int? take)
{
var 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

@@ -0,0 +1,35 @@
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 event Action? OnChange;
public void SetDarkMode(bool isDarkMode)
{
if (IsDarkMode == isDarkMode)
{
return;
}
IsDarkMode = isDarkMode;
var theme = Themes.Fluent.Clone(properties =>
{
properties.Mode = isDarkMode ? ThemeMode.Dark : ThemeMode.Light;
properties.ApplyToPageElements = true;
});
themeChangeService.SetTheme(theme);
OnChange?.Invoke();
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ApiBaseUrl": "https://localhost:7204/"
}

View File

@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ApiBaseUrl": "https://localhost:7204/",
"AllowedHosts": "*"
}

View File

@@ -1,13 +1,23 @@
html, body { :root {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; --global-size: 16px;
} }
h1:focus { html, body {
outline: none; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: var(--global-size);
}
.app-dark {
background-color: #1b1b1b;
color: #f1f1f1;
} }
a, .btn-link { a, .btn-link {
color: #0071c1; color: #006bb7;
}
.app-dark a, .app-dark .btn-link {
color: #6cb6ff;
} }
.btn-primary { .btn-primary {
@@ -24,39 +34,24 @@ a, .btn-link {
padding-top: 1.1rem; padding-top: 1.1rem;
} }
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) { .valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050; outline: 1px solid #26b050;
} }
.invalid { .invalid {
outline: 1px solid red; outline: 1px solid #e50000;
} }
.validation-message { .validation-message {
color: red; color: #e50000;
} }
#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 { .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+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA9NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDg2IDY2LjAxODMgMjYzLjU4NiA2Ni4wMTgzWk0yNjMuNTc2IDg2LjA1NDdDMjYxLjA0OSA4Ni4wNTQ3IDI1OS43ODUgODcuMzAwNSAxNTEuMDIyIDg5Ljc5MjEgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem; padding: 1rem 1rem 1rem 3.7rem;
color: white; color: white;
} }
@@ -65,39 +60,6 @@ a, .btn-link {
content: "An error has occurred." content: "An error has occurred."
} }
.loading-progress { .darker-border-checkbox.form-check-input {
position: relative; border-color: #929292;
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;
} }

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
window.setSize = function (fontSize) {
document.documentElement.style.setProperty('--global-size', fontSize);
};

View File

@@ -0,0 +1,7 @@
namespace DbFirst.Domain;
public enum CatalogUpdateProcedure
{
Update = 0,
Save = 1
}

View File

@@ -6,8 +6,4 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Folder Include="Repositories\" />
</ItemGroup>
</Project> </Project>

View File

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

View File

@@ -0,0 +1,15 @@
namespace DbFirst.Domain.Entities;
public class SmfLayout
{
public long Guid { get; set; }
public bool Active { get; set; }
public string LayoutType { get; set; } = string.Empty;
public string LayoutKey { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public byte[] LayoutData { get; set; } = Array.Empty<byte>();
public string AddedWho { get; set; } = string.Empty;
public DateTime AddedWhen { get; set; }
public string? ChangedWho { get; set; }
public DateTime? ChangedWhen { get; set; }
}

View File

@@ -1,14 +0,0 @@
using DbFirst.Domain.Entities;
namespace DbFirst.Domain.Repositories;
// TODO: instead of creating interface per entity, consider using generic repository pattern (eg. IRepository<T>) to reduce code duplication.
//TODO: move to application layer as a part of clean architecture
public interface ICatalogRepository
{
Task<List<VwmyCatalog>> GetAllAsync(CancellationToken cancellationToken = default);
Task<VwmyCatalog?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<VwmyCatalog> InsertAsync(VwmyCatalog catalog, CancellationToken cancellationToken = default);
Task<VwmyCatalog?> UpdateAsync(int id, VwmyCatalog catalog, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(int id, CancellationToken cancellationToken = default);
}

View File

@@ -1,49 +1,86 @@
using DbFirst.Domain.Entities; using DbFirst.Domain.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace DbFirst.Infrastructure; namespace DbFirst.Infrastructure;
public partial class ApplicationDbContext : DbContext public partial class ApplicationDbContext : DbContext
{ {
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) private readonly TableConfigurations _config;
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, IOptionsMonitor<TableConfigurations> configOptions)
: base(options) : base(options)
{ {
_config = configOptions.CurrentValue;
} }
public virtual DbSet<VwmyCatalog> VwmyCatalogs { get; set; } public virtual DbSet<VwmyCatalog> VwmyCatalogs { get; set; }
public virtual DbSet<SmfLayout> SmfLayouts { get; set; }
// TODO: Configure column names on appsettings via IConfiguration
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
var catCfg = _config.VwmyCatalog;
modelBuilder.Entity<VwmyCatalog>(entity => modelBuilder.Entity<VwmyCatalog>(entity =>
{ {
entity.HasKey(e => e.Guid); entity.HasKey(e => e.Guid);
entity.ToView("VWMY_CATALOG"); entity.ToView(catCfg.ViewName);
entity.Property(e => e.Guid).HasColumnName("GUID"); entity.Property(e => e.Guid).HasColumnName(catCfg.GuidColumnName);
entity.Property(e => e.AddedWho) entity.Property(e => e.AddedWho)
.HasMaxLength(30) .HasMaxLength(30)
.IsUnicode(false) .IsUnicode(false)
.HasColumnName(catCfg.AddedWhoColumnName);
entity.Property(e => e.AddedWhen)
.HasColumnType("datetime")
.HasColumnName(catCfg.AddedWhenColumnName);
entity.Property(e => e.CatString)
.HasMaxLength(900)
.IsUnicode(false)
.HasColumnName(catCfg.CatStringColumnName);
entity.Property(e => e.CatTitle)
.HasMaxLength(100)
.IsUnicode(false)
.HasColumnName(catCfg.CatTitleColumnName);
entity.Property(e => e.ChangedWhen)
.HasColumnType("datetime")
.HasColumnName(catCfg.ChangedWhenColumnName);
entity.Property(e => e.ChangedWho)
.HasMaxLength(30)
.IsUnicode(false)
.HasColumnName(catCfg.ChangedWhoColumnName);
});
modelBuilder.Entity<SmfLayout>(entity =>
{
entity.HasKey(e => e.Guid);
entity.ToTable("TBDD_SMF_LAYOUT", tb => tb.HasTrigger("TBDD_SMF_LAYOUT_AFT_UPD"));
entity.Property(e => e.Guid).HasColumnName("GUID");
entity.Property(e => e.Active).HasColumnName("ACTIVE");
entity.Property(e => e.LayoutType)
.HasMaxLength(50)
.HasColumnName("LAYOUT_TYPE");
entity.Property(e => e.LayoutKey)
.HasMaxLength(150)
.HasColumnName("LAYOUT_KEY");
entity.Property(e => e.UserName)
.HasMaxLength(50)
.HasColumnName("USER_NAME");
entity.Property(e => e.LayoutData).HasColumnName("LAYOUT_DATA");
entity.Property(e => e.AddedWho)
.HasMaxLength(50)
.HasColumnName("ADDED_WHO"); .HasColumnName("ADDED_WHO");
entity.Property(e => e.AddedWhen) entity.Property(e => e.AddedWhen)
.HasColumnType("datetime") .HasColumnType("datetime")
.HasColumnName("ADDED_WHEN"); .HasColumnName("ADDED_WHEN");
entity.Property(e => e.CatString) entity.Property(e => e.ChangedWho)
.HasMaxLength(900) .HasMaxLength(50)
.IsUnicode(false) .HasColumnName("CHANGED_WHO");
.HasColumnName("CAT_STRING");
entity.Property(e => e.CatTitle)
.HasMaxLength(100)
.IsUnicode(false)
.HasColumnName("CAT_TITLE");
entity.Property(e => e.ChangedWhen) entity.Property(e => e.ChangedWhen)
.HasColumnType("datetime") .HasColumnType("datetime")
.HasColumnName("CHANGED_WHEN"); .HasColumnName("CHANGED_WHEN");
entity.Property(e => e.ChangedWho)
.HasMaxLength(30)
.IsUnicode(false)
.HasColumnName("CHANGED_WHO");
}); });
OnModelCreatingPartial(modelBuilder); OnModelCreatingPartial(modelBuilder);

View File

@@ -15,10 +15,13 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.1" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DbFirst.Domain\DbFirst.Domain.csproj" /> <ProjectReference Include="..\DbFirst.Domain\DbFirst.Domain.csproj" />
<ProjectReference Include="..\DbFirst.Application\DbFirst.Application.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

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