Compare commits

..

41 Commits

Author SHA1 Message Date
01cd8b2607 Improve publish script with dynamic versioning
Added dynamic version resolution from the `<Version>` tag in the
`.csproj` file, enabling versioned output directories. Updated
`setlocal` to use `enabledelayedexpansion` for better variable
handling. Enhanced logging of build parameters and streamlined
the "Publishing" step by removing redundant logs.

Improved output verification with additional checks for critical
files (`hostfxr.dll`, `coreclr.dll`, etc.), logging failures if
files are missing. Updated success messages to include the
resolved version and generalized next-step instructions.
2026-09-21 14:17:49 +02:00
310d2efc6e Update project version to 1.1.0-beta
Updated versioning details in `EnvelopeGenerator.Server.csproj`:
- Changed `<Version>` from `1.0.1-beta` to `1.1.0-beta`.
- Updated `<FileVersion>` from `1.0.1.0` to `1.1.0.0`.
- Updated `<AssemblyVersion>` from `1.0.1.0` to `1.1.0.0`.

This minor version update reflects new features or improvements
while maintaining backward compatibility.
2026-09-21 13:55:42 +02:00
e5402bafc9 Refactor and cleanup EnvelopeSender components
Refactored code for improved readability, consistency, and maintainability. Removed the WASM boot loading overlay and its associated styles/scripts. Updated SVG paths to use self-closing tags for HTML compliance. Reformatted conditional blocks, state management, and session data logic. Enhanced error handling, spinner visibility, and empty state displays.

Removed redundant comments and unused code. Added `envelope-editor.js` for new functionality. Cleaned up CSS and JavaScript related to the removed WASM overlay. Improved grid layout persistence and signature field logic. Adjusted localization strings for better alignment with German translations.
2026-09-21 13:05:59 +02:00
f517ab4cfb Enhance grid UI and refactor boot overlay logic
- Updated `MappingProfile.cs` to add detailed mapping for `Envelope` to `EnvelopeDto`, including `EnvelopeTypeTitle`.
- Added a grid persistence hint banner in `EnvelopeSenderPage.razor` with an SVG icon and German message.
- Introduced three new columns (`EnvelopeTypeTitle`, `AddedWhen`, `ChangedWhen`) to the grid in `EnvelopeSenderPage.razor`.
- Refactored boot overlay removal logic in `App.razor` into a reusable function `sfRemoveBootOverlay`.
- Simplified event handling for overlay removal and fallback logic in `App.razor`.
- Adjusted `.sender-grid-wrapper` padding in `sender-page.css` and added styles for the new grid persistence hint banner.
2026-09-21 12:03:27 +02:00
6d63f9db2b Update Default connection string in appsettings.json
The `ConnectionStrings.Default` value in `appsettings.json` was updated.
The connection string now points to the testing environment
(`SDD-VHP04-SQL19\DD_TESTING01`) instead of the development environment
(`SDD-VMP04-SQL17\DD_DEVELOP01`). The password was also updated to
`123456789dD!` to reflect the new environment credentials.
2026-09-21 11:07:50 +02:00
46c7c31e03 Add customer logo area to action bar
Introduced a customer logo section in `EnvelopeSenderPage.razor` that dynamically displays a customer logo or a placeholder when no logo is configured. The logo is fetched from the `CustomImagesService` and styled using new CSS classes in `sender-page.css`.

- Added `_companyLogo` property to store customer logo configuration.
- Updated `CustomImagesService` to fetch both app and customer logos.
- Modified `appsettings.json` to include logo paths and styles.
- Enhanced CSS for a three-section action bar layout.
- Added placeholder styling for missing customer logos.
- Updated initialization logic to load logos dynamically.
- Documented implementation details in `SF-74-PLAN.md`.

The implementation is modular and deployment-specific, allowing logo paths and styles to be updated via configuration without code changes.
2026-09-06 10:50:05 +02:00
8f6db435bb SF-74: Client UI — IndexPage + EnvelopeSenderPage logo integration (signFLOW + company logo, branding layout) 2026-09-04 01:07:34 +02:00
9bf7676464 SF-74: Client — CustomImagesService (in-memory cache, GET /api/CustomImages), DI registration 2026-09-04 01:07:27 +02:00
d9556c18ca SF-74: Server wwwroot — logo images (DD_signFLOW_LOGO.png, digital_data.svg), app.css logo CSS classes 2026-09-04 01:07:16 +02:00
5b7157f422 SF-74: Server — CustomImagesController (GET /api/CustomImages), DI registration, appsettings logo config, App.razor 2026-09-04 01:07:04 +02:00
119ee3ec16 SF-74: Models — CustomImagesOptions (Server, Options Pattern), CustomImagesDto (Client) 2026-09-04 01:06:52 +02:00
b536b31a4b SF-74: CSS — app.css theme/dark-mode additions, envelope-viewer.css, sender-page.css new stylesheets 2026-09-03 15:40:20 +02:00
a80efee075 SF-74: ThemeService + signflow-theme.js — JS interop fixes and dark mode init improvements 2026-09-03 15:40:14 +02:00
86d6daafbd SF-74: EnvelopeSenderPage — ThemeService/UserPreferencesService inject, LoadFromServerAsync, grid layout persistence (LayoutAutoLoading/Saving) 2026-09-03 14:58:31 +02:00
aefa1a8883 SF-74: Client UI — MainLayout HeadContent+footer theme controls, ThemeControls component, app.css sf-footer styles 2026-09-03 14:58:19 +02:00
b3e7f77165 SF-74: Server — add signflow-theme.js (localStorage/system dark mode JS interop) 2026-09-03 14:58:07 +02:00
bac9bfd036 SF-74: Client Services — ThemeService (debounce, SaveError, DisplayName), UserPreferencesService (1h cache) 2026-09-03 14:57:55 +02:00
6effb8a0d6 SF-74: Server — UserPreferencesController, claim extensions, DI registration, App.razor 2026-09-03 14:57:44 +02:00
a9ff0d88f2 SF-74: Infrastructure — UserPreferencesService (IDistributedCache, no try-catch) 2026-09-03 14:57:32 +02:00
0fdfe991ea SF-74: Domain/Application — UserPreferences DTO, interface, Sender role constant 2026-09-03 14:57:21 +02:00
7fc2c14f9c Add dynamic theme and dark mode functionality
Introduced `ThemeService` to manage themes and dark mode, with a new footer UI for theme selection and toggling dark mode. Added dynamic CSS loading and Bootstrap theme support. Registered `UserPreferencesService` and `ThemeService` for SSR compatibility. Removed hardcoded DevExpress theme links.

Fixed German localization issues in `LoginReceiverPage` and `LoginSenderPage`. Updated `app.css` with styles for the new footer controls. Simplified font loading in `MainLayout.razor`.
2026-09-03 09:24:13 +02:00
5098deedd1 Add ThemeService and UserPreferencesService
Introduce ThemeService to manage themes and dark mode state,
including initialization, persistence, and server sync.

Add UserPreferencesService to handle API communication for
retrieving and saving user preferences with in-memory caching
to reduce network overhead.

Register both services in the DI container in Program.cs.

Enhance ThemeService with detailed documentation and introduce
ThemeOption record for selectable themes. Improve server-side
caching in UserPreferencesService for consistency and efficiency.
2026-09-03 01:18:01 +02:00
2e634361c5 Add user preferences management via distributed cache
Introduced `UserPreferencesDto` to represent UI preferences,
including theme, dark mode, and grid layouts. Added the
`IUserPreferencesService` interface and implemented it in
`UserPreferencesService` to manage preferences stored in
the distributed cache (`dbo.TBDD_CACHE`).

Registered `UserPreferencesService` in dependency injection
and added a NuGet reference for `Microsoft.Extensions.Caching.Abstractions`.

Created `UserPreferencesController` with `GET` and `PUT`
endpoints to retrieve and save user preferences. Validated
theme names in the `PUT` endpoint.

Updated `Program.cs` to ensure proper service registration.
2026-09-02 12:35:19 +02:00
60ebebc0d8 Add widget annotation and update stroke color
Updated `createAnnotations` to initialize a `PSPDFKit.Annotations.WidgetAnnotation` object, enabling the creation of widget annotations.

Modified `ctx.strokeStyle` in `createAnnotationFrameBlob` to use 'darkblue' instead of 'darkgray', improving the visual appearance of the annotation frame.
2026-08-27 14:46:32 +02:00
c2d8b6531c Target single framework and add publish profile
Updated `EnvelopeGenerator.Service.csproj` to target a single framework (`net4.6.2`) by replacing `<TargetFrameworks>` with `<TargetFramework>`.

Added `DefaultProfile.pubxml` to define a publish profile with settings for release configuration, platform, and file system-based publishing.
2026-08-27 12:44:11 +02:00
665266457f Migrate legacy VB.NET service to modern C# Worker
Migrated the legacy VB.NET Windows Service to a modern C# Worker Service targeting .NET Framework 4.6.2. Key changes include:

- Added `Microsoft.Extensions.Hosting.WindowsServices`, `NLog`, and `Quartz` for logging and scheduling.
- Introduced `LogConfigFactory` to bridge `ServiceConfig` and `LogConfig` for Quartz jobs.
- Rewrote `Worker` to handle the full service lifecycle, including database connection, temp file management, and scheduler initialization.
- Implemented `SchedulerFinishEnvelope` and `SchedulerEnvelopeTaskApi` for Quartz job scheduling.
- Created `TempFiles` for service-level temp file management.
- Configured `NLog` for file and console logging.
- Updated `appsettings.json` for connection strings and service-specific configuration.
- Replaced legacy `ProjectInstaller` with `AddWindowsService()` for SCM integration.
2026-08-27 11:39:49 +02:00
14d581acac Add EnvelopeGenerator.Service as a .NET Worker Service
Introduced a new project `EnvelopeGenerator.Service` to the solution as a .NET Worker Service targeting the `net4.6.2` framework.

- Configured the project with nullable reference types, implicit usings, and `LangVersion` set to `latest`.
- Added a `Worker` class that logs periodic messages while running.
- Updated `Program.cs` to configure and run the Worker Service.
- Added `launchSettings.json` for development environment configuration.
- Added `appsettings.json` and `appsettings.Development.json` for logging configuration.
- Included the project in `EnvelopeGenerator.sln` with appropriate build configurations and hierarchy updates.
2026-08-27 10:36:55 +02:00
eb533a1dc2 Replace EnvelopeGenerator.Service with Service_legacy
The project `EnvelopeGenerator.Service` has been removed from the solution and replaced with a new project, `EnvelopeGenerator.Service_legacy`.

Updated build configuration mappings to reflect the addition of `Service_legacy` and the removal of `Service`. Adjusted project nesting to place `Service_legacy` under the same parent as the removed `Service`.
2026-08-27 10:36:01 +02:00
36025fad7b Enhance handleReset functionality and fix async issues
Updated the `handleReset` method in `app.js` to await the
`Swal.fire` call, ensuring proper asynchronous handling.
Added logic to reset editable text form fields and restore
widget backgrounds after signing.

Refactored `app.min.js` to align with changes in `app.js`,
ensuring consistency between the minified and non-minified
versions. Improved code readability and maintainability
across both files.
2026-08-27 09:32:51 +02:00
0606efabf9 Update truncation limits for "position" and "city"
Increased the maximum width values for the "position" and "city" keys in the `TextMaxWidths` property of `Jobs.FinalizeDocument` (PDFBurnerParams.vb) from `1.25F` to `1.41F`. This change allows more text to fit before truncation occurs, improving text rendering flexibility.
2026-08-25 16:38:51 +02:00
2cb1e266f7 Improve annotation handling and UI stability
Updated `appsettings.UI.json` to adjust annotation properties for better alignment and positioning. Refactored `createAnnotations` and `createAnnotationFrameBlob` in `annotation.js` to improve readability, reusability, and asynchronous handling.

Enhanced the `App` class in `app.js` by introducing `signatureWidgets` for stable frame placement and added logic to find and update the nearest signature widget. Improved bounding box calculations and removed widget backgrounds after signing.

Refactored `handleAnnotationsCreate` to align with new logic and updated `app.min.js` to reflect these changes. General improvements include better code maintainability, reduced redundancy, and enhanced user experience.
2026-08-25 16:38:22 +02:00
35c1374b9c Update text truncation logic to use rendered width
Replaced character-based truncation with pixel-accurate truncation based on rendered width in inches. Updated `TruncateText` in `PDFBurner.vb` to use `Graphics.MeasureString` for width calculation and introduced a binary search for efficient truncation. Renamed `egName` to `fieldEgName` for clarity.

Replaced `TextMaxLengths` with `TextMaxWidths` in `PDFBurnerParams.vb`, specifying maximum render widths in inches. Updated default values for `position`, `city`, and `date`. Revised XML documentation to reflect the new truncation logic.
2026-08-25 09:47:22 +02:00
feaea1626b Fix signature field formatting and add text truncation
Address customer feedback on signature field layout issues:
- Added truncation logic in `PDFBurner.vb` to limit text length
  based on per-field maximums, appending a suffix (e.g., `..`).
- Enhanced `PDFBurnerParams.vb` with `TextMaxLengths` and
  `TruncationSuffix` for configurable truncation behavior.
- Improved spacing and layout handling for signature fields to
  prevent text collisions within fixed dimensions.
- Updated `EnvelopeGenerator.sln` with documentation for the
  signature formatting fix (`fix-signature-field-formatting.md`).
- Applied truncation consistently across `AddFormFieldValue`
  overloads and ensured deterministic rendering of annotations.
2026-08-25 09:07:02 +02:00
babd96200a Refactor and enhance PDF finalization process
- Added new imports to integrate additional services.
- Renamed `envelope` to `oEnvelopeEntity` for clarity.
- Improved envelope retrieval logic and error handling.
- Refactored `ReadOnly` envelope handling for readability.
- Reintroduced and enhanced annotation processing logic.
- Updated `BurnAnnotsToPDF` call with better logging.
- Added envelope domain model loading for report creation.
- Implemented signing certificate report generation.
- Merged burned document with signing certificate report.
- Updated output file naming and adjusted save logic.
- Improved method structure with clear steps and logging.
2026-08-24 14:33:03 +02:00
f9cfcf1f19 Fix: Add "Read and confirmed" label for RaC envelopes
Updated the Signature Certificate report to display the correct
label "Read and confirmed" for "Read and Sign" envelopes.

- Added `DocumentSignedRaC` resource key to `Model.resx` and
  `Model.en.resx` with localized values ("Gelesen und bestätigt"
  and "Read and confirmed").
- Updated `ReportItem.vb` to include conditional logic in
  `ItemStatusTranslated` for "Read and Sign" envelopes.
- Added strongly-typed property for `DocumentSignedRaC` in
  `Model.Designer.cs`.
- Updated `fix-report-label-read-and-confirmed.md` documentation
  to reflect changes, including testing steps and task completion.

These changes ensure accurate status labels in the report for
different envelope types.
2026-08-24 14:31:40 +02:00
ffb9e07466 Fix label for "Read and Sign" envelopes in reports
Addressed an issue where the Signature Certificate report incorrectly displayed "Document signed" for "Read and Sign" envelopes. Added a new resource key, `DocumentSignedRaC`, with translations ("Read and confirmed" / "Gelesen und bestätigt") to resolve this.

Updated `ItemStatusTranslated` in `ReportItem.vb` to use the new key for "Read and Sign" envelopes (`EnvelopeTypeId == 2`) while retaining existing behavior for other envelope types. Leveraged `Envelope.IsReadAndConfirm()` to determine envelope type.

Pending tasks include adding the new resource key to resource files and updating `Model.Designer.cs` with a strongly-typed property. Testing instructions provided to verify correct label behavior.
2026-08-24 13:47:28 +02:00
5384a08905 Update .gitignore to exclude specific files and directories
The .gitignore file was updated to ignore the following files and
directories:
- `FodyWeavers.xsd`
- `/EnvelopeGenerator.Tests.Application/annotations.json`
- `/EnvelopeGenerator.Server/EnvelopeGenerator.Server/TekH - SoftHSM Test.md`
- `/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md`
- `/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output`
- `/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md+/ignore/EnvelopeGenerator.Form`

These changes ensure that unnecessary or sensitive files are not
tracked in version control.
2026-08-20 14:58:51 +02:00
dda5ff28e1 Update build package paths to include PreRelease folder
Updated the `DesktopBuildPackageLocation` property in
`IISProfileNet8.pubxml` and `IISProfile.pubxml` to store
build packages in the `PreRelease` directory.

- Updated `EnvelopeGenerator.API.zip` and `EnvelopeGenerator.Server.zip` paths in `IISProfileNet8.pubxml`.
- Updated `EnvelopeGenerator.ReceiverUI.zip` path in `IISProfile.pubxml`.

No other properties or configurations were modified.
2026-07-06 14:32:18 +02:00
043349a47f Update EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md 2026-07-06 10:17:10 +02:00
c36f7ccf98 Merge branch 'feat/migr-DxReportViewer' into master 2026-07-02 15:59:31 +02:00
cf3e85bcd7 Update .gitignore and add new solution items
Updated the `.gitignore` file to exclude the `MigrationBackup/`
directory. Added `publish-output` and `tekh_softHSM_test.md`
to the `EnvelopeGenerator.Server` directory.

Modified the `EnvelopeGenerator.sln` file to include a new
solution item `RECEIVER_PDF_VIEWER_CONTEXT.md` under the
`src` project section. No structural changes were made to
existing projects in the solution.
2026-07-02 15:56:46 +02:00
92 changed files with 3690 additions and 291 deletions

1
.gitignore vendored
View File

@@ -369,3 +369,4 @@ FodyWeavers.xsd
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/ignore/EnvelopeGenerator.Form

View File

@@ -11,7 +11,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>5e0e17c0-ff5a-4246-bf87-1add85376a27</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\API\net8\$(Version)\EnvelopeGenerator.API.zip</DesktopBuildPackageLocation>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\PreRelease\API\net8\$(Version)\EnvelopeGenerator.API.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId>

View File

@@ -0,0 +1,39 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Represents a user's UI preferences stored in the distributed cache (dbo.TBDD_CACHE).
/// </summary>
public sealed record UserPreferencesDto
{
/// <summary>
/// The active DevExpress theme name.
/// Allowed values: "blazing-berry", "fluent", "purple"
/// </summary>
public string ThemeName { get; set; } = "blazing-berry";
/// <summary>
/// Whether dark mode is active.
/// Default: false (light mode).
/// </summary>
public bool IsDarkMode { get; set; } = false;
/// <summary>
/// Persisted DxGrid layout states, keyed by a stable grid identifier.
/// Example keys: "sender.active-envelopes", "sender.completed-envelopes"
/// Values are opaque JSON strings produced by DxGrid.SaveLayoutAsync().
/// </summary>
public Dictionary<string, string> GridLayouts { get; set; } = new();
/// <summary>
/// The authenticated user's display name ("{GivenName} {Surname}").
/// Populated by the server on GET responses from JWT claims.
/// NOT persisted in cache — injected at read time by UserPreferencesController.
/// Ignored on PUT requests.
/// </summary>
public string? DisplayName { get; set; }
/// <summary>
///
/// </summary>
public static UserPreferencesDto Default { get; } = new UserPreferencesDto();
}

View File

@@ -0,0 +1,29 @@
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
/// <summary>
/// Manages per-user UI preferences (theme, dark mode, grid layouts).
/// Preferences are stored in the distributed cache (dbo.TBDD_CACHE) as JSON.
/// Cache key schema: "signflow.user-preferences:{username}"
/// </summary>
public interface IUserPreferencesService
{
/// <summary>
/// Retrieves preferences for the given user.
/// Always returns a valid object — never throws, never returns null.
/// Returns defaults if no entry exists in cache.
/// </summary>
/// <param name="id"></param>
/// <param name="ct">Cancellation token.</param>
Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default);
/// <summary>
/// Persists the given preferences for the user in the distributed cache.
/// Failures are non-fatal and silently ignored.
/// </summary>
/// <param name="id"></param>
/// <param name="preferences">The preferences to persist.</param>
/// <param name="ct">Cancellation token.</param>
Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default);
}

View File

@@ -28,7 +28,9 @@ public class MappingProfile : Profile
CreateMap<DocReceiverElement, DocReceiverElementDto>();
CreateMap<DocumentStatus, DocumentStatusDto>();
CreateMap<EmailTemplate, EmailTemplateDto>();
CreateMap<Envelope, EnvelopeDto>();
CreateMap<Envelope, EnvelopeDto>()
.ForMember(dest => dest.EnvelopeTypeTitle,
opt => opt.MapFrom(src => src.Type != null ? src.Type.Title : null));
CreateMap<Document, DocumentDto>();
CreateMap<History, HistoryDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
CreateMap<History, HistoryCreateDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));

View File

