Compare commits
64 Commits
feat/serve
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 223017006b | |||
| ad9250c7b1 | |||
| 5cc47ca9c7 | |||
| 156988403a | |||
| e960c02cd4 | |||
| a0e2492757 | |||
| 28f8b829e3 | |||
| d00c459124 | |||
| 287c14e114 | |||
| 212ba9d569 | |||
| 735f072dd1 | |||
| 48cfd3f416 | |||
| 0d2e43b2d6 | |||
| 5aaa37b8ec | |||
| 051a8ce211 | |||
| 519a8bf3f9 | |||
| a7d0b40e39 | |||
| 3dc9a180d3 | |||
| 4dba965c75 | |||
| 4d2538cc2c | |||
| d201eacc97 | |||
| 9daa671655 | |||
| 3fbf011fe3 | |||
| a815705fa4 | |||
| 6e44d0d4b5 | |||
| 42a853cb0d | |||
| b46c3c8ebe | |||
| 9f712a6b8c | |||
| 4449743105 |
46
AGENTS.md
46
AGENTS.md
@@ -69,9 +69,9 @@ This section explains every markdown file in the repo: what it contains, why it
|
|||||||
- **EnvelopeGenerator.Server** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend
|
- **EnvelopeGenerator.Server** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend
|
||||||
- **EnvelopeGenerator.Server.Client** (WASM): `@rendermode InteractiveWebAssembly` - Login, dashboards, business logic
|
- **EnvelopeGenerator.Server.Client** (WASM): `@rendermode InteractiveWebAssembly` - Login, dashboards, business logic
|
||||||
|
|
||||||
**Backend:** EnvelopeGenerator.API (ASP.NET Core 8.0)
|
**Backend + API Host:** `EnvelopeGenerator.Server` (ASP.NET Core 8.0, merged Blazor + Web API host)
|
||||||
|
|
||||||
**Proxy:** YARP in EnvelopeGenerator.Server routes `/api/*` → `localhost:8088` (API)
|
**Proxy:** YARP in `EnvelopeGenerator.Server` is used for external integrations (for example AuthHub forwarding), not for routing app APIs to `EnvelopeGenerator.API`.
|
||||||
|
|
||||||
### Deprecated Projects - DO NOT USE
|
### Deprecated Projects - DO NOT USE
|
||||||
- `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to Server)
|
- `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to Server)
|
||||||
@@ -80,18 +80,14 @@ This section explains every markdown file in the repo: what it contains, why it
|
|||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Run Both Projects (Required)
|
### Run Active Host
|
||||||
```powershell
|
```powershell
|
||||||
# Terminal 1 - API Backend
|
# Terminal - Blazor + Web API host
|
||||||
cd EnvelopeGenerator.API
|
|
||||||
dotnet run
|
|
||||||
|
|
||||||
# Terminal 2 - Blazor Frontend
|
|
||||||
cd EnvelopeGenerator.Server\EnvelopeGenerator.Server
|
cd EnvelopeGenerator.Server\EnvelopeGenerator.Server
|
||||||
dotnet run
|
dotnet run
|
||||||
```
|
```
|
||||||
|
|
||||||
**Critical:** Both must run simultaneously. EnvelopeGenerator.Server proxy forwards `/api/*` to API.
|
**Critical:** `EnvelopeGenerator.Server` is the active runtime host for both UI and API controllers.
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
```powershell
|
```powershell
|
||||||
@@ -104,10 +100,10 @@ dotnet build EnvelopeGenerator.sln
|
|||||||
EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.)
|
EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.)
|
||||||
EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers)
|
EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers)
|
||||||
EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories
|
EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories
|
||||||
EnvelopeGenerator.API/ # Controllers, endpoints
|
|
||||||
EnvelopeGenerator.Server/
|
EnvelopeGenerator.Server/
|
||||||
EnvelopeGenerator.Server/ # Server-side Blazor components
|
EnvelopeGenerator.Server/ # Server-side Blazor + API controllers
|
||||||
├─ Components/Pages/ # @rendermode InteractiveServer
|
├─ Components/Pages/ # @rendermode InteractiveServer
|
||||||
|
├─ Controllers/ # Active Web API endpoints
|
||||||
EnvelopeGenerator.Server.Client/ # Client-side WASM components
|
EnvelopeGenerator.Server.Client/ # Client-side WASM components
|
||||||
├─ Pages/ # @rendermode InteractiveWebAssembly
|
├─ Pages/ # @rendermode InteractiveWebAssembly
|
||||||
├─ Services/ # HTTP API clients
|
├─ Services/ # HTTP API clients
|
||||||
@@ -156,15 +152,13 @@ float inches = (pixelX / canvasWidth) * pageWidthInches;
|
|||||||
- **Use case:** External API consumers
|
- **Use case:** External API consumers
|
||||||
- **Not suitable for:** Step-by-step UI workflow (no draft support, no partial updates)
|
- **Not suitable for:** Step-by-step UI workflow (no draft support, no partial updates)
|
||||||
|
|
||||||
### Missing Granular Endpoints (Need to Create)
|
### Sender Workflow Endpoint
|
||||||
```
|
```
|
||||||
POST /api/Envelope/draft # Create draft envelope
|
POST /api/Envelope # Create/update/save/send envelope workflow payload
|
||||||
PUT /api/Envelope/{id} # Update metadata
|
|
||||||
DELETE /api/Envelope/{id} # Delete with reason
|
DELETE /api/Envelope/{id} # Delete with reason
|
||||||
POST /api/Envelope/{id}/document # Upload PDF
|
POST /api/Envelope/{id}/document # Upload PDF
|
||||||
POST /api/Envelope/{id}/receivers # Add receiver
|
POST /api/Envelope/{id}/receivers # Add receiver
|
||||||
POST /api/Envelope/{id}/signature-fields # Place signature field
|
POST /api/Envelope/{id}/signature-fields # Place signature field
|
||||||
POST /api/Envelope/{id}/send # Send to receivers
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See `FORM_APPLICATION_CONTEXT.md` for detailed workflow requirements.
|
See `FORM_APPLICATION_CONTEXT.md` for detailed workflow requirements.
|
||||||
@@ -199,7 +193,7 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### API Config (`API/appsettings.json`)
|
### Server Host Config (`EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json`)
|
||||||
- `ConnectionStrings:Default` - SQL Server DB
|
- `ConnectionStrings:Default` - SQL Server DB
|
||||||
- `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`)
|
- `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`)
|
||||||
- `Cache:SignatureCacheExpiration` - Signature persistence timeout
|
- `Cache:SignatureCacheExpiration` - Signature persistence timeout
|
||||||
@@ -237,16 +231,21 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
|
|||||||
**No automated tests exist yet.**
|
**No automated tests exist yet.**
|
||||||
|
|
||||||
Manual testing workflow:
|
Manual testing workflow:
|
||||||
1. Start API (`dotnet run` in `EnvelopeGenerator.API`)
|
1. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
|
||||||
2. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
|
2. Navigate to `https://localhost:5131` (or check console output for port)
|
||||||
3. Navigate to `https://localhost:5131` (or check console output for port)
|
3. Test sender login at `/sender/login`
|
||||||
4. Test sender login at `/sender/login`
|
4. Test receiver flow at `/envelope/login/{envelopeKey}`
|
||||||
5. Test receiver flow at `/envelope/login/{envelopeKey}`
|
|
||||||
|
## Technical Debt TODOs
|
||||||
|
|
||||||
|
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity.
|
||||||
|
- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation).
|
||||||
|
- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
**SQL Server** (DD_ECM)
|
**SQL Server** (DD_ECM)
|
||||||
- Connection string in `API/appsettings.json`
|
- Connection string in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json`
|
||||||
- EF Core migrations NOT used (manual SQL scripts)
|
- EF Core migrations NOT used (manual SQL scripts)
|
||||||
- Stored procedures: `PRSIG_*` prefix
|
- Stored procedures: `PRSIG_*` prefix
|
||||||
|
|
||||||
@@ -320,5 +319,4 @@ Each envelope maintains independent authentication state.
|
|||||||
None required. All config in `appsettings.json`.
|
None required. All config in `appsettings.json`.
|
||||||
|
|
||||||
**Local dev ports:**
|
**Local dev ports:**
|
||||||
- API: `https://localhost:8088`
|
- App host (UI + API): `https://localhost:5131` or `http://localhost:7192`
|
||||||
- WebUI: `https://localhost:5131` or `http://localhost:7192`
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ This project contains:
|
|||||||
- `EnvelopeGenerator.Domain` — domain models, constants, shared abstractions
|
- `EnvelopeGenerator.Domain` — domain models, constants, shared abstractions
|
||||||
- `EnvelopeGenerator.Infrastructure` — EF Core and infrastructure services
|
- `EnvelopeGenerator.Infrastructure` — EF Core and infrastructure services
|
||||||
- `EnvelopeGenerator.PdfEditor` — PDF-related backend utilities
|
- `EnvelopeGenerator.PdfEditor` — PDF-related backend utilities
|
||||||
- `EnvelopeGenerator.API` — still exists in the solution, but the current merged app host is `EnvelopeGenerator.Server`
|
- `EnvelopeGenerator.API` — deprecated project (no longer part of the active solution/runtime path)
|
||||||
|
|
||||||
### Legacy / Do Not Touch
|
### Legacy / Do Not Touch
|
||||||
- `EnvelopeGenerator.Service`
|
- `EnvelopeGenerator.Service`
|
||||||
@@ -121,7 +121,7 @@ Current controller set includes:
|
|||||||
- `SignatureController`
|
- `SignatureController`
|
||||||
- `TfaRegistrationController`
|
- `TfaRegistrationController`
|
||||||
|
|
||||||
Do not assume API behavior lives only in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
|
Do not assume API behavior lives in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ public record EnvelopeReceiverDto
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? PrivateMessage { get; init; }
|
public string? PrivateMessage { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public string? AccessCode { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
|
||||||
|
public sealed class PdfPageImageDto
|
||||||
|
{
|
||||||
|
public int PageNumber { get; init; }
|
||||||
|
|
||||||
|
public double WidthPt { get; init; }
|
||||||
|
|
||||||
|
public double HeightPt { get; init; }
|
||||||
|
|
||||||
|
public int WidthPx { get; init; }
|
||||||
|
|
||||||
|
public int HeightPx { get; init; }
|
||||||
|
|
||||||
|
public string ImageDataUrl { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
|
||||||
|
public sealed class PdfRenderRequestDto
|
||||||
|
{
|
||||||
|
public byte[]? PdfBytes { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||||
|
|
||||||
|
public interface IPdfPageImageRenderer
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
|
||||||
|
|
||||||
|
public record EnvelopeDeletedNotification : INotification, ISendMailNotification
|
||||||
|
{
|
||||||
|
public required Envelope Envelope { get; init; }
|
||||||
|
|
||||||
|
public required EnvelopeReceiver EnvelopeReceiver { get; init; }
|
||||||
|
|
||||||
|
public required string Reason { get; init; }
|
||||||
|
|
||||||
|
public required string SenderReference { get; init; }
|
||||||
|
|
||||||
|
public EmailTemplateType TemplateType => EmailTemplateType.DocumentDeleted;
|
||||||
|
|
||||||
|
public string EmailAddress => EnvelopeReceiver.Receiver?.EmailAddress
|
||||||
|
?? throw new InvalidOperationException("Receiver email is missing for EnvelopeDeletedNotification.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using DigitalData.Core.Abstraction.Application.Repository;
|
||||||
|
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||||
|
using EnvelopeGenerator.Application.Common.Configurations;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted.Handlers;
|
||||||
|
|
||||||
|
public class SendDeletedMailHandler : SendMailHandler<EnvelopeDeletedNotification>
|
||||||
|
{
|
||||||
|
public SendDeletedMailHandler(
|
||||||
|
IRepository<EmailTemplate> tempRepo,
|
||||||
|
IRepository<EmailOut> emailOutRepo,
|
||||||
|
IOptions<MailParams> mailParamsOptions,
|
||||||
|
IOptions<DispatcherParams> dispatcherParamsOptions)
|
||||||
|
: base(tempRepo, emailOutRepo, mailParamsOptions, dispatcherParamsOptions)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ConfigureEmailOut(EnvelopeDeletedNotification notification, EmailOut emailOut)
|
||||||
|
{
|
||||||
|
emailOut.ReferenceString = notification.EmailAddress;
|
||||||
|
emailOut.ReferenceId = notification.EnvelopeReceiver.ReceiverId;
|
||||||
|
emailOut.WfId = (int)EnvelopeStatus.MessageDeletionSent;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Dictionary<string, string> CreatePlaceHolders(EnvelopeDeletedNotification notification)
|
||||||
|
{
|
||||||
|
var senderName = notification.Envelope.User?.Email;
|
||||||
|
if (string.IsNullOrWhiteSpace(senderName))
|
||||||
|
senderName = notification.SenderReference;
|
||||||
|
|
||||||
|
return new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["[NAME_RECEIVER]"] = notification.EnvelopeReceiver.Name ?? string.Empty,
|
||||||
|
["[NAME_SENDER]"] = senderName ?? string.Empty,
|
||||||
|
["[DOCUMENT_TITLE]"] = notification.Envelope.Title ?? string.Empty,
|
||||||
|
["[REASON]"] = notification.Reason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using DigitalData.Core.Abstraction.Application.Repository;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
@@ -13,23 +12,29 @@ namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||||
{
|
{
|
||||||
|
public int? EnvelopeId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
|
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
public string? Title { get; set; }
|
||||||
public required string Title { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Die Nachricht, die im Umschlag enthalten sein soll. Dies ist ein Pflichtfeld.
|
/// Die Nachricht, die im Umschlag enthalten sein soll. Dies ist ein Pflichtfeld.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
public string? Message { get; set; }
|
||||||
public required string Message { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gibt an, ob die Zwei-Faktor-Authentifizierung für den Umschlag aktiviert ist. Standardmäßig false.
|
/// Gibt an, ob die Zwei-Faktor-Authentifizierung für den Umschlag aktiviert ist. Standardmäßig false.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool TFAEnabled { get; set; } = false;
|
public bool TFAEnabled { get; set; } = false;
|
||||||
|
|
||||||
|
public bool Send { get; init; }
|
||||||
|
|
||||||
|
public CreateEnvelopeDocumentDto? Document { get; init; }
|
||||||
|
|
||||||
|
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ID des Absenders
|
/// ID des Absenders
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -56,3 +61,13 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
|||||||
[NotMapped]
|
[NotMapped]
|
||||||
public bool UseSQLExecutor { get; set; } = true;
|
public bool UseSQLExecutor { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record CreateEnvelopeDocumentDto(string DataAsBase64);
|
||||||
|
|
||||||
|
public record CreateEnvelopeFieldDto(double X, double Y, int Page);
|
||||||
|
|
||||||
|
public record CreateEnvelopeReceiverDto(
|
||||||
|
string EmailAddress,
|
||||||
|
string? Name,
|
||||||
|
string? PhoneNumber,
|
||||||
|
List<CreateEnvelopeFieldDto> Fields);
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using DigitalData.Core.Abstraction.Application.Repository;
|
using DigitalData.Core.Abstraction.Application.Repository;
|
||||||
|
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||||
|
using DigitalData.Core.Exceptions;
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
using EnvelopeGenerator.Application.Common.Configurations;
|
||||||
|
using EnvelopeGenerator.Application.Common.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||||
|
using EnvelopeGenerator.Application.Histories.Commands;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Domain;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
||||||
|
|
||||||
@@ -19,6 +29,26 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
|
|
||||||
private IRepository<Envelope> Repository => _provider.GetRequiredService<IRepository<Envelope>>();
|
private IRepository<Envelope> Repository => _provider.GetRequiredService<IRepository<Envelope>>();
|
||||||
|
|
||||||
|
private IRepository<Document> DocumentRepository => _provider.GetRequiredService<IRepository<Document>>();
|
||||||
|
|
||||||
|
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
|
||||||
|
|
||||||
|
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
|
||||||
|
|
||||||
|
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
|
||||||
|
|
||||||
|
private IRepository<EmailTemplate> EmailTemplateRepository => _provider.GetRequiredService<IRepository<EmailTemplate>>();
|
||||||
|
|
||||||
|
private IRepository<EmailOut> EmailOutRepository => _provider.GetRequiredService<IRepository<EmailOut>>();
|
||||||
|
|
||||||
|
private MailParams MailParams => _provider.GetRequiredService<IOptions<MailParams>>().Value;
|
||||||
|
|
||||||
|
private DispatcherParams DispatcherParams => _provider.GetRequiredService<IOptions<DispatcherParams>>().Value;
|
||||||
|
|
||||||
|
private IConfigService ConfigService => _provider.GetRequiredService<IConfigService>();
|
||||||
|
|
||||||
|
private IMediator Mediator => _provider.GetRequiredService<IMediator>();
|
||||||
|
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -40,10 +70,385 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<EnvelopeDto?> Handle(CreateEnvelopeCommand request, CancellationToken cancel)
|
public async Task<EnvelopeDto?> Handle(CreateEnvelopeCommand request, CancellationToken cancel)
|
||||||
{
|
{
|
||||||
var envelope = request.UseSQLExecutor
|
var now = DateTime.Now;
|
||||||
? await Executor.CreateEnvelopeAsync(request.UserId, request.Title, request.Message, request.TFAEnabled, cancel)
|
var title = request.Title?.Trim() ?? string.Empty;
|
||||||
: await Repository.CreateAsync(request, cancel);
|
var message = request.Message?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
Envelope envelope;
|
||||||
|
if (request.EnvelopeId is int envelopeId)
|
||||||
|
{
|
||||||
|
envelope = await Repository.Query
|
||||||
|
.Include(e => e.User)
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == envelopeId, cancel)
|
||||||
|
?? throw new BadRequestException($"Envelope {envelopeId} not found.");
|
||||||
|
|
||||||
|
if (envelope.UserId != request.UserId)
|
||||||
|
throw new ForbiddenException();
|
||||||
|
|
||||||
|
await Repository.UpdateAsync(e =>
|
||||||
|
{
|
||||||
|
e.Title = title;
|
||||||
|
e.Message = message;
|
||||||
|
e.TfaEnabled = request.TFAEnabled;
|
||||||
|
e.Status = EnvelopeStatus.EnvelopeSaved;
|
||||||
|
e.ChangedWhen = now;
|
||||||
|
}, e => e.Id == envelopeId, cancel);
|
||||||
|
|
||||||
|
envelope.Title = title;
|
||||||
|
envelope.Message = message;
|
||||||
|
envelope.TfaEnabled = request.TFAEnabled;
|
||||||
|
envelope.Status = EnvelopeStatus.EnvelopeSaved;
|
||||||
|
envelope.ChangedWhen = now;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
envelope = request.UseSQLExecutor
|
||||||
|
? await Executor.CreateEnvelopeAsync(request.UserId, title, message, request.TFAEnabled, cancel)
|
||||||
|
: await Repository.CreateAsync(new Envelope
|
||||||
|
{
|
||||||
|
UserId = request.UserId,
|
||||||
|
Status = EnvelopeStatus.EnvelopeCreated,
|
||||||
|
Uuid = Guid.NewGuid().ToString(),
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
AddedWhen = now,
|
||||||
|
ChangedWhen = now,
|
||||||
|
Comment = string.Empty,
|
||||||
|
Language = "de-DE",
|
||||||
|
TfaEnabled = request.TFAEnabled,
|
||||||
|
UseAccessCode = false,
|
||||||
|
SendReminderEmails = false
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
await Mediator.Send(new CreateHistoryCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = envelope.Id,
|
||||||
|
UserReference = await ResolveSenderReferenceAsync(envelope, cancel),
|
||||||
|
Status = EnvelopeStatus.EnvelopeCreated
|
||||||
|
}, cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
await UpsertDocumentAsync(envelope.Id, request.Document, now, cancel);
|
||||||
|
await UpsertReceiversAndFieldsAsync(envelope.Id, request.Receivers, now, cancel);
|
||||||
|
|
||||||
|
if (request.Send)
|
||||||
|
await SendEnvelopeAsync(envelope, cancel);
|
||||||
|
|
||||||
|
envelope = await Repository.Query
|
||||||
|
.Include(e => e.User)
|
||||||
|
.Include(e => e.EnvelopeReceivers)
|
||||||
|
.ThenInclude(er => er.Receiver)
|
||||||
|
.FirstAsync(e => e.Id == envelope.Id, cancel);
|
||||||
|
|
||||||
return _mapper.Map<EnvelopeDto>(envelope);
|
return _mapper.Map<EnvelopeDto>(envelope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(envelope.User?.Email))
|
||||||
|
return envelope.User.Email;
|
||||||
|
|
||||||
|
var senderEmail = await Repository.Query
|
||||||
|
.Where(e => e.Id == envelope.Id)
|
||||||
|
.Select(e => e.User.Email)
|
||||||
|
.FirstOrDefaultAsync(cancel);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(senderEmail))
|
||||||
|
throw new BadRequestException("Envelope sender email is missing.");
|
||||||
|
|
||||||
|
return senderEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpsertDocumentAsync(int envelopeId, CreateEnvelopeDocumentDto? document, DateTime now, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
if (document is null || string.IsNullOrWhiteSpace(document.DataAsBase64))
|
||||||
|
return;
|
||||||
|
|
||||||
|
byte[] bytes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(document.DataAsBase64);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Document payload is not valid base64.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingDocumentId = await DocumentRepository.Query
|
||||||
|
.Where(d => d.EnvelopeId == envelopeId)
|
||||||
|
.Select(d => (int?)d.Id)
|
||||||
|
.FirstOrDefaultAsync(cancel);
|
||||||
|
|
||||||
|
if (existingDocumentId is null)
|
||||||
|
{
|
||||||
|
await DocumentRepository.CreateAsync(new Document
|
||||||
|
{
|
||||||
|
EnvelopeId = envelopeId,
|
||||||
|
AddedWhen = now,
|
||||||
|
ByteData = bytes,
|
||||||
|
Filename = string.Empty,
|
||||||
|
Filepath = string.Empty,
|
||||||
|
FileNameOriginal = string.Empty
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DocumentRepository.UpdateAsync(d =>
|
||||||
|
{
|
||||||
|
d.ByteData = bytes;
|
||||||
|
}, d => d.Id == existingDocumentId.Value, cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpsertReceiversAndFieldsAsync(int envelopeId, IReadOnlyCollection<CreateEnvelopeReceiverDto> receivers, DateTime now, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var filteredReceivers = receivers
|
||||||
|
.Where(r => !string.IsNullOrWhiteSpace(r.EmailAddress))
|
||||||
|
.Select(r => r with
|
||||||
|
{
|
||||||
|
EmailAddress = r.EmailAddress.Trim().ToLowerInvariant(),
|
||||||
|
Name = r.Name?.Trim(),
|
||||||
|
PhoneNumber = string.IsNullOrWhiteSpace(r.PhoneNumber) ? null : r.PhoneNumber.Trim(),
|
||||||
|
Fields = r.Fields ?? new List<CreateEnvelopeFieldDto>()
|
||||||
|
})
|
||||||
|
.GroupBy(r => r.EmailAddress, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => g.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (filteredReceivers.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var document = await DocumentRepository.Query
|
||||||
|
.FirstOrDefaultAsync(d => d.EnvelopeId == envelopeId, cancel);
|
||||||
|
|
||||||
|
if (document is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var currentLinks = await EnvelopeReceiverRepository.Query
|
||||||
|
.Include(er => er.Receiver)
|
||||||
|
.Where(er => er.EnvelopeId == envelopeId)
|
||||||
|
.ToListAsync(cancel);
|
||||||
|
|
||||||
|
var linksByEmail = currentLinks
|
||||||
|
.Where(er => !string.IsNullOrWhiteSpace(er.Receiver?.EmailAddress))
|
||||||
|
.GroupBy(er => er.Receiver!.EmailAddress, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key.ToLowerInvariant(), g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var sequence = currentLinks.Count > 0 ? currentLinks.Max(er => er.Sequence) : 0;
|
||||||
|
|
||||||
|
foreach (var receiverDraft in filteredReceivers)
|
||||||
|
{
|
||||||
|
var linkResult = await GetOrCreateEnvelopeReceiverAsync(envelopeId, receiverDraft, linksByEmail, now, sequence, cancel);
|
||||||
|
var link = linkResult.Link;
|
||||||
|
sequence = linkResult.NextSequence;
|
||||||
|
|
||||||
|
await DocReceiverElementRepository.DeleteAsync(query => query
|
||||||
|
.Where(e => e.DocumentId == document.Id)
|
||||||
|
.Where(e => e.ReceiverId == link.ReceiverId), cancel);
|
||||||
|
|
||||||
|
foreach (var field in receiverDraft.Fields.Where(f => f.Page > 0))
|
||||||
|
{
|
||||||
|
await DocReceiverElementRepository.CreateAsync(new DocReceiverElement
|
||||||
|
{
|
||||||
|
DocumentId = document.Id,
|
||||||
|
ReceiverId = link.ReceiverId,
|
||||||
|
ElementType = 1,
|
||||||
|
X = field.X,
|
||||||
|
Y = field.Y,
|
||||||
|
Width = 1.77,
|
||||||
|
Height = 1.96,
|
||||||
|
Page = field.Page,
|
||||||
|
Required = true,
|
||||||
|
ReadOnly = false,
|
||||||
|
AnnotationIndex = 0,
|
||||||
|
AddedWhen = now,
|
||||||
|
FullName = link.Name,
|
||||||
|
Position = string.Empty,
|
||||||
|
Place = string.Empty,
|
||||||
|
Tooltip = string.Empty
|
||||||
|
}, cancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(EnvelopeReceiver Link, int NextSequence)> GetOrCreateEnvelopeReceiverAsync(
|
||||||
|
int envelopeId,
|
||||||
|
CreateEnvelopeReceiverDto receiverDraft,
|
||||||
|
IDictionary<string, EnvelopeReceiver> linksByEmail,
|
||||||
|
DateTime now,
|
||||||
|
int sequence,
|
||||||
|
CancellationToken cancel)
|
||||||
|
{
|
||||||
|
if (linksByEmail.TryGetValue(receiverDraft.EmailAddress, out var existingLink))
|
||||||
|
{
|
||||||
|
var nextName = string.IsNullOrWhiteSpace(receiverDraft.Name) ? existingLink.Name : receiverDraft.Name!;
|
||||||
|
var nextPhoneNumber = receiverDraft.PhoneNumber ?? existingLink.PhoneNumber;
|
||||||
|
|
||||||
|
await EnvelopeReceiverRepository.UpdateAsync(er =>
|
||||||
|
{
|
||||||
|
er.Name = nextName;
|
||||||
|
er.PhoneNumber = nextPhoneNumber;
|
||||||
|
er.ChangedWhen = now;
|
||||||
|
}, er => er.EnvelopeId == existingLink.EnvelopeId && er.ReceiverId == existingLink.ReceiverId, cancel);
|
||||||
|
|
||||||
|
existingLink.Name = nextName;
|
||||||
|
existingLink.PhoneNumber = nextPhoneNumber;
|
||||||
|
return (existingLink, sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
var receiver = await ReceiverRepository.Query
|
||||||
|
.FirstOrDefaultAsync(r => r.EmailAddress != null && r.EmailAddress.ToLower() == receiverDraft.EmailAddress, cancel);
|
||||||
|
|
||||||
|
if (receiver is null)
|
||||||
|
{
|
||||||
|
receiver = await ReceiverRepository.CreateAsync(new Receiver
|
||||||
|
{
|
||||||
|
EmailAddress = receiverDraft.EmailAddress,
|
||||||
|
Signature = receiverDraft.EmailAddress.ToUpperInvariant().GetChecksum(),
|
||||||
|
AddedWhen = now,
|
||||||
|
TotpSecretkey = string.Empty,
|
||||||
|
TfaRegDeadline = null
|
||||||
|
}, cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence++;
|
||||||
|
var link = await EnvelopeReceiverRepository.CreateAsync(new EnvelopeReceiver
|
||||||
|
{
|
||||||
|
EnvelopeId = envelopeId,
|
||||||
|
ReceiverId = receiver.Id,
|
||||||
|
Sequence = sequence,
|
||||||
|
Name = receiverDraft.Name ?? receiverDraft.EmailAddress,
|
||||||
|
JobTitle = string.Empty,
|
||||||
|
CompanyName = string.Empty,
|
||||||
|
PrivateMessage = string.Empty,
|
||||||
|
AccessCode = string.Empty,
|
||||||
|
AddedWhen = now,
|
||||||
|
ChangedWhen = now,
|
||||||
|
PhoneNumber = receiverDraft.PhoneNumber ?? string.Empty
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
link.Receiver = receiver;
|
||||||
|
linksByEmail[receiverDraft.EmailAddress] = link;
|
||||||
|
return (link, sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendEnvelopeAsync(Envelope envelope, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var hasDocument = await DocumentRepository.Query
|
||||||
|
.AnyAsync(d => d.EnvelopeId == envelope.Id && d.ByteData != null && d.ByteData.Length > 0, cancel);
|
||||||
|
|
||||||
|
if (!hasDocument)
|
||||||
|
throw new BadRequestException("Cannot send envelope without a document.");
|
||||||
|
|
||||||
|
var links = await EnvelopeReceiverRepository.Query
|
||||||
|
.Include(er => er.Receiver)
|
||||||
|
.Where(er => er.EnvelopeId == envelope.Id)
|
||||||
|
.ToListAsync(cancel);
|
||||||
|
|
||||||
|
if (links.Count == 0)
|
||||||
|
throw new BadRequestException("Cannot send envelope without receivers.");
|
||||||
|
|
||||||
|
var receiverIds = links.Select(l => l.ReceiverId).Distinct().ToList();
|
||||||
|
var documentIds = await DocumentRepository.Query
|
||||||
|
.Where(d => d.EnvelopeId == envelope.Id)
|
||||||
|
.Select(d => d.Id)
|
||||||
|
.ToListAsync(cancel);
|
||||||
|
|
||||||
|
var receiverFieldCounts = await DocReceiverElementRepository.Query
|
||||||
|
.Where(e => documentIds.Contains(e.DocumentId))
|
||||||
|
.Where(e => receiverIds.Contains(e.ReceiverId))
|
||||||
|
.GroupBy(e => e.ReceiverId)
|
||||||
|
.Select(g => new { ReceiverId = g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.ReceiverId, x => x.Count, cancel);
|
||||||
|
|
||||||
|
var receiversWithoutFields = links
|
||||||
|
.Where(link => !receiverFieldCounts.TryGetValue(link.ReceiverId, out var count) || count <= 0)
|
||||||
|
.Select(link => link.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (receiversWithoutFields.Count > 0)
|
||||||
|
throw new BadRequestException("Cannot send envelope because some receivers have no signature fields.");
|
||||||
|
|
||||||
|
var changedWhen = DateTime.Now;
|
||||||
|
await Repository.UpdateAsync(e =>
|
||||||
|
{
|
||||||
|
e.Status = EnvelopeStatus.EnvelopeQueued;
|
||||||
|
e.ChangedWhen = changedWhen;
|
||||||
|
}, e => e.Id == envelope.Id, cancel);
|
||||||
|
|
||||||
|
var userReference = await ResolveSenderReferenceAsync(envelope, cancel);
|
||||||
|
|
||||||
|
await Mediator.Send(new CreateHistoryCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = envelope.Id,
|
||||||
|
UserReference = userReference,
|
||||||
|
Status = EnvelopeStatus.EnvelopeQueued
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
await QueueInvitationEmailsAsync(envelope, links, userReference, cancel);
|
||||||
|
|
||||||
|
envelope.Status = EnvelopeStatus.EnvelopeQueued;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task QueueInvitationEmailsAsync(
|
||||||
|
Envelope envelope,
|
||||||
|
IReadOnlyCollection<EnvelopeReceiver> links,
|
||||||
|
string senderReference,
|
||||||
|
CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var template = await EmailTemplateRepository.Query
|
||||||
|
.SingleOrDefaultAsync(x => x.Name == EmailTemplateType.DocumentReceived.ToString(), cancel)
|
||||||
|
?? throw new InvalidOperationException($"Email template not found: {EmailTemplateType.DocumentReceived}");
|
||||||
|
|
||||||
|
var signatureHost = await ConfigService.ReadDefaultSignatureHost();
|
||||||
|
var senderName = senderReference;
|
||||||
|
|
||||||
|
foreach (var link in links)
|
||||||
|
{
|
||||||
|
var receiver = link.Receiver;
|
||||||
|
if (receiver is null || string.IsNullOrWhiteSpace(receiver.EmailAddress) || string.IsNullOrWhiteSpace(receiver.Signature))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var envelopeKey = (envelope.Uuid, receiver.Signature).ToEnvelopeKey();
|
||||||
|
var documentLink = $"{signatureHost.TrimEnd('/')}/envelope/login/{Uri.EscapeDataString(envelopeKey)}";
|
||||||
|
|
||||||
|
var placeholders = new Dictionary<string, string>(MailParams.Placeholders)
|
||||||
|
{
|
||||||
|
["[NAME_RECEIVER]"] = link.Name ?? string.Empty,
|
||||||
|
["[NAME_SENDER]"] = senderName ?? string.Empty,
|
||||||
|
["[DOCUMENT_TITLE]"] = envelope.Title ?? string.Empty,
|
||||||
|
["[MESSAGE]"] = envelope.Message ?? string.Empty,
|
||||||
|
["[LINK_TO_DOCUMENT]"] = documentLink,
|
||||||
|
["[LINK_TO_DOCUMENT_TEXT]"] = documentLink[..Math.Min(40, documentLink.Length)] + ".."
|
||||||
|
};
|
||||||
|
|
||||||
|
var subject = ReplacePlaceHolders(template.Subject, placeholders);
|
||||||
|
var body = ReplacePlaceHolders(template.Body, placeholders);
|
||||||
|
|
||||||
|
var emailOut = new EmailOut
|
||||||
|
{
|
||||||
|
EmailAddress = receiver.EmailAddress,
|
||||||
|
EmailSubj = subject,
|
||||||
|
EmailBody = body,
|
||||||
|
AddedWhen = DateTime.Now,
|
||||||
|
AddedWho = DispatcherParams.AddedWho,
|
||||||
|
SendingProfile = DispatcherParams.SendingProfile,
|
||||||
|
ReminderTypeId = DispatcherParams.ReminderTypeId,
|
||||||
|
EmailAttmt1 = DispatcherParams.EmailAttmt1,
|
||||||
|
WfId = (int)EnvelopeStatus.MessageInvitationSent,
|
||||||
|
ReferenceString = receiver.EmailAddress,
|
||||||
|
ReferenceId = link.ReceiverId
|
||||||
|
};
|
||||||
|
|
||||||
|
await EmailOutRepository.CreateAsync(emailOut, cancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReplacePlaceHolders(string text, Dictionary<string, string> placeHolders)
|
||||||
|
{
|
||||||
|
foreach (var placeHolder in placeHolders)
|
||||||
|
text = text.Replace(placeHolder.Key, placeHolder.Value);
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
||||||
|
|
||||||
|
public record DeleteEnvelopeResultDto(int EnvelopeId, EnvelopeStatus AppliedStatus, bool IsNoOp);
|
||||||
|
|
||||||
|
public record DeleteEnvelopeCommand : IRequest<DeleteEnvelopeResultDto>
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public int EnvelopeId { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MinLength(1)]
|
||||||
|
public string Reason { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
internal int UserId { get; private set; }
|
||||||
|
|
||||||
|
public DeleteEnvelopeCommand WithAuth(int userId)
|
||||||
|
{
|
||||||
|
UserId = userId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
|
||||||
|
using DigitalData.Core.Abstraction.Application.Repository;
|
||||||
|
using DigitalData.Core.Exceptions;
|
||||||
|
using EnvelopeGenerator.Application.Histories.Commands;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
||||||
|
|
||||||
|
public class DeleteEnvelopeCommandHandler : IRequestHandler<DeleteEnvelopeCommand, DeleteEnvelopeResultDto>
|
||||||
|
{
|
||||||
|
private readonly IRepository<Envelope> _envelopeRepository;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
public DeleteEnvelopeCommandHandler(
|
||||||
|
IRepository<Envelope> envelopeRepository,
|
||||||
|
IMediator mediator)
|
||||||
|
{
|
||||||
|
_envelopeRepository = envelopeRepository;
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeleteEnvelopeResultDto> Handle(DeleteEnvelopeCommand request, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var reason = request.Reason?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(reason))
|
||||||
|
throw new BadRequestException("Please provide a reason.");
|
||||||
|
|
||||||
|
var envelope = await _envelopeRepository.Query
|
||||||
|
.Include(e => e.User)
|
||||||
|
.Include(e => e.EnvelopeReceivers)
|
||||||
|
.ThenInclude(er => er.Receiver)
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == request.EnvelopeId, cancel)
|
||||||
|
?? throw new NotFoundException($"Envelope {request.EnvelopeId} not found.");
|
||||||
|
|
||||||
|
if (envelope.UserId != request.UserId)
|
||||||
|
throw new ForbiddenException();
|
||||||
|
|
||||||
|
if (envelope.Status is EnvelopeStatus.EnvelopeDeleted or EnvelopeStatus.EnvelopeWithdrawn)
|
||||||
|
return new DeleteEnvelopeResultDto(envelope.Id, envelope.Status, IsNoOp: true);
|
||||||
|
|
||||||
|
var targetStatus = envelope.Status > EnvelopeStatus.EnvelopeSaved
|
||||||
|
? EnvelopeStatus.EnvelopeWithdrawn
|
||||||
|
: EnvelopeStatus.EnvelopeDeleted;
|
||||||
|
|
||||||
|
var changedWhen = DateTime.Now;
|
||||||
|
envelope.Status = targetStatus;
|
||||||
|
envelope.Comment = reason;
|
||||||
|
envelope.ChangedWhen = changedWhen;
|
||||||
|
|
||||||
|
await _envelopeRepository.UpdateAsync(e =>
|
||||||
|
{
|
||||||
|
e.Status = targetStatus;
|
||||||
|
e.Comment = reason;
|
||||||
|
e.ChangedWhen = changedWhen;
|
||||||
|
}, e => e.Id == envelope.Id, cancel);
|
||||||
|
|
||||||
|
var userReference = envelope.User?.Email;
|
||||||
|
if (string.IsNullOrWhiteSpace(userReference))
|
||||||
|
throw new BadRequestException("Envelope sender email is missing.");
|
||||||
|
|
||||||
|
await _mediator.Send(new CreateHistoryCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = envelope.Id,
|
||||||
|
UserReference = userReference,
|
||||||
|
Status = targetStatus,
|
||||||
|
Comment = reason
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
foreach (var envelopeReceiver in envelope.EnvelopeReceivers ?? Enumerable.Empty<EnvelopeReceiver>())
|
||||||
|
{
|
||||||
|
if (envelopeReceiver.Receiver is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await _mediator.Publish(new EnvelopeDeletedNotification
|
||||||
|
{
|
||||||
|
Envelope = envelope,
|
||||||
|
EnvelopeReceiver = envelopeReceiver,
|
||||||
|
Reason = reason,
|
||||||
|
SenderReference = userReference
|
||||||
|
}, cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DeleteEnvelopeResultDto(envelope.Id, targetStatus, IsNoOp: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,7 +149,9 @@ public class ReadEnvelopeQueryHandler : IRequestHandler<ReadEnvelopeQuery, IEnum
|
|||||||
query = query.Where(e => Status.Completed.Contains(e.Status));
|
query = query.Where(e => Status.Completed.Contains(e.Status));
|
||||||
|
|
||||||
var envelopes = await query
|
var envelopes = await query
|
||||||
|
.Include(e => e.Type)
|
||||||
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
|
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
|
||||||
|
.Include(e => e.Histories)
|
||||||
.ToListAsync(cancel);
|
.ToListAsync(cancel);
|
||||||
|
|
||||||
return _mapper.Map<IEnumerable<EnvelopeDto>>(envelopes);
|
return _mapper.Map<IEnumerable<EnvelopeDto>>(envelopes);
|
||||||
|
|||||||
@@ -651,4 +651,125 @@
|
|||||||
<data name="Status" xml:space="preserve">
|
<data name="Status" xml:space="preserve">
|
||||||
<value>Status</value>
|
<value>Status</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="ChangedAt" xml:space="preserve"><value>Geändert am</value></data>
|
||||||
|
<data name="Close" xml:space="preserve"><value>Schließen</value></data>
|
||||||
|
<data name="CloseLanguageMenu" xml:space="preserve"><value>Sprachmenü schließen</value></data>
|
||||||
|
<data name="ConfirmSignature" xml:space="preserve"><value>Unterschrift bestätigen</value></data>
|
||||||
|
<data name="Created" xml:space="preserve"><value>Erstellt</value></data>
|
||||||
|
<data name="Delete" xml:space="preserve"><value>Löschen</value></data>
|
||||||
|
<data name="Document" xml:space="preserve"><value>Dokument</value></data>
|
||||||
|
<data name="DocumentCouldNotBeLoaded" xml:space="preserve"><value>Dokument konnte nicht geladen werden</value></data>
|
||||||
|
<data name="DocumentLoading" xml:space="preserve"><value>Dokument wird geladen...</value></data>
|
||||||
|
<data name="EmailAddress" xml:space="preserve"><value>E-Mail-Adresse</value></data>
|
||||||
|
<data name="Envelope" xml:space="preserve"><value>Umschlag</value></data>
|
||||||
|
<data name="Error" xml:space="preserve"><value>Fehler</value></data>
|
||||||
|
<data name="ErrorLoadingDocument_WithReason" xml:space="preserve"><value>Fehler beim Laden des Dokuments: {0}</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode" xml:space="preserve"><value>Entwicklungsmodus</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode_Detail" xml:space="preserve"><value>Dies kann dazu führen, dass vertrauliche Informationen aus Ausnahmen Endbenutzern angezeigt werden. Für lokales Debugging setzen Sie ASPNETCORE_ENVIRONMENT auf Development und starten die App neu.</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode_Info" xml:space="preserve"><value>Im Entwicklungsmodus werden detailliertere Fehlerinformationen angezeigt.</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode_Warning" xml:space="preserve"><value>Der Entwicklungsmodus sollte in bereitgestellten Anwendungen nicht aktiviert sein.</value></data>
|
||||||
|
<data name="ErrorPage_ProcessingRequest" xml:space="preserve"><value>Bei der Verarbeitung Ihrer Anfrage ist ein Fehler aufgetreten.</value></data>
|
||||||
|
<data name="ErrorPage_RequestId" xml:space="preserve"><value>Anfrage-ID</value></data>
|
||||||
|
<data name="ErrorWithReason" xml:space="preserve"><value>Fehler: {0}</value></data>
|
||||||
|
<data name="From" xml:space="preserve"><value>Von</value></data>
|
||||||
|
<data name="FullName" xml:space="preserve"><value>Vor- und Nachname</value></data>
|
||||||
|
<data name="History" xml:space="preserve"><value>Historie</value></data>
|
||||||
|
<data name="Index_DigitalSignature" xml:space="preserve"><value>Digitale Unterschrift</value></data>
|
||||||
|
<data name="Index_PdfExport" xml:space="preserve"><value>PDF-Export</value></data>
|
||||||
|
<data name="Index_SecureAccess" xml:space="preserve"><value>Sicherer Zugang</value></data>
|
||||||
|
<data name="Index_Subtitle" xml:space="preserve"><value>Willkommen im eSign-Portal</value></data>
|
||||||
|
<data name="Index_ToEnvelopeOverview" xml:space="preserve"><value>Zur Umschlag-Übersicht</value></data>
|
||||||
|
<data name="Languages" xml:space="preserve"><value>Sprachen</value></data>
|
||||||
|
<data name="Loading" xml:space="preserve"><value>Lädt...</value></data>
|
||||||
|
<data name="Login" xml:space="preserve"><value>Anmelden</value></data>
|
||||||
|
<data name="LoginInProgress" xml:space="preserve"><value>Anmelden ...</value></data>
|
||||||
|
<data name="NotAuthenticatedPleaseLoginAgain" xml:space="preserve"><value>Nicht authentifiziert. Bitte melden Sie sich erneut an.</value></data>
|
||||||
|
<data name="Only" xml:space="preserve"><value>Nur</value></data>
|
||||||
|
<data name="OpenDocument" xml:space="preserve"><value>Dokument öffnen</value></data>
|
||||||
|
<data name="Optional" xml:space="preserve"><value>optional</value></data>
|
||||||
|
<data name="Pending" xml:space="preserve"><value>Ausstehend</value></data>
|
||||||
|
<data name="Preview" xml:space="preserve"><value>Vorschau</value></data>
|
||||||
|
<data name="PrivacyPolicy" xml:space="preserve"><value>Datenschutz</value></data>
|
||||||
|
<data name="Receiver" xml:space="preserve"><value>Empfänger</value></data>
|
||||||
|
<data name="ReceiverLogin_AccessCode_Placeholder" xml:space="preserve"><value>Zugangscode eingeben</value></data>
|
||||||
|
<data name="ReceiverLogin_Description" xml:space="preserve"><value>Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.</value></data>
|
||||||
|
<data name="ReceiverLogin_FooterHelp" xml:space="preserve"><value>Bei Problemen wenden Sie sich bitte an den Absender des Dokuments.</value></data>
|
||||||
|
<data name="ReceiverLogin_InvalidCode_Body" xml:space="preserve"><value>Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</value></data>
|
||||||
|
<data name="ReceiverLogin_InvalidCode_Title" xml:space="preserve"><value>Ungültiger Zugangscode.</value></data>
|
||||||
|
<data name="ReceiverLogin_NotFound_Body" xml:space="preserve"><value>Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</value></data>
|
||||||
|
<data name="ReceiverLogin_NotFound_Title" xml:space="preserve"><value>Dokument nicht gefunden.</value></data>
|
||||||
|
<data name="ReceiverLogin_Subtitle" xml:space="preserve"><value>Sicherer Zugang mit Zugangscode</value></data>
|
||||||
|
<data name="ReceiverSigned_ConfirmBody" xml:space="preserve"><value>Diese Aktion kann nicht rückgängig gemacht werden. Mit der Bestätigung erklären Sie, das oben angezeigte Dokument elektronisch unterzeichnet zu haben. Das unterzeichnete Dokument wird anschließend an alle beteiligten Parteien übermittelt.</value></data>
|
||||||
|
<data name="ReceiverSigned_ConfirmQuestion" xml:space="preserve"><value>Möchten Sie das Dokument verbindlich unterschreiben?</value></data>
|
||||||
|
<data name="ReceiverSigned_EnvelopeKeyMissing" xml:space="preserve"><value>Envelope-Schlüssel fehlt.</value></data>
|
||||||
|
<data name="Refresh" xml:space="preserve"><value>Aktualisieren</value></data>
|
||||||
|
<data name="RequiredField" xml:space="preserve"><value>Pflichtfeld</value></data>
|
||||||
|
<data name="Selected" xml:space="preserve"><value>ausgewählte</value></data>
|
||||||
|
<data name="Send" xml:space="preserve"><value>Senden</value></data>
|
||||||
|
<data name="Sender_ActiveEnvelopes" xml:space="preserve"><value>Aktive Umschläge</value></data>
|
||||||
|
<data name="Sender_CompletedEnvelopes" xml:space="preserve"><value>Abgeschlossene Umschläge</value></data>
|
||||||
|
<data name="Sender_CreateEnvelope" xml:space="preserve"><value>Neuer Umschlag</value></data>
|
||||||
|
<data name="Sender_CreateEnvelope_Title" xml:space="preserve"><value>Neuen Umschlag erstellen</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_Header" xml:space="preserve"><value>Umschlag löschen</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_ReasonPlaceholder" xml:space="preserve"><value>Begründung eingeben...</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_ReasonPrompt" xml:space="preserve"><value>Bitte geben Sie einen Grund an, warum der Umschlag gelöscht/zurückgezogen werden soll.</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_ReasonRequired" xml:space="preserve"><value>Bitte geben Sie einen Löschgrund an.</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_Title" xml:space="preserve"><value>Ausgewählten Umschlag löschen</value></data>
|
||||||
|
<data name="Sender_Edit" xml:space="preserve"><value>Bearbeiten</value></data>
|
||||||
|
<data name="Sender_EditEnvelope_Title" xml:space="preserve"><value>Ausgewählten Umschlag bearbeiten</value></data>
|
||||||
|
<data name="Sender_ExportCurrentView_Title" xml:space="preserve"><value>Aktuelle Ansicht exportieren</value></data>
|
||||||
|
<data name="Sender_ExportFormat_Aria" xml:space="preserve"><value>Exportformat auswählen</value></data>
|
||||||
|
<data name="Sender_ExportView_Title" xml:space="preserve"><value>Export der aktuellen Tabellenansicht</value></data>
|
||||||
|
<data name="Sender_LoadError_Title" xml:space="preserve"><value>Fehler beim Laden der Umschläge</value></data>
|
||||||
|
<data name="Sender_LoadingEnvelopes" xml:space="preserve"><value>Umschläge werden geladen...</value></data>
|
||||||
|
<data name="Sender_NoHistory" xml:space="preserve"><value>Keine Historie vorhanden</value></data>
|
||||||
|
<data name="Sender_NoReceivers" xml:space="preserve"><value>Keine Empfänger</value></data>
|
||||||
|
<data name="Sender_Preview_Title" xml:space="preserve"><value>Ausgewählten Umschlag als PDF anzeigen</value></data>
|
||||||
|
<data name="Sender_PreviewDocumentNotFound" xml:space="preserve"><value>Das Dokument für Umschlag #{0} wurde nicht gefunden oder Sie haben keinen Zugriff darauf.</value></data>
|
||||||
|
<data name="SenderEditor_AddSignature" xml:space="preserve"><value>Signatur hinzufügen</value></data>
|
||||||
|
<data name="SenderEditor_ClickDocumentFor" xml:space="preserve"><value>Klicken Sie auf die Stelle im Dokument für</value></data>
|
||||||
|
<data name="SenderEditor_EmailSuggestionsHint" xml:space="preserve"><value>Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.</value></data>
|
||||||
|
<data name="SenderEditor_EmailSuggestionsLoading" xml:space="preserve"><value>E-Mail-Vorschläge werden geladen...</value></data>
|
||||||
|
<data name="SenderEditor_ErrorCreatingEnvelope" xml:space="preserve"><value>Fehler beim Erstellen des Umschlags</value></data>
|
||||||
|
<data name="SenderEditor_LoadEnvelopeError" xml:space="preserve"><value>Fehler beim Laden des Umschlags #{0}: {1}</value></data>
|
||||||
|
<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>
|
||||||
|
<data name="SenderEditor_Sent_Body" xml:space="preserve"><value>Der bestehende Entwurf wurde versendet.</value></data>
|
||||||
|
<data name="SenderEditor_Sent_Header" xml:space="preserve"><value>Umschlag gesendet</value></data>
|
||||||
|
<data name="SenderEditor_Sent_Title" xml:space="preserve"><value>Umschlag wurde erfolgreich gesendet.</value></data>
|
||||||
|
<data name="SenderEditor_SignatureField" xml:space="preserve"><value>Signaturfeld</value></data>
|
||||||
|
<data name="SenderEditor_Title_Placeholder" xml:space="preserve"><value>z.B. Arbeitsvertrag 2025</value></data>
|
||||||
|
<data name="SenderEditor_ToOverview" xml:space="preserve"><value>Zur Übersicht</value></data>
|
||||||
|
<data name="SenderEditor_UploadPdfToPlaceFields" xml:space="preserve"><value>Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_EmailDuplicate" xml:space="preserve"><value>Diese E-Mail-Adresse wurde bereits hinzugefügt.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_EmailInvalid" xml:space="preserve"><value>Bitte geben Sie eine gültige E-Mail-Adresse ein.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_EmailRequired" xml:space="preserve"><value>Bitte geben Sie eine E-Mail-Adresse ein.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_FullNameRequired" xml:space="preserve"><value>Bitte geben Sie einen Vor- und Nachnamen ein.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_ReceiverRequired" xml:space="preserve"><value>Bitte fügen Sie mindestens einen Empfänger hinzu.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_SignatureFieldRequired" xml:space="preserve"><value>Bitte platzieren Sie mindestens ein Signaturfeld.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_TitleRequired" xml:space="preserve"><value>Bitte geben Sie einen Titel ein.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_UploadPdf" xml:space="preserve"><value>Bitte laden Sie zuerst ein PDF-Dokument hoch.</value></data>
|
||||||
|
<data name="SenderLogin_Description" xml:space="preserve"><value>Bitte melden Sie sich mit Ihren Zugangsdaten an, um auf das Sender-Dashboard zuzugreifen.</value></data>
|
||||||
|
<data name="SenderLogin_FooterHelp" xml:space="preserve"><value>Bei Problemen wenden Sie sich bitte an den Administrator.</value></data>
|
||||||
|
<data name="SenderLogin_InvalidCredentials_Body" xml:space="preserve"><value>Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</value></data>
|
||||||
|
<data name="SenderLogin_InvalidCredentials_Title" xml:space="preserve"><value>Ungültige Anmeldedaten.</value></data>
|
||||||
|
<data name="SenderLogin_Password_Placeholder" xml:space="preserve"><value>Passwort eingeben</value></data>
|
||||||
|
<data name="SenderLogin_Subtitle" xml:space="preserve"><value>Sicherer Zugang zum Sender-Dashboard</value></data>
|
||||||
|
<data name="SenderLogin_Title" xml:space="preserve"><value>Sender Anmeldung</value></data>
|
||||||
|
<data name="SenderLogin_Username_Placeholder" xml:space="preserve"><value>Benutzername eingeben</value></data>
|
||||||
|
<data name="ServerError" xml:space="preserve"><value>Serverfehler.</value></data>
|
||||||
|
<data name="Signed" xml:space="preserve"><value>Unterschrieben</value></data>
|
||||||
|
<data name="SignedDocument" xml:space="preserve"><value>Signiertes Dokument</value></data>
|
||||||
|
<data name="System" xml:space="preserve"><value>System</value></data>
|
||||||
|
<data name="UnexpectedErrorTryAgainLater" xml:space="preserve"><value>Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</value></data>
|
||||||
|
<data name="UploadPdf" xml:space="preserve"><value>PDF hochladen</value></data>
|
||||||
|
<data name="UserName" xml:space="preserve"><value>Benutzername</value></data>
|
||||||
|
<data name="VerifyInProgress" xml:space="preserve"><value>Überprüfen ...</value></data>
|
||||||
</root>
|
</root>
|
||||||
@@ -651,4 +651,125 @@
|
|||||||
<data name="Status" xml:space="preserve">
|
<data name="Status" xml:space="preserve">
|
||||||
<value>Status</value>
|
<value>Status</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="ChangedAt" xml:space="preserve"><value>Changed at</value></data>
|
||||||
|
<data name="Close" xml:space="preserve"><value>Close</value></data>
|
||||||
|
<data name="CloseLanguageMenu" xml:space="preserve"><value>Close language menu</value></data>
|
||||||
|
<data name="ConfirmSignature" xml:space="preserve"><value>Confirm signature</value></data>
|
||||||
|
<data name="Created" xml:space="preserve"><value>Created</value></data>
|
||||||
|
<data name="Delete" xml:space="preserve"><value>Delete</value></data>
|
||||||
|
<data name="Document" xml:space="preserve"><value>Document</value></data>
|
||||||
|
<data name="DocumentCouldNotBeLoaded" xml:space="preserve"><value>Document could not be loaded</value></data>
|
||||||
|
<data name="DocumentLoading" xml:space="preserve"><value>Document is loading...</value></data>
|
||||||
|
<data name="EmailAddress" xml:space="preserve"><value>Email address</value></data>
|
||||||
|
<data name="Envelope" xml:space="preserve"><value>Envelope</value></data>
|
||||||
|
<data name="Error" xml:space="preserve"><value>Error</value></data>
|
||||||
|
<data name="ErrorLoadingDocument_WithReason" xml:space="preserve"><value>Error loading document: {0}</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode" xml:space="preserve"><value>Development Mode</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode_Detail" xml:space="preserve"><value>This can result in sensitive exception information being shown to end users. For local debugging, set ASPNETCORE_ENVIRONMENT to Development and restart the app.</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode_Info" xml:space="preserve"><value>Switching to Development will display more detailed error information.</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode_Warning" xml:space="preserve"><value>The Development environment should not be enabled in deployed applications.</value></data>
|
||||||
|
<data name="ErrorPage_ProcessingRequest" xml:space="preserve"><value>An error occurred while processing your request.</value></data>
|
||||||
|
<data name="ErrorPage_RequestId" xml:space="preserve"><value>Request ID</value></data>
|
||||||
|
<data name="ErrorWithReason" xml:space="preserve"><value>Error: {0}</value></data>
|
||||||
|
<data name="From" xml:space="preserve"><value>From</value></data>
|
||||||
|
<data name="FullName" xml:space="preserve"><value>First and last name</value></data>
|
||||||
|
<data name="History" xml:space="preserve"><value>History</value></data>
|
||||||
|
<data name="Index_DigitalSignature" xml:space="preserve"><value>Digital signature</value></data>
|
||||||
|
<data name="Index_PdfExport" xml:space="preserve"><value>PDF export</value></data>
|
||||||
|
<data name="Index_SecureAccess" xml:space="preserve"><value>Secure access</value></data>
|
||||||
|
<data name="Index_Subtitle" xml:space="preserve"><value>Welcome to the eSign portal</value></data>
|
||||||
|
<data name="Index_ToEnvelopeOverview" xml:space="preserve"><value>Go to envelope overview</value></data>
|
||||||
|
<data name="Languages" xml:space="preserve"><value>Languages</value></data>
|
||||||
|
<data name="Loading" xml:space="preserve"><value>Loading...</value></data>
|
||||||
|
<data name="Login" xml:space="preserve"><value>Sign in</value></data>
|
||||||
|
<data name="LoginInProgress" xml:space="preserve"><value>Signing in ...</value></data>
|
||||||
|
<data name="NotAuthenticatedPleaseLoginAgain" xml:space="preserve"><value>Not authenticated. Please sign in again.</value></data>
|
||||||
|
<data name="Only" xml:space="preserve"><value>Only</value></data>
|
||||||
|
<data name="OpenDocument" xml:space="preserve"><value>Open document</value></data>
|
||||||
|
<data name="Optional" xml:space="preserve"><value>optional</value></data>
|
||||||
|
<data name="Pending" xml:space="preserve"><value>Pending</value></data>
|
||||||
|
<data name="Preview" xml:space="preserve"><value>Preview</value></data>
|
||||||
|
<data name="PrivacyPolicy" xml:space="preserve"><value>Privacy policy</value></data>
|
||||||
|
<data name="Receiver" xml:space="preserve"><value>Receiver</value></data>
|
||||||
|
<data name="ReceiverLogin_AccessCode_Placeholder" xml:space="preserve"><value>Enter access code</value></data>
|
||||||
|
<data name="ReceiverLogin_Description" xml:space="preserve"><value>Please enter the access code you received by email to securely open the document.</value></data>
|
||||||
|
<data name="ReceiverLogin_FooterHelp" xml:space="preserve"><value>If you have issues, please contact the document sender.</value></data>
|
||||||
|
<data name="ReceiverLogin_InvalidCode_Body" xml:space="preserve"><value>The entered code is incorrect. Please try again.</value></data>
|
||||||
|
<data name="ReceiverLogin_InvalidCode_Title" xml:space="preserve"><value>Invalid access code.</value></data>
|
||||||
|
<data name="ReceiverLogin_NotFound_Body" xml:space="preserve"><value>The provided access code could not be matched to a document. Please verify the link in your email.</value></data>
|
||||||
|
<data name="ReceiverLogin_NotFound_Title" xml:space="preserve"><value>Document not found.</value></data>
|
||||||
|
<data name="ReceiverLogin_Subtitle" xml:space="preserve"><value>Secure access with access code</value></data>
|
||||||
|
<data name="ReceiverSigned_ConfirmBody" xml:space="preserve"><value>This action cannot be undone. By confirming, you declare that you have electronically signed the document shown above. The signed document will then be delivered to all involved parties.</value></data>
|
||||||
|
<data name="ReceiverSigned_ConfirmQuestion" xml:space="preserve"><value>Do you want to sign this document as binding?</value></data>
|
||||||
|
<data name="ReceiverSigned_EnvelopeKeyMissing" xml:space="preserve"><value>Envelope key is missing.</value></data>
|
||||||
|
<data name="Refresh" xml:space="preserve"><value>Refresh</value></data>
|
||||||
|
<data name="RequiredField" xml:space="preserve"><value>Required field</value></data>
|
||||||
|
<data name="Selected" xml:space="preserve"><value>selected</value></data>
|
||||||
|
<data name="Send" xml:space="preserve"><value>Send</value></data>
|
||||||
|
<data name="Sender_ActiveEnvelopes" xml:space="preserve"><value>Active envelopes</value></data>
|
||||||
|
<data name="Sender_CompletedEnvelopes" xml:space="preserve"><value>Completed envelopes</value></data>
|
||||||
|
<data name="Sender_CreateEnvelope" xml:space="preserve"><value>New envelope</value></data>
|
||||||
|
<data name="Sender_CreateEnvelope_Title" xml:space="preserve"><value>Create new envelope</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_Header" xml:space="preserve"><value>Delete envelope</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_ReasonPlaceholder" xml:space="preserve"><value>Enter reason...</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_ReasonPrompt" xml:space="preserve"><value>Please provide a reason why the envelope should be deleted/withdrawn.</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_ReasonRequired" xml:space="preserve"><value>Please provide a deletion reason.</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_Title" xml:space="preserve"><value>Delete selected envelope</value></data>
|
||||||
|
<data name="Sender_Edit" xml:space="preserve"><value>Edit</value></data>
|
||||||
|
<data name="Sender_EditEnvelope_Title" xml:space="preserve"><value>Edit selected envelope</value></data>
|
||||||
|
<data name="Sender_ExportCurrentView_Title" xml:space="preserve"><value>Export current view</value></data>
|
||||||
|
<data name="Sender_ExportFormat_Aria" xml:space="preserve"><value>Select export format</value></data>
|
||||||
|
<data name="Sender_ExportView_Title" xml:space="preserve"><value>Export current table view</value></data>
|
||||||
|
<data name="Sender_LoadError_Title" xml:space="preserve"><value>Error loading envelopes</value></data>
|
||||||
|
<data name="Sender_LoadingEnvelopes" xml:space="preserve"><value>Loading envelopes...</value></data>
|
||||||
|
<data name="Sender_NoHistory" xml:space="preserve"><value>No history available</value></data>
|
||||||
|
<data name="Sender_NoReceivers" xml:space="preserve"><value>No receivers</value></data>
|
||||||
|
<data name="Sender_Preview_Title" xml:space="preserve"><value>Show selected envelope as PDF</value></data>
|
||||||
|
<data name="Sender_PreviewDocumentNotFound" xml:space="preserve"><value>The document for envelope #{0} was not found or you do not have access.</value></data>
|
||||||
|
<data name="SenderEditor_AddSignature" xml:space="preserve"><value>Add signature</value></data>
|
||||||
|
<data name="SenderEditor_ClickDocumentFor" xml:space="preserve"><value>Click on the location in the document for</value></data>
|
||||||
|
<data name="SenderEditor_EmailSuggestionsHint" xml:space="preserve"><value>Previously used email addresses are suggested while typing.</value></data>
|
||||||
|
<data name="SenderEditor_EmailSuggestionsLoading" xml:space="preserve"><value>Loading email suggestions...</value></data>
|
||||||
|
<data name="SenderEditor_ErrorCreatingEnvelope" xml:space="preserve"><value>Error creating envelope</value></data>
|
||||||
|
<data name="SenderEditor_LoadEnvelopeError" xml:space="preserve"><value>Error loading envelope #{0}: {1}</value></data>
|
||||||
|
<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>
|
||||||
|
<data name="SenderEditor_Sent_Body" xml:space="preserve"><value>The existing draft has been sent.</value></data>
|
||||||
|
<data name="SenderEditor_Sent_Header" xml:space="preserve"><value>Envelope sent</value></data>
|
||||||
|
<data name="SenderEditor_Sent_Title" xml:space="preserve"><value>Envelope was sent successfully.</value></data>
|
||||||
|
<data name="SenderEditor_SignatureField" xml:space="preserve"><value>Signature field</value></data>
|
||||||
|
<data name="SenderEditor_Title_Placeholder" xml:space="preserve"><value>e.g. Employment contract 2025</value></data>
|
||||||
|
<data name="SenderEditor_ToOverview" xml:space="preserve"><value>To overview</value></data>
|
||||||
|
<data name="SenderEditor_UploadPdfToPlaceFields" xml:space="preserve"><value>Upload a PDF file to place signature fields.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_EmailDuplicate" xml:space="preserve"><value>This email address has already been added.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_EmailInvalid" xml:space="preserve"><value>Please enter a valid email address.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_EmailRequired" xml:space="preserve"><value>Please enter an email address.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_FullNameRequired" xml:space="preserve"><value>Please enter first and last name.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_ReceiverRequired" xml:space="preserve"><value>Please add at least one receiver.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_SignatureFieldRequired" xml:space="preserve"><value>Please place at least one signature field.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_TitleRequired" xml:space="preserve"><value>Please enter a title.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_UploadPdf" xml:space="preserve"><value>Please upload a PDF document first.</value></data>
|
||||||
|
<data name="SenderLogin_Description" xml:space="preserve"><value>Please sign in with your credentials to access the sender dashboard.</value></data>
|
||||||
|
<data name="SenderLogin_FooterHelp" xml:space="preserve"><value>If you have issues, please contact the administrator.</value></data>
|
||||||
|
<data name="SenderLogin_InvalidCredentials_Body" xml:space="preserve"><value>Username or password is incorrect. Please try again.</value></data>
|
||||||
|
<data name="SenderLogin_InvalidCredentials_Title" xml:space="preserve"><value>Invalid credentials.</value></data>
|
||||||
|
<data name="SenderLogin_Password_Placeholder" xml:space="preserve"><value>Enter password</value></data>
|
||||||
|
<data name="SenderLogin_Subtitle" xml:space="preserve"><value>Secure access to sender dashboard</value></data>
|
||||||
|
<data name="SenderLogin_Title" xml:space="preserve"><value>Sender login</value></data>
|
||||||
|
<data name="SenderLogin_Username_Placeholder" xml:space="preserve"><value>Enter username</value></data>
|
||||||
|
<data name="ServerError" xml:space="preserve"><value>Server error.</value></data>
|
||||||
|
<data name="Signed" xml:space="preserve"><value>Signed</value></data>
|
||||||
|
<data name="SignedDocument" xml:space="preserve"><value>Signed document</value></data>
|
||||||
|
<data name="System" xml:space="preserve"><value>System</value></data>
|
||||||
|
<data name="UnexpectedErrorTryAgainLater" xml:space="preserve"><value>An unexpected error occurred. Please try again later.</value></data>
|
||||||
|
<data name="UploadPdf" xml:space="preserve"><value>Upload PDF</value></data>
|
||||||
|
<data name="UserName" xml:space="preserve"><value>Username</value></data>
|
||||||
|
<data name="VerifyInProgress" xml:space="preserve"><value>Verifying ...</value></data>
|
||||||
</root>
|
</root>
|
||||||
@@ -651,4 +651,125 @@
|
|||||||
<data name="Status" xml:space="preserve">
|
<data name="Status" xml:space="preserve">
|
||||||
<value>Statut</value>
|
<value>Statut</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="ChangedAt" xml:space="preserve"><value>Modifié le</value></data>
|
||||||
|
<data name="Close" xml:space="preserve"><value>Fermer</value></data>
|
||||||
|
<data name="CloseLanguageMenu" xml:space="preserve"><value>Fermer le menu des langues</value></data>
|
||||||
|
<data name="ConfirmSignature" xml:space="preserve"><value>Confirmer la signature</value></data>
|
||||||
|
<data name="Created" xml:space="preserve"><value>Créé</value></data>
|
||||||
|
<data name="Delete" xml:space="preserve"><value>Supprimer</value></data>
|
||||||
|
<data name="Document" xml:space="preserve"><value>Document</value></data>
|
||||||
|
<data name="DocumentCouldNotBeLoaded" xml:space="preserve"><value>Le document n'a pas pu être chargé</value></data>
|
||||||
|
<data name="DocumentLoading" xml:space="preserve"><value>Chargement du document...</value></data>
|
||||||
|
<data name="EmailAddress" xml:space="preserve"><value>Adresse e-mail</value></data>
|
||||||
|
<data name="Envelope" xml:space="preserve"><value>Enveloppe</value></data>
|
||||||
|
<data name="Error" xml:space="preserve"><value>Erreur</value></data>
|
||||||
|
<data name="ErrorLoadingDocument_WithReason" xml:space="preserve"><value>Erreur lors du chargement du document : {0}</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode" xml:space="preserve"><value>Mode développement</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode_Detail" xml:space="preserve"><value>Cela peut afficher des informations sensibles d'exception aux utilisateurs finaux. Pour le débogage local, définissez ASPNETCORE_ENVIRONMENT sur Development et redémarrez l'application.</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode_Info" xml:space="preserve"><value>Le mode développement affiche des informations d'erreur plus détaillées.</value></data>
|
||||||
|
<data name="ErrorPage_DevelopmentMode_Warning" xml:space="preserve"><value>Le mode développement ne doit pas être activé dans les applications déployées.</value></data>
|
||||||
|
<data name="ErrorPage_ProcessingRequest" xml:space="preserve"><value>Une erreur s'est produite lors du traitement de votre demande.</value></data>
|
||||||
|
<data name="ErrorPage_RequestId" xml:space="preserve"><value>ID de requête</value></data>
|
||||||
|
<data name="ErrorWithReason" xml:space="preserve"><value>Erreur : {0}</value></data>
|
||||||
|
<data name="From" xml:space="preserve"><value>De</value></data>
|
||||||
|
<data name="FullName" xml:space="preserve"><value>Nom et prénom</value></data>
|
||||||
|
<data name="History" xml:space="preserve"><value>Historique</value></data>
|
||||||
|
<data name="Index_DigitalSignature" xml:space="preserve"><value>Signature numérique</value></data>
|
||||||
|
<data name="Index_PdfExport" xml:space="preserve"><value>Export PDF</value></data>
|
||||||
|
<data name="Index_SecureAccess" xml:space="preserve"><value>Accès sécurisé</value></data>
|
||||||
|
<data name="Index_Subtitle" xml:space="preserve"><value>Bienvenue sur le portail eSign</value></data>
|
||||||
|
<data name="Index_ToEnvelopeOverview" xml:space="preserve"><value>Aller à l'aperçu des enveloppes</value></data>
|
||||||
|
<data name="Languages" xml:space="preserve"><value>Langues</value></data>
|
||||||
|
<data name="Loading" xml:space="preserve"><value>Chargement...</value></data>
|
||||||
|
<data name="Login" xml:space="preserve"><value>Connexion</value></data>
|
||||||
|
<data name="LoginInProgress" xml:space="preserve"><value>Connexion ...</value></data>
|
||||||
|
<data name="NotAuthenticatedPleaseLoginAgain" xml:space="preserve"><value>Non authentifié. Veuillez vous reconnecter.</value></data>
|
||||||
|
<data name="Only" xml:space="preserve"><value>Uniquement</value></data>
|
||||||
|
<data name="OpenDocument" xml:space="preserve"><value>Ouvrir le document</value></data>
|
||||||
|
<data name="Optional" xml:space="preserve"><value>optionnel</value></data>
|
||||||
|
<data name="Pending" xml:space="preserve"><value>En attente</value></data>
|
||||||
|
<data name="Preview" xml:space="preserve"><value>Aperçu</value></data>
|
||||||
|
<data name="PrivacyPolicy" xml:space="preserve"><value>Politique de confidentialité</value></data>
|
||||||
|
<data name="Receiver" xml:space="preserve"><value>Destinataire</value></data>
|
||||||
|
<data name="ReceiverLogin_AccessCode_Placeholder" xml:space="preserve"><value>Saisir le code d'accès</value></data>
|
||||||
|
<data name="ReceiverLogin_Description" xml:space="preserve"><value>Veuillez saisir le code d'accès reçu par e-mail pour ouvrir le document en toute sécurité.</value></data>
|
||||||
|
<data name="ReceiverLogin_FooterHelp" xml:space="preserve"><value>En cas de problème, veuillez contacter l'expéditeur du document.</value></data>
|
||||||
|
<data name="ReceiverLogin_InvalidCode_Body" xml:space="preserve"><value>Le code saisi est incorrect. Veuillez réessayer.</value></data>
|
||||||
|
<data name="ReceiverLogin_InvalidCode_Title" xml:space="preserve"><value>Code d'accès invalide.</value></data>
|
||||||
|
<data name="ReceiverLogin_NotFound_Body" xml:space="preserve"><value>Le code d'accès indiqué ne correspond à aucun document. Veuillez vérifier le lien dans votre e-mail.</value></data>
|
||||||
|
<data name="ReceiverLogin_NotFound_Title" xml:space="preserve"><value>Document introuvable.</value></data>
|
||||||
|
<data name="ReceiverLogin_Subtitle" xml:space="preserve"><value>Accès sécurisé avec code d'accès</value></data>
|
||||||
|
<data name="ReceiverSigned_ConfirmBody" xml:space="preserve"><value>Cette action est irréversible. En confirmant, vous déclarez avoir signé électroniquement le document affiché ci-dessus. Le document signé sera ensuite transmis à toutes les parties concernées.</value></data>
|
||||||
|
<data name="ReceiverSigned_ConfirmQuestion" xml:space="preserve"><value>Voulez-vous signer ce document de manière contraignante ?</value></data>
|
||||||
|
<data name="ReceiverSigned_EnvelopeKeyMissing" xml:space="preserve"><value>La clé d'enveloppe est manquante.</value></data>
|
||||||
|
<data name="Refresh" xml:space="preserve"><value>Actualiser</value></data>
|
||||||
|
<data name="RequiredField" xml:space="preserve"><value>Champ obligatoire</value></data>
|
||||||
|
<data name="Selected" xml:space="preserve"><value>sélectionnées</value></data>
|
||||||
|
<data name="Send" xml:space="preserve"><value>Envoyer</value></data>
|
||||||
|
<data name="Sender_ActiveEnvelopes" xml:space="preserve"><value>Enveloppes actives</value></data>
|
||||||
|
<data name="Sender_CompletedEnvelopes" xml:space="preserve"><value>Enveloppes terminées</value></data>
|
||||||
|
<data name="Sender_CreateEnvelope" xml:space="preserve"><value>Nouvelle enveloppe</value></data>
|
||||||
|
<data name="Sender_CreateEnvelope_Title" xml:space="preserve"><value>Créer une nouvelle enveloppe</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_Header" xml:space="preserve"><value>Supprimer l'enveloppe</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_ReasonPlaceholder" xml:space="preserve"><value>Saisir la raison...</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_ReasonPrompt" xml:space="preserve"><value>Veuillez indiquer la raison pour laquelle l'enveloppe doit être supprimée/retirée.</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_ReasonRequired" xml:space="preserve"><value>Veuillez indiquer un motif de suppression.</value></data>
|
||||||
|
<data name="Sender_DeleteEnvelope_Title" xml:space="preserve"><value>Supprimer l'enveloppe sélectionnée</value></data>
|
||||||
|
<data name="Sender_Edit" xml:space="preserve"><value>Modifier</value></data>
|
||||||
|
<data name="Sender_EditEnvelope_Title" xml:space="preserve"><value>Modifier l'enveloppe sélectionnée</value></data>
|
||||||
|
<data name="Sender_ExportCurrentView_Title" xml:space="preserve"><value>Exporter la vue actuelle</value></data>
|
||||||
|
<data name="Sender_ExportFormat_Aria" xml:space="preserve"><value>Sélectionner le format d'export</value></data>
|
||||||
|
<data name="Sender_ExportView_Title" xml:space="preserve"><value>Exporter la vue actuelle du tableau</value></data>
|
||||||
|
<data name="Sender_LoadError_Title" xml:space="preserve"><value>Erreur lors du chargement des enveloppes</value></data>
|
||||||
|
<data name="Sender_LoadingEnvelopes" xml:space="preserve"><value>Chargement des enveloppes...</value></data>
|
||||||
|
<data name="Sender_NoHistory" xml:space="preserve"><value>Aucun historique disponible</value></data>
|
||||||
|
<data name="Sender_NoReceivers" xml:space="preserve"><value>Aucun destinataire</value></data>
|
||||||
|
<data name="Sender_Preview_Title" xml:space="preserve"><value>Afficher l'enveloppe sélectionnée en PDF</value></data>
|
||||||
|
<data name="Sender_PreviewDocumentNotFound" xml:space="preserve"><value>Le document de l'enveloppe #{0} est introuvable ou vous n'avez pas l'autorisation d'y accéder.</value></data>
|
||||||
|
<data name="SenderEditor_AddSignature" xml:space="preserve"><value>Ajouter une signature</value></data>
|
||||||
|
<data name="SenderEditor_ClickDocumentFor" xml:space="preserve"><value>Cliquez sur l'emplacement dans le document pour</value></data>
|
||||||
|
<data name="SenderEditor_EmailSuggestionsHint" xml:space="preserve"><value>Les adresses e-mail déjà utilisées sont proposées pendant la saisie.</value></data>
|
||||||
|
<data name="SenderEditor_EmailSuggestionsLoading" xml:space="preserve"><value>Chargement des suggestions d'e-mail...</value></data>
|
||||||
|
<data name="SenderEditor_ErrorCreatingEnvelope" xml:space="preserve"><value>Erreur lors de la création de l'enveloppe</value></data>
|
||||||
|
<data name="SenderEditor_LoadEnvelopeError" xml:space="preserve"><value>Erreur lors du chargement de l'enveloppe #{0} : {1}</value></data>
|
||||||
|
<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>
|
||||||
|
<data name="SenderEditor_Sent_Body" xml:space="preserve"><value>Le brouillon existant a été envoyé.</value></data>
|
||||||
|
<data name="SenderEditor_Sent_Header" xml:space="preserve"><value>Enveloppe envoyée</value></data>
|
||||||
|
<data name="SenderEditor_Sent_Title" xml:space="preserve"><value>L'enveloppe a été envoyée avec succès.</value></data>
|
||||||
|
<data name="SenderEditor_SignatureField" xml:space="preserve"><value>Champ de signature</value></data>
|
||||||
|
<data name="SenderEditor_Title_Placeholder" xml:space="preserve"><value>p. ex. Contrat de travail 2025</value></data>
|
||||||
|
<data name="SenderEditor_ToOverview" xml:space="preserve"><value>Vers l'aperçu</value></data>
|
||||||
|
<data name="SenderEditor_UploadPdfToPlaceFields" xml:space="preserve"><value>Téléversez un fichier PDF pour placer des champs de signature.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_EmailDuplicate" xml:space="preserve"><value>Cette adresse e-mail a déjà été ajoutée.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_EmailInvalid" xml:space="preserve"><value>Veuillez saisir une adresse e-mail valide.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_EmailRequired" xml:space="preserve"><value>Veuillez saisir une adresse e-mail.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_FullNameRequired" xml:space="preserve"><value>Veuillez saisir le nom et le prénom.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_ReceiverRequired" xml:space="preserve"><value>Veuillez ajouter au moins un destinataire.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_SignatureFieldRequired" xml:space="preserve"><value>Veuillez placer au moins un champ de signature.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_TitleRequired" xml:space="preserve"><value>Veuillez saisir un titre.</value></data>
|
||||||
|
<data name="SenderEditor_Validation_UploadPdf" xml:space="preserve"><value>Veuillez d'abord téléverser un document PDF.</value></data>
|
||||||
|
<data name="SenderLogin_Description" xml:space="preserve"><value>Veuillez vous connecter avec vos identifiants pour accéder au tableau de bord expéditeur.</value></data>
|
||||||
|
<data name="SenderLogin_FooterHelp" xml:space="preserve"><value>En cas de problème, veuillez contacter l'administrateur.</value></data>
|
||||||
|
<data name="SenderLogin_InvalidCredentials_Body" xml:space="preserve"><value>Le nom d'utilisateur ou le mot de passe est incorrect. Veuillez réessayer.</value></data>
|
||||||
|
<data name="SenderLogin_InvalidCredentials_Title" xml:space="preserve"><value>Identifiants invalides.</value></data>
|
||||||
|
<data name="SenderLogin_Password_Placeholder" xml:space="preserve"><value>Saisir le mot de passe</value></data>
|
||||||
|
<data name="SenderLogin_Subtitle" xml:space="preserve"><value>Accès sécurisé au tableau de bord expéditeur</value></data>
|
||||||
|
<data name="SenderLogin_Title" xml:space="preserve"><value>Connexion expéditeur</value></data>
|
||||||
|
<data name="SenderLogin_Username_Placeholder" xml:space="preserve"><value>Saisir le nom d'utilisateur</value></data>
|
||||||
|
<data name="ServerError" xml:space="preserve"><value>Erreur serveur.</value></data>
|
||||||
|
<data name="Signed" xml:space="preserve"><value>Signé</value></data>
|
||||||
|
<data name="SignedDocument" xml:space="preserve"><value>Document signé</value></data>
|
||||||
|
<data name="System" xml:space="preserve"><value>Système</value></data>
|
||||||
|
<data name="UnexpectedErrorTryAgainLater" xml:space="preserve"><value>Une erreur inattendue s'est produite. Veuillez réessayer plus tard.</value></data>
|
||||||
|
<data name="UploadPdf" xml:space="preserve"><value>Téléverser un PDF</value></data>
|
||||||
|
<data name="UserName" xml:space="preserve"><value>Nom d'utilisateur</value></data>
|
||||||
|
<data name="VerifyInProgress" xml:space="preserve"><value>Vérification ...</value></data>
|
||||||
</root>
|
</root>
|
||||||
7
EnvelopeGenerator.Domain/Resources/Model.cs
Normal file
7
EnvelopeGenerator.Domain/Resources/Model.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace EnvelopeGenerator.Domain.Resources
|
||||||
|
{
|
||||||
|
// Marker type for IStringLocalizer lookup of Resources/Model.resx.
|
||||||
|
public class Model
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
116
EnvelopeGenerator.Domain/Resources/Model.fr-FR.resx
Normal file
116
EnvelopeGenerator.Domain/Resources/Model.fr-FR.resx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
|
||||||
|
<data name="AccessCodeIncorrect" xml:space="preserve">
|
||||||
|
<value>Code d'acces incorrect</value>
|
||||||
|
</data>
|
||||||
|
<data name="DocumentRejected" xml:space="preserve">
|
||||||
|
<value>Document rejete</value>
|
||||||
|
</data>
|
||||||
|
<data name="DocumentSigned" xml:space="preserve">
|
||||||
|
<value>Document signe</value>
|
||||||
|
</data>
|
||||||
|
<data name="Draft" xml:space="preserve">
|
||||||
|
<value>Brouillon</value>
|
||||||
|
</data>
|
||||||
|
<data name="EnvelopeCompletelySigned" xml:space="preserve">
|
||||||
|
<value>Entierement signe</value>
|
||||||
|
</data>
|
||||||
|
<data name="EnvelopeCreated" xml:space="preserve">
|
||||||
|
<value>Enveloppe creee</value>
|
||||||
|
</data>
|
||||||
|
<data name="EnvelopeDeleted" xml:space="preserve">
|
||||||
|
<value>Enveloppe supprimee</value>
|
||||||
|
</data>
|
||||||
|
<data name="EnvelopePartlySigned" xml:space="preserve">
|
||||||
|
<value>Partiellement signee</value>
|
||||||
|
</data>
|
||||||
|
<data name="EnvelopeQueued" xml:space="preserve">
|
||||||
|
<value>Enveloppe en file d'attente</value>
|
||||||
|
</data>
|
||||||
|
<data name="EnvelopeRejected" xml:space="preserve">
|
||||||
|
<value>Enveloppe rejetee</value>
|
||||||
|
</data>
|
||||||
|
<data name="EnvelopeSaved" xml:space="preserve">
|
||||||
|
<value>Enregistree</value>
|
||||||
|
</data>
|
||||||
|
<data name="EnvelopeSent" xml:space="preserve">
|
||||||
|
<value>Envoyee</value>
|
||||||
|
</data>
|
||||||
|
<data name="EnvelopeWithdrawn" xml:space="preserve">
|
||||||
|
<value>Enveloppe retiree</value>
|
||||||
|
</data>
|
||||||
|
<data name="MessageAccessCodeSent" xml:space="preserve">
|
||||||
|
<value>Code d'acces envoye</value>
|
||||||
|
</data>
|
||||||
|
<data name="MessageCompletionSent" xml:space="preserve">
|
||||||
|
<value>E-mail de cloture envoye</value>
|
||||||
|
</data>
|
||||||
|
<data name="MessageConfirmationSent" xml:space="preserve">
|
||||||
|
<value>Confirmation finale envoyee</value>
|
||||||
|
</data>
|
||||||
|
<data name="MessageDeletionSent" xml:space="preserve">
|
||||||
|
<value>Notification de suppression envoyee</value>
|
||||||
|
</data>
|
||||||
|
<data name="MessageInvitationSent" xml:space="preserve">
|
||||||
|
<value>Invitation envoyee</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
@@ -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,459 @@
|
|||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
|
@using EnvelopeGenerator.Application.Common.Dto
|
||||||
|
@using System.Globalization
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
|
<div class="sender-image-editor-shell">
|
||||||
|
@if (PageCount > 0)
|
||||||
|
{
|
||||||
|
<div class="sender-image-editor__toolbar-host">
|
||||||
|
<SenderPdfImageEditorToolbar CurrentPage="@_currentPage"
|
||||||
|
TotalPages="@PageCount"
|
||||||
|
ZoomPercent="@_zoomPercent"
|
||||||
|
MinZoomPercent="@MinZoomPercent"
|
||||||
|
MaxZoomPercent="@MaxZoomPercent"
|
||||||
|
ZoomStepPercent="@ZoomStepPercent"
|
||||||
|
GoFirstPage="OnGoFirstPageAsync"
|
||||||
|
GoPreviousPage="OnGoPreviousPageAsync"
|
||||||
|
GoNextPage="OnGoNextPageAsync"
|
||||||
|
GoLastPage="OnGoLastPageAsync"
|
||||||
|
GoToPage="OnGoToPageAsync"
|
||||||
|
ZoomOut="OnZoomOutAsync"
|
||||||
|
ZoomIn="OnZoomInAsync"
|
||||||
|
SetZoomPercent="OnSetZoomPercentAsync"
|
||||||
|
CanDownloadPdf="@CanDownloadPdf"
|
||||||
|
CanPrintPdf="@CanPrintPdf"
|
||||||
|
IsDownloading="@_isDownloading"
|
||||||
|
IsPrinting="@_isPrinting"
|
||||||
|
DownloadPdf="OnDownloadPdfAsync"
|
||||||
|
PrintPdf="OnPrintPdfAsync" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="pdf-editor-wrapper sender-image-editor sender-editor-pdf-viewer"
|
||||||
|
style="cursor: @(IsPlacementMode ? "crosshair" : (_isDragging ? "grabbing" : "default"));"
|
||||||
|
tabindex="0"
|
||||||
|
@onclick="HandleClickAsync"
|
||||||
|
@onwheel="OnEditorWheelAsync"
|
||||||
|
@onwheel:preventDefault="true"
|
||||||
|
@onscroll="OnEditorScrollAsync"
|
||||||
|
@onmousemove="OnEditorMouseMoveAsync"
|
||||||
|
@onmouseup="OnEditorMouseUp"
|
||||||
|
@onmouseleave="OnEditorMouseUp">
|
||||||
|
@if (PageCount <= 0)
|
||||||
|
{
|
||||||
|
<div class="sender-image-editor__empty">@L("SenderEditor_NoPagesToDisplay", "Keine Seiten zur Anzeige verfügbar.")</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (Pages.Count == 0 && !string.IsNullOrWhiteSpace(PdfDataUrl))
|
||||||
|
{
|
||||||
|
<div class="sender-image-editor__native-preview" style="@GetZoomStyle()">
|
||||||
|
<iframe class="sender-image-editor__native-frame"
|
||||||
|
src="@PdfDataUrl"
|
||||||
|
title="PDF Preview"></iframe>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="sender-image-editor__pages" style="@GetZoomStyle()">
|
||||||
|
@for (var pageIndex = 0; pageIndex < PageCount; pageIndex++)
|
||||||
|
{
|
||||||
|
var pagePreview = pageIndex < Pages.Count ? Pages[pageIndex] : null;
|
||||||
|
var pageNumber = pagePreview?.PageNumber ?? (pageIndex + 1);
|
||||||
|
var widthPt = pagePreview?.WidthPt ?? 595.0;
|
||||||
|
var heightPt = pagePreview?.HeightPt ?? 842.0;
|
||||||
|
|
||||||
|
<div class="sender-image-editor__page-block"
|
||||||
|
data-editor-page="@pageNumber"
|
||||||
|
data-page-width-pt="@widthPt"
|
||||||
|
data-page-height-pt="@heightPt">
|
||||||
|
<div class="sender-image-editor__page-canvas"
|
||||||
|
data-editor-page-canvas="@pageNumber"
|
||||||
|
style="@GetPageCanvasStyle(widthPt, heightPt)">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(pagePreview?.ImageDataUrl))
|
||||||
|
{
|
||||||
|
<img class="sender-image-editor__page-image"
|
||||||
|
src="@(pagePreview!.ImageDataUrl)"
|
||||||
|
alt="PDF Page @pageNumber"
|
||||||
|
draggable="false" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="sender-image-editor__page-placeholder">@L("SenderEditor_PageLabel", "Seite") @pageNumber</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var field in Fields.Where(f => f.Page == pageNumber))
|
||||||
|
{
|
||||||
|
<div class="sender-image-editor__field"
|
||||||
|
style="@GetFieldStyle(field, widthPt, heightPt)"
|
||||||
|
title="@field.ReceiverName"
|
||||||
|
@onmousedown="args => OnFieldMouseDownAsync(args, field)"
|
||||||
|
@onmousedown:preventDefault="true"
|
||||||
|
@onclick:stopPropagation="true">
|
||||||
|
<span class="sender-image-editor__field-label">@field.ReceiverName</span>
|
||||||
|
<button type="button"
|
||||||
|
class="sender-image-editor__field-remove"
|
||||||
|
title='@L("Delete", "Löschen")'
|
||||||
|
draggable="false"
|
||||||
|
@onclick:stopPropagation="true"
|
||||||
|
@onclick="() => RemoveField.InvokeAsync(field.Index)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public bool IsPlacementMode { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int PageCount { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<PdfPageImageDto> Pages { get; set; } = Array.Empty<PdfPageImageDto>();
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? PdfDataUrl { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? PdfFileName { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IReadOnlyList<SenderPdfFieldOverlay> Fields { get; set; } = Array.Empty<SenderPdfFieldOverlay>();
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<MouseEventArgs> EditorClick { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<int> RemoveField { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<SenderPdfFieldMoveRequest> MoveField { get; set; }
|
||||||
|
|
||||||
|
private int? _draggedFieldIndex;
|
||||||
|
private bool _isDragging;
|
||||||
|
private double _grabOffsetXPt;
|
||||||
|
private double _grabOffsetYPt;
|
||||||
|
private int _currentPage = 1;
|
||||||
|
private int _zoomPercent = 112;
|
||||||
|
private bool _zoomOverrideInitialized;
|
||||||
|
private bool _isDownloading;
|
||||||
|
private bool _isPrinting;
|
||||||
|
|
||||||
|
private const int MinZoomPercent = 50;
|
||||||
|
private const int MaxZoomPercent = 300;
|
||||||
|
private const int ZoomStepPercent = 5;
|
||||||
|
private bool CanDownloadPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
|
||||||
|
private bool CanPrintPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
|
||||||
|
|
||||||
|
private static string GetFieldStyle(SenderPdfFieldOverlay field, double pageWidthPt, double pageHeightPt)
|
||||||
|
{
|
||||||
|
var leftPercent = (field.XPt / pageWidthPt) * 100.0;
|
||||||
|
var topPercent = (field.YPt / pageHeightPt) * 100.0;
|
||||||
|
var widthPercent = (127.44 / pageWidthPt) * 100.0;
|
||||||
|
var heightPercent = (141.12 / pageHeightPt) * 100.0;
|
||||||
|
|
||||||
|
var left = leftPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||||
|
var top = topPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||||
|
var width = widthPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||||
|
var height = heightPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
return string.Join(' ',
|
||||||
|
"position:absolute;",
|
||||||
|
$"left:{left}%;",
|
||||||
|
$"top:{top}%;",
|
||||||
|
$"width:{width}%;",
|
||||||
|
$"height:{height}%;",
|
||||||
|
"border:2px solid;",
|
||||||
|
"border-radius:8px;",
|
||||||
|
"display:flex;",
|
||||||
|
"align-items:flex-start;",
|
||||||
|
"justify-content:space-between;",
|
||||||
|
"gap:0.35rem;",
|
||||||
|
"padding:0.2rem 0.3rem;",
|
||||||
|
"overflow:hidden;",
|
||||||
|
"z-index:30;",
|
||||||
|
$"border-color:{field.Color};",
|
||||||
|
$"background-color:{field.Color}22;");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPageCanvasStyle(double pageWidthPt, double pageHeightPt)
|
||||||
|
{
|
||||||
|
var ratio = pageHeightPt <= 0 ? 595.0 / 842.0 : pageWidthPt / pageHeightPt;
|
||||||
|
var ratioText = ratio.ToString("F6", CultureInfo.InvariantCulture);
|
||||||
|
var cursor = _isDragging
|
||||||
|
? "grabbing"
|
||||||
|
: (IsPlacementMode ? "crosshair" : "default");
|
||||||
|
return $"aspect-ratio:{ratioText};cursor:{cursor};";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetZoomStyle()
|
||||||
|
{
|
||||||
|
var factor = (_zoomPercent / 100.0).ToString("F4", CultureInfo.InvariantCulture);
|
||||||
|
return $"zoom:{factor};";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
if (PageCount <= 0)
|
||||||
|
{
|
||||||
|
_currentPage = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPage = Math.Max(1, Math.Min(_currentPage, PageCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (_zoomOverrideInitialized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"envelopeEditor.ensureZoomOverride",
|
||||||
|
"sender-editor-pdf-viewer");
|
||||||
|
|
||||||
|
_zoomOverrideInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleClickAsync(MouseEventArgs args)
|
||||||
|
{
|
||||||
|
if (EditorClick.HasDelegate)
|
||||||
|
await EditorClick.InvokeAsync(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnFieldMouseDownAsync(MouseEventArgs args, SenderPdfFieldOverlay field)
|
||||||
|
{
|
||||||
|
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
|
||||||
|
"envelopeEditor.getClickCoordsOnPdfPage",
|
||||||
|
"sender-editor-pdf-viewer",
|
||||||
|
args.ClientX,
|
||||||
|
args.ClientY);
|
||||||
|
|
||||||
|
if (coords is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var pageNumber = coords.PageNumber;
|
||||||
|
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
|
||||||
|
var pointerXPt = coords.NormX * pageWidthPt;
|
||||||
|
var pointerYPt = coords.NormY * pageHeightPt;
|
||||||
|
|
||||||
|
_draggedFieldIndex = field.Index;
|
||||||
|
_isDragging = true;
|
||||||
|
_grabOffsetXPt = pointerXPt - field.XPt;
|
||||||
|
_grabOffsetYPt = pointerYPt - field.YPt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnEditorMouseMoveAsync(MouseEventArgs args)
|
||||||
|
{
|
||||||
|
if (_draggedFieldIndex is null || !MoveField.HasDelegate)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
|
||||||
|
"envelopeEditor.getClickCoordsOnPdfPage",
|
||||||
|
"sender-editor-pdf-viewer",
|
||||||
|
args.ClientX,
|
||||||
|
args.ClientY);
|
||||||
|
|
||||||
|
if (coords is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var pageNumber = coords.PageNumber;
|
||||||
|
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
|
||||||
|
|
||||||
|
const double sigWidthPt = 1.77 * 72;
|
||||||
|
const double sigHeightPt = 1.96 * 72;
|
||||||
|
|
||||||
|
var xPt = (coords.NormX * pageWidthPt) - _grabOffsetXPt;
|
||||||
|
var yPt = (coords.NormY * pageHeightPt) - _grabOffsetYPt;
|
||||||
|
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - sigWidthPt));
|
||||||
|
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - sigHeightPt));
|
||||||
|
|
||||||
|
await MoveField.InvokeAsync(new SenderPdfFieldMoveRequest(_draggedFieldIndex.Value, pageNumber, xPt, yPt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnGoFirstPageAsync()
|
||||||
|
{
|
||||||
|
await NavigateToPageAsync(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnGoPreviousPageAsync()
|
||||||
|
{
|
||||||
|
await NavigateToPageAsync(_currentPage - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnGoNextPageAsync()
|
||||||
|
{
|
||||||
|
await NavigateToPageAsync(_currentPage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnGoLastPageAsync()
|
||||||
|
{
|
||||||
|
await NavigateToPageAsync(PageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnGoToPageAsync(int page)
|
||||||
|
{
|
||||||
|
await NavigateToPageAsync(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NavigateToPageAsync(int targetPage)
|
||||||
|
{
|
||||||
|
if (PageCount <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var page = Math.Max(1, Math.Min(targetPage, PageCount));
|
||||||
|
_currentPage = page;
|
||||||
|
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"envelopeEditor.scrollToPdfPage",
|
||||||
|
"sender-editor-pdf-viewer",
|
||||||
|
page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnEditorScrollAsync(EventArgs _)
|
||||||
|
{
|
||||||
|
if (PageCount <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var visiblePage = await JSRuntime.InvokeAsync<int?>(
|
||||||
|
"envelopeEditor.getVisiblePdfPageNumber",
|
||||||
|
"sender-editor-pdf-viewer");
|
||||||
|
|
||||||
|
if (!visiblePage.HasValue)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var clamped = Math.Max(1, Math.Min(visiblePage.Value, PageCount));
|
||||||
|
if (clamped == _currentPage)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_currentPage = clamped;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnZoomOutAsync()
|
||||||
|
{
|
||||||
|
SetZoomPercent(_zoomPercent - ZoomStepPercent);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnZoomInAsync()
|
||||||
|
{
|
||||||
|
SetZoomPercent(_zoomPercent + ZoomStepPercent);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSetZoomPercentAsync(int zoomPercent)
|
||||||
|
{
|
||||||
|
SetZoomPercent(zoomPercent);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnEditorWheelAsync(WheelEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.CtrlKey)
|
||||||
|
{
|
||||||
|
if (args.DeltaY < 0)
|
||||||
|
await OnZoomInAsync();
|
||||||
|
else if (args.DeltaY > 0)
|
||||||
|
await OnZoomOutAsync();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"envelopeEditor.scrollPdfViewerByDelta",
|
||||||
|
"sender-editor-pdf-viewer",
|
||||||
|
args.DeltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetZoomPercent(int value)
|
||||||
|
{
|
||||||
|
_zoomPercent = Math.Max(MinZoomPercent, Math.Min(value, MaxZoomPercent));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnDownloadPdfAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isDownloading || _isPrinting)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isDownloading = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fallbackName = string.IsNullOrWhiteSpace(PdfFileName)
|
||||||
|
? "document.pdf"
|
||||||
|
: PdfFileName;
|
||||||
|
|
||||||
|
var fileName = fallbackName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? fallbackName
|
||||||
|
: $"{fallbackName}.pdf";
|
||||||
|
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"envelopeEditor.downloadDataUrlAsFile",
|
||||||
|
PdfDataUrl,
|
||||||
|
fileName,
|
||||||
|
"application/pdf");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isDownloading = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnPrintPdfAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isPrinting || _isDownloading)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isPrinting = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"envelopeEditor.printDataUrlPdf",
|
||||||
|
PdfDataUrl,
|
||||||
|
"application/pdf");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isPrinting = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEditorMouseUp(MouseEventArgs args)
|
||||||
|
{
|
||||||
|
_draggedFieldIndex = null;
|
||||||
|
_isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (double WidthPt, double HeightPt) GetPageSize(int pageNumber)
|
||||||
|
{
|
||||||
|
var page = Pages.FirstOrDefault(p => p.PageNumber == pageNumber);
|
||||||
|
return page is null ? (595.0, 842.0) : (page.WidthPt, page.HeightPt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record EditorCoords(double NormX, double NormY, int PageIndex, int PageNumber);
|
||||||
|
|
||||||
|
private string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var value = UiLocalizer[key];
|
||||||
|
return value.ResourceNotFound ? fallback : value.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
|
@rendermode InteractiveWebAssembly
|
||||||
|
|
||||||
|
@inject LocalizationService LocalizationService
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
|
|
||||||
|
<div class="sf-lang-combo">
|
||||||
|
<button type="button"
|
||||||
|
class="sf-lang-trigger"
|
||||||
|
@onclick="ToggleMenu"
|
||||||
|
aria-label="@L("Language", "Language")"
|
||||||
|
aria-expanded="@_isOpen">
|
||||||
|
<span class="fi @_selected.FlagClass"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (_isOpen)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="sf-lang-backdrop"
|
||||||
|
aria-label="@L("CloseLanguageMenu", "Close language menu")"
|
||||||
|
@onclick="CloseMenu">
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="sf-lang-menu" role="listbox" aria-label="@L("Languages", "Languages")">
|
||||||
|
@foreach (var option in _options)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="sf-lang-option @(_selected.Code == option.Code ? "sf-lang-option--active" : string.Empty)"
|
||||||
|
role="option"
|
||||||
|
aria-selected="@(_selected.Code == option.Code)"
|
||||||
|
@onmousedown:preventDefault="true"
|
||||||
|
@onclick="() => SelectLanguageAsync(option)">
|
||||||
|
<span class="fi @option.FlagClass"></span>
|
||||||
|
<span>@option.ShortName</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly IReadOnlyList<LanguageOption> _options =
|
||||||
|
[
|
||||||
|
new("de-DE", "fi-de", "DE"),
|
||||||
|
new("en-US", "fi-us", "EN"),
|
||||||
|
new("fr-FR", "fi-fr", "FR")
|
||||||
|
];
|
||||||
|
|
||||||
|
private LanguageOption _selected = new("de-DE", "fi-de", "DE");
|
||||||
|
private bool _isOpen;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var currentLanguage = await LocalizationService.GetLanguageAsync();
|
||||||
|
_selected = _options.FirstOrDefault(x => x.Code.Equals(currentLanguage, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? _options[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleMenu()
|
||||||
|
{
|
||||||
|
_isOpen = !_isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseMenu()
|
||||||
|
{
|
||||||
|
_isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SelectLanguageAsync(LanguageOption option)
|
||||||
|
{
|
||||||
|
if (_selected.Code.Equals(option.Code, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_isOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selected = option;
|
||||||
|
_isOpen = false;
|
||||||
|
await LocalizationService.SetLanguageAsync(option.Code);
|
||||||
|
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record LanguageOption(string Code, string FlagClass, string ShortName);
|
||||||
|
|
||||||
|
string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var value = UiLocalizer[key];
|
||||||
|
return value.ResourceNotFound ? fallback : value.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
@using EnvelopeGenerator.Server.Client.Services
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
|
@using System.Globalization
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
@inject ThemeService ThemeService
|
@inject ThemeService ThemeService
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<HeadContent>
|
<HeadContent>
|
||||||
<link href="@ThemeService.ThemeCssHref" rel="stylesheet" />
|
<link href="@ThemeService.ThemeCssHref" rel="stylesheet" />
|
||||||
@@ -18,10 +24,13 @@
|
|||||||
<div class="receiver-footer__left">
|
<div class="receiver-footer__left">
|
||||||
<span>© SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
|
<span>© SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
|
||||||
<span class="receiver-footer__sep">|</span>
|
<span class="receiver-footer__sep">|</span>
|
||||||
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
|
<a href="@GetPrivacyPolicyPath()" target="_blank" rel="noopener">@L("PrivacyPolicy", "Datenschutz")</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ThemeControls />
|
<div class="receiver-footer__right">
|
||||||
|
<LanguageControls />
|
||||||
|
<ThemeControls />
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,6 +41,7 @@
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
Navigation.LocationChanged += OnLocationChanged;
|
||||||
await FontLoader.LoadFonts(HttpClientFactory, ["opensans.ttf"]);
|
await FontLoader.LoadFonts(HttpClientFactory, ["opensans.ttf"]);
|
||||||
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
|
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
|
||||||
ThemeService.OnChange += _themeChangeHandler;
|
ThemeService.OnChange += _themeChangeHandler;
|
||||||
@@ -39,5 +49,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
=> ThemeService.OnChange -= _themeChangeHandler;
|
{
|
||||||
|
ThemeService.OnChange -= _themeChangeHandler;
|
||||||
|
Navigation.LocationChanged -= OnLocationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||||
|
=> _ = InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
private string GetPrivacyPolicyPath()
|
||||||
|
{
|
||||||
|
var current = CultureInfo.CurrentUICulture.Name;
|
||||||
|
var code = current switch
|
||||||
|
{
|
||||||
|
"de-DE" => "de-DE",
|
||||||
|
"en-US" => "en-US",
|
||||||
|
"fr-FR" => "fr-FR",
|
||||||
|
_ => "de-DE"
|
||||||
|
};
|
||||||
|
|
||||||
|
return $"docs/privacy-policy.{code}.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var value = UiLocalizer[key];
|
||||||
|
return value.ResourceNotFound ? fallback : value.Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@page "/envelope/signed-result"
|
||||||
|
@rendermode InteractiveWebAssembly
|
||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
|
|
||||||
|
<div class="receiver-signed-result-page">
|
||||||
|
<div class="receiver-signed-result-card">
|
||||||
|
<div class="receiver-signed-result-icon" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M2.5 8a5.5 5.5 0 0 1 8.25-4.764.5.5 0 0 0 .5-.866A6.5 6.5 0 1 0 14.5 8a.5.5 0 0 0-1 0 5.5 5.5 0 1 1-11 0z" />
|
||||||
|
<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l7-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="receiver-signed-result-title">@L("DocumentSuccessfullySigned", "Dokument erfolgreich unterzeichnet")</h1>
|
||||||
|
<p class="receiver-signed-result-message">
|
||||||
|
@L("DocumentSignedConfirmationMessage", "Vielen Dank. Ihre Unterschrift wurde erfolgreich gespeichert und an die beteiligten Parteien übermittelt.")
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var value = UiLocalizer[key];
|
||||||
|
return value.ResourceNotFound ? fallback : value.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
|
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
@rendermode InteractiveWebAssembly
|
@rendermode InteractiveWebAssembly
|
||||||
|
|
||||||
<div class="home-page-wrapper">
|
<div class="home-page-wrapper">
|
||||||
@@ -24,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
<h1 class="home-hero-header__title">SignFlow</h1>
|
<h1 class="home-hero-header__title">SignFlow</h1>
|
||||||
<p class="home-hero-header__subtitle">Willkommen im eSign-Portal</p>
|
<p class="home-hero-header__subtitle">@L("Index_Subtitle", "Willkommen im eSign-Portal")</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +46,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
|
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Zur Umschlag-Übersicht
|
@L("Index_ToEnvelopeOverview", "Zur Umschlag-Übersicht")
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -54,20 +57,20 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Sicherer Zugang
|
@L("Index_SecureAccess", "Sicherer Zugang")
|
||||||
</div>
|
</div>
|
||||||
<div class="home-feature-badge">
|
<div class="home-feature-badge">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" 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"/>
|
<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"/>
|
||||||
</svg>
|
</svg>
|
||||||
Digitale Unterschrift
|
@L("Index_DigitalSignature", "Digitale Unterschrift")
|
||||||
</div>
|
</div>
|
||||||
<div class="home-feature-badge">
|
<div class="home-feature-badge">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||||
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||||
</svg>
|
</svg>
|
||||||
PDF-Export
|
@L("Index_PdfExport", "PDF-Export")
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,10 +83,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private const string HomePageDescription =
|
private string HomePageDescription => L(
|
||||||
"Das digitale Unterschriftenportal ist eine Plattform, die entwickelt wurde, um Ihre Dokumente sicher zu unterschreiben und zu verwalten. " +
|
"HomePageDescription",
|
||||||
"Mit seiner benutzerfreundlichen Oberfläche können Sie Ihre Dokumente schnell hochladen, die Unterschriftsprozesse verfolgen und Ihre digitalen Unterschriftenanwendungen einfach durchführen. " +
|
"Das digitale Unterschriftenportal ist eine Plattform, die entwickelt wurde, um Ihre Dokumente sicher zu unterschreiben und zu verwalten. Mit seiner benutzerfreundlichen Oberfläche können Sie Ihre Dokumente schnell hochladen, die Unterschriftsprozesse verfolgen und Ihre digitalen Unterschriftenanwendungen einfach durchführen. Dieses Portal beschleunigt Ihren Arbeitsablauf mit rechtlich gültigen Unterschriften und erhöht gleichzeitig die Sicherheit Ihrer Dokumente.");
|
||||||
"Dieses Portal beschleunigt Ihren Arbeitsablauf mit rechtlich gültigen Unterschriften und erhöht gleichzeitig die Sicherheit Ihrer Dokumente.";
|
|
||||||
|
|
||||||
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _appLogo;
|
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _appLogo;
|
||||||
|
|
||||||
@@ -99,4 +101,10 @@
|
|||||||
if (!firstRender) return;
|
if (!firstRender) return;
|
||||||
await JS.InvokeVoidAsync("receiverSignature.startTyped", "home-description", HomePageDescription, 15);
|
await JS.InvokeVoidAsync("receiverSignature.startTyped", "home-description", HomePageDescription, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var value = UiLocalizer[key];
|
||||||
|
return value.ResourceNotFound ? fallback : value.Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
@page "/envelope/login/{EnvelopeKey}"
|
@page "/envelope/login/{EnvelopeKey}"
|
||||||
@rendermode InteractiveWebAssembly
|
@rendermode InteractiveWebAssembly
|
||||||
@using EnvelopeGenerator.Server.Client.Services
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
@inject AuthService AuthService
|
@inject AuthService AuthService
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
|
|
||||||
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
|
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
|
||||||
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
|
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
|
||||||
@@ -13,14 +16,14 @@
|
|||||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h5 class="mb-0 fw-semibold">Dokument öffnen</h5>
|
<h5 class="mb-0 fw-semibold">@L("OpenDocument", "Dokument öffnen")</h5>
|
||||||
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">Sicherer Zugang mit Zugangscode</p>
|
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">@L("ReceiverLogin_Subtitle", "Sicherer Zugang mit Zugangscode")</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
|
|
||||||
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
|
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
|
||||||
Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.
|
@L("ReceiverLogin_Description", "Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.")
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@if (LoginResult == EnvelopeLoginResult.NotFound)
|
@if (LoginResult == EnvelopeLoginResult.NotFound)
|
||||||
@@ -30,8 +33,8 @@
|
|||||||
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<strong>Dokument nicht gefunden.</strong><br />
|
<strong>@L("ReceiverLogin_NotFound_Title", "Dokument nicht gefunden.")</strong><br />
|
||||||
<span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span>
|
<span style="font-size:0.85rem;">@L("ReceiverLogin_NotFound_Body", "Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.")</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -42,8 +45,8 @@
|
|||||||
<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" />
|
<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>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<strong>Ungültiger Zugangscode.</strong><br />
|
<strong>@L("ReceiverLogin_InvalidCode_Title", "Ungültiger Zugangscode.")</strong><br />
|
||||||
<span style="font-size:0.85rem;">Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</span>
|
<span style="font-size:0.85rem;">@L("ReceiverLogin_InvalidCode_Body", "Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.")</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -55,15 +58,15 @@
|
|||||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<strong>Serverfehler.</strong><br />
|
<strong>@L("ServerError", "Serverfehler.")</strong><br />
|
||||||
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
|
<span style="font-size:0.85rem;">@L("UnexpectedErrorTryAgainLater", "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.")</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="form-label fw-medium" for="login-access-code">
|
<label class="form-label fw-medium" for="login-access-code">
|
||||||
Zugangscode
|
@L("AccessCode", "Zugangscode")
|
||||||
<span class="text-danger ms-1">*</span>
|
<span class="text-danger ms-1">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@@ -75,7 +78,7 @@
|
|||||||
<input id="login-access-code"
|
<input id="login-access-code"
|
||||||
type="@(ShowCode ? "text" : "password")"
|
type="@(ShowCode ? "text" : "password")"
|
||||||
class="form-control border-start-0 border-end-0 @(LoginResult == EnvelopeLoginResult.InvalidCode ? "is-invalid" : null)"
|
class="form-control border-start-0 border-end-0 @(LoginResult == EnvelopeLoginResult.InvalidCode ? "is-invalid" : null)"
|
||||||
placeholder="Zugangscode eingeben"
|
placeholder="@L("ReceiverLogin_AccessCode_Placeholder", "Zugangscode eingeben")"
|
||||||
@bind="AccessCode"
|
@bind="AccessCode"
|
||||||
@bind:event="oninput"
|
@bind:event="oninput"
|
||||||
@onkeydown="OnKeyDownAsync"
|
@onkeydown="OnKeyDownAsync"
|
||||||
@@ -113,21 +116,21 @@
|
|||||||
@if (IsLoading)
|
@if (IsLoading)
|
||||||
{
|
{
|
||||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
<span>Überprüfen …</span>
|
<span>@L("VerifyInProgress", "Überprüfen …")</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
||||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Dokument öffnen</span>
|
<span>@L("OpenDocument", "Dokument öffnen")</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-footer text-center text-muted py-3 border-0 bg-transparent" style="font-size: 0.78rem;">
|
<div class="card-footer text-center text-muted py-3 border-0 bg-transparent" style="font-size: 0.78rem;">
|
||||||
Bei Problemen wenden Sie sich bitte an den Absender des Dokuments.
|
@L("ReceiverLogin_FooterHelp", "Bei Problemen wenden Sie sich bitte an den Absender des Dokuments.")
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -141,6 +144,14 @@
|
|||||||
bool IsLoading;
|
bool IsLoading;
|
||||||
EnvelopeLoginResult? LoginResult;
|
EnvelopeLoginResult? LoginResult;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_ = await AuthService.RequestEnvelopeReceiverAccessCodeAsync(EnvelopeKey);
|
||||||
|
}
|
||||||
|
|
||||||
async Task OnKeyDownAsync(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
|
async Task OnKeyDownAsync(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Key == "Enter")
|
if (e.Key == "Enter")
|
||||||
@@ -167,5 +178,11 @@
|
|||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var value = UiLocalizer[key];
|
||||||
|
return value.ResourceNotFound ? fallback : value.Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
@page "/sender/login"
|
@page "/sender/login"
|
||||||
@rendermode InteractiveWebAssembly
|
@rendermode InteractiveWebAssembly
|
||||||
@using EnvelopeGenerator.Server.Client.Services
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
@inject AuthService AuthService
|
@inject AuthService AuthService
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
|
|
||||||
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
|
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
|
||||||
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
|
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
|
||||||
@@ -14,14 +17,14 @@
|
|||||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h5 class="mb-0 fw-semibold">Sender Anmeldung</h5>
|
<h5 class="mb-0 fw-semibold">@L("SenderLogin_Title", "Sender Anmeldung")</h5>
|
||||||
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">Sicherer Zugang zum Sender-Dashboard</p>
|
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">@L("SenderLogin_Subtitle", "Sicherer Zugang zum Sender-Dashboard")</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
|
|
||||||
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
|
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
|
||||||
Bitte melden Sie sich mit Ihren Zugangsdaten an, um auf das Sender-Dashboard zuzugreifen.
|
@L("SenderLogin_Description", "Bitte melden Sie sich mit Ihren Zugangsdaten an, um auf das Sender-Dashboard zuzugreifen.")
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@if (LoginResult == SenderLoginResult.InvalidCredentials) {
|
@if (LoginResult == SenderLoginResult.InvalidCredentials) {
|
||||||
@@ -30,8 +33,8 @@
|
|||||||
<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"/>
|
<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>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<strong>Ungültige Anmeldedaten.</strong><br />
|
<strong>@L("SenderLogin_InvalidCredentials_Title", "Ungültige Anmeldedaten.")</strong><br />
|
||||||
<span style="font-size:0.85rem;">Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</span>
|
<span style="font-size:0.85rem;">@L("SenderLogin_InvalidCredentials_Body", "Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.")</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} else if (LoginResult == SenderLoginResult.Error) {
|
} else if (LoginResult == SenderLoginResult.Error) {
|
||||||
@@ -41,15 +44,15 @@
|
|||||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<strong>Serverfehler.</strong><br />
|
<strong>@L("ServerError", "Serverfehler.")</strong><br />
|
||||||
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
|
<span style="font-size:0.85rem;">@L("UnexpectedErrorTryAgainLater", "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.")</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-medium" for="login-username">
|
<label class="form-label fw-medium" for="login-username">
|
||||||
Benutzername
|
@L("UserName", "Benutzername")
|
||||||
<span class="text-danger ms-1">*</span>
|
<span class="text-danger ms-1">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@@ -61,7 +64,7 @@
|
|||||||
<input id="login-username"
|
<input id="login-username"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control @(LoginResult == SenderLoginResult.InvalidCredentials ? "is-invalid" : null)"
|
class="form-control @(LoginResult == SenderLoginResult.InvalidCredentials ? "is-invalid" : null)"
|
||||||
placeholder="Benutzername eingeben"
|
placeholder="@L("SenderLogin_Username_Placeholder", "Benutzername eingeben")"
|
||||||
@bind="Username"
|
@bind="Username"
|
||||||
@bind:event="oninput"
|
@bind:event="oninput"
|
||||||
@onkeydown="OnKeyDownAsync"
|
@onkeydown="OnKeyDownAsync"
|
||||||
@@ -72,7 +75,7 @@
|
|||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="form-label fw-medium" for="login-password">
|
<label class="form-label fw-medium" for="login-password">
|
||||||
Passwort
|
@L("Password", "Passwort")
|
||||||
<span class="text-danger ms-1">*</span>
|
<span class="text-danger ms-1">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@@ -84,7 +87,7 @@
|
|||||||
<input id="login-password"
|
<input id="login-password"
|
||||||
type="@(ShowPassword ? "text" : "password")"
|
type="@(ShowPassword ? "text" : "password")"
|
||||||
class="form-control border-start-0 border-end-0 @(LoginResult == SenderLoginResult.InvalidCredentials ? "is-invalid" : null)"
|
class="form-control border-start-0 border-end-0 @(LoginResult == SenderLoginResult.InvalidCredentials ? "is-invalid" : null)"
|
||||||
placeholder="Passwort eingeben"
|
placeholder="@L("SenderLogin_Password_Placeholder", "Passwort eingeben")"
|
||||||
@bind="Password"
|
@bind="Password"
|
||||||
@bind:event="oninput"
|
@bind:event="oninput"
|
||||||
@onkeydown="OnKeyDownAsync"
|
@onkeydown="OnKeyDownAsync"
|
||||||
@@ -118,20 +121,20 @@
|
|||||||
disabled="@(IsLoading || string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))">
|
disabled="@(IsLoading || string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))">
|
||||||
@if (IsLoading) {
|
@if (IsLoading) {
|
||||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
<span>Anmelden …</span>
|
<span>@L("LoginInProgress", "Anmelden …")</span>
|
||||||
} else {
|
} else {
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
||||||
<path fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0v-2z"/>
|
<path fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0v-2z"/>
|
||||||
<path fill-rule="evenodd" d="M4.146 8.354a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H14.5a.5.5 0 0 1 0 1H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3z"/>
|
<path fill-rule="evenodd" d="M4.146 8.354a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H14.5a.5.5 0 0 1 0 1H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Anmelden</span>
|
<span>@L("Login", "Anmelden")</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-footer text-center text-muted py-3 border-0 bg-transparent" style="font-size: 0.78rem;">
|
<div class="card-footer text-center text-muted py-3 border-0 bg-transparent" style="font-size: 0.78rem;">
|
||||||
Bei Problemen wenden Sie sich bitte an den Administrator.
|
@L("SenderLogin_FooterHelp", "Bei Problemen wenden Sie sich bitte an den Administrator.")
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -167,4 +170,10 @@
|
|||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
@@ -5,9 +5,17 @@ using DevExpress.Blazor.Reporting;
|
|||||||
using DevExpress.XtraReports.Web.Extensions;
|
using DevExpress.XtraReports.Web.Extensions;
|
||||||
using DevExpress.DataAccess.Web;
|
using DevExpress.DataAccess.Web;
|
||||||
using DevExpress.XtraReports.Services;
|
using DevExpress.XtraReports.Services;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
|
var initialCulture = await ResolveInitialCultureAsync(builder.HostEnvironment.BaseAddress);
|
||||||
|
var cultureInfo = new CultureInfo(initialCulture);
|
||||||
|
CultureInfo.DefaultThreadCurrentCulture = cultureInfo;
|
||||||
|
CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
|
||||||
|
|
||||||
|
builder.Services.AddLocalization();
|
||||||
|
|
||||||
// Named HttpClient for API calls (both for services and DevExpress components)
|
// Named HttpClient for API calls (both for services and DevExpress components)
|
||||||
builder.Services.AddHttpClient("EnvelopeGenerator.Server", client =>
|
builder.Services.AddHttpClient("EnvelopeGenerator.Server", client =>
|
||||||
{
|
{
|
||||||
@@ -33,6 +41,9 @@ builder.Services.AddScoped<SignatureService>();
|
|||||||
builder.Services.AddScoped<SignatureCacheService>();
|
builder.Services.AddScoped<SignatureCacheService>();
|
||||||
builder.Services.AddSingleton<AppVersionService>();
|
builder.Services.AddSingleton<AppVersionService>();
|
||||||
builder.Services.AddScoped<EnvelopeService>();
|
builder.Services.AddScoped<EnvelopeService>();
|
||||||
|
builder.Services.AddScoped<LocalizationService>();
|
||||||
|
builder.Services.AddScoped<ReceiverService>();
|
||||||
|
builder.Services.AddScoped<PdfRenderService>();
|
||||||
|
|
||||||
// UI Preferences & Theme
|
// UI Preferences & Theme
|
||||||
builder.Services.AddScoped<UserPreferencesService>();
|
builder.Services.AddScoped<UserPreferencesService>();
|
||||||
@@ -66,3 +77,23 @@ ReportStorageWebExtension.RegisterExtensionGlobal(new InMemoryReportStorageWebEx
|
|||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
await FontLoader.LoadFonts(host.Services.GetRequiredService<IHttpClientFactory>(), new List<string> { "opensans.ttf" });
|
await FontLoader.LoadFonts(host.Services.GetRequiredService<IHttpClientFactory>(), new List<string> { "opensans.ttf" });
|
||||||
await host.RunAsync();
|
await host.RunAsync();
|
||||||
|
|
||||||
|
static async Task<string> ResolveInitialCultureAsync(string baseAddress)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { BaseAddress = new Uri(baseAddress) };
|
||||||
|
var response = await http.GetAsync("/api/Localization/lang");
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return "de-DE";
|
||||||
|
|
||||||
|
var value = await response.Content.ReadAsStringAsync();
|
||||||
|
var culture = value.Trim().Trim('"');
|
||||||
|
return string.IsNullOrWhiteSpace(culture) ? "de-DE" : culture;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "de-DE";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,4 +26,24 @@ public class AnnotationService(IHttpClientFactory httpClientFactory)
|
|||||||
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
|
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
|
||||||
return result ?? [];
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ public class AuthService(IHttpClientFactory httpClientFactory)
|
|||||||
return response.StatusCode == HttpStatusCode.OK;
|
return response.StatusCode == HttpStatusCode.OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Triggers access code e-mail request flow for the receiver login page.
|
||||||
|
/// Calls POST /api/Auth/envelope-receiver/{key}/request-access-code.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> RequestEnvelopeReceiverAccessCodeAsync(string envelopeKey, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = CreateDefaultClient();
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/Auth/envelope-receiver/{Uri.EscapeDataString(envelopeKey)}/request-access-code",
|
||||||
|
null,
|
||||||
|
cancel);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Submits the access code for the given envelope key.
|
/// Submits the access code for the given envelope key.
|
||||||
/// Calls POST /api/Auth/envelope-receiver/{key} with multipart/form-data.
|
/// Calls POST /api/Auth/envelope-receiver/{key} with multipart/form-data.
|
||||||
@@ -72,6 +86,18 @@ public class AuthService(IHttpClientFactory httpClientFactory)
|
|||||||
return response.IsSuccessStatusCode;
|
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>
|
/// <summary>
|
||||||
/// Removes the per-envelope receiver cookie for the given envelope key.
|
/// Removes the per-envelope receiver cookie for the given envelope key.
|
||||||
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.
|
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Server.Client.Services;
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
public class DocumentService(IHttpClientFactory httpClientFactory)
|
public class DocumentService(IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches the PDF bytes for the given envelope key from the API.
|
/// Fetches the PDF bytes for the given envelope key from the API.
|
||||||
@@ -29,4 +33,26 @@ public class DocumentService(IHttpClientFactory httpClientFactory)
|
|||||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
|
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches sender document details (including signature elements) by envelope id.
|
||||||
|
/// Uses GET /api/Document?EnvelopeId={envelopeId}.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<DocumentDto?> GetSenderDocumentDetailsByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var response = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var statusCode = (int)response.StatusCode;
|
||||||
|
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Failed to load sender document details. Status: {statusCode} ({reasonPhrase})",
|
||||||
|
null,
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<DocumentDto>(_jsonOptions, cancel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
using EnvelopeGenerator.Application.Envelopes.Commands;
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
@@ -12,6 +13,8 @@ namespace EnvelopeGenerator.Server.Client.Services;
|
|||||||
public class EnvelopeService(IHttpClientFactory clientFactory)
|
public class EnvelopeService(IHttpClientFactory clientFactory)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
private sealed record DeleteEnvelopeRequest(string Reason);
|
||||||
|
private sealed record ErrorResponse(string? Message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches envelopes from the API with optional filters.
|
/// Fetches envelopes from the API with optional filters.
|
||||||
@@ -61,4 +64,83 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
|
|||||||
|
|
||||||
return await response.Content.ReadFromJsonAsync<IEnumerable<EnvelopeDto>>(_jsonOptions, cancel);
|
return await response.Content.ReadFromJsonAsync<IEnumerable<EnvelopeDto>>(_jsonOptions, cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int envelopeId, string reason, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/Envelope/{envelopeId}")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new DeleteEnvelopeRequest(reason), options: _jsonOptions)
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await httpClient.SendAsync(request, cancel);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string? message = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>(_jsonOptions, cancel);
|
||||||
|
message = error?.Message;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
message = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusCode = (int)response.StatusCode;
|
||||||
|
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Failed to delete envelope. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(message) ? string.Empty : $" - {message}")}",
|
||||||
|
null,
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EnvelopeDto?> UpsertAsync(
|
||||||
|
CreateEnvelopeCommand request,
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return await response.Content.ReadFromJsonAsync<EnvelopeDto>(_jsonOptions, cancel);
|
||||||
|
|
||||||
|
string? message = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>(_jsonOptions, cancel);
|
||||||
|
message = error?.Message;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
message = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusCode = (int)response.StatusCode;
|
||||||
|
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Failed to save envelope. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(message) ? string.Empty : $" - {message}")}",
|
||||||
|
null,
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
public class LocalizationService(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
private HttpClient CreateDefaultClient() => httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
|
||||||
|
public async Task<string> GetLanguageAsync(CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = CreateDefaultClient();
|
||||||
|
var response = await http.GetAsync("/api/Localization/lang", cancel);
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
return "de-DE";
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return "de-DE";
|
||||||
|
|
||||||
|
var language = await response.Content.ReadAsStringAsync(cancel);
|
||||||
|
return string.IsNullOrWhiteSpace(language) ? "de-DE" : language.Trim('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetLanguageAsync(string language, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = CreateDefaultClient();
|
||||||
|
var response = await http.PostAsync($"/api/Localization/lang?language={Uri.EscapeDataString(language)}", null, cancel);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Services
|
||||||
@using EnvelopeGenerator.Server.Client.Models
|
@using EnvelopeGenerator.Server.Client.Models
|
||||||
@using EnvelopeGenerator.Server.Client.Options
|
@using EnvelopeGenerator.Server.Client.Options
|
||||||
|
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
|
||||||
@using DevExpress.Blazor
|
@using DevExpress.Blazor
|
||||||
@using DevExpress.Blazor.PdfViewer
|
@using DevExpress.Blazor.PdfViewer
|
||||||
@using DevExpress.Blazor.Reporting
|
@using DevExpress.Blazor.Reporting
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
|
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" />
|
||||||
<link rel="stylesheet" href="css/app.css" />
|
<link rel="stylesheet" href="css/app.css" />
|
||||||
<link rel="stylesheet" href="css/envelope-viewer.css" />
|
<link rel="stylesheet" href="css/envelope-viewer.css" />
|
||||||
<link rel="stylesheet" href="EnvelopeGenerator.Server.styles.css" />
|
<link rel="stylesheet" href="EnvelopeGenerator.Server.styles.css" />
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
@page "/sender/editor"
|
@page "/sender/editor_legacy"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@using DevExpress.Blazor.PdfViewer
|
@using DevExpress.Blazor.PdfViewer
|
||||||
@using DevExpress.Blazor.Reporting.Models
|
@using DevExpress.Blazor.Reporting.Models
|
||||||
@using DevExpress.Blazor
|
@using DevExpress.Blazor
|
||||||
@using EnvelopeGenerator.Application.EnvelopeReceivers.Commands
|
@using EnvelopeGenerator.Application.Common.Dto
|
||||||
|
@using EnvelopeGenerator.Application.Envelopes.Commands
|
||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
@using EnvelopeGenerator.Server.Client.Services
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
@using EnvelopeGenerator.Server.Services
|
@using EnvelopeGenerator.Server.Services
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.Extensions.Caching.Memory
|
@using Microsoft.Extensions.Caching.Memory
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
||||||
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
||||||
@inject EnvelopeReceiverService EnvelopeReceiverService
|
@inject EnvelopeService EnvelopeService
|
||||||
|
@inject IEnvelopeAuthService EnvelopeAuthService
|
||||||
@inject IMemoryCache MemoryCache
|
@inject IMemoryCache MemoryCache
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
|
|
||||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
<link href="_content/DevExpress.Blazor.Viewer/css/dx-blazor-viewer-components.bs5.css" rel="stylesheet" />
|
<link href="_content/DevExpress.Blazor.Viewer/css/dx-blazor-viewer-components.bs5.css" rel="stylesheet" />
|
||||||
@@ -38,12 +44,12 @@
|
|||||||
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 1 1 180px; min-width: 140px;">
|
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 1 1 180px; min-width: 140px;">
|
||||||
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
|
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;">
|
text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
Titel <span style="color:#dc2626;">*</span>
|
@L("Title", "Titel") <span style="color:#dc2626;">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
value="@_envelopeTitle"
|
value="@_envelopeTitle"
|
||||||
@oninput="e => _envelopeTitle = e.Value?.ToString() ?? string.Empty"
|
@oninput="e => _envelopeTitle = e.Value?.ToString() ?? string.Empty"
|
||||||
placeholder="z.B. Arbeitsvertrag 2025"
|
placeholder='@L("SenderEditor_Title_Placeholder", "z.B. Arbeitsvertrag 2025")'
|
||||||
maxlength="120"
|
maxlength="120"
|
||||||
style="font-size: 0.78rem; font-weight: 500; color: #1f2937;
|
style="font-size: 0.78rem; font-weight: 500; color: #1f2937;
|
||||||
border: 1px solid #d1d5db; border-radius: 5px;
|
border: 1px solid #d1d5db; border-radius: 5px;
|
||||||
@@ -52,7 +58,7 @@
|
|||||||
@(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "border-color:#dc2626;" : "")" />
|
@(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "border-color:#dc2626;" : "")" />
|
||||||
@if (_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle))
|
@if (_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle))
|
||||||
{
|
{
|
||||||
<span style="font-size:0.6rem;color:#dc2626;">Pflichtfeld</span>
|
<span style="font-size:0.6rem;color:#dc2626;">@L("RequiredField", "Pflichtfeld")</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,12 +66,12 @@
|
|||||||
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 2 1 260px; min-width: 180px;">
|
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 2 1 260px; min-width: 180px;">
|
||||||
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
|
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;">
|
text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
Nachricht <span style="color:#6b7280;font-weight:400;">(optional)</span>
|
@L("Message", "Nachricht") <span style="color:#6b7280;font-weight:400;">(@L("Optional", "optional"))</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
value="@_envelopeMessage"
|
value="@_envelopeMessage"
|
||||||
@oninput="e => _envelopeMessage = e.Value?.ToString() ?? string.Empty"
|
@oninput="e => _envelopeMessage = e.Value?.ToString() ?? string.Empty"
|
||||||
placeholder="Bitte unterzeichnen Sie das beigefügte Dokument."
|
placeholder='@L("SenderEditor_Message_Placeholder", "Bitte unterzeichnen Sie das beigefügte Dokument.")'
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
style="font-size: 0.78rem; color: #1f2937;
|
style="font-size: 0.78rem; color: #1f2937;
|
||||||
border: 1px solid #d1d5db; border-radius: 5px;
|
border: 1px solid #d1d5db; border-radius: 5px;
|
||||||
@@ -83,7 +89,7 @@
|
|||||||
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem;
|
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem;
|
||||||
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
|
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
|
||||||
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
|
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
|
||||||
@_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "")
|
@_signatureFields.Count @L("SenderEditor_SignatureField", "Signaturfeld")@(_signatureFields.Count != 1 ? "er" : "")
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -93,24 +99,24 @@
|
|||||||
<div class="sender-receivers-panel">
|
<div class="sender-receivers-panel">
|
||||||
<div class="sender-receivers-panel__header">
|
<div class="sender-receivers-panel__header">
|
||||||
<div class="sender-receivers-panel__title-wrap">
|
<div class="sender-receivers-panel__title-wrap">
|
||||||
<span class="sender-receivers-panel__title">Empfänger</span>
|
<span class="sender-receivers-panel__title">@L("Receiver", "Empfänger")</span>
|
||||||
<span class="sender-receivers-panel__count">@_receivers.Count</span>
|
<span class="sender-receivers-panel__count">@_receivers.Count</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn"
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn"
|
||||||
@onclick="OpenAddReceiverPopup"
|
@onclick="OpenAddReceiverPopup"
|
||||||
title="Empfänger hinzufügen">
|
title='@L("AddReceiver", "Empfänger hinzufügen")'>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M8 1a.5.5 0 0 1 .5.5v6h6a.5.5 0 0 1 0 1h-6v6a.5.5 0 0 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 8 1z" />
|
<path d="M8 1a.5.5 0 0 1 .5.5v6h6a.5.5 0 0 1 0 1h-6v6a.5.5 0 0 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 8 1z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="pdf-toolbar__btn-text">Empfänger hinzufügen</span>
|
<span class="pdf-toolbar__btn-text">@L("AddReceiver", "Empfänger hinzufügen")</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (_receivers.Count == 0)
|
@if (_receivers.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="sender-receivers-panel__empty">
|
<div class="sender-receivers-panel__empty">
|
||||||
Noch keine Empfänger hinzugefügt.
|
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -133,11 +139,11 @@
|
|||||||
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
|
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
|
||||||
@onclick="() => ActivatePlacementForReceiver(receiver)"
|
@onclick="() => ActivatePlacementForReceiver(receiver)"
|
||||||
style="@(_pendingReceiverForPlacement?.Id == receiver.Id ? $"background:{receiver.Color};color:#fff;border-color:{receiver.Color};" : $"color:{receiver.Color};border-color:{receiver.Color};")"
|
style="@(_pendingReceiverForPlacement?.Id == receiver.Id ? $"background:{receiver.Color};color:#fff;border-color:{receiver.Color};" : $"color:{receiver.Color};border-color:{receiver.Color};")"
|
||||||
title="Signaturfeld platzieren">
|
title='@L("SenderEditor_PlaceSignatureField", "Signaturfeld platzieren")'>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="pdf-toolbar__btn-text">Signatur hinzufügen</span>
|
<span class="pdf-toolbar__btn-text">@L("SenderEditor_AddSignature", "Signatur hinzufügen")</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -151,59 +157,73 @@
|
|||||||
|
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||||||
@onclick="Cancel"
|
@onclick="Cancel"
|
||||||
title="Abbrechen">
|
title='@L("Cancel", "Abbrechen")'>
|
||||||
Abbrechen
|
@L("Cancel", "Abbrechen")
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@* PDF Upload *@
|
@* PDF Upload *@
|
||||||
<label class="pdf-toolbar__btn"
|
<label class="pdf-toolbar__btn"
|
||||||
title="PDF hochladen"
|
title='@L("UploadPdf", "PDF hochladen")'
|
||||||
style="cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; font-weight: 500;">
|
style="cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; font-weight: 500;">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
|
||||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
|
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
|
||||||
</svg>
|
</svg>
|
||||||
PDF hochladen
|
@L("UploadPdf", "PDF hochladen")
|
||||||
<InputFile OnChange="OnPdfFileSelectedAsync"
|
<InputFile OnChange="OnPdfFileSelectedAsync"
|
||||||
accept=".pdf"
|
accept=".pdf"
|
||||||
style="display: none;" />
|
style="display: none;" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@if (_pdfLoaded)
|
@if (_pdfLoaded && _signatureFields.Count > 0)
|
||||||
{
|
{
|
||||||
@* Clear all fields *@
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||||||
@if (_signatureFields.Count > 0)
|
@onclick="ClearAllFieldsAsync"
|
||||||
{
|
title='@L("SenderEditor_RemoveAllSignatureFields", "Alle Signaturfelder entfernen")'>
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
@onclick="ClearAllFieldsAsync"
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||||
title="Alle Signaturfelder entfernen">
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h3a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3h11V2h-11v1z" />
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
</svg>
|
||||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
|
||||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h3a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3h11V2h-11v1z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
@* Save *@
|
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
|
|
||||||
@onclick="SaveAsync"
|
|
||||||
disabled="@_isSaving"
|
|
||||||
title="Speichern"
|
|
||||||
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
|
|
||||||
@if (_isSaving)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-1" role="status"
|
|
||||||
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
|
||||||
<path d="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" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
Speichern
|
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--draft"
|
||||||
|
@onclick="SaveDraftAsync"
|
||||||
|
disabled="@_isSaving"
|
||||||
|
title='@L("Save", "Speichern")'>
|
||||||
|
@if (_isSaving)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"
|
||||||
|
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5.414A2 2 0 0 0 15.414 4L12 0.586A2 2 0 0 0 10.586 0H2zm9 1.5v2A1.5 1.5 0 0 1 9.5 7h-3A1.5 1.5 0 0 1 5 5.5v-4h5.586a1 1 0 0 1 .707.293L14.207 4.707A1 1 0 0 1 14.5 5.414V12a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5V4A.5.5 0 0 1 2 3.5h9z"/>
|
||||||
|
<path d="M5.5 1.5v4a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-4h-4zM4 9a1 1 0 0 0-1 1v3h10v-3a1 1 0 0 0-1-1H4z"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
<span class="pdf-toolbar__btn-text">@L("Save", "Speichern")</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
|
||||||
|
@onclick="SendAsync"
|
||||||
|
disabled="@_isSaving"
|
||||||
|
title='@L("SendEnvelope", "Umschlag senden")'
|
||||||
|
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
|
||||||
|
@if (_isSaving)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"
|
||||||
|
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="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" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
@L("Send", "Senden")
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,10 +232,10 @@
|
|||||||
{
|
{
|
||||||
<div style="background: @_pendingReceiverForPlacement.Color; color: white; font-size: 0.75rem; font-weight: 500;
|
<div style="background: @_pendingReceiverForPlacement.Color; color: white; font-size: 0.75rem; font-weight: 500;
|
||||||
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
|
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
|
||||||
📌 Klicken Sie auf die Stelle im Dokument für <strong>@_pendingReceiverForPlacement.FullName</strong>.
|
📌 @L("SenderEditor_ClickDocumentFor", "Klicken Sie auf die Stelle im Dokument für") <strong>@_pendingReceiverForPlacement.FullName</strong>.
|
||||||
<button @onclick="CancelPlacement"
|
<button @onclick="CancelPlacement"
|
||||||
style="background:none;border:none;color:white;cursor:pointer;font-size:0.75rem;text-decoration:underline;padding:0;">
|
style="background:none;border:none;color:white;cursor:pointer;font-size:0.75rem;text-decoration:underline;padding:0;">
|
||||||
Abbrechen
|
@L("Cancel", "Abbrechen")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -235,16 +255,16 @@
|
|||||||
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
|
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h5 class="text-white mb-2">Kein Dokument geladen</h5>
|
<h5 class="text-white mb-2">@L("SenderEditor_NoDocumentLoaded", "Kein Dokument geladen")</h5>
|
||||||
<p class="text-white mb-4" style="opacity: 0.75; font-size: 0.85rem;">
|
<p class="text-white mb-4" style="opacity: 0.75; font-size: 0.85rem;">
|
||||||
Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.
|
@L("SenderEditor_UploadPdfToPlaceFields", "Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.")
|
||||||
</p>
|
</p>
|
||||||
<label class="btn btn-light btn-sm" style="cursor: pointer; font-weight: 500;">
|
<label class="btn btn-light btn-sm" style="cursor: pointer; font-weight: 500;">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
|
||||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
|
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
|
||||||
</svg>
|
</svg>
|
||||||
PDF hochladen
|
@L("UploadPdf", "PDF hochladen")
|
||||||
<InputFile OnChange="OnPdfFileSelectedAsync" accept=".pdf" style="display: none;" />
|
<InputFile OnChange="OnPdfFileSelectedAsync" accept=".pdf" style="display: none;" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,7 +274,7 @@
|
|||||||
{
|
{
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<div class="alert alert-danger shadow-lg m-4">
|
<div class="alert alert-danger shadow-lg m-4">
|
||||||
<strong>Fehler:</strong> @_errorMessage
|
<strong>@L("Error", "Fehler"):</strong> @_errorMessage
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -273,7 +293,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DxPopup @bind-Visible="_receiverPopupVisible"
|
<DxPopup @bind-Visible="_receiverPopupVisible"
|
||||||
HeaderText="Empfänger hinzufügen"
|
HeaderText='@L("AddReceiver", "Empfänger hinzufügen")'
|
||||||
ShowFooter="true"
|
ShowFooter="true"
|
||||||
CloseOnEscape="true"
|
CloseOnEscape="true"
|
||||||
Width="720px"
|
Width="720px"
|
||||||
@@ -282,7 +302,7 @@
|
|||||||
<div class="sender-receiver-popup__body">
|
<div class="sender-receiver-popup__body">
|
||||||
<div class="sender-receiver-popup__form-grid">
|
<div class="sender-receiver-popup__form-grid">
|
||||||
<div class="sender-receiver-popup__field">
|
<div class="sender-receiver-popup__field">
|
||||||
<div class="sender-receiver-popup__label">Vor- und Nachname</div>
|
<div class="sender-receiver-popup__label">@L("FullName", "Vor- und Nachname")</div>
|
||||||
<DxTextBox Text="@_receiverDraftName"
|
<DxTextBox Text="@_receiverDraftName"
|
||||||
TextChanged="OnReceiverNameChanged"
|
TextChanged="OnReceiverNameChanged"
|
||||||
NullText="Max Mustermann"
|
NullText="Max Mustermann"
|
||||||
@@ -292,7 +312,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sender-receiver-popup__field">
|
<div class="sender-receiver-popup__field">
|
||||||
<div class="sender-receiver-popup__label">Telefonnummer (optional)</div>
|
<div class="sender-receiver-popup__label">@L("PhoneNumber", "Telefonnummer") (@L("Optional", "optional"))</div>
|
||||||
<DxTextBox Text="@_receiverDraftPhoneNumber"
|
<DxTextBox Text="@_receiverDraftPhoneNumber"
|
||||||
TextChanged="OnReceiverPhoneNumberChanged"
|
TextChanged="OnReceiverPhoneNumberChanged"
|
||||||
NullText="+49 170 1234567"
|
NullText="+49 170 1234567"
|
||||||
@@ -302,7 +322,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sender-receiver-popup__field">
|
<div class="sender-receiver-popup__field">
|
||||||
<div class="sender-receiver-popup__label">E-Mail-Adresse</div>
|
<div class="sender-receiver-popup__label">@L("EmailAddress", "E-Mail-Adresse")</div>
|
||||||
<div @onkeydown="OnReceiverEmailKeyDownAsync">
|
<div @onkeydown="OnReceiverEmailKeyDownAsync">
|
||||||
<DxTextBox Text="@_receiverDraftEmail"
|
<DxTextBox Text="@_receiverDraftEmail"
|
||||||
TextChanged="OnReceiverEmailTextChangedAsync"
|
TextChanged="OnReceiverEmailTextChangedAsync"
|
||||||
@@ -330,12 +350,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sender-receiver-popup__hint">
|
<div class="sender-receiver-popup__hint">
|
||||||
Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.
|
@L("SenderEditor_EmailSuggestionsHint", "Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.")
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (_isReceiverEmailSearchRunning)
|
@if (_isReceiverEmailSearchRunning)
|
||||||
{
|
{
|
||||||
<div class="sender-receiver-popup__loading">E-Mail-Vorschläge werden geladen…</div>
|
<div class="sender-receiver-popup__loading">@L("SenderEditor_EmailSuggestionsLoading", "E-Mail-Vorschläge werden geladen…")</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(_receiverPopupValidationMessage))
|
@if (!string.IsNullOrWhiteSpace(_receiverPopupValidationMessage))
|
||||||
@@ -346,10 +366,10 @@
|
|||||||
</BodyContentTemplate>
|
</BodyContentTemplate>
|
||||||
<FooterContentTemplate>
|
<FooterContentTemplate>
|
||||||
<div class="sender-receiver-popup__footer">
|
<div class="sender-receiver-popup__footer">
|
||||||
<DxButton Text="Abbrechen"
|
<DxButton Text='@L("Cancel", "Abbrechen")'
|
||||||
RenderStyle="ButtonRenderStyle.Secondary"
|
RenderStyle="ButtonRenderStyle.Secondary"
|
||||||
Click="CloseAddReceiverPopup" />
|
Click="CloseAddReceiverPopup" />
|
||||||
<DxButton Text="Empfänger hinzufügen"
|
<DxButton Text='@L("AddReceiver", "Empfänger hinzufügen")'
|
||||||
RenderStyle="ButtonRenderStyle.Primary"
|
RenderStyle="ButtonRenderStyle.Primary"
|
||||||
Click="SaveReceiverAsync" />
|
Click="SaveReceiverAsync" />
|
||||||
</div>
|
</div>
|
||||||
@@ -358,7 +378,7 @@
|
|||||||
|
|
||||||
@* ── Save result popup (success + error) ── *@
|
@* ── Save result popup (success + error) ── *@
|
||||||
<DxPopup @bind-Visible="_savePopupVisible"
|
<DxPopup @bind-Visible="_savePopupVisible"
|
||||||
HeaderText="@(_saveErrorMessage is null ? "Umschlag erstellt" : "Fehler")"
|
HeaderText='@(_saveErrorMessage is null ? _saveSuccessHeader : L("Error", "Fehler"))'
|
||||||
Width="460px"
|
Width="460px"
|
||||||
MaxWidth="95vw"
|
MaxWidth="95vw"
|
||||||
ShowFooter="true"
|
ShowFooter="true"
|
||||||
@@ -377,10 +397,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
||||||
Umschlag wurde erfolgreich erstellt.
|
@_saveSuccessTitle
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
|
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
|
||||||
Der Umschlag wurde gespeichert und die Empfänger wurden benachrichtigt.
|
@_saveSuccessBody
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,7 +417,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
|
||||||
Fehler beim Erstellen des Umschlags
|
@L("SenderEditor_ErrorCreatingEnvelope", "Fehler beim Erstellen des Umschlags")
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;word-break:break-word;">
|
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;word-break:break-word;">
|
||||||
@_saveErrorMessage
|
@_saveErrorMessage
|
||||||
@@ -410,19 +430,28 @@
|
|||||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
|
||||||
@if (_saveErrorMessage is null)
|
@if (_saveErrorMessage is null)
|
||||||
{
|
{
|
||||||
<button class="btn btn-primary"
|
<button class="btn btn-outline-secondary"
|
||||||
@onclick="GoToDashboard"
|
@onclick="() => _savePopupVisible = false"
|
||||||
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
|
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
|
||||||
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
|
@L("Close", "Schließen")
|
||||||
Zur Übersicht
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@if (_saveNavigateToDashboard)
|
||||||
|
{
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
@onclick="GoToDashboard"
|
||||||
|
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
|
||||||
|
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
|
||||||
|
@L("SenderEditor_ToOverview", "Zur Übersicht")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<button class="btn btn-outline-secondary"
|
<button class="btn btn-outline-secondary"
|
||||||
@onclick="() => _savePopupVisible = false"
|
@onclick="() => _savePopupVisible = false"
|
||||||
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
|
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
|
||||||
Schließen
|
@L("Close", "Schließen")
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -434,6 +463,9 @@
|
|||||||
[SupplyParameterFromQuery(Name = "esid")]
|
[SupplyParameterFromQuery(Name = "esid")]
|
||||||
public string? Esid { get; set; }
|
public string? Esid { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = "envelopeId")]
|
||||||
|
public int? EnvelopeId { get; set; }
|
||||||
|
|
||||||
// ── Constants ──
|
// ── Constants ──
|
||||||
// Signature field size in PDF points (fixed): 1.77" × 1.96"
|
// Signature field size in PDF points (fixed): 1.77" × 1.96"
|
||||||
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
||||||
@@ -452,6 +484,9 @@
|
|||||||
string? _errorMessage;
|
string? _errorMessage;
|
||||||
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
|
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
|
||||||
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
|
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
|
||||||
|
int? _loadedEnvelopeId;
|
||||||
|
int? _editingEnvelopeId;
|
||||||
|
bool _isLoadingEnvelopeForEdit;
|
||||||
|
|
||||||
List<SignatureFieldDraft> _signatureFields = [];
|
List<SignatureFieldDraft> _signatureFields = [];
|
||||||
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
||||||
@@ -460,6 +495,10 @@
|
|||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
bool _savePopupVisible = false;
|
bool _savePopupVisible = false;
|
||||||
string? _saveErrorMessage = null;
|
string? _saveErrorMessage = null;
|
||||||
|
string _saveSuccessHeader = "Erfolg";
|
||||||
|
string _saveSuccessTitle = "Vorgang erfolgreich abgeschlossen.";
|
||||||
|
string _saveSuccessBody = string.Empty;
|
||||||
|
bool _saveNavigateToDashboard = false;
|
||||||
|
|
||||||
// ── Envelope metadata ──
|
// ── Envelope metadata ──
|
||||||
string _envelopeTitle = string.Empty;
|
string _envelopeTitle = string.Empty;
|
||||||
@@ -481,6 +520,7 @@
|
|||||||
|
|
||||||
// ── Cache key helper ──
|
// ── Cache key helper ──
|
||||||
string SessionKey => $"sender-editor:{Esid}";
|
string SessionKey => $"sender-editor:{Esid}";
|
||||||
|
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
|
||||||
|
|
||||||
// ── Lifecycle ──
|
// ── Lifecycle ──
|
||||||
|
|
||||||
@@ -500,12 +540,27 @@
|
|||||||
if (string.IsNullOrWhiteSpace(Esid))
|
if (string.IsNullOrWhiteSpace(Esid))
|
||||||
{
|
{
|
||||||
var sid = Guid.NewGuid().ToString("N");
|
var sid = Guid.NewGuid().ToString("N");
|
||||||
NavigationManager.NavigateTo($"/sender/editor?esid={sid}", forceLoad: false);
|
var editQuery = EnvelopeId.HasValue && EnvelopeId.Value > 0
|
||||||
|
? $"&envelopeId={EnvelopeId.Value}"
|
||||||
|
: string.Empty;
|
||||||
|
NavigationManager.NavigateTo($"/sender/editor_legacy?esid={sid}{editQuery}", forceLoad: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
|
if (EnvelopeId.HasValue && EnvelopeId.Value > 0)
|
||||||
|
_editingEnvelopeId = EnvelopeId.Value;
|
||||||
|
|
||||||
|
if (IsEditMode && _loadedEnvelopeId != EnvelopeId)
|
||||||
|
{
|
||||||
|
await LoadEnvelopeForEditAsync(EnvelopeId!.Value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsEditMode)
|
||||||
|
return;
|
||||||
|
|
||||||
// After the esid is set via query param, try to restore from cache.
|
// After the esid is set via query param, try to restore from cache.
|
||||||
if (!string.IsNullOrWhiteSpace(Esid)
|
if (!string.IsNullOrWhiteSpace(Esid)
|
||||||
&& MemoryCache.TryGetValue(SessionKey, out EditorSessionData? cached)
|
&& MemoryCache.TryGetValue(SessionKey, out EditorSessionData? cached)
|
||||||
@@ -525,6 +580,93 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async Task LoadEnvelopeForEditAsync(int envelopeId)
|
||||||
|
{
|
||||||
|
if (_isLoadingEnvelopeForEdit)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isLoadingEnvelopeForEdit = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = EnvelopeAuthService.GetCurrentUser();
|
||||||
|
if (user?.Identity is null || !user.Identity.IsAuthenticated)
|
||||||
|
throw new InvalidOperationException("Sender is not authenticated.");
|
||||||
|
|
||||||
|
var envelopes = await EnvelopeService.GetAsync(id: envelopeId);
|
||||||
|
var envelope = envelopes?.FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException($"Envelope {envelopeId} not found.");
|
||||||
|
|
||||||
|
var document = await ReceiverPageDataService.GetDocumentDetailsOfSenderAsync(user, envelopeId);
|
||||||
|
|
||||||
|
_envelopeTitle = envelope.Title ?? string.Empty;
|
||||||
|
_envelopeMessage = envelope.Message ?? string.Empty;
|
||||||
|
|
||||||
|
var envelopeReceivers = envelope.EnvelopeReceivers?.ToList() ?? [];
|
||||||
|
_receivers = envelopeReceivers
|
||||||
|
.Select((er, index) => new ReceiverDraft(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
er.Name ?? er.Receiver?.EmailAddress ?? $"Receiver {er.ReceiverId}",
|
||||||
|
er.Receiver?.EmailAddress ?? string.Empty,
|
||||||
|
string.Empty,
|
||||||
|
ReceiverPalette[index % ReceiverPalette.Length]))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var receiverById = envelopeReceivers.ToDictionary(er => er.ReceiverId, er => er);
|
||||||
|
var colorByEmail = _receivers
|
||||||
|
.Where(r => !string.IsNullOrWhiteSpace(r.Email))
|
||||||
|
.GroupBy(r => r.Email, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Color, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
_signatureFields = (document?.Elements ?? Enumerable.Empty<DocReceiverElementDto>())
|
||||||
|
.Select(element =>
|
||||||
|
{
|
||||||
|
receiverById.TryGetValue(element.ReceiverId, out var envelopeReceiver);
|
||||||
|
|
||||||
|
var receiverName = envelopeReceiver?.Name
|
||||||
|
?? envelopeReceiver?.Receiver?.EmailAddress
|
||||||
|
?? element.FullName
|
||||||
|
?? $"Receiver {element.ReceiverId}";
|
||||||
|
|
||||||
|
var receiverEmail = envelopeReceiver?.Receiver?.EmailAddress;
|
||||||
|
var color = (!string.IsNullOrWhiteSpace(receiverEmail) && colorByEmail.TryGetValue(receiverEmail, out var receiverColor))
|
||||||
|
? receiverColor
|
||||||
|
: ReceiverPalette[Math.Abs(element.ReceiverId) % ReceiverPalette.Length];
|
||||||
|
|
||||||
|
return new SignatureFieldDraft(
|
||||||
|
XPt: element.X * 72.0,
|
||||||
|
YPt: element.Y * 72.0,
|
||||||
|
Page: element.Page,
|
||||||
|
ReceiverName: receiverName,
|
||||||
|
Color: color);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_originalPdfBytes = document?.ByteData;
|
||||||
|
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
|
||||||
|
_pdfBytes = _pdfLoaded
|
||||||
|
? DrawPlaceholders(_originalPdfBytes!, _signatureFields)
|
||||||
|
: null;
|
||||||
|
_fileName = $"Envelope-{envelopeId}.pdf";
|
||||||
|
_pendingReceiverForPlacement = null;
|
||||||
|
_loadedEnvelopeId = envelopeId;
|
||||||
|
_editingEnvelopeId = envelopeId;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to load envelope {EnvelopeId} for edit mode", envelopeId);
|
||||||
|
_errorMessage = string.Format(
|
||||||
|
L("SenderEditor_LoadEnvelopeError", "Fehler beim Laden des Umschlags #{0}: {1}"),
|
||||||
|
envelopeId,
|
||||||
|
ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoadingEnvelopeForEdit = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── PDF upload ──
|
// ── PDF upload ──
|
||||||
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -534,7 +676,7 @@
|
|||||||
|
|
||||||
if (!file.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
|
if (!file.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_errorMessage = "Bitte wählen Sie eine PDF-Datei aus.";
|
_errorMessage = L("SenderEditor_SelectPdf", "Bitte wählen Sie eine PDF-Datei aus.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +701,9 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_errorMessage = $"Fehler beim Laden der Datei: {ex.Message}";
|
_errorMessage = string.Format(
|
||||||
|
L("SenderEditor_LoadFileError", "Fehler beim Laden der Datei: {0}"),
|
||||||
|
ex.Message);
|
||||||
Logger.LogError(ex, "Failed to load PDF file");
|
Logger.LogError(ex, "Failed to load PDF file");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -670,32 +814,72 @@
|
|||||||
NavigationManager.NavigateTo("/sender");
|
NavigationManager.NavigateTo("/sender");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Save — POST /api/EnvelopeReceiver ──
|
async Task SaveDraftAsync()
|
||||||
async Task SaveAsync()
|
{
|
||||||
|
_isSaving = true;
|
||||||
|
_saveErrorMessage = null;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var previousEnvelopeId = EnvelopeId;
|
||||||
|
var command = BuildCreateEnvelopeCommand(send: false);
|
||||||
|
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId} IsEditMode={IsEditMode}", command.EnvelopeId, IsEditMode);
|
||||||
|
var result = await EnvelopeService.UpsertAsync(command);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
throw new InvalidOperationException("Draft save did not return a result.");
|
||||||
|
|
||||||
|
EnvelopeId = result.Id;
|
||||||
|
_loadedEnvelopeId = result.Id;
|
||||||
|
_editingEnvelopeId = result.Id;
|
||||||
|
|
||||||
|
if (previousEnvelopeId != result.Id)
|
||||||
|
{
|
||||||
|
var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid;
|
||||||
|
NavigationManager.NavigateTo($"/sender/editor_legacy?esid={sid}&envelopeId={result.Id}", forceLoad: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationManager.NavigateTo("/sender", forceLoad: false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "[SenderEditor] Failed to save draft");
|
||||||
|
_saveErrorMessage = ex.Message;
|
||||||
|
_savePopupVisible = true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send envelope ──
|
||||||
|
async Task SendAsync()
|
||||||
{
|
{
|
||||||
// ── Validation ──
|
// ── Validation ──
|
||||||
_titleTouched = true;
|
_titleTouched = true;
|
||||||
if (!_pdfLoaded || _originalPdfBytes is null)
|
if (!_pdfLoaded || _originalPdfBytes is null)
|
||||||
{
|
{
|
||||||
_saveErrorMessage = "Bitte laden Sie zuerst ein PDF-Dokument hoch.";
|
_saveErrorMessage = L("SenderEditor_Validation_UploadPdf", "Bitte laden Sie zuerst ein PDF-Dokument hoch.");
|
||||||
_savePopupVisible = true;
|
_savePopupVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (string.IsNullOrWhiteSpace(_envelopeTitle))
|
if (string.IsNullOrWhiteSpace(_envelopeTitle))
|
||||||
{
|
{
|
||||||
_saveErrorMessage = "Bitte geben Sie einen Titel ein.";
|
_saveErrorMessage = L("SenderEditor_Validation_TitleRequired", "Bitte geben Sie einen Titel ein.");
|
||||||
_savePopupVisible = true;
|
_savePopupVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_receivers.Count == 0)
|
if (_receivers.Count == 0)
|
||||||
{
|
{
|
||||||
_saveErrorMessage = "Bitte fügen Sie mindestens einen Empfänger hinzu.";
|
_saveErrorMessage = L("SenderEditor_Validation_ReceiverRequired", "Bitte fügen Sie mindestens einen Empfänger hinzu.");
|
||||||
_savePopupVisible = true;
|
_savePopupVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_signatureFields.Count == 0)
|
if (_signatureFields.Count == 0)
|
||||||
{
|
{
|
||||||
_saveErrorMessage = "Bitte platzieren Sie mindestens ein Signaturfeld.";
|
_saveErrorMessage = L("SenderEditor_Validation_SignatureFieldRequired", "Bitte platzieren Sie mindestens ein Signaturfeld.");
|
||||||
_savePopupVisible = true;
|
_savePopupVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -717,57 +901,38 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// ── Build request ──
|
var envelopeId = _editingEnvelopeId
|
||||||
// Document: use the ORIGINAL pdf (without placeholder burn-in) as base64
|
?? EnvelopeId
|
||||||
var docBase64 = Convert.ToBase64String(_originalPdfBytes);
|
?? _loadedEnvelopeId
|
||||||
|
?? GetEnvelopeIdFromCurrentUri();
|
||||||
|
|
||||||
// Build receivers list — each receiver gets their own signature fields
|
if (!envelopeId.HasValue || envelopeId.Value <= 0)
|
||||||
// Coordinate conversion: PDF points → inches (DB stores inches)
|
|
||||||
var receiversCmd = _receivers.Select(receiver =>
|
|
||||||
{
|
{
|
||||||
var fields = _signatureFields
|
var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false))
|
||||||
.Where(f => f.ReceiverName == receiver.FullName)
|
?? throw new InvalidOperationException("Draft save did not return a result.");
|
||||||
.Select(f => new DocReceiverElementCreateDto(
|
|
||||||
X: f.XPt / 72.0,
|
|
||||||
Y: f.YPt / 72.0,
|
|
||||||
Page: f.Page))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new ReceiverGetOrCreateCommand
|
envelopeId = saveResult.Id;
|
||||||
{
|
EnvelopeId = saveResult.Id;
|
||||||
EmailAddress = receiver.Email,
|
_loadedEnvelopeId = saveResult.Id;
|
||||||
Salution = receiver.FullName,
|
_editingEnvelopeId = saveResult.Id;
|
||||||
PhoneNumber = string.IsNullOrWhiteSpace(receiver.PhoneNumber)
|
}
|
||||||
? null
|
|
||||||
: receiver.PhoneNumber,
|
|
||||||
DocReceiverElements = fields,
|
|
||||||
};
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
var command = new CreateEnvelopeReceiverCommand
|
var sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId })
|
||||||
{
|
?? throw new InvalidOperationException("Send did not return a result.");
|
||||||
Title = _envelopeTitle.Trim(),
|
|
||||||
Message = string.IsNullOrWhiteSpace(_envelopeMessage)
|
|
||||||
? "Bitte unterzeichnen Sie das beigefügte Dokument."
|
|
||||||
: _envelopeMessage.Trim(),
|
|
||||||
TFAEnabled = false,
|
|
||||||
Document = new DocumentCreateCommand { DataAsBase64 = docBase64 },
|
|
||||||
Receivers = receiversCmd,
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = await EnvelopeReceiverService.CreateAsync(command);
|
Logger.LogInformation("[SenderEditor] Envelope sent from draft. Id={Id} Status={Status}", sendResult.Id, sendResult.Status);
|
||||||
|
|
||||||
Logger.LogInformation(
|
|
||||||
"[SenderEditor] Envelope created. Id={Id} SentReceivers={Count}",
|
|
||||||
result?.Id, result?.SentReceiver.Count());
|
|
||||||
|
|
||||||
// Success — show popup; GoToDashboard clears cache and navigates
|
// Success — show popup; GoToDashboard clears cache and navigates
|
||||||
_saveErrorMessage = null;
|
_saveErrorMessage = null;
|
||||||
|
_saveSuccessHeader = L("SenderEditor_Sent_Header", "Umschlag gesendet");
|
||||||
|
_saveSuccessTitle = L("SenderEditor_Sent_Title", "Umschlag wurde erfolgreich gesendet.");
|
||||||
|
_saveSuccessBody = L("SenderEditor_Sent_Body", "Der bestehende Entwurf wurde versendet.");
|
||||||
|
_saveNavigateToDashboard = true;
|
||||||
_savePopupVisible = true;
|
_savePopupVisible = true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "[SenderEditor] Failed to create envelope");
|
Logger.LogError(ex, "[SenderEditor] Failed to send envelope draft");
|
||||||
_saveErrorMessage = ex.Message;
|
_saveErrorMessage = ex.Message;
|
||||||
_savePopupVisible = true;
|
_savePopupVisible = true;
|
||||||
}
|
}
|
||||||
@@ -777,6 +942,71 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CreateEnvelopeCommand BuildCreateEnvelopeCommand(bool send)
|
||||||
|
{
|
||||||
|
var envelopeId = _editingEnvelopeId
|
||||||
|
?? EnvelopeId
|
||||||
|
?? _loadedEnvelopeId
|
||||||
|
?? GetEnvelopeIdFromCurrentUri();
|
||||||
|
|
||||||
|
if (IsEditMode && envelopeId is null)
|
||||||
|
throw new InvalidOperationException("Edit mode envelope id is missing.");
|
||||||
|
|
||||||
|
var docBase64 = _originalPdfBytes is { Length: > 0 }
|
||||||
|
? Convert.ToBase64String(_originalPdfBytes)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var receiverCommands = _receivers.Select(receiver =>
|
||||||
|
{
|
||||||
|
var fields = _signatureFields
|
||||||
|
.Where(f => f.ReceiverName == receiver.FullName)
|
||||||
|
.Select(f => new CreateEnvelopeFieldDto(
|
||||||
|
X: f.XPt / 72.0,
|
||||||
|
Y: f.YPt / 72.0,
|
||||||
|
Page: f.Page))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new CreateEnvelopeReceiverDto(
|
||||||
|
EmailAddress: receiver.Email,
|
||||||
|
Name: receiver.FullName,
|
||||||
|
PhoneNumber: string.IsNullOrWhiteSpace(receiver.PhoneNumber) ? null : receiver.PhoneNumber,
|
||||||
|
Fields: fields);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new CreateEnvelopeCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = envelopeId,
|
||||||
|
Title = _envelopeTitle,
|
||||||
|
Message = _envelopeMessage,
|
||||||
|
TFAEnabled = false,
|
||||||
|
Send = send,
|
||||||
|
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
|
||||||
|
Receivers = receiverCommands
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
int? GetEnvelopeIdFromCurrentUri()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
||||||
|
if (string.IsNullOrWhiteSpace(uri.Query))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
if (!query.TryGetValue("envelopeId", out var raw))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return int.TryParse(raw.ToString(), out var id) && id > 0
|
||||||
|
? id
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Cache persistence ──
|
// ── Cache persistence ──
|
||||||
void PersistSession()
|
void PersistSession()
|
||||||
{
|
{
|
||||||
@@ -1000,22 +1230,22 @@
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(fullName))
|
if (string.IsNullOrWhiteSpace(fullName))
|
||||||
{
|
{
|
||||||
_receiverPopupValidationMessage = "Bitte geben Sie einen Vor- und Nachnamen ein.";
|
_receiverPopupValidationMessage = L("SenderEditor_Validation_FullNameRequired", "Bitte geben Sie einen Vor- und Nachnamen ein.");
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
if (string.IsNullOrWhiteSpace(email))
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
{
|
{
|
||||||
_receiverPopupValidationMessage = "Bitte geben Sie eine E-Mail-Adresse ein.";
|
_receiverPopupValidationMessage = L("SenderEditor_Validation_EmailRequired", "Bitte geben Sie eine E-Mail-Adresse ein.");
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
if (!ReceiverEmailValidator.IsValid(email))
|
if (!ReceiverEmailValidator.IsValid(email))
|
||||||
{
|
{
|
||||||
_receiverPopupValidationMessage = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
|
_receiverPopupValidationMessage = L("SenderEditor_Validation_EmailInvalid", "Bitte geben Sie eine gültige E-Mail-Adresse ein.");
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
if (_receivers.Any(r => string.Equals(r.Email, email, StringComparison.OrdinalIgnoreCase)))
|
if (_receivers.Any(r => string.Equals(r.Email, email, StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
_receiverPopupValidationMessage = "Diese E-Mail-Adresse wurde bereits hinzugefügt.";
|
_receiverPopupValidationMessage = L("SenderEditor_Validation_EmailDuplicate", "Diese E-Mail-Adresse wurde bereits hinzugefügt.");
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1053,4 +1283,10 @@
|
|||||||
"#BE185D", // pink
|
"#BE185D", // pink
|
||||||
"#65A30D", // lime
|
"#65A30D", // lime
|
||||||
];
|
];
|
||||||
|
|
||||||
|
string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var value = UiLocalizer[key];
|
||||||
|
return value.ResourceNotFound ? fallback : value.Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
@using DevExpress.Blazor.PdfViewer
|
@using DevExpress.Blazor.PdfViewer
|
||||||
@using EnvelopeGenerator.Server.Client.Services
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
@using EnvelopeGenerator.Application.Common.Dto
|
@using EnvelopeGenerator.Application.Common.Dto
|
||||||
|
@using EnvelopeGenerator.Application.Common.Dto.History
|
||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
|
@using EnvelopeGenerator.Domain.Resources
|
||||||
@using EnvelopeGenerator.Server.Services
|
@using EnvelopeGenerator.Server.Services
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
|
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
|
||||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||||
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
|
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
|
||||||
@@ -19,6 +23,8 @@
|
|||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
|
@inject IStringLocalizer<Model> ModelLocalizer
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
|
|
||||||
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
||||||
<link href="@AppVersion.GetVersionedUrl("css/sender-page.css")" rel="stylesheet" />
|
<link href="@AppVersion.GetVersionedUrl("css/sender-page.css")" rel="stylesheet" />
|
||||||
@@ -66,29 +72,29 @@
|
|||||||
|
|
||||||
@* ── Toolbar (rechts) ─────────────────────────────────────────── *@
|
@* ── Toolbar (rechts) ─────────────────────────────────────────── *@
|
||||||
<div class="sender-toolbar">
|
<div class="sender-toolbar">
|
||||||
<button class="sender-btn sender-btn--primary" @onclick="CreateEnvelope" title="Neuen Umschlag erstellen">
|
<button class="sender-btn sender-btn--primary" @onclick="CreateEnvelope" title="@L("Sender_CreateEnvelope_Title", "Neuen Umschlag erstellen")">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||||
</svg>
|
</svg>
|
||||||
Neuer Umschlag
|
@L("Sender_CreateEnvelope", "Neuer Umschlag")
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || IsEnvelopeSent(_selectedEnvelope))" title="Ausgewählten Umschlag bearbeiten">
|
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || !IsEnvelopeEditable(_selectedEnvelope))" title="@L("Sender_EditEnvelope_Title", "Ausgewählten Umschlag bearbeiten")">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||||
</svg>
|
</svg>
|
||||||
Bearbeiten
|
@L("Sender_Edit", "Bearbeiten")
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="sender-btn sender-btn--danger" @onclick="DeleteEnvelope" disabled="@(_selectedEnvelope == null)" title="Ausgewählten Umschlag löschen">
|
<button class="sender-btn sender-btn--danger" @onclick="DeleteEnvelope" disabled="@(_selectedEnvelope == null)" title="@L("Sender_DeleteEnvelope_Title", "Ausgewählten Umschlag löschen")">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
|
||||||
</svg>
|
</svg>
|
||||||
Löschen
|
@L("Delete", "Löschen")
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="sender-btn" @onclick="RefreshEnvelopes" disabled="@_isLoading" title="Aktualisieren">
|
<button class="sender-btn" @onclick="RefreshEnvelopes" disabled="@_isLoading" title="@L("Refresh", "Aktualisieren")">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z" />
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z" />
|
||||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z" />
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z" />
|
||||||
@@ -99,16 +105,16 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="sender-btn" @onclick="ShowPreview" disabled="@(_selectedEnvelope == null)" title="Ausgewählten Umschlag als PDF anzeigen">
|
<button class="sender-btn" @onclick="ShowPreview" disabled="@(_selectedEnvelope == null)" title="@L("Sender_Preview_Title", "Ausgewählten Umschlag als PDF anzeigen")">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/>
|
<path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/>
|
||||||
<path d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8zm8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z"/>
|
<path d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8zm8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Vorschau
|
@L("Preview", "Vorschau")
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="sender-export-group" title="Export der aktuellen Tabellenansicht">
|
<div class="sender-export-group" title="@L("Sender_ExportView_Title", "Export der aktuellen Tabellenansicht")">
|
||||||
<select id="sender-export-format" class="sender-export-format" @bind="_selectedExportFormat" aria-label="Exportformat auswählen">
|
<select id="sender-export-format" class="sender-export-format" @bind="_selectedExportFormat" aria-label="@L("Sender_ExportFormat_Aria", "Exportformat auswählen")">
|
||||||
<option value="xlsx">XLSX</option>
|
<option value="xlsx">XLSX</option>
|
||||||
<option value="csv">CSV</option>
|
<option value="csv">CSV</option>
|
||||||
<option value="pdf">PDF</option>
|
<option value="pdf">PDF</option>
|
||||||
@@ -117,15 +123,15 @@
|
|||||||
<label class="sender-export-selected">
|
<label class="sender-export-selected">
|
||||||
<input type="checkbox" @bind="_exportSelectedRowsOnly" />
|
<input type="checkbox" @bind="_exportSelectedRowsOnly" />
|
||||||
<span class="sender-export-selected-text">
|
<span class="sender-export-selected-text">
|
||||||
<span>Nur</span>
|
<span>@L("Only", "Nur")</span>
|
||||||
<span>ausgewählte</span>
|
<span>@L("Selected", "ausgewählte")</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button class="sender-btn sender-btn--export"
|
<button class="sender-btn sender-btn--export"
|
||||||
@onclick="ExportSelectedFormatAsync"
|
@onclick="ExportSelectedFormatAsync"
|
||||||
disabled="@IsExportDisabled()"
|
disabled="@IsExportDisabled()"
|
||||||
title="Aktuelle Ansicht exportieren">
|
title="@L("Sender_ExportCurrentView_Title", "Aktuelle Ansicht exportieren")">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5V13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.6a.5.5 0 0 1 1 0V13a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.6a.5.5 0 0 1 .5-.5z" />
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5V13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.6a.5.5 0 0 1 1 0V13a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.6a.5.5 0 0 1 .5-.5z" />
|
||||||
<path d="M5.354 6.146a.5.5 0 0 1 .707 0L7.5 7.586V.5a.5.5 0 0 1 1 0v7.086l1.439-1.44a.5.5 0 0 1 .707.708l-2.293 2.293a.5.5 0 0 1-.707 0L5.354 6.854a.5.5 0 0 1 0-.708z" />
|
<path d="M5.354 6.146a.5.5 0 0 1 .707 0L7.5 7.586V.5a.5.5 0 0 1 1 0v7.086l1.439-1.44a.5.5 0 0 1 .707.708l-2.293 2.293a.5.5 0 0 1-.707 0L5.354 6.854a.5.5 0 0 1 0-.708z" />
|
||||||
@@ -136,12 +142,12 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span>Exportieren</span>
|
<span>@L("Export", "Exportieren")</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="sender-btn sender-btn--logout" @onclick="LogoutAsync" disabled="@_isLoggingOut" title="Abmelden">
|
<button class="sender-btn sender-btn--logout" @onclick="LogoutAsync" disabled="@_isLoggingOut" title="@L("Logout", "Abmelden")">
|
||||||
@if (_isLoggingOut)
|
@if (_isLoggingOut)
|
||||||
{
|
{
|
||||||
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
|
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
|
||||||
@@ -164,9 +170,9 @@
|
|||||||
<div class="d-flex justify-content-center align-items-center h-100">
|
<div class="d-flex justify-content-center align-items-center h-100">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="spinner-border text-white mb-3" style="width: 3.5rem; height: 3.5rem;" role="status">
|
<div class="spinner-border text-white mb-3" style="width: 3.5rem; height: 3.5rem;" role="status">
|
||||||
<span class="visually-hidden">Lädt...</span>
|
<span class="visually-hidden">@L("Loading", "Lädt...")</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-white fw-semibold">Umschläge werden geladen...</p>
|
<p class="text-white fw-semibold">@L("Sender_LoadingEnvelopes", "Umschläge werden geladen...")</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -180,7 +186,7 @@
|
|||||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<h5 class="mb-2">Fehler beim Laden der Umschläge</h5>
|
<h5 class="mb-2">@L("Sender_LoadError_Title", "Fehler beim Laden der Umschläge")</h5>
|
||||||
<p class="mb-0">@_errorMessage</p>
|
<p class="mb-0">@_errorMessage</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,14 +198,14 @@
|
|||||||
<div class="sender-grid-container">
|
<div class="sender-grid-container">
|
||||||
<div class="sender-tabs">
|
<div class="sender-tabs">
|
||||||
<button class="sender-tab @(_activeTab == "active" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "active"'>
|
<button class="sender-tab @(_activeTab == "active" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "active"'>
|
||||||
<span>Aktive Umschläge</span>
|
<span>@L("Sender_ActiveEnvelopes", "Aktive Umschläge")</span>
|
||||||
@if (_activeEnvelopes != null)
|
@if (_activeEnvelopes != null)
|
||||||
{
|
{
|
||||||
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_activeEnvelopes.Count())</span>
|
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_activeEnvelopes.Count())</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<button class="sender-tab @(_activeTab == "completed" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "completed"'>
|
<button class="sender-tab @(_activeTab == "completed" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "completed"'>
|
||||||
<span>Abgeschlossene Umschläge</span>
|
<span>@L("Sender_CompletedEnvelopes", "Abgeschlossene Umschläge")</span>
|
||||||
@if (_completedEnvelopes != null)
|
@if (_completedEnvelopes != null)
|
||||||
{
|
{
|
||||||
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_completedEnvelopes.Count())</span>
|
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_completedEnvelopes.Count())</span>
|
||||||
@@ -208,15 +214,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sender-grid-wrapper">
|
<div class="sender-grid-wrapper">
|
||||||
@if (_activeTab == "active")
|
<DxGrid @key="_activeTab"
|
||||||
{
|
Data="@CurrentGridData"
|
||||||
<DxGrid Data="@_activeEnvelopes"
|
@ref="_grid"
|
||||||
@ref="_gridActive"
|
|
||||||
KeyFieldName="Id"
|
KeyFieldName="Id"
|
||||||
ShowFilterRow="true"
|
ShowFilterRow="false"
|
||||||
|
FilterMenuButtonDisplayMode="GridFilterMenuButtonDisplayMode.Always"
|
||||||
ShowSearchBox="true"
|
ShowSearchBox="true"
|
||||||
AllowColumnReorder="true"
|
AllowColumnReorder="true"
|
||||||
AllowSort=true
|
AllowSort="true"
|
||||||
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
|
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
|
||||||
PageSize="20"
|
PageSize="20"
|
||||||
PagerVisible="true"
|
PagerVisible="true"
|
||||||
@@ -227,28 +233,36 @@
|
|||||||
RowClick="OnRowClick"
|
RowClick="OnRowClick"
|
||||||
RowDoubleClick="OnRowDoubleClick"
|
RowDoubleClick="OnRowDoubleClick"
|
||||||
CustomizeElement="OnCustomizeElement"
|
CustomizeElement="OnCustomizeElement"
|
||||||
LayoutAutoLoading="OnActiveGridLayoutAutoLoading"
|
LayoutAutoLoading="OnGridLayoutAutoLoading"
|
||||||
LayoutAutoSaving="OnActiveGridLayoutAutoSaving">
|
LayoutAutoSaving="OnGridLayoutAutoSaving">
|
||||||
<Columns>
|
<Columns>
|
||||||
<DxGridDataColumn FieldName="Id" Caption="ID"
|
<DxGridDataColumn FieldName="Id" Caption="ID"
|
||||||
|
DisplayFormat="0"
|
||||||
SortIndex="0"
|
SortIndex="0"
|
||||||
SortOrder="GridColumnSortOrder.Descending">
|
SortOrder="GridColumnSortOrder.Descending">
|
||||||
<CellDisplayTemplate Context="cellContext">
|
<CellDisplayTemplate Context="cellContext">
|
||||||
@((cellContext.DataItem as EnvelopeDto)?.Id)
|
@((cellContext.DataItem as EnvelopeDto)?.Id)
|
||||||
</CellDisplayTemplate>
|
</CellDisplayTemplate>
|
||||||
</DxGridDataColumn>
|
</DxGridDataColumn>
|
||||||
<DxGridDataColumn FieldName="Title" Caption="Titel">
|
<DxGridDataColumn FieldName="Title" Caption="@L("Title", "Titel")">
|
||||||
<CellDisplayTemplate Context="cellContext">
|
<CellDisplayTemplate Context="cellContext">
|
||||||
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
|
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
|
||||||
</CellDisplayTemplate>
|
</CellDisplayTemplate>
|
||||||
</DxGridDataColumn>
|
</DxGridDataColumn>
|
||||||
<DxGridDataColumn FieldName="Status" Caption="Status" ExportEnabled="false">
|
<DxGridDataColumn FieldName="Status" Caption="@L("Status", "Status")" ExportEnabled="false">
|
||||||
|
<EditSettings>
|
||||||
|
<DxComboBoxSettings Data="@GetStatusFilterItems(CurrentGridData)"
|
||||||
|
ValueFieldName="Value"
|
||||||
|
TextFieldName="Text"
|
||||||
|
SearchMode="ListSearchMode.AutoSearch"
|
||||||
|
SearchFilterCondition="ListSearchFilterCondition.Contains" />
|
||||||
|
</EditSettings>
|
||||||
<CellDisplayTemplate Context="cellContext">
|
<CellDisplayTemplate Context="cellContext">
|
||||||
@{
|
@{
|
||||||
var envelope = cellContext.DataItem as EnvelopeDto;
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||||
if (envelope != null)
|
if (envelope != null)
|
||||||
{
|
{
|
||||||
var statusInfo = GetStatusInfo(envelope.Status);
|
var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
|
||||||
<div class="status-badge status-badge--@statusInfo.CssClass">
|
<div class="status-badge status-badge--@statusInfo.CssClass">
|
||||||
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
|
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
|
||||||
@statusInfo.Label
|
@statusInfo.Label
|
||||||
@@ -257,18 +271,18 @@
|
|||||||
}
|
}
|
||||||
</CellDisplayTemplate>
|
</CellDisplayTemplate>
|
||||||
</DxGridDataColumn>
|
</DxGridDataColumn>
|
||||||
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger" ExportEnabled="false">
|
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="@L("Receiver", "Empfänger")" ExportEnabled="false">
|
||||||
<CellDisplayTemplate Context="cellContext">
|
<CellDisplayTemplate Context="cellContext">
|
||||||
@{
|
@{
|
||||||
var envelope = cellContext.DataItem as EnvelopeDto;
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||||
if (envelope != null)
|
if (envelope != null)
|
||||||
{
|
{
|
||||||
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
|
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
|
||||||
var signed = receivers.Count(r => r.Signed);
|
var signed = GetSignedReceiverCount(envelope);
|
||||||
var total = receivers.Count;
|
var total = receivers.Count;
|
||||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<span style="font-size: 0.875rem; color: #6b7280;">
|
<span style="font-size: 0.875rem; color: #6b7280;">
|
||||||
@signed / @total unterschrieben
|
@signed / @total @L("Signed", "unterschrieben")
|
||||||
</span>
|
</span>
|
||||||
@if (total > 0)
|
@if (total > 0)
|
||||||
{
|
{
|
||||||
@@ -281,16 +295,16 @@
|
|||||||
}
|
}
|
||||||
</CellDisplayTemplate>
|
</CellDisplayTemplate>
|
||||||
</DxGridDataColumn>
|
</DxGridDataColumn>
|
||||||
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="Typ"
|
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="@L("Type", "Typ")"
|
||||||
Width="120px" AllowSort="true" />
|
Width="120px" AllowSort="true" />
|
||||||
<DxGridDataColumn FieldName="AddedWhen" Caption="Erstellt"
|
<DxGridDataColumn FieldName="AddedWhen" Caption="@L("Created", "Erstellt")"
|
||||||
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
|
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
|
||||||
<DxGridDataColumn FieldName="ChangedWhen" Caption="Geändert am"
|
<DxGridDataColumn FieldName="ChangedWhen" Caption="@L("ChangedAt", "Geändert am")"
|
||||||
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
|
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
|
||||||
</Columns>
|
</Columns>
|
||||||
<DetailRowTemplate Context="detailContext">
|
<DetailRowTemplate Context="detailContext">
|
||||||
<div style="padding: 1rem; background: #f9fafb;">
|
<div style="padding: 1rem; background: #f9fafb;">
|
||||||
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">Empfänger</h6>
|
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">@L("Receiver", "Empfänger")</h6>
|
||||||
@{
|
@{
|
||||||
var envelope = detailContext.DataItem as EnvelopeDto;
|
var envelope = detailContext.DataItem as EnvelopeDto;
|
||||||
if (envelope?.EnvelopeReceivers?.Any() == true)
|
if (envelope?.EnvelopeReceivers?.Any() == true)
|
||||||
@@ -299,13 +313,26 @@
|
|||||||
@foreach (var receiver in envelope.EnvelopeReceivers)
|
@foreach (var receiver in envelope.EnvelopeReceivers)
|
||||||
{
|
{
|
||||||
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
|
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
|
||||||
<span class="receiver-badge receiver-badge--@(receiver.Signed ? "signed" : "unsigned")" style="min-width: 100px;">
|
@{
|
||||||
@if (receiver.Signed)
|
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--@(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">
|
<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" />
|
<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" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Unterschrieben</span>
|
<span>@L("Signed", "Unterschrieben")</span>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -313,134 +340,62 @@
|
|||||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Ausstehend</span>
|
<span>@L("Pending", "Ausstehend")</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<div style="flex: 1; font-size: 0.875rem;">
|
<div style="flex: 1; font-size: 0.875rem;">
|
||||||
<strong style="color: #1f2937;">@receiver.Name</strong>
|
<strong style="color: #1f2937;">@receiver.Name</strong>
|
||||||
<span style="color: #6b7280; margin-left: 0.5rem;">@receiver.Receiver?.EmailAddress</span>
|
<span style="color: #6b7280; margin-left: 0.5rem;">@receiver.Receiver?.EmailAddress</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sender-access-code">
|
||||||
|
<span class="sender-access-code__label">@L("AccessCode", "Access code")</span>
|
||||||
|
<div class="sender-access-code__row">
|
||||||
|
<input class="sender-access-code__value"
|
||||||
|
value="@(hasAccessCode ? accessCode : "-")"
|
||||||
|
readonly
|
||||||
|
title="@L("AccessCode", "Access code")" />
|
||||||
|
<button type="button"
|
||||||
|
class="sender-access-code__copy"
|
||||||
|
disabled="@(!hasAccessCode)"
|
||||||
|
@onclick='() => CopyAccessCodeAsync(accessCode)'
|
||||||
|
title="@L("Copy", "Copy")">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M10 1.5A1.5 1.5 0 0 1 11.5 3v1H13A1.5 1.5 0 0 1 14.5 5.5v8A1.5 1.5 0 0 1 13 15H5A1.5 1.5 0 0 1 3.5 13.5V12H3A1.5 1.5 0 0 1 1.5 10.5v-8A1.5 1.5 0 0 1 3 1h7zm.5 2A.5.5 0 0 0 10 3H3a.5.5 0 0 0-.5.5v7A.5.5 0 0 0 3 11h.5V5.5A1.5 1.5 0 0 1 5 4h5.5v-.5zM5 5a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8A.5.5 0 0 0 13 5H5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
|
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">@L("Sender_NoReceivers", "Keine Empfänger")</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
</DetailRowTemplate>
|
<h6 style="font-weight: 600; color: #374151; margin: 1rem 0 0.75rem;">@L("History", "Historie")</h6>
|
||||||
</DxGrid>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<DxGrid Data="@_completedEnvelopes"
|
|
||||||
@ref="_gridCompleted"
|
|
||||||
KeyFieldName="Id"
|
|
||||||
ShowFilterRow="true"
|
|
||||||
ShowSearchBox="true"
|
|
||||||
PageSize="20"
|
|
||||||
PagerVisible="true"
|
|
||||||
SelectionMode="GridSelectionMode.Single"
|
|
||||||
AllowSelectRowByClick="true"
|
|
||||||
SelectedDataItem="@_selectedEnvelope"
|
|
||||||
SelectedDataItemChanged="@((object o) => _selectedEnvelope = o as EnvelopeDto)"
|
|
||||||
RowClick="OnRowClick"
|
|
||||||
RowDoubleClick="OnRowDoubleClick"
|
|
||||||
CustomizeElement="OnCustomizeElement"
|
|
||||||
LayoutAutoLoading="OnCompletedGridLayoutAutoLoading"
|
|
||||||
LayoutAutoSaving="OnCompletedGridLayoutAutoSaving">
|
|
||||||
<Columns>
|
|
||||||
<DxGridDataColumn FieldName="Id" Caption="ID"
|
|
||||||
SortIndex="0"
|
|
||||||
SortOrder="GridColumnSortOrder.Descending">
|
|
||||||
<CellDisplayTemplate Context="cellContext">
|
|
||||||
@((cellContext.DataItem as EnvelopeDto)?.Id)
|
|
||||||
</CellDisplayTemplate>
|
|
||||||
</DxGridDataColumn>
|
|
||||||
<DxGridDataColumn FieldName="Title" Caption="Titel">
|
|
||||||
<CellDisplayTemplate Context="cellContext">
|
|
||||||
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
|
|
||||||
</CellDisplayTemplate>
|
|
||||||
</DxGridDataColumn>
|
|
||||||
<DxGridDataColumn FieldName="Status" Caption="Status" ExportEnabled="false">
|
|
||||||
<CellDisplayTemplate Context="cellContext">
|
|
||||||
@{
|
|
||||||
var envelope = cellContext.DataItem as EnvelopeDto;
|
|
||||||
if (envelope != null)
|
|
||||||
{
|
|
||||||
var statusInfo = GetStatusInfo(envelope.Status);
|
|
||||||
<div class="status-badge status-badge--@statusInfo.CssClass">
|
|
||||||
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
|
|
||||||
@statusInfo.Label
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</CellDisplayTemplate>
|
|
||||||
</DxGridDataColumn>
|
|
||||||
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger" ExportEnabled="false">
|
|
||||||
<CellDisplayTemplate Context="cellContext">
|
|
||||||
@{
|
|
||||||
var envelope = cellContext.DataItem as EnvelopeDto;
|
|
||||||
if (envelope != null)
|
|
||||||
{
|
|
||||||
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
|
|
||||||
var signed = receivers.Count(r => r.Signed);
|
|
||||||
var total = receivers.Count;
|
|
||||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<span style="font-size: 0.875rem; color: #6b7280;">
|
|
||||||
@signed / @total unterschrieben
|
|
||||||
</span>
|
|
||||||
@if (total > 0)
|
|
||||||
{
|
|
||||||
<div style="flex: 1; min-width: 60px; max-width: 120px; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden;">
|
|
||||||
<div style="height: 100%; background: linear-gradient(90deg, #81c784 0%, #66bb6a 100%); width: @((signed * 100.0 / total).ToString("F0"))%;"></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</CellDisplayTemplate>
|
|
||||||
</DxGridDataColumn>
|
|
||||||
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="Typ"
|
|
||||||
Width="120px" AllowSort="true" />
|
|
||||||
<DxGridDataColumn FieldName="AddedWhen" Caption="Erstellt"
|
|
||||||
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
|
|
||||||
<DxGridDataColumn FieldName="ChangedWhen" Caption="Geändert am"
|
|
||||||
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
|
|
||||||
</Columns>
|
|
||||||
<DetailRowTemplate Context="detailContext">
|
|
||||||
<div style="padding: 1rem; background: #f9fafb;">
|
|
||||||
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">Empfänger</h6>
|
|
||||||
@{
|
@{
|
||||||
var envelope = detailContext.DataItem as EnvelopeDto;
|
var historyEntries = envelope is null
|
||||||
if (envelope?.EnvelopeReceivers?.Any() == true)
|
? Enumerable.Empty<HistoryDto>()
|
||||||
|
: GetOrderedHistories(envelope);
|
||||||
|
if (historyEntries.Any())
|
||||||
{
|
{
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
<div class="sender-history-list">
|
||||||
@foreach (var receiver in envelope.EnvelopeReceivers)
|
@foreach (var history in historyEntries)
|
||||||
{
|
{
|
||||||
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
|
var statusInfo = GetStatusInfo(history.Status);
|
||||||
<span class="receiver-badge receiver-badge--@(receiver.Signed ? "signed" : "unsigned")" style="min-width: 100px;">
|
<div class="sender-history-item">
|
||||||
@if (receiver.Signed)
|
<div class="sender-history-item__left">
|
||||||
{
|
<div class="status-badge status-badge--@statusInfo.CssClass">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
|
||||||
<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" />
|
@statusInfo.Label
|
||||||
</svg>
|
</div>
|
||||||
<span>Unterschrieben</span>
|
<span class="sender-history-item__actor">@GetHistoryActor(history)</span>
|
||||||
}
|
</div>
|
||||||
else
|
<div class="sender-history-item__right">
|
||||||
{
|
@GetHistoryDate(history).ToString("dd.MM.yyyy HH:mm")
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
|
||||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
|
||||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
|
|
||||||
</svg>
|
|
||||||
<span>Ausstehend</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<div style="flex: 1; font-size: 0.875rem;">
|
|
||||||
<strong style="color: #1f2937;">@receiver.Name</strong>
|
|
||||||
<span style="color: #6b7280; margin-left: 0.5rem;">@receiver.Receiver?.EmailAddress</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -448,22 +403,22 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
|
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">@L("Sender_NoHistory", "Keine Historie vorhanden")</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</DetailRowTemplate>
|
</DetailRowTemplate>
|
||||||
</DxGrid>
|
</DxGrid>
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
|
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
|
||||||
<DxPopup @bind-Visible="@_previewVisible"
|
<DxPopup @bind-Visible="@_previewVisible"
|
||||||
HeaderText="@($"Vorschau — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
|
HeaderText="@($"{L("Preview", "Vorschau")} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
|
||||||
Width="80vw"
|
Width="80vw"
|
||||||
MaxWidth="80vw"
|
MaxWidth="80vw"
|
||||||
Height="90vh"
|
Height="90vh"
|
||||||
@@ -486,9 +441,9 @@
|
|||||||
<div class="sender-preview-state-wrap">
|
<div class="sender-preview-state-wrap">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="spinner-border mb-3" style="width:3rem;height:3rem;" role="status">
|
<div class="spinner-border mb-3" style="width:3rem;height:3rem;" role="status">
|
||||||
<span class="visually-hidden">Lädt...</span>
|
<span class="visually-hidden">@L("Loading", "Lädt...")</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:#6b7280;font-size:0.875rem;">Dokument wird geladen…</p>
|
<p style="color:#6b7280;font-size:0.875rem;">@L("DocumentLoading", "Dokument wird geladen…")</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -503,7 +458,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="sender-preview-error-title">Dokument konnte nicht geladen werden</p>
|
<p class="sender-preview-error-title">@L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden")</p>
|
||||||
<p class="sender-preview-error-msg">@_previewErrorMessage</p>
|
<p class="sender-preview-error-msg">@_previewErrorMessage</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -521,6 +476,54 @@
|
|||||||
</BodyContentTemplate>
|
</BodyContentTemplate>
|
||||||
</DxPopup>
|
</DxPopup>
|
||||||
|
|
||||||
|
<DxPopup @bind-Visible="@_deletePopupVisible"
|
||||||
|
HeaderText="@L("Sender_DeleteEnvelope_Header", "Umschlag löschen")"
|
||||||
|
Width="560px"
|
||||||
|
MaxWidth="95vw"
|
||||||
|
ShowFooter="true"
|
||||||
|
CloseOnOutsideClick="false"
|
||||||
|
ShowCloseButton="true"
|
||||||
|
CloseOnEscape="true"
|
||||||
|
ShowHeader="true"
|
||||||
|
CssClass="sender-delete-popup">
|
||||||
|
<BodyContentTemplate>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:0.75rem;">
|
||||||
|
<p style="margin:0;color:#374151;font-size:0.9rem;line-height:1.45;">
|
||||||
|
@L("Sender_DeleteEnvelope_ReasonPrompt", "Bitte geben Sie einen Grund an, warum der Umschlag gelöscht/zurückgezogen werden soll.")
|
||||||
|
</p>
|
||||||
|
@if (_selectedEnvelope is not null)
|
||||||
|
{
|
||||||
|
<p style="margin:0;color:#6b7280;font-size:0.825rem;">
|
||||||
|
@L("Envelope", "Umschlag"): <strong>#@_selectedEnvelope.Id - @_selectedEnvelope.Title</strong>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<DxMemo @bind-Text="_deleteReason"
|
||||||
|
NullText="@L("Sender_DeleteEnvelope_ReasonPlaceholder", "Begründung eingeben...")"
|
||||||
|
Rows="5"
|
||||||
|
MaxLength="2000"
|
||||||
|
CssClass="w-100" />
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_deleteErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger" style="margin:0;padding:0.5rem 0.75rem;font-size:0.85rem;">
|
||||||
|
@_deleteErrorMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</BodyContentTemplate>
|
||||||
|
<FooterContentTemplate>
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:0.5rem;width:100%;">
|
||||||
|
<DxButton Text="@L("Cancel", "Abbrechen")"
|
||||||
|
RenderStyle="ButtonRenderStyle.Secondary"
|
||||||
|
Click="CloseDeletePopup"
|
||||||
|
Enabled="@(!_isDeletingEnvelope)" />
|
||||||
|
<DxButton Text="@L("Delete", "Löschen")"
|
||||||
|
RenderStyle="ButtonRenderStyle.Danger"
|
||||||
|
Click="ConfirmDeleteEnvelopeAsync"
|
||||||
|
Enabled="@(!_isDeletingEnvelope)" />
|
||||||
|
</div>
|
||||||
|
</FooterContentTemplate>
|
||||||
|
</DxPopup>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private IEnumerable<EnvelopeDto>? _allEnvelopes;
|
private IEnumerable<EnvelopeDto>? _allEnvelopes;
|
||||||
private IEnumerable<EnvelopeDto>? _activeEnvelopes;
|
private IEnumerable<EnvelopeDto>? _activeEnvelopes;
|
||||||
@@ -530,8 +533,7 @@
|
|||||||
private bool _isLoading = true;
|
private bool _isLoading = true;
|
||||||
private bool _isLoggingOut = false;
|
private bool _isLoggingOut = false;
|
||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
private DxGrid? _gridActive;
|
private DxGrid? _grid;
|
||||||
private DxGrid? _gridCompleted;
|
|
||||||
|
|
||||||
// Logo config loaded from /api/CustomImages after auth
|
// Logo config loaded from /api/CustomImages after auth
|
||||||
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _appLogo;
|
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _appLogo;
|
||||||
@@ -549,10 +551,20 @@
|
|||||||
private bool _exportSelectedRowsOnly = false;
|
private bool _exportSelectedRowsOnly = false;
|
||||||
private bool _isExporting = false;
|
private bool _isExporting = false;
|
||||||
|
|
||||||
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
|
private bool _deletePopupVisible = false;
|
||||||
|
private bool _isDeletingEnvelope = false;
|
||||||
|
private string _deleteReason = string.Empty;
|
||||||
|
private string? _deleteErrorMessage;
|
||||||
|
|
||||||
|
// Stable keys used to store/restore each tab's layout in UserPreferences.GridLayouts
|
||||||
private const string GridKeyActive = "sender.active-envelopes";
|
private const string GridKeyActive = "sender.active-envelopes";
|
||||||
private const string GridKeyCompleted = "sender.completed-envelopes";
|
private const string GridKeyCompleted = "sender.completed-envelopes";
|
||||||
|
|
||||||
|
private IEnumerable<EnvelopeDto> CurrentGridData =>
|
||||||
|
(_activeTab == "completed" ? _completedEnvelopes : _activeEnvelopes) ?? Enumerable.Empty<EnvelopeDto>();
|
||||||
|
|
||||||
|
private string CurrentGridKey => _activeTab == "completed" ? GridKeyCompleted : GridKeyActive;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var hasAccess = await AuthService.CheckSenderAccessAsync();
|
var hasAccess = await AuthService.CheckSenderAccessAsync();
|
||||||
@@ -586,10 +598,11 @@
|
|||||||
{
|
{
|
||||||
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
|
_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();
|
var envelopes = _allEnvelopes.ToList();
|
||||||
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
|
_activeEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsActive()).ToList();
|
||||||
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
|
_completedEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsCompleted()).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -617,7 +630,7 @@
|
|||||||
|
|
||||||
async Task ExportSelectedFormatAsync()
|
async Task ExportSelectedFormatAsync()
|
||||||
{
|
{
|
||||||
var grid = _activeTab == "active" ? _gridActive : _gridCompleted;
|
var grid = _grid;
|
||||||
if (grid is null)
|
if (grid is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -675,13 +688,59 @@
|
|||||||
void EditEnvelope()
|
void EditEnvelope()
|
||||||
{
|
{
|
||||||
if (_selectedEnvelope == null) return;
|
if (_selectedEnvelope == null) return;
|
||||||
// TODO: Navigate to envelope editor
|
if (!IsEnvelopeEditable(_selectedEnvelope)) return;
|
||||||
|
|
||||||
|
Navigation.NavigateTo($"/sender/editor?envelopeId={_selectedEnvelope.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
void DeleteEnvelope()
|
void DeleteEnvelope()
|
||||||
{
|
{
|
||||||
if (_selectedEnvelope == null) return;
|
if (_selectedEnvelope == null) return;
|
||||||
// TODO: Show delete confirmation dialog
|
_deleteReason = string.Empty;
|
||||||
|
_deleteErrorMessage = null;
|
||||||
|
_deletePopupVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CloseDeletePopup()
|
||||||
|
{
|
||||||
|
if (_isDeletingEnvelope)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_deletePopupVisible = false;
|
||||||
|
_deleteErrorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ConfirmDeleteEnvelopeAsync()
|
||||||
|
{
|
||||||
|
if (_selectedEnvelope is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_deleteReason))
|
||||||
|
{
|
||||||
|
_deleteErrorMessage = L("Sender_DeleteEnvelope_ReasonRequired", "Bitte geben Sie einen Löschgrund an.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDeletingEnvelope = true;
|
||||||
|
_deleteErrorMessage = null;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EnvelopeService.DeleteAsync(_selectedEnvelope.Id, _deleteReason.Trim());
|
||||||
|
_deletePopupVisible = false;
|
||||||
|
_selectedEnvelope = null;
|
||||||
|
await LoadEnvelopesAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_deleteErrorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isDeletingEnvelope = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task LogoutAsync()
|
async Task LogoutAsync()
|
||||||
@@ -695,27 +754,173 @@
|
|||||||
|
|
||||||
bool IsEnvelopeSent(EnvelopeDto envelope)
|
bool IsEnvelopeSent(EnvelopeDto envelope)
|
||||||
{
|
{
|
||||||
var status = (EnvelopeStatus)envelope.Status;
|
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||||
return status >= EnvelopeStatus.EnvelopeQueued;
|
return status >= EnvelopeStatus.EnvelopeQueued;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool IsEnvelopeEditable(EnvelopeDto envelope)
|
||||||
|
{
|
||||||
|
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||||
|
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
|
||||||
|
}
|
||||||
|
|
||||||
(string Label, string CssClass, string DotColor) GetStatusInfo(EnvelopeStatus status)
|
(string Label, string CssClass, string DotColor) GetStatusInfo(EnvelopeStatus status)
|
||||||
{
|
{
|
||||||
|
var label = GetStatusLabel(status);
|
||||||
|
|
||||||
return status switch
|
return status switch
|
||||||
{
|
{
|
||||||
EnvelopeStatus.EnvelopePartlySigned => ("Teilweise unterschrieben", "partly-signed", "green"),
|
EnvelopeStatus.EnvelopePartlySigned => (label, "partly-signed", "green"),
|
||||||
EnvelopeStatus.EnvelopeQueued => ("In Warteschlange", "queued", "orange"),
|
EnvelopeStatus.EnvelopeCompletelySigned => (label, "completed", "green"),
|
||||||
EnvelopeStatus.EnvelopeSent => ("Gesendet", "sent", "orange"),
|
EnvelopeStatus.DocumentSigned => (label, "partly-signed", "green"),
|
||||||
EnvelopeStatus.EnvelopeCompletelySigned => ("Vollständig unterschrieben", "completed", "green"),
|
EnvelopeStatus.MessageCompletionSent => (label, "completed", "green"),
|
||||||
EnvelopeStatus.EnvelopeDeleted => ("Gelöscht", "deleted", "red"),
|
|
||||||
EnvelopeStatus.EnvelopeRejected => ("Abgelehnt", "rejected", "red"),
|
EnvelopeStatus.EnvelopeQueued => (label, "queued", "orange"),
|
||||||
EnvelopeStatus.EnvelopeWithdrawn => ("Zurückgezogen", "withdrawn", "red"),
|
EnvelopeStatus.EnvelopeSent => (label, "sent", "orange"),
|
||||||
EnvelopeStatus.EnvelopeCreated => ("Erstellt", "created", "blue"),
|
EnvelopeStatus.MessageInvitationSent => (label, "queued", "orange"),
|
||||||
EnvelopeStatus.EnvelopeSaved => ("Gespeichert", "saved", "blue"),
|
EnvelopeStatus.MessageAccessCodeSent => (label, "queued", "orange"),
|
||||||
_ => ("Unbekannt", "unknown", "blue")
|
EnvelopeStatus.MessageConfirmationSent => (label, "queued", "orange"),
|
||||||
|
|
||||||
|
EnvelopeStatus.EnvelopeDeleted => (label, "deleted", "red"),
|
||||||
|
EnvelopeStatus.EnvelopeRejected => (label, "rejected", "red"),
|
||||||
|
EnvelopeStatus.EnvelopeWithdrawn => (label, "withdrawn", "red"),
|
||||||
|
EnvelopeStatus.DocumentRejected => (label, "rejected", "red"),
|
||||||
|
EnvelopeStatus.AccessCodeIncorrect => (label, "rejected", "red"),
|
||||||
|
EnvelopeStatus.MessageDeletionSent => (label, "deleted", "red"),
|
||||||
|
|
||||||
|
_ => (label, "created", "blue")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string GetStatusLabel(EnvelopeStatus status)
|
||||||
|
{
|
||||||
|
var key = status.ToString();
|
||||||
|
var localized = ModelLocalizer[key];
|
||||||
|
return localized.ResourceNotFound ? key : localized.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<HistoryDto> GetOrderedHistories(EnvelopeDto envelope)
|
||||||
|
{
|
||||||
|
return (envelope.Histories ?? Enumerable.Empty<HistoryDto>())
|
||||||
|
.OrderByDescending(GetHistoryDate)
|
||||||
|
.Take(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<StatusFilterItem> GetStatusFilterItems(IEnumerable<EnvelopeDto>? source)
|
||||||
|
{
|
||||||
|
return (source ?? Enumerable.Empty<EnvelopeDto>())
|
||||||
|
.Select(GetEffectiveEnvelopeStatus)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(x => x)
|
||||||
|
.Select(x => new StatusFilterItem(x, GetStatusLabel(x)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
int GetSignedReceiverCount(EnvelopeDto envelope)
|
||||||
|
{
|
||||||
|
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
|
||||||
|
if (receivers.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return receivers.Count(r => IsReceiverSigned(envelope, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
|
||||||
|
{
|
||||||
|
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||||
|
if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var histories = envelope.Histories?.Where(h => h.Status == EnvelopeStatus.DocumentSigned).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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
string GetHistoryActor(HistoryDto history)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(history.UserReference))
|
||||||
|
return history.UserReference;
|
||||||
|
|
||||||
|
return L("System", "System");
|
||||||
|
}
|
||||||
|
|
||||||
|
string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var value = UiLocalizer[key];
|
||||||
|
return value.ResourceNotFound ? fallback : value.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task CopyAccessCodeAsync(string? accessCode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(accessCode))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", accessCode);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("console.warn", "[Sender] Failed to copy access code to clipboard.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void OnCustomizeElement(GridCustomizeElementEventArgs e)
|
void OnCustomizeElement(GridCustomizeElementEventArgs e)
|
||||||
{
|
{
|
||||||
// Future: Add custom row coloring based on status if needed
|
// Future: Add custom row coloring based on status if needed
|
||||||
@@ -767,7 +972,7 @@
|
|||||||
var user = EnvelopeAuthService.GetCurrentUser();
|
var user = EnvelopeAuthService.GetCurrentUser();
|
||||||
if (user?.Identity is null || !user.Identity.IsAuthenticated)
|
if (user?.Identity is null || !user.Identity.IsAuthenticated)
|
||||||
{
|
{
|
||||||
_previewErrorMessage = "Nicht authentifiziert. Bitte melden Sie sich erneut an.";
|
_previewErrorMessage = L("NotAuthenticatedPleaseLoginAgain", "Nicht authentifiziert. Bitte melden Sie sich erneut an.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,12 +983,16 @@
|
|||||||
}
|
}
|
||||||
catch (DigitalData.Core.Exceptions.NotFoundException)
|
catch (DigitalData.Core.Exceptions.NotFoundException)
|
||||||
{
|
{
|
||||||
_previewErrorMessage = $"Das Dokument für Umschlag #{envelope.Id} wurde nicht gefunden oder Sie haben keinen Zugriff darauf.";
|
_previewErrorMessage = string.Format(
|
||||||
|
L("Sender_PreviewDocumentNotFound", "Das Dokument für Umschlag #{0} wurde nicht gefunden oder Sie haben keinen Zugriff darauf."),
|
||||||
|
envelope.Id);
|
||||||
Logger.LogWarning("Preview: document not found for envelope {EnvelopeId}", envelope.Id);
|
Logger.LogWarning("Preview: document not found for envelope {EnvelopeId}", envelope.Id);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_previewErrorMessage = $"Fehler beim Laden des Dokuments: {ex.Message}";
|
_previewErrorMessage = string.Format(
|
||||||
|
L("ErrorLoadingDocument_WithReason", "Fehler beim Laden des Dokuments: {0}"),
|
||||||
|
ex.Message);
|
||||||
Logger.LogError(ex, "Preview: failed to load document for envelope {EnvelopeId}", envelope.Id);
|
Logger.LogError(ex, "Preview: failed to load document for envelope {EnvelopeId}", envelope.Id);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -814,21 +1023,26 @@
|
|||||||
// UserPreferencesService.GetAsync() uses the 1h in-memory cache → 0 HTTP on restore.
|
// UserPreferencesService.GetAsync() uses the 1h in-memory cache → 0 HTTP on restore.
|
||||||
// UserPreferencesService.SaveGridLayoutAsync() merges the single key → 1 PUT.
|
// UserPreferencesService.SaveGridLayoutAsync() merges the single key → 1 PUT.
|
||||||
|
|
||||||
async Task OnActiveGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
|
async Task OnGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
|
||||||
{
|
{
|
||||||
// Use cached preferences only — avoid HTTP during grid initialization.
|
// Use cached preferences only — avoid HTTP during grid initialization.
|
||||||
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
|
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
|
||||||
var prefs = UserPreferencesService.GetCached();
|
var prefs = UserPreferencesService.GetCached();
|
||||||
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyActive, out var json))
|
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 OnActiveGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
|
async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
SanitizeLayoutFilters(e.Layout);
|
||||||
var json = JsonSerializer.Serialize(e.Layout);
|
var json = JsonSerializer.Serialize(e.Layout);
|
||||||
await UserPreferencesService.SaveGridLayoutAsync(GridKeyActive, json);
|
await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -836,16 +1050,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task OnCompletedGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
|
// 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)
|
||||||
{
|
{
|
||||||
var prefs = UserPreferencesService.GetCached();
|
if (layout is null)
|
||||||
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyCompleted, out var json))
|
return;
|
||||||
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task OnCompletedGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
|
static void ClearWritableProperty(object target, string propertyName)
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(e.Layout);
|
var prop = target.GetType()
|
||||||
await UserPreferencesService.SaveGridLayoutAsync(GridKeyCompleted, json);
|
.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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
@page "/Error"
|
@page "/Error"
|
||||||
@using System.Diagnostics
|
@using System.Diagnostics
|
||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
|
|
||||||
<PageTitle>Error</PageTitle>
|
<PageTitle>@L("Error", "Error")</PageTitle>
|
||||||
|
|
||||||
<h1 class="text-danger">Error.</h1>
|
<h1 class="text-danger">@L("Error", "Error").</h1>
|
||||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
<h2 class="text-danger">@L("ErrorPage_ProcessingRequest", "An error occurred while processing your request.")</h2>
|
||||||
|
|
||||||
@if (ShowRequestId)
|
@if (ShowRequestId)
|
||||||
{
|
{
|
||||||
<p>
|
<p>
|
||||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
<strong>@L("ErrorPage_RequestId", "Request ID"):</strong> <code>@RequestId</code>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<h3>Development Mode</h3>
|
<h3>@L("ErrorPage_DevelopmentMode", "Development Mode")</h3>
|
||||||
<p>
|
<p>
|
||||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
@L("ErrorPage_DevelopmentMode_Info", "Swapping to Development environment will display more detailed information about the error that occurred.")
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
<strong>@L("ErrorPage_DevelopmentMode_Warning", "The Development environment shouldn't be enabled for deployed applications.")</strong>
|
||||||
It can result in displaying sensitive information from exceptions to end users.
|
@L("ErrorPage_DevelopmentMode_Detail", "It can result in displaying sensitive information from exceptions to end users. For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development and restarting the app.")
|
||||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
|
||||||
and restarting the app.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@code{
|
@code{
|
||||||
@@ -33,4 +34,10 @@
|
|||||||
|
|
||||||
protected override void OnInitialized() =>
|
protected override void OnInitialized() =>
|
||||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||||
|
|
||||||
|
string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var value = UiLocalizer[key];
|
||||||
|
return value.ResourceNotFound ? fallback : value.Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,28 @@
|
|||||||
@using EnvelopeGenerator.Server.Client.Models
|
@using EnvelopeGenerator.Server.Client.Models
|
||||||
@using EnvelopeGenerator.Server.Client.Models.Constants
|
@using EnvelopeGenerator.Server.Client.Models.Constants
|
||||||
@using EnvelopeGenerator.Server.Client.Services
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@using EnvelopeGenerator.Application.Common.Extensions
|
||||||
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
||||||
|
@using EnvelopeGenerator.Application.EnvelopeReceivers.Queries
|
||||||
|
@using EnvelopeGenerator.Domain.Constants
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using DevExpress.Blazor
|
@using DevExpress.Blazor
|
||||||
@using System.Drawing
|
@using System.Drawing
|
||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
@using Microsoft.Extensions.Caching.Memory
|
@using Microsoft.Extensions.Caching.Memory
|
||||||
|
@using MediatR
|
||||||
|
@using EnvelopeGenerator.Server.Extensions
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||||
|
@inject AnnotationService AnnotationService
|
||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
@inject IMemoryCache MemoryCache
|
@inject IMemoryCache MemoryCache
|
||||||
@inject ILogger<ReceiverPage> Logger
|
@inject ILogger<ReceiverPage> Logger
|
||||||
|
@inject IMediator Mediator
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
@@ -386,6 +394,80 @@
|
|||||||
string _signerFullName = string.Empty;
|
string _signerFullName = string.Empty;
|
||||||
string _signerPosition = string.Empty;
|
string _signerPosition = string.Empty;
|
||||||
string _signaturePlace = string.Empty;
|
string _signaturePlace = string.Empty;
|
||||||
|
bool _isLoggingOut = false;
|
||||||
|
|
||||||
|
// ----- Share popup state -----
|
||||||
|
bool _sharePopupVisible = false;
|
||||||
|
bool _isSharing = false;
|
||||||
|
string _shareReceiverMail = string.Empty;
|
||||||
|
DateTime _shareDateValid = DateTime.Today.AddDays(7);
|
||||||
|
string? _shareMessage;
|
||||||
|
bool _shareMessageIsError = false;
|
||||||
|
|
||||||
|
async Task LogoutAsync()
|
||||||
|
{
|
||||||
|
if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isLoggingOut = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Receiver logout failed for {EnvelopeKey}", EnvelopeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpenSharePopup()
|
||||||
|
{
|
||||||
|
_shareReceiverMail = string.Empty;
|
||||||
|
_shareDateValid = DateTime.Today.AddDays(7);
|
||||||
|
_shareMessage = null;
|
||||||
|
_shareMessageIsError = false;
|
||||||
|
_sharePopupVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CloseSharePopup()
|
||||||
|
{
|
||||||
|
if (_isSharing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_sharePopupVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnShareDateChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParse(args.Value?.ToString(), out var parsedDate))
|
||||||
|
_shareDateValid = parsedDate.Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ShareEnvelopeAsync()
|
||||||
|
{
|
||||||
|
if (_isSharing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var email = _shareReceiverMail.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(email) || !new EmailAddressAttribute().IsValid(email))
|
||||||
|
{
|
||||||
|
_shareMessage = "Bitte geben Sie eine gultige E-Mail-Adresse ein.";
|
||||||
|
_shareMessageIsError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var minDate = DateTime.Today.AddDays(1);
|
||||||
|
var maxDate = DateTime.Today.AddDays(90);
|
||||||
|
if (_shareDateValid.Date < minDate || _shareDateValid.Date > maxDate)
|
||||||
|
{
|
||||||
|
_shareMessage = "Bitte wahlen Sie ein Datum zwischen morgen und 90 Tagen.";
|
||||||
|
_shareMessageIsError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Lifecycle -----
|
// ----- Lifecycle -----
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
@@ -397,6 +479,12 @@
|
|||||||
return;
|
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
|
// Authorization — same pattern as EnvelopeReceiverPage
|
||||||
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
|
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
|
||||||
if (_receiverUser is null)
|
if (_receiverUser is null)
|
||||||
@@ -405,6 +493,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await Mediator.IsSignedAsync(_receiverUser.EnvelopeUuid(), _receiverUser.ReceiverSignature()))
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Load PDF bytes via MediatR (uses authenticated user's claims)
|
// Load PDF bytes via MediatR (uses authenticated user's claims)
|
||||||
@@ -423,6 +517,11 @@
|
|||||||
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
||||||
if (_envelopeReceiver is null)
|
if (_envelopeReceiver is null)
|
||||||
Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey);
|
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)
|
// Build initial report (no signature image yet)
|
||||||
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
|
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
|
||||||
@@ -468,6 +567,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----- Report builder -----
|
// ----- 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>
|
/// <summary>
|
||||||
/// Builds an XtraReport wrapping the PDF bytes.
|
/// Builds an XtraReport wrapping the PDF bytes.
|
||||||
/// If a signature is captured and there are signature fields, the signature image is
|
/// If a signature is captured and there are signature fields, the signature image is
|
||||||
@@ -701,6 +810,56 @@
|
|||||||
forceLoad: true);
|
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()
|
async Task<string?> GetActiveSignatureDataUrlAsync()
|
||||||
{
|
{
|
||||||
if (_activeSignatureTab == SignatureTabDraw)
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
@using EnvelopeGenerator.Server.Client.Models
|
@using EnvelopeGenerator.Server.Client.Models
|
||||||
@using EnvelopeGenerator.Server.Client.Services
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
||||||
|
@using EnvelopeGenerator.Application.Resources
|
||||||
@using Microsoft.Extensions.Caching.Memory
|
@using Microsoft.Extensions.Caching.Memory
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@@ -16,6 +18,7 @@
|
|||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
@inject IMemoryCache MemoryCache
|
@inject IMemoryCache MemoryCache
|
||||||
@inject ILogger<ReceiverSignedPage> Logger
|
@inject ILogger<ReceiverSignedPage> Logger
|
||||||
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
@@ -30,18 +33,18 @@
|
|||||||
@if (_envelopeReceiver is not null)
|
@if (_envelopeReceiver is not null)
|
||||||
{
|
{
|
||||||
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||||
@(_envelopeReceiver.Envelope?.Title ?? "Dokument")
|
@(_envelopeReceiver.Envelope?.Title ?? L("Document", "Dokument"))
|
||||||
</div>
|
</div>
|
||||||
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName))
|
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName))
|
||||||
{
|
{
|
||||||
<span style="font-size: 0.7rem; color: #6b7280; white-space: nowrap;">
|
<span style="font-size: 0.7rem; color: #6b7280; white-space: nowrap;">
|
||||||
Von <span style="font-weight: 500; color: #374151;">@_envelopeReceiver.Envelope.User.FullName</span>
|
@L("From", "Von") <span style="font-weight: 500; color: #374151;">@_envelopeReceiver.Envelope.User.FullName</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937;">Signiertes Dokument</div>
|
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937;">@L("SignedDocument", "Signiertes Dokument")</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,12 +53,12 @@
|
|||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change pdf-toolbar__btn--signature-change-active"
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change pdf-toolbar__btn--signature-change-active"
|
||||||
@onclick="OpenSubmitConfirmPopup"
|
@onclick="OpenSubmitConfirmPopup"
|
||||||
disabled="@_isLoggingOut"
|
disabled="@_isLoggingOut"
|
||||||
title="Abschließen"
|
title="@L("Complete", "Abschließen")"
|
||||||
style="flex-shrink: 0;">
|
style="flex-shrink: 0;">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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"/>
|
<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"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="pdf-toolbar__btn-text">Abschließen</span>
|
<span class="pdf-toolbar__btn-text">@L("Complete", "Abschließen")</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,9 +71,9 @@
|
|||||||
<div class="d-flex justify-content-center align-items-center h-100">
|
<div class="d-flex justify-content-center align-items-center h-100">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="spinner-border text-white mb-3" style="width: 3.5rem; height: 3.5rem;" role="status">
|
<div class="spinner-border text-white mb-3" style="width: 3.5rem; height: 3.5rem;" role="status">
|
||||||
<span class="visually-hidden">Lädt...</span>
|
<span class="visually-hidden">@L("Loading", "Lädt...")</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-white fw-semibold">Dokument wird geladen...</p>
|
<p class="text-white fw-semibold">@L("DocumentLoading", "Dokument wird geladen...")</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -84,7 +87,7 @@
|
|||||||
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<h5 class="mb-2">Fehler</h5>
|
<h5 class="mb-2">@L("Error", "Fehler")</h5>
|
||||||
<p class="mb-0">@_errorMessage</p>
|
<p class="mb-0">@_errorMessage</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +103,7 @@
|
|||||||
|
|
||||||
@* Submit confirmation popup *@
|
@* Submit confirmation popup *@
|
||||||
<DxPopup @bind-Visible="_submitConfirmVisible"
|
<DxPopup @bind-Visible="_submitConfirmVisible"
|
||||||
HeaderText="Unterschrift bestätigen"
|
HeaderText="@L("ConfirmSignature", "Unterschrift bestätigen")"
|
||||||
Width="440px"
|
Width="440px"
|
||||||
MaxWidth="95vw"
|
MaxWidth="95vw"
|
||||||
ShowFooter="true"
|
ShowFooter="true"
|
||||||
@@ -116,10 +119,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p style="margin: 0 0 0.4rem; font-weight: 600; color: #1f2937; font-size: 0.95rem;">
|
<p style="margin: 0 0 0.4rem; font-weight: 600; color: #1f2937; font-size: 0.95rem;">
|
||||||
Möchten Sie das Dokument verbindlich unterschreiben?
|
@L("ReceiverSigned_ConfirmQuestion", "Möchten Sie das Dokument verbindlich unterschreiben?")
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 0; color: #6b7280; font-size: 0.85rem; line-height: 1.5;">
|
<p style="margin: 0; color: #6b7280; font-size: 0.85rem; line-height: 1.5;">
|
||||||
Diese Aktion kann nicht rückgängig gemacht werden. Mit der Bestätigung erklären Sie, das oben angezeigte Dokument elektronisch unterzeichnet zu haben. Das unterzeichnete Dokument wird anschließend an alle beteiligten Parteien übermittelt.
|
@L("ReceiverSigned_ConfirmBody", "Diese Aktion kann nicht rückgängig gemacht werden. Mit der Bestätigung erklären Sie, das oben angezeigte Dokument elektronisch unterzeichnet zu haben. Das unterzeichnete Dokument wird anschließend an alle beteiligten Parteien übermittelt.")
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +138,7 @@
|
|||||||
<button class="btn btn-outline-secondary"
|
<button class="btn btn-outline-secondary"
|
||||||
@onclick="() => _submitConfirmVisible = false"
|
@onclick="() => _submitConfirmVisible = false"
|
||||||
style="border-radius: 6px; padding: 0.5rem 1.25rem; font-weight: 500;">
|
style="border-radius: 6px; padding: 0.5rem 1.25rem; font-weight: 500;">
|
||||||
Abbrechen
|
@L("Cancel", "Abbrechen")
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary"
|
<button class="btn btn-primary"
|
||||||
@onclick="SubmitAndLogoutAsync"
|
@onclick="SubmitAndLogoutAsync"
|
||||||
@@ -145,7 +148,7 @@
|
|||||||
{
|
{
|
||||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||||
}
|
}
|
||||||
Abschließen
|
@L("Complete", "Abschließen")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</FooterContentTemplate>
|
</FooterContentTemplate>
|
||||||
@@ -201,7 +204,7 @@
|
|||||||
await SignatureService.SubmitAsync(EnvelopeKey, payload);
|
await SignatureService.SubmitAsync(EnvelopeKey, payload);
|
||||||
|
|
||||||
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
|
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
|
||||||
Navigation.NavigateTo("/", forceLoad: true);
|
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -216,7 +219,7 @@
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||||
{
|
{
|
||||||
_errorMessage = "Envelope-Schlüssel fehlt.";
|
_errorMessage = L("ReceiverSigned_EnvelopeKeyMissing", "Envelope-Schlüssel fehlt.");
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -253,7 +256,7 @@
|
|||||||
var pdfBytes = await PageDataService.GetDocumentOfReceiverAsync(_receiverUser);
|
var pdfBytes = await PageDataService.GetDocumentOfReceiverAsync(_receiverUser);
|
||||||
if (pdfBytes is not { Length: > 0 })
|
if (pdfBytes is not { Length: > 0 })
|
||||||
{
|
{
|
||||||
_errorMessage = "Dokument konnte nicht geladen werden.";
|
_errorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden.");
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -282,7 +285,7 @@
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_errorMessage = $"Fehler: {ex.Message}";
|
_errorMessage = string.Format(L("ErrorWithReason", "Fehler: {0}"), ex.Message);
|
||||||
Logger.LogError(ex, "Error loading signed page for {EnvelopeKey}", EnvelopeKey);
|
Logger.LogError(ex, "Error loading signed page for {EnvelopeKey}", EnvelopeKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,4 +434,10 @@
|
|||||||
if (comma < 0) return null;
|
if (comma < 0) return null;
|
||||||
return Convert.FromBase64String(dataUrl[(comma + 1)..]);
|
return Convert.FromBase64String(dataUrl[(comma + 1)..]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
/// <summary>
|
||||||
/// Rejects the document for the current receiver.
|
/// Rejects the document for the current receiver.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="envelopeKey">Envelope key route parameter.</param>
|
||||||
/// <param name="reason">Optional rejection reason.</param>
|
/// <param name="reason">Optional rejection reason.</param>
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
[HttpPost("reject")]
|
[HttpPost("reject/{envelopeKey}")]
|
||||||
[Obsolete("Use MediatR")]
|
[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 signature = User.ReceiverSignature();
|
||||||
var uuid = User.EnvelopeUuid();
|
var uuid = User.EnvelopeUuid();
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
using DigitalData.Auth.Claims;
|
using DigitalData.Auth.Claims;
|
||||||
|
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||||
|
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||||
using EnvelopeGenerator.Server.Controllers.Interfaces;
|
using EnvelopeGenerator.Server.Controllers.Interfaces;
|
||||||
using EnvelopeGenerator.Server.Models;
|
using EnvelopeGenerator.Server.Models;
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -15,7 +18,9 @@ namespace EnvelopeGenerator.Server.Controllers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions, IAuthorizationService authService) : ControllerBase, IAuthController
|
public partial class AuthController(
|
||||||
|
IOptions<AuthTokenKeys> authTokenKeyOptions,
|
||||||
|
IAuthorizationService authService) : ControllerBase, IAuthController
|
||||||
{
|
{
|
||||||
private readonly AuthTokenKeys authTokenKeys = authTokenKeyOptions.Value;
|
private readonly AuthTokenKeys authTokenKeys = authTokenKeyOptions.Value;
|
||||||
|
|
||||||
@@ -82,6 +87,64 @@ public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions,
|
|||||||
[HttpGet("check/envelope/{envelopeKey}")]
|
[HttpGet("check/envelope/{envelopeKey}")]
|
||||||
public IActionResult CheckEnvelopeReceiver([FromRoute] string envelopeKey) => Ok();
|
public IActionResult CheckEnvelopeReceiver([FromRoute] string envelopeKey) => Ok();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the receiver access code e-mail on the first envelope login-page visit.
|
||||||
|
/// The operation is idempotent: if the code was already requested, it returns 200 without re-sending.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">Envelope receiver key.</param>
|
||||||
|
/// <response code="200">Access code is already available or requested successfully.</response>
|
||||||
|
/// <response code="404">Envelope receiver key was not found.</response>
|
||||||
|
/// <response code="500">Access code could not be persisted or e-mail could not be sent.</response>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("envelope-receiver/{key}/request-access-code")]
|
||||||
|
public async Task<IActionResult> RequestEnvelopeReceiverAccessCode(
|
||||||
|
[FromRoute] string key,
|
||||||
|
[FromServices] IMediator mediator,
|
||||||
|
[FromServices] IEnvelopeHistoryService historyService,
|
||||||
|
[FromServices] IEnvelopeMailService mailService,
|
||||||
|
[FromServices] ILogger<AuthController> logger,
|
||||||
|
CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var envelopeReceiver = (await mediator.Send(new ReadEnvelopeReceiverQuery { Key = key }, cancel)).SingleOrDefault();
|
||||||
|
|
||||||
|
if (envelopeReceiver is null || envelopeReceiver.Envelope is null || envelopeReceiver.Receiver is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
if (envelopeReceiver.Envelope.UseAccessCode != true)
|
||||||
|
return Ok();
|
||||||
|
|
||||||
|
var userReference = envelopeReceiver.Receiver.EmailAddress;
|
||||||
|
|
||||||
|
if (await historyService.IsSigned(envelopeReceiver.EnvelopeId, userReference))
|
||||||
|
return Ok();
|
||||||
|
|
||||||
|
var accessCodeAlreadyRequested = await historyService.AccessCodeAlreadyRequested(envelopeReceiver.EnvelopeId, userReference);
|
||||||
|
if (accessCodeAlreadyRequested)
|
||||||
|
return Ok();
|
||||||
|
|
||||||
|
var historyResult = await historyService.RecordAsync(envelopeReceiver.EnvelopeId, userReference, EnvelopeStatus.AccessCodeRequested);
|
||||||
|
if (historyResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Failed to record AccessCodeRequested history for envelope {EnvelopeId} and receiver {ReceiverEmail}.",
|
||||||
|
envelopeReceiver.EnvelopeId,
|
||||||
|
userReference);
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mailResult = await mailService.SendAccessCodeAsync(envelopeReceiver);
|
||||||
|
if (mailResult.IsFailed)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Failed to send access code mail for envelope {EnvelopeId} and receiver {ReceiverEmail}.",
|
||||||
|
envelopeReceiver.EnvelopeId,
|
||||||
|
userReference);
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes the per-envelope receiver cookie for the given envelope key.
|
/// Removes the per-envelope receiver cookie for the given envelope key.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ namespace EnvelopeGenerator.Server.Controllers;
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public class EnvelopeController : ControllerBase
|
public class EnvelopeController : ControllerBase
|
||||||
{
|
{
|
||||||
|
public record DeleteEnvelopeRequest(string Reason);
|
||||||
|
|
||||||
private readonly ILogger<EnvelopeController> _logger;
|
private readonly ILogger<EnvelopeController> _logger;
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
@@ -93,7 +95,6 @@ public class EnvelopeController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="command"></param>
|
/// <param name="command"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[NonAction]
|
|
||||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
|
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
|
||||||
@@ -108,4 +109,18 @@ public class EnvelopeController : ControllerBase
|
|||||||
else
|
else
|
||||||
return Ok(res);
|
return Ok(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
public async Task<IActionResult> DeleteAsync([FromRoute] int id, [FromBody] DeleteEnvelopeRequest request)
|
||||||
|
{
|
||||||
|
var result = await _mediator.Send(new DeleteEnvelopeCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = id,
|
||||||
|
Reason = request?.Reason ?? string.Empty
|
||||||
|
}.WithAuth(User.GetId()));
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Localization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using EnvelopeGenerator.Application.Resources;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Server.Controllers;
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ public class LocalizationController : ControllerBase
|
|||||||
|
|
||||||
private readonly ILogger<LocalizationController> _logger;
|
private readonly ILogger<LocalizationController> _logger;
|
||||||
private readonly IStringLocalizer<Resource> _mLocalizer;
|
private readonly IStringLocalizer<Resource> _mLocalizer;
|
||||||
private readonly IStringLocalizer<Resource> _localizer;
|
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -32,14 +30,12 @@ public class LocalizationController : ControllerBase
|
|||||||
/// <param name="_modelLocalizer">Lokalisierungsdienst für Modelle.</param>
|
/// <param name="_modelLocalizer">Lokalisierungsdienst für Modelle.</param>
|
||||||
public LocalizationController(
|
public LocalizationController(
|
||||||
ILogger<LocalizationController> logger,
|
ILogger<LocalizationController> logger,
|
||||||
IStringLocalizer<Resource> localizer,
|
|
||||||
IMemoryCache memoryCache,
|
IMemoryCache memoryCache,
|
||||||
IStringLocalizer<Resource> _modelLocalizer)
|
IStringLocalizer<Resource> modelLocalizer)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_localizer = localizer;
|
|
||||||
_cache = memoryCache;
|
_cache = memoryCache;
|
||||||
_mLocalizer = _modelLocalizer;
|
_mLocalizer = modelLocalizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -92,10 +88,10 @@ public class LocalizationController : ControllerBase
|
|||||||
var cookieValue = Request.Cookies[CookieRequestCultureProvider.DefaultCookieName];
|
var cookieValue = Request.Cookies[CookieRequestCultureProvider.DefaultCookieName];
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(cookieValue))
|
if (string.IsNullOrEmpty(cookieValue))
|
||||||
return null;
|
return "de-DE";
|
||||||
|
|
||||||
var culture = CookieRequestCultureProvider.ParseCookieValue(cookieValue)?.Cultures[0];
|
var culture = CookieRequestCultureProvider.ParseCookieValue(cookieValue)?.Cultures[0];
|
||||||
return culture?.Value ?? null;
|
return culture?.Value ?? "de-DE";
|
||||||
}
|
}
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using DigitalData.Auth.Claims;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("pages")]
|
||||||
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
|
public async Task<IActionResult> RenderPages([FromBody] PdfRenderRequestDto? request, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
if (request?.PdfBytes is not { Length: > 0 } bytes)
|
||||||
|
return BadRequest("Missing PDF bytes.");
|
||||||
|
|
||||||
|
IReadOnlyList<PdfPageImageDto> pages = await renderer.RenderPagesAsImagesAsync(bytes, cancel);
|
||||||
|
return Ok(pages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
using DigitalData.Core.Abstraction.Application.DTO;
|
using DigitalData.Core.Abstraction.Application.DTO;
|
||||||
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
|
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||||
|
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
using EnvelopeGenerator.Server.Extensions;
|
using MediatR;
|
||||||
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
@@ -33,24 +35,36 @@ public class ReadOnlyController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new read-only receiver for the current envelope.
|
/// Creates a new read-only receiver for an envelope.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="envelopeKey">Envelope key (uuid+signature encoded).</param>
|
||||||
/// <param name="createDto">Creation payload.</param>
|
/// <param name="createDto">Creation payload.</param>
|
||||||
[HttpPost]
|
[HttpPost("{envelopeKey}")]
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
|
||||||
[Obsolete("Use MediatR")]
|
[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();
|
var envelopeReceiver = await mediator.Send(new ReadEnvelopeReceiverQuery { Key = envelopeKey }, cancel);
|
||||||
if (authReceiverMail is null)
|
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));
|
_logger.LogError("Receiver e-mail is missing in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
|
||||||
return Unauthorized();
|
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||||
}
|
}
|
||||||
|
|
||||||
var envelopeId = User.EnvelopeId();
|
var envelopeId = authEnvelopeReceiver.EnvelopeId;
|
||||||
|
|
||||||
createDto.AddedWho = authReceiverMail;
|
createDto.AddedWho = addedWho;
|
||||||
createDto.EnvelopeId = envelopeId;
|
createDto.EnvelopeId = envelopeId;
|
||||||
|
|
||||||
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);
|
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
<Authors>Digital Data GmbH</Authors>
|
<Authors>Digital Data GmbH</Authors>
|
||||||
<Company>Digital Data GmbH</Company>
|
<Company>Digital Data GmbH</Company>
|
||||||
<Product>EnvelopeGenerator.Server</Product>
|
<Product>EnvelopeGenerator.Server</Product>
|
||||||
<Version>1.2.0-beta</Version>
|
<Version>1.4.0-beta</Version>
|
||||||
<FileVersion>1.2.0.0</FileVersion>
|
<FileVersion>1.4.0.0</FileVersion>
|
||||||
<AssemblyVersion>1.2.0.0</AssemblyVersion>
|
<AssemblyVersion>1.4.0.0</AssemblyVersion>
|
||||||
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
|
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
|
||||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<ProjectReference Include="..\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj" />
|
<ProjectReference Include="..\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj" />
|
||||||
<ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
<ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.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.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
|
<ProjectReference Include="..\..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ public static class ReceiverClaimExtensions
|
|||||||
/// Gets the authenticated receiver email address from the claims.
|
/// Gets the authenticated receiver email address from the claims.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string ReceiverMail(this ClaimsPrincipal user)
|
public static string ReceiverMail(this ClaimsPrincipal user)
|
||||||
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
|
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email, ClaimTypes.Email);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the authenticated envelope identifier from the claims.
|
/// Gets the authenticated envelope identifier from the claims.
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ public class ExceptionHandlingMiddleware
|
|||||||
message = notFoundEx.Message;
|
message = notFoundEx.Message;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case ForbiddenException forbiddenEx:
|
||||||
|
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
|
||||||
|
message = forbiddenEx.Message;
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.LogError(exception, "Unhandled exception occurred.");
|
logger.LogError(exception, "Unhandled exception occurred.");
|
||||||
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ using NLog.Web;
|
|||||||
using NLog;
|
using NLog;
|
||||||
using DigitalData.Auth.Claims;
|
using DigitalData.Auth.Claims;
|
||||||
using EnvelopeGenerator.Server;
|
using EnvelopeGenerator.Server;
|
||||||
|
using DigitalData.EmailProfilerDispatcher;
|
||||||
|
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||||
|
using EnvelopeGenerator.Infrastructure.Doc;
|
||||||
|
|
||||||
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
|
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
|
||||||
logger.Info("EnvelopeGenerator.Server logging initialized!");
|
logger.Info("EnvelopeGenerator.Server logging initialized!");
|
||||||
@@ -323,6 +326,8 @@ try
|
|||||||
.AddEnvelopeGeneratorServices(config);
|
.AddEnvelopeGeneratorServices(config);
|
||||||
#pragma warning restore CS0618
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
|
builder.Services.AddDispatcher<EGDbContext>();
|
||||||
|
|
||||||
// User Preferences (theme, dark mode, grid layouts — stored in TBDD_CACHE)
|
// User Preferences (theme, dark mode, grid layouts — stored in TBDD_CACHE)
|
||||||
// Registration handled by Infrastructure DependencyInjection (AddEnvelopeGeneratorInfrastructureServices)
|
// Registration handled by Infrastructure DependencyInjection (AddEnvelopeGeneratorInfrastructureServices)
|
||||||
|
|
||||||
@@ -337,6 +342,9 @@ try
|
|||||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
|
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
|
||||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
|
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
|
||||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
|
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
|
||||||
|
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
|
||||||
|
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>();
|
||||||
|
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>();
|
||||||
|
|
||||||
// Business Services (Server specific)
|
// Business Services (Server specific)
|
||||||
builder.Services.AddScoped<DocumentService>();
|
builder.Services.AddScoped<DocumentService>();
|
||||||
@@ -345,6 +353,8 @@ try
|
|||||||
builder.Services.AddScoped<EnvelopeReceiverService>();
|
builder.Services.AddScoped<EnvelopeReceiverService>();
|
||||||
builder.Services.AddScoped<SignatureService>();
|
builder.Services.AddScoped<SignatureService>();
|
||||||
builder.Services.AddScoped<SignatureCacheService>();
|
builder.Services.AddScoped<SignatureCacheService>();
|
||||||
|
builder.Services.AddScoped<IPdfPageImageRenderer, PdfPageImageRenderer>();
|
||||||
|
builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>();
|
||||||
builder.Services.AddSingleton<AppVersionService>();
|
builder.Services.AddSingleton<AppVersionService>();
|
||||||
|
|
||||||
// EnvelopeService with HttpClient factory (for SSR scenarios)
|
// EnvelopeService with HttpClient factory (for SSR scenarios)
|
||||||
@@ -410,16 +420,22 @@ try
|
|||||||
// Set CORS policy
|
// Set CORS policy
|
||||||
app.UseCors("AllowSpecificOriginsPolicy");
|
app.UseCors("AllowSpecificOriginsPolicy");
|
||||||
|
|
||||||
// Localization
|
// Localization (default German; can be switched via cookie)
|
||||||
string[] supportedCultureNames = ["de-DE", "en-US"];
|
var defaultCulture = new CultureInfo("de-DE");
|
||||||
IList<CultureInfo> list = [.. supportedCultureNames.Select(cn => new CultureInfo(cn))];
|
IList<CultureInfo> list =
|
||||||
var cultureInfo = list.FirstOrDefault() ?? throw new InvalidOperationException("There is no supported culture.");
|
[
|
||||||
|
defaultCulture,
|
||||||
|
new CultureInfo("en-US"),
|
||||||
|
new CultureInfo("fr-FR")
|
||||||
|
];
|
||||||
var requestLocalizationOptions = new RequestLocalizationOptions
|
var requestLocalizationOptions = new RequestLocalizationOptions
|
||||||
{
|
{
|
||||||
|
DefaultRequestCulture = new RequestCulture(defaultCulture),
|
||||||
SupportedCultures = list,
|
SupportedCultures = list,
|
||||||
SupportedUICultures = list
|
SupportedUICultures = list,
|
||||||
|
RequestCultureProviders = [new CookieRequestCultureProvider()]
|
||||||
};
|
};
|
||||||
requestLocalizationOptions.RequestCultureProviders.Add(new QueryStringRequestCultureProvider());
|
requestLocalizationOptions.ApplyCurrentCultureToResponseHeaders = true;
|
||||||
app.UseRequestLocalization(requestLocalizationOptions);
|
app.UseRequestLocalization(requestLocalizationOptions);
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"applicationUrl": "https://localhost:8089;http://localhost:5132",
|
"applicationUrl": "https://localhost:8088;http://localhost:5131",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ public class EnvelopeReceiverPageDataService(
|
|||||||
: throw new NotFoundException($"Document for envelope {envelopeId} not found.");
|
: throw new NotFoundException($"Document for envelope {envelopeId} not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the full document DTO (including receiver elements) for the authenticated sender.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<DocumentDto?> GetDocumentDetailsOfSenderAsync(ClaimsPrincipal user, int envelopeId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await mediator.Send(new ReadDocumentQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads the current receiver's signature placeholders.
|
/// Loads the current receiver's signature placeholders.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -345,6 +345,83 @@ article {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Signed result page */
|
||||||
|
.receiver-signed-result-page {
|
||||||
|
min-height: calc(100vh - 36px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
background: linear-gradient(160deg, #1e2e3e 0%, #2c3e50 40%, #3498db 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.receiver-signed-result-card {
|
||||||
|
width: min(680px, 100%);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.24);
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receiver-signed-result-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 92px;
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #0f8f58;
|
||||||
|
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receiver-signed-result-title {
|
||||||
|
margin: 0;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receiver-signed-result-message {
|
||||||
|
margin: 0.85rem auto 0;
|
||||||
|
max-width: 58ch;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="purple"] .receiver-signed-result-page {
|
||||||
|
background: linear-gradient(160deg, #1a0533 0%, #3b0764 45%, #6b21a8 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="blazing-dark"] .receiver-signed-result-page {
|
||||||
|
background: linear-gradient(160deg, #0a0f1e 0%, #111827 50%, #1e1b4b 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="blazing-dark"] .receiver-signed-result-card {
|
||||||
|
background: rgba(15, 20, 40, 0.94);
|
||||||
|
border-color: rgba(129, 140, 248, 0.28);
|
||||||
|
box-shadow: 0 20px 46px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="blazing-dark"] .receiver-signed-result-title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="blazing-dark"] .receiver-signed-result-message {
|
||||||
|
color: rgba(255, 255, 255, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="blazing-dark"] .receiver-signed-result-icon {
|
||||||
|
color: #86efac;
|
||||||
|
background: rgba(16, 185, 129, 0.16);
|
||||||
|
border-color: rgba(110, 231, 183, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Logos ───────────────────────────────────────────────────────────────── */
|
/* ── Logos ───────────────────────────────────────────────────────────────── */
|
||||||
/* Heights are controlled by inline style="height: Xpx" from appsettings.json */
|
/* Heights are controlled by inline style="height: Xpx" from appsettings.json */
|
||||||
|
|
||||||
@@ -404,6 +481,89 @@ article {
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.receiver-footer__right {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-lang-combo {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-lang-trigger {
|
||||||
|
width: 28px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-lang-trigger:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-lang-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 64px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(29, 38, 55, 0.98);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 110;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-lang-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
cursor: default;
|
||||||
|
z-index: 105;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-lang-option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-lang-option:hover,
|
||||||
|
.sf-lang-option--active {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-lang-trigger .fi,
|
||||||
|
.sf-lang-option .fi {
|
||||||
|
width: 16px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25) inset;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Footer: Theme Slider ────────────────────────────────────────────────── */
|
/* ── Footer: Theme Slider ────────────────────────────────────────────────── */
|
||||||
/* 🌙 blazing-dark ──◇──·──◇── purple ☀️ */
|
/* 🌙 blazing-dark ──◇──·──◇── purple ☀️ */
|
||||||
/* Left group: copyright | datenschutz → Right group: slider */
|
/* Left group: copyright | datenschutz → Right group: slider */
|
||||||
|
|||||||
@@ -56,6 +56,351 @@
|
|||||||
height: 100%;
|
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 {
|
.sender-editor-pdf-viewer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -542,6 +887,40 @@ body.resizing {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Draft save button for sender editor */
|
||||||
|
.pdf-toolbar__btn--draft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: auto;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f4c81;
|
||||||
|
border: 1px solid rgba(14, 165, 233, 0.34);
|
||||||
|
background: linear-gradient(135deg, rgba(14, 165, 233, 0.12) 0%, rgba(2, 132, 199, 0.1) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar__btn--draft:hover:not(:disabled) {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: transparent;
|
||||||
|
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar__btn--draft:disabled {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar__btn--draft svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar__btn--draft .pdf-toolbar__btn-text {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
/* Signature Change Button */
|
/* Signature Change Button */
|
||||||
.pdf-toolbar__btn--signature-change {
|
.pdf-toolbar__btn--signature-change {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -575,6 +954,29 @@ body.resizing {
|
|||||||
cursor: not-allowed;
|
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 {
|
.pdf-toolbar__btn-text {
|
||||||
font-size: 0.813rem;
|
font-size: 0.813rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1008,6 +1410,41 @@ body.resizing {
|
|||||||
padding: 0.75rem;
|
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 {
|
.pdf-thumbnails {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
border-radius: 0 0 0 16px;
|
border-radius: 0 0 0 16px;
|
||||||
@@ -1177,6 +1614,18 @@ body.resizing {
|
|||||||
color: #a5b4fc;
|
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 */
|
/* Error container */
|
||||||
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
|
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
|
||||||
background: rgba(239, 68, 68, 0.12);
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
|||||||
@@ -238,6 +238,7 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.sender-grid-container {
|
.sender-grid-container {
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: rgba(255, 255, 255, 0.98);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
@@ -381,6 +382,109 @@
|
|||||||
color: #c62828;
|
color: #c62828;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sender-access-code {
|
||||||
|
min-width: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-access-code__label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-access-code__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-access-code__value {
|
||||||
|
width: 72px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid rgba(126, 34, 206, 0.22);
|
||||||
|
border-right: none;
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
padding: 0 0.55rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-access-code__value:focus {
|
||||||
|
outline: 2px solid rgba(126, 34, 206, 0.18);
|
||||||
|
border-color: rgba(126, 34, 206, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-access-code__copy {
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid rgba(126, 34, 206, 0.3);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
width: 30px;
|
||||||
|
min-width: 30px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #4c1d95;
|
||||||
|
background: rgba(126, 34, 206, 0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-access-code__copy:hover:not(:disabled) {
|
||||||
|
border-color: rgba(126, 34, 206, 0.45);
|
||||||
|
background: rgba(126, 34, 206, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-access-code__copy:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-history-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-history-item__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-history-item__actor {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-history-item__right {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1320px) {
|
@media (max-width: 1320px) {
|
||||||
.sender-action-bar__inner {
|
.sender-action-bar__inner {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -482,6 +586,10 @@
|
|||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
font-size: 0.813rem;
|
font-size: 0.813rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sender-access-code {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
@@ -652,6 +760,7 @@
|
|||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[data-sf-theme="blazing-dark"] .sender-grid-container::before {
|
[data-sf-theme="blazing-dark"] .sender-grid-container::before {
|
||||||
background: linear-gradient(90deg, #6366f1 0%, #4338ca 100%);
|
background: linear-gradient(90deg, #6366f1 0%, #4338ca 100%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
window.envelopeEditor = {
|
window.envelopeEditor = {
|
||||||
|
|
||||||
|
_moveThrottle: {
|
||||||
|
rafId: 0,
|
||||||
|
latest: null,
|
||||||
|
waiters: []
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the click position normalised to [0,1] relative to the rendered PDF page
|
* Returns the click position normalised to [0,1] relative to the rendered PDF page
|
||||||
* element inside DxPdfViewer (or DxReportViewer as fallback).
|
* element inside DxPdfViewer (or DxReportViewer as fallback).
|
||||||
@@ -25,9 +31,10 @@ window.envelopeEditor = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Candidate page elements (ordered by preference) ---
|
// --- Candidate page elements (ordered by preference) ---
|
||||||
// DxPdfViewer renders individual pages as .dxbl-pdfv-page elements.
|
// Sender editor custom renderer uses data-editor-page blocks.
|
||||||
// DxReportViewer uses .dxbrv-report-preview-content-img as fallback.
|
// Legacy Dx viewers remain as fallback selectors.
|
||||||
const pageSelectors = [
|
const pageSelectors = [
|
||||||
|
'[data-editor-page]',
|
||||||
'.dxbl-pdfv-page',
|
'.dxbl-pdfv-page',
|
||||||
'.dxbrv-report-preview-page',
|
'.dxbrv-report-preview-page',
|
||||||
'.dxbrv-report-preview-content-img',
|
'.dxbrv-report-preview-content-img',
|
||||||
@@ -89,7 +96,229 @@ window.envelopeEditor = {
|
|||||||
return {
|
return {
|
||||||
normX: normX,
|
normX: normX,
|
||||||
normY: normY,
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using DigitalData.Core.Exceptions;
|
||||||
|
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
|
||||||
|
using EnvelopeGenerator.Application.Envelopes.Commands;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Tests.Application;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class DeleteEnvelopeCommandTests : TestBase
|
||||||
|
{
|
||||||
|
protected override void ConfigureServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DeleteEnvelope_DraftEnvelope_ShouldSetDeleted_CreateHistory_AndQueueDeletionEmail()
|
||||||
|
{
|
||||||
|
CancellationToken cancel = default;
|
||||||
|
var reason = "Invalid receiver data";
|
||||||
|
|
||||||
|
var envelope = this.CreateEnvelope(User.Id);
|
||||||
|
envelope.Status = EnvelopeStatus.EnvelopeCreated;
|
||||||
|
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
|
||||||
|
|
||||||
|
var createReceiverCmd = this.CreateReceiverCommand();
|
||||||
|
(var receiver, _) = await Mediator.Send(createReceiverCmd, cancel);
|
||||||
|
|
||||||
|
var envReceiver = this.CreateEnvelopeReceiver(envelope.Id, receiver.Id);
|
||||||
|
await GetRepository<EnvelopeReceiver>().CreateAsync(envReceiver, cancel);
|
||||||
|
|
||||||
|
var result = await Mediator.Send(new DeleteEnvelopeCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = envelope.Id,
|
||||||
|
Reason = reason
|
||||||
|
}.WithAuth(User.Id), cancel);
|
||||||
|
|
||||||
|
Assert.That(result.EnvelopeId, Is.EqualTo(envelope.Id));
|
||||||
|
Assert.That(result.AppliedStatus, Is.EqualTo(EnvelopeStatus.EnvelopeDeleted));
|
||||||
|
Assert.That(result.IsNoOp, Is.False);
|
||||||
|
|
||||||
|
var histories = await GetRepository<History>().Query
|
||||||
|
.Where(h => h.EnvelopeId == envelope.Id && h.Status == EnvelopeStatus.EnvelopeDeleted)
|
||||||
|
.ToListAsync(cancel);
|
||||||
|
Assert.That(histories, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(histories[0].Comment, Is.EqualTo(reason));
|
||||||
|
|
||||||
|
var emails = await GetRepository<EmailOut>().Query
|
||||||
|
.Where(e => e.WfId == (int)EnvelopeStatus.MessageDeletionSent && e.ReferenceId == receiver.Id)
|
||||||
|
.ToListAsync(cancel);
|
||||||
|
Assert.That(emails, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(emails[0].EmailAddress, Is.EqualTo(receiver.EmailAddress));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DeleteEnvelope_SentEnvelope_ShouldSetWithdrawn()
|
||||||
|
{
|
||||||
|
CancellationToken cancel = default;
|
||||||
|
|
||||||
|
var envelope = this.CreateEnvelope(User.Id);
|
||||||
|
envelope.Status = EnvelopeStatus.EnvelopeQueued;
|
||||||
|
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
|
||||||
|
|
||||||
|
var result = await Mediator.Send(new DeleteEnvelopeCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = envelope.Id,
|
||||||
|
Reason = "Sender requested cancellation"
|
||||||
|
}.WithAuth(User.Id), cancel);
|
||||||
|
|
||||||
|
Assert.That(result.AppliedStatus, Is.EqualTo(EnvelopeStatus.EnvelopeWithdrawn));
|
||||||
|
|
||||||
|
var histories = await GetRepository<History>().Query
|
||||||
|
.Where(h => h.EnvelopeId == envelope.Id && h.Status == EnvelopeStatus.EnvelopeWithdrawn)
|
||||||
|
.ToListAsync(cancel);
|
||||||
|
Assert.That(histories, Has.Count.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void DeleteEnvelope_MissingReason_ShouldThrowBadRequest()
|
||||||
|
{
|
||||||
|
Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||||
|
await Mediator.Send(new DeleteEnvelopeCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = 123,
|
||||||
|
Reason = " "
|
||||||
|
}.WithAuth(User.Id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task DeleteEnvelope_WrongOwner_ShouldThrowForbidden()
|
||||||
|
{
|
||||||
|
CancellationToken cancel = default;
|
||||||
|
|
||||||
|
var envelope = this.CreateEnvelope(User.Id);
|
||||||
|
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
|
||||||
|
|
||||||
|
var otherUserCmd = this.CreateUserCommand();
|
||||||
|
var otherUser = await GetRepository<DigitalData.UserManager.Domain.Entities.User>().CreateAsync(otherUserCmd, cancel);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<ForbiddenException>(async () =>
|
||||||
|
await Mediator.Send(new DeleteEnvelopeCommand
|
||||||
|
{
|
||||||
|
EnvelopeId = envelope.Id,
|
||||||
|
Reason = "Not allowed"
|
||||||
|
}.WithAuth(otherUser.Id), cancel));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ public class Fake
|
|||||||
|
|
||||||
services.AddEnvelopeGeneratorInfrastructureServices(opt =>
|
services.AddEnvelopeGeneratorInfrastructureServices(opt =>
|
||||||
{
|
{
|
||||||
opt.AddDbContext(dbCtxOpt => dbCtxOpt.UseInMemoryDatabase("EnvelopeGeneratorTestDb"));
|
opt.AddDbContext(dbCtxOpt => dbCtxOpt.UseInMemoryDatabase($"EnvelopeGeneratorTestDb_{Guid.NewGuid():N}"));
|
||||||
|
|
||||||
opt.AddDbTriggerParams(context.Configuration);
|
opt.AddDbTriggerParams(context.Configuration);
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ public abstract class TestBase : Faker
|
|||||||
|
|
||||||
// Add seed email templates
|
// Add seed email templates
|
||||||
foreach (var temp in SeedEmailTemplates)
|
foreach (var temp in SeedEmailTemplates)
|
||||||
|
{
|
||||||
|
temp.LangCode ??= "de";
|
||||||
await repo.CreateAsync(temp);
|
await repo.CreateAsync(temp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[TearDown]
|
[TearDown]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"DbTriggerParams": {
|
"DbTriggerParams": {
|
||||||
"Envelope": [ "TBSIG_ENVELOPE_AFT_INS" ],
|
"Envelope": [ "TBSIG_ENVELOPE_AFT_INS" ],
|
||||||
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
||||||
|
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
|
||||||
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
||||||
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
||||||
"Receiver": [],
|
"Receiver": [],
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ EndProject
|
|||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
EnvelopeGenerator.Server\publish.bat = EnvelopeGenerator.Server\publish.bat
|
EnvelopeGenerator.Server\publish.bat = EnvelopeGenerator.Server\publish.bat
|
||||||
|
SENDER_SAVE_EDIT_MIGRATION_PLAN.md = SENDER_SAVE_EDIT_MIGRATION_PLAN.md
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
|
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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.csproj", "{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.csproj", "{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Infrastructure.Doc", "EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj", "{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -136,6 +143,7 @@ Global
|
|||||||
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
||||||
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||||
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
|
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}
|
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# EnvelopeGenerator.Form — VB.NET Desktop Application Context
|
# EnvelopeGenerator.Form — VB.NET Desktop Application Context
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
**Legacy Windows Forms application** for envelope creation, management, and signature field placement. Built with **DevExpress components** and **GdPicture14** for PDF manipulation. This application is being **migrated to EnvelopeGenerator.Server + API** architecture.
|
**Legacy Windows Forms application** for envelope creation, management, and signature field placement. Built with **DevExpress components** and **GdPicture14** for PDF manipulation. This application is being **migrated to `EnvelopeGenerator.Server` architecture** (merged Blazor + Web API host).
|
||||||
|
|
||||||
**Primary Libraries:** DevExpress XtraGrid/XtraEditors, GdPicture14, VB.NET (.NET Framework 4.6.2)
|
**Primary Libraries:** DevExpress XtraGrid/XtraEditors, GdPicture14, VB.NET (.NET Framework 4.6.2)
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ End Sub
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Migration to EnvelopeGenerator.Server + API
|
## Migration to EnvelopeGenerator.Server
|
||||||
|
|
||||||
### Mapping Table
|
### Mapping Table
|
||||||
|
|
||||||
|
|||||||
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).
|
||||||
281
SENDER_SAVE_EDIT_MIGRATION_PLAN.md
Normal file
281
SENDER_SAVE_EDIT_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Sender Save/Edit Migration Plan (Legacy Form -> Blazor Server)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Migrate the legacy `frmEnvelopeEditor` save/edit workflow into current architecture so that:
|
||||||
|
|
||||||
|
1. Sender can **save incomplete envelope as draft** (`EnvelopeSaved = 1002`) from `EnvelopeSenderEditorPage.razor`.
|
||||||
|
2. Sender can **resume editing** saved/created envelopes from `EnvelopeSenderPage.razor` via **Bearbeiten** and continue where they left off.
|
||||||
|
|
||||||
|
This plan introduces a distinct draft flow and a same-ID send-from-draft flow, aligned with legacy Form app semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
- Add separate **Save** and **Send** actions in sender editor.
|
||||||
|
- Persist draft data server-side with partial/incomplete payload support.
|
||||||
|
- Re-open existing draft from dashboard and restore editor state.
|
||||||
|
- Preserve existing delete/withdraw logic and status model.
|
||||||
|
- Add endpoints and application commands required for progressive sender workflow.
|
||||||
|
|
||||||
|
### Out of scope (this ticket)
|
||||||
|
- Full redesign of editor UX.
|
||||||
|
- Multi-document support parity with `frmOrderFiles`.
|
||||||
|
- New DB schema changes (unless separately approved).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Gap Analysis
|
||||||
|
|
||||||
|
## Current behavior (problem)
|
||||||
|
- `EnvelopeSenderEditorPage.razor` has only one save path (`SaveAsync`) that behaves like "send":
|
||||||
|
- requires title + PDF + receivers + fields.
|
||||||
|
- calls `EnvelopeReceiverService.CreateAsync(...)` -> `/api/EnvelopeReceiver` monolithic create/send pipeline.
|
||||||
|
- `EnvelopeSenderPage.razor` `EditEnvelope()` is TODO and does not navigate to editable persisted draft data.
|
||||||
|
|
||||||
|
## Legacy behavior (target)
|
||||||
|
- `frmEnvelopeEditor` has both:
|
||||||
|
- **Save**: persists envelope without full validation/sending.
|
||||||
|
- **Send**: validates required business data then sends invitations.
|
||||||
|
- Dashboard "Bearbeiten" opens selected unsent envelope and restores work state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Decisions
|
||||||
|
|
||||||
|
1. **Split command model by intent**
|
||||||
|
- Draft persistence must not reuse monolithic `/api/EnvelopeReceiver` create+send endpoint.
|
||||||
|
- Introduce dedicated sender workflow commands/endpoints for draft lifecycle.
|
||||||
|
|
||||||
|
2. **Progressive persistence**
|
||||||
|
- Persist envelope metadata, document, receivers, and fields incrementally.
|
||||||
|
- Draft save accepts partial data and updates only provided segments.
|
||||||
|
|
||||||
|
3. **Status ownership**
|
||||||
|
- Draft operations set status to `EnvelopeSaved` (1002).
|
||||||
|
- Send operation transitions to queued/sent path (existing 1003+ behavior).
|
||||||
|
|
||||||
|
4. **Editor route strategy**
|
||||||
|
- Keep `/sender/editor` for new envelope.
|
||||||
|
- Support edit mode via query: `/sender/editor?envelopeId={id}` (or route variant in follow-up if preferred).
|
||||||
|
|
||||||
|
5. **No DB schema migration by default**
|
||||||
|
- Use existing tables (`TBSIG_ENVELOPE`, `TBSIG_DOCUMENT`, `TBSIG_ENVELOPE_RECEIVER`, `TBSIG_DOC_RECEIVER_ELEMENT`).
|
||||||
|
- If a field is missing for a requirement, handle in application logic first and escalate only if unavoidable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target Workflow
|
||||||
|
|
||||||
|
## A) New envelope (draft first)
|
||||||
|
1. User opens `/sender/editor`.
|
||||||
|
2. User enters any subset of data.
|
||||||
|
3. User clicks **Save**.
|
||||||
|
4. System creates or updates draft envelope and sets status `EnvelopeSaved`.
|
||||||
|
5. User gets confirmation and remains in editor (or optional return to dashboard).
|
||||||
|
|
||||||
|
## B) New envelope (direct send)
|
||||||
|
1. User fills required data.
|
||||||
|
2. User clicks **Send**.
|
||||||
|
3. System validates complete data and sends invitations.
|
||||||
|
4. Status transitions to queued/sent flow.
|
||||||
|
|
||||||
|
## C) Edit existing draft/created
|
||||||
|
1. User selects row in `/sender` with `EnvelopeCreated` or `EnvelopeSaved`.
|
||||||
|
2. User clicks **Bearbeiten**.
|
||||||
|
3. App navigates to `/sender/editor?envelopeId={id}`.
|
||||||
|
4. Editor loads persisted data (metadata, PDF, receivers, fields).
|
||||||
|
5. User can save again (draft) or send.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Implementation Plan
|
||||||
|
|
||||||
|
## Phase 1 - Contracts and Application Use Cases
|
||||||
|
|
||||||
|
### 1.1 Add sender draft commands (Application)
|
||||||
|
- `CreateEnvelopeCommand` (create/update draft with partial data)
|
||||||
|
- `ReadEnvelopeDraftQuery` (load full editable projection by envelope id)
|
||||||
|
- `CreateEnvelopeCommand` (same request, `Send=true` for final send)
|
||||||
|
|
||||||
|
Each command should enforce sender ownership (`UserId`) and return structured result DTOs.
|
||||||
|
|
||||||
|
### 1.2 Validation policy split
|
||||||
|
- **Draft Save validation (minimal):**
|
||||||
|
- title optional at first save? choose one policy and keep consistent.
|
||||||
|
- no requirement for receivers/fields/document.
|
||||||
|
- **Send validation (strict):**
|
||||||
|
- title required
|
||||||
|
- document required
|
||||||
|
- at least one receiver
|
||||||
|
- required signature field mapping per receiver (business rule)
|
||||||
|
|
||||||
|
### 1.3 History policy
|
||||||
|
- On draft save/update: optional lightweight history entry (`EnvelopeSaved`) if legacy parity requires it.
|
||||||
|
- On send: keep current history/send pipeline behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 - Server Endpoints
|
||||||
|
|
||||||
|
Implement in `EnvelopeGenerator.Server` only.
|
||||||
|
|
||||||
|
### 2.1 Draft endpoints
|
||||||
|
- `POST /api/Envelope`
|
||||||
|
- Create/update draft envelope (partial payload allowed), and send when `Send=true`.
|
||||||
|
- `GET /api/Envelope/{id}/draft`
|
||||||
|
- Read full editable aggregate for editor hydration.
|
||||||
|
|
||||||
|
### 2.2 Authorization + errors
|
||||||
|
- 400 validation issues
|
||||||
|
- 403 sender mismatch
|
||||||
|
- 404 envelope not found
|
||||||
|
- 409 invalid state transition (optional, if needed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 - Sender Editor UI (`EnvelopeSenderEditorPage.razor`)
|
||||||
|
|
||||||
|
### 3.1 Action buttons
|
||||||
|
- Keep current action as **Send** (rename from "Speichern" to "Senden").
|
||||||
|
- Add separate **Save** button next to Send.
|
||||||
|
|
||||||
|
### 3.2 Editor mode state
|
||||||
|
- Add `_editingEnvelopeId` from query param `envelopeId`.
|
||||||
|
- `null` => create mode, value => edit mode.
|
||||||
|
|
||||||
|
### 3.3 Load existing draft
|
||||||
|
- On parameter set / first render in edit mode:
|
||||||
|
- call draft read endpoint.
|
||||||
|
- hydrate title, message, PDF bytes, receiver list, field list.
|
||||||
|
- map DB inches <-> PDF points correctly:
|
||||||
|
- DB inches -> UI points: `pt = inches * 72`
|
||||||
|
- UI points -> DB inches: `inches = pt / 72`
|
||||||
|
|
||||||
|
### 3.4 Save behavior (new)
|
||||||
|
- Save button calls `SaveDraftAsync()`:
|
||||||
|
- no strict send validations.
|
||||||
|
- create draft if no id yet, otherwise update.
|
||||||
|
- force status `EnvelopeSaved`.
|
||||||
|
- keep user in editor and show non-blocking success feedback.
|
||||||
|
|
||||||
|
### 3.5 Send behavior (existing path replacement)
|
||||||
|
- Send button calls `SendAsync()`:
|
||||||
|
- strict validation.
|
||||||
|
- if no draft id: create draft first then send, or direct send command.
|
||||||
|
- if edit mode: send current draft id.
|
||||||
|
|
||||||
|
### 3.6 Session cache coexistence
|
||||||
|
- Keep `esid` memory cache for transient UX resilience.
|
||||||
|
- In edit mode, server data is source of truth; cache is fallback only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 - Dashboard Edit Resume (`EnvelopeSenderPage.razor`)
|
||||||
|
|
||||||
|
### 4.1 Edit button enablement
|
||||||
|
- Enable **Bearbeiten** for statuses:
|
||||||
|
- `EnvelopeCreated`
|
||||||
|
- `EnvelopeSaved`
|
||||||
|
- Keep disabled for sent/queued/completed/deleted/withdrawn/rejected.
|
||||||
|
|
||||||
|
### 4.2 Navigation
|
||||||
|
- Implement `EditEnvelope()`:
|
||||||
|
- `Navigation.NavigateTo($"/sender/editor?envelopeId={_selectedEnvelope.Id}")`.
|
||||||
|
|
||||||
|
### 4.3 Optional UX
|
||||||
|
- Double-click on editable rows can open editor instead of preview (or keep current preview behavior per product decision).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 - Data Mapping Rules
|
||||||
|
|
||||||
|
1. **Document**
|
||||||
|
- Persist original PDF bytes (no visual placeholder burn-in).
|
||||||
|
2. **Receivers**
|
||||||
|
- Draft can contain receivers with/without fields.
|
||||||
|
3. **Fields**
|
||||||
|
- Store in DB inches with fixed signature size semantics.
|
||||||
|
4. **Ownership**
|
||||||
|
- Sender can read/update/send only own envelopes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested DTO Shape (Draft)
|
||||||
|
|
||||||
|
Use one aggregate DTO for editor hydration/save/update:
|
||||||
|
|
||||||
|
- `EnvelopeDraftDto`
|
||||||
|
- `EnvelopeId`
|
||||||
|
- `Status`
|
||||||
|
- `Title`
|
||||||
|
- `Message`
|
||||||
|
- `DocumentBase64` (or document id + fetch endpoint)
|
||||||
|
- `Receivers[]`
|
||||||
|
- `ReceiverId?`
|
||||||
|
- `Name`
|
||||||
|
- `Email`
|
||||||
|
- `Phone`
|
||||||
|
- `Fields[] { XInches, YInches, Page }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
## Step A (safe vertical slice)
|
||||||
|
- Add read/edit route query handling and dashboard navigation first.
|
||||||
|
- Hydrate editor from existing envelope data where possible.
|
||||||
|
|
||||||
|
## Step B (draft save)
|
||||||
|
- Implement draft create/update endpoints + app commands.
|
||||||
|
- Wire Save button.
|
||||||
|
|
||||||
|
## Step C (send from draft)
|
||||||
|
- Move Send to explicit command/endpoint.
|
||||||
|
- Ensure existing email/history behavior remains intact.
|
||||||
|
|
||||||
|
## Step D (hardening)
|
||||||
|
- Add tests + manual QA checklist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
## Automated (Application)
|
||||||
|
- Save draft with partial data -> status `EnvelopeSaved`.
|
||||||
|
- Edit draft updates existing records, no duplicate orphan data.
|
||||||
|
- Send draft with missing required data -> validation error.
|
||||||
|
- Send draft complete -> queued/sent flow success.
|
||||||
|
- Ownership checks -> 403 behavior.
|
||||||
|
|
||||||
|
## Manual (UI)
|
||||||
|
1. New editor -> save with only title.
|
||||||
|
2. Reopen from dashboard -> state restored.
|
||||||
|
3. Add PDF only -> save -> reopen -> PDF still present.
|
||||||
|
4. Add receivers/fields incrementally -> save repeatedly.
|
||||||
|
5. Final send -> status transitions and receiver notifications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
1. **Monolithic endpoint coupling risk**
|
||||||
|
- Mitigation: draft/send dedicated endpoints.
|
||||||
|
2. **Data duplication on repeated save**
|
||||||
|
- Mitigation: idempotent upsert logic for receivers/fields.
|
||||||
|
3. **Coordinate regression**
|
||||||
|
- Mitigation: centralize inches/points conversion helpers.
|
||||||
|
4. **Trigger side effects/history duplication**
|
||||||
|
- Mitigation: verify DB-trigger interactions per status events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- Editor has two distinct actions: **Save** and **Send**.
|
||||||
|
- Save allows incomplete payload and persists with `EnvelopeSaved` status.
|
||||||
|
- Dashboard **Bearbeiten** opens editor for `EnvelopeCreated`/`EnvelopeSaved` and restores state.
|
||||||
|
- Sender can continue from last saved point and eventually send.
|
||||||
|
- Server endpoints support save/edit/send draft workflow with same envelope id.
|
||||||
|
- No DB schema changes introduced without explicit approval.
|
||||||
Reference in New Issue
Block a user