From 8f4b7513033ec3f45b1d0729f0a1d9c2793adb13 Mon Sep 17 00:00:00 2001 From: TekH Date: Tue, 30 Jun 2026 23:27:20 +0200 Subject: [PATCH] Add envelope report page with signature capture Added a new Razor page `EnvelopeReceiverReportPage.razor` to display and manage envelope reports at the route `/envelope/{EnvelopeKey}/report`. Integrated DevExpress Blazor Reporting components (`DxReportViewer`, `DxPopup`) for rendering PDF documents and capturing user signatures. Implemented a multi-tab signature capture interface supporting drawing, text input with font selection, and image uploads. Added support for dynamically overlaying captured signatures on PDF documents using `XRPictureBox`. Introduced dependency injection for services like `AuthService`, `ReceiverAuthorizationService`, and `PageDataService` to handle authentication, data retrieval, and logging. Included lifecycle methods for user authorization, PDF loading, and restoring cached signatures. Added validation for signature input, error handling for missing data, and utility methods for building reports, extracting PDF page counts, and converting base64 data URLs. Integrated JavaScript interop for canvas-based signature handling. Included custom styles and assets, and implemented disposal logic for cleaning up resources. --- .../Pages/EnvelopeReceiverReportPage.razor | 771 ++++++++++++++++++ 1 file changed, 771 insertions(+) create mode 100644 EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverReportPage.razor diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverReportPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverReportPage.razor new file mode 100644 index 00000000..06b44f44 --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverReportPage.razor @@ -0,0 +1,771 @@ +@page "/envelope/{EnvelopeKey}/report" +@rendermode InteractiveServer +@using DevExpress.Blazor.Reporting +@using DevExpress.XtraReports.UI +@using DevExpress.XtraPrinting +@using DevExpress.XtraPrinting.Drawing +@using EnvelopeGenerator.Server.Client.Models +@using EnvelopeGenerator.Server.Client.Models.Constants +@using EnvelopeGenerator.Server.Client.Services +@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver +@using Microsoft.JSInterop +@using DevExpress.Blazor +@using System.Drawing +@using System.Security.Claims +@inject NavigationManager Navigation +@inject IJSRuntime JSRuntime +@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService +@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService +@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService +@inject AppVersionService AppVersion +@inject ILogger Logger +@implements IDisposable + + + + + + +
+
+
+ @* Row 1: Title + Sender + Badges *@ +
+ @* Left: Title + Sender *@ +
+ @if (_envelopeReceiver is not null) + { +
+ @(_envelopeReceiver.Envelope?.Title ?? "Dokument") +
+ @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName) || !string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.Email)) + { + + Von + @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName)) + { + @_envelopeReceiver.Envelope.User.FullName + } + @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.Email)) + { + <@_envelopeReceiver.Envelope.User.Email> + } + @if (_envelopeReceiver.Envelope?.AddedWhen != null) + { +  · @_envelopeReceiver.Envelope.AddedWhen.ToString("dd.MM.yyyy") + } + + } + } + else + { +
Dokumentenansicht
+ } +
+ + @* Right: Badges + Signature status *@ +
+ @if (_envelopeReceiver is not null) + { +
+ @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Name)) + { + + + + + @_envelopeReceiver.Name + + } + @if (_signatures.Count > 0) + { + + + + + @_signatures.Count Unterschrift@(_signatures.Count != 1 ? "en" : "") + @if (_capturedSignature is not null) + { + + } + + } + @if (_envelopeReceiver.Envelope?.UseAccessCode ?? false) + { + + + + + Code + + } + @if (_envelopeReceiver.Envelope?.TFAEnabled ?? false) + { + + + + + + 2FA + + } +
+ } + + @* Unterschrift ändern button (when signature captured) *@ + @if (_capturedSignature is not null) + { + + } +
+
+ + @* Row 2: Messages *@ + @if (_envelopeReceiver is not null && (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.Message) || !string.IsNullOrWhiteSpace(_envelopeReceiver.PrivateMessage))) + { +
+ @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.Message)) + { +
+ 📧 + @_envelopeReceiver.Envelope.Message +
+ } + @if (!string.IsNullOrWhiteSpace(_envelopeReceiver.PrivateMessage)) + { +
+ 🔒 + @_envelopeReceiver.PrivateMessage +
+ } +
+ } +
+
+ +
+ @if (_isLoading) + { +
+
+
+ Lädt... +
+

Dokument wird geladen...

+
+
+ } + else if (_errorMessage is not null) + { +
+
+
+ + + + +
+
Fehler beim Laden des Dokuments
+

@_errorMessage

+
+
+
+
+ } + else if (_report is not null) + { + + } +
+
+ +@* Signature Popup *@ + + + + + @if (_activeSignatureTab == SignatureTabDraw) + { +

Bitte unterschreiben Sie im folgenden Feld.

+ + } + else if (_activeSignatureTab == SignatureTabText) + { +

Geben Sie Ihre Unterschrift als Text ein und wählen Sie eine Schriftart.

+
+
+ +
+
+ +
+
+ + } + else + { +

Laden Sie ein Bild Ihrer Unterschrift hoch.

+ + + } + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_popupValidationMessage)) + { +
+ @_popupValidationMessage +
+ } +
+ +
+ + +
+
+
+ +@code { + // ----- Constants ----- + const string SignatureTabDraw = "draw"; + const string SignatureTabText = "text"; + const string SignatureTabImage = "image"; + const string DrawCanvasId = "rp-signature-pad"; + const string TypedCanvasId = "rp-typed-signature-pad"; + const string ImageInputId = "rp-signature-image-input"; + const string ImageCanvasId = "rp-image-signature-pad"; + + // A4 page dimensions in DX units (1/100 inch). + // 8.27" × 11.69" → 827 × 1169 + const float PageWidthDx = 827f; + const float PageHeightDx = 1169f; + + // Fixed signature field size in DX units: 1.77" × 1.96" + const float SigWidthDx = 177f; + const float SigHeightDx = 196f; + + readonly (string Text, string Value)[] TypedSignatureFonts = + [ + ("Brush Script", "'Brush Script MT', cursive"), + ("Segoe Script", "'Segoe Script', cursive"), + ("Lucida Handwriting", "'Lucida Handwriting', cursive"), + ("Comic Sans", "'Comic Sans MS', cursive"), + ("Cursive", "cursive"), + ]; + + // ----- Parameters ----- + [Parameter] public string? EnvelopeKey { get; set; } + + // ----- Page state ----- + bool _isLoading = true; + string? _errorMessage; + byte[]? _pdfBytes; + IReadOnlyList _signatures = []; + EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto? _envelopeReceiver; + ClaimsPrincipal? _receiverUser; + + // ----- Report viewer ----- + DxReportViewer? _reportViewer; + XtraReport? _report; + + // ----- Signature popup state ----- + SignatureCaptureDto? _capturedSignature; + bool _signaturePopupVisible = false; + string? _popupValidationMessage; + string _activeSignatureTab = SignatureTabDraw; + string _typedSignatureText = string.Empty; + string _typedSignatureFont = "'Brush Script MT', cursive"; + string _signerFullName = string.Empty; + string _signerPosition = string.Empty; + string _signaturePlace = string.Empty; + + // ----- Lifecycle ----- + protected override async Task OnInitializedAsync() + { + if (string.IsNullOrWhiteSpace(EnvelopeKey)) + { + _errorMessage = "Envelope-Schlüssel fehlt."; + _isLoading = false; + return; + } + + // Authorization — same pattern as EnvelopeReceiverPage + _receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey); + if (_receiverUser is null) + { + Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}"); + return; + } + + try + { + // Load PDF bytes via MediatR (uses authenticated user's claims) + _pdfBytes = await PageDataService.GetDocumentAsync(_receiverUser); + if (_pdfBytes is not { Length: > 0 }) + { + _errorMessage = "Dokument konnte nicht geladen werden: Keine Daten empfangen."; + _isLoading = false; + return; + } + + // Load signature fields for this receiver + _signatures = await PageDataService.GetSignaturesAsync(_receiverUser); + + // Load envelope receiver metadata + _envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey); + if (_envelopeReceiver is null) + Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey); + + // Build initial report (no signature image yet) + _report = BuildReport(_pdfBytes, _signatures, capturedSignature: null); + + // Try to restore cached signature + try + { + var cachedSignature = await PageDataService.GetCachedSignatureAsync(_receiverUser); + if (cachedSignature is not null) + { + _capturedSignature = cachedSignature; + _signerFullName = cachedSignature.FullName; + _signerPosition = cachedSignature.Position; + _signaturePlace = cachedSignature.Place; + _signaturePopupVisible = false; + + // Rebuild with cached signature overlaid + _report = BuildReport(_pdfBytes, _signatures, _capturedSignature); + } + else + { + _activeSignatureTab = SignatureTabDraw; + _signaturePopupVisible = _signatures.Count > 0; + _popupValidationMessage = null; + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to load cached signature for {EnvelopeKey}", EnvelopeKey); + _activeSignatureTab = SignatureTabDraw; + _signaturePopupVisible = _signatures.Count > 0; + _popupValidationMessage = null; + } + } + catch (Exception ex) + { + _errorMessage = $"Fehler beim Laden des Dokuments: {ex.Message}"; + Logger.LogError(ex, "Unexpected error for {EnvelopeKey}", EnvelopeKey); + } + + _isLoading = false; + await InvokeAsync(StateHasChanged); + } + + // ----- Report builder ----- + /// + /// Builds an XtraReport that displays the PDF pages via XRPdfContent (embedded mode, + /// GenerateOwnPages = false). Each PDF page is wrapped in its own subreport so that + /// XRPictureBox overlays can be positioned accurately per page. + /// + static XtraReport BuildReport( + byte[] pdfBytes, + IReadOnlyList signatures, + SignatureCaptureDto? capturedSignature) + { + // Determine the number of pages using DevExpress PDF processor + int pageCount = GetPdfPageCount(pdfBytes); + if (pageCount < 1) pageCount = 1; + + // Outer (main) report - acts as container for subreports + var mainReport = new XtraReport + { + PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4, + Landscape = false, + Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0), + }; + + var mainDetail = new DetailBand { HeightF = 0f }; + mainReport.Bands.Add(mainDetail); + + for (int page = 1; page <= pageCount; page++) + { + // Build a subreport for this PDF page + var pageReport = BuildPageSubreport(pdfBytes, page, signatures, capturedSignature); + + var subreport = new XRSubreport + { + ReportSource = pageReport, + GenerateOwnPages = true, + LocationF = new PointF(0f, 0f), + SizeF = new SizeF(PageWidthDx, PageHeightDx), + }; + + mainDetail.Controls.Add(subreport); + } + + return mainReport; + } + + /// + /// Builds a single-page subreport: one DetailBand containing the PDF page (via + /// XRPdfContent with GenerateOwnPages = false) plus XRPictureBox overlays for + /// any signatures placed on this page. + /// + static XtraReport BuildPageSubreport( + byte[] pdfBytes, + int pageNumber, + IReadOnlyList signatures, + SignatureCaptureDto? capturedSignature) + { + var report = new XtraReport + { + PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4, + Landscape = false, + Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0), + }; + + var detail = new DetailBand + { + HeightF = PageHeightDx, + Name = $"DetailBand_Page{pageNumber}", + }; + report.Bands.Add(detail); + + // --- PDF content (embedded, no own pages) --- + var pdfContent = new XRPdfContent + { + Source = pdfBytes, + PageRange = pageNumber.ToString(), + GenerateOwnPages = false, + LocationF = new PointF(0f, 0f), + SizeF = new SizeF(PageWidthDx, PageHeightDx), + }; + detail.Controls.Add(pdfContent); + + // --- Signature overlays --- + if (capturedSignature is not null && !string.IsNullOrWhiteSpace(capturedSignature.DataUrl)) + { + var signaturesOnPage = signatures.Where(s => s.Page == pageNumber).ToList(); + foreach (var sig in signaturesOnPage) + { + try + { + var imgBytes = DataUrlToBytes(capturedSignature.DataUrl); + if (imgBytes is { Length: > 0 }) + { + using var imgStream = new System.IO.MemoryStream(imgBytes); + var img = System.Drawing.Image.FromStream(imgStream); + var picBox = new XRPictureBox + { + // DB stores INCHES; DX unit = 1/100 inch → multiply by 100 + LocationF = new PointF((float)(sig.X * 100), (float)(sig.Y * 100)), + SizeF = new SizeF(SigWidthDx, SigHeightDx), + Image = img, + Sizing = ImageSizeMode.Squeeze, + CanGrow = false, + CanShrink = false, + }; + detail.Controls.Add(picBox); + } + } + catch + { + // Non-critical: skip overlay on error + } + } + } + + return report; + } + + /// Reads the page count of a PDF using iText7 (already referenced in the server project). + static int GetPdfPageCount(byte[] pdfBytes) + { + try + { + using var ms = new System.IO.MemoryStream(pdfBytes); + using var reader = new iText.Kernel.Pdf.PdfReader(ms); + using var pdfDoc = new iText.Kernel.Pdf.PdfDocument(reader); + return pdfDoc.GetNumberOfPages(); + } + catch + { + return 1; + } + } + + /// Converts a base64 data URL (data:image/...;base64,...) to raw bytes. + static byte[]? DataUrlToBytes(string dataUrl) + { + try + { + var commaIndex = dataUrl.IndexOf(','); + if (commaIndex < 0) return null; + return Convert.FromBase64String(dataUrl[(commaIndex + 1)..]); + } + catch + { + return null; + } + } + + // ----- Signature popup handlers ----- + void OpenSignaturePopup() + { + _activeSignatureTab = SignatureTabDraw; + _signaturePopupVisible = true; + _popupValidationMessage = null; + } + + async Task OnPopupShownAsync() + { + await InitializeActiveSignatureTabAsync(); + } + + async Task SetSignatureTabAsync(string tab) + { + _activeSignatureTab = tab; + _popupValidationMessage = null; + await InvokeAsync(StateHasChanged); + await Task.Delay(50); + await InitializeActiveSignatureTabAsync(); + } + + async Task InitializeActiveSignatureTabAsync() + { + if (_activeSignatureTab == SignatureTabDraw) + await JSRuntime.InvokeVoidAsync("receiverSignature.initialize", DrawCanvasId); + else if (_activeSignatureTab == SignatureTabText) + { + await JSRuntime.InvokeVoidAsync("receiverSignature.initializeTyped", TypedCanvasId); + await RenderTypedSignatureAsync(); + } + else + await JSRuntime.InvokeVoidAsync("receiverSignature.initializeImage", ImageInputId, ImageCanvasId); + } + + async Task RenewSignatureAsync() + { + _popupValidationMessage = null; + if (_activeSignatureTab == SignatureTabDraw) + await JSRuntime.InvokeVoidAsync("receiverSignature.clear", DrawCanvasId); + else if (_activeSignatureTab == SignatureTabText) + { + _typedSignatureText = string.Empty; + await JSRuntime.InvokeVoidAsync("receiverSignature.clearTyped", TypedCanvasId); + } + else + await JSRuntime.InvokeVoidAsync("receiverSignature.clearImage", ImageInputId, ImageCanvasId); + } + + async Task OnTypedSignatureChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args) + { + _typedSignatureText = args.Value?.ToString() ?? string.Empty; + await RenderTypedSignatureAsync(); + } + + async Task OnTypedSignatureFontChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args) + { + _typedSignatureFont = args.Value?.ToString() ?? _typedSignatureFont; + await RenderTypedSignatureAsync(); + } + + async Task RenderTypedSignatureAsync() + { + await JSRuntime.InvokeVoidAsync("receiverSignature.renderTypedSignature", + TypedCanvasId, _typedSignatureText, _typedSignatureFont); + } + + async Task SaveSignatureAsync() + { + if (string.IsNullOrWhiteSpace(_signerFullName)) + { + _popupValidationMessage = "Bitte geben Sie Vor- und Nachname ein."; + return; + } + if (string.IsNullOrWhiteSpace(_signaturePlace)) + { + _popupValidationMessage = "Bitte geben Sie den Ort ein."; + return; + } + + var signatureDataUrl = await GetActiveSignatureDataUrlAsync(); + if (string.IsNullOrWhiteSpace(signatureDataUrl)) + { + _popupValidationMessage = "Die Unterschrift ist erforderlich."; + return; + } + + _popupValidationMessage = null; + _capturedSignature = new SignatureCaptureDto + { + DataUrl = signatureDataUrl, + FullName = _signerFullName.Trim(), + Position = _signerPosition.Trim(), + Place = _signaturePlace.Trim(), + }; + _signaturePopupVisible = false; + + // Persist to cache (fire-and-forget) + if (_receiverUser is not null) + { + _ = Task.Run(async () => + { + try { await PageDataService.SaveCachedSignatureAsync(_receiverUser, _capturedSignature); } + catch { /* non-critical */ } + }); + } + + // Rebuild the report with signatures overlaid + if (_pdfBytes is not null) + { + var newReport = BuildReport(_pdfBytes, _signatures, _capturedSignature); + + if (_reportViewer is not null) + { + await _reportViewer.OpenReportAsync(newReport); + // Dispose previous report after opening new one + _report?.Dispose(); + } + + _report = newReport; + } + + await InvokeAsync(StateHasChanged); + } + + async Task GetActiveSignatureDataUrlAsync() + { + if (_activeSignatureTab == SignatureTabDraw) + return await JSRuntime.InvokeAsync("receiverSignature.getDataUrl", DrawCanvasId); + + if (_activeSignatureTab == SignatureTabText) + { + await RenderTypedSignatureAsync(); + return await JSRuntime.InvokeAsync("receiverSignature.getTypedDataUrl", TypedCanvasId); + } + + return await JSRuntime.InvokeAsync("receiverSignature.getImageDataUrl", ImageCanvasId); + } + + // ----- Disposal ----- + public void Dispose() + { + _report?.Dispose(); + } +}