Compare commits
41 Commits
feat/migr-
...
01cd8b2607
| Author | SHA1 | Date | |
|---|---|---|---|
| 01cd8b2607 | |||
| 310d2efc6e | |||
| e5402bafc9 | |||
| f517ab4cfb | |||
| 6d63f9db2b | |||
| 46c7c31e03 | |||
| 8f6db435bb | |||
| 9bf7676464 | |||
| d9556c18ca | |||
| 5b7157f422 | |||
| 119ee3ec16 | |||
| b536b31a4b | |||
| a80efee075 | |||
| 86d6daafbd | |||
| aefa1a8883 | |||
| b3e7f77165 | |||
| bac9bfd036 | |||
| 6effb8a0d6 | |||
| a9ff0d88f2 | |||
| 0fdfe991ea | |||
| 7fc2c14f9c | |||
| 5098deedd1 | |||
| 2e634361c5 | |||
| 60ebebc0d8 | |||
| c2d8b6531c | |||
| 665266457f | |||
| 14d581acac | |||
| eb533a1dc2 | |||
| 36025fad7b | |||
| 0606efabf9 | |||
| 2cb1e266f7 | |||
| 35c1374b9c | |||
| feaea1626b | |||
| babd96200a | |||
| f9cfcf1f19 | |||
| ffb9e07466 | |||
| 5384a08905 | |||
| dda5ff28e1 | |||
| 043349a47f | |||
| c36f7ccf98 | |||
| cf3e85bcd7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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&Service\0 DD - Smart UP\signFLOW\API\net8\$(Version)\EnvelopeGenerator.API.zip</DesktopBuildPackageLocation>
|
||||
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\signFLOW\PreRelease\API\net8\$(Version)\EnvelopeGenerator.API.zip</DesktopBuildPackageLocation>
|
||||
<PackageAsSingleFile>true</PackageAsSingleFile>
|
||||
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
|
||||
<_TargetId>IISWebDeployPackage</_TargetId>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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()}"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
46
EnvelopeGenerator.Infrastructure/UserPreferencesService.cs
Normal file
46
EnvelopeGenerator.Infrastructure/UserPreferencesService.cs
Normal 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
|
||||
@@ -9,7 +9,7 @@
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<ProjectGuid>fb2d306b-1042-4a70-31ed-f991a1599371</ProjectGuid>
|
||||
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\signFLOW\ReceiverUI\net8\$(Version)\EnvelopeGenerator.ReceiverUI.zip</DesktopBuildPackageLocation>
|
||||
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\signFLOW\PreRelease\ReceiverUI\net8\$(Version)\EnvelopeGenerator.ReceiverUI.zip</DesktopBuildPackageLocation>
|
||||
<PackageAsSingleFile>true</PackageAsSingleFile>
|
||||
<DeployIisAppPath>EnvelopeGenerator.ReceiverUI</DeployIisAppPath>
|
||||
<_TargetId>IISWebDeployPackage</_TargetId>
|
||||
|
||||
@@ -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>© SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
|
||||
<span class="receiver-footer__sep">|</span>
|
||||
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
|
||||
<div class="receiver-footer__left">
|
||||
<span>© SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
|
||||
<span class="receiver-footer__sep">|</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"
|
||||
};
|
||||
|
||||
protected async override Task OnInitializedAsync() {
|
||||
await FontLoader.LoadFonts(HttpClientFactory, RequiredFonts);
|
||||
private Action? _themeChangeHandler;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()) { }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -362,18 +464,15 @@
|
||||
try
|
||||
{
|
||||
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
|
||||
|
||||
|
||||
// Split into active and completed based on status
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>.
|
||||
<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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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&Service\0 DD - Smart UP\signFLOW\API\net8\$(Version)\EnvelopeGenerator.Server.zip</DesktopBuildPackageLocation>
|
||||
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\signFLOW\PreRelease\API\net8\$(Version)\EnvelopeGenerator.Server.zip</DesktopBuildPackageLocation>
|
||||
<PackageAsSingleFile>true</PackageAsSingleFile>
|
||||
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
|
||||
<_TargetId>IISWebDeployPackage</_TargetId>
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
415
EnvelopeGenerator.Server/SF-74-PLAN.md
Normal file
415
EnvelopeGenerator.Server/SF-74-PLAN.md
Normal 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*
|
||||
@@ -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.
|
||||
|
||||
41
EnvelopeGenerator.Service/EnvelopeGenerator.Service.csproj
Normal file
41
EnvelopeGenerator.Service/EnvelopeGenerator.Service.csproj
Normal 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>
|
||||
28
EnvelopeGenerator.Service/LogConfigFactory.cs
Normal file
28
EnvelopeGenerator.Service/LogConfigFactory.cs
Normal 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><BaseDirectory>\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;
|
||||
}
|
||||
}
|
||||
315
EnvelopeGenerator.Service/MIGRATION_PLAN.md
Normal file
315
EnvelopeGenerator.Service/MIGRATION_PLAN.md
Normal 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.
|
||||
|
||||
41
EnvelopeGenerator.Service/Program.cs
Normal file
41
EnvelopeGenerator.Service/Program.cs
Normal 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();
|
||||
|
||||
@@ -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>
|
||||
12
EnvelopeGenerator.Service/Properties/launchSettings.json
Normal file
12
EnvelopeGenerator.Service/Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"EnvelopeGenerator.Service": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
EnvelopeGenerator.Service/QuartzLogProvider.cs
Normal file
60
EnvelopeGenerator.Service/QuartzLogProvider.cs
Normal 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();
|
||||
}
|
||||
@@ -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!");
|
||||
}
|
||||
}
|
||||
@@ -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!");
|
||||
}
|
||||
}
|
||||
14
EnvelopeGenerator.Service/ServiceConfig.cs
Normal file
14
EnvelopeGenerator.Service/ServiceConfig.cs
Normal 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();
|
||||
}
|
||||
75
EnvelopeGenerator.Service/TempFiles.cs
Normal file
75
EnvelopeGenerator.Service/TempFiles.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
111
EnvelopeGenerator.Service/Worker.cs
Normal file
111
EnvelopeGenerator.Service/Worker.cs
Normal 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!");
|
||||
}
|
||||
}
|
||||
8
EnvelopeGenerator.Service/appsettings.Development.json
Normal file
8
EnvelopeGenerator.Service/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
EnvelopeGenerator.Service/appsettings.json
Normal file
24
EnvelopeGenerator.Service/appsettings.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
20
EnvelopeGenerator.Service/nlog.config
Normal file
20
EnvelopeGenerator.Service/nlog.config
Normal 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>
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
4
EnvelopeGenerator.Web/wwwroot/js/app.min.js
vendored
4
EnvelopeGenerator.Web/wwwroot/js/app.min.js
vendored
@@ -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;}
|
||||
@@ -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}
|
||||
|
||||
135
fix-report-label-read-and-confirmed.md
Normal file
135
fix-report-label-read-and-confirmed.md
Normal 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"**
|
||||
111
fix-signature-field-formatting.md
Normal file
111
fix-signature-field-formatting.md
Normal 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.
|
||||
Reference in New Issue
Block a user