@@ -1,6 +1,7 @@
Imports System.IO
Imports DigitalData.Modules.Database
Imports DigitalData.Modules.Logging
Imports EnvelopeGenerator.CommonServices
Imports EnvelopeGenerator.CommonServices.Jobs
Imports EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument
Imports GdPicture14
@@ -145,64 +146,91 @@ Public Class frmFinalizePDF
log.AppendLine($"Document bytes: {oBuffer.Length}")
' 3. Check what BurnAnnotsToPDF will do internally
Dim oEnvelopeEntity As Envelope = Nothing
Using scope = Factory.Shared.ScopeFactory.CreateScope()
Dim envRepo = scope.ServiceProvider.Repository(Of Envelope)()
Dim envelope = envRepo.Where(Function(env) env.Id = envelopeId).FirstOrDefault()
oEnvelopeEntity = envRepo.Where(Function(env) env.Id = envelopeId).FirstOrDefault()
If envelope Is Nothing Then
If oEnvelopeEntity Is Nothing Then
log.AppendLine("ERROR: Envelope not found in EF Core!")
txtResult.Text = log.ToString()
Return
End If
log.AppendLine($"Envelope found: Id={envelope.Id}, EnvelopeTypeId={envelope.EnvelopeTypeId}")
log.AppendLine($"ReadOnly (IsReadAndConfirm): {envelope.ReadOnly}")
log.AppendLine($"Envelope found: Id={oEnvelopeEntity.Id}, EnvelopeTypeId={oEnvelopeEntity.EnvelopeTypeId}")
log.AppendLine($"ReadOnly (IsReadAndConfirm): {oEnvelopeEntity.ReadOnly}")
If envelope.ReadOnly Then
log.AppendLine(">>> EARLY RETURN: ReadOnly=True, original PDF returned without burning")
txtResult.Text = log.ToString()
Return
End If
Dim sigRepo = scope.ServiceProvider.Repository(Of DocReceiverElement)()
Dim elements = sigRepo _
.Where(Function(sig) sig.Document.EnvelopeId = envelopeId) _
.Include(Function(sig) sig.Annotations) _
.ToList()
log.AppendLine($"Elements (Signature) count: {elements.Count}")
If elements.Any() Then
log.AppendLine(">>> PATH: BurnElementAnnotsToPDF (new element-based path)")
For Each elem In elements
Dim annotCount = If(elem.Annotations IsNot Nothing, elem.Annotations.Count(), 0)
log.AppendLine($" Element Id={elem.Id}, Page={elem.Page}, X={elem.X}, Y={elem.Y}, W={elem.Width}, H={elem.Height}, Annotations={annotCount}")
If elem.Annotations IsNot Nothing Then
For Each annot In elem.Annotations
log.AppendLine($" Annot: Name={annot.Name}, Type={annot.Type}, X={annot.X}, Y={annot.Y}, W={annot.Width}, H={annot.Height}")
Next
End If
Next
If oEnvelopeEntity.ReadOnly Then
log.AppendLine(">>> ReadOnly=True: original PDF will be used without burning (annotations skipped)")
Else
log.AppendLine(">>> PATH: BurnInstantJSONAnnotsToPDF (old JSON-based path)")
Dim sigRepo = scope.ServiceProvider.Repository(Of DocReceiverElement)()
Dim elements = sigRepo _
.Where(Function(sig) sig.Document.EnvelopeId = envelopeId) _
.Include(Function(sig) sig.Annotations) _
.ToList()
log.AppendLine($"Elements (Signature) count: {elements.Count}")
If elements.Any() Then
log.AppendLine(">>> PATH: BurnElementAnnotsToPDF (new element-based path)")
For Each elem In elements
Dim annotCount = If(elem.Annotations IsNot Nothing, elem.Annotations.Count(), 0)
log.AppendLine($" Element Id={elem.Id}, Page={elem.Page}, X={elem.X}, Y={elem.Y}, W={elem.Width}, H={elem.Height}, Annotations={annotCount}")
If elem.Annotations IsNot Nothing Then
For Each annot In elem.Annotations
log.AppendLine($" Annot: Name={annot.Name}, Type={annot.Type}, X={annot.X}, Y={annot.Y}, W={annot.Width}, H={annot.Height}")
Next
End If
Next
Else
log.AppendLine(">>> PATH: BurnInstantJSONAnnotsToPDF (old JSON-based path)")
End If
End If
End Using
' 4. Actually call BurnAnnotsToPDF (same as Service)
' 4. Burn annotations (ReadOnly envelopes return original PDF unchanged)
log.AppendLine("")
log.AppendLine("Calling BurnAnnotsToPDF...")
Dim oNewBuffer = PDFBurner.BurnAnnotsToPDF(oBuffer, oJsonList, envelopeId)
log.AppendLine($"Result bytes: {oNewBuffer.Length}")
log.AppendLine($"Same as input: {oBuffer.Length = oNewBuffer.Length AndAlso oBuffer.SequenceEqual(oNewBuffer)}")
Dim oBurnedBuffer As Byte() = PDFBurner.BurnAnnotsToPDF(oBuffer, oJsonList, envelopeId)
log.AppendLine($"Burned bytes: {oBurnedBuffer.Length}")
log.AppendLine($"Unchanged (ReadOnly): {oBuffer.Length = oBurnedBuffer.Length AndAlso oBuffer.SequenceEqual(oBurnedBuffer)}")
' 5. Write output
' 5. Build State and load Envelope domain model for ReportCreator
log.AppendLine("")
log.AppendLine("Loading envelope domain model for report...")
Dim oState As New State() With {
.Database = Database,
.LogConfig = LogConfig
}
Dim oEnvelopeModel As New EnvelopeModel(oState)
Dim oEnvelope = oEnvelopeModel.GetById(envelopeId)
If oEnvelope Is Nothing Then
log.AppendLine("ERROR: Envelope not found via EnvelopeModel!")
txtResult.Text = log.ToString()
Return
End If
log.AppendLine($"Envelope loaded: {oEnvelope.Id}")
' 6. Create signing certificate report
log.AppendLine("Creating signing certificate report...")
Dim oReportCreator As New ReportCreator(LogConfig, oState)
Dim oReportBuffer As Byte() = oReportCreator.CreateReport(oEnvelope)
log.AppendLine($"Report bytes: {oReportBuffer.Length}")
' 7. Merge burned document + report (last page)
log.AppendLine("Merging documents...")
Dim oMerger As New PDFMerger(LogConfig, pGDPictureLicenseKey)
Dim oMergedBuffer As Byte() = oMerger.MergeDocuments(oBurnedBuffer, oReportBuffer)
log.AppendLine($"Merged bytes: {oMergedBuffer.Length}")
' 8. Save and open
Dim desktopPath As String = Environment.GetFolderPath(Environment.SpecialFolder.Desktop)
Dim oNewPath = Path.Combine(desktopPath, $"E{txtEnvelope.Text}_FullTest.burned.pdf")
File.WriteAllBytes(oNewPath, oNewBuffer)
log.AppendLine($"Output: {oNewPath}")
Dim oMergedPath = Path.Combine(desktopPath, $"E{txtEnvelope.Text}_FullTest.merged.pdf")
File.WriteAllBytes(oMergedPath, oMergedBuffer)
log.AppendLine($"Output: {oMergedPath}")
txtResult.Text = log.ToString()
Process.Start(oNewPath)
Process.Start(oMergedPath)
Catch ex As Exception
txtResult.Text = $"ERROR: {ex.Message}{vbCrLf}{vbCrLf}{ex.ToString()}"

View File

@@ -119,7 +119,7 @@ Namespace Jobs.FinalizeDocument
Dim y = frameY + yOffsetofFF
If annot.Type = AnnotationType.FormField Then
AddFormFieldValue(annot.X / inchFactor, y, annot.Width / inchFactor, annot.Height / inchFactor, element.Page, annot.Value)
AddFormFieldValue(annot.X / inchFactor, y, annot.Width / inchFactor, annot.Height / inchFactor, element.Page, annot.Value, annot.Name)
ElseIf annot.Type = AnnotationType.Image Then
AddImageAnnotation(
annot.X / inchFactor,
@@ -269,11 +269,14 @@ Namespace Jobs.FinalizeDocument
Next
End Sub
Private Sub AddFormFieldValue(x As Double, y As Double, width As Double, height As Double, page As Integer, value As String)
Private Sub AddFormFieldValue(x As Double, y As Double, width As Double, height As Double, page As Integer, value As String, Optional egName As String = "")
Manager.SelectPage(page)
' Truncate if needed
Dim displayValue As String = TruncateText(value, egName)
' Add the text annotation
Dim ant = Manager.AddTextAnnot(x, y, width, height, value)
Dim ant = Manager.AddTextAnnot(x, y, width, height, displayValue)
' Set the font properties
ant.FontName = _pdfBurnerParams.FontName
@@ -294,8 +297,9 @@ Namespace Jobs.FinalizeDocument
Dim oHeight = oBounds.Item(3)
Manager.SelectPage(pAnnotation.pageIndex + 1)
' Add the text annotation
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, formFieldValue.value)
' Add the text annotation (truncate if needed)
Dim displayValue As String = TruncateText(formFieldValue.value, pAnnotation.egName)
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, displayValue)
' Set the font properties
ant.FontName = _pdfBurnerParams.FontName
@@ -306,6 +310,48 @@ Namespace Jobs.FinalizeDocument
#End Region
#Region "Helpers"
''' <summary>
''' Truncates a field value so its rendered width fits within the configured max width (inches).
''' Uses Graphics.MeasureString with the configured font for pixel-accurate measurement.
''' Appends TruncationSuffix when the value is cut.
''' </summary>
Private Function TruncateText(value As String, fieldEgName As String) As String
If String.IsNullOrEmpty(value) Then Return value
If _pdfBurnerParams.TextMaxWidths Is Nothing Then Return value
Dim maxWidthInches As Single = 0
If Not _pdfBurnerParams.TextMaxWidths.TryGetValue(fieldEgName, maxWidthInches) Then Return value
If maxWidthInches <= 0 Then Return value
Dim suffix As String = If(_pdfBurnerParams.TruncationSuffix, "")
Using bmp As New Bitmap(1, 1)
Using g As Graphics = Graphics.FromImage(bmp)
Dim font As New Font(_pdfBurnerParams.FontName, _pdfBurnerParams.FontSize, _pdfBurnerParams.FontStyle, GraphicsUnit.Point)
Dim maxWidthPx As Single = maxWidthInches * g.DpiX
' Already fits — no truncation needed
If g.MeasureString(value, font).Width <= maxWidthPx Then Return value
' Binary search for the longest prefix that fits (with suffix)
Dim lo As Integer = 0
Dim hi As Integer = value.Length
While lo < hi
Dim mid As Integer = (lo + hi + 1) \ 2
Dim candidate As String = value.Substring(0, mid) & suffix
If g.MeasureString(candidate, font).Width <= maxWidthPx Then
lo = mid
Else
hi = mid - 1
End If
End While
Return value.Substring(0, lo) & suffix
End Using
End Using
End Function
Private Function ToPointF(pPoints As List(Of Single)) As PointF
Dim oPoints = pPoints.Select(AddressOf ToInches).ToList()
Return New PointF(oPoints.Item(0), oPoints.Item(1))

View File

@@ -12,5 +12,24 @@ Namespace Jobs.FinalizeDocument
Public Property FontSize As Integer = 8
Public Property FontStyle As FontStyle = FontStyle.Italic
''' <summary>
''' The suffix appended to a truncated field value. Defaults to "..".
''' Can be changed to any string (e.g. "…", "...", "").
''' </summary>
Public Property TruncationSuffix As String = ".."
''' <summary>
''' Per-field maximum render width in inches.
''' Text is measured using the configured Font/FontSize via Graphics.MeasureString (pixel-accurate).
''' If the rendered width exceeds the limit, the text is cut and TruncationSuffix is appended.
''' Keys must match egName values used in PDFBurner (e.g. "name", "position", "city", "date").
''' Set a value to 0 to disable truncation for that field.
''' </summary>
Public Property TextMaxWidths As New Dictionary(Of String, Single) From {
{"position", 1.41F},
{"city", 1.41F},
{"date", 2.0F}
}
End Class
End Namespace

View File

@@ -1,5 +1,6 @@
Imports EnvelopeGenerator.Domain.Constants
Imports EnvelopeGenerator.Domain.Entities
Imports EnvelopeGenerator.Domain.Interfaces
Public Class ReportItem
@@ -11,8 +12,15 @@ Public Class ReportItem
Public Property ItemStatus As EnvelopeStatus
Public ReadOnly Property ItemStatusTranslated As String
Get
Dim oStatus = ItemStatus.ToString()
Return My.Resources.Model.ResourceManager.GetString(oStatus)
Dim oStatusKey = ItemStatus.ToString()
If ItemStatus = EnvelopeStatus.DocumentSigned AndAlso
Envelope IsNot Nothing AndAlso
Envelope.IsReadAndConfirm() Then
oStatusKey = "DocumentSignedRaC"
End If
Return My.Resources.Model.ResourceManager.GetString(oStatusKey)
End Get
End Property

View File

@@ -168,6 +168,9 @@
<data name="DocumentSigned" xml:space="preserve">
<value>Document signed</value>
</data>
<data name="DocumentSignedRaC" xml:space="preserve">
<value>Read and confirmed</value>
</data>
<data name="Draft" xml:space="preserve">
<value>Draft</value>
</data>

View File

@@ -168,6 +168,9 @@
<data name="DocumentSigned" xml:space="preserve">
<value>Dokument unterzeichnet</value>
</data>
<data name="DocumentSignedRaC" xml:space="preserve">
<value>Gelesen und bestätigt</value>
</data>
<data name="Draft" xml:space="preserve">
<value>Entwurf</value>
</data>

View File

@@ -1,5 +1,6 @@
#if NETFRAMEWORK
using System;
using System.Collections.Generic;
#endif
namespace EnvelopeGenerator.Domain.Constants
@@ -15,7 +16,11 @@ namespace EnvelopeGenerator.Domain.Constants
public static class Receiver
{
public const string TFA = "EGReceiverTFA";
[Obsolete("Use Receiver.Authorized")]
public const string Full = "EGReceiver";
public static readonly IReadOnlyList<string> Authorized = new[] { "receiver", "EGReceiver" };
}
public const string Sender = "EGSender";

View File

