diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor index c1cd9fca..8ec8991f 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor @@ -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 Logger +@inject EnvelopeReceiverPageDataService ReceiverPageDataService @@ -21,22 +23,66 @@ style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;"> @* Left: Title *@ -
-
- Neues Dokument -
- @if (_pdfLoaded) - { - @_fileName - @if (_signatureFields.Count > 0) +
+
+
+ Neues Dokument +
+ @if (_pdfLoaded) { - - @_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "") - + @_fileName + @if (_signatureFields.Count > 0) + { + + @_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "") + + } } - } +
+ +
+
+
+ Empfänger + @_receivers.Count +
+ + +
+ + @if (_receivers.Count == 0) + { +
+ Noch keine Empfänger hinzugefügt. +
+ } + else + { +
+ @foreach (var receiver in _receivers) + { +
+
+
@receiver.FullName
+ +
+ + +
+ } +
+ } +
@* Right: Buttons *@ @@ -208,6 +254,78 @@
+ + +
+
+
+
Vor- und Nachname
+ +
+ +
+
E-Mail-Adresse
+ + +
+ @if (_receiverEmailSuggestions.Count > 0) + { +
+ +
+ } +
+
+
+ +
+ Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen. +
+ + @if (_isReceiverEmailSearchRunning) + { +
E-Mail-Vorschläge werden geladen…
+ } + + @if (!string.IsNullOrWhiteSpace(_receiverPopupValidationMessage)) + { +
@_receiverPopupValidationMessage
+ } +
+
+ + + +
+ @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 _signatureFields = []; + List _receivers = []; + bool _receiverPopupVisible; + string _receiverDraftName = string.Empty; + string _receiverDraftEmail = string.Empty; + string? _receiverPopupValidationMessage; + bool _isReceiverEmailSearchRunning; + List _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); } diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverPageDataService.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverPageDataService.cs index 563db72e..a367a54e 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverPageDataService.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverPageDataService.cs @@ -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) + IOptions 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> 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); + }) ?? []; + } } diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css index da42b7f9..fa214406 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css @@ -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; }