Compare commits

..

4 Commits

Author SHA1 Message Date
96c7cd6e8a Add "Technical Debt TODOs" section to AGENTS.md
Added a new section in AGENTS.md to document technical debt tasks.
Outlined three specific areas for improvement:
- Refactor `EnvelopeSenderPage.razor` to reduce complexity.
- Move sender dashboard logic to dedicated services/extensions.
- Keep `EnvelopeSenderPage.razor` focused on UI/state orchestration.

These changes aim to improve code modularity, maintainability, and
separation of concerns.
2026-09-30 16:34:06 +02:00
4047ba7c6b Improve envelope status handling and UI enhancements
Refactored status handling to use `GetEffectiveEnvelopeStatus`
for better accuracy and historical context. Updated methods
like `IsEnvelopeSent`, `IsEnvelopeEditable`, and others to
use effective status instead of raw status.

Enhanced receiver status display by adding logic for rejected
receivers (`IsReceiverRejected`) and updated the UI to show
rejection badges with icons and labels. Improved badge
rendering to differentiate between signed, unsigned, and
rejected states.

Improved envelope categorization by ensuring rejected
envelopes are not shown in the active tab. Introduced
`GetEffectiveEnvelopeStatus` to centralize status logic.

Sanitized grid layout filters to prevent persistence of
filter-related properties, ensuring grids appear complete
when restoring layouts. Added utility methods for clearing
filter properties dynamically.

Added comments and refactored code for better readability
and maintainability.
2026-09-30 16:33:19 +02:00
897860b2bf Add rejection handling and dedicated page for envelopes
Introduced a new rejection flow in `ReceiverPage.razor` to detect
rejected envelopes and redirect users to a dedicated rejection
page (`ReceiverRejectedPage.razor`).

- Added `IsRejected` helper method to check envelope rejection
  status based on current status and history.
- Created `ReceiverRejectedPage.razor` to display rejection
  messages, sender details, and navigation options.
- Implemented localized strings for dynamic rejection messages.
- Designed a visually distinct layout for the rejection page.
- Improved user experience with clear messaging and proper
  handling of invalid or unauthorized access.
2026-09-30 14:21:09 +02:00
8933cff558 Add envelope rejection feature with popup UI
Introduced a new feature to allow users to reject envelopes with an optional reason. Added a `RejectAsync` method in `AnnotationService` to handle rejection requests. Updated `ReceiverPage.razor` to include a "Reject" button in the PDF toolbar and a rejection popup for user input.

Implemented state management for the rejection popup, including visibility, submission status, and validation messages. Updated `AnnotationController` to accept `envelopeKey` as a route parameter for the rejection endpoint. Modified `ReceiverClaimExtensions` to support multiple email claim types.

Added new CSS styles for the "Reject" button, including hover effects and dark theme support. Enhanced error handling and logging for the rejection process.
2026-09-30 14:03:56 +02:00
42 changed files with 1925 additions and 3594 deletions

View File

@@ -236,6 +236,12 @@ Manual testing workflow:
3. Test sender login at `/sender/login`
4. Test receiver flow at `/envelope/login/{envelopeKey}`
## Technical Debt TODOs
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity.
- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation).
- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
## Database
**SQL Server** (DD_ECM)

View File

@@ -1,16 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed class PdfPageImageDto
{
public int PageNumber { get; init; }
public double WidthPt { get; init; }
public double HeightPt { get; init; }
public int WidthPx { get; init; }
public int HeightPx { get; init; }
public string ImageDataUrl { get; init; } = string.Empty;
}

View File

@@ -1,6 +0,0 @@
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed class PdfRenderRequestDto
{
public byte[]? PdfBytes { get; init; }
}

View File

@@ -1,8 +0,0 @@
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Interfaces.Doc;
public interface IPdfPageImageRenderer
{
Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
}

View File

