Introduced a new `SenderEditorPage` with a modern UI for managing envelopes, uploading PDFs, and placing signature fields. Integrated `DevExpress.Blazor.PdfViewer` for PDF rendering and interaction. Key features include: - PDF upload, rendering, and validation. - Signature field placement with normalized coordinates. - Receiver management with validation and email suggestions. - Envelope metadata handling (title, message). - Save as draft and send functionality with validation. - Edit mode for existing envelopes. - Improved UX with dynamic elements and color-coded receivers. - Localization support for multi-language UI. Updated `_Imports.razor` to include necessary namespaces for DevExpress components and the new `PdfEditor` namespace.
1113 lines
50 KiB
Plaintext
1113 lines
50 KiB
Plaintext
@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<SenderEditorPage> Logger
|
||
@inject DocumentService DocumentService
|
||
@inject ReceiverService ReceiverService
|
||
@inject EnvelopeService EnvelopeService
|
||
@inject IStringLocalizer<Resource> UiLocalizer
|
||
|
||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||
<link href="_content/DevExpress.Blazor.Viewer/css/dx-blazor-viewer-components.bs5.css" rel="stylesheet" />
|
||
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
||
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
|
||
|
||
<div class="envelope-viewer-layout">
|
||
|
||
@* ── Action Bar ── *@
|
||
<div class="envelope-action-bar">
|
||
<div class="envelope-action-bar__inner"
|
||
style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;">
|
||
|
||
@* Left: Title + meta inputs + receivers *@
|
||
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0.5rem;">
|
||
|
||
@* ── Title & Message inputs ── *@
|
||
<div style="display: flex; align-items: flex-start; gap: 0.5rem; flex-wrap: wrap;">
|
||
|
||
@* Titel *@
|
||
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 1 1 180px; min-width: 140px;">
|
||
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
|
||
text-transform: uppercase; letter-spacing: 0.05em;">
|
||
@L("Title", "Titel") <span style="color:#dc2626;">*</span>
|
||
</label>
|
||
<input type="text"
|
||
value="@_envelopeTitle"
|
||
@oninput="e => _envelopeTitle = e.Value?.ToString() ?? string.Empty"
|
||
placeholder='@L("SenderEditor_Title_Placeholder", "z.B. Arbeitsvertrag 2025")'
|
||
maxlength="120"
|
||
style="font-size: 0.78rem; font-weight: 500; color: #1f2937;
|
||
border: 1px solid #d1d5db; border-radius: 5px;
|
||
padding: 0.3rem 0.55rem; outline: none; width: 100%;
|
||
background: #fff;
|
||
@(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "border-color:#dc2626;" : "")" />
|
||
@if (_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle))
|
||
{
|
||
<span style="font-size:0.6rem;color:#dc2626;">@L("RequiredField", "Pflichtfeld")</span>
|
||
}
|
||
</div>
|
||
|
||
@* Nachricht *@
|
||
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 2 1 260px; min-width: 180px;">
|
||
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
|
||
text-transform: uppercase; letter-spacing: 0.05em;">
|
||
@L("Message", "Nachricht") <span style="color:#6b7280;font-weight:400;">(@L("Optional", "optional"))</span>
|
||
</label>
|
||
<input type="text"
|
||
value="@_envelopeMessage"
|
||
@oninput="e => _envelopeMessage = e.Value?.ToString() ?? string.Empty"
|
||
placeholder='@L("SenderEditor_Message_Placeholder", "Bitte unterzeichnen Sie das beigefügte Dokument.")'
|
||
maxlength="500"
|
||
style="font-size: 0.78rem; color: #1f2937;
|
||
border: 1px solid #d1d5db; border-radius: 5px;
|
||
padding: 0.3rem 0.55rem; outline: none; width: 100%;
|
||
background: #fff;" />
|
||
</div>
|
||
|
||
@* PDF filename badge *@
|
||
@if (_pdfLoaded)
|
||
{
|
||
<div style="display:flex;align-items:flex-end;gap:0.4rem;padding-bottom:0.05rem;flex-shrink:0;">
|
||
<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 @L("SenderEditor_SignatureField", "Signaturfeld")@(_signatureFields.Count != 1 ? "er" : "")
|
||
</span>
|
||
}
|
||
</div>
|
||
}
|
||
</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">@L("Receiver", "Empfänger")</span>
|
||
<span class="sender-receivers-panel__count">@_receivers.Count</span>
|
||
</div>
|
||
|
||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn"
|
||
@onclick="OpenAddReceiverPopup"
|
||
title='@L("AddReceiver", "Empfänger hinzufügen")'>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||
<path d="M8 1a.5.5 0 0 1 .5.5v6h6a.5.5 0 0 1 0 1h-6v6a.5.5 0 0 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 8 1z" />
|
||
</svg>
|
||
<span class="pdf-toolbar__btn-text">@L("AddReceiver", "Empfänger hinzufügen")</span>
|
||
</button>
|
||
</div>
|
||
|
||
@if (_receivers.Count == 0)
|
||
{
|
||
<div class="sender-receivers-panel__empty">
|
||
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="sender-receivers-list">
|
||
@foreach (var receiver in _receivers)
|
||
{
|
||
<div class="sender-receiver-chip"
|
||
style="border-left: 3px solid @receiver.Color;">
|
||
<div class="sender-receiver-chip__body">
|
||
<div class="sender-receiver-chip__name">@receiver.FullName</div>
|
||
<div class="sender-receiver-chip__email">@receiver.Email</div>
|
||
@if (!string.IsNullOrWhiteSpace(receiver.PhoneNumber))
|
||
{
|
||
<div class="sender-receiver-chip__phone">@receiver.PhoneNumber</div>
|
||
}
|
||
</div>
|
||
|
||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact
|
||
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
|
||
@onclick="() => ActivatePlacementForReceiver(receiver)"
|
||
style="@(_pendingReceiverForPlacement?.Id == receiver.Id ? $"background:{receiver.Color};color:#fff;border-color:{receiver.Color};" : $"color:{receiver.Color};border-color:{receiver.Color};")"
|
||
title='@L("SenderEditor_PlaceSignatureField", "Signaturfeld platzieren")'>
|
||
<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-10z" />
|
||
</svg>
|
||
<span class="pdf-toolbar__btn-text">@L("SenderEditor_AddSignature", "Signatur hinzufügen")</span>
|
||
</button>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
@* Right: Buttons *@
|
||
<div style="display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;">
|
||
|
||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||
@onclick="Cancel"
|
||
title='@L("Cancel", "Abbrechen")'>
|
||
@L("Cancel", "Abbrechen")
|
||
</button>
|
||
|
||
@* PDF Upload *@
|
||
<label class="pdf-toolbar__btn"
|
||
title='@L("UploadPdf", "PDF hochladen")'
|
||
style="cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; font-weight: 500;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
|
||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
|
||
</svg>
|
||
@L("UploadPdf", "PDF hochladen")
|
||
<InputFile OnChange="OnPdfFileSelectedAsync"
|
||
accept=".pdf"
|
||
style="display: none;" />
|
||
</label>
|
||
|
||
@if (_pdfLoaded && _signatureFields.Count > 0)
|
||
{
|
||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||
@onclick="ClearAllFieldsAsync"
|
||
title='@L("SenderEditor_RemoveAllSignatureFields", "Alle Signaturfelder entfernen")'>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h3a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3h11V2h-11v1z" />
|
||
</svg>
|
||
</button>
|
||
}
|
||
|
||
<button class="pdf-toolbar__btn pdf-toolbar__btn--draft"
|
||
@onclick="SaveDraftAsync"
|
||
disabled="@_isSaving"
|
||
title='@L("Save", "Speichern")'>
|
||
@if (_isSaving)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1" role="status"
|
||
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||
}
|
||
else
|
||
{
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||
<path d="M2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5.414A2 2 0 0 0 15.414 4L12 0.586A2 2 0 0 0 10.586 0H2zm9 1.5v2A1.5 1.5 0 0 1 9.5 7h-3A1.5 1.5 0 0 1 5 5.5v-4h5.586a1 1 0 0 1 .707.293L14.207 4.707A1 1 0 0 1 14.5 5.414V12a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5V4A.5.5 0 0 1 2 3.5h9z"/>
|
||
<path d="M5.5 1.5v4a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-4h-4zM4 9a1 1 0 0 0-1 1v3h10v-3a1 1 0 0 0-1-1H4z"/>
|
||
</svg>
|
||
}
|
||
<span class="pdf-toolbar__btn-text">@L("Save", "Speichern")</span>
|
||
</button>
|
||
|
||
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
|
||
@onclick="SendAsync"
|
||
disabled="@_isSaving"
|
||
title='@L("SendEnvelope", "Umschlag senden")'
|
||
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
|
||
@if (_isSaving)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1" role="status"
|
||
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||
}
|
||
else
|
||
{
|
||
<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>
|
||
}
|
||
@L("Send", "Senden")
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
@* Placement mode hint bar *@
|
||
@if (_pendingReceiverForPlacement is not null)
|
||
{
|
||
<div style="background: @_pendingReceiverForPlacement.Color; color: white; font-size: 0.75rem; font-weight: 500;
|
||
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
|
||
📌 @L("SenderEditor_ClickDocumentFor", "Klicken Sie auf die Stelle im Dokument für") <strong>@_pendingReceiverForPlacement.FullName</strong>.
|
||
<button @onclick="CancelPlacement"
|
||
style="background:none;border:none;color:white;cursor:pointer;font-size:0.75rem;text-decoration:underline;padding:0;">
|
||
@L("Cancel", "Abbrechen")
|
||
</button>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
@* ── Content ── *@
|
||
<div class="envelope-content" style="padding: 0; overflow: hidden;">
|
||
@if (!_pdfLoaded)
|
||
{
|
||
@* Empty state *@
|
||
<div class="d-flex justify-content-center align-items-center h-100">
|
||
<div class="text-center" style="max-width: 420px;">
|
||
<div style="width: 72px; height: 72px; background: rgba(255,255,255,0.15);
|
||
border-radius: 50%; display: flex; align-items: center;
|
||
justify-content: center; margin: 0 auto 1.25rem;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="white" viewBox="0 0 16 16">
|
||
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
|
||
</svg>
|
||
</div>
|
||
<h5 class="text-white mb-2">@L("SenderEditor_NoDocumentLoaded", "Kein Dokument geladen")</h5>
|
||
<p class="text-white mb-4" style="opacity: 0.75; font-size: 0.85rem;">
|
||
@L("SenderEditor_UploadPdfToPlaceFields", "Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.")
|
||
</p>
|
||
<label class="btn btn-light btn-sm" style="cursor: pointer; 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 d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
|
||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
|
||
</svg>
|
||
@L("UploadPdf", "PDF hochladen")
|
||
<InputFile OnChange="OnPdfFileSelectedAsync" accept=".pdf" style="display: none;" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
}
|
||
else if (_errorMessage is not null)
|
||
{
|
||
<div class="error-container">
|
||
<div class="alert alert-danger shadow-lg m-4">
|
||
<strong>@L("Error", "Fehler"):</strong> @_errorMessage
|
||
</div>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<SenderPdfImageEditor PdfBytes="@_pdfBytes"
|
||
IsPlacementMode="@(_pendingReceiverForPlacement is not null)"
|
||
ViewerCssClass="@ViewerCssClass"
|
||
EditorClick="OnPdfAreaClickAsync" />
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<DxPopup @bind-Visible="_receiverPopupVisible"
|
||
HeaderText='@L("AddReceiver", "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">@L("FullName", "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">@L("PhoneNumber", "Telefonnummer") (@L("Optional", "optional"))</div>
|
||
<DxTextBox Text="@_receiverDraftPhoneNumber"
|
||
TextChanged="OnReceiverPhoneNumberChanged"
|
||
NullText="+49 170 1234567"
|
||
ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"
|
||
BindValueMode="BindValueMode.OnInput"
|
||
CssClass="w-100" />
|
||
</div>
|
||
|
||
<div class="sender-receiver-popup__field">
|
||
<div class="sender-receiver-popup__label">@L("EmailAddress", "E-Mail-Adresse")</div>
|
||
<div @onkeydown="OnReceiverEmailKeyDownAsync">
|
||
<DxTextBox Text="@_receiverDraftEmail"
|
||
TextChanged="OnReceiverEmailTextChangedAsync"
|
||
NullText="name@beispiel.de"
|
||
ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"
|
||
BindValueMode="BindValueMode.OnInput"
|
||
CssClass="w-100" />
|
||
</div>
|
||
|
||
<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="@_selectedReceiverEmailSuggestion"
|
||
ValueChanged="OnReceiverEmailSuggestionCommittedAsync"
|
||
SelectionMode="ListBoxSelectionMode.Single"
|
||
CssClass="w-100" />
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sender-receiver-popup__hint">
|
||
@L("SenderEditor_EmailSuggestionsHint", "Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.")
|
||
</div>
|
||
|
||
@if (_isReceiverEmailSearchRunning)
|
||
{
|
||
<div class="sender-receiver-popup__loading">@L("SenderEditor_EmailSuggestionsLoading", "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='@L("Cancel", "Abbrechen")'
|
||
RenderStyle="ButtonRenderStyle.Secondary"
|
||
Click="CloseAddReceiverPopup" />
|
||
<DxButton Text='@L("AddReceiver", "Empfänger hinzufügen")'
|
||
RenderStyle="ButtonRenderStyle.Primary"
|
||
Click="SaveReceiverAsync" />
|
||
</div>
|
||
</FooterContentTemplate>
|
||
</DxPopup>
|
||
|
||
@* ── Save result popup (success + error) ── *@
|
||
<DxPopup @bind-Visible="_savePopupVisible"
|
||
HeaderText='@(_saveErrorMessage is null ? _saveSuccessHeader : L("Error", "Fehler"))'
|
||
Width="460px"
|
||
MaxWidth="95vw"
|
||
ShowFooter="true"
|
||
CloseOnOutsideClick="false"
|
||
ShowCloseButton="false"
|
||
CloseOnEscape="false">
|
||
<BodyContentTemplate>
|
||
@if (_saveErrorMessage is null)
|
||
{
|
||
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
|
||
<div style="flex-shrink:0;width:40px;height:40px;background:#d1fae5;border-radius:50%;
|
||
display:flex;align-items:center;justify-content:center;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#065f46" viewBox="0 0 16 16">
|
||
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
||
@_saveSuccessTitle
|
||
</p>
|
||
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
|
||
@_saveSuccessBody
|
||
</p>
|
||
</div>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
|
||
<div style="flex-shrink:0;width:40px;height:40px;background:#fee2e2;border-radius:50%;
|
||
display:flex;align-items:center;justify-content:center;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#991b1b" 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>
|
||
<div>
|
||
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
||
@L("SenderEditor_ErrorCreatingEnvelope", "Fehler beim Erstellen des Umschlags")
|
||
</p>
|
||
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;word-break:break-word;">
|
||
@_saveErrorMessage
|
||
</p>
|
||
</div>
|
||
</div>
|
||
}
|
||
</BodyContentTemplate>
|
||
<FooterContentTemplate>
|
||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
|
||
@if (_saveErrorMessage is null)
|
||
{
|
||
<button class="btn btn-outline-secondary"
|
||
@onclick="() => _savePopupVisible = false"
|
||
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
|
||
@L("Close", "Schließen")
|
||
</button>
|
||
|
||
@if (_saveNavigateToDashboard)
|
||
{
|
||
<button class="btn btn-primary"
|
||
@onclick="GoToDashboard"
|
||
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
|
||
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
|
||
@L("SenderEditor_ToOverview", "Zur Übersicht")
|
||
</button>
|
||
}
|
||
}
|
||
else
|
||
{
|
||
<button class="btn btn-outline-secondary"
|
||
@onclick="() => _savePopupVisible = false"
|
||
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
|
||
@L("Close", "Schließen")
|
||
</button>
|
||
}
|
||
</div>
|
||
</FooterContentTemplate>
|
||
</DxPopup>
|
||
|
||
@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<SignatureFieldDraft> _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<ReceiverDraft> _receivers = [];
|
||
bool _receiverPopupVisible;
|
||
string _receiverDraftName = string.Empty;
|
||
string _receiverDraftEmail = string.Empty;
|
||
string _receiverDraftPhoneNumber = string.Empty;
|
||
string? _selectedReceiverEmailSuggestion;
|
||
string? _receiverPopupValidationMessage;
|
||
bool _isReceiverEmailSearchRunning;
|
||
List<string> _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<DocReceiverElementDto>())
|
||
.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<NormalisedCoords?>(
|
||
"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;
|
||
}
|
||
}
|