Add receiver management to EnvelopeSenderEditorPage

Introduced a new "Receivers" panel in `EnvelopeSenderEditorPage` to manage receivers. Added a popup for adding receivers with validation, email suggestions, and caching for performance. Updated the layout to display receiver details and signature fields.

Injected `EnvelopeReceiverPageDataService` for receiver-related operations. Added a `ReceiverDraft` model and implemented methods for managing receivers. Enhanced CSS for the new UI elements and ensured responsiveness. Minor refactoring and cleanup included.
This commit is contained in:
2026-07-01 16:32:28 +02:00
parent 6120e6062e
commit ca24a96084
3 changed files with 572 additions and 16 deletions

View File

@@ -1,13 +1,15 @@
@page "/envelope/editor"
@page "/sender/editor"
@rendermode InteractiveServer
@using DevExpress.Blazor.PdfViewer
@using DevExpress.Blazor.Reporting.Models
@using DevExpress.Blazor
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Server.Services
@using Microsoft.AspNetCore.Components.Forms
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
@inject ILogger<EnvelopeSenderEditorPage> Logger
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
@@ -21,22 +23,66 @@
style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;">
@* Left: Title *@
<div style="flex: 1; min-width: 0; display: flex; align-items: center; gap: 0.75rem;">
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap;">
Neues Dokument
</div>
@if (_pdfLoaded)
{
<span style="font-size: 0.7rem; color: #6b7280;">@_fileName</span>
@if (_signatureFields.Count > 0)
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.75rem; min-width: 0; flex-wrap: wrap;">
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap;">
Neues Dokument
</div>
@if (_pdfLoaded)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem;
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
@_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "")
</span>
<span style="font-size: 0.7rem; color: #6b7280;">@_fileName</span>
@if (_signatureFields.Count > 0)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem;
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
@_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "")
</span>
}
}
}
</div>
<div class="sender-receivers-panel">
<div class="sender-receivers-panel__header">
<div class="sender-receivers-panel__title-wrap">
<span class="sender-receivers-panel__title">Empfänger</span>
<span class="sender-receivers-panel__count">@_receivers.Count</span>
</div>
<DxButton CssClass="sender-receivers-panel__add-btn pdf-toolbar-like-btn pdf-toolbar-like-btn--add"
Text="Empfänger hinzufügen"
RenderStyle="ButtonRenderStyle.Secondary"
SizeMode="SizeMode.Small"
Click="OpenAddReceiverPopup" />
</div>
@if (_receivers.Count == 0)
{
<div class="sender-receivers-panel__empty">
Noch keine Empfänger hinzugefügt.
</div>
}
else
{
<div class="sender-receivers-list">
@foreach (var receiver in _receivers)
{
<div class="sender-receiver-chip">
<div class="sender-receiver-chip__body">
<div class="sender-receiver-chip__name">@receiver.FullName</div>
<div class="sender-receiver-chip__email">@receiver.Email</div>
</div>
<DxButton CssClass="sender-receiver-chip__action pdf-toolbar-like-btn pdf-toolbar-like-btn--signature"
Text="Signatur hinzufügen"
RenderStyle="ButtonRenderStyle.Secondary"
SizeMode="SizeMode.Small"
Click="@(() => AddSignatureForReceiver(receiver))" />
</div>
}
</div>
}
</div>
</div>
@* Right: Buttons *@
@@ -208,6 +254,78 @@
</div>
</div>
<DxPopup @bind-Visible="_receiverPopupVisible"
HeaderText="Empfänger hinzufügen"
ShowFooter="true"
CloseOnEscape="true"
Width="720px"
CssClass="sender-receiver-popup">
<BodyContentTemplate Context="popupContext">
<div class="sender-receiver-popup__body">
<div class="sender-receiver-popup__form-grid">
<div class="sender-receiver-popup__field">
<div class="sender-receiver-popup__label">Vor- und Nachname</div>
<DxTextBox Text="@_receiverDraftName"
TextChanged="OnReceiverNameChanged"
NullText="Max Mustermann"
ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"
BindValueMode="BindValueMode.OnInput"
CssClass="w-100" />
</div>
<div class="sender-receiver-popup__field">
<div class="sender-receiver-popup__label">E-Mail-Adresse</div>
<DxTextBox Text="@_receiverDraftEmail"
TextChanged="OnReceiverEmailTextChangedAsync"
NullText="name@beispiel.de"
ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"
BindValueMode="BindValueMode.OnInput"
CssClass="w-100" />
<div class="sender-receiver-popup__suggestions-shell">
@if (_receiverEmailSuggestions.Count > 0)
{
<div class="sender-receiver-popup__suggestions">
<DxListBox TData="string"
TValue="string"
Data="@_receiverEmailSuggestions"
Value="@_receiverDraftEmail"
ValueChanged="OnReceiverEmailSuggestionSelectedAsync"
SelectionMode="ListBoxSelectionMode.Single"
CssClass="w-100" />
</div>
}
</div>
</div>
</div>
<div class="sender-receiver-popup__hint">
Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.
</div>
@if (_isReceiverEmailSearchRunning)
{
<div class="sender-receiver-popup__loading">E-Mail-Vorschläge werden geladen…</div>
}
@if (!string.IsNullOrWhiteSpace(_receiverPopupValidationMessage))
{
<div class="sender-receiver-popup__validation">@_receiverPopupValidationMessage</div>
}
</div>
</BodyContentTemplate>
<FooterContentTemplate>
<div class="sender-receiver-popup__footer">
<DxButton Text="Abbrechen"
RenderStyle="ButtonRenderStyle.Secondary"
Click="CloseAddReceiverPopup" />
<DxButton Text="Empfänger hinzufügen"
RenderStyle="ButtonRenderStyle.Primary"
Click="SaveReceiverAsync" />
</div>
</FooterContentTemplate>
</DxPopup>
@code {
// ── Constants ──
// Signature field size in PDF points (fixed): 1.77" × 1.96" × 72 pt/inch
@@ -227,6 +345,15 @@
string? _errorMessage;
bool _placementMode = false;
List<SignatureFieldDraft> _signatureFields = [];
List<ReceiverDraft> _receivers = [];
bool _receiverPopupVisible;
string _receiverDraftName = string.Empty;
string _receiverDraftEmail = string.Empty;
string? _receiverPopupValidationMessage;
bool _isReceiverEmailSearchRunning;
List<string> _receiverEmailSuggestions = [];
int _receiverEmailSearchVersion;
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
// ── PDF upload ──
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
@@ -336,6 +463,120 @@
$"[SenderEditor] Total fields: {_signatureFields.Count}");
}
void OpenAddReceiverPopup()
{
_receiverDraftName = string.Empty;
_receiverDraftEmail = string.Empty;
_receiverPopupValidationMessage = null;
_receiverEmailSuggestions.Clear();
_receiverPopupVisible = true;
}
void CloseAddReceiverPopup()
{
_receiverPopupVisible = false;
_receiverPopupValidationMessage = null;
_isReceiverEmailSearchRunning = false;
}
void OnReceiverNameChanged(string? value)
{
_receiverDraftName = value ?? string.Empty;
_receiverPopupValidationMessage = null;
}
Task OnReceiverEmailSuggestionSelectedAsync(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return Task.CompletedTask;
_receiverDraftEmail = value.Trim();
_receiverPopupValidationMessage = null;
return Task.CompletedTask;
}
async Task OnReceiverEmailTextChangedAsync(string? value)
{
_receiverDraftEmail = value?.Trim() ?? string.Empty;
_receiverPopupValidationMessage = null;
var searchVersion = ++_receiverEmailSearchVersion;
if (string.IsNullOrWhiteSpace(_receiverDraftEmail) || _receiverDraftEmail.Length < 2)
{
_receiverEmailSuggestions.Clear();
_isReceiverEmailSearchRunning = false;
return;
}
_isReceiverEmailSearchRunning = true;
try
{
var results = await ReceiverPageDataService.SearchReceiverEMailsAsync(_receiverDraftEmail);
if (searchVersion != _receiverEmailSearchVersion)
return;
_receiverEmailSuggestions = results
.Where(email => !string.IsNullOrWhiteSpace(email))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(email => email)
.Take(12)
.ToList();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load receiver email suggestions for {SearchTerm}", _receiverDraftEmail);
if (searchVersion == _receiverEmailSearchVersion)
_receiverEmailSuggestions.Clear();
}
finally
{
if (searchVersion == _receiverEmailSearchVersion)
_isReceiverEmailSearchRunning = false;
}
}
Task SaveReceiverAsync()
{
var fullName = _receiverDraftName.Trim();
var email = _receiverDraftEmail.Trim();
if (string.IsNullOrWhiteSpace(fullName))
{
_receiverPopupValidationMessage = "Bitte geben Sie einen Vor- und Nachnamen ein.";
return Task.CompletedTask;
}
if (string.IsNullOrWhiteSpace(email))
{
_receiverPopupValidationMessage = "Bitte geben Sie eine E-Mail-Adresse ein.";
return Task.CompletedTask;
}
if (!ReceiverEmailValidator.IsValid(email))
{
_receiverPopupValidationMessage = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
return Task.CompletedTask;
}
if (_receivers.Any(receiver => string.Equals(receiver.Email, email, StringComparison.OrdinalIgnoreCase)))
{
_receiverPopupValidationMessage = "Diese E-Mail-Adresse wurde bereits hinzugefügt.";
return Task.CompletedTask;
}
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email));
CloseAddReceiverPopup();
return Task.CompletedTask;
}
void AddSignatureForReceiver(ReceiverDraft receiver)
{
Logger.LogInformation("Signature placement requested for receiver {Email}", receiver.Email);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!_pdfLoaded || _errorMessage is not null)
@@ -351,4 +592,6 @@
record SignatureFieldDraft(double XPt, double YPt, int Page, double DisplayX, double DisplayY);
record OverlayCoords(double RelX, double RelY, double ContainerW, double ContainerH);
record ReceiverDraft(Guid Id, string FullName, string Email);
}

