Files
EnvelopeGenerator/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/Sender/CreateEnvelopeWizardPopup.razor
TekH fac4ec28a4 Add localization and UI updates for receiver actions
Added new localized strings for "Recently used recipients" and
"Skip" in German, English, and French resource files. Updated
the `CreateEnvelopeWizardPopup.razor` to enhance the "Add
Receiver" button with a primary render style and a new CSS
class. Introduced styles for the button in `sender-page.css`
to improve its appearance with a minimum width, bold font,
and box shadow.
2026-10-05 17:02:12 +02:00

745 lines
26 KiB
Plaintext

@using EnvelopeGenerator.Application.Common.Dto
@using EnvelopeGenerator.Application.Common.Dto.Receiver
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Resources
@using EnvelopeGenerator.Server.Client.Services
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Model> ModelLocalizer
@inject IStringLocalizer<Resource> UiLocalizer
@inject ReceiverService ReceiverService
<DxPopup Visible="@_isVisible"
VisibleChanged="OnPopupVisibleChangedAsync"
HeaderText="@L("SenderWizard_Header", "Neuen Umschlag erstellen")"
Width="860px"
MaxWidth="96vw"
ShowFooter="true"
CloseOnOutsideClick="false"
ShowCloseButton="true"
CloseOnEscape="true"
ShowHeader="true"
CssClass="sender-create-wizard-popup">
<BodyContentTemplate>
<div class="sender-create-wizard"
tabindex="0"
@onkeydown="OnWizardKeyDownAsync">
<div class="sender-create-wizard__progress">
<div class="sender-create-wizard__progress-track"></div>
@for (var step = 0; step < TotalSteps; step++)
{
var isActive = step == _currentStep;
var isDone = step < _currentStep;
<div class="sender-create-wizard__step @(isActive ? "sender-create-wizard__step--active" : string.Empty) @(isDone ? "sender-create-wizard__step--done" : string.Empty)">
<span class="sender-create-wizard__step-index">@(step + 1)</span>
</div>
}
</div>
<div class="sender-create-wizard__meta">
<div class="sender-create-wizard__title">@CurrentStepTitle</div>
<div class="sender-create-wizard__description">@CurrentStepDescription</div>
</div>
@if (!string.IsNullOrWhiteSpace(_validationMessage))
{
<div class="sender-create-wizard__validation">@_validationMessage</div>
}
@if (_currentStep == 0)
{
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("Title", "Titel") <span class="sender-create-wizard__required">*</span>
</label>
<DxTextBox Text="@_title"
TextChanged="OnTitleChanged"
BindValueMode="BindValueMode.OnInput"
NullText='@L("SenderEditor_Title_Placeholder", "z.B. Arbeitsvertrag 2025")'
CssClass="w-100" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("EnvelopeType", "Umschlagtyp") <span class="sender-create-wizard__required">*</span>
</label>
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
@onchange="OnEnvelopeTypeChanged"
class="sender-create-wizard__select">
@foreach (var type in EnvelopeTypes)
{
<option value="@type.Id">@type.Title</option>
}
</select>
</div>
</div>
}
else if (_currentStep == 1)
{
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field sender-create-wizard__field--full">
<label class="sender-create-wizard__label">
@L("Message", "Nachricht") <span class="sender-create-wizard__optional">(@L("Optional", "optional"))</span>
</label>
<DxMemo Text="@_message"
TextChanged="OnMessageChanged"
Rows="5"
MaxLength="500"
NullText='@L("SenderEditor_Message_Placeholder", "Bitte unterzeichnen Sie das beigefügte Dokument.")'
CssClass="w-100" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("Language", "Sprache")
</label>
<select @bind="_language" class="sender-create-wizard__select">
<option value="de-DE">Deutsch (de-DE)</option>
<option value="en-US">English (en-US)</option>
<option value="de">Deutsch (de)</option>
<option value="en">English (en)</option>
</select>
</div>
</div>
}
else if (_currentStep == 2)
{
<div class="sender-create-wizard__suggestions">
<div class="sender-create-wizard__suggestions-title">@L("SenderWizard_RecentReceivers", "Zuletzt verwendete Empfänger")</div>
@if (_isLoadingRecentSuggestions)
{
<div class="sender-create-wizard__empty-list">@L("Loading", "Lädt...")</div>
}
else if (_recentReceiverSuggestions.Count > 0)
{
<div class="sender-create-wizard__suggestion-list">
@foreach (var suggestion in _recentReceiverSuggestions)
{
<button type="button"
class="sender-create-wizard__suggestion-item"
@onclick="() => ApplyRecentSuggestion(suggestion)"
disabled="@IsActionLocked">
<span class="sender-create-wizard__suggestion-name">@suggestion.FullName</span>
<span class="sender-create-wizard__suggestion-email">@suggestion.EmailAddress</span>
</button>
}
</div>
}
</div>
<div class="sender-create-wizard__form-grid">
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("FullName", "Vor- und Nachname") <span class="sender-create-wizard__required">*</span>
</label>
<DxTextBox Text="@_receiverName"
TextChanged="OnReceiverNameChanged"
BindValueMode="BindValueMode.OnInput"
NullText="Max Mustermann"
CssClass="w-100" />
</div>
<div class="sender-create-wizard__field">
<label class="sender-create-wizard__label">
@L("EmailAddress", "E-Mail-Adresse") <span class="sender-create-wizard__required">*</span>
</label>
<DxTextBox Text="@_receiverEmail"
TextChanged="OnReceiverEmailChanged"
BindValueMode="BindValueMode.OnInput"
NullText="name@beispiel.de"
CssClass="w-100" />
</div>
</div>
<div class="sender-create-wizard__receiver-actions">
<DxButton Text='@L("AddReceiver", "Empfänger hinzufügen")'
RenderStyle="ButtonRenderStyle.Primary"
CssClass="sender-create-wizard__add-receiver-btn"
Click="AddReceiver"
Enabled="@(!IsActionLocked)" />
</div>
@if (_receivers.Count == 0)
{
<div class="sender-create-wizard__empty-list">
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
</div>
}
else
{
<div class="sender-create-wizard__receiver-list">
@foreach (var receiver in _receivers)
{
<div class="sender-create-wizard__receiver-item">
<div>
<div class="sender-create-wizard__receiver-name">@receiver.FullName</div>
<div class="sender-create-wizard__receiver-email">@receiver.EmailAddress</div>
</div>
<button type="button"
class="sender-create-wizard__receiver-remove"
@onclick="() => RemoveReceiver(receiver)"
disabled="@IsActionLocked">
@L("Delete", "Löschen")
</button>
</div>
}
</div>
}
}
else
{
<div class="sender-create-wizard__upload-stage">
<label class="sender-create-wizard__upload-btn @(IsActionLocked ? "sender-create-wizard__upload-btn--disabled" : string.Empty)">
@if (_documentBytes is null)
{
<span>@L("UploadPdf", "PDF hochladen")</span>
}
else
{
<span>@L("SenderWizard_ReplacePdf", "PDF ersetzen")</span>
}
<InputFile OnChange="OnDocumentSelectedAsync"
accept=".pdf"
disabled="@IsActionLocked"
class="sender-create-wizard__hidden-input" />
</label>
@if (_documentBytes is not null)
{
<div class="sender-create-wizard__document-meta">
<div class="sender-create-wizard__document-name">@_documentFileName</div>
<div class="sender-create-wizard__document-size">@_documentSizeText</div>
</div>
}
else
{
<div class="sender-create-wizard__empty-list">
@L("SenderWizard_DocumentRequiredHint", "Bitte laden Sie ein PDF-Dokument hoch, um fortzufahren.")
</div>
}
</div>
}
</div>
</BodyContentTemplate>
<FooterContentTemplate>
<div class="sender-create-wizard__footer">
<DxButton Text="@L("Cancel", "Abbrechen")"
RenderStyle="ButtonRenderStyle.Secondary"
Click="CloseAsync"
Enabled="@(!IsActionLocked)" />
@if (CanSkipCurrentStep)
{
<DxButton Text="@L("Skip", "Überspringen")"
RenderStyle="ButtonRenderStyle.Secondary"
Click="SkipOptionalStep"
Enabled="@(!IsActionLocked)" />
}
<DxButton Text="@L("Back", "Zurück")"
RenderStyle="ButtonRenderStyle.Secondary"
Click="MoveBack"
Enabled="@(!IsFirstStep && !IsActionLocked)" />
@if (!IsLastStep)
{
<DxButton Text="@L("Next", "Weiter")"
RenderStyle="ButtonRenderStyle.Primary"
Click="MoveNextAsync"
Enabled="@(!IsActionLocked)" />
}
else
{
<DxButton Text="@L("Start", "Editor öffnen")"
RenderStyle="ButtonRenderStyle.Primary"
Click="FinishAsync"
Enabled="@(!IsActionLocked)" />
}
</div>
</FooterContentTemplate>
</DxPopup>
@code {
[Parameter]
public bool Visible { get; set; }
[Parameter]
public EventCallback<bool> VisibleChanged { get; set; }
[Parameter]
public EventCallback<CreateEnvelopeWizardResult> StartEditorRequested { get; set; }
[Parameter]
public IReadOnlyList<EnvelopeTypeDto> EnvelopeTypes { get; set; } = [];
private const int TotalSteps = 4;
private static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute EmailValidator = new();
private bool _isVisible;
private bool _previousVisible;
private int _currentStep;
private string _title = string.Empty;
private int? _selectedEnvelopeTypeId;
private string _message = string.Empty;
private string _language = "de-DE";
private string _receiverName = string.Empty;
private string _receiverEmail = string.Empty;
private readonly List<CreateEnvelopeWizardReceiver> _receivers = [];
private readonly List<RecentReceiverSuggestionDto> _recentReceiverSuggestions = [];
private bool _isLoadingRecentSuggestions;
private bool _recentSuggestionsLoaded;
private byte[]? _documentBytes;
private string? _documentFileName;
private string _documentSizeText = string.Empty;
private bool _isDocumentUploading;
private bool _isStepActionRunning;
private bool _isFinishing;
private string? _validationMessage;
private bool IsActionLocked => _isDocumentUploading || _isStepActionRunning || _isFinishing;
private bool IsFirstStep => _currentStep <= 0;
private bool IsLastStep => _currentStep >= TotalSteps - 1;
private bool CanSkipCurrentStep => _currentStep == 1;
private string CurrentStepTitle => _currentStep switch
{
0 => L("SenderWizard_Step_Basics", "Grunddaten"),
1 => L("SenderWizard_Step_Message", "Nachricht und Optionen"),
2 => L("SenderWizard_Step_Receivers", "Empfänger"),
3 => L("SenderWizard_Step_Document", "Dokument"),
_ => L("SenderWizard_Step_Basics", "Grunddaten")
};
private string CurrentStepDescription => _currentStep switch
{
0 => L("SenderWizard_Step_Basics_Desc", "Starten Sie mit den Pflichtangaben für den Umschlag."),
1 => L("SenderWizard_Step_Message_Desc", "Fügen Sie optionale Nachricht und Einstellungen hinzu."),
2 => L("SenderWizard_Step_Receivers_Desc", "Definieren Sie die Empfänger für den Signaturprozess."),
3 => L("SenderWizard_Step_Document_Desc", "Laden Sie das Dokument hoch und platzieren Sie Signaturfelder."),
_ => L("SenderWizard_Step_Basics_Desc", "Starten Sie mit den Pflichtangaben für den Umschlag.")
};
protected override void OnParametersSet()
{
_isVisible = Visible;
if (Visible && !_previousVisible)
ResetWizardState();
_previousVisible = Visible;
}
private Task OnTitleChanged(string? value)
{
_title = value ?? string.Empty;
_validationMessage = null;
return Task.CompletedTask;
}
private Task OnMessageChanged(string? value)
{
_message = value ?? string.Empty;
_validationMessage = null;
return Task.CompletedTask;
}
private Task OnReceiverNameChanged(string? value)
{
_receiverName = value ?? string.Empty;
_validationMessage = null;
return Task.CompletedTask;
}
private Task OnReceiverEmailChanged(string? value)
{
_receiverEmail = value ?? string.Empty;
_validationMessage = null;
return Task.CompletedTask;
}
private Task OnEnvelopeTypeChanged(ChangeEventArgs args)
{
_validationMessage = null;
var raw = args.Value?.ToString();
if (int.TryParse(raw, out var id) && id > 0)
{
_selectedEnvelopeTypeId = id;
return Task.CompletedTask;
}
_selectedEnvelopeTypeId = null;
return Task.CompletedTask;
}
private Task AddReceiver()
{
if (IsActionLocked)
return Task.CompletedTask;
var fullName = _receiverName.Trim();
var email = _receiverEmail.Trim();
if (string.IsNullOrWhiteSpace(fullName))
{
_validationMessage = L("SenderEditor_Validation_FullNameRequired", "Bitte geben Sie einen Vor- und Nachnamen ein.");
return Task.CompletedTask;
}
if (string.IsNullOrWhiteSpace(email))
{
_validationMessage = L("SenderEditor_Validation_EmailRequired", "Bitte geben Sie eine E-Mail-Adresse ein.");
return Task.CompletedTask;
}
if (!EmailValidator.IsValid(email))
{
_validationMessage = L("SenderEditor_Validation_EmailInvalid", "Bitte geben Sie eine gültige E-Mail-Adresse ein.");
return Task.CompletedTask;
}
if (_receivers.Any(x => string.Equals(x.EmailAddress, email, StringComparison.OrdinalIgnoreCase)))
{
_validationMessage = L("SenderEditor_Validation_EmailDuplicate", "Diese E-Mail-Adresse wurde bereits hinzugefügt.");
return Task.CompletedTask;
}
_receivers.Add(new CreateEnvelopeWizardReceiver(fullName, email));
_receiverName = string.Empty;
_receiverEmail = string.Empty;
_validationMessage = null;
return Task.CompletedTask;
}
private Task RemoveReceiver(CreateEnvelopeWizardReceiver receiver)
{
if (IsActionLocked)
return Task.CompletedTask;
_receivers.Remove(receiver);
_validationMessage = null;
return Task.CompletedTask;
}
private async Task OnDocumentSelectedAsync(InputFileChangeEventArgs args)
{
if (IsActionLocked)
return;
_validationMessage = null;
var file = args.File;
if (file is null)
return;
if (!file.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
{
_validationMessage = L("SenderEditor_SelectPdf", "Bitte wählen Sie eine PDF-Datei aus.");
return;
}
try
{
_isDocumentUploading = true;
const long maxBytes = 50 * 1024 * 1024;
await using var stream = file.OpenReadStream(maxBytes);
using var ms = new System.IO.MemoryStream();
await stream.CopyToAsync(ms);
_documentBytes = ms.ToArray();
_documentFileName = file.Name;
_documentSizeText = FormatSize(_documentBytes.Length);
}
finally
{
_isDocumentUploading = false;
}
}
private Task MoveBack()
{
if (IsActionLocked)
return Task.CompletedTask;
_validationMessage = null;
if (!IsFirstStep)
_currentStep--;
return Task.CompletedTask;
}
private async Task MoveNextAsync()
{
if (IsActionLocked)
return;
_isStepActionRunning = true;
try
{
if (!ValidateCurrentStep())
return;
_validationMessage = null;
if (!IsLastStep)
{
_currentStep++;
if (_currentStep == 2)
await EnsureRecentSuggestionsLoadedAsync();
}
}
finally
{
_isStepActionRunning = false;
}
await Task.CompletedTask;
}
private async Task SkipOptionalStep()
{
if (IsActionLocked)
return;
if (!CanSkipCurrentStep)
return;
_message = string.Empty;
_validationMessage = null;
await MoveNextAsync();
}
private async Task CloseAsync()
{
if (IsActionLocked)
return;
await CloseCoreAsync();
}
private async Task CloseCoreAsync()
{
ResetWizardState();
await OnPopupVisibleChangedAsync(false);
}
private async Task FinishAsync()
{
if (IsActionLocked)
return;
_isFinishing = true;
try
{
if (!ValidateCurrentStep())
return;
var result = new CreateEnvelopeWizardResult(
Title: _title.Trim(),
Message: _message.Trim(),
EnvelopeTypeId: _selectedEnvelopeTypeId,
Language: string.IsNullOrWhiteSpace(_language) ? "de-DE" : _language,
Receivers: _receivers.ToList(),
DocumentBytes: _documentBytes,
DocumentFileName: _documentFileName);
await CloseCoreAsync();
if (StartEditorRequested.HasDelegate)
await StartEditorRequested.InvokeAsync(result);
}
finally
{
_isFinishing = false;
}
}
private async Task OnWizardKeyDownAsync(KeyboardEventArgs args)
{
if (!_isVisible)
return;
if (string.Equals(args.Key, "Escape", StringComparison.OrdinalIgnoreCase))
{
await CloseAsync();
return;
}
if (!string.Equals(args.Key, "Enter", StringComparison.OrdinalIgnoreCase))
return;
if (_currentStep == 1)
return;
if (_currentStep == 2 && (!string.IsNullOrWhiteSpace(_receiverName) || !string.IsNullOrWhiteSpace(_receiverEmail)))
{
await AddReceiver();
return;
}
if (IsLastStep)
{
await FinishAsync();
return;
}
await MoveNextAsync();
}
private async Task OnPopupVisibleChangedAsync(bool value)
{
_isVisible = value;
if (!value)
ResetWizardState();
if (VisibleChanged.HasDelegate)
await VisibleChanged.InvokeAsync(value);
}
private async Task EnsureRecentSuggestionsLoadedAsync()
{
if (_recentSuggestionsLoaded || _isLoadingRecentSuggestions)
return;
_isLoadingRecentSuggestions = true;
try
{
var suggestions = await ReceiverService.GetRecentReceiverSuggestionsAsync();
_recentReceiverSuggestions.Clear();
_recentReceiverSuggestions.AddRange(suggestions);
_recentSuggestionsLoaded = true;
}
catch
{
_recentReceiverSuggestions.Clear();
_recentSuggestionsLoaded = true;
}
finally
{
_isLoadingRecentSuggestions = false;
await InvokeAsync(StateHasChanged);
}
}
private void ApplyRecentSuggestion(RecentReceiverSuggestionDto suggestion)
{
if (IsActionLocked)
return;
_receiverName = suggestion.FullName;
_receiverEmail = suggestion.EmailAddress;
_validationMessage = null;
}
private bool ValidateCurrentStep()
{
if (_currentStep == 0)
{
if (string.IsNullOrWhiteSpace(_title))
{
_validationMessage = L("SenderEditor_Validation_TitleRequired", "Bitte geben Sie einen Titel ein.");
return false;
}
if (!_selectedEnvelopeTypeId.HasValue || _selectedEnvelopeTypeId.Value <= 0)
{
_validationMessage = L("SenderWizard_Validation_EnvelopeTypeRequired", "Bitte wählen Sie einen Umschlagtyp aus.");
return false;
}
return true;
}
if (_currentStep == 2)
{
if (_receivers.Count == 0)
{
_validationMessage = L("SenderEditor_Validation_ReceiverRequired", "Bitte fügen Sie mindestens einen Empfänger hinzu.");
return false;
}
return true;
}
if (_currentStep == 3)
{
if (_documentBytes is null || _documentBytes.Length == 0)
{
_validationMessage = L("SenderEditor_Validation_UploadPdf", "Bitte laden Sie zuerst ein PDF-Dokument hoch.");
return false;
}
return true;
}
return true;
}
private static string FormatSize(long bytes)
{
const double kb = 1024;
const double mb = kb * 1024;
if (bytes >= mb)
return $"{bytes / mb:0.##} MB";
if (bytes >= kb)
return $"{bytes / kb:0.##} KB";
return $"{bytes} B";
}
private void ResetWizardState()
{
_currentStep = 0;
_validationMessage = null;
_title = string.Empty;
_selectedEnvelopeTypeId = GetDefaultEnvelopeTypeId();
_message = string.Empty;
_language = "de-DE";
_receiverName = string.Empty;
_receiverEmail = string.Empty;
_receivers.Clear();
_recentReceiverSuggestions.Clear();
_isLoadingRecentSuggestions = false;
_recentSuggestionsLoaded = false;
_documentBytes = null;
_documentFileName = null;
_documentSizeText = string.Empty;
}
private int? GetDefaultEnvelopeTypeId()
{
if (EnvelopeTypes.Count == 0)
return null;
var handwrittenType = EnvelopeTypes.FirstOrDefault(x =>
!string.IsNullOrWhiteSpace(x.Title)
&& x.Title.Contains("Handschriftliche Unterschrift", StringComparison.OrdinalIgnoreCase));
return handwrittenType?.Id ?? EnvelopeTypes[0].Id;
}
private 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;
}
}