@@ -735,9 +735,7 @@
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Fehler beim Laden der Datei: {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Bitte unterzeichnen Sie das beigefügte Dokument.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Kein Dokument geladen</value></data>
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Keine Seiten zur Anzeige verfügbar.</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Noch keine Empfänger hinzugefügt.</value></data>
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Seite</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Signaturfeld platzieren</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Alle Signaturfelder entfernen</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Bitte wählen Sie eine PDF-Datei aus.</value></data>

View File

@@ -735,9 +735,7 @@
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Error loading file: {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Please sign the attached document.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>No document loaded</value></data>
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>No pages to display.</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>No receivers added yet.</value></data>
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Place signature field</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Remove all signature fields</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Please select a PDF file.</value></data>

View File

@@ -735,9 +735,7 @@
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Erreur lors du chargement du fichier : {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Veuillez signer le document ci-joint.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Aucun document chargé</value></data>
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Aucune page à afficher.</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Aucun destinataire ajouté pour le moment.</value></data>
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Placer un champ de signature</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Supprimer tous les champs de signature</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Veuillez sélectionner un fichier PDF.</value></data>

View File

@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Document.Processor" Version="25.2.3" />
</ItemGroup>
</Project>

View File

@@ -1,57 +0,0 @@
using DevExpress.Drawing;
using DevExpress.Pdf;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
namespace EnvelopeGenerator.Infrastructure.Doc;
public class PdfPageImageRenderer : IPdfPageImageRenderer
{
private const float RenderDpi = 110f;
public Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(pdfBytes);
if (pdfBytes.Length == 0)
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
using var stream = new MemoryStream(pdfBytes, writable: false);
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(stream);
var pages = processor.Document.Pages;
if (pages.Count == 0)
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
var renderOptions = PdfPageRenderingParameters.CreateWithResolution(RenderDpi);
var results = new List<PdfPageImageDto>(pages.Count);
for (var i = 0; i < pages.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var pageNumber = i + 1;
var page = pages[i];
var cropBox = page.CropBox;
using DXBitmap bitmap = processor.CreateDXBitmap(pageNumber, renderOptions);
using var imageStream = new MemoryStream();
bitmap.Save(imageStream, DXImageFormat.Png);
var dataUrl = $"data:image/png;base64,{Convert.ToBase64String(imageStream.ToArray())}";
results.Add(new PdfPageImageDto
{
PageNumber = pageNumber,
WidthPt = cropBox.Width,
HeightPt = cropBox.Height,
WidthPx = bitmap.Width,
HeightPx = bitmap.Height,
ImageDataUrl = dataUrl
});
}
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(results);
}
}

View File

@@ -1,7 +0,0 @@
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
public sealed record SenderPdfFieldMoveRequest(
int Index,
int Page,
double XPt,
double YPt);

View File

@@ -1,9 +0,0 @@
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
public sealed record SenderPdfFieldOverlay(
int Index,
int Page,
double XPt,
double YPt,
string ReceiverName,
string Color);

View File

@@ -1,459 +0,0 @@
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Application.Common.Dto
@using System.Globalization
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Resource> UiLocalizer
@inject IJSRuntime JSRuntime
<div class="sender-image-editor-shell">
@if (PageCount > 0)
{
<div class="sender-image-editor__toolbar-host">
<SenderPdfImageEditorToolbar CurrentPage="@_currentPage"
TotalPages="@PageCount"
ZoomPercent="@_zoomPercent"
MinZoomPercent="@MinZoomPercent"
MaxZoomPercent="@MaxZoomPercent"
ZoomStepPercent="@ZoomStepPercent"
GoFirstPage="OnGoFirstPageAsync"
GoPreviousPage="OnGoPreviousPageAsync"
GoNextPage="OnGoNextPageAsync"
GoLastPage="OnGoLastPageAsync"
GoToPage="OnGoToPageAsync"
ZoomOut="OnZoomOutAsync"
ZoomIn="OnZoomInAsync"
SetZoomPercent="OnSetZoomPercentAsync"
CanDownloadPdf="@CanDownloadPdf"
CanPrintPdf="@CanPrintPdf"
IsDownloading="@_isDownloading"
IsPrinting="@_isPrinting"
DownloadPdf="OnDownloadPdfAsync"
PrintPdf="OnPrintPdfAsync" />
</div>
}
<div class="pdf-editor-wrapper sender-image-editor sender-editor-pdf-viewer"
style="cursor: @(IsPlacementMode ? "crosshair" : (_isDragging ? "grabbing" : "default"));"
tabindex="0"
@onclick="HandleClickAsync"
@onwheel="OnEditorWheelAsync"
@onwheel:preventDefault="true"
@onscroll="OnEditorScrollAsync"
@onmousemove="OnEditorMouseMoveAsync"
@onmouseup="OnEditorMouseUp"
@onmouseleave="OnEditorMouseUp">
@if (PageCount <= 0)
{
<div class="sender-image-editor__empty">@L("SenderEditor_NoPagesToDisplay", "Keine Seiten zur Anzeige verfügbar.")</div>
}
else
{
@if (Pages.Count == 0 && !string.IsNullOrWhiteSpace(PdfDataUrl))
{
<div class="sender-image-editor__native-preview" style="@GetZoomStyle()">
<iframe class="sender-image-editor__native-frame"
src="@PdfDataUrl"
title="PDF Preview"></iframe>
</div>
}
<div class="sender-image-editor__pages" style="@GetZoomStyle()">
@for (var pageIndex = 0; pageIndex < PageCount; pageIndex++)
{
var pagePreview = pageIndex < Pages.Count ? Pages[pageIndex] : null;
var pageNumber = pagePreview?.PageNumber ?? (pageIndex + 1);
var widthPt = pagePreview?.WidthPt ?? 595.0;
var heightPt = pagePreview?.HeightPt ?? 842.0;
<div class="sender-image-editor__page-block"
data-editor-page="@pageNumber"
data-page-width-pt="@widthPt"
data-page-height-pt="@heightPt">
<div class="sender-image-editor__page-canvas"
data-editor-page-canvas="@pageNumber"
style="@GetPageCanvasStyle(widthPt, heightPt)">
@if (!string.IsNullOrWhiteSpace(pagePreview?.ImageDataUrl))
{
<img class="sender-image-editor__page-image"
src="@(pagePreview!.ImageDataUrl)"
alt="PDF Page @pageNumber"
draggable="false" />
}
else
{
<div class="sender-image-editor__page-placeholder">@L("SenderEditor_PageLabel", "Seite") @pageNumber</div>
}
@foreach (var field in Fields.Where(f => f.Page == pageNumber))
{
<div class="sender-image-editor__field"
style="@GetFieldStyle(field, widthPt, heightPt)"
title="@field.ReceiverName"
@onmousedown="args => OnFieldMouseDownAsync(args, field)"
@onmousedown:preventDefault="true"
@onclick:stopPropagation="true">
<span class="sender-image-editor__field-label">@field.ReceiverName</span>
<button type="button"
class="sender-image-editor__field-remove"
title='@L("Delete", "Löschen")'
draggable="false"
@onclick:stopPropagation="true"
@onclick="() => RemoveField.InvokeAsync(field.Index)">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
}
</div>
</div>
}
</div>
}
</div>
</div>
@code {
[Parameter]
public bool IsPlacementMode { get; set; }
[Parameter]
public int PageCount { get; set; }
[Parameter]
public IReadOnlyList<PdfPageImageDto> Pages { get; set; } = Array.Empty<PdfPageImageDto>();
[Parameter]
public string? PdfDataUrl { get; set; }
[Parameter]
public string? PdfFileName { get; set; }
[Parameter]
public IReadOnlyList<SenderPdfFieldOverlay> Fields { get; set; } = Array.Empty<SenderPdfFieldOverlay>();
[Parameter]
public EventCallback<MouseEventArgs> EditorClick { get; set; }
[Parameter]
public EventCallback<int> RemoveField { get; set; }
[Parameter]
public EventCallback<SenderPdfFieldMoveRequest> MoveField { get; set; }
private int? _draggedFieldIndex;
private bool _isDragging;
private double _grabOffsetXPt;
private double _grabOffsetYPt;
private int _currentPage = 1;
private int _zoomPercent = 112;
private bool _zoomOverrideInitialized;
private bool _isDownloading;
private bool _isPrinting;
private const int MinZoomPercent = 50;
private const int MaxZoomPercent = 300;
private const int ZoomStepPercent = 5;
private bool CanDownloadPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
private bool CanPrintPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
private static string GetFieldStyle(SenderPdfFieldOverlay field, double pageWidthPt, double pageHeightPt)
{
var leftPercent = (field.XPt / pageWidthPt) * 100.0;
var topPercent = (field.YPt / pageHeightPt) * 100.0;
var widthPercent = (127.44 / pageWidthPt) * 100.0;
var heightPercent = (141.12 / pageHeightPt) * 100.0;
var left = leftPercent.ToString("F4", CultureInfo.InvariantCulture);
var top = topPercent.ToString("F4", CultureInfo.InvariantCulture);
var width = widthPercent.ToString("F4", CultureInfo.InvariantCulture);
var height = heightPercent.ToString("F4", CultureInfo.InvariantCulture);
return string.Join(' ',
"position:absolute;",
$"left:{left}%;",
$"top:{top}%;",
$"width:{width}%;",
$"height:{height}%;",
"border:2px solid;",
"border-radius:8px;",
"display:flex;",
"align-items:flex-start;",
"justify-content:space-between;",
"gap:0.35rem;",
"padding:0.2rem 0.3rem;",
"overflow:hidden;",
"z-index:30;",
$"border-color:{field.Color};",
$"background-color:{field.Color}22;");
}
private string GetPageCanvasStyle(double pageWidthPt, double pageHeightPt)
{
var ratio = pageHeightPt <= 0 ? 595.0 / 842.0 : pageWidthPt / pageHeightPt;
var ratioText = ratio.ToString("F6", CultureInfo.InvariantCulture);
var cursor = _isDragging
? "grabbing"
: (IsPlacementMode ? "crosshair" : "default");
return $"aspect-ratio:{ratioText};cursor:{cursor};";
}
private string GetZoomStyle()
{
var factor = (_zoomPercent / 100.0).ToString("F4", CultureInfo.InvariantCulture);
return $"zoom:{factor};";
}
protected override void OnParametersSet()
{
if (PageCount <= 0)
{
_currentPage = 1;
return;
}
_currentPage = Math.Max(1, Math.Min(_currentPage, PageCount));
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_zoomOverrideInitialized)
return;
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.ensureZoomOverride",
"sender-editor-pdf-viewer");
_zoomOverrideInitialized = true;
}
private async Task HandleClickAsync(MouseEventArgs args)
{
if (EditorClick.HasDelegate)
await EditorClick.InvokeAsync(args);
}
private async Task OnFieldMouseDownAsync(MouseEventArgs args, SenderPdfFieldOverlay field)
{
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
"envelopeEditor.getClickCoordsOnPdfPage",
"sender-editor-pdf-viewer",
args.ClientX,
args.ClientY);
if (coords is null)
return;
var pageNumber = coords.PageNumber;
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
var pointerXPt = coords.NormX * pageWidthPt;
var pointerYPt = coords.NormY * pageHeightPt;
_draggedFieldIndex = field.Index;
_isDragging = true;
_grabOffsetXPt = pointerXPt - field.XPt;
_grabOffsetYPt = pointerYPt - field.YPt;
}
private async Task OnEditorMouseMoveAsync(MouseEventArgs args)
{
if (_draggedFieldIndex is null || !MoveField.HasDelegate)
return;
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
"envelopeEditor.getClickCoordsOnPdfPage",
"sender-editor-pdf-viewer",
args.ClientX,
args.ClientY);
if (coords is null)
return;
var pageNumber = coords.PageNumber;
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
const double sigWidthPt = 1.77 * 72;
const double sigHeightPt = 1.96 * 72;
var xPt = (coords.NormX * pageWidthPt) - _grabOffsetXPt;
var yPt = (coords.NormY * pageHeightPt) - _grabOffsetYPt;
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - sigWidthPt));
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - sigHeightPt));
await MoveField.InvokeAsync(new SenderPdfFieldMoveRequest(_draggedFieldIndex.Value, pageNumber, xPt, yPt));
}
private async Task OnGoFirstPageAsync()
{
await NavigateToPageAsync(1);
}
private async Task OnGoPreviousPageAsync()
{
await NavigateToPageAsync(_currentPage - 1);
}
private async Task OnGoNextPageAsync()
{
await NavigateToPageAsync(_currentPage + 1);
}
private async Task OnGoLastPageAsync()
{
await NavigateToPageAsync(PageCount);
}
private async Task OnGoToPageAsync(int page)
{
await NavigateToPageAsync(page);
}
private async Task NavigateToPageAsync(int targetPage)
{
if (PageCount <= 0)
return;
var page = Math.Max(1, Math.Min(targetPage, PageCount));
_currentPage = page;
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.scrollToPdfPage",
"sender-editor-pdf-viewer",
page);
}
private async Task OnEditorScrollAsync(EventArgs _)
{
if (PageCount <= 0)
return;
var visiblePage = await JSRuntime.InvokeAsync<int?>(
"envelopeEditor.getVisiblePdfPageNumber",
"sender-editor-pdf-viewer");
if (!visiblePage.HasValue)
return;
var clamped = Math.Max(1, Math.Min(visiblePage.Value, PageCount));
if (clamped == _currentPage)
return;
_currentPage = clamped;
await InvokeAsync(StateHasChanged);
}
private async Task OnZoomOutAsync()
{
SetZoomPercent(_zoomPercent - ZoomStepPercent);
await InvokeAsync(StateHasChanged);
}
private async Task OnZoomInAsync()
{
SetZoomPercent(_zoomPercent + ZoomStepPercent);
await InvokeAsync(StateHasChanged);
}
private async Task OnSetZoomPercentAsync(int zoomPercent)
{
SetZoomPercent(zoomPercent);
await InvokeAsync(StateHasChanged);
}
private async Task OnEditorWheelAsync(WheelEventArgs args)
{
if (args.CtrlKey)
{
if (args.DeltaY < 0)
await OnZoomInAsync();
else if (args.DeltaY > 0)
await OnZoomOutAsync();
return;
}
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.scrollPdfViewerByDelta",
"sender-editor-pdf-viewer",
args.DeltaY);
}
private void SetZoomPercent(int value)
{
_zoomPercent = Math.Max(MinZoomPercent, Math.Min(value, MaxZoomPercent));
}
private async Task OnDownloadPdfAsync()
{
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isDownloading || _isPrinting)
return;
_isDownloading = true;
await InvokeAsync(StateHasChanged);
try
{
var fallbackName = string.IsNullOrWhiteSpace(PdfFileName)
? "document.pdf"
: PdfFileName;
var fileName = fallbackName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
? fallbackName
: $"{fallbackName}.pdf";
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.downloadDataUrlAsFile",
PdfDataUrl,
fileName,
"application/pdf");
}
finally
{
_isDownloading = false;
await InvokeAsync(StateHasChanged);
}
}
private async Task OnPrintPdfAsync()
{
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isPrinting || _isDownloading)
return;
_isPrinting = true;
await InvokeAsync(StateHasChanged);
try
{
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.printDataUrlPdf",
PdfDataUrl,
"application/pdf");
}
finally
{
_isPrinting = false;
await InvokeAsync(StateHasChanged);
}
}
private void OnEditorMouseUp(MouseEventArgs args)
{
_draggedFieldIndex = null;
_isDragging = false;
}
private (double WidthPt, double HeightPt) GetPageSize(int pageNumber)
{
var page = Pages.FirstOrDefault(p => p.PageNumber == pageNumber);
return page is null ? (595.0, 842.0) : (page.WidthPt, page.HeightPt);
}
private sealed record EditorCoords(double NormX, double NormY, int PageIndex, int PageNumber);
private string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -1,267 +0,0 @@
<div class="sender-image-toolbar" @onclick:stopPropagation="true">
<div class="sender-image-toolbar__group sender-image-toolbar__group--nav" role="group" aria-label="Page navigation">
<button type="button" class="sender-image-toolbar__icon-btn" title="First page" disabled="@(!CanGoPrevious)" @onclick="GoToFirstAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M16 7L11 12L16 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" class="sender-image-toolbar__icon-btn" title="Previous page" disabled="@(!CanGoPrevious)" @onclick="GoToPreviousAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 7L10 12L15 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="sender-image-toolbar__value-box">
<input type="number"
min="1"
max="@TotalPagesDisplay"
value="@CurrentPageDisplay"
class="sender-image-toolbar__page-input"
@onchange="OnPageInputChangedAsync"
disabled="@(TotalPagesDisplay <= 0)" />
<span class="sender-image-toolbar__page-total">of @TotalPagesDisplay</span>
</div>
<div class="sender-image-toolbar__select-wrap" title="Page list">
<button type="button" class="sender-image-toolbar__icon-btn sender-image-toolbar__icon-btn--caret" disabled="@(TotalPagesDisplay <= 0)">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<select class="sender-image-toolbar__page-select"
aria-label="Select page"
disabled="@(TotalPagesDisplay <= 0)"
value="@CurrentPageDisplay"
@onchange="OnPageSelectChangedAsync">
@for (var pageNumber = 1; pageNumber <= TotalPagesDisplay; pageNumber++)
{
<option value="@pageNumber">@pageNumber</option>
}
</select>
</div>
<button type="button" class="sender-image-toolbar__icon-btn" title="Next page" disabled="@(!CanGoNext)" @onclick="GoToNextAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 7L14 12L9 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" class="sender-image-toolbar__icon-btn" title="Last page" disabled="@(!CanGoNext)" @onclick="GoToLastAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M16 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M8 7L13 12L8 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
<div class="sender-image-toolbar__group sender-image-toolbar__group--zoom" role="group" aria-label="Zoom controls">
<button type="button" class="sender-image-toolbar__icon-btn" title="Zoom out" disabled="@(!CanZoomOut)" @onclick="ZoomOutAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 12H18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div class="sender-image-toolbar__value-box" aria-hidden="true">
<span>@ZoomPercentDisplay%</span>
</div>
<input type="range"
class="sender-image-toolbar__zoom-slider"
min="@MinZoomPercent"
max="@MaxZoomPercent"
step="@ZoomStepPercent"
value="@ZoomPercentDisplay"
@oninput="OnZoomSliderInputAsync"
aria-label="Zoom slider" />
<button type="button" class="sender-image-toolbar__icon-btn" title="Zoom in" disabled="@(!CanZoomIn)" @onclick="ZoomInAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M6 12H18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
<div class="sender-image-toolbar__group sender-image-toolbar__group--actions" role="group" aria-label="Document actions">
<button type="button"
class="sender-image-toolbar__icon-btn @(IsPrinting ? "sender-image-toolbar__icon-btn--busy" : string.Empty)"
title="@(IsPrinting ? "Preparing print..." : "Print")"
disabled="@(!CanPrintPdf || IsPrinting || IsDownloading)"
@onclick="PrintPdfAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7 9V4H17V9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<rect x="5" y="9" width="14" height="8" rx="2" stroke="currentColor" stroke-width="2" />
<path d="M7 14H17V20H7V14Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
</button>
<button type="button"
class="sender-image-toolbar__icon-btn @(IsDownloading ? "sender-image-toolbar__icon-btn--busy" : string.Empty)"
title="@(IsDownloading ? "Preparing download..." : "Download PDF")"
disabled="@(!CanDownloadPdf || IsDownloading || IsPrinting)"
@onclick="DownloadPdfAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 4V14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M8 10L12 14L16 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M5 18H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
@code {
[Parameter]
public int CurrentPage { get; set; }
[Parameter]
public int TotalPages { get; set; }
[Parameter]
public int ZoomPercent { get; set; } = 112;
[Parameter]
public int MinZoomPercent { get; set; } = 50;
[Parameter]
public int MaxZoomPercent { get; set; } = 300;
[Parameter]
public int ZoomStepPercent { get; set; } = 5;
[Parameter]
public EventCallback GoFirstPage { get; set; }
[Parameter]
public EventCallback GoPreviousPage { get; set; }
[Parameter]
public EventCallback GoNextPage { get; set; }
[Parameter]
public EventCallback GoLastPage { get; set; }
[Parameter]
public EventCallback<int> GoToPage { get; set; }
[Parameter]
public EventCallback ZoomOut { get; set; }
[Parameter]
public EventCallback ZoomIn { get; set; }
[Parameter]
public EventCallback<int> SetZoomPercent { get; set; }
[Parameter]
public bool CanDownloadPdf { get; set; }
[Parameter]
public bool CanPrintPdf { get; set; }
[Parameter]
public bool IsDownloading { get; set; }
[Parameter]
public bool IsPrinting { get; set; }
[Parameter]
public EventCallback DownloadPdf { get; set; }
[Parameter]
public EventCallback PrintPdf { get; set; }
private int CurrentPageDisplay => CurrentPage > 0 ? CurrentPage : 0;
private int TotalPagesDisplay => TotalPages > 0 ? TotalPages : 0;
private int ZoomPercentDisplay => ZoomPercent > 0 ? ZoomPercent : 100;
private bool CanGoPrevious => CurrentPageDisplay > 1;
private bool CanGoNext => TotalPagesDisplay > 0 && CurrentPageDisplay < TotalPagesDisplay;
private bool CanZoomOut => ZoomPercentDisplay > MinZoomPercent;
private bool CanZoomIn => ZoomPercentDisplay < MaxZoomPercent;
private async Task GoToFirstAsync()
{
if (GoFirstPage.HasDelegate)
await GoFirstPage.InvokeAsync();
}
private async Task GoToPreviousAsync()
{
if (GoPreviousPage.HasDelegate)
await GoPreviousPage.InvokeAsync();
}
private async Task GoToNextAsync()
{
if (GoNextPage.HasDelegate)
await GoNextPage.InvokeAsync();
}
private async Task GoToLastAsync()
{
if (GoLastPage.HasDelegate)
await GoLastPage.InvokeAsync();
}
private async Task OnPageInputChangedAsync(ChangeEventArgs args)
{
if (!GoToPage.HasDelegate)
return;
if (!int.TryParse(args.Value?.ToString(), out var page))
return;
page = Math.Max(1, Math.Min(page, Math.Max(1, TotalPagesDisplay)));
await GoToPage.InvokeAsync(page);
}
private async Task OnPageSelectChangedAsync(ChangeEventArgs args)
{
if (!GoToPage.HasDelegate)
return;
if (!int.TryParse(args.Value?.ToString(), out var page))
return;
page = Math.Max(1, Math.Min(page, Math.Max(1, TotalPagesDisplay)));
await GoToPage.InvokeAsync(page);
}
private async Task ZoomOutAsync()
{
if (ZoomOut.HasDelegate)
await ZoomOut.InvokeAsync();
}
private async Task ZoomInAsync()
{
if (ZoomIn.HasDelegate)
await ZoomIn.InvokeAsync();
}
private async Task OnZoomSliderInputAsync(ChangeEventArgs args)
{
if (!SetZoomPercent.HasDelegate)
return;
if (!int.TryParse(args.Value?.ToString(), out var zoomPercent))
return;
zoomPercent = Math.Max(MinZoomPercent, Math.Min(zoomPercent, MaxZoomPercent));
await SetZoomPercent.InvokeAsync(zoomPercent);
}
private async Task DownloadPdfAsync()
{
if (DownloadPdf.HasDelegate)
await DownloadPdf.InvokeAsync();
}
private async Task PrintPdfAsync()
{
if (PrintPdf.HasDelegate)
await PrintPdf.InvokeAsync();
}
}

