Compare commits
60 Commits
feat/serve
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 27c6b017cd | |||
| 8378c33465 | |||
| c7c4e1194d | |||
| 3b367eff7f | |||
| d0c3d79fbc | |||
| e81c7896a1 | |||
| 127e2b253e | |||
| 6d0b78072e | |||
| 3721ea9a1f | |||
| f86588551c | |||
| 0eaa9ff08f | |||
| ec79d7622e | |||
| f558d03bdf | |||
| db79053c62 | |||
| 85dd85aa92 | |||
| c7bc9fbcca | |||
| 1e8e5c96c4 | |||
| 61d04ca51a | |||
| af674a811d | |||
| 123889451f | |||
| 3b5075fe44 | |||
| 48302a374f | |||
| b2c245d6bd | |||
| e9c0ec139a | |||
| 819bdcfcfb | |||
| ad7686b7c2 | |||
| ade542ded0 | |||
| 01c1283622 | |||
| d969dbe843 | |||
| 41779a4f58 | |||
| bf739c1360 | |||
| 0f167d731e | |||
| 021d7cfe29 | |||
| e2322b41cd | |||
| 618451f0c1 | |||
| 8386b85592 | |||
| 640f0b95fe | |||
| 181f7f4dec | |||
| 95961a296f | |||
| db613358d5 | |||
| 7a126050cf | |||
| 7081c5f64e | |||
| 12ec5b28fa | |||
| 925e60a45c | |||
| b0b9ba7212 | |||
| 33c75ea7e1 | |||
| 513efa0833 | |||
| 0c81d79a9b | |||
| 696cd1b623 | |||
| 2434f67627 | |||
| 776700b247 | |||
| 7a114f47a6 | |||
| ed44d841e7 | |||
| 4bd0cc2e94 | |||
| 8a4963472c | |||
| 4d3f72a3ed | |||
| 1ebd43e3b0 | |||
| 19aeda5aef | |||
| 940d129c11 | |||
| 44bbdbb835 |
10
AGENTS.md
10
AGENTS.md
@@ -241,6 +241,16 @@ Manual testing workflow:
|
||||
- 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.
|
||||
- Apply a modular page architecture across Server pages (starting with `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`): split large pages into smaller subcomponents/partial modules, eliminate duplicated code paths, and centralize shared UI/domain logic for maintainability.
|
||||
- Perform a senior-level page performance review across sender/receiver flows before further UX changes: verify route transitions, component mount/unmount behavior, loading-state correctness, duplicate API calls, and perceived latency (first meaningful paint and return-navigation responsiveness). Treat loading logic defects as architecture/performance issues first, not styling problems.
|
||||
- Audit `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/` for unnecessary request/payload remapping and remove redundant mappings where direct request DTO usage is safe; review client HTTP methods end-to-end for similar avoidable transformation layers.
|
||||
- Centralize dependency injection registrations into dedicated extension methods/modules (especially server + client service registrations in `Program.cs`) to reduce startup composition sprawl and improve maintainability.
|
||||
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfRenderController.cs` + matching client service contract to remove JSON/base64 `byte[]` transport for PDF rendering; prefer binary streaming (`application/octet-stream` or multipart), avoid `data:` URL bloat where possible, and validate end-to-end memory/latency behavior with large PDFs.
|
||||
- Establish a DevExpress toast-based notification architecture using `DxToastProvider` + `IToastNotificationService` (tab/page-scoped provider strategy, standardized `ToastOptions` factory, and severity-to-style mapping) so sender/receiver flows show consistent, non-duplicated, actionable feedback.
|
||||
- Eliminate manual property-by-property request→domain mapping drift (example: `CreateEnvelopeCommandHandler`) by introducing a standardized mapping policy (AutoMapper/profile conventions or reflection/source-generator based mapper) and enforcing it across envelope workflows; this needs to be solved in the shared company Core library level so all services get the same guardrails by default.
|
||||
- P1 security workstream: remove dynamic SQL string composition/interpolation from active save/create/update paths (`string.Format`, `$"...{input}..."`, `ToSqlParam`) and migrate to parameterized queries only; treat this as a potential SQL injection vulnerability (stability + security risk), execute repo-wide audit, and add CI guardrails to block reintroduction.
|
||||
- Alternative UX design (Server component): in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`, enforce deterministic row double-click behavior as exactly one action (preview OR edit), and keep row action buttons/tooltips aligned with the same rule to eliminate ambiguous navigation.
|
||||
- Implement SPA-style language switching so the UI culture updates immediately without a full page reload.
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using DigitalData.Core.Exceptions;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Behaviors;
|
||||
|
||||
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
|
||||
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
||||
{
|
||||
_validators = validators;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_validators.Any())
|
||||
{
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
var failures = _validators
|
||||
.Select(v => v.Validate(context))
|
||||
.SelectMany(r => r.Errors)
|
||||
.Where(f => f is not null)
|
||||
.ToList();
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
var message = string.Join("; ", failures.Select(f => f.ErrorMessage).Distinct());
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
|
||||
return await next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object representing document metadata and elements without binary payload.
|
||||
/// </summary>
|
||||
public record DocumentDetailsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the document.
|
||||
/// </summary>
|
||||
public int Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the envelope ID to which the document belongs.
|
||||
/// </summary>
|
||||
public int EnvelopeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date and time when the document was added.
|
||||
/// </summary>
|
||||
public DateTime AddedWhen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of elements associated with the document for receiver interactions, if any.
|
||||
/// </summary>
|
||||
public IEnumerable<DocReceiverElementDto>? Elements { get; init; }
|
||||
}
|
||||
@@ -3,30 +3,25 @@
|
||||
/// <summary>
|
||||
/// Data Transfer Object representing a document within an envelope, including optional binary data and form elements.
|
||||
/// </summary>
|
||||
public class DocumentDto
|
||||
public record DocumentDto : DocumentDetailsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the document.
|
||||
///
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
public DocumentDto() { }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the envelope ID to which the document belongs.
|
||||
///
|
||||
/// </summary>
|
||||
public int EnvelopeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date and time when the document was added.
|
||||
/// </summary>
|
||||
public DateTime AddedWhen { get; set; }
|
||||
/// <param name="byteData"></param>
|
||||
/// <param name="details"></param>
|
||||
public DocumentDto(byte[] byteData, DocumentDetailsDto details) : base(details)
|
||||
{
|
||||
ByteData = byteData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the binary data of the document, if available.
|
||||
/// </summary>
|
||||
public byte[]? ByteData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of elements associated with the document for receiver interactions, if any.
|
||||
/// </summary>
|
||||
public IEnumerable<DocReceiverElementDto>? Elements { get; set; }
|
||||
public byte[]? ByteData { get; init; }
|
||||
}
|
||||
@@ -86,6 +86,41 @@ public record EnvelopeDto : IEnvelope
|
||||
/// </summary>
|
||||
public int? CertificationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? FinalEmailToCreator { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? FinalEmailToReceivers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public bool? SendReminderEmails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? FirstReminderDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? ReminderIntervalDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? ExpiresWhenDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? ExpiresWarningWhenDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
|
||||
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
public sealed class PdfRenderRequestDto
|
||||
{
|
||||
public byte[]? PdfBytes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
|
||||
public interface IPdfPageImageRenderer
|
||||
{
|
||||
Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ public class MappingProfile : Profile
|
||||
.ForMember(dest => dest.EnvelopeTypeTitle,
|
||||
opt => opt.MapFrom(src => src.Type != null ? src.Type.Title : null));
|
||||
CreateMap<Document, DocumentDto>();
|
||||
CreateMap<Document, DocumentDetailsDto>();
|
||||
CreateMap<History, HistoryDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
|
||||
CreateMap<History, HistoryCreateDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
|
||||
CreateMap<EnvelopeReceiver, EnvelopeReceiverDto>();
|
||||
|
||||
@@ -10,6 +10,8 @@ using System.Reflection;
|
||||
using MediatR;
|
||||
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
|
||||
using EnvelopeGenerator.Application.DocReceiverElements.Behaviors;
|
||||
using EnvelopeGenerator.Application.Common.Behaviors;
|
||||
using FluentValidation;
|
||||
|
||||
namespace EnvelopeGenerator.Application;
|
||||
|
||||
@@ -60,6 +62,9 @@ public static class DependencyInjection
|
||||
{
|
||||
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
|
||||
|
||||
// Generic request validation
|
||||
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||
|
||||
// Register SignCommand pipeline behaviors in execution order
|
||||
// 0. EnvelopeReceiverResolutionBehavior - Resolves EnvelopeReceiver from query parameters (executes FIRST)
|
||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
|
||||
@@ -80,6 +85,8 @@ public static class DependencyInjection
|
||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
|
||||
});
|
||||
|
||||
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Documents.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a query to read document metadata/details without binary payload.
|
||||
/// </summary>
|
||||
/// <param name="EnvelopeId">The identifier of the envelope associated with the document.</param>
|
||||
/// <param name="UserId">The unique identifier of the sending user. Optional.</param>
|
||||
public record ReadDocumentDetailsQuery(int EnvelopeId, int? UserId = null) : IRequest<DocumentDetailsDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// Handles queries for reading document details without loading BYTE_DATA.
|
||||
/// </summary>
|
||||
public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetailsQuery, DocumentDetailsDto?>
|
||||
{
|
||||
private readonly IRepository<Document> _repo;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo, IMapper mapper)
|
||||
{
|
||||
_repo = repo;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<DocumentDetailsDto?> Handle(ReadDocumentDetailsQuery query, CancellationToken cancel)
|
||||
{
|
||||
var docQuery = _repo.Query.AsNoTracking();
|
||||
|
||||
if (query.UserId is int userId)
|
||||
docQuery = docQuery.Where(doc => doc.Envelope.UserId == userId);
|
||||
|
||||
return await docQuery
|
||||
.Where(d => d.EnvelopeId == query.EnvelopeId)
|
||||
.ProjectTo<DocumentDetailsDto>(_mapper.ConfigurationProvider)
|
||||
.FirstOrDefaultAsync(cancel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Documents.Queries;
|
||||
|
||||
public class ReadDocumentDetailsQueryValidator : AbstractValidator<ReadDocumentDetailsQuery>
|
||||
{
|
||||
public ReadDocumentDetailsQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.EnvelopeId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("EnvelopeId is required.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Documents.Queries;
|
||||
|
||||
public class ReadDocumentQueryValidator : AbstractValidator<ReadDocumentQuery>
|
||||
{
|
||||
public ReadDocumentQueryValidator()
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.Must(x => (x.Id ?? 0) > 0 || (x.EnvelopeId ?? 0) > 0)
|
||||
.WithMessage("Id or EnvelopeId is required.");
|
||||
|
||||
RuleFor(x => x.Id)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.Id.HasValue)
|
||||
.WithMessage("Id must be greater than 0.");
|
||||
|
||||
RuleFor(x => x.EnvelopeId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.EnvelopeId.HasValue)
|
||||
.WithMessage("EnvelopeId must be greater than 0.");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
<PackageReference Include="DigitalData.Core.Client" Version="2.1.0" />
|
||||
<PackageReference Include="DigitalData.Core.Exceptions" Version="1.1.0" />
|
||||
<PackageReference Include="DigitalData.EmailProfilerDispatcher" Version="3.1.1" />
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using MediatR;
|
||||
using AutoMapper;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -18,17 +17,14 @@ public record ReadEnvelopeTypesQuery : IRequest<IEnumerable<EnvelopeTypeDto>>;
|
||||
public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQuery, IEnumerable<EnvelopeTypeDto>>
|
||||
{
|
||||
private readonly IRepository<EnvelopeType> _repository;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="repository"></param>
|
||||
/// <param name="mapper"></param>
|
||||
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository, IMapper mapper)
|
||||
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository)
|
||||
{
|
||||
_repository = repository;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -39,7 +35,28 @@ public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQu
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<EnvelopeTypeDto>> Handle(ReadEnvelopeTypesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var types = await _repository.Query.ToListAsync(cancellationToken);
|
||||
return _mapper.Map<IEnumerable<EnvelopeTypeDto>>(types);
|
||||
// TODO: Temporary manual projection to avoid null-materialization crashes from legacy nullable DB columns in EnvelopeType.
|
||||
// Revisit and switch to AutoMapper query projection (ProjectTo) after schema nullability and mapping contracts are stabilized.
|
||||
return await _repository.Query
|
||||
.AsNoTracking()
|
||||
.Select(t => new EnvelopeTypeDto
|
||||
{
|
||||
Id = t.Id,
|
||||
Title = t.Title ?? string.Empty,
|
||||
Language = string.IsNullOrWhiteSpace(t.Language) ? "de-DE" : t.Language,
|
||||
ExpiresDays = t.ExpiresDays,
|
||||
CertificationType = (int?)t.CertificationType,
|
||||
UseAccessCode = t.UseAccessCode,
|
||||
FinalEmailToCreator = (int?)t.FinalEmailToCreator,
|
||||
FinalEmailToReceivers = (int?)t.FinalEmailToReceivers,
|
||||
AddedWhen = t.AddedWhen,
|
||||
ChangedWhen = t.ChangedWhen,
|
||||
ExpiresWarningDays = t.ExpiresWarningDays,
|
||||
SendReminderEmails = t.SendReminderEmails,
|
||||
FirstReminderDays = t.FirstReminderDays,
|
||||
ReminderIntervalDays = t.ReminderIntervalDays,
|
||||
ContractType = t.ContractType
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,66 @@ namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
||||
/// </summary>
|
||||
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// Optionale Umschlag-ID fuer Aktualisierungsvorgaenge.
|
||||
/// </summary>
|
||||
public int? EnvelopeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Kennung des ausgewaehlten Umschlagtyps (Vorlage).
|
||||
/// </summary>
|
||||
public int? EnvelopeTypeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sprachcode fuer Umschlaginhalt und Benachrichtigungen.
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gibt an, ob der Zugriffscode fuer Empfaenger aktiviert ist.
|
||||
/// </summary>
|
||||
public bool? UseAccessCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Zertifizierungstyp fuer den Signaturprozess.
|
||||
/// </summary>
|
||||
public int? CertificationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Finale E-Mail-Einstellung fuer den Ersteller.
|
||||
/// </summary>
|
||||
public int? FinalEmailToCreator { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Finale E-Mail-Einstellung fuer die Empfaenger.
|
||||
/// </summary>
|
||||
public int? FinalEmailToReceivers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gibt an, ob Erinnerungs-E-Mails aktiviert sind.
|
||||
/// </summary>
|
||||
public bool? SendReminderEmails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Anzahl der Tage bis zur ersten Erinnerung.
|
||||
/// </summary>
|
||||
public int? FirstReminderDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Intervall in Tagen zwischen Erinnerungs-E-Mails.
|
||||
/// </summary>
|
||||
public int? ReminderIntervalDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Anzahl der Tage bis zum Ablauf des Umschlags.
|
||||
/// </summary>
|
||||
public int? ExpiresWhenDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Anzahl der Tage vor Ablauf, ab denen gewarnt wird.
|
||||
/// </summary>
|
||||
public int? ExpiresWarningWhenDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
|
||||
/// </summary>
|
||||
@@ -29,10 +87,19 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||
/// </summary>
|
||||
public bool TFAEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gibt an, ob der Umschlag nach dem Speichern sofort versendet werden soll.
|
||||
/// </summary>
|
||||
public bool Send { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optionaler PDF-Dokumentinhalt als Base64.
|
||||
/// </summary>
|
||||
public CreateEnvelopeDocumentDto? Document { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Empfaengerliste inklusive optionaler Signaturfeld-Koordinaten.
|
||||
/// </summary>
|
||||
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -33,6 +33,8 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
|
||||
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
|
||||
|
||||
private IRepository<EnvelopeType> EnvelopeTypeRepository => _provider.GetRequiredService<IRepository<EnvelopeType>>();
|
||||
|
||||
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
|
||||
|
||||
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
|
||||
@@ -73,6 +75,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
var now = DateTime.Now;
|
||||
var title = request.Title?.Trim() ?? string.Empty;
|
||||
var message = request.Message?.Trim() ?? string.Empty;
|
||||
var settings = await ResolveEnvelopeSettingsAsync(request, existing: null, cancel);
|
||||
|
||||
Envelope envelope;
|
||||
if (request.EnvelopeId is int envelopeId)
|
||||
@@ -85,11 +88,27 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
if (envelope.UserId != request.UserId)
|
||||
throw new ForbiddenException();
|
||||
|
||||
settings = await ResolveEnvelopeSettingsAsync(request, envelope, cancel);
|
||||
|
||||
await Repository.UpdateAsync(e =>
|
||||
{
|
||||
e.Title = title;
|
||||
e.Message = message;
|
||||
e.TfaEnabled = request.TFAEnabled;
|
||||
e.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||
e.Language = settings.Language;
|
||||
e.UseAccessCode = settings.UseAccessCode;
|
||||
e.CertificationType = settings.CertificationType;
|
||||
e.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||
e.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||
e.SendReminderEmails = settings.SendReminderEmails;
|
||||
e.FirstReminderDays = settings.FirstReminderDays;
|
||||
e.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||
e.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||
e.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||
e.ContractType = settings.ContractType;
|
||||
e.ExpiresWhen = settings.ExpiresWhen;
|
||||
e.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||
e.Status = EnvelopeStatus.EnvelopeSaved;
|
||||
e.ChangedWhen = now;
|
||||
}, e => e.Id == envelopeId, cancel);
|
||||
@@ -97,6 +116,20 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
envelope.Title = title;
|
||||
envelope.Message = message;
|
||||
envelope.TfaEnabled = request.TFAEnabled;
|
||||
envelope.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||
envelope.Language = settings.Language;
|
||||
envelope.UseAccessCode = settings.UseAccessCode;
|
||||
envelope.CertificationType = settings.CertificationType;
|
||||
envelope.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||
envelope.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||
envelope.SendReminderEmails = settings.SendReminderEmails;
|
||||
envelope.FirstReminderDays = settings.FirstReminderDays;
|
||||
envelope.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||
envelope.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||
envelope.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||
envelope.ContractType = settings.ContractType;
|
||||
envelope.ExpiresWhen = settings.ExpiresWhen;
|
||||
envelope.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||
envelope.Status = EnvelopeStatus.EnvelopeSaved;
|
||||
envelope.ChangedWhen = now;
|
||||
}
|
||||
@@ -114,12 +147,69 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
AddedWhen = now,
|
||||
ChangedWhen = now,
|
||||
Comment = string.Empty,
|
||||
Language = "de-DE",
|
||||
EnvelopeTypeId = settings.EnvelopeTypeId,
|
||||
Language = settings.Language,
|
||||
TfaEnabled = request.TFAEnabled,
|
||||
UseAccessCode = false,
|
||||
SendReminderEmails = false
|
||||
UseAccessCode = settings.UseAccessCode,
|
||||
CertificationType = settings.CertificationType,
|
||||
FinalEmailToCreator = settings.FinalEmailToCreator,
|
||||
FinalEmailToReceivers = settings.FinalEmailToReceivers,
|
||||
SendReminderEmails = settings.SendReminderEmails,
|
||||
FirstReminderDays = settings.FirstReminderDays,
|
||||
ReminderIntervalDays = settings.ReminderIntervalDays,
|
||||
ExpiresWhenDays = settings.ExpiresWhenDays,
|
||||
ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays,
|
||||
ExpiresWhen = settings.ExpiresWhen,
|
||||
ExpiresWarningWhen = settings.ExpiresWarningWhen,
|
||||
ContractType = settings.ContractType
|
||||
}, cancel);
|
||||
|
||||
if (request.UseSQLExecutor)
|
||||
{
|
||||
await Repository.UpdateAsync(e =>
|
||||
{
|
||||
e.Title = title;
|
||||
e.Message = message;
|
||||
e.TfaEnabled = request.TFAEnabled;
|
||||
e.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||
e.Language = settings.Language;
|
||||
e.UseAccessCode = settings.UseAccessCode;
|
||||
e.CertificationType = settings.CertificationType;
|
||||
e.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||
e.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||
e.SendReminderEmails = settings.SendReminderEmails;
|
||||
e.FirstReminderDays = settings.FirstReminderDays;
|
||||
e.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||
e.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||
e.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||
e.ExpiresWhen = settings.ExpiresWhen;
|
||||
e.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||
e.ContractType = settings.ContractType;
|
||||
e.Status = EnvelopeStatus.EnvelopeSaved;
|
||||
e.ChangedWhen = now;
|
||||
}, e => e.Id == envelope.Id, cancel);
|
||||
|
||||
envelope.Title = title;
|
||||
envelope.Message = message;
|
||||
envelope.TfaEnabled = request.TFAEnabled;
|
||||
envelope.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||
envelope.Language = settings.Language;
|
||||
envelope.UseAccessCode = settings.UseAccessCode;
|
||||
envelope.CertificationType = settings.CertificationType;
|
||||
envelope.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||
envelope.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||
envelope.SendReminderEmails = settings.SendReminderEmails;
|
||||
envelope.FirstReminderDays = settings.FirstReminderDays;
|
||||
envelope.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||
envelope.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||
envelope.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||
envelope.ExpiresWhen = settings.ExpiresWhen;
|
||||
envelope.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||
envelope.ContractType = settings.ContractType;
|
||||
envelope.Status = EnvelopeStatus.EnvelopeSaved;
|
||||
envelope.ChangedWhen = now;
|
||||
}
|
||||
|
||||
await Mediator.Send(new CreateHistoryCommand
|
||||
{
|
||||
EnvelopeId = envelope.Id,
|
||||
@@ -143,6 +233,105 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
return _mapper.Map<EnvelopeDto>(envelope);
|
||||
}
|
||||
|
||||
private async Task<EnvelopeSettings> ResolveEnvelopeSettingsAsync(CreateEnvelopeCommand request, Envelope? existing, CancellationToken cancel)
|
||||
{
|
||||
EnvelopeType? selectedType = null;
|
||||
if (request.EnvelopeTypeId is int envelopeTypeId && envelopeTypeId > 0)
|
||||
{
|
||||
selectedType = await EnvelopeTypeRepository.Query
|
||||
.FirstOrDefaultAsync(t => t.Id == envelopeTypeId, cancel)
|
||||
?? throw new BadRequestException($"Envelope type {envelopeTypeId} not found.");
|
||||
}
|
||||
|
||||
var expiresWhenDays = Math.Max(0,
|
||||
request.ExpiresWhenDays
|
||||
?? selectedType?.ExpiresWhenDays
|
||||
?? existing?.ExpiresWhenDays
|
||||
?? 0);
|
||||
|
||||
var expiresWarningWhenDays = Math.Max(0,
|
||||
request.ExpiresWarningWhenDays
|
||||
?? selectedType?.ExpiresWarningWhenDays
|
||||
?? existing?.ExpiresWarningWhenDays
|
||||
?? 0);
|
||||
|
||||
var useAccessCode = request.UseAccessCode
|
||||
?? selectedType?.UseAccessCode
|
||||
?? existing?.UseAccessCode
|
||||
?? false;
|
||||
|
||||
if (request.TFAEnabled)
|
||||
useAccessCode = true;
|
||||
|
||||
return new EnvelopeSettings(
|
||||
EnvelopeTypeId: request.EnvelopeTypeId ?? existing?.EnvelopeTypeId,
|
||||
Language: ResolveLanguage(request.Language, selectedType?.Language, existing?.Language),
|
||||
UseAccessCode: useAccessCode,
|
||||
CertificationType: request.CertificationType
|
||||
?? (int?)selectedType?.CertificationType
|
||||
?? existing?.CertificationType
|
||||
?? (int)CertificationType.AdvancedElectronicSignature,
|
||||
FinalEmailToCreator: request.FinalEmailToCreator
|
||||
?? (int?)selectedType?.FinalEmailToCreator
|
||||
?? existing?.FinalEmailToCreator
|
||||
?? (int)FinalEmailType.No,
|
||||
FinalEmailToReceivers: request.FinalEmailToReceivers
|
||||
?? (int?)selectedType?.FinalEmailToReceivers
|
||||
?? existing?.FinalEmailToReceivers
|
||||
?? (int)FinalEmailType.No,
|
||||
SendReminderEmails: request.SendReminderEmails
|
||||
?? selectedType?.SendReminderEmails
|
||||
?? existing?.SendReminderEmails
|
||||
?? false,
|
||||
FirstReminderDays: Math.Max(0,
|
||||
request.FirstReminderDays
|
||||
?? selectedType?.FirstReminderDays
|
||||
?? existing?.FirstReminderDays
|
||||
?? 0),
|
||||
ReminderIntervalDays: Math.Max(0,
|
||||
request.ReminderIntervalDays
|
||||
?? selectedType?.ReminderIntervalDays
|
||||
?? existing?.ReminderIntervalDays
|
||||
?? 0),
|
||||
ExpiresWhenDays: expiresWhenDays,
|
||||
ExpiresWarningWhenDays: expiresWarningWhenDays,
|
||||
ExpiresWhen: expiresWhenDays > 0 ? DateTime.Now.AddDays(expiresWhenDays) : null,
|
||||
ExpiresWarningWhen: expiresWarningWhenDays > 0 ? DateTime.Now.AddDays(expiresWarningWhenDays) : null,
|
||||
ContractType: existing?.ContractType
|
||||
?? selectedType?.ContractType
|
||||
?? (int)ContractType.Contract);
|
||||
}
|
||||
|
||||
private static string ResolveLanguage(string? preferred, string? fallback1, string? fallback2)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(preferred))
|
||||
return preferred.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallback1))
|
||||
return fallback1.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallback2))
|
||||
return fallback2.Trim();
|
||||
|
||||
return "de-DE";
|
||||
}
|
||||
|
||||
private sealed record EnvelopeSettings(
|
||||
int? EnvelopeTypeId,
|
||||
string Language,
|
||||
bool UseAccessCode,
|
||||
int CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
int FirstReminderDays,
|
||||
int ReminderIntervalDays,
|
||||
int ExpiresWhenDays,
|
||||
int ExpiresWarningWhenDays,
|
||||
DateTime? ExpiresWhen,
|
||||
DateTime? ExpiresWarningWhen,
|
||||
int ContractType);
|
||||
|
||||
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(envelope.User?.Email))
|
||||
|
||||
@@ -735,7 +735,9 @@
|
||||
<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>
|
||||
|
||||
@@ -735,7 +735,9 @@
|
||||
<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>
|
||||
|
||||
@@ -735,7 +735,9 @@
|
||||
<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>
|
||||
|
||||
@@ -22,41 +22,41 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
public string Language { get; set; }
|
||||
|
||||
[Column("EXPIRES_DAYS")]
|
||||
public int ExpiresDays { get; set; }
|
||||
public int? ExpiresDays { get; set; }
|
||||
|
||||
[Column("CERTIFICATION_TYPE")]
|
||||
public Constants.CertificationType CertificationType { get; set; } = Constants.CertificationType.AdvancedElectronicSignature;
|
||||
public Constants.CertificationType? CertificationType { get; set; }
|
||||
|
||||
[Column("USE_ACCESS_CODE")]
|
||||
public bool UseAccessCode { get; set; }
|
||||
public bool? UseAccessCode { get; set; }
|
||||
|
||||
[Column("FINAL_EMAIL_TO_CREATOR")]
|
||||
public Constants.FinalEmailType FinalEmailToCreator { get; set; }
|
||||
public Constants.FinalEmailType? FinalEmailToCreator { get; set; }
|
||||
|
||||
[Column("FINAL_EMAIL_TO_RECEIVERS")]
|
||||
public Constants.FinalEmailType FinalEmailToReceivers { get; set; }
|
||||
public Constants.FinalEmailType? FinalEmailToReceivers { get; set; }
|
||||
|
||||
[Required]
|
||||
[Column("ADDED_WHEN", TypeName = "datetime")]
|
||||
public DateTime AddedWhen { get; set; }
|
||||
|
||||
[Column("CHANGED_WHEN", TypeName = "datetime")]
|
||||
public DateTime ChangedWhen { get; set; }
|
||||
public DateTime? ChangedWhen { get; set; }
|
||||
|
||||
[Column("EXPIRES_WARNING_DAYS")]
|
||||
public int ExpiresWarningDays { get; set; }
|
||||
public int? ExpiresWarningDays { get; set; }
|
||||
|
||||
[Column("SEND_REMINDER_EMAILS")]
|
||||
public bool SendReminderEmails { get; set; } = false;
|
||||
public bool? SendReminderEmails { get; set; }
|
||||
|
||||
[Column("FIRST_REMINDER_DAYS")]
|
||||
public int FirstReminderDays { get; set; } = 0;
|
||||
public int? FirstReminderDays { get; set; }
|
||||
|
||||
[Column("REMINDER_INTERVAL_DAYS")]
|
||||
public int ReminderIntervalDays { get; set; } = 0;
|
||||
public int? ReminderIntervalDays { get; set; }
|
||||
|
||||
[Column("CONTRACT_TYPE")]
|
||||
public int ContractType { get; set; }
|
||||
public int? ContractType { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public bool TFA_Enabled { get; set; } = false;
|
||||
@@ -70,14 +70,14 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
[NotMapped]
|
||||
public int ExpiresWhenDays
|
||||
{
|
||||
get => ExpiresDays;
|
||||
get => ExpiresDays ?? 0;
|
||||
set => ExpiresDays = value;
|
||||
}
|
||||
|
||||
[NotMapped]
|
||||
public int ExpiresWarningWhenDays
|
||||
{
|
||||
get => ExpiresWarningDays;
|
||||
get => ExpiresWarningDays ?? 0;
|
||||
set => ExpiresWarningDays = value;
|
||||
}
|
||||
|
||||
|
||||
18
EnvelopeGenerator.Infrastructure.Doc/DependencyInjection.cs
Normal file
18
EnvelopeGenerator.Infrastructure.Doc/DependencyInjection.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Doc;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddDocInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IPdfPageImageRenderer, PdfPageImageRenderer>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
57
EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs
Normal file
57
EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
|
||||
|
||||
public sealed record SenderPdfFieldMoveRequest(
|
||||
int Index,
|
||||
int Page,
|
||||
double XPt,
|
||||
double YPt);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
|
||||
|
||||
public sealed record SenderPdfFieldOverlay(
|
||||
int Index,
|
||||
int Page,
|
||||
double XPt,
|
||||
double YPt,
|
||||
string ReceiverName,
|
||||
string Color);
|
||||
@@ -0,0 +1,458 @@
|
||||
@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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<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();
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@
|
||||
|
||||
if (result == EnvelopeLoginResult.Success)
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
var result = await AuthService.LoginSenderAsync(Username.Trim(), Password.Trim());
|
||||
|
||||
if (result == SenderLoginResult.Success) {
|
||||
Navigation.NavigateTo("/sender", forceLoad: true);
|
||||
Navigation.NavigateTo("/sender", forceLoad: false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
@page "/sender/editor"
|
||||
@rendermode InteractiveServer
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
@rendermode InteractiveWebAssembly
|
||||
@using DevExpress.Blazor.Reporting.Models
|
||||
@using DevExpress.Blazor
|
||||
@using EnvelopeGenerator.Application.Common.Dto
|
||||
@using EnvelopeGenerator.Application.Envelopes.Commands
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Server.Services
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.Extensions.Caching.Memory
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AppVersionService AppVersion
|
||||
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
||||
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
||||
@inject ILogger<SenderEditorPage> Logger
|
||||
@inject DocumentService DocumentService
|
||||
@inject PdfRenderService PdfRenderService
|
||||
@inject ReceiverService ReceiverService
|
||||
@inject EnvelopeService EnvelopeService
|
||||
@inject IEnvelopeAuthService EnvelopeAuthService
|
||||
@inject IMemoryCache MemoryCache
|
||||
@inject EnvelopeTypeService EnvelopeTypeService
|
||||
@inject AuthService AuthService
|
||||
@inject IStringLocalizer<Resource> UiLocalizer
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
@@ -96,6 +97,107 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<details style="border:1px solid #dbe5ef;border-radius:8px;background:#f9fbfd;">
|
||||
<summary style="cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;padding:0.55rem 0.7rem;font-size:0.74rem;font-weight:700;color:#1f2937;text-transform:uppercase;letter-spacing:0.04em;">
|
||||
<span>@L("SenderEditor_EnvelopeSettings", "Envelope-Einstellungen")</span>
|
||||
<span style="font-size:0.66rem;color:#64748b;font-weight:600;">@L("SenderEditor_AdvancedOptional", "Erweitert")</span>
|
||||
</summary>
|
||||
|
||||
<div style="padding:0 0.7rem 0.7rem;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0.45rem;">
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("EnvelopeType", "Umschlagtyp")</label>
|
||||
<select value="@(_selectedEnvelopeTypeId?.ToString() ?? string.Empty)"
|
||||
@onchange="OnEnvelopeTypeChanged"
|
||||
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
|
||||
<option value="">@L("SenderEditor_SelectTemplate", "Vorlage auswählen")</option>
|
||||
@foreach (var type in _envelopeTypes)
|
||||
{
|
||||
<option value="@type.Id">@type.Title</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("Language", "Sprache")</label>
|
||||
<select @bind="_language"
|
||||
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
|
||||
<option value="de">Deutsch (de)</option>
|
||||
<option value="en">English (en)</option>
|
||||
<option value="de-DE">Deutsch (de-DE)</option>
|
||||
<option value="en-US">English (en-US)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("CertificationType", "Zertifizierung")</label>
|
||||
<select @bind="_certificationType"
|
||||
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
|
||||
<option value="1">@L("AdvancedElectronicSignature", "Advanced Electronic Signature")</option>
|
||||
<option value="2">@L("ElectronicSignature", "Electronic Signature")</option>
|
||||
<option value="3">@L("QualifiedSignature", "Qualified Signature")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.3rem;justify-content:flex-end;padding-bottom:0.25rem;">
|
||||
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
|
||||
<input type="checkbox" checked="@_tfaEnabled" @onchange="OnTfaChanged" />
|
||||
@L("TFAEnabled", "2FA aktiviert")
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
|
||||
<input type="checkbox" checked="@_useAccessCode" @onchange="OnUseAccessCodeChanged" disabled="@_tfaEnabled" />
|
||||
@L("UseAccessCode", "Zugriffscode verwenden")
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FinalEmailToCreator", "Finale E-Mail an Ersteller")</label>
|
||||
<select @bind="_finalEmailToCreator"
|
||||
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
|
||||
<option value="0">@L("No", "Nein")</option>
|
||||
<option value="1">@L("Yes", "Ja")</option>
|
||||
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FinalEmailToReceivers", "Finale E-Mail an Empfänger")</label>
|
||||
<select @bind="_finalEmailToReceivers"
|
||||
style="font-size:0.78rem;border:1px solid #d1d5db;border-radius:5px;padding:0.3rem 0.45rem;background:#fff;">
|
||||
<option value="0">@L("No", "Nein")</option>
|
||||
<option value="1">@L("Yes", "Ja")</option>
|
||||
<option value="2">@L("YesWithAttachment", "Ja, mit Anhang")</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.3rem;justify-content:flex-end;padding-bottom:0.25rem;">
|
||||
<label style="display:flex;align-items:center;gap:0.45rem;font-size:0.74rem;color:#1f2937;">
|
||||
<input type="checkbox" checked="@_sendReminderEmails" @onchange="OnSendReminderEmailsChanged" />
|
||||
@L("SendReminderEmails", "Erinnerungs-E-Mails senden")
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("FirstReminderDays", "Erste Erinnerung (Tage)")</label>
|
||||
<InputNumber @bind-Value="_firstReminderDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ReminderIntervalDays", "Erinnerungsintervall (Tage)")</label>
|
||||
<InputNumber @bind-Value="_reminderIntervalDays" class="form-control form-control-sm" disabled="@(!_sendReminderEmails)" />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ExpiresWhenDays", "Läuft ab nach (Tage)")</label>
|
||||
<InputNumber @bind-Value="_expiresWhenDays" class="form-control form-control-sm" />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0.2rem;">
|
||||
<label style="font-size:0.65rem;font-weight:600;color:#64748b;">@L("ExpiresWarningWhenDays", "Ablaufwarnung (Tage)")</label>
|
||||
<InputNumber @bind-Value="_expiresWarningWhenDays" class="form-control form-control-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="sender-receivers-panel">
|
||||
<div class="sender-receivers-panel__header">
|
||||
<div class="sender-receivers-panel__title-wrap">
|
||||
@@ -164,14 +266,39 @@
|
||||
@* PDF Upload *@
|
||||
<label class="pdf-toolbar__btn"
|
||||
title='@L("UploadPdf", "PDF hochladen")'
|
||||
style="cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; font-weight: 500;">
|
||||
style="cursor: @((_isPageReady && !_isPdfUploadInProgress) ? "pointer" : "not-allowed");
|
||||
opacity:@((_isPageReady && !_isPdfUploadInProgress) ? "1" : "0.6");
|
||||
display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; font-weight: 500;">
|
||||
@if (_isPdfUploadInProgress)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm" role="status" style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
|
||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
|
||||
</svg>
|
||||
}
|
||||
@if (_isPdfUploadInProgress)
|
||||
{
|
||||
@L("UploadPdf", "PDF hochladen")
|
||||
@:
|
||||
@:...
|
||||
}
|
||||
else if (_isPageReady)
|
||||
{
|
||||
@L("UploadPdf", "PDF hochladen")
|
||||
}
|
||||
else
|
||||
{
|
||||
@L("Loading", "Wird geladen")
|
||||
@:
|
||||
@:...
|
||||
}
|
||||
<InputFile OnChange="OnPdfFileSelectedAsync"
|
||||
accept=".pdf"
|
||||
disabled="@(!_isPageReady || _isPdfUploadInProgress)"
|
||||
style="display: none;" />
|
||||
</label>
|
||||
|
||||
@@ -259,13 +386,41 @@
|
||||
<p class="text-white mb-4" style="opacity: 0.75; font-size: 0.85rem;">
|
||||
@L("SenderEditor_UploadPdfToPlaceFields", "Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.")
|
||||
</p>
|
||||
<label class="btn btn-light btn-sm" style="cursor: pointer; font-weight: 500;">
|
||||
<label class="btn btn-light btn-sm"
|
||||
style="cursor: @((_isPageReady && !_isPdfUploadInProgress) ? "pointer" : "not-allowed");
|
||||
opacity:@((_isPageReady && !_isPdfUploadInProgress) ? "1" : "0.8");
|
||||
font-weight: 500;">
|
||||
@if (_isPdfUploadInProgress)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
|
||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
|
||||
</svg>
|
||||
}
|
||||
@if (_isPdfUploadInProgress)
|
||||
{
|
||||
@L("UploadPdf", "PDF hochladen")
|
||||
<InputFile OnChange="OnPdfFileSelectedAsync" accept=".pdf" style="display: none;" />
|
||||
@:
|
||||
@:...
|
||||
}
|
||||
else if (_isPageReady)
|
||||
{
|
||||
@L("UploadPdf", "PDF hochladen")
|
||||
}
|
||||
else
|
||||
{
|
||||
@L("Loading", "Wird geladen")
|
||||
@:
|
||||
@:...
|
||||
}
|
||||
<InputFile OnChange="OnPdfFileSelectedAsync"
|
||||
accept=".pdf"
|
||||
disabled="@(!_isPageReady || _isPdfUploadInProgress)"
|
||||
style="display: none;" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,14 +435,15 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@* PDF viewer — click capture active only in placement mode *@
|
||||
<div class="pdf-editor-wrapper"
|
||||
style="cursor: @(_pendingReceiverForPlacement is not null ? "crosshair" : "default");"
|
||||
@onclick="OnPdfAreaClickAsync">
|
||||
<DxPdfViewer @ref="_pdfViewer"
|
||||
DocumentContent="@_pdfBytes"
|
||||
CssClass="sender-editor-pdf-viewer" />
|
||||
</div>
|
||||
<SenderPdfImageEditor IsPlacementMode="@(_pendingReceiverForPlacement is not null)"
|
||||
PageCount="@_pageCount"
|
||||
Pages="@_pagePreviews"
|
||||
PdfDataUrl="@PdfDataUrl"
|
||||
PdfFileName="@_fileName"
|
||||
Fields="@FieldOverlays"
|
||||
EditorClick="OnPdfAreaClickAsync"
|
||||
MoveField="OnMoveFieldAsync"
|
||||
RemoveField="OnRemoveFieldByIndexAsync" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -459,10 +615,6 @@
|
||||
</DxPopup>
|
||||
|
||||
@code {
|
||||
// ── Session query param — persists across SignalR reconnects ──
|
||||
[SupplyParameterFromQuery(Name = "esid")]
|
||||
public string? Esid { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "envelopeId")]
|
||||
public int? EnvelopeId { get; set; }
|
||||
|
||||
@@ -471,19 +623,18 @@
|
||||
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
||||
const double SigHeightPt = 1.96 * 72; // 141.12 pt
|
||||
|
||||
// CssClass for DxPdfViewer — used by JS to locate page elements
|
||||
// CssClass for JS click coordinate helper
|
||||
const string ViewerCssClass = "sender-editor-pdf-viewer";
|
||||
|
||||
// Cache TTL for editor session (30 min of inactivity)
|
||||
static readonly TimeSpan SessionTtl = TimeSpan.FromMinutes(30);
|
||||
|
||||
// ── State ──
|
||||
DxPdfViewer? _pdfViewer;
|
||||
bool _pdfLoaded = false;
|
||||
bool _isPageReady;
|
||||
bool _isPdfUploadInProgress;
|
||||
string _fileName = string.Empty;
|
||||
string? _errorMessage;
|
||||
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
|
||||
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
|
||||
int _pageCount;
|
||||
List<PdfPageImageDto> _pagePreviews = [];
|
||||
int? _loadedEnvelopeId;
|
||||
int? _editingEnvelopeId;
|
||||
bool _isLoadingEnvelopeForEdit;
|
||||
@@ -491,6 +642,14 @@
|
||||
List<SignatureFieldDraft> _signatureFields = [];
|
||||
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
||||
|
||||
IReadOnlyList<SenderPdfFieldOverlay> FieldOverlays => _signatureFields
|
||||
.Select((f, i) => new SenderPdfFieldOverlay(i, f.Page, f.XPt, f.YPt, f.ReceiverName, f.Color))
|
||||
.ToList();
|
||||
|
||||
string? PdfDataUrl => _originalPdfBytes is { Length: > 0 }
|
||||
? $"data:application/pdf;base64,{Convert.ToBase64String(_originalPdfBytes)}"
|
||||
: null;
|
||||
|
||||
// ── Save state ──
|
||||
bool _isSaving = false;
|
||||
bool _savePopupVisible = false;
|
||||
@@ -505,6 +664,20 @@
|
||||
string _envelopeMessage = string.Empty;
|
||||
bool _titleTouched = false;
|
||||
|
||||
List<EnvelopeTypeDto> _envelopeTypes = [];
|
||||
int? _selectedEnvelopeTypeId;
|
||||
string _language = "de-DE";
|
||||
bool _useAccessCode;
|
||||
bool _tfaEnabled;
|
||||
int _certificationType = (int)CertificationType.AdvancedElectronicSignature;
|
||||
int _finalEmailToCreator = (int)FinalEmailType.No;
|
||||
int _finalEmailToReceivers = (int)FinalEmailType.No;
|
||||
bool _sendReminderEmails;
|
||||
int _firstReminderDays;
|
||||
int _reminderIntervalDays;
|
||||
int _expiresWhenDays;
|
||||
int _expiresWarningWhenDays;
|
||||
|
||||
List<ReceiverDraft> _receivers = [];
|
||||
bool _receiverPopupVisible;
|
||||
string _receiverDraftName = string.Empty;
|
||||
@@ -518,32 +691,33 @@
|
||||
|
||||
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
|
||||
|
||||
// ── Cache key helper ──
|
||||
string SessionKey => $"sender-editor:{Esid}";
|
||||
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
protected override void OnInitialized()
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Intentionally empty.
|
||||
// esid redirect is done in OnAfterRenderAsync to avoid NavigationException
|
||||
// during SSR prerendering, where NavigateTo throws by design.
|
||||
var hasAccess = await AuthService.CheckSenderAccessAsync();
|
||||
if (!hasAccess)
|
||||
{
|
||||
NavigationManager.NavigateTo("/sender/login");
|
||||
return;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
try
|
||||
{
|
||||
if (!firstRender) return;
|
||||
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
|
||||
|
||||
// Generate a session id on first interactive render and push it into the URL.
|
||||
// At this point SignalR is connected and NavigateTo is safe.
|
||||
if (string.IsNullOrWhiteSpace(Esid))
|
||||
if (!IsEditMode && _selectedEnvelopeTypeId is null && _envelopeTypes.Count > 0)
|
||||
{
|
||||
var sid = Guid.NewGuid().ToString("N");
|
||||
var editQuery = EnvelopeId.HasValue && EnvelopeId.Value > 0
|
||||
? $"&envelopeId={EnvelopeId.Value}"
|
||||
: string.Empty;
|
||||
NavigationManager.NavigateTo($"/sender/editor?esid={sid}{editQuery}", forceLoad: false);
|
||||
_selectedEnvelopeTypeId = _envelopeTypes[0].Id;
|
||||
ApplyEnvelopeTypeDefaults(_envelopeTypes[0]);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to load envelope types for sender editor.");
|
||||
_envelopeTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,24 +734,17 @@
|
||||
|
||||
if (IsEditMode)
|
||||
return;
|
||||
|
||||
// After the esid is set via query param, try to restore from cache.
|
||||
if (!string.IsNullOrWhiteSpace(Esid)
|
||||
&& MemoryCache.TryGetValue(SessionKey, out EditorSessionData? cached)
|
||||
&& cached is not null)
|
||||
{
|
||||
_originalPdfBytes = cached.OriginalPdfBytes;
|
||||
_signatureFields = cached.Fields;
|
||||
_fileName = cached.FileName;
|
||||
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
|
||||
_receivers = cached.Receivers;
|
||||
_envelopeTitle = cached.Title;
|
||||
_envelopeMessage = cached.Message;
|
||||
|
||||
// Redraw placeholders onto the original PDF
|
||||
if (_pdfLoaded)
|
||||
_pdfBytes = DrawPlaceholders(_originalPdfBytes!, _signatureFields);
|
||||
}
|
||||
|
||||
protected override Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_isPageReady = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task LoadEnvelopeForEditAsync(int envelopeId)
|
||||
@@ -590,18 +757,32 @@
|
||||
|
||||
try
|
||||
{
|
||||
var user = EnvelopeAuthService.GetCurrentUser();
|
||||
if (user?.Identity is null || !user.Identity.IsAuthenticated)
|
||||
throw new InvalidOperationException("Sender is not authenticated.");
|
||||
|
||||
var envelopes = await EnvelopeService.GetAsync(id: envelopeId);
|
||||
var envelope = envelopes?.FirstOrDefault()
|
||||
?? throw new InvalidOperationException($"Envelope {envelopeId} not found.");
|
||||
|
||||
var document = await ReceiverPageDataService.GetDocumentDetailsOfSenderAsync(user, envelopeId);
|
||||
var document = await DocumentService.GetSenderDocumentDetailsByEnvelopeIdAsync(envelopeId);
|
||||
|
||||
_envelopeTitle = envelope.Title ?? string.Empty;
|
||||
_envelopeMessage = envelope.Message ?? string.Empty;
|
||||
_selectedEnvelopeTypeId = envelope.EnvelopeTypeId;
|
||||
_language = string.IsNullOrWhiteSpace(envelope.Language) ? "de-DE" : envelope.Language;
|
||||
_useAccessCode = envelope.UseAccessCode;
|
||||
_tfaEnabled = envelope.TFAEnabled;
|
||||
if (_tfaEnabled)
|
||||
_useAccessCode = true;
|
||||
|
||||
_certificationType = envelope.CertificationType ?? (int)CertificationType.AdvancedElectronicSignature;
|
||||
_finalEmailToCreator = envelope.FinalEmailToCreator ?? (int)FinalEmailType.No;
|
||||
_finalEmailToReceivers = envelope.FinalEmailToReceivers ?? (int)FinalEmailType.No;
|
||||
|
||||
_sendReminderEmails = envelope.SendReminderEmails ?? envelope.EnvelopeType?.SendReminderEmails ?? false;
|
||||
_firstReminderDays = envelope.FirstReminderDays ?? envelope.EnvelopeType?.FirstReminderDays ?? 0;
|
||||
_reminderIntervalDays = envelope.ReminderIntervalDays ?? envelope.EnvelopeType?.ReminderIntervalDays ?? 0;
|
||||
_expiresWhenDays = envelope.ExpiresWhenDays ?? envelope.EnvelopeType?.ExpiresDays ?? 0;
|
||||
_expiresWarningWhenDays = envelope.ExpiresWarningWhenDays ?? envelope.EnvelopeType?.ExpiresWarningDays ?? 0;
|
||||
|
||||
NormalizeSettings();
|
||||
|
||||
var envelopeReceivers = envelope.EnvelopeReceivers?.ToList() ?? [];
|
||||
_receivers = envelopeReceivers
|
||||
@@ -645,9 +826,31 @@
|
||||
|
||||
_originalPdfBytes = document?.ByteData;
|
||||
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
|
||||
_pdfBytes = _pdfLoaded
|
||||
? DrawPlaceholders(_originalPdfBytes!, _signatureFields)
|
||||
: null;
|
||||
|
||||
if (_pdfLoaded)
|
||||
{
|
||||
try
|
||||
{
|
||||
_pagePreviews = (await PdfRenderService.RenderPagesAsync(_originalPdfBytes!))
|
||||
.OrderBy(p => p.PageNumber)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception renderEx)
|
||||
{
|
||||
Logger.LogWarning(renderEx, "Failed to render PDF page previews for envelope {EnvelopeId}. Falling back to native preview.", envelopeId);
|
||||
_pagePreviews = [];
|
||||
}
|
||||
|
||||
_pageCount = _pagePreviews.Count > 0
|
||||
? _pagePreviews.Count
|
||||
: 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
_pagePreviews = [];
|
||||
_pageCount = Math.Max(1, _signatureFields.Select(f => f.Page).DefaultIfEmpty(1).Max());
|
||||
}
|
||||
|
||||
_fileName = $"Envelope-{envelopeId}.pdf";
|
||||
_pendingReceiverForPlacement = null;
|
||||
_loadedEnvelopeId = envelopeId;
|
||||
@@ -670,6 +873,12 @@
|
||||
// ── PDF upload ──
|
||||
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
||||
{
|
||||
if (!_isPageReady)
|
||||
return;
|
||||
|
||||
if (_isPdfUploadInProgress)
|
||||
return;
|
||||
|
||||
_errorMessage = null;
|
||||
var file = e.File;
|
||||
if (file is null) return;
|
||||
@@ -682,6 +891,9 @@
|
||||
|
||||
try
|
||||
{
|
||||
_isPdfUploadInProgress = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
const long maxBytes = 50 * 1024 * 1024;
|
||||
using var ms = new System.IO.MemoryStream();
|
||||
await file.OpenReadStream(maxBytes).CopyToAsync(ms);
|
||||
@@ -689,14 +901,15 @@
|
||||
_originalPdfBytes = ms.ToArray();
|
||||
_fileName = file.Name;
|
||||
_pdfLoaded = true;
|
||||
_pagePreviews = (await PdfRenderService.RenderPagesAsync(_originalPdfBytes))
|
||||
.OrderBy(p => p.PageNumber)
|
||||
.ToList();
|
||||
_pageCount = _pagePreviews.Count > 0
|
||||
? _pagePreviews.Count
|
||||
: EstimatePdfPageCount(_originalPdfBytes) ?? 1;
|
||||
_signatureFields.Clear();
|
||||
_pendingReceiverForPlacement = null;
|
||||
|
||||
// Rendered PDF starts as the clean original (no placeholders yet)
|
||||
_pdfBytes = _originalPdfBytes;
|
||||
|
||||
PersistSession();
|
||||
|
||||
Logger.LogInformation("PDF loaded: {Name} ({Size} bytes)", _fileName, _originalPdfBytes.Length);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -706,6 +919,11 @@
|
||||
ex.Message);
|
||||
Logger.LogError(ex, "Failed to load PDF file");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isPdfUploadInProgress = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Placement mode ──
|
||||
@@ -719,6 +937,90 @@
|
||||
|
||||
void CancelPlacement() => _pendingReceiverForPlacement = null;
|
||||
|
||||
Task OnEnvelopeTypeChanged(ChangeEventArgs e)
|
||||
{
|
||||
var raw = e.Value?.ToString();
|
||||
if (!int.TryParse(raw, out var id) || id <= 0)
|
||||
{
|
||||
_selectedEnvelopeTypeId = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_selectedEnvelopeTypeId = id;
|
||||
var selectedType = _envelopeTypes.FirstOrDefault(x => x.Id == id);
|
||||
if (selectedType is not null)
|
||||
ApplyEnvelopeTypeDefaults(selectedType);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
void ApplyEnvelopeTypeDefaults(EnvelopeTypeDto envelopeType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(envelopeType.Language))
|
||||
_language = envelopeType.Language;
|
||||
|
||||
if (envelopeType.UseAccessCode.HasValue)
|
||||
_useAccessCode = envelopeType.UseAccessCode.Value;
|
||||
|
||||
if (_tfaEnabled)
|
||||
_useAccessCode = true;
|
||||
|
||||
if (envelopeType.CertificationType.HasValue)
|
||||
_certificationType = envelopeType.CertificationType.Value;
|
||||
|
||||
if (envelopeType.FinalEmailToCreator.HasValue)
|
||||
_finalEmailToCreator = envelopeType.FinalEmailToCreator.Value;
|
||||
|
||||
if (envelopeType.FinalEmailToReceivers.HasValue)
|
||||
_finalEmailToReceivers = envelopeType.FinalEmailToReceivers.Value;
|
||||
|
||||
if (envelopeType.SendReminderEmails.HasValue)
|
||||
_sendReminderEmails = envelopeType.SendReminderEmails.Value;
|
||||
|
||||
if (envelopeType.FirstReminderDays.HasValue)
|
||||
_firstReminderDays = envelopeType.FirstReminderDays.Value;
|
||||
|
||||
if (envelopeType.ReminderIntervalDays.HasValue)
|
||||
_reminderIntervalDays = envelopeType.ReminderIntervalDays.Value;
|
||||
|
||||
if (envelopeType.ExpiresDays.HasValue)
|
||||
_expiresWhenDays = envelopeType.ExpiresDays.Value;
|
||||
|
||||
if (envelopeType.ExpiresWarningDays.HasValue)
|
||||
_expiresWarningWhenDays = envelopeType.ExpiresWarningDays.Value;
|
||||
}
|
||||
|
||||
void OnTfaChanged(ChangeEventArgs e)
|
||||
{
|
||||
_tfaEnabled = ParseCheckboxValue(e);
|
||||
if (_tfaEnabled)
|
||||
_useAccessCode = true;
|
||||
}
|
||||
|
||||
void OnUseAccessCodeChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (_tfaEnabled)
|
||||
{
|
||||
_useAccessCode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_useAccessCode = ParseCheckboxValue(e);
|
||||
}
|
||||
|
||||
void OnSendReminderEmailsChanged(ChangeEventArgs e)
|
||||
{
|
||||
_sendReminderEmails = ParseCheckboxValue(e);
|
||||
}
|
||||
|
||||
static bool ParseCheckboxValue(ChangeEventArgs e)
|
||||
{
|
||||
if (e.Value is bool b)
|
||||
return b;
|
||||
|
||||
return bool.TryParse(e.Value?.ToString(), out var parsed) && parsed;
|
||||
}
|
||||
|
||||
// ── PDF area click → place field ──
|
||||
async Task OnPdfAreaClickAsync(MouseEventArgs e)
|
||||
{
|
||||
@@ -736,23 +1038,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Read page dimensions from the original PDF via PdfSharp
|
||||
double pageWidthPt;
|
||||
double pageHeightPt;
|
||||
try
|
||||
{
|
||||
using var ms = new System.IO.MemoryStream(_originalPdfBytes);
|
||||
var doc = PdfSharp.Pdf.IO.PdfReader.Open(ms, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import);
|
||||
int pageIndex = Math.Max(0, Math.Min(coords.PageIndex, doc.PageCount - 1));
|
||||
var page = doc.Pages[pageIndex];
|
||||
pageWidthPt = page.Width.Point;
|
||||
pageHeightPt = page.Height.Point;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "[SenderEditor] Failed to read page dimensions from PDF");
|
||||
return;
|
||||
}
|
||||
int page1Based = coords.PageIndex + 1;
|
||||
var pagePreview = _pagePreviews.FirstOrDefault(p => p.PageNumber == page1Based);
|
||||
var pageWidthPt = pagePreview?.WidthPt ?? 595.0;
|
||||
var pageHeightPt = pagePreview?.HeightPt ?? 842.0;
|
||||
|
||||
// Convert normalised [0,1] → PDF points; clamp so box stays inside page
|
||||
double xPt = coords.NormX * pageWidthPt;
|
||||
@@ -761,8 +1050,6 @@
|
||||
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - SigWidthPt));
|
||||
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - SigHeightPt));
|
||||
|
||||
int page1Based = coords.PageIndex + 1;
|
||||
|
||||
var field = new SignatureFieldDraft(
|
||||
XPt: xPt,
|
||||
YPt: yPt,
|
||||
@@ -773,23 +1060,34 @@
|
||||
_signatureFields.Add(field);
|
||||
_pendingReceiverForPlacement = null;
|
||||
|
||||
// Burn all placeholders onto the original PDF and update the viewer
|
||||
_pdfBytes = DrawPlaceholders(_originalPdfBytes, _signatureFields);
|
||||
PersistSession();
|
||||
|
||||
Logger.LogInformation(
|
||||
"[SenderEditor] Field added: Page={Page} X={X:F1}pt Y={Y:F1}pt Receiver={Receiver}",
|
||||
page1Based, xPt, yPt, field.ReceiverName);
|
||||
}
|
||||
|
||||
// ── Remove a single field ──
|
||||
async Task RemoveFieldAsync(SignatureFieldDraft field)
|
||||
async Task OnRemoveFieldByIndexAsync(int index)
|
||||
{
|
||||
_signatureFields.Remove(field);
|
||||
_pdfBytes = _originalPdfBytes is not null
|
||||
? DrawPlaceholders(_originalPdfBytes, _signatureFields)
|
||||
: _pdfBytes;
|
||||
PersistSession();
|
||||
if (index < 0 || index >= _signatureFields.Count)
|
||||
return;
|
||||
|
||||
_signatureFields.RemoveAt(index);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task OnMoveFieldAsync(SenderPdfFieldMoveRequest request)
|
||||
{
|
||||
if (request.Index < 0 || request.Index >= _signatureFields.Count)
|
||||
return;
|
||||
|
||||
var field = _signatureFields[request.Index];
|
||||
_signatureFields[request.Index] = field with
|
||||
{
|
||||
XPt = request.XPt,
|
||||
YPt = request.YPt,
|
||||
Page = request.Page
|
||||
};
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -798,24 +1096,55 @@
|
||||
{
|
||||
_signatureFields.Clear();
|
||||
_pendingReceiverForPlacement = null;
|
||||
_pdfBytes = _originalPdfBytes;
|
||||
PersistSession();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
static int? EstimatePdfPageCount(byte[]? documentBytes)
|
||||
{
|
||||
if (documentBytes is null || documentBytes.Length == 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var text = System.Text.Encoding.ASCII.GetString(documentBytes);
|
||||
const string marker = "/Type /Page";
|
||||
var count = 0;
|
||||
var index = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
index = text.IndexOf(marker, index, StringComparison.Ordinal);
|
||||
if (index < 0)
|
||||
break;
|
||||
|
||||
count++;
|
||||
index += marker.Length;
|
||||
}
|
||||
|
||||
return count > 0 ? count : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void Cancel() => NavigationManager.NavigateTo("/sender");
|
||||
|
||||
void GoToDashboard()
|
||||
{
|
||||
// Clear session from cache on successful save so it is not restored if user returns
|
||||
if (!string.IsNullOrWhiteSpace(Esid))
|
||||
MemoryCache.Remove(SessionKey);
|
||||
|
||||
NavigationManager.NavigateTo("/sender");
|
||||
}
|
||||
|
||||
async Task SaveDraftAsync()
|
||||
{
|
||||
if (!ValidateSettings(out var settingsError))
|
||||
{
|
||||
_saveErrorMessage = settingsError;
|
||||
_savePopupVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_isSaving = true;
|
||||
_saveErrorMessage = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
@@ -836,8 +1165,7 @@
|
||||
|
||||
if (previousEnvelopeId != result.Id)
|
||||
{
|
||||
var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid;
|
||||
NavigationManager.NavigateTo($"/sender/editor?esid={sid}&envelopeId={result.Id}", forceLoad: false);
|
||||
NavigationManager.NavigateTo($"/sender/editor?envelopeId={result.Id}", forceLoad: false);
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo("/sender", forceLoad: false);
|
||||
@@ -857,6 +1185,13 @@
|
||||
// ── Send envelope ──
|
||||
async Task SendAsync()
|
||||
{
|
||||
if (!ValidateSettings(out var settingsError))
|
||||
{
|
||||
_saveErrorMessage = settingsError;
|
||||
_savePopupVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Validation ──
|
||||
_titleTouched = true;
|
||||
if (!_pdfLoaded || _originalPdfBytes is null)
|
||||
@@ -944,6 +1279,8 @@
|
||||
|
||||
CreateEnvelopeCommand BuildCreateEnvelopeCommand(bool send)
|
||||
{
|
||||
NormalizeSettings();
|
||||
|
||||
var envelopeId = _editingEnvelopeId
|
||||
?? EnvelopeId
|
||||
?? _loadedEnvelopeId
|
||||
@@ -976,15 +1313,68 @@
|
||||
return new CreateEnvelopeCommand
|
||||
{
|
||||
EnvelopeId = envelopeId,
|
||||
EnvelopeTypeId = _selectedEnvelopeTypeId,
|
||||
Title = _envelopeTitle,
|
||||
Message = _envelopeMessage,
|
||||
TFAEnabled = false,
|
||||
Language = _language,
|
||||
UseAccessCode = _useAccessCode,
|
||||
TFAEnabled = _tfaEnabled,
|
||||
CertificationType = _certificationType,
|
||||
FinalEmailToCreator = _finalEmailToCreator,
|
||||
FinalEmailToReceivers = _finalEmailToReceivers,
|
||||
SendReminderEmails = _sendReminderEmails,
|
||||
FirstReminderDays = _firstReminderDays,
|
||||
ReminderIntervalDays = _reminderIntervalDays,
|
||||
ExpiresWhenDays = _expiresWhenDays,
|
||||
ExpiresWarningWhenDays = _expiresWarningWhenDays,
|
||||
Send = send,
|
||||
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
|
||||
Receivers = receiverCommands
|
||||
};
|
||||
}
|
||||
|
||||
void NormalizeSettings()
|
||||
{
|
||||
_language = string.IsNullOrWhiteSpace(_language) ? "de-DE" : _language.Trim();
|
||||
|
||||
if (_tfaEnabled)
|
||||
_useAccessCode = true;
|
||||
|
||||
_firstReminderDays = Math.Max(0, _firstReminderDays);
|
||||
_reminderIntervalDays = Math.Max(0, _reminderIntervalDays);
|
||||
_expiresWhenDays = Math.Max(0, _expiresWhenDays);
|
||||
_expiresWarningWhenDays = Math.Max(0, _expiresWarningWhenDays);
|
||||
|
||||
if (_expiresWhenDays > 0 && _expiresWarningWhenDays > _expiresWhenDays)
|
||||
_expiresWarningWhenDays = _expiresWhenDays;
|
||||
}
|
||||
|
||||
bool ValidateSettings(out string? message)
|
||||
{
|
||||
NormalizeSettings();
|
||||
|
||||
if (_sendReminderEmails && _reminderIntervalDays <= 0)
|
||||
{
|
||||
message = L("SenderEditor_Validation_ReminderInterval", "Bitte geben Sie ein Erinnerungsintervall groesser als 0 Tage ein.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_sendReminderEmails && _firstReminderDays < 0)
|
||||
{
|
||||
message = L("SenderEditor_Validation_FirstReminder", "Bitte geben Sie gueltige Tage fuer die erste Erinnerung ein.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_expiresWhenDays > 0 && _expiresWarningWhenDays > _expiresWhenDays)
|
||||
{
|
||||
message = L("SenderEditor_Validation_ExpiryWarning", "Die Ablaufwarnung darf nicht nach dem Ablaufdatum liegen.");
|
||||
return false;
|
||||
}
|
||||
|
||||
message = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
int? GetEnvelopeIdFromCurrentUri()
|
||||
{
|
||||
try
|
||||
@@ -1007,87 +1397,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cache persistence ──
|
||||
void PersistSession()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Esid)) return;
|
||||
|
||||
var data = new EditorSessionData(
|
||||
OriginalPdfBytes: _originalPdfBytes ?? [],
|
||||
Fields: [.. _signatureFields],
|
||||
FileName: _fileName,
|
||||
Receivers: [.. _receivers],
|
||||
Title: _envelopeTitle,
|
||||
Message: _envelopeMessage);
|
||||
|
||||
MemoryCache.Set(SessionKey, data, SessionTtl);
|
||||
}
|
||||
|
||||
// ── PdfSharp: burn all placeholder boxes onto the original PDF ──
|
||||
static byte[] DrawPlaceholders(byte[] originalPdf, IReadOnlyList<SignatureFieldDraft> fields)
|
||||
{
|
||||
if (fields.Count == 0) return originalPdf;
|
||||
|
||||
using var inputMs = new System.IO.MemoryStream(originalPdf);
|
||||
using var outputMs = new System.IO.MemoryStream();
|
||||
|
||||
var document = PdfSharp.Pdf.IO.PdfReader.Open(
|
||||
inputMs, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Modify);
|
||||
|
||||
foreach (var field in fields)
|
||||
{
|
||||
int pageIndex = field.Page - 1;
|
||||
if (pageIndex < 0 || pageIndex >= document.PageCount) continue;
|
||||
|
||||
var page = document.Pages[pageIndex];
|
||||
using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page);
|
||||
|
||||
// Derive colours from the receiver's hex colour
|
||||
var fillBrush = new PdfSharp.Drawing.XSolidBrush(HexToXColor(field.Color, alpha: 35));
|
||||
var borderPen = new PdfSharp.Drawing.XPen(HexToXColor(field.Color, alpha: 210), 1.5);
|
||||
var textBrush = new PdfSharp.Drawing.XSolidBrush(HexToXColor(field.Color, alpha: 200));
|
||||
var nameBrush = new PdfSharp.Drawing.XSolidBrush(HexToXColor(field.Color, alpha: 230));
|
||||
|
||||
var fontLabel = new PdfSharp.Drawing.XFont("Arial", 9, PdfSharp.Drawing.XFontStyleEx.Bold);
|
||||
var fontName = new PdfSharp.Drawing.XFont("Arial", 7, PdfSharp.Drawing.XFontStyleEx.Regular);
|
||||
|
||||
var fmtCenter = new PdfSharp.Drawing.XStringFormat
|
||||
{
|
||||
Alignment = PdfSharp.Drawing.XStringAlignment.Center,
|
||||
LineAlignment = PdfSharp.Drawing.XLineAlignment.Center,
|
||||
};
|
||||
|
||||
var rect = new PdfSharp.Drawing.XRect(field.XPt, field.YPt, SigWidthPt, SigHeightPt);
|
||||
|
||||
gfx.DrawRectangle(fillBrush, rect);
|
||||
gfx.DrawRectangle(borderPen, rect);
|
||||
|
||||
// "UNTERSCHRIFT" label centred in upper two-thirds
|
||||
var labelRect = new PdfSharp.Drawing.XRect(
|
||||
field.XPt, field.YPt, SigWidthPt, SigHeightPt * 0.65);
|
||||
gfx.DrawString("UNTERSCHRIFT", fontLabel, textBrush, labelRect, fmtCenter);
|
||||
|
||||
// Receiver name centred in lower third
|
||||
var nameRect = new PdfSharp.Drawing.XRect(
|
||||
field.XPt + 4, field.YPt + SigHeightPt * 0.68,
|
||||
SigWidthPt - 8, SigHeightPt * 0.30);
|
||||
var displayName = field.ReceiverName.Length > 22
|
||||
? field.ReceiverName[..19] + "..."
|
||||
: field.ReceiverName;
|
||||
gfx.DrawString(displayName, fontName, nameBrush, nameRect, fmtCenter);
|
||||
}
|
||||
|
||||
document.Save(outputMs);
|
||||
return outputMs.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Converts a CSS hex colour string (e.g. "#4F46E5") to a PdfSharp XColor.</summary>
|
||||
static PdfSharp.Drawing.XColor HexToXColor(string hex, int alpha)
|
||||
{
|
||||
var c = System.Drawing.ColorTranslator.FromHtml(hex);
|
||||
return PdfSharp.Drawing.XColor.FromArgb(alpha, c.R, c.G, c.B);
|
||||
}
|
||||
|
||||
// ── Receiver popup ──
|
||||
void OpenAddReceiverPopup()
|
||||
{
|
||||
@@ -1193,7 +1502,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
var results = await ReceiverPageDataService.SearchReceiverEMailsAsync(_receiverDraftEmail);
|
||||
var results = await ReceiverService.SearchReceiverEmailsAsync(_receiverDraftEmail);
|
||||
if (searchVersion != _receiverEmailSearchVersion) return;
|
||||
|
||||
_receiverEmailSuggestions = results
|
||||
@@ -1251,7 +1560,6 @@
|
||||
|
||||
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber,
|
||||
ReceiverPalette[_receivers.Count % ReceiverPalette.Length]));
|
||||
PersistSession();
|
||||
CloseAddReceiverPopup();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -1263,14 +1571,6 @@
|
||||
|
||||
record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber, string Color);
|
||||
|
||||
record EditorSessionData(
|
||||
byte[] OriginalPdfBytes,
|
||||
List<SignatureFieldDraft> Fields,
|
||||
string FileName,
|
||||
List<ReceiverDraft> Receivers,
|
||||
string Title,
|
||||
string Message);
|
||||
|
||||
// ── Receiver colour palette (cycles when > 8 receivers) ──
|
||||
static readonly string[] ReceiverPalette =
|
||||
[
|
||||
@@ -1,24 +1,22 @@
|
||||
@page "/sender"
|
||||
@rendermode InteractiveServer
|
||||
@rendermode InteractiveWebAssembly
|
||||
@using System.Text.Json
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@using EnvelopeGenerator.Server.Client.Models
|
||||
@using DevExpress.Blazor
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Application.Common.Dto
|
||||
@using EnvelopeGenerator.Application.Common.Dto.History
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Resources
|
||||
@using EnvelopeGenerator.Server.Services
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.DocumentService DocumentService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.PdfRenderService PdfRenderService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
|
||||
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
||||
@inject IEnvelopeAuthService EnvelopeAuthService
|
||||
@inject ILogger<EnvelopeSenderPage> Logger
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@@ -467,10 +465,15 @@
|
||||
else if (_previewPdfBytes is not null && _previewViewerMounted)
|
||||
{
|
||||
<div class="sender-preview-viewer-wrap">
|
||||
<DxPdfViewer DocumentContent="@_previewPdfBytes"
|
||||
ZoomLevel="-2"
|
||||
IsSinglePagePreview="true"
|
||||
CssClass="sender-preview-pdf-viewer" />
|
||||
<SenderPdfImageEditor IsPlacementMode="false"
|
||||
PageCount="@_previewPageCount"
|
||||
Pages="@_previewPagePreviews"
|
||||
PdfDataUrl="@PreviewPdfDataUrl"
|
||||
PdfFileName="@_previewFileName"
|
||||
Fields="@_previewFieldOverlays"
|
||||
EditorClick="OnPreviewEditorClickAsync"
|
||||
RemoveField="OnPreviewRemoveFieldAsync"
|
||||
MoveField="OnPreviewMoveFieldAsync" />
|
||||
</div>
|
||||
}
|
||||
</BodyContentTemplate>
|
||||
@@ -544,7 +547,15 @@
|
||||
private bool _previewLoading = false;
|
||||
private bool _previewViewerMounted = false;
|
||||
private byte[]? _previewPdfBytes = null;
|
||||
private int _previewPageCount = 0;
|
||||
private List<PdfPageImageDto> _previewPagePreviews = [];
|
||||
private string _previewFileName = "document.pdf";
|
||||
private string? _previewErrorMessage = null;
|
||||
private readonly IReadOnlyList<SenderPdfFieldOverlay> _previewFieldOverlays = [];
|
||||
|
||||
private string? PreviewPdfDataUrl => _previewPdfBytes is { Length: > 0 }
|
||||
? $"data:application/pdf;base64,{Convert.ToBase64String(_previewPdfBytes)}"
|
||||
: null;
|
||||
|
||||
// Phase 6e (step 1): UI state only. Export logic is added in step 2.
|
||||
private string _selectedExportFormat = "xlsx";
|
||||
@@ -749,7 +760,7 @@
|
||||
await InvokeAsync(StateHasChanged);
|
||||
UserPreferencesService.InvalidateCache();
|
||||
await AuthService.LogoutSenderAsync();
|
||||
Navigation.NavigateTo("/sender/login", forceLoad: true);
|
||||
Navigation.NavigateTo("/sender/login", forceLoad: false);
|
||||
}
|
||||
|
||||
bool IsEnvelopeSent(EnvelopeDto envelope)
|
||||
@@ -962,6 +973,9 @@
|
||||
// Unmount the viewer first — let the previous instance fully dispose
|
||||
_previewViewerMounted = false;
|
||||
_previewPdfBytes = null;
|
||||
_previewPageCount = 0;
|
||||
_previewPagePreviews = [];
|
||||
_previewFileName = $"envelope-{envelope.Id}.pdf";
|
||||
_previewErrorMessage = null;
|
||||
_previewLoading = true;
|
||||
_previewVisible = true;
|
||||
@@ -969,19 +983,35 @@
|
||||
|
||||
try
|
||||
{
|
||||
var user = EnvelopeAuthService.GetCurrentUser();
|
||||
if (user?.Identity is null || !user.Identity.IsAuthenticated)
|
||||
_previewPdfBytes = await DocumentService.GetSenderDocumentBytesByEnvelopeIdAsync(envelope.Id);
|
||||
if (_previewPdfBytes is null || _previewPdfBytes.Length == 0)
|
||||
{
|
||||
_previewErrorMessage = L("NotAuthenticatedPleaseLoginAgain", "Nicht authentifiziert. Bitte melden Sie sich erneut an.");
|
||||
_previewErrorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden");
|
||||
return;
|
||||
}
|
||||
|
||||
_previewPdfBytes = await ReceiverPageDataService.GetDocumentOfSenderAsync(user, envelope.Id);
|
||||
var previewPdfBytes = _previewPdfBytes;
|
||||
|
||||
_previewPagePreviews = [];
|
||||
try
|
||||
{
|
||||
_previewPagePreviews = (await PdfRenderService.RenderPagesAsync(previewPdfBytes))
|
||||
.OrderBy(p => p.PageNumber)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception renderEx)
|
||||
{
|
||||
Logger.LogWarning(renderEx, "Preview: page image rendering failed for envelope {EnvelopeId}. Falling back to native PDF preview.", envelope.Id);
|
||||
}
|
||||
|
||||
_previewPageCount = _previewPagePreviews.Count > 0
|
||||
? _previewPagePreviews.Count
|
||||
: EstimatePdfPageCount(previewPdfBytes) ?? 1;
|
||||
|
||||
// PDF ready — mount the viewer now
|
||||
_previewViewerMounted = true;
|
||||
}
|
||||
catch (DigitalData.Core.Exceptions.NotFoundException)
|
||||
catch (HttpRequestException ex) when ((int?)ex.StatusCode == 404)
|
||||
{
|
||||
_previewErrorMessage = string.Format(
|
||||
L("Sender_PreviewDocumentNotFound", "Das Dokument für Umschlag #{0} wurde nicht gefunden oder Sie haben keinen Zugriff darauf."),
|
||||
@@ -1011,12 +1041,60 @@
|
||||
{
|
||||
await Task.Yield(); // wait one render cycle for the viewer to be removed from the DOM
|
||||
_previewPdfBytes = null;
|
||||
_previewPageCount = 0;
|
||||
_previewPagePreviews = [];
|
||||
_previewFileName = "document.pdf";
|
||||
_previewErrorMessage = null;
|
||||
_previewLoading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
}
|
||||
|
||||
Task OnPreviewEditorClickAsync(Microsoft.AspNetCore.Components.Web.MouseEventArgs _)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task OnPreviewRemoveFieldAsync(int _)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task OnPreviewMoveFieldAsync(SenderPdfFieldMoveRequest _)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static int? EstimatePdfPageCount(byte[]? documentBytes)
|
||||
{
|
||||
if (documentBytes is null || documentBytes.Length == 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var text = System.Text.Encoding.ASCII.GetString(documentBytes);
|
||||
const string marker = "/Type /Page";
|
||||
var count = 0;
|
||||
var index = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
index = text.IndexOf(marker, index, StringComparison.Ordinal);
|
||||
if (index < 0)
|
||||
break;
|
||||
|
||||
count++;
|
||||
index += marker.Length;
|
||||
}
|
||||
|
||||
return count > 0 ? count : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grid Layout Persistence ───────────────────────────────────────────────
|
||||
// LayoutAutoLoading fires when the grid initializes — restore saved layout.
|
||||
// LayoutAutoSaving fires when the user changes column order/width/sort — persist it.
|
||||
@@ -41,7 +41,10 @@ builder.Services.AddScoped<SignatureService>();
|
||||
builder.Services.AddScoped<SignatureCacheService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
builder.Services.AddScoped<EnvelopeService>();
|
||||
builder.Services.AddScoped<EnvelopeTypeService>();
|
||||
builder.Services.AddScoped<LocalizationService>();
|
||||
builder.Services.AddScoped<ReceiverService>();
|
||||
builder.Services.AddScoped<PdfRenderService>();
|
||||
|
||||
// UI Preferences & Theme
|
||||
builder.Services.AddScoped<UserPreferencesService>();
|
||||
|
||||
@@ -86,6 +86,18 @@ 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}.
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
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);
|
||||
|
||||
private static HttpRequestException CreateHttpException(string messagePrefix, HttpStatusCode statusCode, string? reasonPhrase)
|
||||
{
|
||||
var statusCodeInt = (int)statusCode;
|
||||
return new HttpRequestException(
|
||||
$"{messagePrefix}. Status: {statusCodeInt} ({reasonPhrase ?? "Unknown error"})",
|
||||
null,
|
||||
statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the PDF bytes for the given envelope key from the API.
|
||||
@@ -17,16 +30,75 @@ public class DocumentService(IHttpClientFactory httpClientFactory)
|
||||
var response = await http.GetAsync($"/api/Document/{Uri.EscapeDataString(envelopeKey)}", cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||
throw new HttpRequestException(
|
||||
$"Failed to load document. Status: {statusCode} ({reasonPhrase})",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
throw CreateHttpException("Failed to load document", response.StatusCode, response.ReasonPhrase);
|
||||
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches sender document details (including signature elements) by envelope id.
|
||||
/// Uses GET /api/Document/details?EnvelopeId={envelopeId}.
|
||||
/// </summary>
|
||||
public async Task<DocumentDto?> GetSenderDocumentDetailsByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var detailsResponse = await http.GetAsync($"/api/Document/details?EnvelopeId={envelopeId}", cancel);
|
||||
|
||||
if (detailsResponse.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
var fallbackBytes = await TryGetSenderDocumentBytesByEnvelopeIdAsync(http, envelopeId, cancel);
|
||||
if (fallbackBytes is null)
|
||||
return null;
|
||||
|
||||
return new DocumentDto(fallbackBytes, new DocumentDetailsDto
|
||||
{
|
||||
EnvelopeId = envelopeId,
|
||||
AddedWhen = DateTime.UtcNow,
|
||||
Elements = []
|
||||
});
|
||||
}
|
||||
|
||||
if (!detailsResponse.IsSuccessStatusCode)
|
||||
throw CreateHttpException("Failed to load sender document details", detailsResponse.StatusCode, detailsResponse.ReasonPhrase);
|
||||
|
||||
var details = await detailsResponse.Content.ReadFromJsonAsync<DocumentDetailsDto>(_jsonOptions, cancel)
|
||||
?? throw new HttpRequestException("Failed to deserialize sender document details response.");
|
||||
|
||||
var byteData = await TryGetSenderDocumentBytesByEnvelopeIdAsync(http, envelopeId, cancel);
|
||||
if (byteData is null)
|
||||
byteData = [];
|
||||
|
||||
return new DocumentDto(byteData, details);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches sender document bytes by envelope id.
|
||||
/// Uses GET /api/Document?EnvelopeId={envelopeId}.
|
||||
/// </summary>
|
||||
public async Task<byte[]?> GetSenderDocumentBytesByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw CreateHttpException("Failed to load sender document bytes", response.StatusCode, response.ReasonPhrase);
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(cancel);
|
||||
}
|
||||
|
||||
private static async Task<byte[]?> TryGetSenderDocumentBytesByEnvelopeIdAsync(HttpClient http, int envelopeId, CancellationToken cancel)
|
||||
{
|
||||
var bytesResponse = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
|
||||
if (bytesResponse.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
if (!bytesResponse.IsSuccessStatusCode)
|
||||
throw CreateHttpException("Failed to load sender document bytes", bytesResponse.StatusCode, bytesResponse.ReasonPhrase);
|
||||
|
||||
return await bytesResponse.Content.ReadAsByteArrayAsync(cancel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,26 +101,7 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
|
||||
CancellationToken cancel = default)
|
||||
{
|
||||
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var payload = new
|
||||
{
|
||||
EnvelopeId = request.EnvelopeId,
|
||||
Title = request.Title,
|
||||
Message = request.Message,
|
||||
TFAEnabled = request.TFAEnabled,
|
||||
Send = request.Send,
|
||||
Document = request.Document is null
|
||||
? null
|
||||
: new { DataAsBase64 = request.Document.DataAsBase64 },
|
||||
Receivers = request.Receivers.Select(r => new
|
||||
{
|
||||
EmailAddress = r.EmailAddress,
|
||||
Name = r.Name,
|
||||
PhoneNumber = r.PhoneNumber,
|
||||
Fields = r.Fields.Select(f => new { X = f.X, Y = f.Y, Page = f.Page }).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var response = await httpClient.PostAsJsonAsync("/api/Envelope", payload, _jsonOptions, cancel);
|
||||
var response = await httpClient.PostAsJsonAsync("/api/Envelope", request, _jsonOptions, cancel);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<EnvelopeDto>(_jsonOptions, cancel);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class EnvelopeTypeService(IHttpClientFactory clientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<IReadOnlyList<EnvelopeTypeDto>> GetAsync(CancellationToken cancel = default)
|
||||
{
|
||||
var http = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await http.GetAsync("/api/EnvelopeType", cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var data = await response.Content.ReadFromJsonAsync<List<EnvelopeTypeDto>>(JsonOptions, cancel);
|
||||
return data ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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 ?? [];
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
@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
|
||||
|
||||
@@ -5,13 +5,18 @@
|
||||
@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
|
||||
@@ -21,6 +26,7 @@
|
||||
@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" />
|
||||
@@ -127,17 +133,6 @@
|
||||
<span class="pdf-toolbar__btn-text">Unterschreiben</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<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 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">Ablehnen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -355,75 +350,6 @@
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
|
||||
@* Rejection Popup *@
|
||||
<DxPopup @bind-Visible="_rejectPopupVisible"
|
||||
HeaderText="Dokument ablehnen"
|
||||
Width="500px"
|
||||
MaxWidth="95vw"
|
||||
ShowFooter="true"
|
||||
CloseOnOutsideClick="false"
|
||||
ShowCloseButton="false"
|
||||
CloseOnEscape="false">
|
||||
<BodyContentTemplate>
|
||||
<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>
|
||||
<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(_rejectValidationMessage))
|
||||
{
|
||||
<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 gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick="CloseRejectPopup"
|
||||
disabled="@_isRejectSubmitting"
|
||||
style="border-radius: 6px; padding: 0.625rem 1.25rem; font-weight: 500;">
|
||||
Zurück
|
||||
</button>
|
||||
<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>
|
||||
</DxPopup>
|
||||
|
||||
@code {
|
||||
// ----- Constants -----
|
||||
const string SignatureTabDraw = "draw";
|
||||
@@ -468,13 +394,110 @@
|
||||
string _signerFullName = string.Empty;
|
||||
string _signerPosition = string.Empty;
|
||||
string _signaturePlace = string.Empty;
|
||||
bool _isLoggingOut = false;
|
||||
|
||||
// ----- Rejection popup state -----
|
||||
bool _rejectPopupVisible;
|
||||
bool _isRejectSubmitting;
|
||||
// ----- Share popup state -----
|
||||
bool _sharePopupVisible = false;
|
||||
bool _isSharing = false;
|
||||
string _shareReceiverMail = string.Empty;
|
||||
DateTime _shareDateValid = DateTime.Today.AddDays(7);
|
||||
string? _shareMessage;
|
||||
bool _shareMessageIsError = false;
|
||||
|
||||
// ----- Reject popup state -----
|
||||
bool _rejectPopupVisible = false;
|
||||
bool _isRejectSubmitting = false;
|
||||
string _rejectReason = string.Empty;
|
||||
string? _rejectValidationMessage;
|
||||
|
||||
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
|
||||
{
|
||||
_shareMessage = "Freigabe ist in dieser Ansicht derzeit nicht verfugbar.";
|
||||
_shareMessageIsError = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to share envelope {EnvelopeKey}", EnvelopeKey);
|
||||
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
|
||||
_shareMessageIsError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSharing = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Lifecycle -----
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -485,6 +508,12 @@
|
||||
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)
|
||||
@@ -493,6 +522,12 @@
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
@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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
@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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,17 +31,30 @@ public class DocumentController(IMediator mediator, IAuthorizationService authSe
|
||||
/// <param name="cancel">Cancellation token.</param>
|
||||
[HttpGet]
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null)
|
||||
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery query)
|
||||
{
|
||||
if (query is null)
|
||||
return BadRequest("Missing document query.");
|
||||
|
||||
var senderDoc = await mediator.Send(query, cancel);
|
||||
return senderDoc.ByteData is byte[] senderDocByte
|
||||
return senderDoc?.ByteData is byte[] senderDocByte
|
||||
? File(senderDocByte, "application/octet-stream")
|
||||
: NotFound("Document is empty.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns sender document metadata and element details (without binary payload) for editor loading.
|
||||
/// </summary>
|
||||
/// <param name="query">Document query with EnvelopeId.</param>
|
||||
/// <param name="cancel">Cancellation token.</param>
|
||||
[HttpGet("details")]
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
public async Task<IActionResult> GetDocumentDetails(CancellationToken cancel, [FromQuery] ReadDocumentDetailsQuery query)
|
||||
{
|
||||
var senderDoc = await mediator.Send(query with { UserId = User.GetId() }, cancel);
|
||||
if (senderDoc is null)
|
||||
return NotFound("Document is empty.");
|
||||
|
||||
return Ok(senderDoc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the document for the specified envelope key.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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}");
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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
|
||||
{
|
||||
// TODO(perf): Replace JSON/base64 transport for PdfBytes with application/octet-stream (or multipart) and stream-based request parsing to reduce payload size and allocation pressure for large PDFs.
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
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 EnvelopeGenerator.Server.Extensions;
|
||||
using MediatR;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
@@ -33,24 +35,36 @@ public class ReadOnlyController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new read-only receiver for the current envelope.
|
||||
/// Creates a new read-only receiver for an envelope.
|
||||
/// </summary>
|
||||
/// <param name="envelopeKey">Envelope key (uuid+signature encoded).</param>
|
||||
/// <param name="createDto">Creation payload.</param>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||
[HttpPost("{envelopeKey}")]
|
||||
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
|
||||
[Obsolete("Use MediatR")]
|
||||
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto)
|
||||
public async Task<IActionResult> CreateAsync(
|
||||
[FromRoute] string envelopeKey,
|
||||
[FromBody] EnvelopeReceiverReadOnlyCreateDto createDto,
|
||||
[FromServices] IMediator mediator,
|
||||
CancellationToken cancel)
|
||||
{
|
||||
var authReceiverMail = User.ReceiverMail();
|
||||
if (authReceiverMail is null)
|
||||
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))
|
||||
{
|
||||
_logger.LogError("EmailAddress claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
|
||||
return Unauthorized();
|
||||
_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);
|
||||
}
|
||||
|
||||
var envelopeId = User.EnvelopeId();
|
||||
var envelopeId = authEnvelopeReceiver.EnvelopeId;
|
||||
|
||||
createDto.AddedWho = authReceiverMail;
|
||||
createDto.AddedWho = addedWho;
|
||||
createDto.EnvelopeId = envelopeId;
|
||||
|
||||
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>EnvelopeGenerator.Server</Product>
|
||||
<Version>1.2.0-beta</Version>
|
||||
<FileVersion>1.2.0.0</FileVersion>
|
||||
<AssemblyVersion>1.2.0.0</AssemblyVersion>
|
||||
<Version>1.4.0-beta</Version>
|
||||
<FileVersion>1.4.0.0</FileVersion>
|
||||
<AssemblyVersion>1.4.0.0</AssemblyVersion>
|
||||
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
@@ -21,6 +21,7 @@
|
||||
<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>
|
||||
|
||||
@@ -26,6 +26,8 @@ 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!");
|
||||
@@ -341,6 +343,9 @@ 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>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService>();
|
||||
|
||||
// Business Services (Server specific)
|
||||
builder.Services.AddScoped<DocumentService>();
|
||||
@@ -349,6 +354,7 @@ try
|
||||
builder.Services.AddScoped<EnvelopeReceiverService>();
|
||||
builder.Services.AddScoped<SignatureService>();
|
||||
builder.Services.AddScoped<SignatureCacheService>();
|
||||
builder.Services.AddDocInfrastructure();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@ public class EnvelopeReceiverPageDataService(
|
||||
return await mediator.Send(new ReadDocumentQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads document metadata/elements for the authenticated sender without binary payload.
|
||||
/// </summary>
|
||||
public async Task<DocumentDetailsDto?> GetDocumentMetadataOfSenderAsync(ClaimsPrincipal user, int envelopeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await mediator.Send(new ReadDocumentDetailsQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the current receiver's signature placeholders.
|
||||
/// </summary>
|
||||
|
||||
@@ -56,6 +56,351 @@
|
||||
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%;
|
||||
@@ -1065,6 +1410,41 @@ 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;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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).
|
||||
@@ -25,9 +31,10 @@ window.envelopeEditor = {
|
||||
}
|
||||
|
||||
// --- Candidate page elements (ordered by preference) ---
|
||||
// DxPdfViewer renders individual pages as .dxbl-pdfv-page elements.
|
||||
// DxReportViewer uses .dxbrv-report-preview-content-img as fallback.
|
||||
// Sender editor custom renderer uses data-editor-page blocks.
|
||||
// Legacy Dx viewers remain as fallback selectors.
|
||||
const pageSelectors = [
|
||||
'[data-editor-page]',
|
||||
'.dxbl-pdfv-page',
|
||||
'.dxbrv-report-preview-page',
|
||||
'.dxbrv-report-preview-content-img',
|
||||
@@ -89,7 +96,229 @@ window.envelopeEditor = {
|
||||
return {
|
||||
normX: normX,
|
||||
normY: normY,
|
||||
pageIndex: targetIndex
|
||||
pageIndex: targetIndex,
|
||||
pageNumber: parseInt(targetPage.getAttribute('data-editor-page') || String(targetIndex + 1), 10)
|
||||
};
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ 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}"
|
||||
@@ -51,6 +52,8 @@ 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
|
||||
@@ -113,6 +116,10 @@ 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
|
||||
@@ -136,6 +143,7 @@ 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}
|
||||
|
||||
217
SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md
Normal file
217
SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# 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.
|
||||
86
SENDER_PDF_TOOLBAR_PLAN_EN.md
Normal file
86
SENDER_PDF_TOOLBAR_PLAN_EN.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user