diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditor.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditor.razor new file mode 100644 index 00000000..d40e51ef --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditor.razor @@ -0,0 +1,31 @@ +@using DevExpress.Blazor.PdfViewer + +
+ @if (PdfBytes is { Length: > 0 }) + { + + } +
+ +@code { + [Parameter] + public byte[]? PdfBytes { get; set; } + + [Parameter] + public bool IsPlacementMode { get; set; } + + [Parameter] + public string ViewerCssClass { get; set; } = "sender-editor-pdf-viewer"; + + [Parameter] + public EventCallback EditorClick { get; set; } + + private async Task HandleClickAsync(MouseEventArgs args) + { + if (EditorClick.HasDelegate) + await EditorClick.InvokeAsync(args); + } +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor new file mode 100644 index 00000000..bc3afdd9 --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor @@ -0,0 +1,1112 @@ +@page "/sender/editor2" +@rendermode InteractiveWebAssembly +@using DevExpress.Blazor.Reporting.Models +@using DevExpress.Blazor +@using EnvelopeGenerator.Application.Common.Dto +@using EnvelopeGenerator.Application.Envelopes.Commands +@using EnvelopeGenerator.Application.Resources +@using EnvelopeGenerator.Server.Client.Services +@using Microsoft.AspNetCore.WebUtilities +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.Extensions.Localization +@inject IJSRuntime JSRuntime +@inject NavigationManager NavigationManager +@inject AppVersionService AppVersion +@inject ILogger Logger +@inject DocumentService DocumentService +@inject ReceiverService ReceiverService +@inject EnvelopeService EnvelopeService +@inject IStringLocalizer UiLocalizer + + + + + + +
+ + @* ── Action Bar ── *@ +
+
+ + @* Left: Title + meta inputs + receivers *@ +
+ + @* ── Title & Message inputs ── *@ +
+ + @* Titel *@ +
+ + + @if (_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle)) + { + @L("RequiredField", "Pflichtfeld") + } +
+ + @* Nachricht *@ +
+ + +
+ + @* PDF filename badge *@ + @if (_pdfLoaded) + { +
+ @_fileName + @if (_signatureFields.Count > 0) + { + + @_signatureFields.Count @L("SenderEditor_SignatureField", "Signaturfeld")@(_signatureFields.Count != 1 ? "er" : "") + + } +
+ } +
+ +
+
+
+ @L("Receiver", "Empfänger") + @_receivers.Count +
+ + +
+ + @if (_receivers.Count == 0) + { +
+ @L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.") +
+ } + else + { +
+ @foreach (var receiver in _receivers) + { +
+
+
@receiver.FullName
+ + @if (!string.IsNullOrWhiteSpace(receiver.PhoneNumber)) + { +
@receiver.PhoneNumber
+ } +
+ + +
+ } +
+ } +
+
+ + @* Right: Buttons *@ +
+ + + + @* PDF Upload *@ + + + @if (_pdfLoaded && _signatureFields.Count > 0) + { + + } + + + + +
+
+ + @* Placement mode hint bar *@ + @if (_pendingReceiverForPlacement is not null) + { +
+ 📌 @L("SenderEditor_ClickDocumentFor", "Klicken Sie auf die Stelle im Dokument für") @_pendingReceiverForPlacement.FullName. +   +
+ } +
+ + @* ── Content ── *@ +
+ @if (!_pdfLoaded) + { + @* Empty state *@ +
+
+
+ + + +
+
@L("SenderEditor_NoDocumentLoaded", "Kein Dokument geladen")
+

+ @L("SenderEditor_UploadPdfToPlaceFields", "Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.") +

+ +
+
+ } + else if (_errorMessage is not null) + { +
+
+ @L("Error", "Fehler"): @_errorMessage +
+
+ } + else + { + + } +
+
+ + + +
+
+
+
@L("FullName", "Vor- und Nachname")
+ +
+ +
+
@L("PhoneNumber", "Telefonnummer") (@L("Optional", "optional"))
+ +
+ +
+
@L("EmailAddress", "E-Mail-Adresse")
+
+ +
+ +
+ @if (_receiverEmailSuggestions.Count > 0) + { +
+ +
+ } +
+
+
+ +
+ @L("SenderEditor_EmailSuggestionsHint", "Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.") +
+ + @if (_isReceiverEmailSearchRunning) + { +
@L("SenderEditor_EmailSuggestionsLoading", "E-Mail-Vorschläge werden geladen…")
+ } + + @if (!string.IsNullOrWhiteSpace(_receiverPopupValidationMessage)) + { +
@_receiverPopupValidationMessage
+ } +
+
+ + + +
+ +@* ── Save result popup (success + error) ── *@ + + + @if (_saveErrorMessage is null) + { +
+
+ + + +
+
+

+ @_saveSuccessTitle +

+

+ @_saveSuccessBody +

+
+
+ } + else + { +
+
+ + + + +
+
+

+ @L("SenderEditor_ErrorCreatingEnvelope", "Fehler beim Erstellen des Umschlags") +

+

+ @_saveErrorMessage +

+
+
+ } +
+ +
+ @if (_saveErrorMessage is null) + { + + + @if (_saveNavigateToDashboard) + { + + } + } + else + { + + } +
+
+
+ +@code { + [SupplyParameterFromQuery(Name = "envelopeId")] + public int? EnvelopeId { get; set; } + + // ── Constants ── + // Signature field size in PDF points (fixed): 1.77" × 1.96" + const double SigWidthPt = 1.77 * 72; // 127.44 pt + const double SigHeightPt = 1.96 * 72; // 141.12 pt + + // CssClass for DxPdfViewer — used by JS to locate page elements + const string ViewerCssClass = "sender-editor-pdf-viewer"; + + // ── State ── + bool _pdfLoaded = false; + string _fileName = string.Empty; + string? _errorMessage; + byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in) + byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw + int? _loadedEnvelopeId; + int? _editingEnvelopeId; + bool _isLoadingEnvelopeForEdit; + + List _signatureFields = []; + ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen" + + // ── Save state ── + bool _isSaving = false; + bool _savePopupVisible = false; + string? _saveErrorMessage = null; + string _saveSuccessHeader = "Erfolg"; + string _saveSuccessTitle = "Vorgang erfolgreich abgeschlossen."; + string _saveSuccessBody = string.Empty; + bool _saveNavigateToDashboard = false; + + // ── Envelope metadata ── + string _envelopeTitle = string.Empty; + string _envelopeMessage = string.Empty; + bool _titleTouched = false; + + List _receivers = []; + bool _receiverPopupVisible; + string _receiverDraftName = string.Empty; + string _receiverDraftEmail = string.Empty; + string _receiverDraftPhoneNumber = string.Empty; + string? _selectedReceiverEmailSuggestion; + string? _receiverPopupValidationMessage; + bool _isReceiverEmailSearchRunning; + List _receiverEmailSuggestions = []; + int _receiverEmailSearchVersion; + + static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new(); + + bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0; + + // ── Lifecycle ── + + protected override async Task OnParametersSetAsync() + { + if (EnvelopeId.HasValue && EnvelopeId.Value > 0) + _editingEnvelopeId = EnvelopeId.Value; + + if (IsEditMode && _loadedEnvelopeId != EnvelopeId) + { + await LoadEnvelopeForEditAsync(EnvelopeId!.Value); + return; + } + + if (IsEditMode) + return; + } + + async Task LoadEnvelopeForEditAsync(int envelopeId) + { + if (_isLoadingEnvelopeForEdit) + return; + + _isLoadingEnvelopeForEdit = true; + _errorMessage = null; + + try + { + var envelopes = await EnvelopeService.GetAsync(id: envelopeId); + var envelope = envelopes?.FirstOrDefault() + ?? throw new InvalidOperationException($"Envelope {envelopeId} not found."); + + var document = await DocumentService.GetSenderDocumentDetailsByEnvelopeIdAsync(envelopeId); + + _envelopeTitle = envelope.Title ?? string.Empty; + _envelopeMessage = envelope.Message ?? string.Empty; + + var envelopeReceivers = envelope.EnvelopeReceivers?.ToList() ?? []; + _receivers = envelopeReceivers + .Select((er, index) => new ReceiverDraft( + Guid.NewGuid(), + er.Name ?? er.Receiver?.EmailAddress ?? $"Receiver {er.ReceiverId}", + er.Receiver?.EmailAddress ?? string.Empty, + string.Empty, + ReceiverPalette[index % ReceiverPalette.Length])) + .ToList(); + + var receiverById = envelopeReceivers.ToDictionary(er => er.ReceiverId, er => er); + var colorByEmail = _receivers + .Where(r => !string.IsNullOrWhiteSpace(r.Email)) + .GroupBy(r => r.Email, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Color, StringComparer.OrdinalIgnoreCase); + + _signatureFields = (document?.Elements ?? Enumerable.Empty()) + .Select(element => + { + receiverById.TryGetValue(element.ReceiverId, out var envelopeReceiver); + + var receiverName = envelopeReceiver?.Name + ?? envelopeReceiver?.Receiver?.EmailAddress + ?? element.FullName + ?? $"Receiver {element.ReceiverId}"; + + var receiverEmail = envelopeReceiver?.Receiver?.EmailAddress; + var color = (!string.IsNullOrWhiteSpace(receiverEmail) && colorByEmail.TryGetValue(receiverEmail, out var receiverColor)) + ? receiverColor + : ReceiverPalette[Math.Abs(element.ReceiverId) % ReceiverPalette.Length]; + + return new SignatureFieldDraft( + XPt: element.X * 72.0, + YPt: element.Y * 72.0, + Page: element.Page, + ReceiverName: receiverName, + Color: color); + }) + .ToList(); + + _originalPdfBytes = document?.ByteData; + _pdfLoaded = _originalPdfBytes is { Length: > 0 }; + _pdfBytes = _pdfLoaded ? _originalPdfBytes : null; + _fileName = $"Envelope-{envelopeId}.pdf"; + _pendingReceiverForPlacement = null; + _loadedEnvelopeId = envelopeId; + _editingEnvelopeId = envelopeId; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load envelope {EnvelopeId} for edit mode", envelopeId); + _errorMessage = string.Format( + L("SenderEditor_LoadEnvelopeError", "Fehler beim Laden des Umschlags #{0}: {1}"), + envelopeId, + ex.Message); + } + finally + { + _isLoadingEnvelopeForEdit = false; + } + } + + // ── PDF upload ── + async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e) + { + _errorMessage = null; + var file = e.File; + if (file is null) return; + + if (!file.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) + { + _errorMessage = L("SenderEditor_SelectPdf", "Bitte wählen Sie eine PDF-Datei aus."); + return; + } + + try + { + const long maxBytes = 50 * 1024 * 1024; + using var ms = new System.IO.MemoryStream(); + await file.OpenReadStream(maxBytes).CopyToAsync(ms); + + _originalPdfBytes = ms.ToArray(); + _fileName = file.Name; + _pdfLoaded = true; + _signatureFields.Clear(); + _pendingReceiverForPlacement = null; + + // Rendered PDF starts as the clean original (no placeholders yet) + _pdfBytes = _originalPdfBytes; + + Logger.LogInformation("PDF loaded: {Name} ({Size} bytes)", _fileName, _originalPdfBytes.Length); + } + catch (Exception ex) + { + _errorMessage = string.Format( + L("SenderEditor_LoadFileError", "Fehler beim Laden der Datei: {0}"), + ex.Message); + Logger.LogError(ex, "Failed to load PDF file"); + } + } + + // ── Placement mode ── + void ActivatePlacementForReceiver(ReceiverDraft receiver) + { + // Toggle: clicking the same receiver again cancels placement + _pendingReceiverForPlacement = _pendingReceiverForPlacement?.Id == receiver.Id + ? null + : receiver; + } + + void CancelPlacement() => _pendingReceiverForPlacement = null; + + // ── PDF area click → place field ── + async Task OnPdfAreaClickAsync(MouseEventArgs e) + { + if (_pendingReceiverForPlacement is null) return; + if (!_pdfLoaded || _originalPdfBytes is null) return; + + // Ask JS for the normalised click position within the rendered PDF page + var coords = await JSRuntime.InvokeAsync( + "envelopeEditor.getClickCoordsOnPdfPage", + ViewerCssClass, e.ClientX, e.ClientY); + + if (coords is null) + { + Logger.LogWarning("[SenderEditor] getClickCoordsOnPdfPage returned null"); + return; + } + + // Approximate points based on A4. This is temporary until the image-based viewer + // pipeline provides precise per-page dimensions. + const double pageWidthPt = 595.0; + const double pageHeightPt = 842.0; + + // Convert normalised [0,1] → PDF points; clamp so box stays inside page + double xPt = coords.NormX * pageWidthPt; + double yPt = coords.NormY * pageHeightPt; + + xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - SigWidthPt)); + yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - SigHeightPt)); + + int page1Based = coords.PageIndex + 1; + + var field = new SignatureFieldDraft( + XPt: xPt, + YPt: yPt, + Page: page1Based, + ReceiverName: _pendingReceiverForPlacement.FullName, + Color: _pendingReceiverForPlacement.Color); + + _signatureFields.Add(field); + _pendingReceiverForPlacement = null; + + // Keep original PDF bytes unchanged. Signature boxes are tracked in-memory. + _pdfBytes = _originalPdfBytes; + + Logger.LogInformation( + "[SenderEditor] Field added: Page={Page} X={X:F1}pt Y={Y:F1}pt Receiver={Receiver}", + page1Based, xPt, yPt, field.ReceiverName); + } + + // ── Remove a single field ── + async Task RemoveFieldAsync(SignatureFieldDraft field) + { + _signatureFields.Remove(field); + _pdfBytes = _originalPdfBytes; + await Task.CompletedTask; + } + + // ── Clear all fields ── + async Task ClearAllFieldsAsync() + { + _signatureFields.Clear(); + _pendingReceiverForPlacement = null; + _pdfBytes = _originalPdfBytes; + await Task.CompletedTask; + } + + void Cancel() => NavigationManager.NavigateTo("/sender"); + + void GoToDashboard() + { + NavigationManager.NavigateTo("/sender"); + } + + async Task SaveDraftAsync() + { + _isSaving = true; + _saveErrorMessage = null; + await InvokeAsync(StateHasChanged); + + try + { + var previousEnvelopeId = EnvelopeId; + var command = BuildCreateEnvelopeCommand(send: false); + Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId} IsEditMode={IsEditMode}", command.EnvelopeId, IsEditMode); + var result = await EnvelopeService.UpsertAsync(command); + + if (result is null) + throw new InvalidOperationException("Draft save did not return a result."); + + EnvelopeId = result.Id; + _loadedEnvelopeId = result.Id; + _editingEnvelopeId = result.Id; + + if (previousEnvelopeId != result.Id) + { + NavigationManager.NavigateTo($"/sender/editor2?envelopeId={result.Id}", forceLoad: false); + } + + NavigationManager.NavigateTo("/sender", forceLoad: false); + } + catch (Exception ex) + { + Logger.LogError(ex, "[SenderEditor] Failed to save draft"); + _saveErrorMessage = ex.Message; + _savePopupVisible = true; + } + finally + { + _isSaving = false; + } + } + + // ── Send envelope ── + async Task SendAsync() + { + // ── Validation ── + _titleTouched = true; + if (!_pdfLoaded || _originalPdfBytes is null) + { + _saveErrorMessage = L("SenderEditor_Validation_UploadPdf", "Bitte laden Sie zuerst ein PDF-Dokument hoch."); + _savePopupVisible = true; + return; + } + if (string.IsNullOrWhiteSpace(_envelopeTitle)) + { + _saveErrorMessage = L("SenderEditor_Validation_TitleRequired", "Bitte geben Sie einen Titel ein."); + _savePopupVisible = true; + return; + } + if (_receivers.Count == 0) + { + _saveErrorMessage = L("SenderEditor_Validation_ReceiverRequired", "Bitte fügen Sie mindestens einen Empfänger hinzu."); + _savePopupVisible = true; + return; + } + if (_signatureFields.Count == 0) + { + _saveErrorMessage = L("SenderEditor_Validation_SignatureFieldRequired", "Bitte platzieren Sie mindestens ein Signaturfeld."); + _savePopupVisible = true; + return; + } + + // Warn if any receiver has no signature field, but don't block + var receiversWithoutField = _receivers + .Where(r => !_signatureFields.Any(f => f.ReceiverName == r.FullName)) + .ToList(); + if (receiversWithoutField.Count > 0) + { + Logger.LogWarning( + "[SenderEditor] Receivers without signature field: {Names}", + string.Join(", ", receiversWithoutField.Select(r => r.FullName))); + } + + _isSaving = true; + _saveErrorMessage = null; + await InvokeAsync(StateHasChanged); + + try + { + var envelopeId = _editingEnvelopeId + ?? EnvelopeId + ?? _loadedEnvelopeId + ?? GetEnvelopeIdFromCurrentUri(); + + if (!envelopeId.HasValue || envelopeId.Value <= 0) + { + var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false)) + ?? throw new InvalidOperationException("Draft save did not return a result."); + + envelopeId = saveResult.Id; + EnvelopeId = saveResult.Id; + _loadedEnvelopeId = saveResult.Id; + _editingEnvelopeId = saveResult.Id; + } + + var sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId }) + ?? throw new InvalidOperationException("Send did not return a result."); + + Logger.LogInformation("[SenderEditor] Envelope sent from draft. Id={Id} Status={Status}", sendResult.Id, sendResult.Status); + + // Success — show popup; GoToDashboard clears cache and navigates + _saveErrorMessage = null; + _saveSuccessHeader = L("SenderEditor_Sent_Header", "Umschlag gesendet"); + _saveSuccessTitle = L("SenderEditor_Sent_Title", "Umschlag wurde erfolgreich gesendet."); + _saveSuccessBody = L("SenderEditor_Sent_Body", "Der bestehende Entwurf wurde versendet."); + _saveNavigateToDashboard = true; + _savePopupVisible = true; + } + catch (Exception ex) + { + Logger.LogError(ex, "[SenderEditor] Failed to send envelope draft"); + _saveErrorMessage = ex.Message; + _savePopupVisible = true; + } + finally + { + _isSaving = false; + } + } + + CreateEnvelopeCommand BuildCreateEnvelopeCommand(bool send) + { + var envelopeId = _editingEnvelopeId + ?? EnvelopeId + ?? _loadedEnvelopeId + ?? GetEnvelopeIdFromCurrentUri(); + + if (IsEditMode && envelopeId is null) + throw new InvalidOperationException("Edit mode envelope id is missing."); + + var docBase64 = _originalPdfBytes is { Length: > 0 } + ? Convert.ToBase64String(_originalPdfBytes) + : null; + + var receiverCommands = _receivers.Select(receiver => + { + var fields = _signatureFields + .Where(f => f.ReceiverName == receiver.FullName) + .Select(f => new CreateEnvelopeFieldDto( + X: f.XPt / 72.0, + Y: f.YPt / 72.0, + Page: f.Page)) + .ToList(); + + return new CreateEnvelopeReceiverDto( + EmailAddress: receiver.Email, + Name: receiver.FullName, + PhoneNumber: string.IsNullOrWhiteSpace(receiver.PhoneNumber) ? null : receiver.PhoneNumber, + Fields: fields); + }).ToList(); + + return new CreateEnvelopeCommand + { + EnvelopeId = envelopeId, + Title = _envelopeTitle, + Message = _envelopeMessage, + TFAEnabled = false, + Send = send, + Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64), + Receivers = receiverCommands + }; + } + + int? GetEnvelopeIdFromCurrentUri() + { + try + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + if (string.IsNullOrWhiteSpace(uri.Query)) + return null; + + var query = QueryHelpers.ParseQuery(uri.Query); + if (!query.TryGetValue("envelopeId", out var raw)) + return null; + + return int.TryParse(raw.ToString(), out var id) && id > 0 + ? id + : null; + } + catch + { + return null; + } + } + + // ── Receiver popup ── + void OpenAddReceiverPopup() + { + _receiverDraftName = string.Empty; + _receiverDraftEmail = string.Empty; + _receiverDraftPhoneNumber = string.Empty; + _selectedReceiverEmailSuggestion = null; + _receiverPopupValidationMessage = null; + _receiverEmailSuggestions.Clear(); + _receiverPopupVisible = true; + } + + void CloseAddReceiverPopup() + { + _receiverPopupVisible = false; + _receiverPopupValidationMessage = null; + _selectedReceiverEmailSuggestion = null; + _isReceiverEmailSearchRunning = false; + } + + void OnReceiverNameChanged(string? value) + { + _receiverDraftName = value ?? string.Empty; + _receiverPopupValidationMessage = null; + } + + void OnReceiverPhoneNumberChanged(string? value) + { + _receiverDraftPhoneNumber = value ?? string.Empty; + _receiverPopupValidationMessage = null; + } + + async Task OnReceiverEmailKeyDownAsync(KeyboardEventArgs e) + { + if (_receiverEmailSuggestions.Count == 0) + { + if (e.Key == "Escape") _selectedReceiverEmailSuggestion = null; + return; + } + + var currentIndex = _selectedReceiverEmailSuggestion is null + ? -1 + : _receiverEmailSuggestions.FindIndex(em => + string.Equals(em, _selectedReceiverEmailSuggestion, StringComparison.OrdinalIgnoreCase)); + + if (e.Key == "ArrowDown") + { + var next = currentIndex < _receiverEmailSuggestions.Count - 1 ? currentIndex + 1 : 0; + SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]); + } + else if (e.Key == "ArrowUp") + { + var next = currentIndex > 0 ? currentIndex - 1 : _receiverEmailSuggestions.Count - 1; + SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]); + } + else if (e.Key == "Enter") + { + var sel = currentIndex >= 0 && currentIndex < _receiverEmailSuggestions.Count + ? _receiverEmailSuggestions[currentIndex] + : _receiverEmailSuggestions.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(sel)) + await OnReceiverEmailSuggestionCommittedAsync(sel); + } + else if (e.Key == "Escape") + { + _receiverEmailSuggestions.Clear(); + _selectedReceiverEmailSuggestion = null; + } + } + + void SelectReceiverEmailSuggestion(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return; + _selectedReceiverEmailSuggestion = value.Trim(); + _receiverDraftEmail = _selectedReceiverEmailSuggestion; + _receiverPopupValidationMessage = null; + } + + Task OnReceiverEmailSuggestionCommittedAsync(string? value) + { + SelectReceiverEmailSuggestion(value); + _receiverEmailSuggestions.Clear(); + return Task.CompletedTask; + } + + async Task OnReceiverEmailTextChangedAsync(string? value) + { + _receiverDraftEmail = value?.Trim() ?? string.Empty; + _selectedReceiverEmailSuggestion = _receiverDraftEmail; + _receiverPopupValidationMessage = null; + + var searchVersion = ++_receiverEmailSearchVersion; + + if (string.IsNullOrWhiteSpace(_receiverDraftEmail) || _receiverDraftEmail.Length < 2) + { + _receiverEmailSuggestions.Clear(); + _selectedReceiverEmailSuggestion = null; + _isReceiverEmailSearchRunning = false; + return; + } + + _isReceiverEmailSearchRunning = true; + + try + { + var results = await ReceiverService.SearchReceiverEmailsAsync(_receiverDraftEmail); + if (searchVersion != _receiverEmailSearchVersion) return; + + _receiverEmailSuggestions = results + .Where(em => !string.IsNullOrWhiteSpace(em)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(em => em) + .Take(12) + .ToList(); + + _selectedReceiverEmailSuggestion = _receiverEmailSuggestions.FirstOrDefault(em => + string.Equals(em, _receiverDraftEmail, StringComparison.OrdinalIgnoreCase)); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to load receiver email suggestions for {SearchTerm}", _receiverDraftEmail); + if (searchVersion == _receiverEmailSearchVersion) + { + _receiverEmailSuggestions.Clear(); + _selectedReceiverEmailSuggestion = null; + } + } + finally + { + if (searchVersion == _receiverEmailSearchVersion) + _isReceiverEmailSearchRunning = false; + } + } + + Task SaveReceiverAsync() + { + var fullName = _receiverDraftName.Trim(); + var email = _receiverDraftEmail.Trim(); + var phoneNumber = _receiverDraftPhoneNumber.Trim(); + + if (string.IsNullOrWhiteSpace(fullName)) + { + _receiverPopupValidationMessage = L("SenderEditor_Validation_FullNameRequired", "Bitte geben Sie einen Vor- und Nachnamen ein."); + return Task.CompletedTask; + } + if (string.IsNullOrWhiteSpace(email)) + { + _receiverPopupValidationMessage = L("SenderEditor_Validation_EmailRequired", "Bitte geben Sie eine E-Mail-Adresse ein."); + return Task.CompletedTask; + } + if (!ReceiverEmailValidator.IsValid(email)) + { + _receiverPopupValidationMessage = L("SenderEditor_Validation_EmailInvalid", "Bitte geben Sie eine gültige E-Mail-Adresse ein."); + return Task.CompletedTask; + } + if (_receivers.Any(r => string.Equals(r.Email, email, StringComparison.OrdinalIgnoreCase))) + { + _receiverPopupValidationMessage = L("SenderEditor_Validation_EmailDuplicate", "Diese E-Mail-Adresse wurde bereits hinzugefügt."); + return Task.CompletedTask; + } + + _receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber, + ReceiverPalette[_receivers.Count % ReceiverPalette.Length])); + CloseAddReceiverPopup(); + return Task.CompletedTask; + } + + // ── Models ── + record SignatureFieldDraft(double XPt, double YPt, int Page, string ReceiverName, string Color); + + record NormalisedCoords(double NormX, double NormY, int PageIndex); + + record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber, string Color); + + // ── Receiver colour palette (cycles when > 8 receivers) ── + static readonly string[] ReceiverPalette = + [ + "#4F46E5", // indigo + "#059669", // emerald + "#DC2626", // red + "#D97706", // amber + "#7C3AED", // violet + "#0891B2", // cyan + "#BE185D", // pink + "#65A30D", // lime + ]; + + string L(string key, string fallback) + { + var value = UiLocalizer[key]; + return value.ResourceNotFound ? fallback : value.Value; + } +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/_Imports.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/_Imports.razor index ed2136be..84ebf4e7 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/_Imports.razor +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/_Imports.razor @@ -11,6 +11,7 @@ @using EnvelopeGenerator.Server.Client.Services @using EnvelopeGenerator.Server.Client.Models @using EnvelopeGenerator.Server.Client.Options +@using EnvelopeGenerator.Server.Client.Components.PdfEditor @using DevExpress.Blazor @using DevExpress.Blazor.PdfViewer @using DevExpress.Blazor.Reporting