Compare commits
15 Commits
bugfix/dev
...
feat/migr-
| Author | SHA1 | Date | |
|---|---|---|---|
| d94821433a | |||
| 278b9964f1 | |||
| e6722803bb | |||
| 47bc7675c9 | |||
| 789e312316 | |||
| bc34317720 | |||
| 76ff3e47e1 | |||
| 2d22bfcd06 | |||
| 185c783824 | |||
| b957b4b4bb | |||
| df154d83cc | |||
| 49ec9fbead | |||
| 01fc29f59e | |||
| 733b70cca2 | |||
| 8f4b751303 |
@@ -1,5 +1,6 @@
|
||||
@page "/"
|
||||
@inject IJSRuntime JS
|
||||
@rendermode InteractiveWebAssembly
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
|
||||
if (result == EnvelopeLoginResult.Success)
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/report", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
@page "/envelope/{EnvelopeKey}/report"
|
||||
@rendermode InteractiveServer
|
||||
@using DevExpress.Blazor.Reporting
|
||||
@using DevExpress.XtraReports.UI
|
||||
@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
|
||||
@using Microsoft.Extensions.Caching.Memory
|
||||
@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 IMemoryCache MemoryCache
|
||||
@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><@_envelopeReceiver.Envelope.User.Email></span>
|
||||
}
|
||||
@if (_envelopeReceiver.Envelope?.AddedWhen != null)
|
||||
{
|
||||
<span> · @_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>
|
||||
}
|
||||
|
||||
@* Unterschreiben button — visible only when signature fields exist *@
|
||||
@if (_signatures.Count > 0)
|
||||
{
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change pdf-toolbar__btn--signature-change-active"
|
||||
@onclick="OpenSignaturePopup"
|
||||
title="Unterschreiben"
|
||||
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">Unterschreiben</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";
|
||||
|
||||
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 = false;
|
||||
_popupValidationMessage = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to load cached signature for {EnvelopeKey}", EnvelopeKey);
|
||||
_activeSignatureTab = SignatureTabDraw;
|
||||
_signaturePopupVisible = false;
|
||||
_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 wrapping the PDF bytes.
|
||||
/// If a signature is captured and there are signature fields, the signature image is
|
||||
/// first burned into the PDF via DevExpress PdfDocumentProcessor, then the modified
|
||||
/// PDF is handed to XRPdfContent with GenerateOwnPages = true so that all pages appear.
|
||||
/// </summary>
|
||||
static XtraReport BuildReport(
|
||||
byte[] pdfBytes,
|
||||
IReadOnlyList<SignatureDto> signatures,
|
||||
SignatureCaptureDto? capturedSignature)
|
||||
{
|
||||
// Always draw placeholder boxes on signature fields so the user knows where to sign.
|
||||
// When a captured signature exists, it will be applied in the Signed page instead.
|
||||
byte[] sourcePdf = pdfBytes;
|
||||
if (signatures.Count > 0)
|
||||
{
|
||||
sourcePdf = DrawSignaturePlaceholders(pdfBytes, signatures);
|
||||
}
|
||||
|
||||
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 = 0f };
|
||||
report.Bands.Add(detail);
|
||||
|
||||
detail.Controls.Add(new XRPdfContent
|
||||
{
|
||||
Source = sourcePdf,
|
||||
GenerateOwnPages = true,
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses PdfSharp to draw a visible signature placeholder box on every signature field.
|
||||
/// sig.X / sig.Y come from GetSignaturesAsync(UnitOfLength.Point) → already in PDF points.
|
||||
/// PdfSharp coordinate origin: bottom-left, Y up. Conversion: pdfY = pageH - sigY - sigH
|
||||
/// Signature field size (fixed): 1.77" × 1.96" = 127.44pt × 141.12pt
|
||||
/// </summary>
|
||||
static byte[] DrawSignaturePlaceholders(
|
||||
byte[] pdfBytes,
|
||||
IReadOnlyList<SignatureDto> signatures)
|
||||
{
|
||||
if (signatures.Count == 0) return pdfBytes;
|
||||
|
||||
using var inputMs = new System.IO.MemoryStream(pdfBytes);
|
||||
using var outputMs = new System.IO.MemoryStream();
|
||||
|
||||
var document = PdfSharp.Pdf.IO.PdfReader.Open(
|
||||
inputMs,
|
||||
PdfSharp.Pdf.IO.PdfDocumentOpenMode.Modify);
|
||||
|
||||
const double sigW = 1.77 * 72; // 127.44 pt
|
||||
const double sigH = 1.96 * 72; // 141.12 pt
|
||||
|
||||
foreach (var sig in signatures)
|
||||
{
|
||||
int pageIndex = sig.Page - 1;
|
||||
if (pageIndex < 0 || pageIndex >= document.PageCount) continue;
|
||||
|
||||
var page = document.Pages[pageIndex];
|
||||
|
||||
// PdfSharp XGraphics uses top-left origin, Y down — same as sig.X/sig.Y
|
||||
// No coordinate conversion needed.
|
||||
using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page);
|
||||
|
||||
var rect = new PdfSharp.Drawing.XRect(sig.X, sig.Y, sigW, sigH);
|
||||
|
||||
// Filled semi-transparent rectangle
|
||||
var fillBrush = new PdfSharp.Drawing.XSolidBrush(
|
||||
PdfSharp.Drawing.XColor.FromArgb(40, 60, 80, 160));
|
||||
var borderPen = new PdfSharp.Drawing.XPen(
|
||||
PdfSharp.Drawing.XColor.FromArgb(200, 60, 80, 200), 1.5);
|
||||
|
||||
gfx.DrawRectangle(fillBrush, rect);
|
||||
gfx.DrawRectangle(borderPen, rect);
|
||||
|
||||
// "UNTERSCHRIFT" label centred in the box
|
||||
var font = new PdfSharp.Drawing.XFont("Arial", 9,
|
||||
PdfSharp.Drawing.XFontStyleEx.Bold);
|
||||
var textBrush = new PdfSharp.Drawing.XSolidBrush(
|
||||
PdfSharp.Drawing.XColor.FromArgb(200, 40, 60, 140));
|
||||
|
||||
var textFmt = new PdfSharp.Drawing.XStringFormat
|
||||
{
|
||||
Alignment = PdfSharp.Drawing.XStringAlignment.Center,
|
||||
LineAlignment = PdfSharp.Drawing.XLineAlignment.Center,
|
||||
};
|
||||
gfx.DrawString("UNTERSCHRIFT", font, textBrush, rect, textFmt);
|
||||
}
|
||||
|
||||
document.Save(outputMs);
|
||||
return outputMs.ToArray();
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
// Store signature in IMemoryCache with a Guid key (1 minute TTL)
|
||||
var sid = Guid.NewGuid().ToString("N");
|
||||
MemoryCache.Set(
|
||||
sid,
|
||||
_capturedSignature,
|
||||
TimeSpan.FromMinutes(1));
|
||||
|
||||
Logger.LogInformation(
|
||||
"Signature cached with sid={Sid} for envelope {EnvelopeKey}", sid, EnvelopeKey);
|
||||
|
||||
// Null the report → DxReportViewer removed from DOM → no crash on dispose
|
||||
_report = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
await Task.Delay(50);
|
||||
|
||||
// Navigate — forceLoad:true for clean circuit teardown
|
||||
Navigation.NavigateTo(
|
||||
$"/envelope/{Uri.EscapeDataString(EnvelopeKey!)}/signed?sid={sid}",
|
||||
forceLoad: true);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
@page "/envelope/{EnvelopeKey}/signed"
|
||||
@rendermode InteractiveServer
|
||||
@using DevExpress.Blazor.Reporting
|
||||
@using DevExpress.XtraReports.UI
|
||||
@using EnvelopeGenerator.Server.Client.Models
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
||||
@using Microsoft.Extensions.Caching.Memory
|
||||
@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 IMemoryCache MemoryCache
|
||||
@inject ILogger<EnvelopeReceiverReportSignedPage> 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" />
|
||||
|
||||
<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;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem;">
|
||||
<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))
|
||||
{
|
||||
<span style="font-size: 0.7rem; color: #6b7280; white-space: nowrap;">
|
||||
Von <span style="font-weight: 500; color: #374151;">@_envelopeReceiver.Envelope.User.FullName</span>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937;">Signiertes Dokument</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Right: Submit button *@
|
||||
<div style="flex: 0 0 auto;">
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change pdf-toolbar__btn--signature-change-active"
|
||||
@onclick="OpenSubmitConfirmPopup"
|
||||
disabled="@_isLoggingOut"
|
||||
title="Abschließen"
|
||||
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="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 class="pdf-toolbar__btn-text">Abschließen</span>
|
||||
</button>
|
||||
</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</h5>
|
||||
<p class="mb-0">@_errorMessage</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_report is not null)
|
||||
{
|
||||
<DxReportViewer Report="_report" RootCssClasses="w-100 h-100" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Submit confirmation popup *@
|
||||
<DxPopup @bind-Visible="_submitConfirmVisible"
|
||||
HeaderText="Unterschrift bestätigen"
|
||||
Width="440px"
|
||||
MaxWidth="95vw"
|
||||
ShowFooter="true"
|
||||
CloseOnOutsideClick="false"
|
||||
ShowCloseButton="false"
|
||||
CloseOnEscape="false">
|
||||
<BodyContentTemplate>
|
||||
<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;">
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p style="margin: 0 0 0.4rem; font-weight: 600; color: #1f2937; font-size: 0.95rem;">
|
||||
Möchten Sie das Dokument verbindlich unterschreiben?
|
||||
</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 0.85rem; line-height: 1.5;">
|
||||
Diese Aktion kann nicht rückgängig gemacht werden. Mit der Bestätigung erklären Sie, das oben angezeigte Dokument elektronisch unterzeichnet zu haben. Das unterzeichnete Dokument wird anschließend an alle beteiligten Parteien übermittelt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BodyContentTemplate>
|
||||
<FooterContentTemplate>
|
||||
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick="() => _submitConfirmVisible = false"
|
||||
style="border-radius: 6px; padding: 0.5rem 1.25rem; font-weight: 500;">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button class="btn btn-primary"
|
||||
@onclick="SubmitAndLogoutAsync"
|
||||
disabled="@_isLoggingOut"
|
||||
style="background: linear-gradient(135deg, #059669 0%, #047857 100%); border: none; border-radius: 6px; padding: 0.5rem 1.5rem; font-weight: 600; box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3);">
|
||||
@if (_isLoggingOut)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
}
|
||||
Abschließen
|
||||
</button>
|
||||
</div>
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? EnvelopeKey { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "sid")]
|
||||
public string? Sid { get; set; }
|
||||
|
||||
bool _isLoading = true;
|
||||
string? _errorMessage;
|
||||
ClaimsPrincipal? _receiverUser;
|
||||
EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto? _envelopeReceiver;
|
||||
IReadOnlyList<SignatureDto> _signatures = [];
|
||||
XtraReport? _report;
|
||||
SignatureCaptureDto? _sig;
|
||||
|
||||
// ----- Submit / logout state -----
|
||||
bool _isLoggingOut = false;
|
||||
bool _submitConfirmVisible = false;
|
||||
|
||||
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
|
||||
|
||||
async Task SubmitAndLogoutAsync()
|
||||
{
|
||||
if (_isLoggingOut) return;
|
||||
_isLoggingOut = true;
|
||||
_submitConfirmVisible = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey!);
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||
{
|
||||
_errorMessage = "Envelope-Schlüssel fehlt.";
|
||||
_isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
|
||||
if (_receiverUser is null)
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read signature from IMemoryCache
|
||||
if (!string.IsNullOrWhiteSpace(Sid)
|
||||
&& MemoryCache.TryGetValue(Sid, out SignatureCaptureDto? cached)
|
||||
&& cached is not null)
|
||||
{
|
||||
_sig = cached;
|
||||
}
|
||||
|
||||
// Cache miss or missing sid — redirect back to report page
|
||||
if (_sig is null)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"[SignedPage] Cache miss or no sid={Sid} for {EnvelopeKey}, redirecting to report page.",
|
||||
Sid, EnvelopeKey);
|
||||
Navigation.NavigateTo(
|
||||
$"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/report",
|
||||
forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var pdfBytes = await PageDataService.GetDocumentAsync(_receiverUser);
|
||||
if (pdfBytes is not { Length: > 0 })
|
||||
{
|
||||
_errorMessage = "Dokument konnte nicht geladen werden.";
|
||||
_isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
||||
_signatures = await PageDataService.GetSignaturesAsync(_receiverUser);
|
||||
|
||||
// Burn signature image + info onto PDF via PdfSharp
|
||||
if (_sig is not null && _signatures.Count > 0)
|
||||
pdfBytes = DrawSignaturesOnPdf(pdfBytes, _signatures, _sig);
|
||||
|
||||
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();
|
||||
report.Bands.Add(detail);
|
||||
detail.Controls.Add(new XRPdfContent
|
||||
{
|
||||
Source = pdfBytes,
|
||||
GenerateOwnPages = true,
|
||||
});
|
||||
_report = report;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Fehler: {ex.Message}";
|
||||
Logger.LogError(ex, "Error loading signed page for {EnvelopeKey}", EnvelopeKey);
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
|
||||
if (_sig is not null)
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("console.log",
|
||||
$"[SignedPage] sid={Sid} | FullName={_sig.FullName} | Place={_sig.Place} | Position={_sig.Position} | DataUrl.Length={_sig.DataUrl?.Length ?? 0}");
|
||||
}
|
||||
else
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("console.log",
|
||||
$"[SignedPage] Cache miss or no sid. sid={Sid}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_report?.Dispose();
|
||||
}
|
||||
|
||||
// ----- PDF signature rendering -----
|
||||
|
||||
/// <summary>
|
||||
/// Uses PdfSharp to burn the captured signature onto the PDF at each signature field.
|
||||
/// Layout per field (top-left origin, Y down, units = PDF points):
|
||||
/// [top 65%] signature image
|
||||
/// [separator line]
|
||||
/// [bottom 35%] FullName (bold) / Position (optional) / Place, Date
|
||||
/// </summary>
|
||||
static byte[] DrawSignaturesOnPdf(
|
||||
byte[] pdfBytes,
|
||||
IReadOnlyList<SignatureDto> signatures,
|
||||
SignatureCaptureDto sig)
|
||||
{
|
||||
var imgBytes = DataUrlToBytes(sig.DataUrl);
|
||||
if (imgBytes is not { Length: > 0 }) return pdfBytes;
|
||||
|
||||
using var inputMs = new System.IO.MemoryStream(pdfBytes);
|
||||
using var outputMs = new System.IO.MemoryStream();
|
||||
|
||||
var document = PdfSharp.Pdf.IO.PdfReader.Open(
|
||||
inputMs, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Modify);
|
||||
|
||||
const double sigW = 1.77 * 72; // 127.44 pt
|
||||
const double sigH = 1.96 * 72; // 141.12 pt
|
||||
const double imgRatio = 0.52; // top 52% = image
|
||||
const double lineH = 11.5; // fixed row height matching font size (bold 7.5pt + normal 6.5pt)
|
||||
const double bgPad = 3.0; // background box padding around content (pt)
|
||||
|
||||
var black = PdfSharp.Drawing.XColor.FromArgb(255, 20, 20, 20);
|
||||
var darkGray = PdfSharp.Drawing.XColor.FromArgb(255, 80, 80, 80);
|
||||
var lineColor = PdfSharp.Drawing.XColor.FromArgb(180, 100, 100, 120);
|
||||
|
||||
var bgColor = PdfSharp.Drawing.XColor.FromArgb(255, 255, 253, 240);
|
||||
var bgBrush = new PdfSharp.Drawing.XSolidBrush(bgColor);
|
||||
|
||||
var fontBold = new PdfSharp.Drawing.XFont("Arial", 7.5, PdfSharp.Drawing.XFontStyleEx.Bold);
|
||||
var fontNormal = new PdfSharp.Drawing.XFont("Arial", 6.5, PdfSharp.Drawing.XFontStyleEx.Regular);
|
||||
var linePen = new PdfSharp.Drawing.XPen(lineColor, 0.5);
|
||||
|
||||
var fmtLeft = new PdfSharp.Drawing.XStringFormat
|
||||
{
|
||||
Alignment = PdfSharp.Drawing.XStringAlignment.Near,
|
||||
LineAlignment = PdfSharp.Drawing.XLineAlignment.Near,
|
||||
};
|
||||
|
||||
var date = DateTime.Now.ToString("dd.MM.yyyy");
|
||||
|
||||
foreach (var field in signatures)
|
||||
{
|
||||
int pageIndex = field.Page - 1;
|
||||
if (pageIndex < 0 || pageIndex >= document.PageCount) continue;
|
||||
|
||||
var page = document.Pages[pageIndex];
|
||||
|
||||
using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page);
|
||||
|
||||
double x = field.X;
|
||||
double y = field.Y;
|
||||
|
||||
// --- Calculate layout positions first (needed for bg rect) ---
|
||||
double imgH = sigH * imgRatio;
|
||||
double lineY = y + imgH + 1.0; // 1pt gap between image and separator
|
||||
double textY = lineY + 1.5; // 1.5pt gap below separator line
|
||||
double padding = 3;
|
||||
|
||||
// Row 1: FullName
|
||||
double row1Y = textY;
|
||||
// Row 2: Position (optional)
|
||||
double row2Y = row1Y + lineH;
|
||||
// Row 3: Place, Date — immediately after row2 regardless of position
|
||||
double row3Y = !string.IsNullOrWhiteSpace(sig.Position) ? row2Y + lineH : row2Y;
|
||||
double contentBottom = row3Y + lineH;
|
||||
|
||||
// --- Background rectangle sized to actual content (not full sigH) ---
|
||||
var bgRect = new PdfSharp.Drawing.XRect(
|
||||
x - bgPad,
|
||||
y - bgPad,
|
||||
sigW + bgPad * 2,
|
||||
(contentBottom - y) + bgPad * 2);
|
||||
gfx.DrawRectangle(bgBrush, bgRect);
|
||||
|
||||
// --- Image area ---
|
||||
var imgRect = new PdfSharp.Drawing.XRect(x, y, sigW, imgH);
|
||||
using var imgStream = new System.IO.MemoryStream(imgBytes);
|
||||
var xImg = PdfSharp.Drawing.XImage.FromStream(imgStream);
|
||||
gfx.DrawImage(xImg, imgRect);
|
||||
|
||||
// --- Separator line ---
|
||||
gfx.DrawLine(linePen, x + 2, lineY, x + sigW - 2, lineY);
|
||||
|
||||
// --- Text rows ---
|
||||
// Row 1: FullName (bold)
|
||||
var nameRect = new PdfSharp.Drawing.XRect(x + padding, row1Y, sigW - padding * 2, lineH);
|
||||
gfx.DrawString(sig.FullName, fontBold, new PdfSharp.Drawing.XSolidBrush(black), nameRect, fmtLeft);
|
||||
|
||||
// Row 2: Position (optional)
|
||||
if (!string.IsNullOrWhiteSpace(sig.Position))
|
||||
{
|
||||
var posRect = new PdfSharp.Drawing.XRect(x + padding, row2Y, sigW - padding * 2, lineH);
|
||||
gfx.DrawString(sig.Position, fontNormal, new PdfSharp.Drawing.XSolidBrush(darkGray), posRect, fmtLeft);
|
||||
}
|
||||
|
||||
// Row 3: Place, Date
|
||||
var placeDate = $"{sig.Place}, {date}";
|
||||
var dateRect = new PdfSharp.Drawing.XRect(x + padding, row3Y, sigW - padding * 2, lineH);
|
||||
gfx.DrawString(placeDate, fontNormal, new PdfSharp.Drawing.XSolidBrush(darkGray), dateRect, fmtLeft);
|
||||
}
|
||||
|
||||
document.Save(outputMs);
|
||||
return outputMs.ToArray();
|
||||
}
|
||||
|
||||
static byte[]? DataUrlToBytes(string? dataUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dataUrl)) return null;
|
||||
var comma = dataUrl.IndexOf(',');
|
||||
if (comma < 0) return null;
|
||||
return Convert.FromBase64String(dataUrl[(comma + 1)..]);
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,12 @@
|
||||
<PackageReference Include="DigitalData.Core.API" Version="2.2.1" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.11" />
|
||||
<PackageReference Include="itext" Version="8.0.5" />
|
||||
<PackageReference Include="itext.bouncy-castle-adapter" Version="8.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.17" />
|
||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
|
||||
<PackageReference Include="NLog" Version="5.2.5" />
|
||||
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.0" />
|
||||
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.2.1" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
|
||||
|
||||
@@ -104,7 +104,7 @@ try
|
||||
{
|
||||
Version = "v1",
|
||||
Title = "signFLOW Absender-API",
|
||||
Description = "Eine API zur Verwaltung der Erstellung, des Versands und der Nachverfolgung von Umschlägen in der signFLOW-Anwendung.",
|
||||
Description = "Eine API zur Verwaltung der Erstellung, des Versands und der Nachverfolgung von Umschl<EFBFBD>gen in der signFLOW-Anwendung.",
|
||||
Contact = new OpenApiContact
|
||||
{
|
||||
Name = "Digital Data GmbH",
|
||||
@@ -338,6 +338,10 @@ try
|
||||
builder.Services.AddDevExpressBlazor();
|
||||
builder.Services.AddDevExpressServerSideBlazorPdfViewer();
|
||||
|
||||
// PdfSharp font resolver — required for .NET 8 (no system font access without it)
|
||||
PdfSharp.Fonts.GlobalFontSettings.FontResolver =
|
||||
EnvelopeGenerator.Server.Services.PdfSharpFontResolver.Instance;
|
||||
|
||||
// Configuration Options
|
||||
builder.Services.Configure<EnvelopeGenerator.Server.Client.Options.ApiOptions>(
|
||||
builder.Configuration.GetSection("ApiOptions"));
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using PdfSharp.Fonts;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PdfSharp 6.x IFontResolver for .NET 8.
|
||||
/// PdfSharp cannot access system fonts on .NET Core/8 without an explicit resolver.
|
||||
/// This implementation reads fonts directly from the Windows Fonts folder.
|
||||
/// Register once at startup: GlobalFontSettings.FontResolver = PdfSharpFontResolver.Instance;
|
||||
/// </summary>
|
||||
public class PdfSharpFontResolver : IFontResolver
|
||||
{
|
||||
public static readonly PdfSharpFontResolver Instance = new();
|
||||
|
||||
private static readonly string FontsFolder =
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
|
||||
|
||||
public FontResolverInfo? ResolveTypeface(string familyName, bool isBold, bool isItalic)
|
||||
{
|
||||
var key = familyName.ToLowerInvariant() switch
|
||||
{
|
||||
"arial" => isBold ? "arialbd" : "arial",
|
||||
_ => null
|
||||
};
|
||||
|
||||
return key is null ? null : new FontResolverInfo(key);
|
||||
}
|
||||
|
||||
public byte[] GetFont(string faceName)
|
||||
{
|
||||
var fileName = faceName switch
|
||||
{
|
||||
"arialbd" => "arialbd.ttf",
|
||||
_ => "arial.ttf",
|
||||
};
|
||||
|
||||
var path = Path.Combine(FontsFolder, fileName);
|
||||
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException(
|
||||
$"Font file not found: {path}. " +
|
||||
"Ensure Arial is installed on the server.");
|
||||
|
||||
return File.ReadAllBytes(path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user