@@ -213,6 +213,15 @@ namespace My.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Gelesen und bestätigt.
/// </summary>
public static string DocumentSignedRaC {
get {
return ResourceManager.GetString("DocumentSignedRaC", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Entwurf.
/// </summary>

View File

@@ -168,6 +168,9 @@
<data name="DocumentSigned" xml:space="preserve">
<value>Dokument unterzeichnet</value>
</data>
<data name="DocumentSignedRaC" xml:space="preserve">
<value>Gelesen und bestätigt</value>
</data>
<data name="Draft" xml:space="preserve">
<value>Entwurf</value>
</data>

View File

@@ -14,6 +14,7 @@ using EnvelopeGenerator.Infrastructure.Repositories;
using EnvelopeGenerator.Infrastructure.Executor;
using EnvelopeGenerator.Application.Common.Interfaces.Repositories;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
#elif NETFRAMEWORK
using System;
@@ -55,6 +56,9 @@ namespace EnvelopeGenerator.Infrastructure
services.TryAddScoped<IEnvelopeTypeRepository, EnvelopeTypeRepository>();
services.TryAddScoped<IReceiverRepository, ReceiverRepository>();
services.TryAddScoped<IEnvelopeReceiverReadOnlyRepository, EnvelopeReceiverReadOnlyRepository>();
// User Preferences — distributed cache (dbo.TBDD_CACHE)
services.TryAddScoped<IUserPreferencesService, UserPreferencesService>();
#endif
services.AddDbRepository(opt =>

View File

@@ -26,6 +26,7 @@
<PackageReference Include="DigitalData.Core.Infrastructure" Version="2.6.1" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="QuestPDF" Version="2025.7.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />

View File

@@ -0,0 +1,46 @@
#if NET
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
namespace EnvelopeGenerator.Infrastructure;
/// <summary>
/// Implements user preferences storage using the distributed cache (dbo.TBDD_CACHE).
/// No DB schema changes required — uses the existing SQL Server distributed cache table.
/// Cache key schema: "signflow.user-preferences:{username}"
/// Expiration: indefinite (no TTL) — preferences persist until explicitly overwritten.
/// </summary>
public sealed class UserPreferencesService(IDistributedCache cache) : IUserPreferencesService
{
private const string KeyPrefix = "signflow.user-preferences:";
private static string BuildKey(string id) => $"{KeyPrefix}{id}";
/// <inheritdoc />
public async Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default)
{
var json = await cache.GetStringAsync(BuildKey(id), ct);
if(json is not null && JsonSerializer.Deserialize<UserPreferencesDto>(json) is UserPreferencesDto pref)
{
return pref;
}
else
{
// init new
await SaveAsync(id, UserPreferencesDto.Default, ct);
return UserPreferencesDto.Default;
}
}
/// <inheritdoc />
public async Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(preferences);
// No expiration — preferences are stored indefinitely.
await cache.SetStringAsync(BuildKey(id), json, new DistributedCacheEntryOptions(), ct);
}
}
#endif

View File

@@ -9,7 +9,7 @@
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>fb2d306b-1042-4a70-31ed-f991a1599371</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\ReceiverUI\net8\$(Version)\EnvelopeGenerator.ReceiverUI.zip</DesktopBuildPackageLocation>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\PreRelease\ReceiverUI\net8\$(Version)\EnvelopeGenerator.ReceiverUI.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>EnvelopeGenerator.ReceiverUI</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId>

View File

@@ -1,28 +1,43 @@
@using EnvelopeGenerator.Server.Client.Services;
@using EnvelopeGenerator.Server.Client.Services
@inherits LayoutComponentBase
@implements IDisposable
<div class="page">
<main>
@inject ThemeService ThemeService
<HeadContent>
<link href="@ThemeService.ThemeCssHref" rel="stylesheet" />
</HeadContent>
<div class="page" data-bs-theme="@ThemeService.BootstrapTheme">
<main>
<article class="content">
@Body
</article>
</main>
<footer class="receiver-footer">
<span>&copy; SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<span class="receiver-footer__sep">&#124;</span>
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
<div class="receiver-footer__left">
<span>&copy; SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<span class="receiver-footer__sep">&#124;</span>
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
</div>
<ThemeControls />
</footer>
</div>
@code {
[Inject] IHttpClientFactory HttpClientFactory { get; set; } = default!;
List<string> RequiredFonts = new() {
"opensans.ttf"
};
private Action? _themeChangeHandler;
protected async override Task OnInitializedAsync() {
await FontLoader.LoadFonts(HttpClientFactory, RequiredFonts);
protected override async Task OnInitializedAsync()
{
await FontLoader.LoadFonts(HttpClientFactory, ["opensans.ttf"]);
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
ThemeService.OnChange += _themeChangeHandler;
await base.OnInitializedAsync();
}
public void Dispose()
=> ThemeService.OnChange -= _themeChangeHandler;
}

View File

@@ -0,0 +1,95 @@
@using EnvelopeGenerator.Server.Client.Services
@rendermode InteractiveWebAssembly
@implements IDisposable
@inject ThemeService ThemeService
<div class="sf-theme-slider">
@* Moon — left end (blazing-dark) *@
<svg class="sf-theme-icon @(ThemeService.ThemeName == "blazing-dark" ? "sf-theme-icon--active" : "")"
xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 24 24"
title="Blazing Dark">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
@* Track wrapper: range input + left/mid/right ticks *@
<div class="sf-theme-track-wrapper">
@* Left tick *@
<div class="sf-theme-tick sf-theme-tick--left"></div>
@* Mid tick (marks the middle position) *@
<div class="sf-theme-tick sf-theme-tick--mid"></div>
@* Right tick *@
<div class="sf-theme-tick sf-theme-tick--right"></div>
<input type="range"
class="sf-theme-range"
min="0" max="2" step="1"
value="@SliderValue"
@oninput="OnSliderChanged"
title="@ThemeService.AvailableThemes[SliderValue].Label" />
</div>
@* Sun — right end (purple) *@
<svg class="sf-theme-icon @(ThemeService.ThemeName == "purple" ? "sf-theme-icon--active" : (ThemeService.ThemeName == "blazing-berry" ? "sf-theme-icon--mid" : ""))"
xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="1.5"
title="Blazing Berry / Purple">
<circle cx="12" cy="12" r="4" fill="currentColor" stroke="none"/>
<line x1="12" y1="2" x2="12" y2="5"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
<line x1="2" y1="12" x2="5" y2="12"/>
<line x1="19" y1="12" x2="22" y2="12"/>
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
</svg>
</div>
@code {
private Action? _themeChangeHandler;
/// <summary>
/// Maps the active theme to a 0-based slider step.
/// 0 = blazing-dark (🌙 left)
/// 1 = blazing-berry (middle)
/// 2 = purple (☀️ right)
/// </summary>
private int SliderValue => ThemeService.ThemeName switch
{
"blazing-dark" => 0,
"purple" => 2,
_ => 1
};
protected override void OnInitialized()
{
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
ThemeService.OnChange += _themeChangeHandler;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await ThemeService.InitializeAsync();
}
private async Task OnSliderChanged(ChangeEventArgs e)
{
if (!int.TryParse(e.Value?.ToString(), out var step)) return;
var themeName = step switch
{
0 => "blazing-dark",
2 => "purple",
_ => "blazing-berry"
};
await ThemeService.SetThemeAsync(themeName);
}
public void Dispose()
=> ThemeService.OnChange -= _themeChangeHandler;
}

View File

@@ -0,0 +1,23 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Client-side DTO mirroring CustomImageEntry on the server.
/// Height is a CSS value string (e.g. "28px") set in appsettings.json.
/// </summary>
public sealed record CustomImageDto(string Src, string Height, Dictionary<string, string> Classes)
{
public CustomImageDto() : this(string.Empty, "28px", new()) { }
/// <summary>Returns the CSS class for the given context key, or empty string if absent.</summary>
public string GetClass(string key) =>
Classes.TryGetValue(key, out var cls) && cls is not null ? cls : string.Empty;
}
/// <summary>Client-side DTO for the full CustomImages config (App + Company logos).</summary>
public sealed record CustomImagesDto(CustomImageDto App, CustomImageDto Company)
{
public CustomImagesDto() : this(new(), new()) { }
}

View File

@@ -5,19 +5,16 @@
@using EnvelopeGenerator.Server.Client.Models
@using DevExpress.Blazor
@using EnvelopeGenerator.Server.Client.Services
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
@using EnvelopeGenerator.Application.Common.Dto
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/sender-page.css")" rel="stylesheet" />
@@ -26,53 +23,84 @@
<div class="sender-action-bar__inner">
<div class="sender-title-section">
<div class="sender-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
</svg>
@* signFLOW logo from appsettings.json — fallback to envelope SVG if not loaded *@
@if (_appLogo is not null && !string.IsNullOrEmpty(_appLogo.Src))
{
<img src="@_appLogo.Src"
style="height: @_appLogo.Height"
class="@_appLogo.GetClass("Main")"
alt="signFLOW" />
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z" />
</svg>
}
</div>
<div class="sender-title">Umschlag-Übersicht</div>
</div>
@* ── Kunden-Logo (Mitte) ───────────────────────────────────────── *@
<div class="sender-customer-logo-area">
@if (_companyLogo is not null && !string.IsNullOrEmpty(_companyLogo.Src))
{
<img src="@_companyLogo.Src"
style="height: @_companyLogo.Height"
class="@_companyLogo.GetClass("Show")"
alt="Kunden-Logo" />
}
else
{
<div class="sf-customer-logo-placeholder">Kunden-Logo</div>
}
</div>
@* ── Toolbar (rechts) ─────────────────────────────────────────── *@
<div class="sender-toolbar">
<button class="sender-btn sender-btn--primary" @onclick="CreateEnvelope" title="Neuen Umschlag erstellen">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</svg>
Neuer Umschlag
</button>
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || IsEnvelopeSent(_selectedEnvelope))" title="Ausgewählten Umschlag bearbeiten">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
</svg>
Bearbeiten
</button>
<button class="sender-btn sender-btn--danger" @onclick="DeleteEnvelope" disabled="@(_selectedEnvelope == null)" title="Ausgewählten Umschlag löschen">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
</svg>
Löschen
</button>
<button class="sender-btn" @onclick="RefreshEnvelopes" disabled="@_isLoading" title="Aktualisieren">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z" />
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z" />
</svg>
@if (_isLoading) {
@if (_isLoading)
{
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
}
</button>
<button class="sender-btn sender-btn--logout" @onclick="LogoutAsync" disabled="@_isLoggingOut" title="Abmelden">
@if (_isLoggingOut) {
@if (_isLoggingOut)
{
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
} else {
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"/>
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z" />
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z" />
</svg>
}
</button>
@@ -81,7 +109,8 @@
</div>
<div class="sender-content">
@if (_isLoading && _allEnvelopes == null) {
@if (_isLoading && _allEnvelopes == null)
{
<div class="d-flex justify-content-center align-items-center h-100">
<div class="text-center">
<div class="spinner-border text-white mb-3" style="width: 3.5rem; height: 3.5rem;" role="status">
@@ -90,13 +119,15 @@
<p class="text-white fw-semibold">Umschläge werden geladen...</p>
</div>
</div>
} else if (_errorMessage != null) {
}
else if (_errorMessage != null)
{
<div class="error-container">
<div class="alert alert-danger shadow-lg">
<div class="d-flex align-items-start">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="me-3 flex-shrink-0" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
</svg>
<div>
<h5 class="mb-2">Fehler beim Laden der Umschläge</h5>
@@ -105,25 +136,39 @@
</div>
</div>
</div>
} else {
}
else
{
<div class="sender-grid-container">
<div class="sender-tabs">
<button class="sender-tab @(_activeTab == "active" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "active"'>
<span>Aktive Umschläge</span>
@if (_activeEnvelopes != null) {
@if (_activeEnvelopes != null)
{
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_activeEnvelopes.Count())</span>
}
</button>
<button class="sender-tab @(_activeTab == "completed" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "completed"'>
<span>Abgeschlossene Umschläge</span>
@if (_completedEnvelopes != null) {
@if (_completedEnvelopes != null)
{
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_completedEnvelopes.Count())</span>
}
</button>
</div>
<div class="sender-grid-wrapper">
@if (_activeTab == "active") {
@* Grid persistence hint — informs users their layout is auto-saved *@
<div class="sf-grid-persistence-hint">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
</svg>
Die Größe und Position der Spalten wird automatisch für Sie gespeichert.
</div>
@if (_activeTab == "active")
{
<DxGrid Data="@_activeEnvelopes"
@ref="_gridActive"
ShowFilterRow="true"
@@ -136,7 +181,9 @@
SelectionMode="GridSelectionMode.Single"
SelectedDataItem="@_selectedEnvelope"
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement">
CustomizeElement="OnCustomizeElement"
LayoutAutoLoading="OnActiveGridLayoutAutoLoading"
LayoutAutoSaving="OnActiveGridLayoutAutoSaving">
<Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"
SortIndex="0"
@@ -154,7 +201,8 @@
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
if (envelope != null)
{
var statusInfo = GetStatusInfo(envelope.Status);
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@@ -168,7 +216,8 @@
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
if (envelope != null)
{
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
var signed = receivers.Count(r => r.Signed);
var total = receivers.Count;
@@ -176,7 +225,8 @@
<span style="font-size: 0.875rem; color: #6b7280;">
@signed / @total unterschrieben
</span>
@if (total > 0) {
@if (total > 0)
{
<div style="flex: 1; min-width: 60px; max-width: 120px; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden;">
<div style="height: 100%; background: linear-gradient(90deg, #81c784 0%, #66bb6a 100%); width: @((signed * 100.0 / total).ToString("F0"))%;"></div>
</div>
@@ -186,26 +236,37 @@
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="Typ"
Width="120px" AllowSort="true" />
<DxGridDataColumn FieldName="AddedWhen" Caption="Erstellt"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
<DxGridDataColumn FieldName="ChangedWhen" Caption="Geändert am"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
</Columns>
<DetailRowTemplate Context="detailContext">
<div style="padding: 1rem; background: #f9fafb;">
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">Empfänger</h6>
@{
var envelope = detailContext.DataItem as EnvelopeDto;
if (envelope?.EnvelopeReceivers?.Any() == true) {
if (envelope?.EnvelopeReceivers?.Any() == true)
{
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
@foreach (var receiver in envelope.EnvelopeReceivers) {
@foreach (var receiver in envelope.EnvelopeReceivers)
{
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
<span class="receiver-badge receiver-badge--@(receiver.Signed ? "signed" : "unsigned")" style="min-width: 100px;">
@if (receiver.Signed) {
@if (receiver.Signed)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</svg>
<span>Unterschrieben</span>
} else {
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
</svg>
<span>Ausstehend</span>
}
@@ -217,14 +278,18 @@
</div>
}
</div>
} else {
}
else
{
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
}
}
</div>
</DetailRowTemplate>
</DxGrid>
} else {
}
else
{
<DxGrid Data="@_completedEnvelopes"
@ref="_gridCompleted"
ShowFilterRow="true"
@@ -234,7 +299,9 @@
SelectionMode="GridSelectionMode.Single"
SelectedDataItem="@_selectedEnvelope"
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement">
CustomizeElement="OnCustomizeElement"
LayoutAutoLoading="OnCompletedGridLayoutAutoLoading"
LayoutAutoSaving="OnCompletedGridLayoutAutoSaving">
<Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"
SortIndex="0"
@@ -252,7 +319,8 @@
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
if (envelope != null)
{
var statusInfo = GetStatusInfo(envelope.Status);
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@@ -266,7 +334,8 @@
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
if (envelope != null)
{
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
var signed = receivers.Count(r => r.Signed);
var total = receivers.Count;
@@ -274,7 +343,8 @@
<span style="font-size: 0.875rem; color: #6b7280;">
@signed / @total unterschrieben
</span>
@if (total > 0) {
@if (total > 0)
{
<div style="flex: 1; min-width: 60px; max-width: 120px; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden;">
<div style="height: 100%; background: linear-gradient(90deg, #81c784 0%, #66bb6a 100%); width: @((signed * 100.0 / total).ToString("F0"))%;"></div>
</div>
@@ -284,26 +354,37 @@
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="Typ"
Width="120px" AllowSort="true" />
<DxGridDataColumn FieldName="AddedWhen" Caption="Erstellt"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
<DxGridDataColumn FieldName="ChangedWhen" Caption="Geändert am"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
</Columns>
<DetailRowTemplate Context="detailContext">
<div style="padding: 1rem; background: #f9fafb;">
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">Empfänger</h6>
@{
var envelope = detailContext.DataItem as EnvelopeDto;
if (envelope?.EnvelopeReceivers?.Any() == true) {
if (envelope?.EnvelopeReceivers?.Any() == true)
{
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
@foreach (var receiver in envelope.EnvelopeReceivers) {
@foreach (var receiver in envelope.EnvelopeReceivers)
{
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
<span class="receiver-badge receiver-badge--@(receiver.Signed ? "signed" : "unsigned")" style="min-width: 100px;">
@if (receiver.Signed) {
@if (receiver.Signed)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</svg>
<span>Unterschrieben</span>
} else {
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
</svg>
<span>Ausstehend</span>
}
@@ -315,7 +396,9 @@
</div>
}
</div>
} else {
}
else
{
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
}
}
@@ -341,6 +424,14 @@
private DxGrid? _gridActive;
private DxGrid? _gridCompleted;
// Logo config loaded from /api/CustomImages after auth
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _appLogo;
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _companyLogo;
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
private const string GridKeyActive = "sender.active-envelopes";
private const string GridKeyCompleted = "sender.completed-envelopes";
protected override async Task OnInitializedAsync()
{
var hasAccess = await AuthService.CheckSenderAccessAsync();
@@ -350,6 +441,17 @@
return;
}
// Load theme + DisplayName from server after authentication.
// Non-fatal: if this fails the page still loads with default theme.
// ThemeService.ScheduleSaveAsync() already logs errors internally.
try { await ThemeService.LoadFromServerAsync(); }
catch { /* swallow — default theme remains active */ }
// Load logo config — authenticated, so this should succeed
var logos = await CustomImagesService.GetAsync();
_appLogo = logos.App;
_companyLogo = logos.Company;
await LoadEnvelopesAsync();
}
@@ -367,13 +469,10 @@
var envelopes = _allEnvelopes.ToList();
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
await JSRuntime.InvokeVoidAsync("console.log", $"Loaded {_activeEnvelopes.Count()} active and {_completedEnvelopes.Count()} completed envelopes");
}
catch (Exception ex)
{
_errorMessage = ex.Message;
await JSRuntime.InvokeVoidAsync("console.error", "Fehler beim Laden der Umschläge:", ex.ToString());
}
finally
{
@@ -396,20 +495,19 @@
{
if (_selectedEnvelope == null) return;
// TODO: Navigate to envelope editor
JSRuntime.InvokeVoidAsync("console.log", $"Edit envelope {_selectedEnvelope.Id} clicked - not yet implemented");
}
void DeleteEnvelope()
{
if (_selectedEnvelope == null) return;
// TODO: Show delete confirmation dialog
JSRuntime.InvokeVoidAsync("console.log", $"Delete envelope {_selectedEnvelope.Id} clicked - not yet implemented");
}
async Task LogoutAsync()
{
_isLoggingOut = true;
await InvokeAsync(StateHasChanged);
UserPreferencesService.InvalidateCache();
await AuthService.LogoutSenderAsync();
Navigation.NavigateTo("/sender/login", forceLoad: true);
}
@@ -446,4 +544,45 @@
{
_selectedEnvelope = envelope as EnvelopeDto;
}
// ── Grid Layout Persistence ───────────────────────────────────────────────
// LayoutAutoLoading fires when the grid initializes — restore saved layout.
// LayoutAutoSaving fires when the user changes column order/width/sort — persist it.
// UserPreferencesService.GetAsync() uses the 1h in-memory cache → 0 HTTP on restore.
// UserPreferencesService.SaveGridLayoutAsync() merges the single key → 1 PUT.
async Task OnActiveGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
{
// Use cached preferences only — avoid HTTP during grid initialization.
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyActive, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnActiveGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
try
{
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(GridKeyActive, json);
}
catch (Exception ex)
{
await JSRuntime.InvokeVoidAsync("console.warn", $"[Sender] Grid layout save failed: {ex.Message}");
}
}
async Task OnCompletedGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
{
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyCompleted, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnCompletedGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(GridKeyCompleted, json);
}
}

View File

@@ -1,17 +1,27 @@
@page "/"
@inject IJSRuntime JS
@inject NavigationManager Navigation
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@rendermode InteractiveWebAssembly
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<div class="home-page-wrapper">
<div class="home-hero-header">
<div class="home-hero-header__inner">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="home-hero-header__icon" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
</svg>
@* signFLOW logo from appsettings.json CustomImages.App — fallback to SVG if not loaded *@
@if (_appLogo is not null && !string.IsNullOrEmpty(_appLogo.Src))
{
<img src="@_appLogo.Src"
style="height: @_appLogo.Height"
class="home-hero-header__icon @_appLogo.GetClass("Main")"
alt="signFLOW" />
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="home-hero-header__icon" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
</svg>
}
<div>
<h1 class="home-hero-header__title">SignFlow</h1>
<p class="home-hero-header__subtitle">Willkommen im eSign-Portal</p>
@@ -75,11 +85,18 @@
"Mit seiner benutzerfreundlichen Oberfläche können Sie Ihre Dokumente schnell hochladen, die Unterschriftsprozesse verfolgen und Ihre digitalen Unterschriftenanwendungen einfach durchführen. " +
"Dieses Portal beschleunigt Ihren Arbeitsablauf mit rechtlich gültigen Unterschriften und erhöht gleichzeitig die Sicherheit Ihrer Dokumente.";
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _appLogo;
protected override async Task OnInitializedAsync()
{
// Load logo config early — 401 here is normal (not yet logged in), service returns defaults silently
var logos = await CustomImagesService.GetAsync();
_appLogo = logos.App;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("receiverSignature.startTyped", "home-description", HomePageDescription, 15);
}
if (!firstRender) return;
await JS.InvokeVoidAsync("receiverSignature.startTyped", "home-description", HomePageDescription, 15);
}
}

View File

@@ -4,8 +4,6 @@
@inject AuthService AuthService
@inject NavigationManager Navigation
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
@@ -15,14 +13,14 @@
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
</svg>
</div>
<h5 class="mb-0 fw-semibold">Dokument öffnen</h5>
<h5 class="mb-0 fw-semibold">Dokument öffnen</h5>
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">Sicherer Zugang mit Zugangscode</p>
</div>
<div class="card-body p-4">
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.
Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.
</p>
@if (LoginResult == EnvelopeLoginResult.NotFound)
@@ -33,7 +31,7 @@
</svg>
<div>
<strong>Dokument nicht gefunden.</strong><br />
<span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span>
<span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span>
</div>
</div>
}
@@ -44,7 +42,7 @@
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
<div>
<strong>Ungültiger Zugangscode.</strong><br />
<strong>Ungültiger Zugangscode.</strong><br />
<span style="font-size:0.85rem;">Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</span>
</div>
</div>
@@ -58,7 +56,7 @@
</svg>
<div>
<strong>Serverfehler.</strong><br />
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
</div>
</div>
}
@@ -115,14 +113,14 @@
@if (IsLoading)
{
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Überprüfen …</span>
<span>Überprüfen …</span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
</svg>
<span>Dokument öffnen</span>
<span>Dokument öffnen</span>
}
</button>

View File

@@ -4,8 +4,6 @@
@inject AuthService AuthService
@inject NavigationManager Navigation
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
@@ -32,7 +30,7 @@
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>
<div>
<strong>Ungültige Anmeldedaten.</strong><br />
<strong>Ungültige Anmeldedaten.</strong><br />
<span style="font-size:0.85rem;">Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</span>
</div>
</div>
@@ -44,7 +42,7 @@
</svg>
<div>
<strong>Serverfehler.</strong><br />
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
</div>
</div>
}
@@ -120,7 +118,7 @@
disabled="@(IsLoading || string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))">
@if (IsLoading) {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Anmelden …</span>
<span>Anmelden …</span>
} else {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0v-2z"/>

View File

@@ -34,6 +34,13 @@ builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddScoped<EnvelopeService>();
// UI Preferences & Theme
builder.Services.AddScoped<UserPreferencesService>();
builder.Services.AddScoped<ThemeService>();
// Logo configuration (loaded once after login, in-memory cached)
builder.Services.AddScoped<CustomImagesService>();
// DevExpress WASM
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();
builder.Services.AddDevExpressWebAssemblyBlazorReportViewer();

View File

@@ -0,0 +1,55 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Fetches and caches the CustomImages configuration (signFLOW + company logos)
/// from GET /api/CustomImages.
///
/// Config changes rarely (deployment-time only), so results are cached in-memory
/// for the lifetime of the service (WASM app session). No TTL needed.
/// </summary>
public class CustomImagesService(IHttpClientFactory clientFactory)
{
private static readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
private const string Endpoint = "/api/CustomImages";
private CustomImagesDto? _cached;
/// <summary>
/// Returns logo configuration. Fetches once from server; all subsequent calls
/// are served from memory with zero network overhead.
/// Returns defaults (empty Src) on auth failure (401) — components must handle gracefully.
/// </summary>
public async Task<CustomImagesDto> GetAsync(CancellationToken ct = default)
{
if (_cached is not null)
return _cached;
try
{
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.GetAsync(Endpoint, ct);
if (!response.IsSuccessStatusCode)
{
// Not authenticated yet (login page) — return defaults silently.
_cached = new CustomImagesDto();
return _cached;
}
_cached = await response.Content.ReadFromJsonAsync<CustomImagesDto>(_json, ct)
?? new CustomImagesDto();
return _cached;
}
catch (Exception)
{
// Network error or server unavailable — return defaults so UI still renders.
return new CustomImagesDto();
}
}
/// <summary>Call after logout to force re-fetch on next login.</summary>
public void Invalidate() => _cached = null;
}

View File

@@ -0,0 +1,232 @@
using Microsoft.JSInterop;
using Microsoft.Extensions.Logging;
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Manages the active DevExpress theme for the WASM client.
///
/// Theme selection is the sole UI control — no separate dark/light toggle.
/// Themes ordered dark → light: blazing-dark | blazing-berry | purple
///
/// Initialization:
/// 1. InitializeAsync() — reads localStorage["signflow.theme"], falls back to
/// system prefers-color-scheme (dark → blazing-dark, light → blazing-berry).
/// 2. LoadFromServerAsync() — called after login, syncs theme from server cache.
///
/// Persistence:
/// - SetThemeAsync() → debounced 500ms PUT /api/UserPreferences.
/// - ScheduleSaveAsync() uses GetCached() — no extra GET before each save.
///
/// Consumers subscribe to OnChange and call StateHasChanged (e.g. MainLayout).
/// </summary>
public class ThemeService : IAsyncDisposable
{
// ── Allowed themes (ordered dark → light for range slider) ───────────────
// DevExpress.Blazor.Themes 25.2.3: blazing-berry, blazing-dark, purple
public static readonly IReadOnlyList<ThemeOption> AvailableThemes =
[
new("Blazing Dark", "blazing-dark"), // index 0 — 🌙
new("Blazing Berry", "blazing-berry"), // index 1 — middle
new("Purple", "purple"), // index 2 — ☀️
];
private const string LocalStorageThemeKey = "signflow.theme";
// ── State ─────────────────────────────────────────────────────────────────
private string _themeName = "blazing-berry";
private bool _initialized = false;
// ── Dependencies ──────────────────────────────────────────────────────────
private readonly IJSRuntime _js;
private readonly UserPreferencesService _preferencesService;
private readonly ILogger<ThemeService> _logger;
// ── Debounce ──────────────────────────────────────────────────────────────
private CancellationTokenSource? _saveCts;
// ── Change & error notification ───────────────────────────────────────────
/// <summary>Fired whenever theme changes. Subscribers call StateHasChanged.</summary>
public event Action? OnChange;
/// <summary>
/// Set when a background save fails. UI layer can surface this as a warning.
/// Cleared on the next successful save or theme change.
/// </summary>
public Exception? SaveError { get; private set; }
public ThemeService(IJSRuntime js, UserPreferencesService preferencesService, ILogger<ThemeService> logger)
{
_js = js;
_preferencesService = preferencesService;
_logger = logger;
}
// ── Public state ──────────────────────────────────────────────────────────
public string ThemeName => _themeName;
/// <summary>True when blazing-dark is active — used for Bootstrap data-bs-theme.</summary>
public bool IsDarkMode => _themeName == "blazing-dark";
public string? DisplayName { get; private set; }
/// <summary>
/// CSS href for the active DevExpress theme.
/// Example: "_content/DevExpress.Blazor.Themes/blazing-dark.bs5.min.css"
/// </summary>
public string ThemeCssHref =>
$"_content/DevExpress.Blazor.Themes/{_themeName}.bs5.min.css";
/// <summary>Bootstrap 5 data-bs-theme value. Only "dark" for blazing-dark.</summary>
public string BootstrapTheme => _themeName == "blazing-dark" ? "dark" : "light";
// ── Initialization ────────────────────────────────────────────────────────
/// <summary>
/// Called once in OnAfterRenderAsync(firstRender: true).
/// Reads theme from localStorage; falls back to system dark-mode preference.
/// Safe to call multiple times — subsequent calls are no-ops.
/// </summary>
public async Task InitializeAsync()
{
if (_initialized) return;
_initialized = true;
var stored = await _js.InvokeAsync<string?>("localStorage.getItem", LocalStorageThemeKey);
if (stored is not null && AvailableThemes.Any(t => t.Value == stored))
{
_themeName = stored;
}
else
{
// No stored theme — use system preference to pick dark or light
var prefersDark = await _js.InvokeAsync<bool>(
"eval",
"(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()");
_themeName = prefersDark ? "blazing-dark" : "blazing-berry";
}
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
/// <summary>
/// Called after successful sender login.
/// Syncs theme from the server cache; on first login propagates local theme to server.
/// </summary>
public async Task LoadFromServerAsync(CancellationToken ct = default)
{
var prefs = await _preferencesService.GetAsync(ct);
var isFirstLogin = prefs.GridLayouts.Count == 0
&& prefs.ThemeName == "blazing-berry"
&& !prefs.IsDarkMode;
if (isFirstLogin && _themeName != "blazing-berry")
{
// Propagate locally-detected theme to server on first login
prefs.ThemeName = _themeName;
prefs.IsDarkMode = IsDarkMode;
prefs = await _preferencesService.SaveAsync(prefs, ct);
}
ApplyPreferences(prefs);
// Persist resolved theme to localStorage
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
DisplayName = prefs.DisplayName;
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
// ── Mutation ──────────────────────────────────────────────────────────────
/// <summary>
/// Sets the active theme, applies it immediately, and schedules a server save.
/// State change is applied regardless of whether the save succeeds.
/// </summary>
public async Task SetThemeAsync(string themeName)
{
if (themeName == _themeName) return;
if (!AvailableThemes.Any(t => t.Value == themeName))
throw new ArgumentException($"Theme '{themeName}' is not allowed.", nameof(themeName));
_themeName = themeName;
SaveError = null;
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
await ScheduleSaveAsync();
}
// ── Private helpers ───────────────────────────────────────────────────────
private void ApplyPreferences(UserPreferencesDto prefs)
{
_themeName = AvailableThemes.Any(t => t.Value == prefs.ThemeName)
? prefs.ThemeName
: "blazing-berry";
// Legacy: if IsDarkMode flag was saved but ThemeName wasn't updated
if (prefs.IsDarkMode && _themeName == "blazing-berry")
_themeName = "blazing-dark";
}
private async Task ApplyThemeToDocumentAsync()
{
try
{
await _js.InvokeVoidAsync("signflowTheme.apply", ThemeCssHref, BootstrapTheme, _themeName);
}
catch (Exception ex)
{
// JS unavailable during server prerender — <HeadContent> link is the SSR fallback.
_logger.LogDebug(ex, "[ThemeService] ApplyThemeToDocumentAsync skipped (JS unavailable).");
}
}
private async Task ScheduleSaveAsync()
{
_saveCts?.Cancel();
_saveCts = new CancellationTokenSource();
try
{
await Task.Delay(500, _saveCts.Token);
var prefs = _preferencesService.GetCached() ?? new UserPreferencesDto();
prefs.ThemeName = _themeName;
prefs.IsDarkMode = IsDarkMode;
await _preferencesService.SaveAsync(prefs, _saveCts.Token);
}
catch (TaskCanceledException)
{
// Newer save scheduled — intentionally cancelled.
}
catch (Exception ex)
{
_logger.LogError(ex, "Background save of user preferences failed.");
SaveError = ex;
OnChange?.Invoke();
}
}
public ValueTask DisposeAsync()
{
_saveCts?.Cancel();
_saveCts?.Dispose();
return ValueTask.CompletedTask;
}
}
/// <summary>Describes a selectable DevExpress theme.</summary>
/// <param name="Label">User-facing display name.</param>
/// <param name="Value">CSS theme identifier.</param>
public sealed record ThemeOption(string Label, string Value);

View File

@@ -0,0 +1,136 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
using Microsoft.Extensions.Logging;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Retrieves and persists user UI preferences via the server API.
/// The server stores preferences in the distributed cache (dbo.TBDD_CACHE).
///
/// Client-side memory cache:
/// Preferences are cached in-memory for <see cref="CacheDuration"/> after the first fetch.
/// All reads (GetAsync, GetCachedAsync) are served from cache after the first load.
/// Writes (SaveAsync) update both the cache and the server atomically.
/// Cache is invalidated explicitly via InvalidateCache().
/// </summary>
public class UserPreferencesService(
IHttpClientFactory clientFactory,
ILogger<UserPreferencesService> logger)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private const string Endpoint = "/api/UserPreferences";
// ── Client-side memory cache ──────────────────────────────────────────────
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
private UserPreferencesDto? _cached;
private DateTimeOffset _cachedAt = DateTimeOffset.MinValue;
private bool IsCacheValid => _cached is not null
&& DateTimeOffset.UtcNow - _cachedAt < CacheDuration;
/// <summary>
/// Returns preferences from in-memory cache if valid; otherwise fetches from the server.
/// On first call after login this issues one HTTP GET — all subsequent calls within
/// <see cref="CacheDuration"/> are served from memory with zero network overhead.
/// </summary>
public async Task<UserPreferencesDto> GetAsync(CancellationToken ct = default)
{
if (IsCacheValid)
return _cached!;
try
{
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.GetAsync(Endpoint, ct);
// Not authenticated yet (login page) or no preferences saved — return defaults.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound ||
response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
SetCache(new UserPreferencesDto());
return _cached!;
}
response.EnsureSuccessStatusCode();
var dto = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
?? new UserPreferencesDto();
SetCache(dto);
return _cached!;
}
catch (Exception ex) when (ex is not TaskCanceledException)
{
logger.LogError(ex, "Failed to load user preferences from server.");
throw;
}
}
/// <summary>
/// Returns the cached preferences without hitting the network.
/// Returns null if the cache has not been populated yet (before first GetAsync call).
/// </summary>
public UserPreferencesDto? GetCached() => IsCacheValid ? _cached : null;
/// <summary>
/// Persists the given preferences on the server and updates the in-memory cache.
/// </summary>
public async Task<UserPreferencesDto> SaveAsync(UserPreferencesDto preferences, CancellationToken ct = default)
{
try
{
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.PutAsJsonAsync(Endpoint, preferences, _jsonOptions, ct);
// Not authenticated (login page) — skip save, keep local state only.
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
return preferences;
response.EnsureSuccessStatusCode();
var saved = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
?? preferences;
SetCache(saved);
return saved;
}
catch (Exception ex) when (ex is not TaskCanceledException)
{
logger.LogError(ex, "Failed to save user preferences to server.");
throw;
}
}
/// <summary>
/// Updates a single grid layout key without a network read.
/// Uses the in-memory cache for the current state, merges the new layout, then saves.
/// If the cache is empty (cold start), fetches from server first.
/// </summary>
/// <param name="gridKey">Stable grid identifier, e.g. "sender.active-envelopes".</param>
/// <param name="layoutJson">Opaque layout JSON produced by DxGrid.SaveLayoutAsync().</param>
public async Task SaveGridLayoutAsync(string gridKey, string layoutJson, CancellationToken ct = default)
{
// Use cached copy — avoids a GET before every grid layout save.
var prefs = GetCached() ?? await GetAsync(ct);
prefs.GridLayouts[gridKey] = layoutJson;
await SaveAsync(prefs, ct);
}
/// <summary>
/// Invalidates the in-memory cache, forcing the next GetAsync to fetch from the server.
/// Call this after logout or when a forced refresh is needed.
/// </summary>
public void InvalidateCache()
{
_cached = null;
_cachedAt = DateTimeOffset.MinValue;
}
private void SetCache(UserPreferencesDto dto)
{
_cached = dto;
_cachedAt = DateTimeOffset.UtcNow;
}
}

View File

@@ -10,6 +10,23 @@
<link rel="stylesheet" href="css/envelope-viewer.css" />
<link rel="stylesheet" href="EnvelopeGenerator.Server.styles.css" />
<HeadOutlet />
<!-- Apply saved theme immediately on page load — before WASM boots.
Reads localStorage["signflow.theme"] to avoid flash of default theme. -->
<script>
(function () {
var allowed = { 'blazing-dark': true, 'blazing-berry': true, 'purple': true };
var theme = localStorage.getItem('signflow.theme');
if (!theme || !allowed[theme]) theme = 'blazing-berry';
var bsTheme = theme === 'blazing-dark' ? 'dark' : 'light';
document.documentElement.setAttribute('data-sf-theme', theme);
document.documentElement.setAttribute('data-bs-theme', bsTheme);
var link = document.createElement('link');
link.id = 'dx-theme-link';
link.rel = 'stylesheet';
link.href = '_content/DevExpress.Blazor.Themes/' + theme + '.bs5.min.css';
document.head.appendChild(link);
})();
</script>
</head>
<body>
@@ -17,6 +34,8 @@
<script src="_content/DevExpress.Blazor.Resources/js/preload-script.js"></script>
<script src="js/typed.umd.js"></script>
<script src="js/receiver-signature.js?v=9"></script>
<script src="js/signflow-theme.js"></script>
<script src="js/envelope-editor.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>

View File

@@ -81,8 +81,8 @@
@if (_signatureFields.Count > 0)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem;
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
@_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "")
</span>
}
@@ -118,8 +118,8 @@
<div class="sender-receivers-list">
@foreach (var receiver in _receivers)
{
<div class="sender-receiver-chip"
style="border-left: 3px solid @receiver.Color;">
<div class="sender-receiver-chip"
style="border-left: 3px solid @receiver.Color;">
<div class="sender-receiver-chip__body">
<div class="sender-receiver-chip__name">@receiver.FullName</div>
<div class="sender-receiver-chip__email">@receiver.Email</div>
@@ -130,7 +130,7 @@
</div>
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
@onclick="() => ActivatePlacementForReceiver(receiver)"
style="@(_pendingReceiverForPlacement?.Id == receiver.Id ? $"background:{receiver.Color};color:#fff;border-color:{receiver.Color};" : $"color:{receiver.Color};border-color:{receiver.Color};")"
title="Signaturfeld platzieren">
@@ -211,7 +211,7 @@
@if (_pendingReceiverForPlacement is not null)
{
<div style="background: @_pendingReceiverForPlacement.Color; color: white; font-size: 0.75rem; font-weight: 500;
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
📌 Klicken Sie auf die Stelle im Dokument für <strong>@_pendingReceiverForPlacement.FullName</strong>.
&nbsp;<button @onclick="CancelPlacement"
style="background:none;border:none;color:white;cursor:pointer;font-size:0.75rem;text-decoration:underline;padding:0;">
@@ -229,8 +229,8 @@
<div class="d-flex justify-content-center align-items-center h-100">
<div class="text-center" style="max-width: 420px;">
<div style="width: 72px; height: 72px; background: rgba(255,255,255,0.15);
border-radius: 50%; display: flex; align-items: center;
justify-content: center; margin: 0 auto 1.25rem;">
border-radius: 50%; display: flex; align-items: center;
justify-content: center; margin: 0 auto 1.25rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="white" viewBox="0 0 16 16">
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
</svg>
@@ -316,13 +316,13 @@
@if (_receiverEmailSuggestions.Count > 0)
{
<div class="sender-receiver-popup__suggestions">
<DxListBox TData="string"
TValue="string"
Data="@_receiverEmailSuggestions"
Value="@_selectedReceiverEmailSuggestion"
ValueChanged="OnReceiverEmailSuggestionCommittedAsync"
SelectionMode="ListBoxSelectionMode.Single"
CssClass="w-100" />
<DxListBox TData="string"
TValue="string"
Data="@_receiverEmailSuggestions"
Value="@_selectedReceiverEmailSuggestion"
ValueChanged="OnReceiverEmailSuggestionCommittedAsync"
SelectionMode="ListBoxSelectionMode.Single"
CssClass="w-100" />
</div>
}
</div>
@@ -370,9 +370,9 @@
{
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
<div style="flex-shrink:0;width:40px;height:40px;background:#d1fae5;border-radius:50%;
display:flex;align-items:center;justify-content:center;">
display:flex;align-items:center;justify-content:center;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#065f46" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</svg>
</div>
<div>
@@ -389,10 +389,10 @@
{
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
<div style="flex-shrink:0;width:40px;height:40px;background:#fee2e2;border-radius:50%;
display:flex;align-items:center;justify-content:center;">
display:flex;align-items:center;justify-content:center;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#991b1b" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
</svg>
</div>
<div>
@@ -413,7 +413,7 @@
<button class="btn btn-primary"
@onclick="GoToDashboard"
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
Zur Übersicht
</button>
}
@@ -436,7 +436,7 @@
// ── Constants ──
// Signature field size in PDF points (fixed): 1.77" × 1.96"
const double SigWidthPt = 1.77 * 72; // 127.44 pt
const double SigWidthPt = 1.77 * 72; // 127.44 pt
const double SigHeightPt = 1.96 * 72; // 141.12 pt
// CssClass for DxPdfViewer — used by JS to locate page elements
@@ -447,8 +447,8 @@
// ── State ──
DxPdfViewer? _pdfViewer;
bool _pdfLoaded = false;
string _fileName = string.Empty;
bool _pdfLoaded = false;
string _fileName = string.Empty;
string? _errorMessage;
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
@@ -457,25 +457,25 @@
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
// ── Save state ──
bool _isSaving = false;
bool _savePopupVisible = false;
string? _saveErrorMessage = null;
bool _isSaving = false;
bool _savePopupVisible = false;
string? _saveErrorMessage = null;
// ── Envelope metadata ──
string _envelopeTitle = string.Empty;
string _envelopeTitle = string.Empty;
string _envelopeMessage = string.Empty;
bool _titleTouched = false;
bool _titleTouched = false;
List<ReceiverDraft> _receivers = [];
bool _receiverPopupVisible;
string _receiverDraftName = string.Empty;
string _receiverDraftEmail = string.Empty;
bool _receiverPopupVisible;
string _receiverDraftName = string.Empty;
string _receiverDraftEmail = string.Empty;
string _receiverDraftPhoneNumber = string.Empty;
string? _selectedReceiverEmailSuggestion;
string? _receiverPopupValidationMessage;
bool _isReceiverEmailSearchRunning;
bool _isReceiverEmailSearchRunning;
List<string> _receiverEmailSuggestions = [];
int _receiverEmailSearchVersion;
int _receiverEmailSearchVersion;
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
@@ -512,12 +512,12 @@
&& cached is not null)
{
_originalPdfBytes = cached.OriginalPdfBytes;
_signatureFields = cached.Fields;
_fileName = cached.FileName;
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
_receivers = cached.Receivers;
_envelopeTitle = cached.Title;
_envelopeMessage = cached.Message;
_signatureFields = cached.Fields;
_fileName = cached.FileName;
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
_receivers = cached.Receivers;
_envelopeTitle = cached.Title;
_envelopeMessage = cached.Message;
// Redraw placeholders onto the original PDF
if (_pdfLoaded)
@@ -545,8 +545,8 @@
await file.OpenReadStream(maxBytes).CopyToAsync(ms);
_originalPdfBytes = ms.ToArray();
_fileName = file.Name;
_pdfLoaded = true;
_fileName = file.Name;
_pdfLoaded = true;
_signatureFields.Clear();
_pendingReceiverForPlacement = null;
@@ -597,12 +597,12 @@
double pageHeightPt;
try
{
using var ms = new System.IO.MemoryStream(_originalPdfBytes);
var doc = PdfSharp.Pdf.IO.PdfReader.Open(ms, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import);
using var ms = new System.IO.MemoryStream(_originalPdfBytes);
var doc = PdfSharp.Pdf.IO.PdfReader.Open(ms, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import);
int pageIndex = Math.Max(0, Math.Min(coords.PageIndex, doc.PageCount - 1));
var page = doc.Pages[pageIndex];
pageWidthPt = page.Width.Point;
pageHeightPt = page.Height.Point;
var page = doc.Pages[pageIndex];
pageWidthPt = page.Width.Point;
pageHeightPt = page.Height.Point;
}
catch (Exception ex)
{
@@ -614,17 +614,17 @@
double xPt = coords.NormX * pageWidthPt;
double yPt = coords.NormY * pageHeightPt;
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - SigWidthPt));
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - SigWidthPt));
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - SigHeightPt));
int page1Based = coords.PageIndex + 1;
var field = new SignatureFieldDraft(
XPt: xPt,
YPt: yPt,
Page: page1Based,
XPt: xPt,
YPt: yPt,
Page: page1Based,
ReceiverName: _pendingReceiverForPlacement.FullName,
Color: _pendingReceiverForPlacement.Color);
Color: _pendingReceiverForPlacement.Color);
_signatureFields.Add(field);
_pendingReceiverForPlacement = null;
@@ -711,7 +711,7 @@
string.Join(", ", receiversWithoutField.Select(r => r.FullName)));
}
_isSaving = true;
_isSaving = true;
_saveErrorMessage = null;
await InvokeAsync(StateHasChanged);
@@ -728,16 +728,16 @@
var fields = _signatureFields
.Where(f => f.ReceiverName == receiver.FullName)
.Select(f => new DocReceiverElementCreateDto(
X: f.XPt / 72.0,
Y: f.YPt / 72.0,
X: f.XPt / 72.0,
Y: f.YPt / 72.0,
Page: f.Page))
.ToList();
return new ReceiverGetOrCreateCommand
{
EmailAddress = receiver.Email,
Salution = receiver.FullName,
PhoneNumber = string.IsNullOrWhiteSpace(receiver.PhoneNumber)
EmailAddress = receiver.Email,
Salution = receiver.FullName,
PhoneNumber = string.IsNullOrWhiteSpace(receiver.PhoneNumber)
? null
: receiver.PhoneNumber,
DocReceiverElements = fields,
@@ -746,13 +746,13 @@
var command = new CreateEnvelopeReceiverCommand
{
Title = _envelopeTitle.Trim(),
Message = string.IsNullOrWhiteSpace(_envelopeMessage)
Title = _envelopeTitle.Trim(),
Message = string.IsNullOrWhiteSpace(_envelopeMessage)
? "Bitte unterzeichnen Sie das beigefügte Dokument."
: _envelopeMessage.Trim(),
TFAEnabled = false,
Document = new DocumentCreateCommand { DataAsBase64 = docBase64 },
Receivers = receiversCmd,
Document = new DocumentCreateCommand { DataAsBase64 = docBase64 },
Receivers = receiversCmd,
};
var result = await EnvelopeReceiverService.CreateAsync(command);
@@ -784,11 +784,11 @@
var data = new EditorSessionData(
OriginalPdfBytes: _originalPdfBytes ?? [],
Fields: [.. _signatureFields],
FileName: _fileName,
Receivers: [.. _receivers],
Title: _envelopeTitle,
Message: _envelopeMessage);
Fields: [.. _signatureFields],
FileName: _fileName,
Receivers: [.. _receivers],
Title: _envelopeTitle,
Message: _envelopeMessage);
MemoryCache.Set(SessionKey, data, SessionTtl);
}
@@ -798,7 +798,7 @@
{
if (fields.Count == 0) return originalPdf;
using var inputMs = new System.IO.MemoryStream(originalPdf);
using var inputMs = new System.IO.MemoryStream(originalPdf);
using var outputMs = new System.IO.MemoryStream();
var document = PdfSharp.Pdf.IO.PdfReader.Open(
@@ -819,11 +819,11 @@
var nameBrush = new PdfSharp.Drawing.XSolidBrush(HexToXColor(field.Color, alpha: 230));
var fontLabel = new PdfSharp.Drawing.XFont("Arial", 9, PdfSharp.Drawing.XFontStyleEx.Bold);
var fontName = new PdfSharp.Drawing.XFont("Arial", 7, PdfSharp.Drawing.XFontStyleEx.Regular);
var fontName = new PdfSharp.Drawing.XFont("Arial", 7, PdfSharp.Drawing.XFontStyleEx.Regular);
var fmtCenter = new PdfSharp.Drawing.XStringFormat
{
Alignment = PdfSharp.Drawing.XStringAlignment.Center,
Alignment = PdfSharp.Drawing.XStringAlignment.Center,
LineAlignment = PdfSharp.Drawing.XLineAlignment.Center,
};
@@ -861,11 +861,11 @@
// ── Receiver popup ──
void OpenAddReceiverPopup()
{
_receiverDraftName = string.Empty;
_receiverDraftEmail = string.Empty;
_receiverDraftName = string.Empty;
_receiverDraftEmail = string.Empty;
_receiverDraftPhoneNumber = string.Empty;
_selectedReceiverEmailSuggestion = null;
_receiverPopupValidationMessage = null;
_receiverPopupValidationMessage = null;
_receiverEmailSuggestions.Clear();
_receiverPopupVisible = true;
}
@@ -873,9 +873,9 @@
void CloseAddReceiverPopup()
{
_receiverPopupVisible = false;
_receiverPopupValidationMessage = null;
_receiverPopupValidationMessage = null;
_selectedReceiverEmailSuggestion = null;
_isReceiverEmailSearchRunning = false;
_isReceiverEmailSearchRunning = false;
}
void OnReceiverNameChanged(string? value)
@@ -947,7 +947,7 @@
{
_receiverDraftEmail = value?.Trim() ?? string.Empty;
_selectedReceiverEmailSuggestion = _receiverDraftEmail;
_receiverPopupValidationMessage = null;
_receiverPopupValidationMessage = null;
var searchVersion = ++_receiverEmailSearchVersion;
@@ -955,7 +955,7 @@
{
_receiverEmailSuggestions.Clear();
_selectedReceiverEmailSuggestion = null;
_isReceiverEmailSearchRunning = false;
_isReceiverEmailSearchRunning = false;
return;
}
@@ -994,8 +994,8 @@
Task SaveReceiverAsync()
{
var fullName = _receiverDraftName.Trim();
var email = _receiverDraftEmail.Trim();
var fullName = _receiverDraftName.Trim();
var email = _receiverDraftEmail.Trim();
var phoneNumber = _receiverDraftPhoneNumber.Trim();
if (string.IsNullOrWhiteSpace(fullName))
@@ -1034,12 +1034,12 @@
record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber, string Color);
record EditorSessionData(
byte[] OriginalPdfBytes,
byte[] OriginalPdfBytes,
List<SignatureFieldDraft> Fields,
string FileName,
List<ReceiverDraft> Receivers,
string Title,
string Message);
string FileName,
List<ReceiverDraft> Receivers,
string Title,
string Message);
// ── Receiver colour palette (cycles when > 8 receivers) ──
static readonly string[] ReceiverPalette =

View File

@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Models;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Exposes the CustomImages configuration (signFLOW logo + company logo) to WASM clients.
/// Values are sourced from appsettings.json "CustomImages" section via Options Pattern.
///
/// To change logos per deployment: update "CustomImages.App.Src", "App.Height",
/// "Company.Src", "Company.Height" in appsettings.json — no code change required.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
public class CustomImagesController(IOptions<CustomImagesOptions> options) : ControllerBase
{
/// <summary>
/// Returns the signFLOW app logo and company logo configuration.
/// </summary>
[HttpGet]
public ActionResult<CustomImagesOptions> Get() => Ok(options.Value);
}

View File

@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Manages per-user UI preferences (theme, dark mode, grid layouts).
/// Preferences are stored in the distributed cache (dbo.TBDD_CACHE).
/// Accessible by both Sender and Receiver roles.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
public class UserPreferencesController(IUserPreferencesService preferencesService) : ControllerBase
{
private static readonly HashSet<string> AllowedThemes =
["blazing-berry", "blazing-dark", "purple"];
/// <summary>
/// Returns the current user's UI preferences.
/// Always returns 200 with defaults if no preferences have been saved yet.
/// DisplayName is populated from JWT claims for senders only.
/// </summary>
[HttpGet]
public async Task<ActionResult<UserPreferencesDto>> Get(CancellationToken ct)
{
bool isReceiver = User.IsReceiver();
var userId = isReceiver ? User.ReceiverMail() : User.GetUsername();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var prefs = await preferencesService.GetOrCreateAsync(userId, ct);
// Populate DisplayName for sender only (receiver has no given/surname claims)
if (!isReceiver)
prefs.DisplayName = $"{User.GetPrename()} {User.GetName()}".Trim();
return Ok(prefs);
}
/// <summary>
/// Saves (full replace) the current user's UI preferences.
/// </summary>
[HttpPut]
public async Task<ActionResult<UserPreferencesDto>> Put(
[FromBody] UserPreferencesDto dto,
CancellationToken ct)
{
if (!AllowedThemes.Contains(dto.ThemeName))
return BadRequest($"Invalid theme '{dto.ThemeName}'. Allowed: {string.Join(", ", AllowedThemes)}.");
var userId = User.IsReceiver() ? User.ReceiverMail() : User.GetUsername();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
await preferencesService.SaveAsync(userId, dto, ct);
return Ok(dto);
}
}

View File

@@ -10,9 +10,9 @@
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.Server</Product>
<Version>1.0.1-beta</Version>
<FileVersion>1.0.1.0</FileVersion>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<Version>1.1.0-beta</Version>
<FileVersion>1.1.0.0</FileVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>

View File

@@ -1,4 +1,6 @@
using DigitalData.Auth.Claims;
using DigitalData.UserManager.Domain.Entities;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.IdentityModel.JsonWebTokens;
using System.Security.Claims;
@@ -93,4 +95,14 @@ public static class ReceiverClaimExtensions
else
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.ReceiverId}' is not a valid integer.");
}
/// <summary>
///
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
public static bool IsReceiver(this ClaimsPrincipal user)
{
return Role.Receiver.Authorized.Any(user.IsInRole);
}
}

View File

@@ -1,6 +1,36 @@
namespace EnvelopeGenerator.Server.Models;
public class CustomImages : Dictionary<string, Image>
/// <summary>
/// A single image asset entry — source path and optional per-context CSS classes.
/// Height controls the rendered size in CSS units (e.g. "28px", "2rem").
/// Change Src and Height in appsettings.json per deployment; no code change required.
/// </summary>
public sealed class CustomImageEntry
{
public new Image this[string key] => TryGetValue(key, out var img) && img is not null ? img : new();
public string Src { get; set; } = string.Empty;
/// <summary>
/// CSS height value, e.g. "28px" or "2rem".
/// Allows different-sized logos across deployments without CSS changes.
/// </summary>
public string Height { get; set; } = "28px";
public Dictionary<string, string> Classes { get; set; } = new();
/// <summary>Returns the CSS class for the given context key, or empty string if absent.</summary>
public string GetClass(string key) =>
Classes.TryGetValue(key, out var cls) && cls is not null ? cls : string.Empty;
}
/// <summary>
/// Bound from appsettings.json "CustomImages" section via Options Pattern.
/// App = signFLOW application logo.
/// Company = customer/company logo (Digital Data GmbH by default).
/// </summary>
public sealed class CustomImagesOptions
{
public const string SectionName = "CustomImages";
public CustomImageEntry App { get; set; } = new();
public CustomImageEntry Company { get; set; } = new();
}

View File

@@ -271,7 +271,12 @@ try
.AddAuthenticationSchemes(AuthScheme.Receiver)
.RequireAuthenticatedUser()
.RequireRole(Role.Receiver.Full, "receiver"))
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA));
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA))
// Combined policy: accepts both Sender and Receiver tokens.
// Used by endpoints accessible to both roles (e.g. UserPreferences).
.AddPolicy(AuthPolicy.SenderOrReceiver, policy => policy
.AddAuthenticationSchemes(AuthScheme.Sender, AuthScheme.Receiver)
.RequireAuthenticatedUser());
// User Manager
#pragma warning disable CS0618
@@ -318,6 +323,21 @@ try
.AddEnvelopeGeneratorServices(config);
#pragma warning restore CS0618
// User Preferences (theme, dark mode, grid layouts — stored in TBDD_CACHE)
// Registration handled by Infrastructure DependencyInjection (AddEnvelopeGeneratorInfrastructureServices)
// CustomImages — logo sources and heights, configurable per deployment via appsettings.json
builder.Services.Configure<EnvelopeGenerator.Server.Models.CustomImagesOptions>(
config.GetSection(EnvelopeGenerator.Server.Models.CustomImagesOptions.SectionName));
// Client-side services that MainLayout depends on must also be registered here
// so that Blazor Auto prerender (SSR pass) can resolve them on the server.
// ThemeService.InitializeAsync() uses IJSRuntime but is only called in OnAfterRenderAsync
// (never during prerender), so registering it server-side is safe.
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
// Business Services (Server specific)
builder.Services.AddScoped<DocumentService>();
builder.Services.AddScoped<AuthService>();

