feat(sender): add create-envelope wizard with bootstrap handoff
Introduce a 4-step modal wizard on SenderPage for creating envelopes. Validate required fields, support optional skip, receiver management, PDF upload, and keyboard shortcuts. Persist wizard payload in sessionStorage via token-based bootstrap service and navigate to editor with bootstrapToken. Register bootstrap storage service in both client and server DI containers and add dedicated sender wizard styling.
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
@using EnvelopeGenerator.Application.Common.Dto
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Resources
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject IStringLocalizer<Model> ModelLocalizer
|
||||
@inject IStringLocalizer<Resource> UiLocalizer
|
||||
|
||||
<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">
|
||||
<option value="">@L("SenderEditor_SelectTemplate", "Vorlage auswählen")</option>
|
||||
@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__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.Secondary"
|
||||
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 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++;
|
||||
}
|
||||
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 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 = EnvelopeTypes.Count > 0 ? EnvelopeTypes[0].Id : null;
|
||||
_message = string.Empty;
|
||||
_language = "de-DE";
|
||||
|
||||
_receiverName = string.Empty;
|
||||
_receiverEmail = string.Empty;
|
||||
_receivers.Clear();
|
||||
|
||||
_documentBytes = null;
|
||||
_documentFileName = null;
|
||||
_documentSizeText = string.Empty;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.Sender;
|
||||
|
||||
public sealed record CreateEnvelopeWizardResult(
|
||||
string Title,
|
||||
string Message,
|
||||
int? EnvelopeTypeId,
|
||||
string Language,
|
||||
IReadOnlyList<CreateEnvelopeWizardReceiver> Receivers,
|
||||
byte[]? DocumentBytes,
|
||||
string? DocumentFileName);
|
||||
|
||||
public sealed record CreateEnvelopeWizardReceiver(string FullName, string EmailAddress);
|
||||
@@ -9,14 +9,17 @@
|
||||
@using EnvelopeGenerator.Application.Common.Dto.History
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Resources
|
||||
@using EnvelopeGenerator.Server.Client.Components.Sender
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.DocumentService DocumentService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.PdfRenderService PdfRenderService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService EnvelopeTypeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService
|
||||
@inject ILogger<SenderPage> Logger
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@@ -414,6 +417,11 @@
|
||||
|
||||
</div>
|
||||
|
||||
<CreateEnvelopeWizardPopup Visible="@_createWizardVisible"
|
||||
VisibleChanged="OnCreateWizardVisibleChangedAsync"
|
||||
EnvelopeTypes="@_envelopeTypes"
|
||||
StartEditorRequested="StartEditorFromWizardAsync" />
|
||||
|
||||
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
|
||||
<DxPopup @bind-Visible="@_previewVisible"
|
||||
HeaderText="@($"{L("Preview", "Vorschau")} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
|
||||
@@ -567,6 +575,9 @@
|
||||
private string _deleteReason = string.Empty;
|
||||
private string? _deleteErrorMessage;
|
||||
|
||||
private bool _createWizardVisible = false;
|
||||
private IReadOnlyList<EnvelopeTypeDto> _envelopeTypes = [];
|
||||
|
||||
// Stable keys used to store/restore each tab's layout in UserPreferences.GridLayouts
|
||||
private const string GridKeyActive = "sender.active-envelopes";
|
||||
private const string GridKeyCompleted = "sender.completed-envelopes";
|
||||
@@ -596,6 +607,16 @@
|
||||
_appLogo = logos.App;
|
||||
_companyLogo = logos.Company;
|
||||
|
||||
try
|
||||
{
|
||||
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to load envelope types for create wizard.");
|
||||
_envelopeTypes = [];
|
||||
}
|
||||
|
||||
await LoadEnvelopesAsync();
|
||||
}
|
||||
|
||||
@@ -693,7 +714,30 @@
|
||||
|
||||
void CreateEnvelope()
|
||||
{
|
||||
Navigation.NavigateTo("/sender/editor");
|
||||
_createWizardVisible = true;
|
||||
}
|
||||
|
||||
Task OnCreateWizardVisibleChangedAsync(bool visible)
|
||||
{
|
||||
_createWizardVisible = visible;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task StartEditorFromWizardAsync(CreateEnvelopeWizardResult result)
|
||||
{
|
||||
var payload = new CreateEnvelopeBootstrapPayload(
|
||||
Title: result.Title,
|
||||
Message: result.Message,
|
||||
EnvelopeTypeId: result.EnvelopeTypeId,
|
||||
Language: result.Language,
|
||||
Receivers: result.Receivers
|
||||
.Select(x => new CreateEnvelopeBootstrapReceiver(x.FullName, x.EmailAddress))
|
||||
.ToList(),
|
||||
DocumentBytes: result.DocumentBytes,
|
||||
DocumentFileName: result.DocumentFileName);
|
||||
|
||||
var token = await CreateEnvelopeBootstrapStorageService.StoreAsync(payload);
|
||||
Navigation.NavigateTo($"/sender/editor?bootstrapToken={token}");
|
||||
}
|
||||
|
||||
void EditEnvelope()
|
||||
|
||||
@@ -45,6 +45,7 @@ builder.Services.AddScoped<EnvelopeTypeService>();
|
||||
builder.Services.AddScoped<LocalizationService>();
|
||||
builder.Services.AddScoped<ReceiverService>();
|
||||
builder.Services.AddScoped<PdfRenderService>();
|
||||
builder.Services.AddScoped<CreateEnvelopeBootstrapStorageService>();
|
||||
|
||||
// UI Preferences & Theme
|
||||
builder.Services.AddScoped<UserPreferencesService>();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public sealed class CreateEnvelopeBootstrapStorageService(IJSRuntime jsRuntime)
|
||||
{
|
||||
private const string StorageKeyPrefix = "sender.createEnvelope.bootstrap.";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<string> StoreAsync(CreateEnvelopeBootstrapPayload payload)
|
||||
{
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var key = BuildStorageKey(token);
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
await jsRuntime.InvokeVoidAsync("sessionStorage.setItem", key, json);
|
||||
return token;
|
||||
}
|
||||
|
||||
public async Task<CreateEnvelopeBootstrapPayload?> ConsumeAsync(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
|
||||
var key = BuildStorageKey(token);
|
||||
var raw = await jsRuntime.InvokeAsync<string?>("sessionStorage.getItem", key);
|
||||
await jsRuntime.InvokeVoidAsync("sessionStorage.removeItem", key);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<CreateEnvelopeBootstrapPayload>(raw, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildStorageKey(string token) => $"{StorageKeyPrefix}{token}";
|
||||
}
|
||||
|
||||
public sealed record CreateEnvelopeBootstrapPayload(
|
||||
string Title,
|
||||
string Message,
|
||||
int? EnvelopeTypeId,
|
||||
string Language,
|
||||
IReadOnlyList<CreateEnvelopeBootstrapReceiver> Receivers,
|
||||
byte[]? DocumentBytes,
|
||||
string? DocumentFileName);
|
||||
|
||||
public sealed record CreateEnvelopeBootstrapReceiver(string FullName, string EmailAddress);
|
||||
@@ -346,6 +346,7 @@ try
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService>();
|
||||
|
||||
// Business Services (Server specific)
|
||||
builder.Services.AddScoped<DocumentService>();
|
||||
|
||||
@@ -1094,3 +1094,310 @@
|
||||
[data-sf-theme="blazing-dark"] .sender-preview-error-msg {
|
||||
color: rgba(255, 255, 255, 0.50);
|
||||
}
|
||||
|
||||
.sender-create-wizard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sender-create-wizard-popup .dxbl-popup-content {
|
||||
max-height: min(78vh, 760px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sender-create-wizard-popup .dxbl-popup-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sender-create-wizard__progress {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__progress-track {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(126, 34, 206, 0.18);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sender-create-wizard__step {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sender-create-wizard__step-index {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border: 1px solid rgba(126, 34, 206, 0.25);
|
||||
}
|
||||
|
||||
.sender-create-wizard__step--active .sender-create-wizard__step-index {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #7e22ce 0%, #2a5298 100%);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 3px rgba(126, 34, 206, 0.15);
|
||||
}
|
||||
|
||||
.sender-create-wizard__step--done .sender-create-wizard__step-index {
|
||||
color: #ffffff;
|
||||
background: #2a5298;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.sender-create-wizard__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sender-create-wizard__description {
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sender-create-wizard__stage {
|
||||
border: 1px solid rgba(126, 34, 206, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(126, 34, 206, 0.04);
|
||||
padding: 0.85rem 0.95rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__stage p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sender-create-wizard__footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sender-create-wizard__footer .dxbl-btn {
|
||||
flex: 1 1 calc(50% - 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.sender-create-wizard__validation {
|
||||
border: 1px solid rgba(220, 38, 38, 0.24);
|
||||
background: rgba(254, 226, 226, 0.9);
|
||||
color: #991b1b;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sender-create-wizard__label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.sender-create-wizard__required {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.sender-create-wizard__optional {
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sender-create-wizard__select {
|
||||
min-height: 38px;
|
||||
border: 1px solid rgba(126, 34, 206, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
background: #ffffff;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid rgba(126, 34, 206, 0.18);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: rgba(248, 250, 252, 0.95);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-name {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-email {
|
||||
font-size: 0.79rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-remove {
|
||||
border: 1px solid rgba(220, 38, 38, 0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
color: #b91c1c;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
padding: 0.25rem 0.55rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__upload-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(126, 34, 206, 0.28);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
background: rgba(126, 34, 206, 0.08);
|
||||
color: #4c1d95;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sender-create-wizard__upload-btn--disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sender-create-wizard__hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sender-create-wizard__document-meta {
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 8px;
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__document-name {
|
||||
color: #064e3b;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sender-create-wizard__document-size {
|
||||
color: #065f46;
|
||||
font-size: 0.77rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__empty-list {
|
||||
border: 1px dashed rgba(100, 116, 139, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
color: #475569;
|
||||
font-size: 0.83rem;
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.sender-create-wizard-popup .dxbl-popup {
|
||||
width: 96vw !important;
|
||||
max-width: 96vw !important;
|
||||
}
|
||||
|
||||
.sender-create-wizard__progress {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__title {
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__description {
|
||||
font-size: 0.81rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-remove {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sender-create-wizard__upload-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user