View File

@@ -42,8 +42,6 @@ builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddScoped<EnvelopeService>();
builder.Services.AddScoped<LocalizationService>();
builder.Services.AddScoped<ReceiverService>();
builder.Services.AddScoped<PdfRenderService>();
// UI Preferences & Theme
builder.Services.AddScoped<UserPreferencesService>();

View File

@@ -26,4 +26,24 @@ public class AnnotationService(IHttpClientFactory httpClientFactory)
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
return result ?? [];
}
public async Task RejectAsync(string envelopeKey, string? reason, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.PostAsJsonAsync(
$"/api/Annotation/reject/{Uri.EscapeDataString(envelopeKey)}",
reason,
_jsonOptions,
cancel);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException(
$"Failed to reject envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}",
null,
response.StatusCode);
}
}
}

View File

@@ -86,18 +86,6 @@ public class AuthService(IHttpClientFactory httpClientFactory)
return response.IsSuccessStatusCode;
}
/// <summary>
/// Shares the current envelope in read-only mode for the specified receiver and expiration date.
/// Calls POST /api/readonly/{envelopeKey}.
/// </summary>
public async Task<bool> ShareEnvelopeReadOnlyAsync(string envelopeKey, string receiverMail, DateTime dateValid, CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var payload = new { receiverMail, dateValid };
var response = await http.PostAsJsonAsync($"/api/readonly/{Uri.EscapeDataString(envelopeKey)}", payload, cancel);
return response.IsSuccessStatusCode;
}
/// <summary>
/// Removes the per-envelope receiver cookie for the given envelope key.
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.

View File

@@ -1,14 +1,10 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Server.Client.Services;
public class DocumentService(IHttpClientFactory httpClientFactory)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Fetches the PDF bytes for the given envelope key from the API.
@@ -33,26 +29,4 @@ public class DocumentService(IHttpClientFactory httpClientFactory)
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
return bytes;
}
/// <summary>
/// Fetches sender document details (including signature elements) by envelope id.
/// Uses GET /api/Document?EnvelopeId={envelopeId}.
/// </summary>
public async Task<DocumentDto?> GetSenderDocumentDetailsByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
if (!response.IsSuccessStatusCode)
{
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to load sender document details. Status: {statusCode} ({reasonPhrase})",
null,
response.StatusCode);
}
return await response.Content.ReadFromJsonAsync<DocumentDto>(_jsonOptions, cancel);
}
}

View File

@@ -1,32 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Server.Client.Services;
public class PdfRenderService(IHttpClientFactory httpClientFactory)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsync(byte[] pdfBytes, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
using var response = await http.PostAsJsonAsync(
"/api/PdfRender/pages",
new PdfRenderRequestDto { PdfBytes = pdfBytes },
_jsonOptions,
cancel);
if (!response.IsSuccessStatusCode)
{
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to render PDF pages. Status: {statusCode} ({reasonPhrase})",
null,
response.StatusCode);
}
return await response.Content.ReadFromJsonAsync<List<PdfPageImageDto>>(_jsonOptions, cancel) ?? [];
}
}

View File

@@ -1,29 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace EnvelopeGenerator.Server.Client.Services;
public class ReceiverService(IHttpClientFactory httpClientFactory)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Fetches receiver email suggestions from sender-authorized API.
/// Calls GET /api/Receiver?EmailAddressSearch={term}&onlyEmailAddress=true.
/// </summary>
public async Task<IReadOnlyList<string>> SearchReceiverEmailsAsync(string searchTerm, CancellationToken cancel = default)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return [];
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var url = $"/api/Receiver?EmailAddressSearch={Uri.EscapeDataString(searchTerm)}&onlyEmailAddress=true";
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
return [];
var emails = await response.Content.ReadFromJsonAsync<List<string>>(JsonOptions, cancel);
return emails ?? [];
}
}

View File

@@ -11,7 +11,6 @@
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Server.Client.Models
@using EnvelopeGenerator.Server.Client.Options
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
@using DevExpress.Blazor
@using DevExpress.Blazor.PdfViewer
@using DevExpress.Blazor.Reporting

View File

@@ -1,4 +1,4 @@
@page "/sender/editor_legacy"
@page "/sender/editor"
@rendermode InteractiveServer
@using DevExpress.Blazor.PdfViewer
@using DevExpress.Blazor.Reporting.Models
@@ -543,7 +543,7 @@
var editQuery = EnvelopeId.HasValue && EnvelopeId.Value > 0
? $"&envelopeId={EnvelopeId.Value}"
: string.Empty;
NavigationManager.NavigateTo($"/sender/editor_legacy?esid={sid}{editQuery}", forceLoad: false);
NavigationManager.NavigateTo($"/sender/editor?esid={sid}{editQuery}", forceLoad: false);
}
}
@@ -837,7 +837,7 @@
if (previousEnvelopeId != result.Id)
{
var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid;
NavigationManager.NavigateTo($"/sender/editor_legacy?esid={sid}&envelopeId={result.Id}", forceLoad: false);
NavigationManager.NavigateTo($"/sender/editor?esid={sid}&envelopeId={result.Id}", forceLoad: false);
}
NavigationManager.NavigateTo("/sender", forceLoad: false);

View File

@@ -262,7 +262,7 @@
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null)
{
var statusInfo = GetStatusInfo(envelope.Status);
var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@statusInfo.Label
@@ -314,12 +314,20 @@
{
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
@{
var isRejected = IsReceiverRejected(envelope, receiver);
var isSigned = IsReceiverSigned(envelope, receiver);
var accessCode = receiver.AccessCode;
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
}
<span class="receiver-badge receiver-badge--@(isSigned ? "signed" : "unsigned")" style="min-width: 100px;">
@if (isSigned)
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
@if (isRejected)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
<span>@GetStatusLabel(EnvelopeStatus.DocumentRejected)</span>
}
else if (isSigned)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
@@ -590,10 +598,11 @@
{
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
// Split into active and completed based on status
// Split into active and completed based on effective status
// (history-aware: rejected envelopes should not stay in active tab).
var envelopes = _allEnvelopes.ToList();
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
_activeEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsCompleted()).ToList();
}
catch (Exception ex)
{
@@ -745,13 +754,13 @@
bool IsEnvelopeSent(EnvelopeDto envelope)
{
var status = (EnvelopeStatus)envelope.Status;
var status = GetEffectiveEnvelopeStatus(envelope);
return status >= EnvelopeStatus.EnvelopeQueued;
}
bool IsEnvelopeEditable(EnvelopeDto envelope)
{
var status = (EnvelopeStatus)envelope.Status;
var status = GetEffectiveEnvelopeStatus(envelope);
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
}
@@ -800,7 +809,7 @@
IReadOnlyList<StatusFilterItem> GetStatusFilterItems(IEnumerable<EnvelopeDto>? source)
{
return (source ?? Enumerable.Empty<EnvelopeDto>())
.Select(x => x.Status)
.Select(GetEffectiveEnvelopeStatus)
.Distinct()
.OrderBy(x => x)
.Select(x => new StatusFilterItem(x, GetStatusLabel(x)))
@@ -818,7 +827,7 @@
bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
var status = (EnvelopeStatus)envelope.Status;
var status = GetEffectiveEnvelopeStatus(envelope);
if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent)
return true;
@@ -841,6 +850,43 @@
return false;
}
bool IsReceiverRejected(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
var status = GetEffectiveEnvelopeStatus(envelope);
var histories = envelope.Histories?.Where(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected).ToList() ?? [];
if (histories.Count == 0)
return false;
if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId))
return true;
var receiverEmail = receiver.Receiver?.EmailAddress;
if (!string.IsNullOrWhiteSpace(receiverEmail) &&
histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase)))
return true;
if (!string.IsNullOrWhiteSpace(receiver.Name) &&
histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase)))
return true;
return status == EnvelopeStatus.EnvelopeRejected && (envelope.EnvelopeReceivers?.Count() ?? 0) == 1;
}
EnvelopeStatus GetEffectiveEnvelopeStatus(EnvelopeDto envelope)
{
var rawStatus = envelope.Status;
if (rawStatus is EnvelopeStatus.EnvelopeRejected or EnvelopeStatus.EnvelopeWithdrawn)
return rawStatus;
var hasRejectedHistory = (envelope.Histories ?? Enumerable.Empty<HistoryDto>())
.Any(h => h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected);
return hasRejectedHistory ? EnvelopeStatus.EnvelopeRejected : rawStatus;
}
DateTime GetHistoryDate(HistoryDto history)
{
return history.ActionDate ?? history.AddedWhen;
@@ -983,13 +1029,18 @@
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(CurrentGridKey, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
{
var layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
SanitizeLayoutFilters(layout);
e.Layout = layout;
}
}
async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
try
{
SanitizeLayoutFilters(e.Layout);
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json);
}
@@ -999,5 +1050,55 @@
}
}
// Keep column/order personalization, but never persist/restore data filters.
// Tab counters are based on tab datasets; persisted grid filters can hide rows and
// make the grid appear empty or incomplete.
static void SanitizeLayoutFilters(GridPersistentLayout? layout)
{
if (layout is null)
return;
ClearWritableProperty(layout, "FilterCriteria");
ClearWritableProperty(layout, "SearchText");
if (layout.Columns is not System.Collections.IEnumerable columns)
return;
foreach (var column in columns)
{
if (column is null)
continue;
ClearWritableProperty(column, "FilterCriteria");
ClearWritableProperty(column, "FilterValues");
ClearWritableProperty(column, "FilterValue");
ClearWritableProperty(column, "SearchText");
}
}
static void ClearWritableProperty(object target, string propertyName)
{
var prop = target.GetType()
.GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)
.FirstOrDefault(p => p.Name == propertyName && p.GetIndexParameters().Length == 0);
if (prop is null || !prop.CanWrite)
return;
var propertyType = prop.PropertyType;
object? value;
if (!propertyType.IsValueType || Nullable.GetUnderlyingType(propertyType) is not null)
{
value = null;
}
else
{
value = Activator.CreateInstance(propertyType);
}
prop.SetValue(target, value);
}
private sealed record StatusFilterItem(EnvelopeStatus Value, string Text);
}