View File

@@ -11,7 +11,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>5e0e17c0-ff5a-4246-bf87-1add85376a27</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\API\net8\$(Version)\EnvelopeGenerator.Server.zip</DesktopBuildPackageLocation>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\PreRelease\API\net8\$(Version)\EnvelopeGenerator.Server.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId>

View File

@@ -84,7 +84,7 @@ dotnet publish EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerat
| `--runtime win-x64` | Zielplattform: Windows 64-Bit |
| `-o .\publish-output` | Ausgabeverzeichnis |
### Doğrulama nach dem Publish
### Überprüfung nach dem Publish
Nach erfolgreichem Publish folgende Dateien im Ausgabeverzeichnis prüfen:

View File

@@ -17,7 +17,7 @@
"http://localhost:7192"
],
"ConnectionStrings": {
"Default": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;",
"Default": "Server=SDD-VHP04-SQL19\\DD_TESTING01;Database=DD_ECM;User Id=sa;Password=123456789dD!;Encrypt=false;TrustServerCertificate=True;",
"DbMigrationTest": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM_DATA_MIGR_TEST;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
},
"DirectorySearchOptions": {
@@ -145,15 +145,17 @@
"CustomImages": {
"App": {
"Src": "/img/DD_signFLOW_LOGO.png",
"Height": "28px",
"Classes": {
"Main": "signFlow-logo"
"Main": "signflow-app-logo"
}
},
"Company": {
"Src": "/img/digital_data.svg",
"Height": "33px",
"Classes": {
"Show": "dd-show-logo",
"Locked": "dd-locked-logo"
"Show": "signflow-company-logo",
"Locked": "signflow-company-logo"
}
}
},

View File

@@ -333,6 +333,24 @@ article {
font-weight: 500;
}
/* ── Logos ───────────────────────────────────────────────────────────────── */
/* Heights are controlled by inline style="height: Xpx" from appsettings.json */
.signflow-app-logo {
width: auto;
object-fit: contain;
display: block;
flex-shrink: 0;
}
.signflow-company-logo {
width: auto;
object-fit: contain;
display: block;
opacity: 0.88;
flex-shrink: 0;
}
/* ── Footer ──────────────────────────────────────────────────────────────── */
.receiver-footer {
position: fixed;
@@ -342,7 +360,7 @@ article {
height: 36px;
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between; /* ← left group | right group */
gap: 10px;
padding: 0 20px;
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
@@ -363,6 +381,224 @@ article {
text-decoration: underline;
}
.receiver-footer__left {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.receiver-footer__sep {
opacity: 0.4;
}
/* ── Footer: Theme Slider ────────────────────────────────────────────────── */
/* 🌙 blazing-dark ──◇──·──◇── purple ☀️ */
/* Left group: copyright | datenschutz → Right group: slider */
.sf-theme-slider {
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
/* Moon / Sun SVG icons */
.sf-theme-icon {
display: block;
opacity: 0.55; /* ↑ was 0.42 — more visible as base */
flex-shrink: 0;
transition: opacity 0.18s;
align-self: center;
}
.sf-theme-icon--active {
opacity: 0.92;
}
.sf-theme-icon--mid {
opacity: 0.62;
}
/* ── Wrapper holds the range input + tick marks ───────────────────────────── */
.sf-theme-track-wrapper {
position: relative;
display: flex;
align-items: center;
width: 80px;
height: 16px; /* enough height so ticks + thumb don't clip */
}
/* Actual range input — fills the wrapper */
.sf-theme-range {
-webkit-appearance: none;
appearance: none;
position: relative;
z-index: 1;
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.28);
border-radius: 1px;
outline: none;
cursor: pointer;
margin: 0;
/* no extra padding so ticks align correctly */
}
.sf-theme-range:hover {
background: rgba(255, 255, 255, 0.42);
}
/* Thumb — white circle */
.sf-theme-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.90);
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
transition: transform 0.12s, box-shadow 0.12s;
}
.sf-theme-range::-webkit-slider-thumb:hover {
transform: scale(1.25);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.38);
}
/* Firefox */
.sf-theme-range::-moz-range-thumb {
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.90);
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
}
.sf-theme-range::-moz-range-track {
height: 2px;
background: rgba(255, 255, 255, 0.28);
border-radius: 1px;
}
/* ── End ticks (left & right): thin vertical bars ────────────────────────── */
.sf-theme-tick {
position: absolute;
z-index: 0;
width: 2px;
height: 7px;
border-radius: 1px;
background: rgba(255, 255, 255, 0.45);
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.sf-theme-tick--left { left: 0; }
.sf-theme-tick--right { right: 0; }
/* Mid tick — thinner, subtler */
.sf-theme-tick--mid {
left: 50%;
transform: translate(-50%, -50%);
width: 1px;
height: 5px;
background: rgba(255, 255, 255, 0.50); /* ↑ was 0.22 */
}
/* Sun icon: always visible regardless of active state */
.sf-theme-icon--sun {
opacity: 0.72; /* minimum visibility */
}
/* ── Theme overrides: Home page & Login ──────────────────────────────────── */
/* selector: [data-sf-theme="<name>"] on <html>, set by signflow-theme.js */
/* ── Purple ── */
[data-sf-theme="purple"] .home-page-wrapper {
background: linear-gradient(160deg, #1a0533 0%, #3b0764 45%, #6b21a8 100%);
}
[data-sf-theme="purple"] .home-btn-primary {
background: linear-gradient(135deg, #6b21a8 0%, #9333ea 100%);
}
[data-sf-theme="purple"] .home-feature-badge {
background: rgba(147, 51, 234, 0.10);
color: #c084fc;
border-color: rgba(147, 51, 234, 0.25);
}
/* Login card header: set via a CSS rule so inline gradient can be overridden */
.login-card .card-header {
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%) !important;
}
[data-sf-theme="purple"] .login-card .card-header {
background: linear-gradient(135deg, #1a0533 0%, #6b21a8 100%) !important;
}
[data-sf-theme="blazing-dark"] .home-page-wrapper {
background: linear-gradient(160deg, #0a0f1e 0%, #111827 50%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .home-btn-primary {
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%);
}
[data-sf-theme="blazing-dark"] .home-feature-badge {
background: rgba(99, 102, 241, 0.12);
color: #a5b4fc;
border-color: rgba(99, 102, 241, 0.28);
}
[data-sf-theme="blazing-dark"] .home-card {
background: rgba(15, 20, 40, 0.92) !important;
color: rgba(255, 255, 255, 0.85) !important;
}
[data-sf-theme="blazing-dark"] .home-card .text-muted {
color: rgba(255, 255, 255, 0.55) !important;
}
[data-sf-theme="blazing-dark"] .login-card {
background: rgba(15, 20, 40, 0.95) !important;
color: rgba(255, 255, 255, 0.85) !important;
}
[data-sf-theme="blazing-dark"] .login-card .card-header {
background: linear-gradient(135deg, #0a0f1e 0%, #1e1b4b 100%) !important;
}
[data-sf-theme="blazing-dark"] .login-card .card-body {
background: rgba(15, 20, 40, 0.95);
}
[data-sf-theme="blazing-dark"] .login-card .card-footer {
background: rgba(15, 20, 40, 0.95) !important;
color: rgba(255, 255, 255, 0.45) !important;
}
[data-sf-theme="blazing-dark"] .login-card .form-label {
color: rgba(255, 255, 255, 0.75);
}
[data-sf-theme="blazing-dark"] .login-card .form-control,
[data-sf-theme="blazing-dark"] .login-card .input-group-text {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.85);
}
[data-sf-theme="blazing-dark"] .login-card .btn-primary {
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%) !important;
border-color: transparent !important;
}
[data-sf-theme="blazing-dark"] .login-card .text-muted {
color: rgba(255, 255, 255, 0.50) !important;
}

View File

@@ -1103,3 +1103,111 @@ body.resizing {
padding: 1.5rem;
}
}
/* ── Theme overrides: Envelope Viewer / Editor / Receiver ───────────────── */
/* selector: [data-sf-theme="<name>"] on <html>, set by signflow-theme.js */
/* ── Purple ─────────────────────────────────────────────────────────────── */
[data-sf-theme="purple"] .envelope-viewer-layout {
background: linear-gradient(135deg, #1a0533 0%, #3b0764 50%, #6b21a8 100%);
}
[data-sf-theme="purple"] .envelope-action-bar {
border-bottom-color: rgba(147, 51, 234, 0.4);
}
[data-sf-theme="purple"] .envelope-logo svg {
color: #9333ea;
filter: drop-shadow(0 2px 4px rgba(147, 51, 234, 0.35));
}
[data-sf-theme="purple"] .pdf-toolbar__btn {
border-color: rgba(147, 51, 234, 0.25);
}
[data-sf-theme="purple"] .pdf-toolbar__btn:hover {
border-color: rgba(147, 51, 234, 0.5);
box-shadow: 0 2px 8px rgba(147, 51, 234, 0.2);
}
[data-sf-theme="purple"] .pdf-text-layer ::selection,
[data-sf-theme="purple"] .pdf-text-layer ::-moz-selection {
background: rgba(147, 51, 234, 0.3);
}
/* ── Blazing Dark ───────────────────────────────────────────────────────── */
[data-sf-theme="blazing-dark"] .envelope-viewer-layout {
background: linear-gradient(135deg, #0a0f1e 0%, #111827 55%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .envelope-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(99, 102, 241, 0.35);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
[data-sf-theme="blazing-dark"] .envelope-title {
color: rgba(255, 255, 255, 0.92);
}
[data-sf-theme="blazing-dark"] .envelope-key {
color: rgba(255, 255, 255, 0.45);
}
[data-sf-theme="blazing-dark"] .envelope-logo svg {
color: #818cf8;
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.82);
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn:hover {
background: rgba(129, 140, 248, 0.15);
border-color: rgba(129, 140, 248, 0.4);
color: #fff;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--signature-change-active {
background: rgba(99, 102, 241, 0.25);
border-color: rgba(99, 102, 241, 0.5);
color: #a5b4fc;
}
/* Error container */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
/* Receiver info header */
[data-sf-theme="blazing-dark"] .receiver-info-header__gradient {
background: linear-gradient(135deg, #0a0f1e 0%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .receiver-info-message {
color: rgba(255, 255, 255, 0.70);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .receiver-info-private-message {
background: rgba(245, 158, 11, 0.08);
border-top-color: rgba(245, 158, 11, 0.18);
color: #fde68a;
}
/* Signature action bar */
[data-sf-theme="blazing-dark"] .receiver-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
/* PDF text selection */
[data-sf-theme="blazing-dark"] .pdf-text-layer ::selection,
[data-sf-theme="blazing-dark"] .pdf-text-layer ::-moz-selection {
background: rgba(99, 102, 241, 0.35);
}

View File

@@ -28,6 +28,34 @@
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0; /* don't shrink — logo area in the middle gets the space */
}
/* ── Customer logo area (middle of action bar) ──────────────────────────── */
.sender-customer-logo-area {
flex: 1; /* takes up remaining space between left and right */
display: flex;
justify-content: center;
align-items: center;
min-width: 0; /* allow flex child to shrink below content size */
}
/* Placeholder shown when no customer logo is configured */
.sf-customer-logo-placeholder {
display: flex;
align-items: center;
height: 28px;
min-width: 90px;
padding: 0 12px;
border: 1px dashed rgba(126, 34, 206, 0.28);
border-radius: 6px;
font-size: 0.65rem;
font-weight: 500;
color: rgba(126, 34, 206, 0.38);
white-space: nowrap;
letter-spacing: 0.03em;
text-transform: uppercase;
user-select: none;
}
.sender-logo svg {
@@ -171,7 +199,20 @@
}
.sender-grid-wrapper {
padding: 1.5rem 2rem 2rem;
padding: 0 2rem 2rem; /* top padding removed — hint banner provides top spacing */
}
/* ── Grid persistence hint banner ─────────────────────────────────────────── */
.sf-grid-persistence-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 2rem;
font-size: 0.72rem;
color: rgba(126, 34, 206, 0.65);
background: rgba(126, 34, 206, 0.04);
border-bottom: 1px solid rgba(126, 34, 206, 0.08);
user-select: none;
}
/* Hide DevExpress empty cells */
@@ -295,3 +336,146 @@
font-size: 0.813rem;
}
}
/* ── Theme overrides ─────────────────────────────────────────────────────── */
/* Selector: [data-sf-theme="<name>"] set on <html> by signflow-theme.js */
/* ── Purple theme ──────────────────────────────────────────────────────────
Deeper violet, less blue — visually distinct from Blazing Berry */
[data-sf-theme="purple"] .sender-dashboard-layout {
background: linear-gradient(135deg, #1a0533 0%, #3b0764 50%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-grid-container::before {
background: linear-gradient(90deg, #9333ea 0%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-action-bar {
border-bottom-color: rgba(147, 51, 234, 0.4);
}
[data-sf-theme="purple"] .sender-logo svg {
color: #9333ea;
filter: drop-shadow(0 2px 4px rgba(147, 51, 234, 0.35));
}
[data-sf-theme="purple"] .sender-btn--primary {
background: linear-gradient(135deg, #9333ea 0%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-btn--primary:hover:not(:disabled) {
background: linear-gradient(135deg, #7e22ce 0%, #581c87 100%);
box-shadow: 0 4px 16px rgba(147, 51, 234, 0.35);
}
[data-sf-theme="purple"] .sender-btn:hover:not(:disabled) {
border-color: rgba(147, 51, 234, 0.45);
box-shadow: 0 4px 12px rgba(147, 51, 234, 0.22);
}
[data-sf-theme="purple"] .sender-tabs {
border-bottom-color: rgba(147, 51, 234, 0.12);
}
[data-sf-theme="purple"] .sender-tab--active {
color: #9333ea;
border-bottom-color: #9333ea;
}
/* ── Blazing Dark theme ────────────────────────────────────────────────────
Dark navy background, frosted glass surfaces turn semi-dark */
[data-sf-theme="blazing-dark"] .sender-dashboard-layout {
background: linear-gradient(135deg, #0a0f1e 0%, #111827 55%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .sender-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(99, 102, 241, 0.35);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
[data-sf-theme="blazing-dark"] .sender-title {
color: rgba(255, 255, 255, 0.92);
}
[data-sf-theme="blazing-dark"] .sender-logo svg {
color: #818cf8;
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
}
[data-sf-theme="blazing-dark"] .sender-btn {
color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
}
[data-sf-theme="blazing-dark"] .sender-btn:hover:not(:disabled) {
background: rgba(129, 140, 248, 0.15);
border-color: rgba(129, 140, 248, 0.4);
color: #fff;
box-shadow: 0 4px 12px rgba(129, 140, 248, 0.2);
}
[data-sf-theme="blazing-dark"] .sender-btn:disabled {
color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .sender-btn--primary {
background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%);
color: white;
border-color: transparent;
}
[data-sf-theme="blazing-dark"] .sender-btn--primary:hover:not(:disabled) {
background: linear-gradient(135deg, #818cf8 0%, #6366f1 100%);
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
}
[data-sf-theme="blazing-dark"] .sender-btn--danger {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.28);
color: #fca5a5;
}
[data-sf-theme="blazing-dark"] .sender-btn--danger:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
border-color: transparent;
}
[data-sf-theme="blazing-dark"] .sender-grid-container {
background: rgba(15, 20, 40, 0.95);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .sender-grid-container::before {
background: linear-gradient(90deg, #6366f1 0%, #4338ca 100%);
}
[data-sf-theme="blazing-dark"] .sender-tabs {
background: rgba(255, 255, 255, 0.03);
border-bottom-color: rgba(99, 102, 241, 0.12);
}
[data-sf-theme="blazing-dark"] .sender-tab {
color: rgba(255, 255, 255, 0.45);
}
[data-sf-theme="blazing-dark"] .sender-tab--active {
color: #818cf8;
border-bottom-color: #818cf8;
}
[data-sf-theme="blazing-dark"] .sender-tab:hover:not(.sender-tab--active) {
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.04);
}
/* Error container in dark mode */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 850 121.32">
<defs>
<style>
.cls-1 {
fill: #a52431;
stroke-width: 0px;
}
</style>
</defs>
<path class="cls-1" d="M83.01,60.66c0-35.12-12.77-57.47-41.51-57.47H0v114.94h39.91c30.33,0,43.1-20.75,43.1-57.47M49.49,60.66c0,19.16-1.6,28.73-15.96,28.73V31.93c14.37,0,15.96,12.77,15.96,28.73M126.11,3.19h-33.52v114.94h33.52V3.19ZM223.49,97.38v-46.29h-39.91l-1.6,23.95h12.77v9.58c-3.19,4.79-6.39,7.98-12.77,7.98-7.98,0-12.77-11.17-12.77-31.93s4.79-31.93,12.77-31.93c6.86,0,11.33,5.75,12.77,14.37l28.73-6.39C218.7,15.96,207.52,0,181.98,0c-33.52,0-46.29,28.73-46.29,60.66s12.77,60.66,46.29,60.66c17.56,0,31.93-7.98,41.51-23.94M268.18,3.19h-33.52v114.94h33.52V3.19ZM346.4,31.93l-3.19-28.74h-65.45l-3.19,28.74h19.16v86.2h33.52V31.93h19.16ZM427.81,118.13L399.07,3.19h-31.93l-28.73,114.94h35.12l1.6-14.37h15.96l1.6,14.37h35.12ZM387.58,76.62h-8.94l3.51-28.74c.32-3.19.64-6.38.8-9.58h.32c.16,3.19.48,6.38.8,9.58l3.51,28.74ZM493.26,89.4h-25.54V3.19h-33.52v114.94h55.87l3.19-28.74ZM614.58,60.66c0-35.12-12.77-57.47-41.51-57.47h-41.51v114.94h39.91c30.33,0,43.1-20.75,43.1-57.47M581.05,60.66c0,19.16-1.6,28.73-15.96,28.73V31.93c14.37,0,15.96,12.77,15.96,28.73M704.75,118.13l-28.73-114.94h-31.93l-28.74,114.94h35.12l1.6-14.37h15.96l1.6,14.37h35.12ZM664.52,76.62h-8.94l3.51-28.74c.32-3.19.64-6.38.8-9.58h.32c.16,3.19.48,6.38.8,9.58l3.51,28.74ZM768.59,31.93l-3.19-28.74h-65.45l-3.19,28.74h19.16v86.2h33.52V31.93h19.16ZM850,118.13l-28.73-114.94h-31.93l-28.73,114.94h35.12l1.6-14.37h15.96l1.6,14.37h35.12ZM809.77,76.62h-8.94l3.51-28.74c.32-3.19.64-6.38.8-9.58h.32c.16,3.19.48,6.38.8,9.58l3.51,28.74Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,37 @@
/**
* signFLOW Theme Helper
* Called from ThemeService (Blazor WASM) to apply theme changes.
*/
window.signflowTheme = {
apply: function (href, bsTheme, themeName) {
console.debug('[signflowTheme] apply', themeName, bsTheme);
// ── Bootstrap dark mode ─────────────────────────────────────────
document.documentElement.setAttribute('data-bs-theme', bsTheme);
// ── Theme name attribute for CSS overrides ──────────────────────
// Applied to <html> so any nested selector can use it:
// [data-sf-theme="blazing-dark"] .sender-dashboard-layout { ... }
document.documentElement.setAttribute('data-sf-theme', themeName);
// ── DevExpress theme CSS link ────────────────────────────────────
document.querySelectorAll('link[href*="DevExpress.Blazor.Themes"]').forEach(function(el) {
if (el.id !== 'dx-theme-link') el.parentNode.removeChild(el);
});
let link = document.getElementById('dx-theme-link');
if (!link) {
link = document.createElement('link');
link.id = 'dx-theme-link';
link.rel = 'stylesheet';
document.head.appendChild(link);
}
const currentRelative = link.getAttribute('href') || '';
if (currentRelative !== href) {
link.setAttribute('href', href);
}
}
};

View File

@@ -0,0 +1,415 @@
# SF-74 — signFLOW: Theme, Dark Mode, Grid Persistence, Logo & Dashboard
**Ticket:** SF-74 | **Bearbeiter:** Hakan Tek | **Status:** In Bearbeitung
**Letzte Aktualisierung:** 2026-09-03
**Erstellt von:** Marlon Schreiber | **Verknüpft mit:** SF-71
---
## Originale Anforderungen
### Marlon Schreiber (Ticket-Text):
1. **signFLOW-Logo** + **Kunden-Logo-Bereich** auf der Startseite
2. **DevExpress Skin-Auswahl** — mindestens 3 Themes, vom User wählbar + gespeichert
3. **Grid-Layout-Persistenz** — Spaltenbreite/Position pro User gespeichert
### Screenshot-Annotationen (`/sender`):
```
[signFLOW Logo] Umschlag-Übersicht [Kunden-Logo] [+Neuer Umschlag][Bearb.][Lösch.][↺][→|]
signFLOW Portal-Übersicht [Zurückrufen/löschen][Dok.anzeigen][Excel]
[Info-Banner: "Die Größe und Position der Spalten muss verändert und für den User gespeichert..."]
ID ↓ Titel Status [Typ] Empfänger [Erstellt] [Geändert am]
```
---
## Projektstruktur — Clean Architecture
```
EnvelopeGenerator.Domain/ → Entities, Domain-Interfaces
EnvelopeGenerator.Application/ → Service-Interfaces, DTOs, Use-Cases
EnvelopeGenerator.Infrastructure/ → Cache, DB, Implementierungen
EnvelopeGenerator.Server/ → Blazor SSR Host, Controller, Middleware
EnvelopeGenerator.Server.Client/ → Blazor WASM Client
```
**Abhängigkeitsregel:** `Domain ← Application ← Infrastructure ← Server / Server.Client`
---
## ✅ Abgeschlossene Phasen (1–5)
| Phase | Inhalt | Status |
|-------|--------|--------|
| 1 | Server: UserPreferences API (TBDD_CACHE, `IUserPreferencesService`) | ✅ |
| 2 | WASM: `ThemeService`, `UserPreferencesService` (1h In-Memory-Cache) | ✅ |
| 3 | Footer Theme Slider (🌙 blazing-dark ── blazing-berry ── purple ☀️) | ✅ |
| 4 | Grid Layout Persistence (`LayoutAutoSaving`/`LayoutAutoLoading`) | ✅ |
| 5 | Theme-CSS-Overrides für alle Seiten (`[data-sf-theme]`) | ✅ |
**Key decisions:** TBDD_CACHE statt eigener DB-Tabelle · `SenderOrReceiver` Policy · Slider statt Toggle · `signflow.theme` in localStorage · DevExpress 25.2.3 hat keine `.dark.`-Suffix-Dateien
---
## 🔲 Phase 6a — signFLOW-Logo
### Ziel
signFLOW-App-Logo im Sender Dashboard (`/sender`) Action Bar links neben dem Titel, sowie auf der Startseite (`/`) im Hero-Header.
### Technischer Ansatz
**Options Pattern** — `appsettings.json` → `CustomImagesOptions` → DI → Controller → WASM Service.
```
appsettings.json
└── CustomImagesOptions (Options Pattern, IOptions<>)
└── GET /api/CustomImages (neuer Controller, auth: SenderOrReceiver)
└── CustomImagesService (WASM, in-memory cache, kein TTL)
└── EnvelopeSenderPage + IndexPage
```
**Warum kein `wwwroot/appsettings.json`?**
Statische Assets sind public (keine Auth). Logo-Pfade sind deployment-spezifisch.
Options Pattern erlaubt Produktionswechsel ohne Code-Änderung.
### Neue/geänderte Dateien
#### `Server/Models/CustomImages.cs` — NEU
```csharp
public sealed class CustomImageEntry
{
public string Src { get; set; } = string.Empty;
/// <summary>CSS height, e.g. "28px" or "2rem". Change per deployment in appsettings.</summary>
public string Height { get; set; } = "28px";
public Dictionary<string, string> Classes { get; set; } = new();
public string GetClass(string key) =>
Classes.TryGetValue(key, out var cls) ? cls ?? string.Empty : string.Empty;
}
public sealed class CustomImagesOptions
{
public const string SectionName = "CustomImages";
public CustomImageEntry App { get; set; } = new();
public CustomImageEntry Company { get; set; } = new();
}
```
#### `appsettings.json` — Änderung (Zeile 145)
```json
"CustomImages": {
"App": { "Src": "/img/DD_signFLOW_LOGO.png", "Height": "28px",
"Classes": { "Main": "signflow-app-logo" } },
"Company": { "Src": "/img/digital_data.svg", "Height": "22px",
"Classes": { "Show": "signflow-company-logo" } }
}
```
> **Deployment:** Nur `Src` + `Height` in `appsettings.json` ändern — keine Code-Anpassung nötig.
#### `Server/Controllers/CustomImagesController.cs` — NEU
```
GET /api/CustomImages
Auth: [Authorize(Policy = AuthPolicy.SenderOrReceiver)]
Returns: CustomImagesOptions
```
#### `Server/Program.cs` — DI
```csharp
builder.Services.Configure<CustomImagesOptions>(
config.GetSection(CustomImagesOptions.SectionName));
```
#### `Server/wwwroot/img/` — Neue Dateien
Kopiert aus `EnvelopeGenerator.Web/wwwroot/img/`:
- `DD_signFLOW_LOGO.png`
- `digital_data.svg`
#### `Client/Services/CustomImagesService.cs` — NEU
```csharp
// GET /api/CustomImages → einmalig laden, in-memory gecacht (Config ändert sich kaum)
public class CustomImagesService(IHttpClientFactory factory)
{
private CustomImagesDto? _cached;
public async Task<CustomImagesDto> GetAsync(CancellationToken ct = default) { ... }
}
```
#### `app.css` — Neue Klassen
```css
.signflow-app-logo { height: var(--sf-app-height, 28px); width: auto; object-fit: contain; }
.signflow-company-logo { height: var(--sf-company-height, 22px); width: auto; object-fit: contain; opacity: .88; }
```
#### `IndexPage.razor` — Änderung
```razor
<!-- Alt: <svg class="home-hero-header__icon"> -->
<!-- Neu: -->
<img src="@_appLogo.Src" style="height: @_appLogo.Height"
class="home-hero-header__icon" alt="signFLOW" />
```
#### `EnvelopeSenderPage.razor` — signFLOW Logo im Action Bar
```razor
<div class="sender-branding">
<img src="@_appLogo.Src" style="height: @_appLogo.Height"
class="@_appLogo.GetClass("Main")" alt="signFLOW" />
<div class="sender-title-block">
<div class="sender-title">Umschlag-Übersicht</div>
<div class="sender-subtitle">signFLOW Portal - Übersicht</div>
</div>
</div>
```
---
## 🔲 Phase 6b — Kunden-Logo Placeholder
### Ziel
Mittlerer Bereich im Action Bar zeigt das Kunden-Logo (aus Config) oder einen Platzhalter.
### Implementierung
**Config-Quelle:** `CustomImagesOptions.Company` (aus Phase 6a bereits geladen)
```razor
<!-- Mitte des Action Bars -->
<div class="sender-customer-logo-area">
@if (!string.IsNullOrEmpty(_companyLogo?.Src))
{
<img src="@_companyLogo.Src" style="height: @_companyLogo.Height"
class="@_companyLogo.GetClass("Show")" alt="Kunden-Logo" />
}
else
{
<div class="sf-customer-logo-placeholder">Kunden-Logo</div>
}
</div>
```
#### `app.css` — Neue Klasse
```css
.sf-customer-logo-placeholder {
height: 24px; min-width: 90px;
border: 1px dashed rgba(126, 34, 206, 0.3); border-radius: 4px;
display: flex; align-items: center; padding: 0 10px;
font-size: 0.62rem; color: rgba(126, 34, 206, 0.4); white-space: nowrap;
}
```
#### `sender-page.css` — Neues Layout (3-Bereich-Action-Bar)
```css
.sender-branding { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
.sender-subtitle { font-size: .72rem; color: #6b7280; font-weight: 400; }
.sender-customer-logo-area { flex: 1; display: flex; justify-content: center; }
```
---
## 🔲 Phase 6c — Grid: Fehlende Spalten + Persistence Hint
### Fehlende Spalten (aus Screenshot-Annotationen)
**Verfügbarkeit in `EnvelopeDto`:**
- `EnvelopeTypeTitle` (string?) → `spalte für "Typ"` ✅
- `AddedWhen` (DateTime) → `spalte für "erstellt"` ✅
- `ChangedWhen` (DateTime?) → `Spalte "zuletzt geändert am"` ✅
> ⚠️ **Vor Implementierung prüfen:** `EnvelopeTypeTitle` wird aus dem Navigation Property `EnvelopeType` gemappt. Mapping-Konfiguration in `Application/...MappingProfile.cs` verifizieren.
#### `EnvelopeSenderPage.razor` — Neue Spalten (beide Grids)
```razor
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="Typ"
Width="120px" AllowSort="true" />
<DxGridDataColumn FieldName="AddedWhen" Caption="Erstellt"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
<DxGridDataColumn FieldName="ChangedWhen" Caption="Geändert am"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
```
### Grid Persistence Hint
Info-Banner über dem Grid:
```razor
<div class="sf-grid-persistence-hint">
<svg><!-- info icon --></svg>
Die Größe und Position der Spalten wird automatisch für Sie gespeichert.
</div>
```
```css
.sf-grid-persistence-hint {
display: flex; align-items: center; gap: 6px;
padding: 5px 16px; font-size: 0.72rem;
color: rgba(126, 34, 206, 0.7);
background: rgba(126, 34, 206, 0.04);
border-bottom: 1px solid rgba(126, 34, 206, 0.08);
}
```
---
## 🔲 Phase 6d — Dokument anzeigen (PDF Preview)
### Anforderung
Ausgewähltes Envelope-Dokument im Popup anzeigen. Envelope ist nach dem Senden **read-only** (Mails wurden gesendet — nachträgliche Änderungen würden Konsistenz brechen).
### Technischer Ansatz
`DxPdfViewer` erfordert **zwingend `@rendermode InteractiveServer`**.
`EnvelopeSenderPage` ist WASM (`@rendermode InteractiveWebAssembly`).
**Lösung:** `DxPopup` im WASM-Parent mit einem Server-Component darin:
```
EnvelopeSenderPage.razor (@rendermode WASM)
└── <DxPopup>
└── <EnvelopePdfPreviewComponent @rendermode InteractiveServer>
└── <DxPdfViewer> (SSR-only)
```
#### `Server/Components/Shared/EnvelopePdfPreviewComponent.razor` — NEU
```razor
@rendermode InteractiveServer
@inject IDocumentService DocumentService <!-- oder HTTP client -->
<DxPdfViewer DocumentContent="@_pdfBytes" />
@code {
[Parameter] public int EnvelopeId { get; set; }
byte[]? _pdfBytes;
protected override async Task OnParametersSetAsync()
{
if (EnvelopeId > 0)
_pdfBytes = await DocumentService.GetPdfBytesAsync(EnvelopeId);
}
}
```
Button: nur aktiv wenn Envelope ausgewählt UND Dokument vorhanden (`envelope.Documents?.Any() == true`).
---
## 🔲 Phase 6e — Export nach Excel
### Technischer Ansatz
`DxGrid.ExportToXlsxAsync(fileName, options)` — direkt im Razor-Code, keine Backend-Implementierung nötig.
Download geht direkt an den Browser.
```csharp
async Task ExportToExcelAsync()
{
var grid = _activeTab == "active" ? _gridActive : _gridCompleted;
if (grid is null) return;
var fileName = $"Umschlaege_{DateTime.Now:yyyy-MM-dd}.xlsx";
await grid.ExportToXlsxAsync(fileName, new GridXlExportOptions());
}
```
> **Hinweis:** `CellDisplayTemplate`-Inhalte werden nicht exportiert (DevExpress-Limitierung).
> Die Spalten `Status`, `EnvelopeReceivers` nutzen Templates → Wert im Export ist leer.
> **Future Task:** Für vollständigen Export mit formatierten Zellen `CustomizeCell`-Event nutzen.
Button in Toolbar:
```razor
<button class="sender-btn" @onclick="ExportToExcelAsync"
title="Aktuelle Ansicht als Excel exportieren">
<!-- Excel-Icon SVG -->
Export nach Excel
</button>
```
---
## 🔲 Phase 6f — Action Bar Redesign
### Ziel-Layout (aus Screenshot)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ [Logo] Umschlag-Übersicht │ [Kunden-Logo] │ [+Neu] [Bearb.] [Lösch.] │
│ signFLOW Portal-Übers│ │ [↺] [→|] │
│ │ │ [Zurückrufen?] [Dok.] [XLS] │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Neue CSS-Klassen in `sender-page.css`
```css
.sender-toolbar-right { display: flex; flex-direction: column; gap: .35rem; align-items: flex-end; }
.sender-toolbar-row { display: flex; align-items: center; gap: .5rem; }
.sender-toolbar-row--primary { /* Hauptbuttons */ }
.sender-toolbar-row--secondary { /* Dokument anzeigen, Export */ }
```
### `Zurückrufen/löschen` — Offene Frage ❓
> **Ticket-Kommentar-Entwurf (Deutsch, für Marlon Schreiber):**
>
> Hallo @Marlon Schreiber,
>
> im Screenshot sehe ich einen Button „Zurückrufen/löschen". Könntest du den
> Unterschied zum bereits vorhandenen „Löschen"-Button erläutern?
>
> Meine Vermutung: „Zurückrufen" bedeutet das Widerrufen eines **bereits gesendeten**
> Umschlags (Status → `EnvelopeWithdrawn`), während „Löschen" einen noch nicht
> gesendeten Umschlag entfernt. Ist das korrekt? Und falls ja:
> - Sollen Empfänger bei einem Widerruf automatisch benachrichtigt werden?
> - Soll der Status auf `Zurückgerufen` gesetzt werden und der Umschlag
> weiterhin in der Liste erscheinen (aber inaktiv)?
>
> Bitte kurz antworten, damit ich dies korrekt implementieren kann.
---
## Implementierungsreihenfolge Phase 6
```
6a (Logo) → 6b (Kunden-Logo) → 6c (Grid-Spalten) → 6d (PDF Preview) → 6e (Excel) → 6f (Action Bar)
```
Jede Teilphase ist unabhängig deploybar. `6f` fasst alle Layout-Änderungen zusammen
und sollte zuletzt implementiert werden, wenn alle Komponenten fertig sind.
---
## Bekannte Einschränkungen & Offene Punkte
| # | Thema | Status |
|---|-------|--------|
| 1 | `DxPdfViewer` → InteractiveServer zwingend | Architektonisch gelöst (6d) |
| 2 | Excel-Export: Template-Spalten leer im Export | Akzeptiert, Future Task (CustomizeCell) |
| 3 | `Zurückrufen/löschen` — Bedeutung unklar | Kommentar-Frage vorbereitet (6f) |
| 4 | `EnvelopeTypeTitle` Mapping prüfen | Vor 6c verifizieren |
| 5 | Logo-Pfade produktionsspezifisch | Via `appsettings.json` konfigurierbar |
| 6 | Server-seitige Pages — inline Farben bleiben | Out-of-scope |
---
## Nicht in diesem Ticket
- `Einstellungen`-Seite (separates Ticket)
- Mobile Responsive (separates Ticket)
- Excel CustomizeCell vollständige Implementierung (Future Task)
- Widerruf-Backend (wartet auf Klärung)
---
*Erstellt: 2026-09-01 | Aktualisiert: 2026-09-03 | Ticket: SF-74 | Projekt: signFLOW*

View File

@@ -1,5 +1,5 @@
@echo off
setlocal
setlocal enabledelayedexpansion
echo ============================================================
echo EnvelopeGenerator.Server - Self-Contained Publish
@@ -10,11 +10,38 @@ echo.
REM Must be run from the solution root directory.
REM This file is located under: EnvelopeGenerator.Server\
set PROJECT=EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj
set OUTPUT=publish-output
set PROJECT=%~dp0EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj
set RID=win-x64
set FRAMEWORK=net8.0
REM ── Resolve project version from .csproj ──────────────────────────────────────
REM Reads the <Version> element directly from the .csproj XML using findstr.
REM Example: <Version>1.1.0-beta</Version> → VERSION=1.1.0-beta
REM 'tokens=2 delims=>' splits on '>' and takes the second token (the value+tag),
REM then 'delims=<' strips the closing '<' tag leaving the bare version string.
for /f "tokens=2 delims=>< " %%v in ('findstr /i "<Version>" "%PROJECT%"') do (
set VERSION=%%v
)
if "%VERSION%"=="" (
echo [ERROR] Could not read ^<Version^> from %PROJECT%. Aborting.
pause
exit /b 1
)
REM ── Output path: network share / versioned subfolder ──────────────────────────
REM Format: M:\App&Service\0 DD - Smart UP\signFLOW\Server\net8\{version}
REM Note: the '&' in the path is literal — no escaping needed inside a set "..." assignment.
set "OUTPUT=M:\App&Service\0 DD - Smart UP\signFLOW\Server\net8\%VERSION%"
echo [0/3] Resolved build parameters
echo Project : %PROJECT%
echo Version : %VERSION%
echo Output : %OUTPUT%
echo RID : %RID%
echo Framework: %FRAMEWORK%
echo.
echo [1/3] Cleaning previous publish output...
if exist "%OUTPUT%" (
rmdir /s /q "%OUTPUT%"
@@ -25,10 +52,6 @@ if exist "%OUTPUT%" (
echo.
echo [2/3] Publishing...
echo Project : %PROJECT%
echo Output : %OUTPUT%
echo RID : %RID%
echo Framework: %FRAMEWORK%
echo.
dotnet publish "%PROJECT%" ^
@@ -50,36 +73,37 @@ echo [3/3] Verifying output...
set PASS=1
if not exist "%OUTPUT%\EnvelopeGenerator.Server.exe" (
if not exist "!OUTPUT!\EnvelopeGenerator.Server.exe" (
echo [FAIL] EnvelopeGenerator.Server.exe not found!
set PASS=0
)
if not exist "%OUTPUT%\hostfxr.dll" (
) else ( echo [OK] EnvelopeGenerator.Server.exe )
if not exist "!OUTPUT!\hostfxr.dll" (
echo [FAIL] hostfxr.dll not found! (Not a self-contained publish?)
set PASS=0
)
if not exist "%OUTPUT%\coreclr.dll" (
) else ( echo [OK] hostfxr.dll )
if not exist "!OUTPUT!\coreclr.dll" (
echo [FAIL] coreclr.dll not found! (Not a self-contained publish?)
set PASS=0
)
if not exist "%OUTPUT%\Microsoft.Extensions.DependencyInjection.Abstractions.dll" (
) else ( echo [OK] coreclr.dll )
if not exist "!OUTPUT!\Microsoft.Extensions.DependencyInjection.Abstractions.dll" (
echo [FAIL] Microsoft.Extensions.DependencyInjection.Abstractions.dll not found!
set PASS=0
)
if not exist "%OUTPUT%\web.config" (
) else ( echo [OK] Microsoft.Extensions.DependencyInjection.Abstractions.dll )
if not exist "!OUTPUT!\web.config" (
echo [FAIL] web.config not found!
set PASS=0
)
) else ( echo [OK] web.config )
if "%PASS%"=="1" (
if "!PASS!"=="1" (
echo.
echo ============================================================
echo PUBLISH SUCCEEDED
echo Output folder: %~dp0%OUTPUT%
echo Version : !VERSION!
echo Output folder: !OUTPUT!
echo ============================================================
echo.
echo Next steps:
echo 1. Copy the contents of '%OUTPUT%' to the IIS application directory
echo 1. Copy the contents of the output folder to the IIS application directory
echo 2. Set the IIS Application Pool to 'No Managed Code'
echo 3. Recycle the Application Pool
echo.

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net4.6.2</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-EnvelopeGenerator.Service-816256a4-c636-4313-92c0-b62b04040c2b</UserSecretsId>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="7.0.0" />
<PackageReference Include="NLog" Version="5.3.1" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.9" />
</ItemGroup>
<!-- DigitalData & Quartz: local packages, must match CommonServices versions exactly -->
<ItemGroup>
<Reference Include="DigitalData.Modules.Database">
<HintPath>..\packages\DigitalData.Modules.Database.2.3.5.4\lib\net462\DigitalData.Modules.Database.dll</HintPath>
</Reference>
<Reference Include="DigitalData.Modules.Encryption">
<HintPath>..\packages\DigitalData.Modules.Encryption.1.3.1\lib\net462\DigitalData.Modules.Encryption.dll</HintPath>
</Reference>
<Reference Include="DigitalData.Modules.Logging">
<HintPath>..\packages\DigitalData.Modules.Logging.2.6.5\lib\net462\DigitalData.Modules.Logging.dll</HintPath>
</Reference>
<!-- Quartz 3.8.0 <20> must match CommonServices (same AppDomain, one version) -->
<Reference Include="Quartz">
<HintPath>..\packages\Quartz.3.8.0\lib\net462\Quartz.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.CommonServices\EnvelopeGenerator.CommonServices.vbproj" />
<ProjectReference Include="..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,28 @@
using DigitalData.Modules.Logging;
namespace EnvelopeGenerator.Service;
/// <summary>
/// Builds a <see cref="LogConfig"/> from <see cref="ServiceConfig"/>.
/// Required because CommonServices jobs have a hard dependency on <see cref="LogConfig"/>
/// in their Quartz <c>JobDataMap</c>.
/// Log path mirrors the legacy behavior: <c>&lt;BaseDirectory&gt;\Log</c>.
/// </summary>
internal static class LogConfigFactory
{
public static LogConfig Create(ServiceConfig config)
{
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log");
var logConfig = new LogConfig(
LogConfig.PathType.CustomPath,
logPath,
null,
"Digital Data",
"EnvelopeGenerator.Service");
logConfig.Debug = config.Debug;
return logConfig;
}
}

View File

@@ -0,0 +1,315 @@
# Migration Plan: EnvelopeGenerator.Service_legacy ? EnvelopeGenerator.Service
**Revision v3** — Full deep analysis complete
## Overview
Migrate the legacy VB.NET Windows Service (`EnvelopeGenerator.Service_legacy`) to a modern
C# Worker Service (`EnvelopeGenerator.Service`) targeting **.NET Framework 4.6.2**, using
`Microsoft.Extensions.Hosting`, `IConfiguration`, `ILogger<T>` and **NLog**.
---
## Deep Analysis Findings (vs. v1/v2)
### Finding 1 — Connection String: Plain Text, No Encryption Required
`FinalizeDocumentJob` and `APIEnvelopeJob` call `MSSQLServer.DecryptConnectionString()` on the
`JobDataMap[Value.DATABASE]` value. However, `MSSQLServer.DecryptConnectionString()` is a pass-through
for plain-text strings — no wrapping/encryption step is needed.
**Resolution**: The connection string is read from `IConfiguration` via the standard
`ConnectionStrings:Default` key (same key as `appsettings.Database.json` in `EnvelopeGenerator.Web`).
It is passed **as-is** to both the Worker-level `MSSQLServer` and the `JobDataMap`. No
`EncryptConnectionString()` call is made anywhere in the new service.
### Finding 2 — Dual TempFiles Architecture
There are **two independent** `TempFiles` classes:
- `EnvelopeGenerator.CommonServices.TempFiles` — used **inside each job** via `New TempFiles(LogConfig)`.
This is called on every job execution and manages the job's own temp lifecycle. It expects `LogConfig`.
**We do not touch this.**
- `EnvelopeGenerator.Service_legacy.TempFiles` — used at the **service/host level** (startup cleanup,
shutdown cleanup). This is the one we rewrite in C# in the Service project using `ILogger<TempFiles>`.
### Finding 3 — LogConfig is a Hard Dependency of All Jobs
`FinalizeDocumentJob.Execute()` and `APIEnvelopeJob.Execute()` both do:
```vb
LogConfig = pContext.MergedJobDataMap.Item(Value.LOGCONFIG)
Logger = LogConfig.GetLogger()
myTempFiles = New TempFiles(LogConfig) ' ? CommonServices.TempFiles
```
`LogConfig` is **not optional**. The schedulers must construct a valid `LogConfig` and place it in the
`JobDataMap`. It must carry the correct log path, debug flag, and application identity.
**Resolution**: A `LogConfigFactory` static helper class will be created in the Service project to
build a `LogConfig` from `ServiceConfig` values:
- `LogPath` = `Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log")` (mirrors legacy `My.Application.Info.DirectoryPath + "\Log"`)
- `Debug` = `ServiceConfig.Debug`
- Application name = `"EnvelopeGenerator.Service"`
### Finding 4 — ProjectInstaller is Obsolete
`ProjectInstaller.vb` was the old `installutil.exe` mechanism for Windows Service registration.
With `AddWindowsService()` + `sc create` / PowerShell `New-Service`, this is **completely replaced**.
No equivalent is needed in the new project.
### Finding 5 — GDPicture License SQL
The legacy service queries: `SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' and ACTIVE = 1`
This must be replicated exactly in the Worker startup sequence. If the result is null/empty, startup fails
with a descriptive exception (matching legacy behavior).
### Finding 6 — Quartz Version Mismatch
`CommonServices` uses `Quartz 3.8.0`. `Service_legacy` uses `Quartz 3.15.0`.
The new `EnvelopeGenerator.Service` must use **Quartz 3.8.0** to match `CommonServices` (same AppDomain,
same version must be loaded). The legacy version bump in `Service_legacy` was inconsistent.
---
## 1. Project File (`EnvelopeGenerator.Service.csproj`)
- Add **NuGet packages**:
- `NLog` (5.x, matching CommonServices' `NLog.5.0.5`)
- `NLog.Extensions.Logging` (latest net462-compatible)
- `Quartz` (**3.8.0** — must match CommonServices)
- `Microsoft.Extensions.Hosting.WindowsServices` (for `AddWindowsService()`)
- Add **project reference** to `EnvelopeGenerator.CommonServices` (vbproj)
- Add **project reference** to `EnvelopeGenerator.Domain` (csproj)
---
## 2. Configuration (`ServiceConfig.cs`)
```csharp
public class ServiceConfig
{
public string ConnectionString { get; set; } = string.Empty;
public bool Debug { get; set; } = false;
public int IntervalInMin { get; set; } = 1;
public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams();
}
```
Connection string is read via the standard `ConnectionStrings:Default` key — consistent with
`appsettings.Database.json` used by `EnvelopeGenerator.Web`:
`appsettings.json`:
```json
{
"ConnectionStrings": {
"Default": ""
},
"ServiceConfig": {
"Debug": false,
"IntervalInMin": 1,
"PDFBurnerParams": {
"IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ],
"TopMargin": 0.1,
"YOffset": -0.3,
"FontName": "Arial",
"FontSize": 8,
"FontStyle": 2,
"TruncationSuffix": "..",
"TextMaxWidths": {
"position": 1.41,
"city": 1.41,
"date": 2.0
}
}
}
}
```
> `FontStyle` integer: Regular=0, Bold=1, **Italic=2**, Underline=4, Strikeout=8.
> All values mirror `PDFBurnerParams.vb` defaults exactly.
---
## 3. NLog Setup
**`nlog.config`**: File target ? `${basedir}/Log/service-${shortdate}.log`, async wrapper,
layout with timestamp + level + logger + message + exception.
**`Program.cs`**: Wire via `NLog.Extensions.Logging`:
```csharp
builder.Logging.ClearProviders();
builder.Logging.AddNLog("nlog.config");
```
---
## 4. LogConfigFactory (`LogConfigFactory.cs`)
New static helper — bridges `ServiceConfig` ? `LogConfig` (required by CommonServices jobs):
```csharp
internal static class LogConfigFactory
{
public static LogConfig Create(ServiceConfig config)
{
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log");
var logConfig = new LogConfig(
LogConfig.PathType.CustomPath, logPath, null,
"Digital Data", "EnvelopeGenerator.Service");
logConfig.Debug = config.Debug;
return logConfig;
}
}
```
This `LogConfig` instance is what gets placed into every `JobDataMap[Value.LOGCONFIG]`.
---
## 5. Quartz LogProvider (`QuartzLogProvider.cs`)
C# rewrite of `LogProvider.vb` — bridges Quartz `ILogProvider` ? `ILogger<T>`:
```csharp
internal class QuartzLogProvider : ILogProvider
{
private readonly ILogger _logger;
public QuartzLogProvider(ILogger logger) => _logger = logger;
public Logger GetLogger(string name) => (level, func, ex, parameters) => { ... };
// OpenNestedContext / OpenMappedContext ? NotImplementedException (same as legacy)
}
```
---
## 6. TempFiles (`TempFiles.cs`) — Service-level only
Service-level rewrite of `Service_legacy/TempFiles.vb` using `ILogger<TempFiles>`:
- `Create()` ? create `%TEMP%\EnvelopeGenerator`, or clean existing files
- `CleanUp()` ? delete the directory on service stop
> **Does NOT replace** `CommonServices.TempFiles` — that one continues to be used
> inside each job via `LogConfig`.
---
## 7. Schedulers
### `Schedulers/SchedulerFinishEnvelope.cs`
```csharp
public class SchedulerFinishEnvelope
{
// Constructor: ILogger<SchedulerFinishEnvelope>, IOptions<ServiceConfig>
// Start(licenseKey, logConfig):
// - Build JobDataMap:
// [GDPICTURE] = licenseKey
// [LOGCONFIG] = logConfig ? LogConfigFactory output
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
// [PDF_BURNER_PARAMS] = config.PDFBurnerParams
// - Schedule FinalizeDocumentJob with Quartz (interval from config)
// - Register QuartzLogProvider
// Stop(): Scheduler.Shutdown()
}
```
### `Schedulers/SchedulerEnvelopeTaskApi.cs`
```csharp
public class SchedulerEnvelopeTaskApi
{
// Constructor: ILogger<SchedulerEnvelopeTaskApi>, IOptions<ServiceConfig>
// Start(logConfig):
// - Build JobDataMap:
// [LOGCONFIG] = logConfig
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
// - Schedule APIEnvelopeJob with Quartz (interval from config)
// - Register QuartzLogProvider
// Stop(): Scheduler.Shutdown()
}
```
---
## 8. Worker (`Worker.cs`)
Full lifecycle replacing the placeholder:
```
ExecuteAsync:
1. Read ServiceConfig from IOptions<ServiceConfig>
2. Validate ConnectionString ? throw if empty
3. Build LogConfig via LogConfigFactory
4. Connect to DB (plain connection string — Worker-level only)
5. Query: SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME='GDPICTURE' AND ACTIVE=1
? throw if null/empty (matches legacy behavior)
6. TempFiles.Create()
7. await SchedulerFinishEnvelope.Start(licenseKey, logConfig)
8. await Task.Delay(2500, stoppingToken) ? preserved from legacy
9. await SchedulerEnvelopeTaskApi.Start(logConfig)
10. await Task.Delay(Timeout.Infinite, stoppingToken) ? wait for cancellation
StopAsync override:
11. await SchedulerFinishEnvelope.Stop()
12. await SchedulerEnvelopeTaskApi.Stop()
13. TempFiles.CleanUp()
```
---
## 9. Program.cs
```csharp
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddNLog("nlog.config");
builder.Services.Configure<ServiceConfig>(
builder.Configuration.GetSection("ServiceConfig"));
builder.Services.AddSingleton<TempFiles>();
builder.Services.AddSingleton<SchedulerFinishEnvelope>();
builder.Services.AddSingleton<SchedulerEnvelopeTaskApi>();
builder.Services.AddHostedService<Worker>();
builder.Services.AddWindowsService(o => o.ServiceName = "EnvelopeGenerator.Service");
var host = builder.Build();
host.Run();
```
---
## 10. Files to Create / Modify
| File | Action | Notes |
|------|--------|-------|
| `EnvelopeGenerator.Service.csproj` | **Modify** | Add packages, project refs |
| `Program.cs` | **Modify** | Full DI/NLog/WindowsService setup |
| `Worker.cs` | **Modify** | Full service lifecycle |
| `ServiceConfig.cs` | **Create** | |
| `LogConfigFactory.cs` | **Create** | Critical bridge: ServiceConfig ? LogConfig |
| `TempFiles.cs` | **Create** | Service-level only; CommonServices.TempFiles untouched |
| `QuartzLogProvider.cs` | **Create** | |
| `Schedulers/SchedulerFinishEnvelope.cs` | **Create** | |
| `Schedulers/SchedulerEnvelopeTaskApi.cs` | **Create** | |
| `appsettings.json` | **Create** | All PDFBurnerParams explicit |
| `nlog.config` | **Create** | |
**Not created** (obsolete in new architecture):
- ~~`ProjectInstaller`~~ ? replaced by `sc create` / `New-Service`
- ~~`Service.Designer.vb`~~ ? BackgroundService handles this
- ~~`Service.resx`~~ ? not applicable
---
## 11. Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| **Quartz 3.8.0** (not 3.15.0) | Must match CommonServices; same AppDomain, one version |
| **Connection string plain-text, no encryption** | `MSSQLServer.DecryptConnectionString()` is a pass-through for plain strings; `ConnectionStrings:Default` key used (same convention as Web project) |
| **Connection string in `ConnectionStrings:Default`** | Consistent with rest of the solution (`appsettings.Database.json`); `ServiceConfig` holds only service-specific settings |
| **LogConfigFactory** | Jobs have hard dependency on `LogConfig` in JobDataMap; this is the clean bridge without leaking LogConfig into the DI container |
| **2500ms delay preserved** | Legacy behavior; ensures Scheduler1 is fully initialized before Scheduler2 starts |
| **`AddWindowsService()`** | Native SCM integration; replaces ProjectInstaller/installutil entirely |
| **NLog log path = BaseDirectory/Log** | Mirrors `My.Application.Info.DirectoryPath + "\Log"` from legacy |
| **CommonServices.TempFiles untouched** | Jobs create their own TempFiles internally via LogConfig; service-level TempFiles is separate concern |
---
## Approval Required
> Please review this plan and confirm before implementation begins.

View File

@@ -0,0 +1,41 @@
using EnvelopeGenerator.Service;
using EnvelopeGenerator.Service.Schedulers;
using Microsoft.Extensions.Options;
using NLog.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
// NLog
builder.Logging.ClearProviders();
builder.Logging.AddNLog("nlog.config");
// Configuration
builder.Services.Configure<ServiceConfig>(
builder.Configuration.GetSection("ServiceConfig"));
// Resolve connection string once for singleton registrations
var connectionString = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("ConnectionStrings:Default is not configured.");
// Services
builder.Services.AddSingleton<TempFiles>();
builder.Services.AddSingleton<SchedulerFinishEnvelope>(sp => new SchedulerFinishEnvelope(
sp.GetRequiredService<ILogger<SchedulerFinishEnvelope>>(),
sp.GetRequiredService<IOptions<ServiceConfig>>(),
connectionString));
builder.Services.AddSingleton<SchedulerEnvelopeTaskApi>(sp => new SchedulerEnvelopeTaskApi(
sp.GetRequiredService<ILogger<SchedulerEnvelopeTaskApi>>(),
sp.GetRequiredService<IOptions<ServiceConfig>>(),
connectionString));
builder.Services.AddHostedService<Worker>();
// Windows Service (SCM) support — replaces legacy ProjectInstaller/installutil
builder.Services.AddWindowsService(options =>
options.ServiceName = "EnvelopeGenerator.Service");
var host = builder.Build();
host.Run();

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net4.6.2\publish\</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<_TargetId>Folder</_TargetId>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,12 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"EnvelopeGenerator.Service": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,60 @@
using Quartz.Logging;
using QuartzLogLevel = Quartz.Logging.LogLevel;
namespace EnvelopeGenerator.Service;
/// <summary>
/// Bridges Quartz's <see cref="ILogProvider"/> to <see cref="ILogger"/>.
/// Mirrors the legacy <c>LogProvider.vb</c> behavior.
/// <c>OpenNestedContext</c> and <c>OpenMappedContext</c> are not implemented (same as legacy).
/// </summary>
internal sealed class QuartzLogProvider : ILogProvider
{
private readonly ILogger _logger;
public QuartzLogProvider(ILogger logger)
{
_logger = logger;
}
public Logger GetLogger(string name)
{
return (level, func, exception, parameters) =>
{
if (exception != null)
{
_logger.LogError(exception, exception.Message);
}
else if (func != null)
{
switch (level)
{
case QuartzLogLevel.Trace:
case QuartzLogLevel.Debug:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
_logger.LogDebug(func(), parameters);
break;
case QuartzLogLevel.Info:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Information))
_logger.LogInformation(func(), parameters);
break;
case QuartzLogLevel.Warn:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Warning))
_logger.LogWarning(func(), parameters);
break;
case QuartzLogLevel.Error:
case QuartzLogLevel.Fatal:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Error))
_logger.LogError(func(), parameters);
break;
}
}
return true;
};
}
public IDisposable OpenNestedContext(string message) => throw new NotImplementedException();
public IDisposable OpenMappedContext(string key, object value, bool destructure = false) => throw new NotImplementedException();
}

View File

@@ -0,0 +1,94 @@
using System.Collections.Specialized;
using DigitalData.Modules.Logging;
using EnvelopeGenerator.CommonServices.Jobs;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.Extensions.Options;
using Quartz;
using Quartz.Logging;
namespace EnvelopeGenerator.Service.Schedulers;
/// <summary>
/// Schedules <see cref="APIEnvelopeJob"/> via Quartz.
/// C# rewrite of the legacy <c>Scheduler_Envelopetask_API.vb</c>.
/// </summary>
public sealed class SchedulerEnvelopeTaskApi
{
private readonly ILogger<SchedulerEnvelopeTaskApi> _logger;
private readonly ServiceConfig _config;
private readonly string _connectionString;
private IScheduler? _scheduler;
private const string JobName = "SendInvitationMailsAPI";
public SchedulerEnvelopeTaskApi(
ILogger<SchedulerEnvelopeTaskApi> logger,
IOptions<ServiceConfig> config,
string connectionString)
{
_logger = logger;
_config = config.Value;
_connectionString = connectionString;
}
public async Task Start(LogConfig logConfig)
{
try
{
_logger.LogDebug("Starting SchedulerEnvelopeTaskApi...");
LogProvider.SetCurrentLogProvider(new QuartzLogProvider(_logger));
_scheduler = await SchedulerBuilder
.Create(new NameValueCollection())
.UseDefaultThreadPool(x => x.MaxConcurrency = 5)
.BuildScheduler();
var jobKey = new JobKey(JobName);
var jobData = new JobDataMap
{
{ Value.LOGCONFIG, logConfig },
{ Value.DATABASE, _connectionString }
};
_logger.LogDebug("Initialized Job [{JobName}]", JobName);
var job = JobBuilder.Create<APIEnvelopeJob>()
.UsingJobData(jobData)
.WithIdentity(jobKey)
.Build();
var trigger = TriggerBuilder.Create()
.ForJob(jobKey)
.WithIdentity($"{JobName}-trigger")
.WithSimpleSchedule(s => s
.RepeatForever()
.WithIntervalInMinutes(_config.IntervalInMin))
.StartNow()
.Build();
_logger.LogDebug("{JobName}-trigger initialized", JobName);
await _scheduler.ScheduleJob(job, trigger);
_logger.LogDebug("{JobName} scheduled", JobName);
await _scheduler.Start();
_logger.LogInformation("{JobName} started", JobName);
}
catch (Exception ex)
{
_logger.LogError(ex, "SchedulerEnvelopeTaskApi failed to start");
throw;
}
}
public async Task Stop()
{
if (_scheduler is null) return;
_logger.LogInformation("Stopping SchedulerEnvelopeTaskApi...");
await _scheduler.Shutdown();
_logger.LogInformation("SchedulerEnvelopeTaskApi stopped!");
}
}

View File

@@ -0,0 +1,97 @@
using System.Collections.Specialized;
using DigitalData.Modules.Logging;
using EnvelopeGenerator.CommonServices.Jobs;
using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.Extensions.Options;
using Quartz;
using Quartz.Logging;
namespace EnvelopeGenerator.Service.Schedulers;
/// <summary>
/// Schedules <see cref="FinalizeDocumentJob"/> via Quartz.
/// C# rewrite of the legacy <c>Scheduler_FinishEnvelope.vb</c>.
/// </summary>
public sealed class SchedulerFinishEnvelope
{
private readonly ILogger<SchedulerFinishEnvelope> _logger;
private readonly ServiceConfig _config;
private readonly string _connectionString;
private IScheduler? _scheduler;
private const string JobName = "CertificateDocumentJob";
public SchedulerFinishEnvelope(
ILogger<SchedulerFinishEnvelope> logger,
IOptions<ServiceConfig> config,
string connectionString)
{
_logger = logger;
_config = config.Value;
_connectionString = connectionString;
}
public async Task Start(string licenseKey, LogConfig logConfig)
{
try
{
_logger.LogDebug("Starting SchedulerFinishEnvelope...");
LogProvider.SetCurrentLogProvider(new QuartzLogProvider(_logger));
_scheduler = await SchedulerBuilder
.Create(new NameValueCollection())
.UseDefaultThreadPool(x => x.MaxConcurrency = 5)
.BuildScheduler();
var jobKey = new JobKey(JobName);
var jobData = new JobDataMap
{
{ Value.GDPICTURE, licenseKey },
{ Value.LOGCONFIG, logConfig },
{ Value.DATABASE, _connectionString },
{ Value.PDF_BURNER_PARAMS, _config.PDFBurnerParams }
};
_logger.LogDebug("Initialized Job [{JobName}]", JobName);
var job = JobBuilder.Create<FinalizeDocumentJob>()
.UsingJobData(jobData)
.WithIdentity(jobKey)
.Build();
var trigger = TriggerBuilder.Create()
.ForJob(jobKey)
.WithIdentity($"{JobName}-trigger")
.WithSimpleSchedule(s => s
.RepeatForever()
.WithIntervalInMinutes(_config.IntervalInMin))
.StartNow()
.Build();
_logger.LogDebug("Initialized Trigger");
await _scheduler.ScheduleJob(job, trigger);
_logger.LogDebug("Job scheduled.");
await _scheduler.Start();
_logger.LogDebug("Scheduler started!");
}
catch (Exception ex)
{
_logger.LogError(ex, "SchedulerFinishEnvelope failed to start");
throw;
}
}
public async Task Stop()
{
if (_scheduler is null) return;
_logger.LogInformation("Stopping SchedulerFinishEnvelope...");
await _scheduler.Shutdown();
_logger.LogInformation("SchedulerFinishEnvelope stopped!");
}
}

View File

@@ -0,0 +1,14 @@
using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
namespace EnvelopeGenerator.Service;
/// <summary>
/// Service-level configuration bound from the "ServiceConfig" section of appsettings.json.
/// Connection string is read separately via the standard "ConnectionStrings:Default" key.
/// </summary>
public class ServiceConfig
{
public bool Debug { get; set; } = false;
public int IntervalInMin { get; set; } = 1;
public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams();
}

View File

@@ -0,0 +1,75 @@
namespace EnvelopeGenerator.Service;
/// <summary>
/// Service-level temp file management. Mirrors <c>TempFiles.vb</c> from the legacy project.
/// Manages <c>%TEMP%\EnvelopeGenerator</c> at host startup and shutdown.
///
/// NOTE: This is independent from <c>EnvelopeGenerator.CommonServices.TempFiles</c>, which is
/// instantiated inside each Quartz job via <see cref="DigitalData.Modules.Logging.LogConfig"/>.
/// Do not confuse the two.
/// </summary>
public sealed class TempFiles
{
private readonly ILogger<TempFiles> _logger;
public string TempPath { get; }
public TempFiles(ILogger<TempFiles> logger)
{
_logger = logger;
TempPath = Path.Combine(Path.GetTempPath(), "EnvelopeGenerator");
}
public bool Create()
{
try
{
if (!Directory.Exists(TempPath))
{
Directory.CreateDirectory(TempPath);
}
else
{
CleanUpFiles();
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create temp directory: {TempPath}", TempPath);
return false;
}
}
private void CleanUpFiles()
{
try
{
foreach (var file in Directory.GetFiles(TempPath))
{
_logger.LogDebug("Deleting temp file: {File}", file);
File.Delete(file);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clean up temp files in: {TempPath}", TempPath);
}
}
public bool CleanUp()
{
try
{
_logger.LogDebug("Deleting temp directory: {TempPath}", TempPath);
Directory.Delete(TempPath, recursive: true);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete temp directory: {TempPath}", TempPath);
return false;
}
}
}

View File

@@ -0,0 +1,111 @@
using DigitalData.Modules.Database;
using EnvelopeGenerator.Service.Schedulers;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Service;
public sealed class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly ServiceConfig _config;
private readonly string _connectionString;
private readonly TempFiles _tempFiles;
private readonly SchedulerFinishEnvelope _schedulerFinishEnvelope;
private readonly SchedulerEnvelopeTaskApi _schedulerEnvelopeTaskApi;
public Worker(
ILogger<Worker> logger,
IOptions<ServiceConfig> config,
IConfiguration configuration,
TempFiles tempFiles,
SchedulerFinishEnvelope schedulerFinishEnvelope,
SchedulerEnvelopeTaskApi schedulerEnvelopeTaskApi)
{
_logger = logger;
_config = config.Value;
_connectionString = configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("ConnectionStrings:Default is not configured.");
_tempFiles = tempFiles;
_schedulerFinishEnvelope = schedulerFinishEnvelope;
_schedulerEnvelopeTaskApi = schedulerEnvelopeTaskApi;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
_logger.LogInformation("EnvelopeGenerator.Service starting...");
// 1. Validate connection string
if (string.IsNullOrWhiteSpace(_connectionString))
throw new InvalidOperationException("Connection string is empty!");
// 2. Build LogConfig (required by CommonServices jobs via JobDataMap)
var logConfig = LogConfigFactory.Create(_config);
_logger.LogInformation("DEBUG = {Debug}", _config.Debug);
// 3. Connect to DB and load GDPicture license
_logger.LogDebug("Initializing Database...");
var database = new MSSQLServer(logConfig, _connectionString);
if (!database.DBInitialized)
throw new InvalidOperationException("Database connection could not be established!");
_logger.LogDebug("Database initialized.");
var licenseKey = database.GetScalarValue(
"SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' AND ACTIVE = 1") as string;
if (string.IsNullOrWhiteSpace(licenseKey))
throw new InvalidOperationException("GDPicture license could not be loaded!");
_logger.LogDebug("GDPicture license loaded.");
// 4. Create service-level temp directory
_tempFiles.Create();
// 5. Start FinalizeDocument scheduler
_logger.LogDebug("Initializing schedulers...");
await _schedulerFinishEnvelope.Start(licenseKey!, logConfig);
// 6. Delay between schedulers (legacy behavior preserved)
await Task.Delay(2500, stoppingToken);
// 7. Start API envelope scheduler
await _schedulerEnvelopeTaskApi.Start(logConfig);
_logger.LogInformation("EnvelopeGenerator.Service started!");
// 8. Wait for cancellation (service runs until stopped)
await Task.Delay(Timeout.Infinite, stoppingToken);
}
catch (OperationCanceledException)
{
// Normal shutdown <20> not an error
}
catch (Exception ex)
{
_logger.LogError(ex, "EnvelopeGenerator.Service encountered a fatal error during startup.");
throw;
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("EnvelopeGenerator.Service stopping...");
try
{
await _schedulerFinishEnvelope.Stop();
await _schedulerEnvelopeTaskApi.Stop();
_tempFiles.CleanUp();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during service shutdown.");
}
await base.StopAsync(cancellationToken);
_logger.LogInformation("EnvelopeGenerator.Service stopped!");
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,24 @@
{
"ConnectionStrings": {
"Default": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
},
"ServiceConfig": {
"Debug": false,
"IntervalInMin": 1,
"PDFBurnerParams": {
"IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ],
"TopMargin": 0.1,
"YOffset": -0.3,
"FontName": "Arial",
"FontSize": 8,
"FontStyle": 2,
"TruncationSuffix": "..",
"TextMaxWidths": {
"position": 1.41,
"city": 1.41,
"date": 2.0
}
}
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
autoReload="true">
<targets async="true">
<target xsi:type="File"
name="file"
fileName="${basedir}/Log/service-${shortdate}.log"
layout="${longdate} | ${level:uppercase=true} | ${logger} | ${message}${onexception:${newline}${exception:format=tostring}}" />
<target xsi:type="Console"
name="console"
layout="${longdate} | ${level:uppercase=true} | ${logger} | ${message}" />
</targets>
<rules>
<logger name="*" minlevel="Debug" writeTo="file,console" />
</rules>
</nlog>

View File

@@ -23,19 +23,20 @@
"Annotations": [
{
"Name": "Signature",
"MarginTop": 0
"MarginTop": 0,
"MarginLeft": 0.20
},
{
"Name": "PositionLabel",
"VerBoundAnnotName": "Signature",
"WidthRatio": 1.2,
"HeightRatio": 0.5,
"MarginTopRatio": 0.22
"MarginTopRatio": 0.30
},
{
"Name": "Position",
"VerBoundAnnotName": "PositionLabel",
"WidthRatio": 1.2,
"WidthRatio": 1.4,
"HeightRatio": 0.5,
"MarginTopRatio": -0.05
},
@@ -49,7 +50,7 @@
{
"Name": "City",
"VerBoundAnnotName": "CityLabel",
"WidthRatio": 1.2,
"WidthRatio": 1.4,
"HeightRatio": 0.5,
"MarginTopRatio": -0.05
},

View File

@@ -11,13 +11,16 @@ async function createAnnotations(document, envelopeId, receiverId) {
//#region signatures
const id = generateId(envelopeId, receiverId, element.id, 'signature');
const boundingBox = new PSPDFKit.Geometry.Rect(annotParams.signature);
const annotation = new PSPDFKit.Annotations.WidgetAnnotation({
id: id,
pageIndex: page,
formFieldName: id,
backgroundColor: PSPDFKit.Color.LIGHT_YELLOW,
backgroundColor: null,
blendMode: 'normal',
boundingBox: new PSPDFKit.Geometry.Rect(annotParams.signature),
boundingBox: boundingBox,
})
const formField = new PSPDFKit.FormFields.SignatureFormField({
@@ -237,7 +240,7 @@ async function createAnnotationFrameBlob(receiverName, receiverSignature, timest
Comp.SignatureProgress.SignedCount += 1;
const canvas = document.createElement('canvas')
const scale = 4
const fontSize = 10
const fontSize = 8
canvas.width = width * scale
canvas.height = height * scale
@@ -276,15 +279,11 @@ async function createAnnotationFrameBlob(receiverName, receiverSignature, timest
ctx.fillStyle = 'black'
ctx.font = `${fontSize * scale}px sans-serif`
ctx.fillText((READ_AND_CONFIRM ? localized.confirmedBy : localized.signedBy) ?? 'Signed by', 15 * scale, 10 * scale)
ctx.fillText(receiverName, 15 * scale, 60 * scale)
ctx.fillText(signatureString, 15 * scale, 70 * scale)
ctx.fillText(receiverName, 15 * scale, 58.5 * scale)
ctx.fillText(signatureString, 15 * scale, 69 * scale)
return new Promise((resolve) => {
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob)
resolve(url)
})
})
const blob = await new Promise((resolve) => canvas.toBlob(resolve))
return URL.createObjectURL(blob)
}
//required

View File

@@ -37,10 +37,15 @@ class App {
});
// Load annotations into PSPDFKit
this.signatureWidgets = [] // { pageIndex, boundingBox, id }
if (!READ_AND_CONFIRM)
try {
let signatures = await createAnnotations(this.currentDocument, this.envelopeReceiver.envelopeId, this.envelopeReceiver.receiverId);
await this.pdfKit.create(signatures);
// Cache signature widget bounding boxes for stable frame placement
this.signatureWidgets = signatures
.filter(s => s instanceof PSPDFKit.Annotations.WidgetAnnotation && s.formFieldName?.includes('signature'))
.map(s => ({ id: s.id, pageIndex: s.pageIndex, boundingBox: s.boundingBox.toJS() }))
} catch (e) {
console.error("Error loading annotations:", e);
}
@@ -66,8 +71,27 @@ class App {
if (isFormField === false && isSignature === true) {
const left = annotation.boundingBox.left - 20
const top = annotation.boundingBox.top - 20
// Find the nearest signature widget on the same page to use its stable bounding box
const inkBBox = annotation.boundingBox
const samePageWidgets = this.signatureWidgets.filter(w => w.pageIndex === annotation.pageIndex)
const nearestWidget = samePageWidgets.reduce((nearest, w) => {
const dx = w.boundingBox.left - inkBBox.left
const dy = w.boundingBox.top - inkBBox.top
const dist = Math.sqrt(dx * dx + dy * dy)
return dist < nearest.dist ? { dist, widget: w } : nearest
}, { dist: Infinity, widget: null }).widget
// 1. Remove widget background after signing
if (nearestWidget) {
const pageAnnots = await this.pdfKit.getAnnotations(annotation.pageIndex)
const widgetAnnot = pageAnnots.find(a => a.id === nearestWidget.id)
if (widgetAnnot)
await this.pdfKit.update(widgetAnnot.set('backgroundColor', PSPDFKit.Color.TRANSPARENT))
}
const bbox = nearestWidget?.boundingBox ?? inkBBox
const left = bbox.left - 20
const top = bbox.top - 12
const width = 150
const height = 75
const timestamp = new Date()
@@ -330,7 +354,7 @@ class App {
}
async handleReset(event) {
const result = Swal.fire({
const result = await Swal.fire({
title: localized.resetConfirmTitle,
text: localized.resetConfirmText,
icon: 'question',
@@ -338,7 +362,24 @@ class App {
})
if (result.isConfirmed) {
const result = await deleteAnnotations(this.pdfKit)
await deleteAnnotations(this.pdfKit)
// Reset editable text form field values (Position, City)
const allFormFields = await this.pdfKit.getFormFields()
const editableFields = allFormFields.filter(f =>
f instanceof PSPDFKit.FormFields.TextFormField && !f.readOnly
)
for (const field of editableFields) {
await this.pdfKit.setFormFieldValues({ [field.name]: '' })
}
// Restore widget backgrounds that were made transparent after signing
for (const w of this.signatureWidgets) {
const pageAnnots = await this.pdfKit.getAnnotations(w.pageIndex)
const widgetAnnot = pageAnnots.find(a => a.id === w.id)
if (widgetAnnot)
await this.pdfKit.update(widgetAnnot.set('backgroundColor', null))
}
}
return result

View File

@@ -1,3 +1,3 @@
const formatLocalized=(n,...t)=>typeof n=="string"?n.replace(/\{(\d+)\}/g,(n,i)=>t[i]??""):"";class App{constructor(n,t,i,r,u,f){this.container=f??`#${this.constructor.name.toLowerCase()}`;this.envelopeKey=n;this.pdfKit=null;this.currentDocument=t.envelope.documents[0];this.currentReceiver=t.receiver;this.signatureCount=t.envelope.documents[0].elements.length;this.envelopeReceiver=t;this.documentBytes=i;this.licenseKey=r;this.locale=u}async init(){if(this.pdfKit=await loadPSPDFKit(this.documentBytes,this.container,this.licenseKey,this.locale),addToolbarItems(this.pdfKit,this.handleClick.bind(this)),this.pdfKit.addEventListener("annotations.load",this.handleAnnotationsLoad.bind(this)),this.pdfKit.addEventListener("annotations.change",this.handleAnnotationsChange.bind(this)),this.pdfKit.addEventListener("annotations.create",this.handleAnnotationsCreate.bind(this)),this.pdfKit.addEventListener("annotations.willChange",()=>{Comp.ActPanel.Toggle()}),!READ_AND_CONFIRM)try{let n=await createAnnotations(this.currentDocument,this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId);await this.pdfKit.create(n)}catch(n){console.error("Error loading annotations:",n)}READ_AND_CONFIRM||[...document.getElementsByClassName("btn_refresh")].forEach(n=>n.addEventListener("click",()=>this.handleClick("RESET")));[...document.getElementsByClassName("btn_complete")].forEach(n=>n.addEventListener("click",()=>this.handleClick("FINISH")));[...document.getElementsByClassName("btn_reject")].forEach(n=>n.addEventListener("click",()=>this.handleClick("REJECT")))}handleAnnotationsLoad(n){n.toJS()}handleAnnotationsChange(){}async handleAnnotationsCreate(n){const t=n.toJS()[0],i=!!t.formFieldName,r=!!t.isSignature;if(i===!1&&r===!0){const r=t.boundingBox.left-20,u=t.boundingBox.top-20,n=150,i=75,f=new Date,e=await createAnnotationFrameBlob(this.envelopeReceiver.name,this.currentReceiver.signature,f,n,i),o=await fetch(e),s=await o.blob(),h=await this.pdfKit.createAttachment(s),c=createImageAnnotation(new PSPDFKit.Geometry.Rect({left:r,top:u,width:n,height:i}),t.pageIndex,h,generateId(this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId,this.fakeElementId--,"signed"));this.pdfKit.create(c)}}async handleClick(n){let t=!1;switch(n){case"RESET":t=await this.handleReset(null);Comp.SignatureProgress.SignedCount=0;t.isConfirmed&&Swal.fire({title:localized.success,text:localized.documentReset,icon:"info"});break;case"FINISH":t=await this.handleFinish(null);t==!0&&(window.location.href=`/Envelope/${this.envelopeKey}`);break;case"REJECT":Swal.fire({title:localized.rejection,html:`<div class="text-start fs-6 p-0 m-0">${localized.rejectionReasonQ}</div>`,icon:"question",input:"text",inputAttributes:{autocapitalize:"off"},showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.complete,cancelButtonText:localized.back,showLoaderOnConfirm:!0,preConfirm:async n=>{try{return await rejectEnvelope(n)}catch(t){Swal.showValidationMessage(`
const formatLocalized=(n,...t)=>typeof n=="string"?n.replace(/\{(\d+)\}/g,(n,i)=>t[i]??""):"";class App{constructor(n,t,i,r,u,f){this.container=f??`#${this.constructor.name.toLowerCase()}`;this.envelopeKey=n;this.pdfKit=null;this.currentDocument=t.envelope.documents[0];this.currentReceiver=t.receiver;this.signatureCount=t.envelope.documents[0].elements.length;this.envelopeReceiver=t;this.documentBytes=i;this.licenseKey=r;this.locale=u}async init(){if(this.pdfKit=await loadPSPDFKit(this.documentBytes,this.container,this.licenseKey,this.locale),addToolbarItems(this.pdfKit,this.handleClick.bind(this)),this.pdfKit.addEventListener("annotations.load",this.handleAnnotationsLoad.bind(this)),this.pdfKit.addEventListener("annotations.change",this.handleAnnotationsChange.bind(this)),this.pdfKit.addEventListener("annotations.create",this.handleAnnotationsCreate.bind(this)),this.pdfKit.addEventListener("annotations.willChange",()=>{Comp.ActPanel.Toggle()}),this.signatureWidgets=[],!READ_AND_CONFIRM)try{let n=await createAnnotations(this.currentDocument,this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId);await this.pdfKit.create(n);this.signatureWidgets=n.filter(n=>n instanceof PSPDFKit.Annotations.WidgetAnnotation&&n.formFieldName?.includes("signature")).map(n=>({id:n.id,pageIndex:n.pageIndex,boundingBox:n.boundingBox.toJS()}))}catch(n){console.error("Error loading annotations:",n)}READ_AND_CONFIRM||[...document.getElementsByClassName("btn_refresh")].forEach(n=>n.addEventListener("click",()=>this.handleClick("RESET")));[...document.getElementsByClassName("btn_complete")].forEach(n=>n.addEventListener("click",()=>this.handleClick("FINISH")));[...document.getElementsByClassName("btn_reject")].forEach(n=>n.addEventListener("click",()=>this.handleClick("REJECT")))}handleAnnotationsLoad(n){n.toJS()}handleAnnotationsChange(){}async handleAnnotationsCreate(n){const t=n.toJS()[0],i=!!t.formFieldName,r=!!t.isSignature;if(i===!1&&r===!0){const n=t.boundingBox,e=this.signatureWidgets.filter(n=>n.pageIndex===t.pageIndex),i=e.reduce((t,i)=>{const r=i.boundingBox.left-n.left,u=i.boundingBox.top-n.top,f=Math.sqrt(r*r+u*u);return f<t.dist?{dist:f,widget:i}:t},{dist:Infinity,widget:null}).widget;if(i){const r=await this.pdfKit.getAnnotations(t.pageIndex),n=r.find(n=>n.id===i.id);n&&await this.pdfKit.update(n.set("backgroundColor",PSPDFKit.Color.TRANSPARENT))}const r=i?.boundingBox??n,o=r.left-20,s=r.top-12,u=150,f=75,h=new Date,c=await createAnnotationFrameBlob(this.envelopeReceiver.name,this.currentReceiver.signature,h,u,f),l=await fetch(c),a=await l.blob(),v=await this.pdfKit.createAttachment(a),y=createImageAnnotation(new PSPDFKit.Geometry.Rect({left:o,top:s,width:u,height:f}),t.pageIndex,v,generateId(this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId,this.fakeElementId--,"signed"));this.pdfKit.create(y)}}async handleClick(n){let t=!1;switch(n){case"RESET":t=await this.handleReset(null);Comp.SignatureProgress.SignedCount=0;t.isConfirmed&&Swal.fire({title:localized.success,text:localized.documentReset,icon:"info"});break;case"FINISH":t=await this.handleFinish(null);t==!0&&(window.location.href=`/Envelope/${this.envelopeKey}`);break;case"REJECT":Swal.fire({title:localized.rejection,html:`<div class="text-start fs-6 p-0 m-0">${localized.rejectionReasonQ}</div>`,icon:"question",input:"text",inputAttributes:{autocapitalize:"off"},showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.complete,cancelButtonText:localized.back,showLoaderOnConfirm:!0,preConfirm:async n=>{try{return await rejectEnvelope(n)}catch(t){Swal.showValidationMessage(`
Request failed: ${t}
`)}},allowOutsideClick:()=>!Swal.isLoading()}).then(n=>{if(n.isConfirmed){const t=n.value;t.ok?reload():Swal.showValidationMessage(`Request failed: ${t.message}`)}});break;case"COPY_URL":const n=window.location.href.replace(/\/readonly/gi,"");navigator.clipboard.writeText(n).then(function(){bsNotify(localized.copyLinkSuccess,{alert_type:"success",delay:4,icon_name:"check_circle"})}).catch(function(){bsNotify(localized.copyLinkFailure??localized.unexpectedErrorTitle,{alert_type:"danger",delay:4,icon_name:"error"})});break;case"SHARE":Comp.ShareBackdrop.show();break;case"LOGOUT":await logout()}}async handleFinish(){let n=undefined;if(READ_AND_CONFIRM){const n=JSON.parse(sessionStorage.getItem("pspdf_all_pages_rendered")||"false")===!0;if(!n){const n=JSON.parse(sessionStorage.getItem("pspdf_unviewed_pages")||"[]"),t=n.length?formatLocalized(localized.viewRemainingPages,n.join(", ")):localized.viewAllPages;return await Swal.fire({title:localized.warning,text:t,icon:"warning"}),!1}}else{const i=await this.pdfKit.exportInstantJSON(),r=i.formFieldValues,u=r.filter(n=>isFieldRequired(n)),f=u.some(n=>n.value===undefined||n.value===null||n.value==="");if(f)return Swal.fire({title:localized.warning,text:localized.locationFieldsRequired,icon:"warning"}),!1;const e=new RegExp("^[a-zA-Z\\u0080-\\u024F]+(?:([\\ \\-\\']|(\\.\\ ))[a-zA-Z\\u0080-\\u024F]+)*$"),o=r.filter(n=>isCityField(n));for(var t of o)if(!IS_MOBILE_DEVICE&&!e.test(t.value))return Swal.fire({title:localized.warning,text:formatLocalized(localized.cityFormatInvalid,t.value),icon:"warning"}),!1;const s=await this.validateAnnotations(this.signatureCount);if(s===!1)return Swal.fire({title:localized.warning,text:localized.missingSignatures,icon:"warning"}),!1;n={instant:i,structured:mapSignature(i)}}return Swal.fire({title:localized.confirmation,html:`<div class="text-start fs-6 p-0 m-0">${READ_AND_CONFIRM?localized.confirmAgree:localized.sigAgree}</div>`,icon:"question",showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.finalize,cancelButtonText:localized.back}).then(async t=>{if(t.isConfirmed){try{await this.pdfKit.save()}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}try{const t=READ_AND_CONFIRM?await signEnvelope():await signEnvelope(n);if(t.ok)return!0;if(t.status===409)return Swal.fire({title:localized.warning,text:localized.envelopeUnavailable,icon:"warning"}),!1;if(t.status===423)Swal.fire({title:localized.info??localized.warning,text:localized.envelopeRejectedRedirect,icon:"info",timer:2e3,showConfirmButton:!1}).then(()=>{location.reload()});else throw new Error;}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}}else return!1})}async validateAnnotations(n){const t=await getAnnotations(this.pdfKit),i=t.map(n=>n.toJS()).filter(n=>n.isSignature);return n<=i.length}async handleReset(){const n=Swal.fire({title:localized.resetConfirmTitle,text:localized.resetConfirmText,icon:"question",showCancelButton:!0});if(n.isConfirmed){const n=await deleteAnnotations(this.pdfKit)}return n}fakeElementId=0;}
`)}},allowOutsideClick:()=>!Swal.isLoading()}).then(n=>{if(n.isConfirmed){const t=n.value;t.ok?reload():Swal.showValidationMessage(`Request failed: ${t.message}`)}});break;case"COPY_URL":const n=window.location.href.replace(/\/readonly/gi,"");navigator.clipboard.writeText(n).then(function(){bsNotify(localized.copyLinkSuccess,{alert_type:"success",delay:4,icon_name:"check_circle"})}).catch(function(){bsNotify(localized.copyLinkFailure??localized.unexpectedErrorTitle,{alert_type:"danger",delay:4,icon_name:"error"})});break;case"SHARE":Comp.ShareBackdrop.show();break;case"LOGOUT":await logout()}}async handleFinish(){let n=undefined;if(READ_AND_CONFIRM){const n=JSON.parse(sessionStorage.getItem("pspdf_all_pages_rendered")||"false")===!0;if(!n){const n=JSON.parse(sessionStorage.getItem("pspdf_unviewed_pages")||"[]"),t=n.length?formatLocalized(localized.viewRemainingPages,n.join(", ")):localized.viewAllPages;return await Swal.fire({title:localized.warning,text:t,icon:"warning"}),!1}}else{const i=await this.pdfKit.exportInstantJSON(),r=i.formFieldValues,u=r.filter(n=>isFieldRequired(n)),f=u.some(n=>n.value===undefined||n.value===null||n.value==="");if(f)return Swal.fire({title:localized.warning,text:localized.locationFieldsRequired,icon:"warning"}),!1;const e=new RegExp("^[a-zA-Z\\u0080-\\u024F]+(?:([\\ \\-\\']|(\\.\\ ))[a-zA-Z\\u0080-\\u024F]+)*$"),o=r.filter(n=>isCityField(n));for(var t of o)if(!IS_MOBILE_DEVICE&&!e.test(t.value))return Swal.fire({title:localized.warning,text:formatLocalized(localized.cityFormatInvalid,t.value),icon:"warning"}),!1;const s=await this.validateAnnotations(this.signatureCount);if(s===!1)return Swal.fire({title:localized.warning,text:localized.missingSignatures,icon:"warning"}),!1;n={instant:i,structured:mapSignature(i)}}return Swal.fire({title:localized.confirmation,html:`<div class="text-start fs-6 p-0 m-0">${READ_AND_CONFIRM?localized.confirmAgree:localized.sigAgree}</div>`,icon:"question",showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.finalize,cancelButtonText:localized.back}).then(async t=>{if(t.isConfirmed){try{await this.pdfKit.save()}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}try{const t=READ_AND_CONFIRM?await signEnvelope():await signEnvelope(n);if(t.ok)return!0;if(t.status===409)return Swal.fire({title:localized.warning,text:localized.envelopeUnavailable,icon:"warning"}),!1;if(t.status===423)Swal.fire({title:localized.info??localized.warning,text:localized.envelopeRejectedRedirect,icon:"info",timer:2e3,showConfirmButton:!1}).then(()=>{location.reload()});else throw new Error;}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}}else return!1})}async validateAnnotations(n){const t=await getAnnotations(this.pdfKit),i=t.map(n=>n.toJS()).filter(n=>n.isSignature);return n<=i.length}async handleReset(){const n=await Swal.fire({title:localized.resetConfirmTitle,text:localized.resetConfirmText,icon:"question",showCancelButton:!0});if(n.isConfirmed){await deleteAnnotations(this.pdfKit);const n=await this.pdfKit.getFormFields(),t=n.filter(n=>n instanceof PSPDFKit.FormFields.TextFormField&&!n.readOnly);for(const n of t)await this.pdfKit.setFormFieldValues({[n.name]:""});for(const n of this.signatureWidgets){const i=await this.pdfKit.getAnnotations(n.pageIndex),t=i.find(t=>t.id===n.id);t&&await this.pdfKit.update(t.set("backgroundColor",null))}}return n}fakeElementId=0;}

View File

@@ -9,8 +9,6 @@ Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.CommonSer
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Web", "EnvelopeGenerator.Web\EnvelopeGenerator.Web.csproj", "{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}"
EndProject
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.vbproj", "{83ED2617-B398-4859-8F59-B38F8807E83E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Domain", "EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj", "{4F32A98D-E6F0-4A09-BD97-1CF26107E837}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Infrastructure", "EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj", "{63E32615-0ECA-42DC-96E3-91037324B7C7}"
@@ -22,6 +20,8 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{134D4164-B291-4E19-99B9-E4FA3AFAB62C}"
ProjectSection(SolutionItems) = preProject
COPILOT_CONTEXT.md = COPILOT_CONTEXT.md
fix-report-label-read-and-confirmed.md = fix-report-label-read-and-confirmed.md
fix-signature-field-formatting.md = fix-signature-field-formatting.md
FORM_APPLICATION_CONTEXT.md = FORM_APPLICATION_CONTEXT.md
RECEIVER_PDF_VIEWER_CONTEXT.md = RECEIVER_PDF_VIEWER_CONTEXT.md
EndProjectSection
@@ -53,6 +53,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server.Client", "EnvelopeGenerator.Server\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj", "{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}"
EndProject
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.Service_legacy", "EnvelopeGenerator.Service_legacy\EnvelopeGenerator.Service_legacy.vbproj", "{83ED2617-B398-4859-8F59-B38F8807E83E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.csproj", "{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -71,10 +75,6 @@ Global
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.ActiveCfg = Debug|Any CPU
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.Build.0 = Debug|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.ActiveCfg = Release|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.Build.0 = Release|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.ActiveCfg = Debug|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.Build.0 = Debug|Any CPU
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Release|Any CPU.ActiveCfg = Debug|Any CPU
@@ -119,6 +119,14 @@ Global
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Release|Any CPU.Build.0 = Release|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.Build.0 = Release|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -127,7 +135,6 @@ Global
{089D5634-FB6B-42D0-B912-7AA7457044E7} = {0CBC2432-A561-4440-89BC-671B66A24146}
{6EA0C51F-C2B1-4462-8198-3DE0B32B74F8} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{4F32A98D-E6F0-4A09-BD97-1CF26107E837} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{63E32615-0ECA-42DC-96E3-91037324B7C7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{5A9984F8-51A2-4558-A415-EC5FEED7CF7D} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
@@ -143,6 +150,8 @@ Global
{BF1700D5-592E-4FFA-84E8-5480E289A1F0} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{4E6C54DA-576D-0955-2564-9EC890BB8279} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}

View File

@@ -0,0 +1,135 @@
# Fix: Report Label "Read and confirmed" for Read and Sign Envelopes
## Status
✅ **COMPLETED**
## Git Branch
```
fix/report-label-read-and-confirmed
```
---
## Problem
In the **Signature Certificate report** (`Signierungszertifikat`), the history event
list always showed **"Document signed"** for status `DocumentSigned` (code `2005`) <20>
even when the envelope type was **"Read and Sign"** (`EnvelopeTypeId = 2`).
For "Read and Sign" envelopes the correct label must be **"Read and confirmed"**
(DE: **"Gelesen und best<73>tigt"**).
### Screenshot reference
The report shows a table with columns *Ereignis | Benutzer | Zeitstempel*.
The row with status 2005 displayed "Document signed" where "Read and confirmed"
was expected.
---
## Root Cause
`ReportItem.ItemStatusTranslated` in
`EnvelopeGenerator.CommonServices\Jobs\FinalizeDocument\ReportItem.vb`
resolved the display label by calling:
```vb
My.Resources.Model.ResourceManager.GetString(ItemStatus.ToString())
```
There was **no branching logic** to handle the Read and Sign envelope type.
The same `DocumentSigned` key was always used regardless of envelope type.
---
## Solution
### Convention followed
The project already uses a `RaC` (**R**ead **a**nd **C**onfirm) suffix for
Read-and-Sign label variants across all resource files:
| Existing RaC keys |
|---|
| `DocumentRejectedRaC` |
| `EnvelopeReportCreatedRaC` |
| `MessageConfirmationSentRaC` |
### New resource key added: `DocumentSignedRaC`
| Resource file | Value |
|---|---|
| `Model.resx` (DE, default) | `Gelesen und best<73>tigt` |
| `Model.en.resx` (EN) | `Read and confirmed` |
### Logic change in `ReportItem.vb`
```vb
Public ReadOnly Property ItemStatusTranslated As String
Get
Dim oStatusKey = ItemStatus.ToString()
If ItemStatus = EnvelopeStatus.DocumentSigned AndAlso
Envelope IsNot Nothing AndAlso
Envelope.IsReadAndConfirm() Then
oStatusKey = "DocumentSignedRaC"
End If
Return My.Resources.Model.ResourceManager.GetString(oStatusKey)
End Get
End Property
```
`Envelope.IsReadAndConfirm()` is defined in
`EnvelopeGenerator.Domain\Interfaces\IEnvelope.cs` and returns `true`
when `EnvelopeTypeId == 2`.
---
## Files Changed
| # | File | Change | Status |
|---|---|---|---|
| 1 | `EnvelopeGenerator.CommonServices\Jobs\FinalizeDocument\ReportItem.vb` | Added `RaC` branching logic to `ItemStatusTranslated` | ? Done |
| 2 | `EnvelopeGenerator.Domain\Resources\Model.resx` | Added `DocumentSignedRaC` = `Gelesen und bestätigt` | ✅ Done |
| 3 | `EnvelopeGenerator.CommonServices\Strings\Model.resx` | Added `DocumentSignedRaC` = `Gelesen und bestätigt` | ✅ Done |
| 4 | `EnvelopeGenerator.CommonServices\Strings\Model.en.resx` | Added `DocumentSignedRaC` = `Read and confirmed` | ✅ Done |
| 5 | `EnvelopeGenerator.Domain\Resources\Model.Designer.cs` | Added `DocumentSignedRaC` strongly-typed property | ✅ Done |
---
## How to resume (for next agent)
All pending items are **resource file additions only**.
Each file needs one new `<data>` block inserted **directly after** the existing
`DocumentSigned` entry:
```xml
<data name="DocumentSignedRaC" xml:space="preserve">
<value>Gelesen und best<73>tigt</value> <!-- DE files -->
<!-- OR -->
<value>Read and confirmed</value> <!-- EN file -->
</data>
```
`Model.Designer.cs` also needs a new strongly-typed property added after
`DocumentSigned`:
```csharp
/// <summary>
/// Looks up a localized string similar to Gelesen und best<73>tigt.
/// </summary>
public static string DocumentSignedRaC {
get {
return ResourceManager.GetString("DocumentSignedRaC", resourceCulture);
}
}
```
---
## Testing
After changes, generate a Signature Certificate report for a **Read and Sign**
envelope and verify:
- History row with status 2005 shows **"Read and confirmed"** (EN)
- History row with status 2005 shows **"Gelesen und best<73>tigt"** (DE)
- Regular (non-RaC) envelopes still show **"Document signed"** / **"Dokument unterzeichnet"**

View File

@@ -0,0 +1,111 @@
# Signature Field Formatting — WISAG Feedback
## Context
Customer feedback received from the new WISAG signFLOW project lead (email dated 3/7/2026).
The signature stamp rendered inside the finalized PDF has layout issues that need to be addressed.
### Current State (screenshot from customer email)
```
Signiert von ___________
[ signature image ]
Armin Schneider
C6B85E96A520C60... ← certificate hash, too close to name
Bereichsleiter Kern- ← position text wraps, no spacing to date
Anwendungen
Frankfurt am Main
23.06.2026, 17:13:42 GMT+2
```
### Issues Reported
1. **Spacing between name and certificate number** is too small — needs more vertical gap.
2. **Spacing between position/title and date/time** is too small — should be dynamic (expand with content).
3. *(Secondary, out of scope here)* Multiple signature fields side-by-side scale individually, causing uneven heights.
---
## Agreed Solution
> **"Height stays fixed. Text stretches horizontally. Beyond a certain character limit, truncate with '...'"**
> *(Original: "Höhe bleibt fix. Text dehnt sich horizontal aus. Ab einem bestimmten Limit wird mit '...' abgekürzt.")*
### Rationale
- The signature field dimensions are **fixed** (`1.77" × 1.96"`). Allowing text to wrap causes position and date rows to collide vertically.
- Truncating long strings with ellipsis (`...`) keeps the layout deterministic and prevents overflow.
- Per-field max-length limits allow tuning each text row independently (name, certificate, position, city, date).
---
## Implementation Plan
### 1. `PDFBurnerParams.vb` — Add per-field max-length dictionary
Add a new property `TextMaxLengths` that maps each field name (matching `EGName` keys) to a maximum character count.
```vbnet
' EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurnerParams.vb
Public Property TextMaxLengths As New Dictionary(Of String, Integer) From {
{"name", 28},
{"position", 32},
{"city", 24},
{"date", 28}
}
```
Default values are starting points and should be tuned against real customer data.
### 2. `PDFBurner.vb` — Add truncation helper and apply in `AddFormFieldValue`
#### 2a. Add a private helper function
```vbnet
Private Function TruncateText(value As String, fieldName As String) As String
If String.IsNullOrEmpty(value) Then Return value
Dim maxLen As Integer = 0
If _pdfBurnerParams.TextMaxLengths IsNot Nothing AndAlso
_pdfBurnerParams.TextMaxLengths.TryGetValue(fieldName, maxLen) AndAlso
maxLen > 0 AndAlso value.Length > maxLen Then
Return value.Substring(0, maxLen - 3) & "..."
End If
Return value
End Function
```
#### 2b. Apply truncation in the `egName`-aware overload
In `AddFormFieldValue(pAnnotation As Annotation, formFieldValue As FormFieldValue)`:
```vbnet
' Before passing value to AddTextAnnot, truncate:
Dim displayValue As String = TruncateText(formFieldValue.value, pAnnotation.egName)
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, displayValue)
```
#### 2c. Apply truncation in the element-based overload (optional)
In `AddFormFieldValue(x, y, width, height, page, value)` — if a field name is available in calling context, pass it through; otherwise this path does not use `egName` and truncation can be skipped or applied with a generic fallback key.
---
## Affected Files
| File | Change |
|------|--------|
| `EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurnerParams.vb` | Add `TextMaxLengths As Dictionary(Of String, Integer)` |
| `EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurner.vb` | Add `TruncateText()` helper; apply in `AddFormFieldValue` overloads |
---
## Notes
- Field names (`"name"`, `"position"`, `"city"`, `"date"`) must match the keys used in `EGName.Index` dictionary (`PDFBurner.vb:443–450`).
- The certificate hash field (e.g. `C6B85E96A520C60...`) is already truncated upstream — verify its `egName` mapping if it needs its own limit.
- The `YOffset` / `TopMargin` values in `PDFBurnerParams` may also need adjustment to increase vertical spacing between rows. That is a separate (simpler) change.
- This issue is **not** related to the Blazor WebUI rendering — it is purely in the GdPicture14 PDF burning pipeline.