Compare commits
60 Commits
223017006b
...
bugfix/sav
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 96c7cd6e8a | |||
| 4047ba7c6b | |||
| 8386b85592 | |||
| 640f0b95fe | |||
| 181f7f4dec | |||
| 95961a296f | |||
| db613358d5 | |||
| 7a126050cf | |||
| 7081c5f64e | |||
| 12ec5b28fa | |||
| 925e60a45c | |||
| 897860b2bf | |||
| 8933cff558 | |||
| b0b9ba7212 | |||
| 33c75ea7e1 | |||
| 513efa0833 | |||
| 0c81d79a9b | |||
| 696cd1b623 | |||
| 2434f67627 | |||
| 776700b247 | |||
| 7a114f47a6 | |||
| ed44d841e7 | |||
| 4bd0cc2e94 | |||
| 8a4963472c | |||
| 4d3f72a3ed | |||
| 1ebd43e3b0 | |||
| 19aeda5aef | |||
| 940d129c11 | |||
| 44bbdbb835 |
15
AGENTS.md
15
AGENTS.md
@@ -236,6 +236,21 @@ Manual testing workflow:
|
||||
3. Test sender login at `/sender/login`
|
||||
4. Test receiver flow at `/envelope/login/{envelopeKey}`
|
||||
|
||||
## Technical Debt TODOs
|
||||
|
||||
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity.
|
||||
- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation).
|
||||
- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
|
||||
- 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.
|
||||
|
||||
## Database
|
||||
|
||||
**SQL Server** (DD_ECM)
|
||||
|
||||
@@ -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>
|
||||
@@ -135,4 +170,4 @@ public record EnvelopeDto : IEnvelope
|
||||
/// Envelope history entries tracking actions like DocumentSigned, EnvelopeOpened, etc.
|
||||
/// </summary>
|
||||
public IEnumerable<HistoryDto>? Histories { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
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>();
|
||||
@@ -68,4 +69,4 @@ public class MappingProfile : Profile
|
||||
? new SmsResponse() { Ok = true }
|
||||
: new SmsResponse() { Ok = false, Errors = gtxRes });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -59,6 +61,9 @@ public static class DependencyInjection
|
||||
services.AddMediatR(cfg =>
|
||||
{
|
||||
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)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -86,4 +86,4 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
return Title;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@
|
||||
_selected = option;
|
||||
_isOpen = false;
|
||||
await LocalizationService.SetLanguageAsync(option.Code);
|
||||
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
|
||||
Navigation.NavigateTo(Navigation.Uri, forceLoad: false);
|
||||
}
|
||||
|
||||
private sealed record LanguageOption(string Code, string FlagClass, string ShortName);
|
||||
|
||||
@@ -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
|
||||
@@ -262,7 +260,7 @@
|
||||
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||
if (envelope != null)
|
||||
{
|
||||
var statusInfo = GetStatusInfo(envelope.Status);
|
||||
var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
|
||||
<div class="status-badge status-badge--@statusInfo.CssClass">
|
||||
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
|
||||
@statusInfo.Label
|
||||
@@ -314,12 +312,20 @@
|
||||
{
|
||||
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
|
||||
@{
|
||||
var isRejected = IsReceiverRejected(envelope, receiver);
|
||||
var isSigned = IsReceiverSigned(envelope, receiver);
|
||||
var accessCode = receiver.AccessCode;
|
||||
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
|
||||
}
|
||||
<span class="receiver-badge receiver-badge--@(isSigned ? "signed" : "unsigned")" style="min-width: 100px;">
|
||||
@if (isSigned)
|
||||
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
|
||||
@if (isRejected)
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
|
||||
</svg>
|
||||
<span>@GetStatusLabel(EnvelopeStatus.DocumentRejected)</span>
|
||||
}
|
||||
else if (isSigned)
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
||||
@@ -459,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>
|
||||
@@ -536,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";
|
||||
@@ -590,10 +609,11 @@
|
||||
{
|
||||
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
|
||||
|
||||
// Split into active and completed based on status
|
||||
// Split into active and completed based on effective status
|
||||
// (history-aware: rejected envelopes should not stay in active tab).
|
||||
var envelopes = _allEnvelopes.ToList();
|
||||
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
|
||||
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
|
||||
_activeEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsActive()).ToList();
|
||||
_completedEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsCompleted()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -740,18 +760,18 @@
|
||||
await InvokeAsync(StateHasChanged);
|
||||
UserPreferencesService.InvalidateCache();
|
||||
await AuthService.LogoutSenderAsync();
|
||||
Navigation.NavigateTo("/sender/login", forceLoad: true);
|
||||
Navigation.NavigateTo("/sender/login", forceLoad: false);
|
||||
}
|
||||
|
||||
bool IsEnvelopeSent(EnvelopeDto envelope)
|
||||
{
|
||||
var status = (EnvelopeStatus)envelope.Status;
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
return status >= EnvelopeStatus.EnvelopeQueued;
|
||||
}
|
||||
|
||||
bool IsEnvelopeEditable(EnvelopeDto envelope)
|
||||
{
|
||||
var status = (EnvelopeStatus)envelope.Status;
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
|
||||
}
|
||||
|
||||
@@ -800,7 +820,7 @@
|
||||
IReadOnlyList<StatusFilterItem> GetStatusFilterItems(IEnumerable<EnvelopeDto>? source)
|
||||
{
|
||||
return (source ?? Enumerable.Empty<EnvelopeDto>())
|
||||
.Select(x => x.Status)
|
||||
.Select(GetEffectiveEnvelopeStatus)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.Select(x => new StatusFilterItem(x, GetStatusLabel(x)))
|
||||
@@ -818,7 +838,7 @@
|
||||
|
||||
bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
|
||||
{
|
||||
var status = (EnvelopeStatus)envelope.Status;
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent)
|
||||
return true;
|
||||
|
||||
@@ -841,6 +861,43 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
bool IsReceiverRejected(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
|
||||
{
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
var histories = envelope.Histories?.Where(h =>
|
||||
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected).ToList() ?? [];
|
||||
|
||||
if (histories.Count == 0)
|
||||
return false;
|
||||
|
||||
if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId))
|
||||
return true;
|
||||
|
||||
var receiverEmail = receiver.Receiver?.EmailAddress;
|
||||
if (!string.IsNullOrWhiteSpace(receiverEmail) &&
|
||||
histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(receiver.Name) &&
|
||||
histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
|
||||
return status == EnvelopeStatus.EnvelopeRejected && (envelope.EnvelopeReceivers?.Count() ?? 0) == 1;
|
||||
}
|
||||
|
||||
EnvelopeStatus GetEffectiveEnvelopeStatus(EnvelopeDto envelope)
|
||||
{
|
||||
var rawStatus = envelope.Status;
|
||||
|
||||
if (rawStatus is EnvelopeStatus.EnvelopeRejected or EnvelopeStatus.EnvelopeWithdrawn)
|
||||
return rawStatus;
|
||||
|
||||
var hasRejectedHistory = (envelope.Histories ?? Enumerable.Empty<HistoryDto>())
|
||||
.Any(h => h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected);
|
||||
|
||||
return hasRejectedHistory ? EnvelopeStatus.EnvelopeRejected : rawStatus;
|
||||
}
|
||||
|
||||
DateTime GetHistoryDate(HistoryDto history)
|
||||
{
|
||||
return history.ActionDate ?? history.AddedWhen;
|
||||
@@ -916,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;
|
||||
@@ -923,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."),
|
||||
@@ -965,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.
|
||||
@@ -983,13 +1107,18 @@
|
||||
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
|
||||
var prefs = UserPreferencesService.GetCached();
|
||||
if (prefs is not null && prefs.GridLayouts.TryGetValue(CurrentGridKey, out var json))
|
||||
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
|
||||
{
|
||||
var layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
|
||||
SanitizeLayoutFilters(layout);
|
||||
e.Layout = layout;
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
SanitizeLayoutFilters(e.Layout);
|
||||
var json = JsonSerializer.Serialize(e.Layout);
|
||||
await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json);
|
||||
}
|
||||
@@ -999,5 +1128,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Keep column/order personalization, but never persist/restore data filters.
|
||||
// Tab counters are based on tab datasets; persisted grid filters can hide rows and
|
||||
// make the grid appear empty or incomplete.
|
||||
static void SanitizeLayoutFilters(GridPersistentLayout? layout)
|
||||
{
|
||||
if (layout is null)
|
||||
return;
|
||||
|
||||
ClearWritableProperty(layout, "FilterCriteria");
|
||||
ClearWritableProperty(layout, "SearchText");
|
||||
|
||||
if (layout.Columns is not System.Collections.IEnumerable columns)
|
||||
return;
|
||||
|
||||
foreach (var column in columns)
|
||||
{
|
||||
if (column is null)
|
||||
continue;
|
||||
|
||||
ClearWritableProperty(column, "FilterCriteria");
|
||||
ClearWritableProperty(column, "FilterValues");
|
||||
ClearWritableProperty(column, "FilterValue");
|
||||
ClearWritableProperty(column, "SearchText");
|
||||
}
|
||||
}
|
||||
|
||||
static void ClearWritableProperty(object target, string propertyName)
|
||||
{
|
||||
var prop = target.GetType()
|
||||
.GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)
|
||||
.FirstOrDefault(p => p.Name == propertyName && p.GetIndexParameters().Length == 0);
|
||||
|
||||
if (prop is null || !prop.CanWrite)
|
||||
return;
|
||||
|
||||
var propertyType = prop.PropertyType;
|
||||
object? value;
|
||||
|
||||
if (!propertyType.IsValueType || Nullable.GetUnderlyingType(propertyType) is not null)
|
||||
{
|
||||
value = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = Activator.CreateInstance(propertyType);
|
||||
}
|
||||
|
||||
prop.SetValue(target, value);
|
||||
}
|
||||
|
||||
private sealed record StatusFilterItem(EnvelopeStatus Value, string Text);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>();
|
||||
|
||||
@@ -26,4 +26,24 @@ public class AnnotationService(IHttpClientFactory httpClientFactory)
|
||||
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
public async Task RejectAsync(string envelopeKey, string? reason, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
|
||||
var response = await http.PostAsJsonAsync(
|
||||
$"/api/Annotation/reject/{Uri.EscapeDataString(envelopeKey)}",
|
||||
reason,
|
||||
_jsonOptions,
|
||||
cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancel);
|
||||
throw new HttpRequestException(
|
||||
$"Failed to reject envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/sender/editor"
|
||||
@page "/sender/editor_legacy"
|
||||
@rendermode InteractiveServer
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
@using DevExpress.Blazor.Reporting.Models
|
||||
@@ -543,7 +543,7 @@
|
||||
var editQuery = EnvelopeId.HasValue && EnvelopeId.Value > 0
|
||||
? $"&envelopeId={EnvelopeId.Value}"
|
||||
: string.Empty;
|
||||
NavigationManager.NavigateTo($"/sender/editor?esid={sid}{editQuery}", forceLoad: false);
|
||||
NavigationManager.NavigateTo($"/sender/editor_legacy?esid={sid}{editQuery}", forceLoad: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,7 +837,7 @@
|
||||
if (previousEnvelopeId != result.Id)
|
||||
{
|
||||
var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid;
|
||||
NavigationManager.NavigateTo($"/sender/editor?esid={sid}&envelopeId={result.Id}", forceLoad: false);
|
||||
NavigationManager.NavigateTo($"/sender/editor_legacy?esid={sid}&envelopeId={result.Id}", forceLoad: false);
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo("/sender", forceLoad: false);
|
||||
|
||||
@@ -5,20 +5,28 @@
|
||||
@using EnvelopeGenerator.Server.Client.Models
|
||||
@using EnvelopeGenerator.Server.Client.Models.Constants
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Application.Common.Extensions
|
||||
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
||||
@using EnvelopeGenerator.Application.EnvelopeReceivers.Queries
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@using Microsoft.JSInterop
|
||||
@using DevExpress.Blazor
|
||||
@using System.Drawing
|
||||
@using System.Security.Claims
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.Extensions.Caching.Memory
|
||||
@using MediatR
|
||||
@using EnvelopeGenerator.Server.Extensions
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||
@inject AnnotationService AnnotationService
|
||||
@inject AppVersionService AppVersion
|
||||
@inject IMemoryCache MemoryCache
|
||||
@inject ILogger<ReceiverPage> Logger
|
||||
@inject IMediator Mediator
|
||||
@implements IDisposable
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
@@ -386,6 +394,109 @@
|
||||
string _signerFullName = string.Empty;
|
||||
string _signerPosition = string.Empty;
|
||||
string _signaturePlace = string.Empty;
|
||||
bool _isLoggingOut = false;
|
||||
|
||||
// ----- Share popup state -----
|
||||
bool _sharePopupVisible = false;
|
||||
bool _isSharing = false;
|
||||
string _shareReceiverMail = string.Empty;
|
||||
DateTime _shareDateValid = DateTime.Today.AddDays(7);
|
||||
string? _shareMessage;
|
||||
bool _shareMessageIsError = false;
|
||||
|
||||
// ----- 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()
|
||||
@@ -397,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)
|
||||
@@ -405,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)
|
||||
@@ -423,6 +546,11 @@
|
||||
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
||||
if (_envelopeReceiver is null)
|
||||
Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey);
|
||||
else if (IsRejected(_envelopeReceiver))
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/rejected", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build initial report (no signature image yet)
|
||||
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
|
||||
@@ -468,6 +596,16 @@
|
||||
}
|
||||
|
||||
// ----- Report builder -----
|
||||
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
|
||||
{
|
||||
var status = envelopeReceiver.Envelope?.Status;
|
||||
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
|
||||
return true;
|
||||
|
||||
return envelopeReceiver.Envelope?.Histories?.Any(h =>
|
||||
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an XtraReport wrapping the PDF bytes.
|
||||
/// If a signature is captured and there are signature fields, the signature image is
|
||||
@@ -701,6 +839,56 @@
|
||||
forceLoad: true);
|
||||
}
|
||||
|
||||
void OpenRejectPopup()
|
||||
{
|
||||
_rejectValidationMessage = null;
|
||||
_rejectPopupVisible = true;
|
||||
}
|
||||
|
||||
void CloseRejectPopup()
|
||||
{
|
||||
if (_isRejectSubmitting)
|
||||
return;
|
||||
|
||||
_rejectPopupVisible = false;
|
||||
}
|
||||
|
||||
async Task SubmitRejectAsync()
|
||||
{
|
||||
if (_isRejectSubmitting)
|
||||
return;
|
||||
|
||||
_isRejectSubmitting = true;
|
||||
_rejectValidationMessage = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
try
|
||||
{
|
||||
var reason = string.IsNullOrWhiteSpace(_rejectReason)
|
||||
? null
|
||||
: _rejectReason.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||
{
|
||||
_rejectValidationMessage = "Envelope-Schluessel fehlt.";
|
||||
_isRejectSubmitting = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
await AnnotationService.RejectAsync(EnvelopeKey, reason);
|
||||
|
||||
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to reject envelope {EnvelopeKey}", EnvelopeKey);
|
||||
_rejectValidationMessage = "Die Ablehnung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.";
|
||||
_isRejectSubmitting = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
async Task<string?> GetActiveSignatureDataUrlAsync()
|
||||
{
|
||||
if (_activeSignatureTab == SignatureTabDraw)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
@page "/envelope/{EnvelopeKey}/rejected"
|
||||
@rendermode InteractiveServer
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@using EnvelopeGenerator.Domain.Interfaces
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject NavigationManager Navigation
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||
@inject IStringLocalizer<Resource> UiLocalizer
|
||||
|
||||
<div class="envelope-viewer-layout" style="justify-content: center; align-items: center; padding: 1.5rem;">
|
||||
<div style="width: min(920px, 100%); background: rgba(255,255,255,0.96); border-radius: 20px; box-shadow: 0 18px 48px rgba(15,23,42,0.22); overflow: hidden;">
|
||||
<div style="padding: 1.2rem 1.4rem; border-bottom: 1px solid rgba(15,23,42,0.08); background: linear-gradient(135deg, rgba(239,68,68,0.12) 0%, rgba(220,38,38,0.08) 100%);">
|
||||
<div style="font-size: 1rem; font-weight: 700; color: #991b1b;">@L("DocRejected", "Dokument abgelehnt")</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 2rem 1.5rem;">
|
||||
<div style="display: flex; justify-content: center; margin-bottom: 1rem;">
|
||||
<div style="width: 90px; height: 90px; border-radius: 999px; background: #fee2e2; display: inline-flex; align-items: center; justify-content: center; color: #b91c1c;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="text-align: center; font-size: 1.4rem; color: #7f1d1d; margin-bottom: 0.75rem;">
|
||||
@(_isReadAndConfirm
|
||||
? L("RejectionInfo1Confirmation", "Dieses Dokument wurde als nicht bestaetigt markiert.")
|
||||
: L("RejectionInfo1", "Dieses Dokument wurde abgelehnt."))
|
||||
</h2>
|
||||
|
||||
<p style="text-align: center; margin: 0 auto; max-width: 760px; color: #374151; line-height: 1.6;">
|
||||
@((MarkupString)_messageLine2)
|
||||
</p>
|
||||
|
||||
<div style="margin-top: 1.4rem; text-align: center;">
|
||||
<a href="/" class="btn btn-outline-secondary" style="font-weight: 600;">Portal Startseite</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? EnvelopeKey { get; set; }
|
||||
|
||||
bool _isReadAndConfirm;
|
||||
string _messageLine2 = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||
{
|
||||
Navigation.NavigateTo("/");
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
|
||||
if (user is null)
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
|
||||
return;
|
||||
}
|
||||
|
||||
var envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
||||
if (envelopeReceiver is null)
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsRejected(envelopeReceiver))
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
_isReadAndConfirm = envelopeReceiver.Envelope?.IsReadAndConfirm() ?? false;
|
||||
|
||||
var senderName = envelopeReceiver.Envelope?.User is null
|
||||
? string.Empty
|
||||
: $"{envelopeReceiver.Envelope.User.Prename} {envelopeReceiver.Envelope.User.Name}".Trim();
|
||||
var senderEmail = envelopeReceiver.Envelope?.User?.Email ?? string.Empty;
|
||||
var envelopeTitle = envelopeReceiver.Envelope?.Title ?? string.Empty;
|
||||
|
||||
var template = L("RejectionInfo2", "Das Dokument \"{2}\" von {0} ({1}) wurde abgelehnt.");
|
||||
_messageLine2 = string.Format(template, senderName, senderEmail, envelopeTitle);
|
||||
}
|
||||
|
||||
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
|
||||
{
|
||||
var status = envelopeReceiver.Envelope?.Status;
|
||||
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
|
||||
return true;
|
||||
|
||||
return envelopeReceiver.Envelope?.Histories?.Any(h =>
|
||||
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
|
||||
}
|
||||
|
||||
string L(string key, string fallback)
|
||||
{
|
||||
var value = UiLocalizer[key];
|
||||
return value.ResourceNotFound ? fallback : value.Value;
|
||||
}
|
||||
}
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,11 +100,12 @@ public class AnnotationController : ControllerBase
|
||||
/// <summary>
|
||||
/// Rejects the document for the current receiver.
|
||||
/// </summary>
|
||||
/// <param name="envelopeKey">Envelope key route parameter.</param>
|
||||
/// <param name="reason">Optional rejection reason.</param>
|
||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||
[HttpPost("reject")]
|
||||
[HttpPost("reject/{envelopeKey}")]
|
||||
[Obsolete("Use MediatR")]
|
||||
public async Task<IActionResult> Reject([FromBody] string? reason = null)
|
||||
public async Task<IActionResult> Reject([FromRoute] string envelopeKey, [FromBody] string? reason = null)
|
||||
{
|
||||
var signature = User.ReceiverSignature();
|
||||
var uuid = User.EnvelopeUuid();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -67,7 +67,7 @@ public static class ReceiverClaimExtensions
|
||||
/// Gets the authenticated receiver email address from the claims.
|
||||
/// </summary>
|
||||
public static string ReceiverMail(this ClaimsPrincipal user)
|
||||
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
|
||||
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email, ClaimTypes.Email);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the authenticated envelope identifier from the claims.
|
||||
@@ -120,4 +120,4 @@ public static class ReceiverClaimExtensions
|
||||
{
|
||||
return Role.Receiver.Authorized.Any(user.IsInRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
@@ -609,6 +954,29 @@ body.resizing {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pdf-toolbar__btn--reject {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.pdf-toolbar__btn--reject:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
border-color: transparent;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.pdf-toolbar__btn--reject:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pdf-toolbar__btn-text {
|
||||
font-size: 0.813rem;
|
||||
font-weight: 600;
|
||||
@@ -1042,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;
|
||||
@@ -1211,6 +1614,18 @@ body.resizing {
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.35);
|
||||
border-color: rgba(248, 113, 113, 0.6);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Error container */
|
||||
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
|
||||
@@ -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