View File

@@ -5,27 +5,22 @@
@using EnvelopeGenerator.Server.Client.Models
@using EnvelopeGenerator.Server.Client.Models.Constants
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Extensions
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
@using EnvelopeGenerator.Application.EnvelopeReceivers.Queries
@using EnvelopeGenerator.Domain.Constants
@using Microsoft.JSInterop
@using DevExpress.Blazor
@using System.Drawing
@using System.Security.Claims
@using System.ComponentModel.DataAnnotations
@using Microsoft.Extensions.Caching.Memory
@using MediatR
@using EnvelopeGenerator.Server.Extensions
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject AnnotationService AnnotationService
@inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache
@inject ILogger<ReceiverPage> Logger
@inject IMediator Mediator
@implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
@@ -133,30 +128,15 @@
</button>
}
@if (!(_envelopeReceiver?.Envelope?.ReadOnly ?? false))
{
<button class="pdf-toolbar__btn"
@onclick="OpenSharePopup"
title="Dokument teilen"
style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.5 1a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2zm-11 5a1.5 1.5 0 1 0 1.415 2H4a1.5 1.5 0 0 0-1.5-2zm11 5a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2z"/>
<path d="M2.854 7.646a.5.5 0 0 1 .0-.707l9-5a.5.5 0 1 1 .486.874l-9 5a.5.5 0 0 1-.486-.167zm0 .708a.5.5 0 0 1 .486-.167l9 5a.5.5 0 0 1-.486.874l-9-5a.5.5 0 0 1 0-.707z"/>
</svg>
<span class="pdf-toolbar__btn-text">Teilen</span>
</button>
}
<button class="pdf-toolbar__btn"
@onclick="LogoutAsync"
disabled="@_isLoggingOut"
title="Abmelden"
<button class="pdf-toolbar__btn pdf-toolbar__btn--reject"
@onclick="OpenRejectPopup"
disabled="@_isRejectSubmitting"
title="Ablehnen"
style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 15a1 1 0 0 0 1-1v-2a.5.5 0 0 1 1 0v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v2a.5.5 0 0 1-1 0V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h7z" />
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L14.293 7.5H6.5a.5.5 0 0 0 0 1h7.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z" />
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
<span class="pdf-toolbar__btn-text">@(_isLoggingOut ? "Abmelden..." : "Abmelden")</span>
<span class="pdf-toolbar__btn-text">Ablehnen</span>
</button>
</div>
</div>
@@ -375,52 +355,70 @@
</FooterContentTemplate>
</DxPopup>
<DxPopup @bind-Visible="_sharePopupVisible"
HeaderText="Dokument teilen"
Width="520px"
@* Rejection Popup *@
<DxPopup @bind-Visible="_rejectPopupVisible"
HeaderText="Dokument ablehnen"
Width="500px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="true">
CloseOnOutsideClick="false"
ShowCloseButton="false"
CloseOnEscape="false">
<BodyContentTemplate>
<div class="mb-3">
<label class="form-label" for="rp-share-mail" style="font-size: 0.9rem; font-weight: 500;">E-Mail</label>
<input id="rp-share-mail"
class="form-control"
placeholder="user@mail.com"
value="@_shareReceiverMail"
@oninput="args => _shareReceiverMail = args.Value?.ToString() ?? string.Empty" />
<div style="display: flex; align-items: flex-start; gap: 0.9rem; padding: 0.2rem 0 0.8rem;">
<div style="flex-shrink: 0; width: 36px; height: 36px; border-radius: 999px; background: #fee2e2; color: #b91c1c; display: inline-flex; align-items: center; justify-content: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
</div>
<div style="min-width: 0;">
<div style="font-size: 0.95rem; font-weight: 600; color: #1f2937; margin-bottom: 0.3rem;">
Möchten Sie dieses Dokument wirklich ablehnen?
</div>
<div style="font-size: 0.84rem; color: #6b7280; line-height: 1.45;">
Diese Aktion markiert den Umschlag als abgelehnt und informiert den Absender.
</div>
</div>
</div>
<div class="mb-1">
<label class="form-label" for="rp-share-date" style="font-size: 0.9rem; font-weight: 500;">Gultig bis</label>
<input id="rp-share-date"
class="form-control"
type="date"
min="@DateTime.Today.AddDays(1).ToString("yyyy-MM-dd")"
max="@DateTime.Today.AddDays(90).ToString("yyyy-MM-dd")"
value="@_shareDateValid.ToString("yyyy-MM-dd")"
@oninput="OnShareDateChanged" />
<div>
<label for="rp-reject-reason" class="form-label" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
Grund <span style="color: #6c757d; font-weight: 400;">(optional)</span>
</label>
<textarea id="rp-reject-reason"
class="form-control"
rows="4"
maxlength="1000"
value="@_rejectReason"
@oninput="args => _rejectReason = args.Value?.ToString() ?? string.Empty"
placeholder="Grund der Ablehnung"
style="border: 2px solid #e9ecef; border-radius: 8px; padding: 0.625rem; resize: vertical;"></textarea>
</div>
@if (!string.IsNullOrWhiteSpace(_shareMessage))
@if (!string.IsNullOrWhiteSpace(_rejectValidationMessage))
{
<div class="mt-3 alert @(_shareMessageIsError ? "alert-danger" : "alert-success") mb-0" role="alert">
@_shareMessage
<div style="background: #fee2e2; border-left: 4px solid #dc2626; padding: 0.75rem 1rem; margin-top: 1rem; border-radius: 4px;">
<span style="color: #b91c1c; font-size: 0.875rem; font-weight: 500;">@_rejectValidationMessage</span>
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div class="d-flex justify-content-end gap-2 w-100">
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
<button class="btn btn-outline-secondary"
@onclick="CloseSharePopup"
disabled="@_isSharing">
Schliessen
@onclick="CloseRejectPopup"
disabled="@_isRejectSubmitting"
style="border-radius: 6px; padding: 0.625rem 1.25rem; font-weight: 500;">
Zurück
</button>
<button class="btn btn-primary"
@onclick="ShareEnvelopeAsync"
disabled="@_isSharing"
style="background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); border: none;">
@(_isSharing ? "Senden..." : "Senden")
<button class="btn btn-danger"
@onclick="SubmitRejectAsync"
disabled="@_isRejectSubmitting"
style="border-radius: 6px; padding: 0.625rem 1.5rem; font-weight: 600;">
@if (_isRejectSubmitting)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
Ablehnen
</button>
</div>
</FooterContentTemplate>
@@ -470,114 +468,12 @@
string _signerFullName = string.Empty;
string _signerPosition = string.Empty;
string _signaturePlace = string.Empty;
bool _isLoggingOut = false;
// ----- Share popup state -----
bool _sharePopupVisible = false;
bool _isSharing = false;
string _shareReceiverMail = string.Empty;
DateTime _shareDateValid = DateTime.Today.AddDays(7);
string? _shareMessage;
bool _shareMessageIsError = false;
async Task LogoutAsync()
{
if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey))
return;
_isLoggingOut = true;
await InvokeAsync(StateHasChanged);
try
{
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Receiver logout failed for {EnvelopeKey}", EnvelopeKey);
}
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
}
void OpenSharePopup()
{
_shareReceiverMail = string.Empty;
_shareDateValid = DateTime.Today.AddDays(7);
_shareMessage = null;
_shareMessageIsError = false;
_sharePopupVisible = true;
}
void CloseSharePopup()
{
if (_isSharing)
return;
_sharePopupVisible = false;
}
void OnShareDateChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args)
{
if (DateTime.TryParse(args.Value?.ToString(), out var parsedDate))
_shareDateValid = parsedDate.Date;
}
async Task ShareEnvelopeAsync()
{
if (_isSharing)
return;
var email = _shareReceiverMail.Trim();
if (string.IsNullOrWhiteSpace(email) || !new EmailAddressAttribute().IsValid(email))
{
_shareMessage = "Bitte geben Sie eine gultige E-Mail-Adresse ein.";
_shareMessageIsError = true;
return;
}
var minDate = DateTime.Today.AddDays(1);
var maxDate = DateTime.Today.AddDays(90);
if (_shareDateValid.Date < minDate || _shareDateValid.Date > maxDate)
{
_shareMessage = "Bitte wahlen Sie ein Datum zwischen morgen und 90 Tagen.";
_shareMessageIsError = true;
return;
}
_isSharing = true;
_shareMessage = null;
_shareMessageIsError = false;
await InvokeAsync(StateHasChanged);
try
{
var shared = await AuthService.ShareEnvelopeReadOnlyAsync(EnvelopeKey!, email, _shareDateValid.Date);
if (shared)
{
_shareMessage = "Dokument wurde erfolgreich als Nur-Lesen freigegeben.";
_shareMessageIsError = false;
_shareReceiverMail = string.Empty;
_shareDateValid = DateTime.Today.AddDays(7);
}
else
{
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
_shareMessageIsError = true;
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to share envelope in read-only mode for {EnvelopeKey}", EnvelopeKey);
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
_shareMessageIsError = true;
}
finally
{
_isSharing = false;
await InvokeAsync(StateHasChanged);
}
}
// ----- Rejection popup state -----
bool _rejectPopupVisible;
bool _isRejectSubmitting;
string _rejectReason = string.Empty;
string? _rejectValidationMessage;
// ----- Lifecycle -----
protected override async Task OnInitializedAsync()
@@ -589,12 +485,6 @@
return;
}
if (EnvelopeKey.TryDecode(out var decodedKeys) && decodedKeys.GetEncodeType() == EncodeType.EnvelopeReceiverReadOnly)
{
Navigation.NavigateTo($"/envelope/readonly/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
// Authorization — same pattern as EnvelopeReceiverPage
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
if (_receiverUser is null)
@@ -603,12 +493,6 @@
return;
}
if (await Mediator.IsSignedAsync(_receiverUser.EnvelopeUuid(), _receiverUser.ReceiverSignature()))
{
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
return;
}
try
{
// Load PDF bytes via MediatR (uses authenticated user's claims)
@@ -627,6 +511,11 @@
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
if (_envelopeReceiver is null)
Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey);
else if (IsRejected(_envelopeReceiver))
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/rejected", forceLoad: true);
return;
}
// Build initial report (no signature image yet)
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
@@ -672,6 +561,16 @@
}
// ----- Report builder -----
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
{
var status = envelopeReceiver.Envelope?.Status;
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
return true;
return envelopeReceiver.Envelope?.Histories?.Any(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
}
/// <summary>
/// Builds an XtraReport wrapping the PDF bytes.
/// If a signature is captured and there are signature fields, the signature image is
@@ -905,6 +804,56 @@
forceLoad: true);
}
void OpenRejectPopup()
{
_rejectValidationMessage = null;
_rejectPopupVisible = true;
}
void CloseRejectPopup()
{
if (_isRejectSubmitting)
return;
_rejectPopupVisible = false;
}
async Task SubmitRejectAsync()
{
if (_isRejectSubmitting)
return;
_isRejectSubmitting = true;
_rejectValidationMessage = null;
await InvokeAsync(StateHasChanged);
try
{
var reason = string.IsNullOrWhiteSpace(_rejectReason)
? null
: _rejectReason.Trim();
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
_rejectValidationMessage = "Envelope-Schluessel fehlt.";
_isRejectSubmitting = false;
await InvokeAsync(StateHasChanged);
return;
}
await AnnotationService.RejectAsync(EnvelopeKey, reason);
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to reject envelope {EnvelopeKey}", EnvelopeKey);
_rejectValidationMessage = "Die Ablehnung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.";
_isRejectSubmitting = false;
await InvokeAsync(StateHasChanged);
}
}
async Task<string?> GetActiveSignatureDataUrlAsync()
{
if (_activeSignatureTab == SignatureTabDraw)

View File

@@ -1,188 +0,0 @@
@page "/envelope/readonly/{ReadOnlyKey}"
@rendermode InteractiveServer
@using DevExpress.Blazor.Reporting
@using DevExpress.XtraReports.UI
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly
@using EnvelopeGenerator.Application.Common.Extensions
@using EnvelopeGenerator.Application.Common.Interfaces.Services
@using EnvelopeGenerator.Domain.Constants
@inject EnvelopeGenerator.Server.Client.Services.AppVersionService AppVersion
@inject ILogger<ReceiverReadOnlyPage> Logger
@inject IEnvelopeReceiverReadOnlyService ReadOnlyService
@inject IEnvelopeReceiverService EnvelopeReceiverService
@inject IEnvelopeHistoryService HistoryService
@implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<div class="envelope-viewer-layout">
<div class="envelope-action-bar">
<div class="envelope-action-bar__inner" style="padding: 0.5rem 1.25rem;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:1rem;">
<div style="font-weight:600; color:#1f2937;">Dokumentansicht (Nur Lesen)</div>
@if (_readOnly is not null)
{
<div style="font-size:0.8rem; color:#6b7280;">Gultig bis: @_readOnly.DateValid.ToString("dd.MM.yyyy")</div>
}
</div>
</div>
</div>
<div class="envelope-content" style="padding:0; overflow:hidden;">
@if (_isLoading)
{
<div class="d-flex justify-content-center align-items-center h-100">
<div class="text-center">
<div class="spinner-border text-white mb-3" style="width:3.5rem; height:3.5rem;" role="status"></div>
<p class="text-white fw-semibold">Dokument wird geladen...</p>
</div>
</div>
}
else if (!string.IsNullOrWhiteSpace(_errorMessage))
{
<div class="error-container">
<div class="alert alert-danger shadow-lg mb-0">
<h5 class="mb-2">Dokument nicht verfugbar</h5>
<p class="mb-0">@_errorMessage</p>
</div>
</div>
}
else if (_isExpired)
{
<div class="error-container">
<div class="alert alert-warning shadow-lg mb-0">
<h5 class="mb-2">Freigabe abgelaufen</h5>
<p class="mb-0">Der Zeitraum fur diese Nur-Lesen-Freigabe ist abgelaufen.</p>
</div>
</div>
}
else if (_report is not null)
{
<DxReportViewer @ref="_reportViewer"
Report="_report"
RootCssClasses="w-100 h-100" />
}
</div>
</div>
@code {
[Parameter] public string? ReadOnlyKey { get; set; }
bool _isLoading = true;
bool _isExpired = false;
string? _errorMessage;
EnvelopeReceiverReadOnlyDto? _readOnly;
DxReportViewer? _reportViewer;
XtraReport? _report;
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(ReadOnlyKey))
{
_errorMessage = "Freigabe-Schlussel fehlt.";
_isLoading = false;
return;
}
if (!ReadOnlyKey.TryDecode(out var decodedKeys) || decodedKeys.GetEncodeType() != EncodeType.EnvelopeReceiverReadOnly)
{
_errorMessage = "Ungultiger Freigabe-Link.";
_isLoading = false;
return;
}
try
{
var readOnlyId = decodedKeys.ParseReadOnlyId();
var readOnlyRes = await ReadOnlyService.ReadByIdAsync(readOnlyId);
if (readOnlyRes.IsFailed)
{
_errorMessage = "Freigabe konnte nicht geladen werden.";
_isLoading = false;
return;
}
_readOnly = readOnlyRes.Data;
if (DateTime.Now > _readOnly.DateValid)
{
_isExpired = true;
_isLoading = false;
return;
}
if (_readOnly.Envelope is null || _readOnly.Receiver is null)
{
_errorMessage = "Freigabe ist unvollstandig.";
_isLoading = false;
return;
}
var erRes = await EnvelopeReceiverService.ReadByUuidSignatureAsync(_readOnly.Envelope.Uuid, _readOnly.Receiver.Signature);
if (erRes.IsFailed)
{
_errorMessage = "Dokument wurde nicht gefunden.";
_isLoading = false;
return;
}
var envelopeReceiver = erRes.Data;
var documentBytes = envelopeReceiver.Envelope?.Documents?.FirstOrDefault()?.ByteData;
if (documentBytes is not { Length: > 0 })
{
_errorMessage = "Dokumentdaten konnten nicht geladen werden.";
_isLoading = false;
return;
}
_report = BuildReport(documentBytes);
try
{
var historyResult = await HistoryService.RecordAsync((int)_readOnly.EnvelopeId, _readOnly.AddedWho, EnvelopeStatus.EnvelopeViewed);
if (historyResult.IsFailed)
Logger.LogWarning("Failed to record EnvelopeViewed history for read-only key {ReadOnlyKey}", ReadOnlyKey);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to record read-only history for {ReadOnlyKey}", ReadOnlyKey);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Unexpected error in read-only page for {ReadOnlyKey}", ReadOnlyKey);
_errorMessage = "Beim Laden ist ein unerwarteter Fehler aufgetreten.";
}
_isLoading = false;
}
static XtraReport BuildReport(byte[] pdfBytes)
{
var report = new XtraReport
{
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4,
Landscape = false,
Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0)
};
var detail = new DetailBand { HeightF = 0f };
report.Bands.Add(detail);
detail.Controls.Add(new XRPdfContent
{
Source = pdfBytes,
GenerateOwnPages = true
});
return report;
}
public void Dispose()
{
_report?.Dispose();
}
}