View File

@@ -4,12 +4,14 @@ using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Receivers.Queries;
using EnvelopeGenerator.Server.Client.Models;
using EnvelopeGenerator.Server.Client.Models.Constants;
using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Server.Options;
using MediatR;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using ApplicationEnvelopeReceiverDto = EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto;
@@ -21,7 +23,8 @@ namespace EnvelopeGenerator.Server.Services;
public class EnvelopeReceiverPageDataService(
IMediator mediator,
IDistributedCache cache,
IOptions<CacheOptions> cacheOptions)
IOptions<CacheOptions> cacheOptions,
IMemoryCache memoryCache)
{
private const string SignatureCacheKeyPrefix = "envelope-generator.receiver-ui.signature:";
@@ -101,4 +104,23 @@ public class EnvelopeReceiverPageDataService(
Page = element.Page,
SenderAppType = (EnvelopeGenerator.Server.Client.Models.Constants.SenderAppType)element.SenderAppType
};
private static readonly string ReceiverEmailSearchCacheKey = Guid.NewGuid().ToString();
public async Task<IEnumerable<string>> SearchReceiverEMailsAsync(string emailSearchTerm, CancellationToken cancellationToken = default)
{
return await memoryCache.GetOrCreateAsync(ReceiverEmailSearchCacheKey + emailSearchTerm, async entry =>
{
var query = new ReadReceiverQuery { EmailAddressSearch = emailSearchTerm };
var receivers = await mediator.Send(query, cancellationToken);
if(receivers.Any())
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
else
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);
return receivers.Select(r => r.EmailAddress);
}) ?? [];
}
}

