Updated AGENTS.md to add a technical debt TODO for modularizing server pages by splitting large components, reducing duplication, and centralizing shared logic. Enhanced ReceiverPage.razor with new state variables and logic to support a "Reject" popup feature, including state management for visibility, submission, and validation. Improved sharing functionality in ReceiverPage.razor by adding error handling, user feedback, and proper state updates during asynchronous operations to enhance maintainability and UX.
912 lines
42 KiB
Plaintext
912 lines
42 KiB
Plaintext
@page "/envelope/{EnvelopeKey}"
|
||
@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.Extensions
|
||
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
||
@using EnvelopeGenerator.Application.EnvelopeReceivers.Queries
|
||
@using EnvelopeGenerator.Domain.Constants
|
||
@using Microsoft.JSInterop
|
||
@using DevExpress.Blazor
|
||
@using System.Drawing
|
||
@using System.Security.Claims
|
||
@using System.ComponentModel.DataAnnotations
|
||
@using Microsoft.Extensions.Caching.Memory
|
||
@using MediatR
|
||
@using EnvelopeGenerator.Server.Extensions
|
||
@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 AnnotationService AnnotationService
|
||
@inject AppVersionService AppVersion
|
||
@inject IMemoryCache MemoryCache
|
||
@inject ILogger<ReceiverPage> Logger
|
||
@inject IMediator Mediator
|
||
@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;
|
||
bool _isLoggingOut = false;
|
||
|
||
// ----- Share popup state -----
|
||
bool _sharePopupVisible = false;
|
||
bool _isSharing = false;
|
||
string _shareReceiverMail = string.Empty;
|
||
DateTime _shareDateValid = DateTime.Today.AddDays(7);
|
||
string? _shareMessage;
|
||
bool _shareMessageIsError = false;
|
||
|
||
// ----- Reject popup state -----
|
||
bool _rejectPopupVisible = false;
|
||
bool _isRejectSubmitting = false;
|
||
string _rejectReason = string.Empty;
|
||
string? _rejectValidationMessage;
|
||
|
||
async Task LogoutAsync()
|
||
{
|
||
if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey))
|
||
return;
|
||
|
||
_isLoggingOut = true;
|
||
await InvokeAsync(StateHasChanged);
|
||
|
||
try
|
||
{
|
||
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Receiver logout failed for {EnvelopeKey}", EnvelopeKey);
|
||
}
|
||
|
||
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||
}
|
||
|
||
void OpenSharePopup()
|
||
{
|
||
_shareReceiverMail = string.Empty;
|
||
_shareDateValid = DateTime.Today.AddDays(7);
|
||
_shareMessage = null;
|
||
_shareMessageIsError = false;
|
||
_sharePopupVisible = true;
|
||
}
|
||
|
||
void CloseSharePopup()
|
||
{
|
||
if (_isSharing)
|
||
return;
|
||
|
||
_sharePopupVisible = false;
|
||
}
|
||
|
||
void OnShareDateChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args)
|
||
{
|
||
if (DateTime.TryParse(args.Value?.ToString(), out var parsedDate))
|
||
_shareDateValid = parsedDate.Date;
|
||
}
|
||
|
||
async Task ShareEnvelopeAsync()
|
||
{
|
||
if (_isSharing)
|
||
return;
|
||
|
||
var email = _shareReceiverMail.Trim();
|
||
if (string.IsNullOrWhiteSpace(email) || !new EmailAddressAttribute().IsValid(email))
|
||
{
|
||
_shareMessage = "Bitte geben Sie eine gultige E-Mail-Adresse ein.";
|
||
_shareMessageIsError = true;
|
||
return;
|
||
}
|
||
|
||
var minDate = DateTime.Today.AddDays(1);
|
||
var maxDate = DateTime.Today.AddDays(90);
|
||
if (_shareDateValid.Date < minDate || _shareDateValid.Date > maxDate)
|
||
{
|
||
_shareMessage = "Bitte wahlen Sie ein Datum zwischen morgen und 90 Tagen.";
|
||
_shareMessageIsError = true;
|
||
return;
|
||
}
|
||
|
||
_isSharing = true;
|
||
_shareMessage = null;
|
||
_shareMessageIsError = false;
|
||
await InvokeAsync(StateHasChanged);
|
||
|
||
try
|
||
{
|
||
_shareMessage = "Freigabe ist in dieser Ansicht derzeit nicht verfugbar.";
|
||
_shareMessageIsError = false;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Failed to share envelope {EnvelopeKey}", EnvelopeKey);
|
||
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
|
||
_shareMessageIsError = true;
|
||
}
|
||
finally
|
||
{
|
||
_isSharing = false;
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
}
|
||
|
||
// ----- Lifecycle -----
|
||
protected override async Task OnInitializedAsync()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||
{
|
||
_errorMessage = "Envelope-Schlüssel fehlt.";
|
||
_isLoading = false;
|
||
return;
|
||
}
|
||
|
||
if (EnvelopeKey.TryDecode(out var decodedKeys) && decodedKeys.GetEncodeType() == EncodeType.EnvelopeReceiverReadOnly)
|
||
{
|
||
Navigation.NavigateTo($"/envelope/readonly/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||
return;
|
||
}
|
||
|
||
// Authorization — same pattern as EnvelopeReceiverPage
|
||
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
|
||
if (_receiverUser is null)
|
||
{
|
||
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
|
||
return;
|
||
}
|
||
|
||
if (await Mediator.IsSignedAsync(_receiverUser.EnvelopeUuid(), _receiverUser.ReceiverSignature()))
|
||
{
|
||
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// Load PDF bytes via MediatR (uses authenticated user's claims)
|
||
_pdfBytes = await PageDataService.GetDocumentOfReceiverAsync(_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);
|
||
else if (IsRejected(_envelopeReceiver))
|
||
{
|
||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/rejected", forceLoad: true);
|
||
return;
|
||
}
|
||
|
||
// 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 -----
|
||
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
|
||
{
|
||
var status = envelopeReceiver.Envelope?.Status;
|
||
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
|
||
return true;
|
||
|
||
return envelopeReceiver.Envelope?.Histories?.Any(h =>
|
||
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
|
||
void OpenRejectPopup()
|
||
{
|
||
_rejectValidationMessage = null;
|
||
_rejectPopupVisible = true;
|
||
}
|
||
|
||
void CloseRejectPopup()
|
||
{
|
||
if (_isRejectSubmitting)
|
||
return;
|
||
|
||
_rejectPopupVisible = false;
|
||
}
|
||
|
||
async Task SubmitRejectAsync()
|
||
{
|
||
if (_isRejectSubmitting)
|
||
return;
|
||
|
||
_isRejectSubmitting = true;
|
||
_rejectValidationMessage = null;
|
||
await InvokeAsync(StateHasChanged);
|
||
|
||
try
|
||
{
|
||
var reason = string.IsNullOrWhiteSpace(_rejectReason)
|
||
? null
|
||
: _rejectReason.Trim();
|
||
|
||
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||
{
|
||
_rejectValidationMessage = "Envelope-Schluessel fehlt.";
|
||
_isRejectSubmitting = false;
|
||
await InvokeAsync(StateHasChanged);
|
||
return;
|
||
}
|
||
|
||
await AnnotationService.RejectAsync(EnvelopeKey, reason);
|
||
|
||
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Failed to reject envelope {EnvelopeKey}", EnvelopeKey);
|
||
_rejectValidationMessage = "Die Ablehnung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.";
|
||
_isRejectSubmitting = false;
|
||
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();
|
||
}
|
||
}
|