View File

@@ -0,0 +1,105 @@
@page "/envelope/{EnvelopeKey}/rejected"
@rendermode InteractiveServer
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.Domain.Interfaces
@using Microsoft.Extensions.Localization
@inject NavigationManager Navigation
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject IStringLocalizer<Resource> UiLocalizer
<div class="envelope-viewer-layout" style="justify-content: center; align-items: center; padding: 1.5rem;">
<div style="width: min(920px, 100%); background: rgba(255,255,255,0.96); border-radius: 20px; box-shadow: 0 18px 48px rgba(15,23,42,0.22); overflow: hidden;">
<div style="padding: 1.2rem 1.4rem; border-bottom: 1px solid rgba(15,23,42,0.08); background: linear-gradient(135deg, rgba(239,68,68,0.12) 0%, rgba(220,38,38,0.08) 100%);">
<div style="font-size: 1rem; font-weight: 700; color: #991b1b;">@L("DocRejected", "Dokument abgelehnt")</div>
</div>
<div style="padding: 2rem 1.5rem;">
<div style="display: flex; justify-content: center; margin-bottom: 1rem;">
<div style="width: 90px; height: 90px; border-radius: 999px; background: #fee2e2; display: inline-flex; align-items: center; justify-content: center; color: #b91c1c;">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
</div>
</div>
<h2 style="text-align: center; font-size: 1.4rem; color: #7f1d1d; margin-bottom: 0.75rem;">
@(_isReadAndConfirm
? L("RejectionInfo1Confirmation", "Dieses Dokument wurde als nicht bestaetigt markiert.")
: L("RejectionInfo1", "Dieses Dokument wurde abgelehnt."))
</h2>
<p style="text-align: center; margin: 0 auto; max-width: 760px; color: #374151; line-height: 1.6;">
@((MarkupString)_messageLine2)
</p>
<div style="margin-top: 1.4rem; text-align: center;">
<a href="/" class="btn btn-outline-secondary" style="font-weight: 600;">Portal Startseite</a>
</div>
</div>
</div>
</div>
@code {
[Parameter] public string? EnvelopeKey { get; set; }
bool _isReadAndConfirm;
string _messageLine2 = string.Empty;
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
Navigation.NavigateTo("/");
return;
}
var user = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
if (user is null)
{
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
var envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
if (envelopeReceiver is null)
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
if (!IsRejected(envelopeReceiver))
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
_isReadAndConfirm = envelopeReceiver.Envelope?.IsReadAndConfirm() ?? false;
var senderName = envelopeReceiver.Envelope?.User is null
? string.Empty
: $"{envelopeReceiver.Envelope.User.Prename} {envelopeReceiver.Envelope.User.Name}".Trim();
var senderEmail = envelopeReceiver.Envelope?.User?.Email ?? string.Empty;
var envelopeTitle = envelopeReceiver.Envelope?.Title ?? string.Empty;
var template = L("RejectionInfo2", "Das Dokument \"{2}\" von {0} ({1}) wurde abgelehnt.");
_messageLine2 = string.Format(template, senderName, senderEmail, envelopeTitle);
}
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
{
var status = envelopeReceiver.Envelope?.Status;
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
return true;
return envelopeReceiver.Envelope?.Histories?.Any(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
}
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -0,0 +1,114 @@
@page "/envelope/DxPdfViewer"
@rendermode InteractiveServer
@using System.IO
@using DevExpress.Blazor
@using System.Reflection
@using DevExpress.Blazor.PdfViewer
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<style>
.custom-drop-zone {
padding: 0 !important;
border-style: dashed;
border-width: 2px !important;
height: 230px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(183, 183, 183, 0.1);
}
.custom-drop-zone.custom-drop-zone-hover {
border-style: solid;
}
.custom-drop-zone svg {
width: 42px;
height: 42px;
}
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
pointer-events: none;
}
.pdf-viewer {
height: 800px !important;
min-height: 800px !important;
}
.pdf-viewer .dxbrv-surface-wrapper,
.pdf-viewer .dxbrv-document-surface {
height: 100% !important;
min-height: 750px !important;
}
.pdf-viewer .dxbrv-report-preview-content {
width: auto !important;
height: auto !important;
min-width: 200px !important;
min-height: 200px !important;
}
</style>
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
<span class="drop-file-icon mb-3"></span>
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
<DxButton Id="overviewDemoSelectButton"
CssClass="m-1"
RenderStyle="ButtonRenderStyle.Primary"
Text="Select File" />
</div>
<DxFileInput @ref="fileInput"
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
CssClass="w-100"
ExternalDropZoneCssSelector="#overviewDemoDropZone"
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
FilesUploading="OnFilesUploading"
MaxFileSize="2000000">
</DxFileInput>
@if (DocumentContent != null && DocumentContent.Length > 0)
{
<div class="alert alert-success mt-3">
PDF loaded: @DocumentContent.Length bytes
</div>
<DxPdfViewer CssClass="w-100 pdf-viewer" DocumentContent="@DocumentContent" />
}
else
{
<div class="alert alert-info mt-3">
Please upload a PDF file to view it.
</div>
}
@code {
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
DxFileInput fileInput;
byte[] DocumentContent { get; set; }
protected override void OnInitialized()
{
Assembly assembly = Assembly.GetExecutingAssembly();
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
if (stream != null)
{
using (stream)
using (var binaryReader = new BinaryReader(stream))
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
}
}
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
{
using (MemoryStream stream = new MemoryStream())
{
IFileInputSelectedFile file = args.Files[0];
await file.OpenReadStream(file.Size).CopyToAsync(stream);
DocumentContent = stream.ToArray();
await InvokeAsync(StateHasChanged);
}
}
}

