Compare commits
20 Commits
master
...
feat/blazo
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f7519941a | |||
| 66e58b4a1a | |||
| de9d8c6f37 | |||
| 47f48ba12a | |||
| 60241c9c6d | |||
| 2a939b4cad | |||
| e89d471c6f | |||
| ece676fbcf | |||
| fac4ec28a4 | |||
| a3b11ed63f | |||
| 5627483589 | |||
| bf23e88cf8 | |||
| 9435561e78 | |||
| db1e91a3f6 | |||
| 0c664c0995 | |||
| 5db2f6f1e3 | |||
| a535e20b0e | |||
| bb35a68b1f | |||
| 715add7f51 | |||
| 621e22701a |
31
AGENTS.md
31
AGENTS.md
@@ -63,6 +63,36 @@ This section explains every markdown file in the repo: what it contains, why it
|
||||
|
||||
---
|
||||
|
||||
## Markdown Audit Snapshot (2026-10-05)
|
||||
|
||||
The following table summarizes repository markdown files by practical status: completed, partially complete, or removable candidates.
|
||||
|
||||
| File | Type | Status | Missing / Gap | Removal Recommendation |
|
||||
|---|---|---|---|---|
|
||||
| `AGENTS.md` | Agent onboarding | Partially complete | Route/migration statements can drift from real code over time. | Keep |
|
||||
| `COPILOT_CONTEXT.md` | Architecture reference | Partially complete | Some route/render details may be out of sync with other docs; needs regular reconciliation. | Keep |
|
||||
| `FORM_APPLICATION_CONTEXT.md` | Legacy-to-modern feature spec | Partially complete | Large sections still marked not implemented / needs implementation. | Keep |
|
||||
| `RECEIVER_PDF_VIEWER_CONTEXT.md` | Receiver PDF viewer deep-dive | Partially complete | Long migration plan needs periodic sync with active implementation. | Keep |
|
||||
| `SENDER_SAVE_EDIT_MIGRATION_PLAN.md` | Sender workflow implementation plan | Partially complete | No explicit implementation progress markers per phase. | Keep (until migration closes) |
|
||||
| `SENDER_PDF_TOOLBAR_PLAN_EN.md` | Sender PDF toolbar plan | Incomplete | Explicit pending item remains (`Toolbar visual polish`). | Keep (until done) |
|
||||
| `SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md` | Sender editor WASM migration plan | Incomplete | Plan exists but completion/progress tracking is minimal. | Keep (until done) |
|
||||
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md` | Deployment guide | Complete (operational) | No critical gap identified. | Keep |
|
||||
| `envelope-generator-ui/README.md` | Angular scaffold README | Complete but generic | Template text only; low project-specific value. | Removable or replace with project-specific summary |
|
||||
| `EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Third-party vendor README | Complete (vendor) | Not project workflow documentation. | Keep with vendor assets |
|
||||
| `EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Third-party license | Complete (legal) | No gap. | Keep |
|
||||
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/open-iconic/README.md` | Third-party vendor README | Complete (vendor) | Not project-specific guidance. | Keep with vendor assets |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/README.md` | Legacy PSPDFKit note | Outdated | Mentions deprecated PSPDFKit-based flow; may mislead if reused. | Removable/archive |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Duplicate vendor README | Redundant | Duplicate copy under `ignore/`. | Removable/archive |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/bootstrap-icons/README.md` | Vendor README in archive area | Secondary | Archive-only utility, not active docs. | Removable/archive |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Duplicate license in archive area | Redundant | Duplicate copy under `ignore/`. | Removable/archive (if legal policy allows) |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/README.md` | Duplicate Angular scaffold README | Redundant | Generic template + duplicate in archive path. | Removable/archive |
|
||||
|
||||
### Documentation Audit TODO
|
||||
|
||||
- [ ] Validate this markdown audit table against the current codebase and update statuses (`complete`, `partial`, `incomplete`, `removable`) when implementation changes land.
|
||||
|
||||
---
|
||||
|
||||
## Active Architecture (Post-Migration)
|
||||
|
||||
**Frontend:** Blazor Auto (Server+WASM hybrid)
|
||||
@@ -251,6 +281,7 @@ Manual testing workflow:
|
||||
- P1 security workstream: remove dynamic SQL string composition/interpolation from active save/create/update paths (`string.Format`, `$"...{input}..."`, `ToSqlParam`) and migrate to parameterized queries only; treat this as a potential SQL injection vulnerability (stability + security risk), execute repo-wide audit, and add CI guardrails to block reintroduction.
|
||||
- Alternative UX design (Server component): in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`, enforce deterministic row double-click behavior as exactly one action (preview OR edit), and keep row action buttons/tooltips aligned with the same rule to eliminate ambiguous navigation.
|
||||
- Implement SPA-style language switching so the UI culture updates immediately without a full page reload.
|
||||
- Implement sender-scoped caching for "recent receiver suggestions" used during envelope creation (wizard/editor flows): cache key should include sender identity, support short TTL + invalidation hooks after successful envelope receiver writes, and prevent cross-sender suggestion leakage.
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
|
||||
public class RecentReceiverSuggestionDto
|
||||
{
|
||||
public required string FullName { get; init; }
|
||||
|
||||
public required string EmailAddress { get; init; }
|
||||
|
||||
public required DateTime LastUsedAt { get; init; }
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFrameworks>net7.0;net8.0;net9.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -16,5 +16,9 @@ public class MappingProfile : Profile
|
||||
public MappingProfile()
|
||||
{
|
||||
CreateMap<Receiver, ReceiverDto>();
|
||||
|
||||
CreateMap<EnvelopeReceiver, RecentReceiverSuggestionDto>()
|
||||
.ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name))
|
||||
.ForMember(dest => dest.EmailAddress, opt => opt.MapFrom(src => src.Receiver.EmailAddress));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Reads recent receiver suggestions for envelope creation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sender scope is assigned via chain method to avoid exposing username mutation from API query binding.
|
||||
/// </remarks>
|
||||
public record ReadRecentReceiverSuggestionsQuery : IRequest<IReadOnlyList<RecentReceiverSuggestionDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// Sender username used for scoping suggestion results.
|
||||
/// </summary>
|
||||
internal string? Username { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Desired number of results.
|
||||
/// </summary>
|
||||
public int? Take { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new query instance scoped to the specified sender username.
|
||||
/// </summary>
|
||||
/// <param name="username">Sender username from authenticated claims.</param>
|
||||
/// <returns>Scoped query instance.</returns>
|
||||
public ReadRecentReceiverSuggestionsQuery ForSender(string username)
|
||||
{
|
||||
return this with { Username = username };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ReadRecentReceiverSuggestionsQuery"/>.
|
||||
/// </summary>
|
||||
public class ReadRecentReceiverSuggestionsQueryHandler(IRepository<EnvelopeReceiver> repository)
|
||||
: IRequestHandler<ReadRecentReceiverSuggestionsQuery, IReadOnlyList<RecentReceiverSuggestionDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves distinct recent receiver suggestions grouped by receiver email.
|
||||
/// </summary>
|
||||
/// <param name="request">Suggestion query.</param>
|
||||
/// <param name="cancel">Cancellation token.</param>
|
||||
/// <returns>Ordered list of recent receiver suggestions.</returns>
|
||||
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> Handle(ReadRecentReceiverSuggestionsQuery request, CancellationToken cancel)
|
||||
{
|
||||
var query = repository.Query
|
||||
.AsNoTracking()
|
||||
.Where(er => er.Receiver != null);
|
||||
|
||||
if (request.Username is not null)
|
||||
query = query.Where(er => er.Envelope != null && er.Envelope.User.Username == request.Username);
|
||||
|
||||
// 1) For each email, compute the latest usage timestamp.
|
||||
var latestPerEmail = query
|
||||
.GroupBy(er => er.Receiver!.EmailAddress)
|
||||
.Select(g => new
|
||||
{
|
||||
EmailAddress = g.Key,
|
||||
LastUsedAt = g.Max(er => er.AddedWhen),
|
||||
});
|
||||
|
||||
// 2) Join back to rows that match the latest timestamp per email.
|
||||
var latestRows =
|
||||
from er in query
|
||||
join latest in latestPerEmail
|
||||
on new { EmailAddress = er.Receiver!.EmailAddress, LastUsedAt = er.AddedWhen }
|
||||
equals new { latest.EmailAddress, latest.LastUsedAt }
|
||||
select new
|
||||
{
|
||||
EmailAddress = er.Receiver!.EmailAddress,
|
||||
FullName = er.Name,
|
||||
LastUsedAt = er.AddedWhen,
|
||||
};
|
||||
|
||||
// 3) Tie-break: if multiple rows share the same latest timestamp for an email,
|
||||
// pick a deterministic name via Max(FullName), then order by most recent usage.
|
||||
var suggestions = latestRows
|
||||
.GroupBy(x => new { x.EmailAddress, x.LastUsedAt })
|
||||
.Select(g => new RecentReceiverSuggestionDto
|
||||
{
|
||||
EmailAddress = g.Key.EmailAddress,
|
||||
FullName = g.Max(x => x.FullName) ?? string.Empty,
|
||||
LastUsedAt = g.Key.LastUsedAt,
|
||||
})
|
||||
.OrderByDescending(x => x.LastUsedAt)
|
||||
.ThenBy(x => x.EmailAddress);
|
||||
|
||||
return request.Take is int take
|
||||
? await suggestions.Take(take).ToListAsync(cancel)
|
||||
: await suggestions.ToListAsync(cancel);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,9 @@
|
||||
<data name="Back" xml:space="preserve">
|
||||
<value>Zurück</value>
|
||||
</data>
|
||||
<data name="Next" xml:space="preserve">
|
||||
<value>Weiter</value>
|
||||
</data>
|
||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||
<value>Klicken Sie auf den</value>
|
||||
</data>
|
||||
@@ -564,6 +567,12 @@
|
||||
<data name="AddReceiver" xml:space="preserve">
|
||||
<value>Empfänger hinzufügen</value>
|
||||
</data>
|
||||
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
|
||||
<value>Zuletzt verwendete Empfänger</value>
|
||||
</data>
|
||||
<data name="Skip" xml:space="preserve">
|
||||
<value>Überspringen</value>
|
||||
</data>
|
||||
<data name="DeleteReceiver" xml:space="preserve">
|
||||
<value>Empfänger löschen</value>
|
||||
</data>
|
||||
|
||||
@@ -123,6 +123,9 @@
|
||||
<data name="Back" xml:space="preserve">
|
||||
<value>Back</value>
|
||||
</data>
|
||||
<data name="Next" xml:space="preserve">
|
||||
<value>Next</value>
|
||||
</data>
|
||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||
<value>Click the</value>
|
||||
</data>
|
||||
@@ -564,6 +567,12 @@
|
||||
<data name="AddReceiver" xml:space="preserve">
|
||||
<value>Add Receiver</value>
|
||||
</data>
|
||||
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
|
||||
<value>Recently used recipients</value>
|
||||
</data>
|
||||
<data name="Skip" xml:space="preserve">
|
||||
<value>Skip</value>
|
||||
</data>
|
||||
<data name="DeleteReceiver" xml:space="preserve">
|
||||
<value>Delete Receiver</value>
|
||||
</data>
|
||||
|
||||
@@ -123,6 +123,9 @@
|
||||
<data name="Back" xml:space="preserve">
|
||||
<value>Retour</value>
|
||||
</data>
|
||||
<data name="Next" xml:space="preserve">
|
||||
<value>Suivant</value>
|
||||
</data>
|
||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||
<value>Cliquez sur</value>
|
||||
</data>
|
||||
@@ -564,6 +567,12 @@
|
||||
<data name="AddReceiver" xml:space="preserve">
|
||||
<value>Ajouter un destinataire</value>
|
||||
</data>
|
||||
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
|
||||
<value>Destinataires recents</value>
|
||||
</data>
|
||||
<data name="Skip" xml:space="preserve">
|
||||
<value>Passer</value>
|
||||
</data>
|
||||
<data name="DeleteReceiver" xml:space="preserve">
|
||||
<value>Supprimer le destinataire</value>
|
||||
</data>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.Sender;
|
||||
|
||||
public sealed record CreateEnvelopeWizardResult(
|
||||
string Title,
|
||||
string Message,
|
||||
int? EnvelopeTypeId,
|
||||
string Language,
|
||||
bool UseAccessCode,
|
||||
bool TFAEnabled,
|
||||
int CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
int FirstReminderDays,
|
||||
int ReminderIntervalDays,
|
||||
int ExpiresWhenDays,
|
||||
int ExpiresWarningWhenDays,
|
||||
IReadOnlyList<CreateEnvelopeWizardReceiver> Receivers,
|
||||
byte[]? DocumentBytes,
|
||||
string? DocumentFileName);
|
||||
|
||||
public sealed record CreateEnvelopeWizardReceiver(string FullName, string EmailAddress);
|
||||
@@ -0,0 +1,104 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
<div class="sender-pdf-dropzone @DropZoneClass">
|
||||
<div class="sender-pdf-dropzone__content">
|
||||
<div class="sender-pdf-dropzone__title">@CurrentActionText</div>
|
||||
@if (!string.IsNullOrWhiteSpace(DragDropHintText))
|
||||
{
|
||||
<div class="sender-pdf-dropzone__hint">@DragDropHintText</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<InputFile OnChange="OnFileSelectedAsync"
|
||||
accept="@Accept"
|
||||
disabled="@Disabled"
|
||||
@ondragenter="OnDragEnter"
|
||||
@ondragleave="OnDragLeave"
|
||||
@ondragover="OnDragOver"
|
||||
@ondrop="OnDrop"
|
||||
class="sender-pdf-dropzone__input" />
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool HasDocument { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Accept { get; set; } = ".pdf";
|
||||
|
||||
[Parameter]
|
||||
public string UploadText { get; set; } = "PDF hochladen";
|
||||
|
||||
[Parameter]
|
||||
public string ReplaceText { get; set; } = "PDF ersetzen";
|
||||
|
||||
[Parameter]
|
||||
public string DragDropHintText { get; set; } = "Datei hierhin ziehen oder klicken";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<InputFileChangeEventArgs> FileSelected { get; set; }
|
||||
|
||||
private int _dragDepth;
|
||||
private bool _isDragActive;
|
||||
|
||||
private string CurrentActionText => HasDocument ? ReplaceText : UploadText;
|
||||
|
||||
private string DropZoneClass
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Disabled)
|
||||
return "sender-pdf-dropzone--disabled";
|
||||
|
||||
return _isDragActive ? "sender-pdf-dropzone--drag-active" : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDragEnter(DragEventArgs _)
|
||||
{
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
_dragDepth++;
|
||||
_isDragActive = true;
|
||||
}
|
||||
|
||||
private void OnDragLeave(DragEventArgs _)
|
||||
{
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
_dragDepth = Math.Max(0, _dragDepth - 1);
|
||||
if (_dragDepth == 0)
|
||||
_isDragActive = false;
|
||||
}
|
||||
|
||||
private void OnDragOver(DragEventArgs _)
|
||||
{
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
_isDragActive = true;
|
||||
}
|
||||
|
||||
private void OnDrop(DragEventArgs _)
|
||||
{
|
||||
_dragDepth = 0;
|
||||
_isDragActive = false;
|
||||
}
|
||||
|
||||
private async Task OnFileSelectedAsync(InputFileChangeEventArgs args)
|
||||
{
|
||||
_dragDepth = 0;
|
||||
_isDragActive = false;
|
||||
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
if (FileSelected.HasDelegate)
|
||||
await FileSelected.InvokeAsync(args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
|
||||
|
||||
public readonly record struct ReceiverFilterCriteria
|
||||
{
|
||||
public readonly string FullName;
|
||||
public readonly string EmailAddress;
|
||||
|
||||
public ReceiverFilterCriteria(string? fullName = "", string? emailAddress = "")
|
||||
{
|
||||
FullName = Normalize(fullName);
|
||||
EmailAddress = Normalize(emailAddress);
|
||||
}
|
||||
|
||||
public readonly bool Matches(ReceiverFilterCriteria criteria)
|
||||
=> FullName.Contains(criteria.FullName, StringComparison.Ordinal)
|
||||
&& EmailAddress.Contains(criteria.EmailAddress, StringComparison.Ordinal);
|
||||
|
||||
private static string Normalize(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return string.Empty;
|
||||
|
||||
return string.Concat(text.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
|
||||
|
||||
public class ReceiverSuggestionFilterHandler(IEnumerable<RecentReceiverSuggestionDto> suggestions)
|
||||
{
|
||||
private readonly IReadOnlyList<Entry> _entries = suggestions
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.FullName) && !string.IsNullOrWhiteSpace(x.EmailAddress))
|
||||
.Select(x => new Entry(new ReceiverFilterCriteria(x.FullName, x.EmailAddress), x))
|
||||
.ToList();
|
||||
|
||||
private readonly record struct Entry(ReceiverFilterCriteria Criteria, RecentReceiverSuggestionDto Suggestion);
|
||||
|
||||
public IEnumerable<RecentReceiverSuggestionDto> Filter(ReceiverFilterCriteria criteria)
|
||||
{
|
||||
return _entries
|
||||
.Where(x => x.Criteria.Matches(criteria))
|
||||
.Select(x => x.Suggestion);
|
||||
}
|
||||
|
||||
public IEnumerable<RecentReceiverSuggestionDto> Filter(string fullName, string emailAddress)
|
||||
{
|
||||
var criteria = new ReceiverFilterCriteria(fullName, emailAddress);
|
||||
return Filter(criteria);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
@inject ReceiverService ReceiverService
|
||||
@inject EnvelopeService EnvelopeService
|
||||
@inject EnvelopeTypeService EnvelopeTypeService
|
||||
@inject CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService
|
||||
@inject AuthService AuthService
|
||||
@inject IStringLocalizer<Resource> UiLocalizer
|
||||
@inject IStringLocalizer<Model> ModelLocalizer
|
||||
@@ -30,6 +31,11 @@
|
||||
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
||||
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
|
||||
|
||||
<DxLoadingPanel Visible="@IsBootstrapLoading"
|
||||
IsContentBlocked="true"
|
||||
ApplyBackgroundShading="true"
|
||||
IndicatorAreaVisible="true"
|
||||
Text='@L("SenderEditor_BootstrapLoadingHint", "Der Umschlag wird vorbereitet und die Daten werden uebernommen.")'>
|
||||
<div class="envelope-viewer-layout">
|
||||
|
||||
@* ── Action Bar ── *@
|
||||
@@ -425,6 +431,7 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</DxLoadingPanel>
|
||||
|
||||
<DxPopup @bind-Visible="_receiverPopupVisible"
|
||||
HeaderText='@L("AddReceiver", "Empfänger hinzufügen")'
|
||||
@@ -589,6 +596,9 @@
|
||||
[SupplyParameterFromQuery(Name = "envelopeId")]
|
||||
public int? EnvelopeId { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "bootstrapToken")]
|
||||
public string? BootstrapToken { get; set; }
|
||||
|
||||
// ── Constants ──
|
||||
// Signature field size in PDF points (fixed): 1.77" × 1.96"
|
||||
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
||||
@@ -609,6 +619,8 @@
|
||||
int? _loadedEnvelopeId;
|
||||
int? _editingEnvelopeId;
|
||||
bool _isLoadingEnvelopeForEdit;
|
||||
bool _bootstrapApplied;
|
||||
string? _pendingBootstrapToken;
|
||||
|
||||
List<SignatureFieldDraft> _signatureFields = [];
|
||||
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
||||
@@ -629,6 +641,7 @@
|
||||
string _saveSuccessTitle = "Vorgang erfolgreich abgeschlossen.";
|
||||
string _saveSuccessBody = string.Empty;
|
||||
bool _saveNavigateToDashboard = false;
|
||||
bool _saveDraftAfterBootstrap;
|
||||
|
||||
// ── Envelope metadata ──
|
||||
string _envelopeTitle = string.Empty;
|
||||
@@ -663,6 +676,7 @@
|
||||
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
|
||||
|
||||
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
|
||||
bool IsBootstrapLoading => !IsEditMode && !_bootstrapApplied;
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
@@ -703,19 +717,115 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsEditMode)
|
||||
if (IsEditMode || _bootstrapApplied)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BootstrapToken))
|
||||
{
|
||||
NavigationManager.NavigateTo("/sender", replace: true);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingBootstrapToken = BootstrapToken;
|
||||
}
|
||||
|
||||
protected override Task OnAfterRenderAsync(bool firstRender)
|
||||
async Task TryApplyBootstrapAsync(string token)
|
||||
{
|
||||
var payload = await CreateEnvelopeBootstrapStorageService.ConsumeAsync(token);
|
||||
_bootstrapApplied = true;
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
NavigationManager.NavigateTo("/sender", replace: true);
|
||||
return;
|
||||
}
|
||||
|
||||
_envelopeTitle = payload.Title ?? string.Empty;
|
||||
_envelopeMessage = payload.Message ?? string.Empty;
|
||||
_selectedEnvelopeTypeId = payload.EnvelopeTypeId;
|
||||
_language = string.IsNullOrWhiteSpace(payload.Language) ? "de-DE" : payload.Language;
|
||||
_useAccessCode = payload.UseAccessCode;
|
||||
_tfaEnabled = payload.TFAEnabled;
|
||||
_certificationType = payload.CertificationType;
|
||||
_finalEmailToCreator = payload.FinalEmailToCreator;
|
||||
_finalEmailToReceivers = payload.FinalEmailToReceivers;
|
||||
_sendReminderEmails = payload.SendReminderEmails;
|
||||
_firstReminderDays = payload.FirstReminderDays;
|
||||
_reminderIntervalDays = payload.ReminderIntervalDays;
|
||||
_expiresWhenDays = payload.ExpiresWhenDays;
|
||||
_expiresWarningWhenDays = payload.ExpiresWarningWhenDays;
|
||||
NormalizeSettings();
|
||||
|
||||
_receivers = payload.Receivers
|
||||
.Select((receiver, index) => new ReceiverDraft(
|
||||
Guid.NewGuid(),
|
||||
receiver.FullName,
|
||||
receiver.EmailAddress,
|
||||
string.Empty,
|
||||
ReceiverPalette[index % ReceiverPalette.Length]))
|
||||
.ToList();
|
||||
|
||||
_originalPdfBytes = payload.DocumentBytes;
|
||||
_fileName = string.IsNullOrWhiteSpace(payload.DocumentFileName) ? "document.pdf" : payload.DocumentFileName;
|
||||
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
|
||||
|
||||
_signatureFields.Clear();
|
||||
_pendingReceiverForPlacement = null;
|
||||
|
||||
_saveDraftAfterBootstrap = true;
|
||||
|
||||
if (_pdfLoaded && _originalPdfBytes is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_pagePreviews = (await PdfRenderService.RenderPagesAsync(_originalPdfBytes))
|
||||
.OrderBy(p => p.PageNumber)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to render bootstrap PDF previews.");
|
||||
_pagePreviews = [];
|
||||
}
|
||||
|
||||
_pageCount = _pagePreviews.Count > 0
|
||||
? _pagePreviews.Count
|
||||
: EstimatePdfPageCount(_originalPdfBytes) ?? 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
_pagePreviews = [];
|
||||
_pageCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_isPageReady = true;
|
||||
StateHasChanged();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsBrowser())
|
||||
return;
|
||||
|
||||
if (!IsEditMode && !_bootstrapApplied && !string.IsNullOrWhiteSpace(_pendingBootstrapToken))
|
||||
{
|
||||
var token = _pendingBootstrapToken;
|
||||
_pendingBootstrapToken = null;
|
||||
await TryApplyBootstrapAsync(token);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_saveDraftAfterBootstrap && !_isSaving)
|
||||
{
|
||||
_saveDraftAfterBootstrap = false;
|
||||
await SaveDraftAndStayOnEditorAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task LoadEnvelopeForEditAsync(int envelopeId)
|
||||
@@ -1108,12 +1218,17 @@
|
||||
}
|
||||
|
||||
async Task SaveDraftAsync()
|
||||
{
|
||||
await SaveDraftCoreAsync(navigateToDashboard: true);
|
||||
}
|
||||
|
||||
async Task<int?> SaveDraftCoreAsync(bool navigateToDashboard)
|
||||
{
|
||||
if (!ValidateSettings(out var settingsError))
|
||||
{
|
||||
_saveErrorMessage = settingsError;
|
||||
_savePopupVisible = true;
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
_isSaving = true;
|
||||
@@ -1134,25 +1249,37 @@
|
||||
_loadedEnvelopeId = result.Id;
|
||||
_editingEnvelopeId = result.Id;
|
||||
|
||||
if (previousEnvelopeId != result.Id)
|
||||
if (navigateToDashboard)
|
||||
{
|
||||
NavigationManager.NavigateTo("/sender", forceLoad: false);
|
||||
}
|
||||
else if (previousEnvelopeId != result.Id)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/sender/editor?envelopeId={result.Id}", forceLoad: false);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async Task SaveDraftAndStayOnEditorAsync()
|
||||
{
|
||||
Logger.LogInformation("[SenderEditor] Trigger bootstrap draft save.");
|
||||
await SaveDraftCoreAsync(navigateToDashboard: false);
|
||||
}
|
||||
|
||||
// ── Send envelope ──
|
||||
async Task SendAsync()
|
||||
{
|
||||
|
||||
@@ -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,40 @@
|
||||
|
||||
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,
|
||||
UseAccessCode: result.UseAccessCode,
|
||||
TFAEnabled: result.TFAEnabled,
|
||||
CertificationType: result.CertificationType,
|
||||
FinalEmailToCreator: result.FinalEmailToCreator,
|
||||
FinalEmailToReceivers: result.FinalEmailToReceivers,
|
||||
SendReminderEmails: result.SendReminderEmails,
|
||||
FirstReminderDays: result.FirstReminderDays,
|
||||
ReminderIntervalDays: result.ReminderIntervalDays,
|
||||
ExpiresWhenDays: result.ExpiresWhenDays,
|
||||
ExpiresWarningWhenDays: result.ExpiresWarningWhenDays,
|
||||
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,64 @@
|
||||
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,
|
||||
bool UseAccessCode,
|
||||
bool TFAEnabled,
|
||||
int CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
int FirstReminderDays,
|
||||
int ReminderIntervalDays,
|
||||
int ExpiresWhenDays,
|
||||
int ExpiresWarningWhenDays,
|
||||
IReadOnlyList<CreateEnvelopeBootstrapReceiver> Receivers,
|
||||
byte[]? DocumentBytes,
|
||||
string? DocumentFileName);
|
||||
|
||||
public sealed record CreateEnvelopeBootstrapReceiver(string FullName, string EmailAddress);
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
@@ -26,4 +27,21 @@ public class ReceiverService(IHttpClientFactory httpClientFactory)
|
||||
var emails = await response.Content.ReadFromJsonAsync<List<string>>(JsonOptions, cancel);
|
||||
return emails ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches recent receiver suggestions for the authenticated sender.
|
||||
/// Calls GET /api/EnvelopeReceiver/recent-suggestions?take={take}.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> GetRecentReceiverSuggestionsAsync(int take = 20, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var url = $"/api/EnvelopeReceiver/recent-suggestions?take={take}";
|
||||
var response = await http.GetAsync(url, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var suggestions = await response.Content.ReadFromJsonAsync<List<RecentReceiverSuggestionDto>>(JsonOptions, cancel);
|
||||
return suggestions ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,20 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
return name is null ? NotFound() : Ok(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns recent receiver suggestions for the authenticated sender.
|
||||
/// </summary>
|
||||
/// <param name="query">Suggestion query options (take 3..5 is applied in handler).</param>
|
||||
/// <param name="cancel">Cancellation token.</param>
|
||||
/// <returns>List of recent distinct receiver suggestions.</returns>
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
[HttpGet("recent-suggestions")]
|
||||
public async Task<IActionResult> GetRecentSuggestions([FromQuery] ReadRecentReceiverSuggestionsQuery query, CancellationToken cancel)
|
||||
{
|
||||
var result = await _mediator.Send(query.ForSender(User.GetUsername()), cancel);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Datenübertragungsobjekt mit Informationen zu Umschlägen, Empfängern und Unterschriften.
|
||||
/// </summary>
|
||||
@@ -278,4 +292,4 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,557 @@
|
||||
[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__settings-grid {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__settings-group {
|
||||
border: 1px solid rgba(148, 163, 184, 0.34);
|
||||
border-radius: 10px;
|
||||
background: rgba(248, 250, 252, 0.86);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__settings-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
color: #334155;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__settings-toggles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 1.1rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__settings-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.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__add-receiver-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 36px;
|
||||
min-width: 220px;
|
||||
font-weight: 700;
|
||||
font-size: 0.84rem;
|
||||
border: 1px solid rgba(14, 116, 144, 0.28);
|
||||
border-radius: 8px;
|
||||
background: rgba(236, 254, 255, 0.95);
|
||||
color: #0f766e;
|
||||
box-shadow: 0 6px 14px rgba(14, 116, 144, 0.15);
|
||||
padding: 0.45rem 0.9rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__add-receiver-btn:hover:not(:disabled) {
|
||||
background: rgba(207, 250, 254, 1);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.sender-create-wizard__add-receiver-btn:disabled {
|
||||
opacity: 0.58;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sender-create-wizard__suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__suggestions-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.sender-create-wizard__suggestion-list {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
gap: 0.45rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__suggestion-item {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.12rem;
|
||||
border: 1px solid rgba(14, 116, 144, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(236, 254, 255, 0.85);
|
||||
color: #0f172a;
|
||||
padding: 0.35rem 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-tagbox-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-tagbox {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.18rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(236, 254, 255, 0.95);
|
||||
border: 1px solid rgba(14, 116, 144, 0.25);
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-tag-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-tag-email {
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__receiver-tag-remove {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #0f766e;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sender-create-wizard__tagbox-empty {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sender-create-wizard__suggestion-item:disabled {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.sender-create-wizard__suggestion-name {
|
||||
font-size: 0.77rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sender-create-wizard__suggestion-email {
|
||||
font-size: 0.72rem;
|
||||
color: #155e75;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.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-pdf-dropzone {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 92px;
|
||||
border: 1px dashed rgba(100, 116, 139, 0.5);
|
||||
border-radius: 8px;
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.sender-pdf-dropzone:hover {
|
||||
border-color: rgba(126, 34, 206, 0.45);
|
||||
background: rgba(241, 245, 249, 0.95);
|
||||
}
|
||||
|
||||
.sender-pdf-dropzone--drag-active {
|
||||
border-color: rgba(16, 185, 129, 0.75);
|
||||
background: linear-gradient(180deg, rgba(16, 185, 129, 0.14), rgba(16, 185, 129, 0.08));
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.16);
|
||||
}
|
||||
|
||||
.sender-pdf-dropzone--disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sender-pdf-dropzone__content {
|
||||
text-align: center;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.sender-pdf-dropzone__title {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sender-pdf-dropzone__hint {
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.sender-pdf-dropzone__input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sender-pdf-dropzone--disabled .sender-pdf-dropzone__input {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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-preview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.sender-create-wizard__document-preview-wrap {
|
||||
border: 1px solid rgba(148, 163, 184, 0.38);
|
||||
border-radius: 10px;
|
||||
background: rgba(248, 250, 252, 0.96);
|
||||
height: 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sender-create-wizard__document-preview-wrap .sender-image-editor-shell {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sender-create-wizard__document-preview-wrap .sender-image-editor {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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-pdf-dropzone {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sender-create-wizard__upload-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user