View File

@@ -573,6 +573,273 @@ body.resizing {
white-space: nowrap;
}
.sender-receivers-panel {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem 0.9rem;
border-radius: 12px;
background: linear-gradient(135deg, rgba(126, 34, 206, 0.05) 0%, rgba(42, 82, 152, 0.05) 100%);
border: 1px solid rgba(126, 34, 206, 0.12);
}
.sender-receivers-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.sender-receivers-panel__title-wrap {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.sender-receivers-panel__title {
font-size: 0.8rem;
font-weight: 700;
color: #4c1d95;
letter-spacing: 0.02em;
}
.sender-receivers-panel__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
min-height: 1.5rem;
padding: 0 0.45rem;
border-radius: 999px;
background: rgba(79, 70, 229, 0.12);
color: #5b21b6;
font-size: 0.72rem;
font-weight: 700;
}
.sender-receivers-panel__add-btn .dxbl-btn {
border-radius: 8px;
font-weight: 600;
}
.pdf-toolbar-like-btn .dxbl-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 34px;
padding: 0.45rem 0.85rem;
border-radius: 8px;
border: 1px solid rgba(126, 34, 206, 0.2);
background: linear-gradient(135deg, rgba(126, 34, 206, 0.05) 0%, rgba(42, 82, 152, 0.05) 100%);
color: #1e293b;
font-size: 0.75rem;
font-weight: 600;
box-shadow: none;
transition: all 0.2s ease;
}
.pdf-toolbar-like-btn .dxbl-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(126, 34, 206, 0.1) 0%, rgba(42, 82, 152, 0.1) 100%);
border-color: rgba(126, 34, 206, 0.4);
color: #1e293b;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(126, 34, 206, 0.2);
}
.pdf-toolbar-like-btn .dxbl-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(126, 34, 206, 0.15);
}
.pdf-toolbar-like-btn--add .dxbl-btn::before,
.pdf-toolbar-like-btn--signature .dxbl-btn::before {
display: inline-block;
font-size: 0.9rem;
line-height: 1;
font-weight: 700;
}
.pdf-toolbar-like-btn--add .dxbl-btn::before {
content: '+';
color: #7e22ce;
}
.pdf-toolbar-like-btn--signature .dxbl-btn {
color: #7e22ce;
}
.pdf-toolbar-like-btn--signature .dxbl-btn::before {
content: '?';
color: #7e22ce;
}
.pdf-toolbar-like-btn--signature .dxbl-btn:hover:not(:disabled) {
color: #7e22ce;
}
.sender-receivers-panel__empty {
font-size: 0.78rem;
color: #64748b;
}
.sender-receivers-list {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
}
.sender-receiver-chip {
display: inline-flex;
align-items: center;
gap: 0.75rem;
min-width: 220px;
max-width: 100%;
padding: 0.625rem 0.75rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(126, 34, 206, 0.12);
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06);
}
.sender-receiver-chip__body {
min-width: 0;
flex: 1;
}
.sender-receiver-chip__name {
font-size: 0.78rem;
font-weight: 700;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sender-receiver-chip__email {
margin-top: 0.15rem;
font-size: 0.72rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sender-receiver-chip__action .dxbl-btn {
border-radius: 8px;
font-weight: 600;
white-space: nowrap;
}
.sender-receiver-popup .dxbl-modal {
border-radius: 18px;
}
.sender-receiver-popup .dxbl-popup {
max-width: min(720px, calc(100vw - 2rem));
}
.sender-receiver-popup .dxbl-popup-content {
padding: 1rem 1.25rem 1.1rem;
}
.sender-receiver-popup .dxbl-popup-header {
padding: 0.95rem 1.25rem;
}
.sender-receiver-popup .dxbl-popup-footer {
padding: 0.75rem 1.25rem 1rem;
}
.sender-receiver-popup__body {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.sender-receiver-popup__form-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1rem 1.25rem;
align-items: start;
min-height: 250px;
}
.sender-receiver-popup__field {
min-width: 0;
}
.sender-receiver-popup__label {
margin-bottom: 0.45rem;
font-size: 0.82rem;
font-weight: 600;
color: #475569;
}
.sender-receiver-popup__field .dxbl-text-edit,
.sender-receiver-popup__field .dxbl-dropdown-edit {
width: 100%;
}
.sender-receiver-popup__field .dxbl-input-editor,
.sender-receiver-popup__field .dxbl-text-edit-input {
min-height: 38px;
}
.sender-receiver-popup__suggestions-shell {
min-height: 188px;
margin-top: 0.5rem;
}
.sender-receiver-popup__suggestions {
border: 1px solid rgba(126, 34, 206, 0.12);
border-radius: 10px;
background: rgba(255, 255, 255, 0.98);
overflow: hidden;
}
.sender-receiver-popup__suggestions .dxbl-listbox {
border: none;
}
.sender-receiver-popup__suggestions .dxbl-listbox-scroll-viewer {
max-height: 180px;
}
.sender-receiver-popup__hint {
font-size: 0.78rem;
color: #64748b;
}
.sender-receiver-popup__loading {
font-size: 0.78rem;
color: #4f46e5;
font-weight: 600;
}
.sender-receiver-popup__validation {
padding: 0.625rem 0.75rem;
border-radius: 10px;
background: rgba(239, 68, 68, 0.08);
color: #b91c1c;
font-size: 0.8rem;
font-weight: 600;
}
.sender-receiver-popup__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
width: 100%;
}
.sender-receiver-popup__footer .dxbl-btn {
min-width: 148px;
border-radius: 8px;
font-weight: 600;
}
.pdf-frame {
background: white;
border-radius: 16px;
@@ -775,6 +1042,30 @@ body.resizing {
flex-wrap: wrap;
}
.sender-receivers-panel {
padding: 0.625rem 0.75rem;
}
.sender-receiver-chip {
width: 100%;
min-width: 0;
flex-wrap: wrap;
}
.sender-receiver-chip__action {
width: 100%;
}
.sender-receiver-chip__action .dxbl-btn {
width: 100%;
}
.sender-receiver-popup__form-grid {
grid-template-columns: 1fr;
gap: 0.85rem;
min-height: 0;
}
.envelope-title {
font-size: 1rem;
}