View File

@@ -0,0 +1,50 @@
@page "/envelope/{EnvelopeKey}/DxReportViewer"
@rendermode InteractiveServer
@using XtraReport = DevExpress.XtraReports.UI.XtraReport
@using DevExpress.Blazor.Reporting
@using Microsoft.Extensions.Options
@using EnvelopeGenerator.Server.Client.Options
@using EnvelopeGenerator.Server.Client.Services
@inject InMemoryReportStorageWebExtension ReportStorage
@inject DocumentService DocumentService
@inject IOptions<ApiOptions> AppOptions
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
@if (_report is not null) {
<DxReportViewer Report="_report" RootCssClasses="w-100 h-100" Zoom="1.3" />
}
@code {
[Parameter] public string EnvelopeKey { get; init; } = null!;
XtraReport? _report = null;
protected override async Task OnInitializedAsync()
{
_report = await CreateReport();
}
async Task<XtraReport> CreateReport()
{
if (AppOptions.Value.UsePredefinedReports)
{
return Client.PredefinedReports.ReportsFactory.GetReport("LargeDatasetReport");
}
else
{
var pdfBytes = await DocumentService.GetDocumentAsync(EnvelopeKey);
if (pdfBytes is null || pdfBytes.Length == 0)
throw new InvalidOperationException($"No PDF bytes found for EnvelopeKey: {EnvelopeKey}");
var report = new XtraReport();
var detail = new DevExpress.XtraReports.UI.DetailBand();
report.Bands.Add(detail);
detail.Controls.Add(new DevExpress.XtraReports.UI.XRPdfContent { Source = pdfBytes, GenerateOwnPages = true });
return report;
}
}
}

View File

@@ -0,0 +1,124 @@
@page "/envelope/Embed"
@rendermode InteractiveServer
@using System.IO
@using DevExpress.Blazor
@using System.Reflection
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<style>
.custom-drop-zone {
padding: 0 !important;
border-style: dashed;
border-width: 2px !important;
height: 230px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(183, 183, 183, 0.1);
}
.custom-drop-zone.custom-drop-zone-hover {
border-style: solid;
}
.custom-drop-zone svg {
width: 42px;
height: 42px;
}
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
pointer-events: none;
}
.pdf-viewer {
height: 800px !important;
min-height: 800px !important;
}
.pdf-viewer .dxbrv-surface-wrapper,
.pdf-viewer .dxbrv-document-surface {
height: 100% !important;
min-height: 750px !important;
}
.pdf-viewer .dxbrv-report-preview-content {
width: auto !important;
height: auto !important;
min-width: 200px !important;
min-height: 200px !important;
}
</style>
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
<span class="drop-file-icon mb-3"></span>
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
<DxButton Id="overviewDemoSelectButton"
CssClass="m-1"
RenderStyle="ButtonRenderStyle.Primary"
Text="Select File" />
</div>
<DxFileInput @ref="fileInput"
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
CssClass="w-100"
ExternalDropZoneCssSelector="#overviewDemoDropZone"
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
FilesUploading="OnFilesUploading"
MaxFileSize="2000000">
</DxFileInput>
@if (DocumentContent != null && DocumentContent.Length > 0)
{
<div class="alert alert-success mt-3">
PDF loaded: @DocumentContent.Length bytes
</div>
<embed src="@GetPdfDataUrl()" type="application/pdf" class="w-100 pdf-viewer" />
}
else
{
<div class="alert alert-info mt-3">
Please upload a PDF file to view it.
</div>
}
@code {
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
DxFileInput fileInput;
byte[] DocumentContent { get; set; }
protected override void OnInitialized()
{
Assembly assembly = Assembly.GetExecutingAssembly();
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
if (stream != null)
{
using (stream)
using (var binaryReader = new BinaryReader(stream))
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
}
}
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
{
using (MemoryStream stream = new MemoryStream())
{
IFileInputSelectedFile file = args.Files[0];
await file.OpenReadStream(file.Size).CopyToAsync(stream);
DocumentContent = stream.ToArray();
await InvokeAsync(StateHasChanged);
}
}
private string GetPdfDataUrl()
{
if (DocumentContent == null || DocumentContent.Length == 0)
return string.Empty;
string base64 = Convert.ToBase64String(DocumentContent);
return $"data:application/pdf;base64,{base64}#toolbar=0&navpanes=0&scrollbar=1";
}
}

View File

@@ -100,11 +100,12 @@ public class AnnotationController : ControllerBase
/// <summary>
/// Rejects the document for the current receiver.
/// </summary>
/// <param name="envelopeKey">Envelope key route parameter.</param>
/// <param name="reason">Optional rejection reason.</param>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("reject")]
[HttpPost("reject/{envelopeKey}")]
[Obsolete("Use MediatR")]
public async Task<IActionResult> Reject([FromBody] string? reason = null)
public async Task<IActionResult> Reject([FromRoute] string envelopeKey, [FromBody] string? reason = null)
{
var signature = User.ReceiverSignature();
var uuid = User.EnvelopeUuid();

View File

@@ -1,18 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Redirects legacy envelope key links to Blazor routes.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
[Route("EnvelopeKey")]
public class EnvelopeKeyRedirectController : Controller
{
/// <summary>
/// Redirects /EnvelopeKey/{*path} links to /envelope/{path}.
/// </summary>
[HttpGet("{*path}")]
public IActionResult RedirectToEnvelopePath([FromRoute] string path)
=> Redirect($"/envelope/{path}");
}

View File

@@ -1,24 +0,0 @@
using DigitalData.Auth.Claims;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
[ApiController]
[Route("api/[controller]")]
public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBase
{
[HttpPost("pages")]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> RenderPages([FromBody] PdfRenderRequestDto? request, CancellationToken cancel)
{
if (request?.PdfBytes is not { Length: > 0 } bytes)
return BadRequest("Missing PDF bytes.");
IReadOnlyList<PdfPageImageDto> pages = await renderer.RenderPagesAsImagesAsync(bytes, cancel);
return Ok(pages);
}
}

View File

@@ -1,10 +1,8 @@
using DigitalData.Core.Abstraction.Application.DTO;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using System.Security.Claims;
using EnvelopeGenerator.Server.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
@@ -35,36 +33,24 @@ public class ReadOnlyController : ControllerBase
}
/// <summary>
/// Creates a new read-only receiver for an envelope.
/// Creates a new read-only receiver for the current envelope.
/// </summary>
/// <param name="envelopeKey">Envelope key (uuid+signature encoded).</param>
/// <param name="createDto">Creation payload.</param>
[HttpPost("{envelopeKey}")]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
[HttpPost]
[Authorize(Policy = AuthPolicy.Receiver)]
[Obsolete("Use MediatR")]
public async Task<IActionResult> CreateAsync(
[FromRoute] string envelopeKey,
[FromBody] EnvelopeReceiverReadOnlyCreateDto createDto,
[FromServices] IMediator mediator,
CancellationToken cancel)
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto)
{
var envelopeReceiver = await mediator.Send(new ReadEnvelopeReceiverQuery { Key = envelopeKey }, cancel);
var authEnvelopeReceiver = envelopeReceiver.FirstOrDefault();
if (authEnvelopeReceiver?.Envelope is null)
return NotFound();
var addedWho = User.FindFirstValue(ClaimTypes.Email)
?? authEnvelopeReceiver.Receiver?.EmailAddress;
if (string.IsNullOrWhiteSpace(addedWho))
var authReceiverMail = User.ReceiverMail();
if (authReceiverMail is null)
{
_logger.LogError("Receiver e-mail is missing in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
return StatusCode(StatusCodes.Status500InternalServerError);
_logger.LogError("EmailAddress claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
return Unauthorized();
}
var envelopeId = authEnvelopeReceiver.EnvelopeId;
var envelopeId = User.EnvelopeId();
createDto.AddedWho = addedWho;
createDto.AddedWho = authReceiverMail;
createDto.EnvelopeId = envelopeId;
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);

View File

@@ -10,9 +10,9 @@
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.Server</Product>
<Version>1.4.0-beta</Version>
<FileVersion>1.4.0.0</FileVersion>
<AssemblyVersion>1.4.0.0</AssemblyVersion>
<Version>1.2.0-beta</Version>
<FileVersion>1.2.0.0</FileVersion>
<AssemblyVersion>1.2.0.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
@@ -21,7 +21,6 @@
<ProjectReference Include="..\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
</ItemGroup>

View File

@@ -67,7 +67,7 @@ public static class ReceiverClaimExtensions
/// Gets the authenticated receiver email address from the claims.
/// </summary>
public static string ReceiverMail(this ClaimsPrincipal user)
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email, ClaimTypes.Email);
/// <summary>
/// Gets the authenticated envelope identifier from the claims.
@@ -120,4 +120,4 @@ public static class ReceiverClaimExtensions
{
return Role.Receiver.Authorized.Any(user.IsInRole);
}
}
}

View File

@@ -26,8 +26,6 @@ using NLog;
using DigitalData.Auth.Claims;
using EnvelopeGenerator.Server;
using DigitalData.EmailProfilerDispatcher;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Infrastructure.Doc;
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Info("EnvelopeGenerator.Server logging initialized!");
@@ -343,8 +341,6 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>();
// Business Services (Server specific)
builder.Services.AddScoped<DocumentService>();
@@ -353,7 +349,6 @@ try
builder.Services.AddScoped<EnvelopeReceiverService>();
builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddScoped<IPdfPageImageRenderer, PdfPageImageRenderer>();
builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>();
builder.Services.AddSingleton<AppVersionService>();

View File

