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.
This commit is contained in:
2026-06-30 23:27:20 +02:00
parent a5e4f97397
commit 8f4b751303

View File

@@ -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<EnvelopeReceiverReportPage> Logger
@implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<script src="@AppVersion.GetVersionedUrl("js/receiver-signature.js")"></script>
<div class="envelope-viewer-layout">
<div class="envelope-action-bar">
<div class="envelope-action-bar__inner" style="flex-direction: column; align-items: stretch; padding: 0.35rem 1.5rem; gap: 0.35rem;">
@* Row 1: Title + Sender + Badges *@
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem;">
@* Left: Title + Sender *@
<div style="flex: 0 1 auto; min-width: 0; display: flex; align-items: center; gap: 0.75rem;">
@if (_envelopeReceiver is not null)
{
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
@(_envelopeReceiver.Envelope?.Title ?? "Dokument")
</div>
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName) || !string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.Email))
{
<span style="font-size: 0.7rem; color: #6b7280; white-space: nowrap;">
Von
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName))
{
<span style="font-weight: 500; color: #374151;">@_envelopeReceiver.Envelope.User.FullName</span>
}
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.Email))
{
<span>&lt;@_envelopeReceiver.Envelope.User.Email&gt;</span>
}
@if (_envelopeReceiver.Envelope?.AddedWhen != null)
{
<span>&nbsp;·&nbsp;@_envelopeReceiver.Envelope.AddedWhen.ToString("dd.MM.yyyy")</span>
}
</span>
}
}
else
{
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937;">Dokumentenansicht</div>
}
</div>
@* Right: Badges + Signature status *@
<div class="d-flex align-items-center" style="gap: 0.75rem; flex: 0 0 auto;">
@if (_envelopeReceiver is not null)
{
<div class="d-flex flex-wrap align-items-center" style="gap: 0.3rem; font-size: 0.7rem;">
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Name))
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: #f3f4f6; border-radius: 0.25rem; color: #374151; white-space: nowrap;">
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4Z" />
</svg>
@_envelopeReceiver.Name
</span>
}
@if (_signatures.Count > 0)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: @(_capturedSignature is not null ? "#d1fae5" : "#ede9fe"); border-radius: 0.25rem; color: @(_capturedSignature is not null ? "#065f46" : "#6d28d9"); font-weight: 500; white-space: nowrap;">
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" 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" />
</svg>
@_signatures.Count Unterschrift@(_signatures.Count != 1 ? "en" : "")
@if (_capturedSignature is not null)
{
<span class="ms-1">✓</span>
}
</span>
}
@if (_envelopeReceiver.Envelope?.UseAccessCode ?? false)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: #fef3c7; border-radius: 0.25rem; color: #92400e; font-weight: 500; white-space: nowrap;">
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" 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>
Code
</span>
}
@if (_envelopeReceiver.Envelope?.TFAEnabled ?? false)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: #dbeafe; border-radius: 0.25rem; color: #1e40af; font-weight: 500; white-space: nowrap;">
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z" />
<path d="M10.854 5.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708 0z" />
</svg>
2FA
</span>
}
</div>
}
@* Unterschrift ändern button (when signature captured) *@
@if (_capturedSignature is not null)
{
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change pdf-toolbar__btn--signature-change-active"
@onclick="OpenSignaturePopup"
title="Unterschrift ändern"
style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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" />
</svg>
<span class="pdf-toolbar__btn-text">Unterschrift</span>
</button>
}
</div>
</div>
@* Row 2: Messages *@
@if (_envelopeReceiver is not null && (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.Message) || !string.IsNullOrWhiteSpace(_envelopeReceiver.PrivateMessage)))
{
<div style="display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.7rem; padding-top: 0.15rem; border-top: 1px solid #e5e7eb;">
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.Message))
{
<div style="flex: 1; min-width: 0; padding: 0.2rem 0.4rem; background: #f9fafb; border-radius: 0.25rem; border-left: 2px solid #9ca3af; display: flex; align-items: flex-start; gap: 0.25rem;">
<span style="font-weight: 500; color: #374151; flex-shrink: 0;">📧</span>
<span style="color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@_envelopeReceiver.Envelope.Message</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.PrivateMessage))
{
<div style="flex: 1; min-width: 0; padding: 0.2rem 0.4rem; background: #fef3c7; border-radius: 0.25rem; border-left: 2px solid #f59e0b; display: flex; align-items: flex-start; gap: 0.25rem;">
<span style="font-weight: 500; color: #92400e; flex-shrink: 0;">🔒</span>
<span style="color: #92400e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@_envelopeReceiver.PrivateMessage</span>
</div>
}
</div>
}
</div>
</div>
<div class="envelope-content" style="padding: 0; overflow: hidden;">
@if (_isLoading)
{
<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">
<span class="visually-hidden">Lädt...</span>
</div>
<p class="text-white fw-semibold">Dokument wird geladen...</p>
</div>
</div>
}
else if (_errorMessage is not 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" />
</svg>
<div>
<h5 class="mb-2">Fehler beim Laden des Dokuments</h5>
<p class="mb-0">@_errorMessage</p>
</div>
</div>
</div>
</div>
}
else if (_report is not null)
{
<DxReportViewer @ref="_reportViewer"
Report="_report"
RootCssClasses="w-100 h-100" />
}
</div>
</div>
@* Signature Popup *@
<DxPopup @bind-Visible="_signaturePopupVisible"
HeaderText="Unterschrift erstellen"
Width="620px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="false"
ShowCloseButton="false"
CloseOnEscape="false"
Shown="OnPopupShownAsync">
<BodyContentTemplate>
<ul class="nav nav-tabs mb-3" style="border-bottom: 2px solid #e9ecef;">
<li class="nav-item">
<button type="button"
class="nav-link @(_activeSignatureTab == SignatureTabDraw ? "active" : "")"
style="@(_activeSignatureTab == SignatureTabDraw ? "border-bottom: 3px solid #4F46E5; color: #4F46E5; font-weight: 600;" : "color: #6c757d;")"
@onclick="() => SetSignatureTabAsync(SignatureTabDraw)">
Zeichnen
</button>
</li>
<li class="nav-item">
<button type="button"
class="nav-link @(_activeSignatureTab == SignatureTabText ? "active" : "")"
style="@(_activeSignatureTab == SignatureTabText ? "border-bottom: 3px solid #4F46E5; color: #4F46E5; font-weight: 600;" : "color: #6c757d;")"
@onclick="() => SetSignatureTabAsync(SignatureTabText)">
Text
</button>
</li>
<li class="nav-item">
<button type="button"
class="nav-link @(_activeSignatureTab == SignatureTabImage ? "active" : "")"
style="@(_activeSignatureTab == SignatureTabImage ? "border-bottom: 3px solid #4F46E5; color: #4F46E5; font-weight: 600;" : "color: #6c757d;")"
@onclick="() => SetSignatureTabAsync(SignatureTabImage)">
Bild
</button>
</li>
</ul>
@if (_activeSignatureTab == SignatureTabDraw)
{
<p style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.75rem;">Bitte unterschreiben Sie im folgenden Feld.</p>
<canvas id="rp-signature-pad"
width="560"
height="180"
style="border: 2px solid #e9ecef; border-radius: 8px; background: white; width: 100%; max-width: 560px; touch-action: none; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"></canvas>
}
else if (_activeSignatureTab == SignatureTabText)
{
<p style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.75rem;">Geben Sie Ihre Unterschrift als Text ein und wählen Sie eine Schriftart.</p>
<div class="row g-3 mb-3">
<div class="col-12 col-md-7">
<input class="form-control"
placeholder="Ihre Unterschrift"
value="@_typedSignatureText"
@oninput="OnTypedSignatureChanged"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
</div>
<div class="col-12 col-md-5">
<select class="form-select"
value="@_typedSignatureFont"
@onchange="OnTypedSignatureFontChanged"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;">
@foreach (var font in TypedSignatureFonts)
{
<option value="@font.Value" style="font-family: @font.Value">@font.Text</option>
}
</select>
</div>
</div>
<canvas id="rp-typed-signature-pad"
width="560"
height="180"
style="border: 2px solid #e9ecef; border-radius: 8px; background: white; width: 100%; max-width: 560px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"></canvas>
}
else
{
<p style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.75rem;">Laden Sie ein Bild Ihrer Unterschrift hoch.</p>
<input id="rp-signature-image-input"
class="form-control mb-3"
type="file"
accept="image/png,image/jpeg,image/webp"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
<canvas id="rp-image-signature-pad"
width="560"
height="180"
style="border: 2px solid #e9ecef; border-radius: 8px; background: white; width: 100%; max-width: 560px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"></canvas>
}
<div style="border-top: 2px solid #e9ecef; margin-top: 1.5rem; padding-top: 1.5rem;">
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="rp-signer-name" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
Vor- und Nachname <span style="color: #dc3545;">*</span>
</label>
<input id="rp-signer-name"
class="form-control"
value="@_signerFullName"
@oninput="args => _signerFullName = args.Value?.ToString() ?? string.Empty"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="rp-signer-position" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
Position <span style="color: #6c757d; font-weight: 400;">(optional)</span>
</label>
<input id="rp-signer-position"
class="form-control"
value="@_signerPosition"
@oninput="args => _signerPosition = args.Value?.ToString() ?? string.Empty"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="rp-signature-place" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
Ort <span style="color: #dc3545;">*</span>
</label>
<input id="rp-signature-place"
class="form-control"
value="@_signaturePlace"
@oninput="args => _signaturePlace = args.Value?.ToString() ?? string.Empty"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
</div>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(_popupValidationMessage))
{
<div style="background: #fee; border-left: 4px solid #dc3545; padding: 0.75rem 1rem; margin-top: 1rem; border-radius: 4px;">
<span style="color: #dc3545; font-size: 0.875rem; font-weight: 500;">@_popupValidationMessage</span>
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div class="d-flex gap-2 justify-content-between w-100" style="padding: 0.5rem 0;">
<button class="btn btn-outline-secondary"
@onclick="RenewSignatureAsync"
style="border-radius: 6px; padding: 0.625rem 1.25rem; font-weight: 500;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" 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" />
</svg>
Erneuern
</button>
<button class="btn btn-primary"
@onclick="SaveSignatureAsync"
style="background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); border: none; border-radius: 6px; padding: 0.625rem 2rem; font-weight: 600; box-shadow: 0 2px 4px rgba(79, 70, 229, 0.3);">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" 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" />
</svg>
Speichern
</button>
</div>
</FooterContentTemplate>
</DxPopup>
@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<SignatureDto> _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 -----
/// <summary>
/// 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.
/// </summary>
static XtraReport BuildReport(
byte[] pdfBytes,
IReadOnlyList<SignatureDto> 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;
}
/// <summary>
/// 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.
/// </summary>
static XtraReport BuildPageSubreport(
byte[] pdfBytes,
int pageNumber,
IReadOnlyList<SignatureDto> 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;
}
/// <summary>Reads the page count of a PDF using iText7 (already referenced in the server project).</summary>
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;
}
}
/// <summary>Converts a base64 data URL (data:image/...;base64,...) to raw bytes.</summary>
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<string?> GetActiveSignatureDataUrlAsync()
{
if (_activeSignatureTab == SignatureTabDraw)
return await JSRuntime.InvokeAsync<string?>("receiverSignature.getDataUrl", DrawCanvasId);
if (_activeSignatureTab == SignatureTabText)
{
await RenderTypedSignatureAsync();
return await JSRuntime.InvokeAsync<string?>("receiverSignature.getTypedDataUrl", TypedCanvasId);
}
return await JSRuntime.InvokeAsync<string?>("receiverSignature.getImageDataUrl", ImageCanvasId);
}
// ----- Disposal -----
public void Dispose()
{
_report?.Dispose();
}
}