Files
EnvelopeGenerator/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor
TekH 74e55e9206 Refactor and optimize envelope handling
Refactored `ReadDocumentDetailsQueryHandler` to remove AutoMapper, improving performance by manually selecting necessary data. Simplified `SenderEditorPage.razor` by removing bootstrap logic and updating the UI for a more compact design. Streamlined envelope creation in `SenderPage.razor` by eliminating the bootstrap token. Updated styles in `envelope-viewer.css` for better alignment and readability.
2026-10-27 21:56:39 +01:00

1561 lines
68 KiB
Plaintext

@page "/sender/editor"
@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.Domain.Constants
@using EnvelopeGenerator.Domain.Resources
@using EnvelopeGenerator.Application.Common.Configurations
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
@using EnvelopeGenerator.Server.Client.Services
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Localization
@using Microsoft.Extensions.Options
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
@inject AppVersionService AppVersion
@inject ILogger<SenderEditorPage> Logger
@inject DocumentService DocumentService
@inject PdfRenderService PdfRenderService
@inject ReceiverService ReceiverService
@inject EnvelopeReceiverService EnvelopeReceiverService
@inject EnvelopeService EnvelopeService
@inject EnvelopeTypeService EnvelopeTypeService
@inject AuthService AuthService
@inject IStringLocalizer<Resource> UiLocalizer
@inject IStringLocalizer<Model> ModelLocalizer
@inject IOptionsMonitor<SignatureFieldParams> SignatureFieldOptions
<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 sender-editor-action-inner">
@* Left: Title + meta inputs + receivers *@
<div class="sender-editor-main-column">
@* ── Title & Message inputs ── *@
<div class="sender-editor-meta-row">
@* Titel *@
<div class="sender-editor-input-field sender-editor-input-field--title">
<label class="sender-editor-label">
@L("Title", "Titel") <span class="sender-editor-required-asterisk">*</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"
class="sender-editor-text-input @(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "sender-editor-text-input--invalid" : string.Empty)" />
@if (_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle))
{
<span class="sender-editor-required-text">@L("RequiredField", "Pflichtfeld")</span>
}
</div>
@* Nachricht *@
<div class="sender-editor-input-field sender-editor-input-field--message">
<label class="sender-editor-label">
@L("Message", "Nachricht") <span class="sender-editor-optional">(@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"
class="sender-editor-text-input" />
</div>
@* PDF filename badge *@
@if (_pdfLoaded)
{
<div class="sender-editor-file-meta">
<span class="sender-editor-file-name">@_fileName</span>
@if (_signatureFields.Count > 0)
{
<span class="sender-editor-signature-count">
@_signatureFields.Count @L("SenderEditor_SignatureField", "Signaturfeld")@(_signatureFields.Count != 1 ? "er" : "")
</span>
}
</div>
}
</div>
<div class="sender-settings-shell">
<DxAccordion ExpandMode="AccordionExpandMode.SingleOrNone" CssClass="sender-settings-accordion">
<Items>
<DxAccordionItem Text='@L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen")' Expanded="false">
<ContentTemplate>
<div class="sender-settings-body">
<div class="sender-settings-grid">
<div class="sender-settings-field">
<label class="sender-settings-label">@L("EnvelopeType", "Umschlagtyp")</label>
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
@onchange="OnEnvelopeTypeChanged"
class="sender-settings-select">
<option value="">@L("SenderEditor_SelectTemplate", "Vorlage auswählen")</option>
@foreach (var type in _envelopeTypes)
{
<option value="@type.Id">@type.Title</option>
}
</select>
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("Language", "Sprache")</label>
<select @bind="_language" class="sender-settings-select">
<option value="de">Deutsch (de)</option>
<option value="en">English (en)</option>
<option value="de-DE">Deutsch (de-DE)</option>
<option value="en-US">English (en-US)</option>
</select>
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("CertificationType", "Zertifizierung")</label>
<select @bind="_certificationType" class="sender-settings-select">
<option value="@CertificationType.AdvancedElectronicSignature">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
<option value="@CertificationType.ElectronicSignature">@L("ElectronicSignature", "Electronic Signature")</option>
</select>
</div>
<div class="sender-settings-toggles">
<label class="sender-settings-toggle">
<input type="checkbox" checked="@_tfaEnabled" @onchange="OnTfaChanged" />
@L("TFAEnabled", "2FA aktiviert")
</label>
<label class="sender-settings-toggle">
<input type="checkbox" checked="@_useAccessCode" @onchange="OnUseAccessCodeChanged" disabled="@_tfaEnabled" />
@L("UseAccessCode", "Zugriffscode verwenden")
</label>
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("FinalEmailToCreator", "Finale E-Mail an Ersteller")</label>
<select @bind="_finalEmailToCreator" class="sender-settings-select">
<option value="0">@L("No", "Nein")</option>
<option value="1">@L("Yes", "Ja")</option>
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
</select>
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("FinalEmailToReceivers", "Finale E-Mail an Empfänger")</label>
<select @bind="_finalEmailToReceivers" class="sender-settings-select">
<option value="0">@L("No", "Nein")</option>
<option value="1">@L("Yes", "Ja")</option>
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
</select>
</div>
<div class="sender-settings-toggles sender-settings-toggles--single">
<label class="sender-settings-toggle">
<input type="checkbox" checked="@_sendReminderEmails" @onchange="OnSendReminderEmailsChanged" />
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
</label>
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("FirstReminderDays", "Erste Erinnerung (Tage)")</label>
<InputNumber @bind-Value="_firstReminderDays" class="form-control form-control-sm sender-settings-number" disabled="@(!_sendReminderEmails)" />
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("ReminderIntervalDays", "Erinnerungsintervall (Tage)")</label>
<InputNumber @bind-Value="_reminderIntervalDays" class="form-control form-control-sm sender-settings-number" disabled="@(!_sendReminderEmails)" />
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("ExpiresWhenDays", "Läuft ab nach (Tage)")</label>
<InputNumber @bind-Value="_expiresWhenDays" class="form-control form-control-sm sender-settings-number" />
</div>
<div class="sender-settings-field">
<label class="sender-settings-label">@L("ExpiresWarningWhenDays", "Ablaufwarnung (Tage)")</label>
<InputNumber @bind-Value="_expiresWarningWhenDays" class="form-control form-control-sm sender-settings-number" />
</div>
</div>
</div>
</ContentTemplate>
</DxAccordionItem>
</Items>
</DxAccordion>
</div>
<div class="sender-receivers-panel">
<div class="sender-receivers-list">
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-receivers-list__add-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="sender-receivers-list__add-text">@L("AddReceiver", "Empfänger hinzufügen")</span>
<span class="badge rounded-pill text-bg-primary sender-receivers-list__count">@_receivers.Count</span>
</button>
@foreach (var receiver in _receivers)
{
<div class="sender-receiver-chip" style="--receiver-color:@receiver.Color;">
<div class="sender-receiver-chip__body">
<div class="sender-receiver-chip__email" title="@receiver.FullName">@receiver.Email</div>
</div>
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset sender-toolbar-action-btn sender-toolbar-action-btn--compact sender-receiver-chip__action sender-receiver-chip__remove-btn"
@onclick="() => RemoveReceiverAsync(receiver)"
title='@L("Delete", "Loeschen")'>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact sender-receiver-chip__action sender-receiver-chip__place-btn
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "sender-receiver-chip__place-btn--active" : "sender-receiver-chip__place-btn--idle")"
@onclick="() => ActivatePlacementForReceiver(receiver)"
style="--receiver-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>
</button>
</div>
}
</div>
@if (_receivers.Count == 0)
{
<div class="sender-receivers-panel__empty">
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
</div>
}
</div>
</div>
@* Right: Buttons *@
<div class="sender-editor-actions">
<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 sender-editor-upload-btn @((!_isPageReady || _isPdfUploadInProgress) ? "sender-editor-upload-btn--disabled" : string.Empty)"
title='@L("UploadPdf", "PDF hochladen")'
>
@if (_isPdfUploadInProgress)
{
<span class="spinner-border spinner-border-sm sender-editor-spinner" role="status"></span>
}
else
{
<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>
}
@if (_isPdfUploadInProgress)
{
@L("UploadPdf", "PDF hochladen")
@:
@:...
}
else if (_isPageReady)
{
@L("UploadPdf", "PDF hochladen")
}
else
{
@L("Loading", "Wird geladen")
@:
@:...
}
<InputFile OnChange="OnPdfFileSelectedAsync"
accept=".pdf"
disabled="@(!_isPageReady || _isPdfUploadInProgress)"
class="sender-editor-hidden-input" />
</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 sender-editor-spinner" role="status"></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")'>
@if (_isSaving)
{
<span class="spinner-border spinner-border-sm me-1 sender-editor-spinner" role="status"></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 class="sender-editor-placement-hint" style="--placement-color:@_pendingReceiverForPlacement.Color;">
📌 @L("SenderEditor_ClickDocumentFor", "Klicken Sie auf die Stelle im Dokument für") <strong>@_pendingReceiverForPlacement.FullName</strong>.
&nbsp;<button @onclick="CancelPlacement" class="sender-editor-placement-cancel">
@L("Cancel", "Abbrechen")
</button>
</div>
}
</div>
@* ── Content ── *@
<div class="envelope-content sender-editor-content">
@if (!_pdfLoaded)
{
@* Empty state *@
<div class="d-flex justify-content-center align-items-center h-100">
<div class="text-center sender-editor-empty-state">
<div class="sender-editor-empty-state__icon-wrap">
<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 sender-editor-empty-state__description">
@L("SenderEditor_UploadPdfToPlaceFields", "Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.")
</p>
<label class="btn btn-light btn-sm sender-editor-empty-state__upload @((!_isPageReady || _isPdfUploadInProgress) ? "sender-editor-empty-state__upload--disabled" : string.Empty)">
@if (_isPdfUploadInProgress)
{
<span class="spinner-border spinner-border-sm me-1 sender-editor-spinner" role="status"></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="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>
}
@if (_isPdfUploadInProgress)
{
@L("UploadPdf", "PDF hochladen")
@:
@:...
}
else if (_isPageReady)
{
@L("UploadPdf", "PDF hochladen")
}
else
{
@L("Loading", "Wird geladen")
@:
@:...
}
<InputFile OnChange="OnPdfFileSelectedAsync"
accept=".pdf"
disabled="@(!_isPageReady || _isPdfUploadInProgress)"
class="sender-editor-hidden-input" />
</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 IsPlacementMode="@(_pendingReceiverForPlacement is not null)"
PageCount="@_pageCount"
Pages="@_pagePreviews"
PdfDataUrl="@PdfDataUrl"
PdfFileName="@_fileName"
Fields="@FieldOverlays"
EditorClick="OnPdfAreaClickAsync"
MoveField="OnMoveFieldAsync"
RemoveField="OnRemoveFieldByIndexAsync" />
}
</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 class="sender-editor-save-result">
<div class="sender-editor-save-result__icon sender-editor-save-result__icon--success">
<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 class="sender-editor-save-result__title">
@_saveSuccessTitle
</p>
<p class="sender-editor-save-result__message">
@_saveSuccessBody
</p>
</div>
</div>
}
else
{
<div class="sender-editor-save-result">
<div class="sender-editor-save-result__icon sender-editor-save-result__icon--error">
<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 class="sender-editor-save-result__title">
@L("SenderEditor_ErrorCreatingEnvelope", "Fehler beim Erstellen des Umschlags")
</p>
<p class="sender-editor-save-result__message sender-editor-save-result__message--break">
@_saveErrorMessage
</p>
</div>
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div class="sender-editor-save-actions">
@if (_saveErrorMessage is null)
{
<button class="btn btn-outline-secondary sender-editor-save-actions__secondary"
@onclick="() => _savePopupVisible = false">
@L("Close", "Schließen")
</button>
@if (_saveNavigateToDashboard)
{
<button class="btn btn-primary sender-editor-save-actions__primary" @onclick="GoToDashboard">
@L("SenderEditor_ToOverview", "Zur Übersicht")
</button>
}
}
else
{
<button class="btn btn-outline-secondary sender-editor-save-actions__secondary"
@onclick="() => _savePopupVisible = false">
@L("Close", "Schließen")
</button>
}
</div>
</FooterContentTemplate>
</DxPopup>
@code {
[SupplyParameterFromQuery(Name = "envelopeId")]
public int? EnvelopeId { get; set; }
// ── Signature field size (configurable) ──
double SigWidthInches => SignatureFieldOptions.CurrentValue.Width;
double SigHeightInches => SignatureFieldOptions.CurrentValue.Height;
double SigWidthPt => SigWidthInches * 72.0;
double SigHeightPt => SigHeightInches * 72.0;
// CssClass for JS click coordinate helper
const string ViewerCssClass = "sender-editor-pdf-viewer";
// ── State ──
bool _pdfLoaded = false;
bool _isPageReady;
bool _isPdfUploadInProgress;
string _fileName = string.Empty;
string? _errorMessage;
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
int _pageCount;
List<PdfPageImageDto> _pagePreviews = [];
int? _loadedEnvelopeId;
bool _isLoadingEnvelopeForEdit;
List<SignatureFieldDraft> _signatureFields = [];
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
IReadOnlyList<SenderPdfFieldOverlay> FieldOverlays => _signatureFields
.Select((f, i) => new SenderPdfFieldOverlay(i, f.Page, f.XPt, f.YPt, f.ReceiverName, f.Color))
.ToList();
string? PdfDataUrl => _originalPdfBytes is { Length: > 0 }
? $"data:application/pdf;base64,{Convert.ToBase64String(_originalPdfBytes)}"
: null;
// ── 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<EnvelopeTypeDto> _envelopeTypes = [];
int? _selectedEnvelopeTypeId;
string _language = "de-DE";
bool _useAccessCode;
bool _tfaEnabled;
CertificationType _certificationType = CertificationType.AdvancedElectronicSignature;
int _finalEmailToCreator = (int)FinalEmailType.No;
int _finalEmailToReceivers = (int)FinalEmailType.No;
bool _sendReminderEmails;
int _firstReminderDays;
int _reminderIntervalDays;
int _expiresWhenDays;
int _expiresWarningWhenDays;
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();
// ── Lifecycle ──
protected override async Task OnInitializedAsync()
{
var hasAccess = await AuthService.CheckSenderAccessAsync();
if (!hasAccess)
{
NavigationManager.NavigateTo("/sender/login");
return;
}
try
{
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load envelope types for sender editor.");
_envelopeTypes = [];
}
}
protected override async Task OnParametersSetAsync()
{
if (!EnvelopeId.HasValue || EnvelopeId.Value <= 0)
{
NavigationManager.NavigateTo("/sender", replace: true);
return;
}
if (_loadedEnvelopeId != EnvelopeId && !_isLoadingEnvelopeForEdit)
await LoadEnvelopeForEditAsync(EnvelopeId.Value);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_isPageReady = true;
await InvokeAsync(StateHasChanged);
}
}
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;
_selectedEnvelopeTypeId = envelope.EnvelopeTypeId;
_language = string.IsNullOrWhiteSpace(envelope.Language) ? "de-DE" : envelope.Language;
_useAccessCode = envelope.UseAccessCode;
_tfaEnabled = envelope.TFAEnabled;
if (_tfaEnabled)
_useAccessCode = true;
_certificationType = envelope.CertificationType ?? CertificationType.AdvancedElectronicSignature;
_finalEmailToCreator = envelope.FinalEmailToCreator ?? (int)FinalEmailType.No;
_finalEmailToReceivers = envelope.FinalEmailToReceivers ?? (int)FinalEmailType.No;
_sendReminderEmails = envelope.SendReminderEmails ?? envelope.EnvelopeType?.SendReminderEmails ?? false;
_firstReminderDays = envelope.FirstReminderDays ?? envelope.EnvelopeType?.FirstReminderDays ?? 0;
_reminderIntervalDays = envelope.ReminderIntervalDays ?? envelope.EnvelopeType?.ReminderIntervalDays ?? 0;
_expiresWhenDays = envelope.ExpiresWhenDays ?? envelope.EnvelopeType?.ExpiresDays ?? 0;
_expiresWarningWhenDays = envelope.ExpiresWarningWhenDays ?? envelope.EnvelopeType?.ExpiresWarningDays ?? 0;
NormalizeSettings();
var envelopeReceivers = envelope.EnvelopeReceivers?.ToList() ?? [];
_receivers = envelopeReceivers
.Select((er, index) => new ReceiverDraft(
Guid.NewGuid(),
er.ReceiverId,
er.Name ?? er.Receiver?.EmailAddress ?? $"Receiver {er.ReceiverId}",
er.Receiver?.EmailAddress ?? string.Empty,
string.Empty,
ReceiverPalette[index % ReceiverPalette.Length]))
.ToList();
var receiverDraftByReceiverId = _receivers
.Where(r => r.ReceiverId.HasValue)
.ToDictionary(r => r.ReceiverId!.Value, r => r);
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];
var receiverDraft = receiverDraftByReceiverId.TryGetValue(element.ReceiverId, out var draft)
? draft
: _receivers.FirstOrDefault(r =>
(!string.IsNullOrWhiteSpace(receiverEmail) && string.Equals(r.Email, receiverEmail, StringComparison.OrdinalIgnoreCase))
|| string.Equals(r.FullName, receiverName, StringComparison.OrdinalIgnoreCase));
return new SignatureFieldDraft(
XPt: element.X * 72.0,
YPt: element.Y * 72.0,
Page: element.Page,
ReceiverDraftId: receiverDraft?.Id ?? Guid.Empty,
ReceiverName: receiverName,
Color: color);
})
.ToList();
_originalPdfBytes = document?.ByteData;
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
if (_pdfLoaded)
{
try
{
_pagePreviews = (await PdfRenderService.RenderPagesAsync(_originalPdfBytes!))
.OrderBy(p => p.PageNumber)
.ToList();
}
catch (Exception renderEx)
{
Logger.LogWarning(renderEx, "Failed to render PDF page previews for envelope {EnvelopeId}. Falling back to native preview.", envelopeId);
_pagePreviews = [];
}
_pageCount = _pagePreviews.Count > 0
? _pagePreviews.Count
: 1;
}
else
{
_pagePreviews = [];
_pageCount = Math.Max(1, _signatureFields.Select(f => f.Page).DefaultIfEmpty(1).Max());
}
_fileName = $"Envelope-{envelopeId}.pdf";
_pendingReceiverForPlacement = null;
_loadedEnvelopeId = 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)
{
if (!_isPageReady)
return;
if (_isPdfUploadInProgress)
return;
_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
{
_isPdfUploadInProgress = true;
await InvokeAsync(StateHasChanged);
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;
_pagePreviews = (await PdfRenderService.RenderPagesAsync(_originalPdfBytes))
.OrderBy(p => p.PageNumber)
.ToList();
_pageCount = _pagePreviews.Count > 0
? _pagePreviews.Count
: EstimatePdfPageCount(_originalPdfBytes) ?? 1;
_signatureFields.Clear();
_pendingReceiverForPlacement = null;
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");
}
finally
{
_isPdfUploadInProgress = false;
await InvokeAsync(StateHasChanged);
}
}
// ── 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;
Task OnEnvelopeTypeChanged(ChangeEventArgs e)
{
var raw = e.Value?.ToString();
if (!int.TryParse(raw, out var id) || id <= 0)
{
_selectedEnvelopeTypeId = null;
return Task.CompletedTask;
}
_selectedEnvelopeTypeId = id;
var selectedType = _envelopeTypes.FirstOrDefault(x => x.Id == id);
if (selectedType is not null)
ApplyEnvelopeTypeDefaults(selectedType);
return Task.CompletedTask;
}
void ApplyEnvelopeTypeDefaults(EnvelopeTypeDto envelopeType)
{
if (!string.IsNullOrWhiteSpace(envelopeType.Language))
_language = envelopeType.Language;
if (envelopeType.UseAccessCode.HasValue)
_useAccessCode = envelopeType.UseAccessCode.Value;
if (_tfaEnabled)
_useAccessCode = true;
if (envelopeType.CertificationType.HasValue)
_certificationType = envelopeType.CertificationType.Value;
if (envelopeType.FinalEmailToCreator.HasValue)
_finalEmailToCreator = envelopeType.FinalEmailToCreator.Value;
if (envelopeType.FinalEmailToReceivers.HasValue)
_finalEmailToReceivers = envelopeType.FinalEmailToReceivers.Value;
if (envelopeType.SendReminderEmails.HasValue)
_sendReminderEmails = envelopeType.SendReminderEmails.Value;
if (envelopeType.FirstReminderDays.HasValue)
_firstReminderDays = envelopeType.FirstReminderDays.Value;
if (envelopeType.ReminderIntervalDays.HasValue)
_reminderIntervalDays = envelopeType.ReminderIntervalDays.Value;
if (envelopeType.ExpiresDays.HasValue)
_expiresWhenDays = envelopeType.ExpiresDays.Value;
if (envelopeType.ExpiresWarningDays.HasValue)
_expiresWarningWhenDays = envelopeType.ExpiresWarningDays.Value;
}
void OnTfaChanged(ChangeEventArgs e)
{
_tfaEnabled = ParseCheckboxValue(e);
if (_tfaEnabled)
_useAccessCode = true;
}
void OnUseAccessCodeChanged(ChangeEventArgs e)
{
if (_tfaEnabled)
{
_useAccessCode = true;
return;
}
_useAccessCode = ParseCheckboxValue(e);
}
void OnSendReminderEmailsChanged(ChangeEventArgs e)
{
_sendReminderEmails = ParseCheckboxValue(e);
}
static bool ParseCheckboxValue(ChangeEventArgs e)
{
if (e.Value is bool b)
return b;
return bool.TryParse(e.Value?.ToString(), out var parsed) && parsed;
}
// ── 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;
}
int page1Based = coords.PageIndex + 1;
var pagePreview = _pagePreviews.FirstOrDefault(p => p.PageNumber == page1Based);
var pageWidthPt = pagePreview?.WidthPt ?? 595.0;
var pageHeightPt = pagePreview?.HeightPt ?? 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));
var field = new SignatureFieldDraft(
XPt: xPt,
YPt: yPt,
Page: page1Based,
ReceiverDraftId: _pendingReceiverForPlacement.Id,
ReceiverName: _pendingReceiverForPlacement.FullName,
Color: _pendingReceiverForPlacement.Color);
_signatureFields.Add(field);
_pendingReceiverForPlacement = null;
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 OnRemoveFieldByIndexAsync(int index)
{
if (index < 0 || index >= _signatureFields.Count)
return;
_signatureFields.RemoveAt(index);
await Task.CompletedTask;
}
async Task OnMoveFieldAsync(SenderPdfFieldMoveRequest request)
{
if (request.Index < 0 || request.Index >= _signatureFields.Count)
return;
var field = _signatureFields[request.Index];
_signatureFields[request.Index] = field with
{
XPt = request.XPt,
YPt = request.YPt,
Page = request.Page
};
await Task.CompletedTask;
}
// ── Clear all fields ──
async Task ClearAllFieldsAsync()
{
_signatureFields.Clear();
_pendingReceiverForPlacement = null;
await Task.CompletedTask;
}
static int? EstimatePdfPageCount(byte[]? documentBytes)
{
if (documentBytes is null || documentBytes.Length == 0)
return null;
try
{
var text = System.Text.Encoding.ASCII.GetString(documentBytes);
const string marker = "/Type /Page";
var count = 0;
var index = 0;
while (true)
{
index = text.IndexOf(marker, index, StringComparison.Ordinal);
if (index < 0)
break;
count++;
index += marker.Length;
}
return count > 0 ? count : null;
}
catch
{
return null;
}
}
void Cancel() => NavigationManager.NavigateTo("/sender");
void GoToDashboard()
{
NavigationManager.NavigateTo("/sender");
}
async Task SaveDraftAsync()
{
await SaveDraftCoreAsync(navigateToDashboard: true);
}
async Task<int?> SaveDraftCoreAsync(bool navigateToDashboard)
{
if (!ValidateSettings(out var settingsError))
{
_saveErrorMessage = settingsError;
_savePopupVisible = true;
return null;
}
_isSaving = true;
_saveErrorMessage = null;
await InvokeAsync(StateHasChanged);
try
{
var command = BuildCreateEnvelopeCommand(send: false);
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId}", command.EnvelopeId);
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;
if (navigateToDashboard)
NavigationManager.NavigateTo("/sender", forceLoad: false);
return result.Id;
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to save draft");
_saveErrorMessage = ex.Message;
_savePopupVisible = true;
return null;
}
finally
{
_isSaving = false;
await InvokeAsync(StateHasChanged);
}
}
// ── Send envelope ──
async Task SendAsync()
{
if (!ValidateSettings(out var settingsError))
{
_saveErrorMessage = settingsError;
_savePopupVisible = true;
return;
}
// ── 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.ReceiverDraftId == r.Id))
.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 = EnvelopeId ?? _loadedEnvelopeId;
if (!envelopeId.HasValue || envelopeId.Value <= 0)
throw new InvalidOperationException("Envelope id is missing for send.");
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)
{
NormalizeSettings();
var envelopeId = EnvelopeId ?? _loadedEnvelopeId;
if (envelopeId is null)
throw new InvalidOperationException("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.ReceiverDraftId == receiver.Id)
.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,
EnvelopeTypeId = _selectedEnvelopeTypeId,
Title = _envelopeTitle,
Message = _envelopeMessage,
Language = _language,
UseAccessCode = _useAccessCode,
TFAEnabled = _tfaEnabled,
CertificationType = _certificationType,
FinalEmailToCreator = _finalEmailToCreator,
FinalEmailToReceivers = _finalEmailToReceivers,
SendReminderEmails = _sendReminderEmails,
FirstReminderDays = _firstReminderDays,
ReminderIntervalDays = _reminderIntervalDays,
ExpiresWhenDays = _expiresWhenDays,
ExpiresWarningWhenDays = _expiresWarningWhenDays,
Send = send,
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
Receivers = receiverCommands
};
}
void NormalizeSettings()
{
_language = string.IsNullOrWhiteSpace(_language) ? "de-DE" : _language.Trim();
if (_tfaEnabled)
_useAccessCode = true;
_firstReminderDays = Math.Max(0, _firstReminderDays);
_reminderIntervalDays = Math.Max(0, _reminderIntervalDays);
_expiresWhenDays = Math.Max(0, _expiresWhenDays);
_expiresWarningWhenDays = Math.Max(0, _expiresWarningWhenDays);
if (_expiresWhenDays > 0 && _expiresWarningWhenDays > _expiresWhenDays)
_expiresWarningWhenDays = _expiresWhenDays;
}
bool ValidateSettings(out string? message)
{
NormalizeSettings();
if (_sendReminderEmails && _reminderIntervalDays <= 0)
{
message = L("SenderEditor_Validation_ReminderInterval", "Bitte geben Sie ein Erinnerungsintervall groesser als 0 Tage ein.");
return false;
}
if (_sendReminderEmails && _firstReminderDays < 0)
{
message = L("SenderEditor_Validation_FirstReminder", "Bitte geben Sie gueltige Tage fuer die erste Erinnerung ein.");
return false;
}
if (_expiresWhenDays > 0 && _expiresWarningWhenDays > _expiresWhenDays)
{
message = L("SenderEditor_Validation_ExpiryWarning", "Die Ablaufwarnung darf nicht nach dem Ablaufdatum liegen.");
return false;
}
message = null;
return true;
}
// ── 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(), null, fullName, email, phoneNumber,
ReceiverPalette[_receivers.Count % ReceiverPalette.Length]));
CloseAddReceiverPopup();
return Task.CompletedTask;
}
async Task RemoveReceiverAsync(ReceiverDraft receiver)
{
if (_isSaving)
return;
var envelopeId = EnvelopeId ?? _loadedEnvelopeId;
if (envelopeId is int persistedEnvelopeId && persistedEnvelopeId > 0 && receiver.ReceiverId is int persistedReceiverId && persistedReceiverId > 0)
{
try
{
await EnvelopeReceiverService.DeleteAsync(persistedEnvelopeId, persistedReceiverId);
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to remove receiver {ReceiverId} from envelope {EnvelopeId}", persistedReceiverId, persistedEnvelopeId);
_saveErrorMessage = string.Format(
L("SenderEditor_RemoveReceiverError", "Empfaenger konnte nicht entfernt werden: {0}"),
ex.Message);
_savePopupVisible = true;
return;
}
}
_receivers.RemoveAll(r => r.Id == receiver.Id);
_signatureFields.RemoveAll(f => f.ReceiverDraftId == receiver.Id);
if (_pendingReceiverForPlacement?.Id == receiver.Id)
_pendingReceiverForPlacement = null;
}
// ── Models ──
record SignatureFieldDraft(double XPt, double YPt, int Page, Guid ReceiverDraftId, string ReceiverName, string Color);
record NormalisedCoords(double NormX, double NormY, int PageIndex);
record ReceiverDraft(Guid Id, int? ReceiverId, 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 modelValue = ModelLocalizer[key];
if (!modelValue.ResourceNotFound)
return modelValue.Value;
var uiValue = UiLocalizer[key];
return uiValue.ResourceNotFound ? fallback : uiValue.Value;
}
}