@@ -56,351 +56,6 @@
height: 100%;
}
.sender-image-editor {
overflow: auto;
padding: 1rem;
}
.sender-image-editor-shell {
position: relative;
width: 100%;
height: 100%;
}
.sender-image-editor__toolbar-host {
position: sticky;
top: 0;
z-index: 40;
height: 0;
pointer-events: none;
}
.sender-image-toolbar {
pointer-events: auto;
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 2rem);
max-width: 1040px;
margin: 0;
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr) auto;
gap: 0.6rem;
align-items: center;
background: transparent;
}
.sender-image-toolbar__group {
min-height: 3.35rem;
background: #ffffff;
border: 1px solid #d6deef;
border-radius: 0.55rem;
padding: 0.38rem 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.28rem;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.09);
}
.sender-image-toolbar__group--nav {
justify-content: flex-start;
}
.sender-image-toolbar__group--zoom {
justify-content: center;
gap: 0.4rem;
}
.sender-image-toolbar__group--actions {
justify-content: center;
gap: 0.4rem;
padding-inline: 0.6rem;
}
.sender-image-toolbar__icon-btn {
width: 2.25rem;
height: 2.25rem;
border: 0;
border-radius: 0.45rem;
background: transparent;
color: #1f2a44;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
}
.sender-image-toolbar__icon-btn:hover:not(:disabled) {
background: #eef3ff;
color: #0f3f99;
}
.sender-image-toolbar__icon-btn:active:not(:disabled) {
transform: translateY(1px);
}
.sender-image-toolbar__icon-btn:focus-visible {
outline: 2px solid #3468d6;
outline-offset: 1px;
}
.sender-image-toolbar__icon-btn:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.sender-image-toolbar__icon-btn--caret {
width: 1.95rem;
}
.sender-image-toolbar__icon-btn svg {
width: 19px;
height: 19px;
}
.sender-image-toolbar__icon-btn--busy svg {
animation: sender-toolbar-spin 0.95s linear infinite;
}
.sender-image-toolbar__select-wrap {
position: relative;
width: 1.95rem;
height: 2.25rem;
}
.sender-image-toolbar__page-select {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
border: 0;
}
.sender-image-toolbar__page-select:disabled {
cursor: not-allowed;
}
.sender-image-toolbar__value-box {
min-width: 7.8rem;
height: 2.25rem;
border: 1px solid #d5dceb;
border-radius: 0.45rem;
background: #f8fafc;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
padding: 0 0.65rem;
color: #233252;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.sender-image-toolbar__value-box:focus-within {
border-color: #7ea2ee;
box-shadow: 0 0 0 2px rgba(126, 162, 238, 0.2);
}
.sender-image-toolbar__page-input {
width: 2.3rem;
border: 0;
outline: none;
background: transparent;
color: inherit;
font-size: inherit;
font-weight: inherit;
text-align: right;
-moz-appearance: textfield;
}
.sender-image-toolbar__page-input::-webkit-outer-spin-button,
.sender-image-toolbar__page-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.sender-image-toolbar__page-total {
color: #5b6c8f;
font-size: 0.68rem;
font-weight: 600;
}
.sender-image-toolbar__zoom-slider {
-webkit-appearance: none;
appearance: none;
width: clamp(130px, 20vw, 190px);
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, #d7e2ff 0%, #84a9ff 100%);
outline: none;
cursor: pointer;
}
.sender-image-toolbar__zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: #2f5fc9;
box-shadow: 0 1px 4px rgba(47, 95, 201, 0.35);
}
.sender-image-toolbar__zoom-slider::-moz-range-thumb {
width: 15px;
height: 15px;
border: 0;
border-radius: 50%;
background: #2f5fc9;
box-shadow: 0 1px 4px rgba(47, 95, 201, 0.35);
}
.sender-image-toolbar__zoom-slider:focus-visible {
outline: 2px solid #3468d6;
outline-offset: 2px;
}
@keyframes sender-toolbar-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.sender-image-editor__pages {
max-width: 1040px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding-top: 5.15rem;
}
.sender-image-editor__native-preview {
max-width: 1040px;
margin: 5.15rem auto 1rem;
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
.sender-image-editor__native-frame {
width: 100%;
min-height: 70vh;
border: 0;
background: #f8fafc;
}
.sender-image-editor__page-block {
background: #e2e8f0;
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 0.75rem;
}
.sender-image-editor__page-canvas {
position: relative;
width: 100%;
aspect-ratio: 595 / 842;
background: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
.sender-image-editor__page-image {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
}
.sender-image-editor__page-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
font-weight: 600;
font-size: 0.95rem;
letter-spacing: 0.01em;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
}
.sender-image-editor__field {
position: absolute;
border: 2px solid;
border-radius: 8px;
font-size: 0.72rem;
font-weight: 700;
color: #0f172a;
padding: 0.2rem 0.3rem;
text-align: left;
line-height: 1.1;
cursor: move;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.35rem;
overflow: hidden;
}
.sender-image-editor__field:active {
cursor: move;
}
.sender-image-editor__field-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sender-image-editor__field-remove {
border: none;
background: rgba(15, 23, 42, 0.78);
color: #ffffff;
width: 1.1rem;
height: 1.1rem;
min-width: 1.1rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
line-height: 1;
margin-left: auto;
flex-shrink: 0;
}
.sender-image-editor__field-remove:hover {
background: rgba(220, 38, 38, 0.9);
}
.sender-image-editor__field:hover {
filter: brightness(0.96);
}
.sender-image-editor__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
color: #64748b;
font-weight: 600;
}
.sender-editor-pdf-viewer {
width: 100%;
height: 100%;
@@ -954,6 +609,29 @@ body.resizing {
cursor: not-allowed;
}
.pdf-toolbar__btn--reject {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: auto;
padding: 0.5rem 0.75rem;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #b91c1c;
}
.pdf-toolbar__btn--reject:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: transparent;
color: #ffffff;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
.pdf-toolbar__btn--reject:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pdf-toolbar__btn-text {
font-size: 0.813rem;
font-weight: 600;
@@ -1387,41 +1065,6 @@ body.resizing {
padding: 0.75rem;
}
.sender-image-toolbar {
grid-template-columns: 1fr;
width: calc(100% - 1rem);
top: 0.5rem;
gap: 0.45rem;
}
.sender-image-toolbar__group {
min-height: 3.15rem;
justify-content: center;
flex-wrap: wrap;
}
.sender-image-editor__pages {
padding-top: 11.25rem;
}
.sender-image-editor__native-preview {
margin-top: 11.25rem;
}
.sender-image-toolbar__value-box {
min-width: 6.7rem;
height: 2rem;
font-size: 0.69rem;
}
.sender-image-toolbar__zoom-slider {
width: min(44vw, 220px);
}
.sender-image-toolbar__group--actions {
justify-content: flex-end;
}
.pdf-thumbnails {
width: 180px;
border-radius: 0 0 0 16px;
@@ -1591,6 +1234,18 @@ body.resizing {
color: #a5b4fc;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(248, 113, 113, 0.4);
color: #fca5a5;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.35);
border-color: rgba(248, 113, 113, 0.6);
color: #ffffff;
}
/* Error container */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);

View File

@@ -1,11 +1,5 @@
window.envelopeEditor = {
_moveThrottle: {
rafId: 0,
latest: null,
waiters: []
},
/**
* Returns the click position normalised to [0,1] relative to the rendered PDF page
* element inside DxPdfViewer (or DxReportViewer as fallback).
@@ -31,10 +25,9 @@ window.envelopeEditor = {
}
// --- Candidate page elements (ordered by preference) ---
// Sender editor custom renderer uses data-editor-page blocks.
// Legacy Dx viewers remain as fallback selectors.
// DxPdfViewer renders individual pages as .dxbl-pdfv-page elements.
// DxReportViewer uses .dxbrv-report-preview-content-img as fallback.
const pageSelectors = [
'[data-editor-page]',
'.dxbl-pdfv-page',
'.dxbrv-report-preview-page',
'.dxbrv-report-preview-content-img',
@@ -96,229 +89,7 @@ window.envelopeEditor = {
return {
normX: normX,
normY: normY,
pageIndex: targetIndex,
pageNumber: parseInt(targetPage.getAttribute('data-editor-page') || String(targetIndex + 1), 10)
pageIndex: targetIndex
};
},
getClickCoordsOnPdfPageThrottled: function (viewerCssClass, clientX, clientY) {
return new Promise((resolve) => {
const state = window.envelopeEditor._moveThrottle;
state.latest = { viewerCssClass, clientX, clientY };
state.waiters.push(resolve);
if (state.rafId !== 0) {
return;
}
state.rafId = window.requestAnimationFrame(() => {
state.rafId = 0;
const args = state.latest;
state.latest = null;
const result = args
? window.envelopeEditor.getClickCoordsOnPdfPage(args.viewerCssClass, args.clientX, args.clientY)
: null;
const waiters = state.waiters.splice(0, state.waiters.length);
for (const waiter of waiters) {
waiter(result);
}
});
});
},
scrollToPdfPage: function (viewerCssClass, pageNumber) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return;
}
const targetPageNumber = Math.max(1, Number(pageNumber) || 1);
const page = viewer.querySelector('[data-editor-page="' + targetPageNumber + '"]');
if (!page) {
return;
}
const viewerRect = viewer.getBoundingClientRect();
const pageRect = page.getBoundingClientRect();
const targetScrollTop = viewer.scrollTop + (pageRect.top - viewerRect.top) - 8;
viewer.scrollTo({
top: Math.max(0, targetScrollTop),
behavior: 'smooth'
});
},
getVisiblePdfPageNumber: function (viewerCssClass) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return null;
}
const pages = Array.from(viewer.querySelectorAll('[data-editor-page]'));
if (pages.length === 0) {
return null;
}
const viewerRect = viewer.getBoundingClientRect();
let bestPage = null;
let bestVisibleHeight = -1;
for (const page of pages) {
const rect = page.getBoundingClientRect();
const visibleTop = Math.max(rect.top, viewerRect.top);
const visibleBottom = Math.min(rect.bottom, viewerRect.bottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
if (visibleHeight > bestVisibleHeight) {
bestVisibleHeight = visibleHeight;
bestPage = page;
}
}
if (!bestPage) {
return null;
}
const raw = bestPage.getAttribute('data-editor-page');
const parsed = Number.parseInt(raw || '0', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
},
scrollPdfViewerByDelta: function (viewerCssClass, deltaY) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return;
}
viewer.scrollTop += Number(deltaY) || 0;
},
ensureZoomOverride: function (viewerCssClass) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return;
}
if (viewer.dataset.zoomOverrideAttached === '1') {
return;
}
viewer.dataset.zoomOverrideAttached = '1';
viewer.addEventListener('wheel', function (event) {
event.preventDefault();
}, { passive: false });
viewer.addEventListener('keydown', function (event) {
if (!event.ctrlKey) {
return;
}
const key = event.key;
if (key === '+' || key === '=' || key === '-' || key === '0') {
event.preventDefault();
}
});
viewer.addEventListener('touchmove', function () {
}, { passive: true });
},
downloadDataUrlAsFile: function (dataUrl, fileName, mimeType) {
if (!dataUrl || typeof dataUrl !== 'string') {
return;
}
const downloadName = fileName || 'document.pdf';
const link = document.createElement('a');
link.href = dataUrl;
link.download = downloadName;
link.rel = 'noopener';
const host = document.body || document.documentElement;
host.appendChild(link);
link.click();
host.removeChild(link);
},
printDataUrlPdf: async function (dataUrl, mimeType) {
if (!dataUrl || typeof dataUrl !== 'string') {
return;
}
let objectUrl = null;
let printSrc = dataUrl;
if (dataUrl.startsWith('data:')) {
try {
const response = await fetch(dataUrl);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
printSrc = objectUrl;
} catch {
printSrc = dataUrl;
}
}
const frame = document.createElement('iframe');
frame.style.position = 'fixed';
frame.style.right = '0';
frame.style.bottom = '0';
frame.style.width = '0';
frame.style.height = '0';
frame.style.border = '0';
frame.setAttribute('aria-hidden', 'true');
const host = document.body || document.documentElement;
host.appendChild(frame);
let cleaned = false;
const cleanup = function () {
if (cleaned) {
return;
}
cleaned = true;
if (frame.parentNode) {
frame.parentNode.removeChild(frame);
}
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
};
frame.onload = function () {
try {
const frameWindow = frame.contentWindow;
if (!frameWindow) {
cleanup();
return;
}
frameWindow.addEventListener('afterprint', function () {
window.setTimeout(cleanup, 250);
}, { once: true });
window.addEventListener('focus', function () {
window.setTimeout(cleanup, 1500);
}, { once: true });
frameWindow.focus();
frameWindow.print();
} catch {
cleanup();
}
};
frame.src = printSrc;
window.setTimeout(cleanup, 120000);
}
};

View File

@@ -41,7 +41,6 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
ProjectSection(SolutionItems) = preProject
EnvelopeGenerator.Server\publish.bat = EnvelopeGenerator.Server\publish.bat
SENDER_SAVE_EDIT_MIGRATION_PLAN.md = SENDER_SAVE_EDIT_MIGRATION_PLAN.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
@@ -52,8 +51,6 @@ Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.Service_l
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.csproj", "{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Infrastructure.Doc", "EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj", "{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -116,10 +113,6 @@ Global
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.Build.0 = Release|Any CPU
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -143,7 +136,6 @@ Global
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}

View File

@@ -1,217 +0,0 @@
# Sender Editor PDF Component Migration Plan
This plan is prepared to remove the DevExpress PDF viewer dependency from `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor` and move to a new WASM-based PDF editor component while preserving the current page design.
## 0) Scope and Non-Negotiable Rules
- **Scope**: Only the PDF editor/viewer area will change; the page action bar, popups, receiver management, and overall visual language must remain intact.
- **Render mode target**: The `/sender/editor` flow will be implemented in `EnvelopeGenerator.Server.Client` and run in WebAssembly.
- **Data compatibility**: The final `CreateEnvelopeCommand` payload shape, field units (`X`, `Y` in inches), receiver-field mapping, and save/send behavior must stay unchanged.
- **To be removed**: `esid` query param + `IMemoryCache`-based editor session persistence will be removed completely.
- **Library constraint**: `pdf.js` and DevExpress PDF viewer will not be used. For PDF rasterization, first verify whether DevExpress provides a suitable capability; if not, use a reliable, popular MIT-licensed alternative.
- **Server-side pattern rule**: All new server-side work must follow the existing architecture; **Clean Architecture layers** and **MediatR pattern** must be preserved.
- **Language rule (code side)**: All code and any code comments must always be in English.
- **Localization rule**: UI text must not be hardcoded; use `IStringLocalizer<Resource>`. For every new key, add German, English, and French translations in `EnvelopeGenerator.Application/Resources/Resource.de-DE.resx`, `EnvelopeGenerator.Application/Resources/Resource.en-US.resx`, and `EnvelopeGenerator.Application/Resources/Resource.fr-FR.resx`.
---
## 1) Current Behavior Inventory (Parity Baseline)
**Goal:** Lock down all behaviors that must not break after migration.
**Tasks**
- List the current editor flow:
- PDF upload,
- receiver add,
- signature field placement,
- field clearing,
- draft save,
- send.
- Clarify current coordinate conversion:
- Viewer click -> PDF points,
- Save payload -> inches (`pt / 72`).
- Capture current validation messages and popup behavior.
- During this inventory phase, continuously add findings into this markdown under the relevant sections so it remains a living document.
**Output**
- A short “parity checklist” is prepared and used throughout development.
---
## 2) Architecture Decisions and Packaging
**Goal:** Define technical boundaries of the new solution.
**Tasks**
- Define the new reusable component:
- Suggested name: `SenderPdfImageEditor`.
- Location: `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/`.
- Define PDF rasterization strategy:
- Convert PDF -> image pages on the server side.
- First confirm whether this conversion is possible with DevExpress.
- If DevExpress does not provide a suitable solution, choose a popular MIT-licensed engine (e.g., `Docnet.Core` / PDFium-based).
- Document license validation and production suitability.
- Draft API contract:
- endpoint(s) returning page images after PDF upload/read,
- page metadata (width/height in pt),
- optional re-raster endpoint for higher zoom levels.
**Output**
- “Component contract + API contract” is finalized.
---
## 3) Copy `/sender/editor` to WASM Page
**Goal:** Run the editor in `Server.Client` using SPA behavior.
**Tasks**
- Create a new page:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/EnvelopeSenderEditorPage.razor`
- Temporary route: `/sender/editor2`
- Keep the existing server-side `/sender/editor` page during development; both pages run in parallel.
- Copy existing toolbar/receiver/popup markup as-is as much as possible.
- Integrate client services (`EnvelopeService`, auth checks, receiver suggestion calls).
**Output**
- WASM page is accessible, old UI layout is preserved, and PDF area works with a placeholder component.
---
## 4) Page-by-Page PDF to Image Conversion Service (With Priority Order)
**Goal:** Convert PDF into page-image list with near-identical visual fidelity.
**Tasks**
- Add new application/service layer:
- PDF bytes input,
- raster output per page (PNG preferred for stable quality/transparency).
- Evaluate client-side (WASM) rasterization feasibility if it is possible and does not hurt performance.
- Priority order:
1. DevExpress feasibility,
2. If not suitable, MIT-licensed alternative,
3. If needed, client-side rasterization option.
- Return metadata:
- page index,
- image URL or base64,
- page width/height (pt),
- native image size in px.
- Performance:
- reasonable initial DPI (e.g., 144),
- lazy/high-res render option for higher zoom levels.
**Output**
- “PDF -> image pages + page metrics” endpoint/service is functional.
---
## 5) New Image-based PDF Viewer Features
**Goal:** Provide viewer UX similar to DevExpress viewer.
**Tasks**
- Implement two display modes in `SenderPdfImageEditor`:
- **Continuous mode**: pages stacked vertically,
- **Single-page mode**: one page with next/prev navigation.
- Add zoom controls:
- zoom in/out,
- fit width,
- fit page.
- Add page navigation:
- page selector (n / total),
- keyboard shortcuts (optional, phase 2).
- Preserve UI consistency:
- keep current sender editor color language and button style.
**Output**
- Image-based viewer is fully functional.
---
## 6) Signature Field Management via DOM Overlay
**Goal:** Manage signature fields in a movable DOM layer instead of burning them into PDF bytes.
**Tasks**
- Place an overlay layer on top of each page image.
- Signature box lifecycle:
- add (by click),
- drag (move),
- remove.
- Normalize all interactions as page-relative coordinates.
- Rendering:
- receiver color,
- receiver name,
- box/badge style close to current placeholder visuals.
- Preserve multi-receiver / multi-field support and receiver-field linkage.
**Output**
- Users can move and delete each signature field independently.
---
## 7) Coordinate and Payload Parity (Critical)
**Goal:** Keep backend data type/unit behavior exactly unchanged.
**Tasks**
- Convert overlay coordinates -> PDF points:
- `xPt = normalizedX * pageWidthPt`
- `yPt = normalizedY * pageHeightPt`
- Keep points -> inches conversion on save/send:
- `X = xPt / 72`, `Y = yPt / 72`.
- Use the same DTO shape in `CreateEnvelopeCommand`.
- Keep existing validation rules unchanged.
**Output**
- Full backend contract parity with the old system.
---
## 8) Remove Session Cache Logic
**Goal:** Fully remove `esid` and memory cache dependency.
**Tasks**
- Remove `SupplyParameterFromQuery(Name = "esid")`.
- Remove `IMemoryCache` injection and `PersistSession` flow.
- Manage editor state using component state + loaded envelope data only.
- Clarify reset behavior after successful save/send.
**Output**
- Session-id-based cache layer is removed completely.
---
## 9) Testing and Acceptance Criteria
**Goal:** Verify migration is complete and safe.
**Critical manual tests**
- PDF upload -> are pages visually accurate?
- Continuous/single-page mode transitions work correctly?
- Overlay boxes keep correct position across zoom levels?
- Add/drag/remove works stably on all pages?
- Draft save and send payload format remains identical?
- In edit mode (existing envelope), fields load and remain editable?
**Definition of done**
- DevExpress PDF viewer is fully removed from the editor.
- Users can edit signature box positions after placing them.
- Backend contract and coordinate units remain unchanged.
---
## 10) Suggested Implementation Sequence (Short Sprint Plan)
1. **WASM page copy + route stabilization**
2. **PDF raster API + renderer integration**
3. **Image viewer (zoom + navigation + modes)**
4. **Overlay signature layer (add/drag/remove)**
5. **Save/Send payload parity verification**
6. **Session/cache removal cleanup**
7. **End-to-end testing and UI polish**
### Implementation Progress Notes
- Localization requirement clarified: all new Sender Editor UI strings must go through `IStringLocalizer<Resource>` with DE/EN/FR resource entries.

View File

@@ -1,86 +0,0 @@
# Sender PDF Toolbar Migration Plan
## Goal
Design and integrate a reusable toolbar component for `SenderPdfImageEditor` that matches the provided visual style and supports:
- page navigation
- zoom in/out (including overriding default mouse-based zoom behavior inside editor)
- PDF download as original bytes (not image)
- print output
Critical rule: existing signature add/remove/drag-drop behavior must remain fully functional.
## Scope and Files
Planned touch points:
1. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditorToolbar.razor` (new)
2. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditorToolbar.razor.cs` (optional, if code-behind needed)
3. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditor.razor` (compose toolbar + editor integration)
4. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor` (pass PDF bytes/metadata/events to editor)
5. `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css` (toolbar visual styles + responsive)
6. `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/envelope-editor.js` (helper functions: scroll-to-page, wheel handling, download, print if needed)
## Non-Regression Strategy
- Keep all existing signature event paths unchanged (`EditorClick`, `MoveField`, `RemoveField`).
- Introduce toolbar as a separate component with explicit parameters and callbacks.
- Add features incrementally; no behavior switch all at once.
- After each stage: run manual black-box checks focused on signatures and new feature.
- If any regression appears: rollback only the stage-local changes.
## Stage Plan
### Stage 1 - Visual Toolbar Skeleton (all buttons disabled)
- Build standalone `SenderPdfImageEditorToolbar` component.
- Recreate layout from reference image (three grouped blocks: page nav, zoom, print/download).
- Render in `SenderPdfImageEditor` above page canvas area.
- All controls disabled; only visual state and responsive behavior implemented.
- No functional binding yet.
Acceptance:
- Toolbar appears and matches structure/spacing/icons closely.
- Signature add/remove/drag still works exactly as before.
### Stage 2 - Page Navigation
- Add current page/total page model and callbacks.
- Implement first/prev/next/last navigation.
- Implement page number selector UI behavior.
- Navigate by scrolling/focusing target page container (`data-editor-page`) without changing signature logic.
Acceptance:
- Navigation controls move to correct page boundaries.
- Signature interactions continue to work on all pages.
### Stage 3 - Zoom In/Out + Mouse Override
- Introduce zoom state (e.g., 50%-300%, step-based).
- Wire `-` / `+` and percent display.
- Apply zoom in a way that preserves coordinate normalization for signature placement/drag.
- Override wheel behavior inside editor container (`@onwheel:preventDefault` + JS helper where needed).
Acceptance:
- Zoom buttons work consistently.
- Mouse wheel behavior follows custom zoom logic.
- Signature placement and drag coordinates remain correct after zoom changes.
### Stage 4 - Download Original PDF Bytes
- Expose download action from toolbar.
- Download source must be original PDF bytes (same content as uploaded/loaded document).
- Suggested filename from current editor state.
Acceptance:
- Downloaded file opens as valid PDF.
- Not an image export; true PDF bytes.
### Stage 5 - Print Output
- Add print action from toolbar.
- Print original PDF content using browser print flow (hidden iframe/object URL approach).
- Ensure action is isolated and does not mutate editor state.
Acceptance:
- Print dialog opens with correct document.
- Existing editor/signature interactions remain intact before and after print.
## Approval Gates
- Stop after each stage.
- Share what changed + exact black-box checklist.
- Wait for explicit approval before moving to next stage.
## Notes
- [ ] Toolbar visual polish is pending (icon sizing/balance/spacing fine-tuning).