diff --git a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs index ed66f11e4..eefd59f20 100644 --- a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs @@ -17,7 +17,7 @@ public class EstimateListPageViewModelTests await vm.InitializeAsync(); Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths); - Assert.Equal("Mes devis à valider", vm.Title); + Assert.Equal("Devis en attente de signature client", vm.Title); Assert.Equal(2, vm.Estimates.Count); // Most recent first Assert.Equal(2, vm.Estimates[0].Id); @@ -35,7 +35,7 @@ public class EstimateListPageViewModelTests await vm.InitializeAsync(); Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths); - Assert.Equal("Mes devis en attente", vm.Title); + Assert.Equal("Devis en attente de signature fournisseur", vm.Title); Assert.Equal(2, vm.Estimates.Count); } diff --git a/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs index 9d173af3c..02a6c64bd 100644 --- a/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs @@ -4,12 +4,13 @@ using System.Net.Http; using PostIt.Controls; using PostIt.ViewModels; using Yavsc.Api.Client; +using Yavsc.Models.Billing; namespace PostIt.Tests; /// -/// Verifies wires the -/// signature pad + perspective into the right +/// Verifies wires the two +/// signature pads (provider + client) into the right /// call. The view (XAML, rendering) /// is not tested here; the VM is driven through the same /// test hooks the signature tests use, @@ -46,9 +47,10 @@ public class EstimateValidationPageViewModelTests var vm = new EstimateValidationPageViewModel( SampleEstimate(), EstimateListPerspective.Provider, front); - var pad = new SignaturePadControl(); - vm.Attach(pad); - DrawOneStroke(pad); + var proPad = new SignaturePadControl(); + var clientPad = new SignaturePadControl(); + vm.Attach(proPad, clientPad); + DrawOneStroke(proPad); // provider signs on their writable pad await vm.ValidateAsync(); @@ -60,6 +62,28 @@ public class EstimateValidationPageViewModelTests Assert.NotNull(body.Strokes); Assert.NotEmpty(body.Strokes); Assert.Equal(10_000, body.CoordinateMax); + // The provider perspective declares the Pro side, so the + // server stores the signature under the provider's author + // regardless of role inference. + Assert.Equal(SignatureType.Pro, body.SignatureType); + } + + [Fact] + public async Task Validate_from_client_perspective_declares_client_side() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + + var cliPad = new SignaturePadControl(); + vm.Attach(new SignaturePadControl(), cliPad); + DrawOneStroke(cliPad); // client signs on their writable pad + + await vm.ValidateAsync(); + + var body = Assert.IsType(api.LastBody); + Assert.Equal(SignatureType.Client, body.SignatureType); // Client side } [Fact] @@ -70,7 +94,7 @@ public class EstimateValidationPageViewModelTests var vm = new EstimateValidationPageViewModel( SampleEstimate(), EstimateListPerspective.Client, front); - vm.Attach(new SignaturePadControl()); + vm.Attach(new SignaturePadControl(), new SignaturePadControl()); await vm.RejectAsync(); @@ -88,7 +112,7 @@ public class EstimateValidationPageViewModelTests var vm = new EstimateValidationPageViewModel( SampleEstimate(), EstimateListPerspective.Provider, front); - vm.Attach(new SignaturePadControl()); // empty pad + vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads await vm.ValidateAsync(); // no throw: returns before GoBack @@ -104,19 +128,19 @@ public class EstimateValidationPageViewModelTests var vm = new EstimateValidationPageViewModel( SampleEstimate(commandId: null), EstimateListPerspective.Provider, front); - var pad = new SignaturePadControl(); - vm.Attach(pad); + var proPad = new SignaturePadControl(); + vm.Attach(proPad, new SignaturePadControl()); // No CommandId ⇒ cannot validate even after signing. - DrawOneStroke(pad); + DrawOneStroke(proPad); Assert.False(vm.ValidateCommand.CanExecute(null)); var vm2 = new EstimateValidationPageViewModel( SampleEstimate(), EstimateListPerspective.Provider, front); - var pad2 = new SignaturePadControl(); - vm2.Attach(pad2); + var proPad2 = new SignaturePadControl(); + vm2.Attach(proPad2, new SignaturePadControl()); Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet - DrawOneStroke(pad2); + DrawOneStroke(proPad2); Assert.True(vm2.ValidateCommand.CanExecute(null)); } @@ -134,48 +158,67 @@ public class EstimateValidationPageViewModelTests } [Fact] - public void Load_existing_signature_renders_loaded_strokes_in_pad() + public void Only_the_author_pad_is_writable() { - var strokes = new[] { 1, 5000, 5000 }; var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); - var estimate = SampleEstimate(); - estimate.Signatures = new List - { - new() { Type = 0, Strokes = strokes, CoordinateMax = 10_000 }, - }; - var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front); - var pad = new SignaturePadControl(); - vm.Attach(pad); + var proVm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + var proPad = new SignaturePadControl(); + var cliPad = new SignaturePadControl(); + proVm.Attach(proPad, cliPad); + Assert.False(proPad.IsReadOnly); // provider writes the Pro pad + Assert.True(cliPad.IsReadOnly); - var snap = pad.Snapshot(); - Assert.False(snap.IsEmpty); - Assert.Equal(strokes, snap.Strokes); - Assert.True(vm.HasSignature); + var cliVm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + var proPad2 = new SignaturePadControl(); + var cliPad2 = new SignaturePadControl(); + cliVm.Attach(proPad2, cliPad2); + Assert.True(proPad2.IsReadOnly); + Assert.False(cliPad2.IsReadOnly); // client writes the Client pad } [Fact] - public void Load_existing_signature_picks_side_by_perspective() + public void Load_existing_signatures_renders_both_sides_in_their_pads() { var proStrokes = new[] { 1, 1000, 1000 }; var cliStrokes = new[] { 1, 9000, 9000 }; var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); var estimate = SampleEstimate(); - estimate.Signatures = new List - { - new() { Type = 0, Strokes = proStrokes }, - new() { Type = 1, Strokes = cliStrokes }, - }; + estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes }; + estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes }; + + // Client perspective: both pads show the stored signatures, but + // only the client pad is writable. + var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front); + var proPad = new SignaturePadControl(); + var cliPad = new SignaturePadControl(); + vm.Attach(proPad, cliPad); + + Assert.Equal(proStrokes, proPad.Snapshot().Strokes); + Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes); + Assert.True(vm.HasProSignature); + Assert.True(vm.HasClientSignature); + } + + [Fact] + public void Load_existing_pro_signature_renders_in_pro_pad_only() + { + var strokes = new[] { 1, 5000, 5000 }; + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + var estimate = SampleEstimate(); + estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 }; + var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front); var proPad = new SignaturePadControl(); - var proVm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front); - proVm.Attach(proPad); - Assert.Equal(proStrokes, proPad.Snapshot().Strokes); - var cliPad = new SignaturePadControl(); - var cliVm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front); - cliVm.Attach(cliPad); - Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes); + vm.Attach(proPad, cliPad); + + Assert.Equal(strokes, proPad.Snapshot().Strokes); + Assert.True(cliPad.Snapshot().IsEmpty); + Assert.True(vm.HasProSignature); + Assert.False(vm.HasClientSignature); } private sealed class RecordingApi : IYavscApiClient diff --git a/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs b/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs index a3c1e326b..02f6ae149 100644 --- a/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs +++ b/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs @@ -84,6 +84,34 @@ public class FrontOfficeApiClientTests await Assert.ThrowsAsync(() => client.RejectQueryAsync("Rdv", 0)); } + [Fact] + public async Task GetEstimatePdf_gets_estimate_pdf_endpoint() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await client.GetEstimatePdfAsync(42); + + Assert.Equal(HttpMethod.Get, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/42/estimate.pdf", + api.LastPath); + } + + [Fact] + public async Task GetEstimateTex_gets_estimate_tex_endpoint() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await client.GetEstimateTexAsync(42); + + Assert.Equal(HttpMethod.Get, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/42/estimate.tex", + api.LastPath); + } + private sealed class RecordingApi : IYavscApiClient { public HttpClient Http { get; } = new(); @@ -116,6 +144,14 @@ public class FrontOfficeApiClientTests public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) => CallAsync(method, path, (object?)null, ct); + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = null; + return Task.FromResult(Array.Empty()); + } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } \ No newline at end of file diff --git a/src/PostIt/PostIt/Controls/SignaturePadControl.cs b/src/PostIt/PostIt/Controls/SignaturePadControl.cs index 44c467a48..bacb52486 100644 --- a/src/PostIt/PostIt/Controls/SignaturePadControl.cs +++ b/src/PostIt/PostIt/Controls/SignaturePadControl.cs @@ -46,6 +46,21 @@ public class SignaturePadControl : TemplatedControl set => SetValue(CaptureAreaProperty, value); } + /// + /// When true the control ignores pointer input — it only displays + /// strokes loaded into its buffer (e.g. the other party's + /// signature, which must be shown but not editable). + /// and still work. + /// + public static readonly StyledProperty IsReadOnlyProperty = + AvaloniaProperty.Register(nameof(IsReadOnly)); + + public bool IsReadOnly + { + get => GetValue(IsReadOnlyProperty); + set => SetValue(IsReadOnlyProperty, value); + } + /// /// Captured strokes in wire form. Exposed as a read-only view /// over the internal buffer. The buffer only mutates on the UI @@ -109,6 +124,7 @@ public class SignaturePadControl : TemplatedControl private void OnCapturePressed(object? sender, PointerPressedEventArgs e) { + if (IsReadOnly) return; if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return; e.Pointer.Capture(CaptureArea); _capturing = true; diff --git a/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs b/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs index c54954ca1..ff75bf663 100644 --- a/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs +++ b/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs @@ -32,6 +32,11 @@ public partial class SignaturePadView : UserControl private const double StrokeThickness = 2.0; private const double CoordinateMax = 10_000.0; + // Last size the ink layer was painted at. LayoutUpdated repaints + // only when this changes, so adding/removing polyline children + // (which invalidates layout) does not loop. + private Size _lastPaintedSize; + public static readonly StyledProperty CaptureWidthProperty = AvaloniaProperty.Register(nameof(CaptureWidth), 600); @@ -71,6 +76,19 @@ public partial class SignaturePadView : UserControl ApplyFrameSize(); InnerPad.RedrawRequested += OnPadRedrawRequested; + // Repaint once layout completes so an ink buffer loaded before + // the first arrange (e.g. an existing signature on reopen) + // still renders without waiting for a pointer event. The + // handler repaints only on a size change to avoid a layout + // loop (child changes invalidate layout → LayoutUpdated). + LayoutUpdated += OnLayoutUpdated; + } + + private void OnLayoutUpdated(object? sender, EventArgs e) + { + var size = EffectivePadSize(); + if (size != _lastPaintedSize) + Repaint(); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) @@ -101,14 +119,34 @@ public partial class SignaturePadView : UserControl /// Wire-format strokes, forwarded from the inner control. public IReadOnlyList Strokes => InnerPad.Strokes; + /// + /// The effective ink surface size: the arranged bounds when + /// available, otherwise the explicitly sized + /// /. The + /// latter covers a repaint triggered before the first layout pass + /// (e.g. loading an existing signature on reopen), when Bounds is + /// still empty. + /// + private Size EffectivePadSize() + { + var w = PadFrame.Bounds.Width > 0 ? PadFrame.Bounds.Width : PadFrame.Width; + var h = PadFrame.Bounds.Height > 0 ? PadFrame.Bounds.Height : PadFrame.Height; + if (w <= 0 || double.IsNaN(w) || h <= 0 || double.IsNaN(h)) + return default; + return new Size(w, h); + } + private void Repaint() { if (InkLayer is null) return; + var size = EffectivePadSize(); + if (size == default) return; + _lastPaintedSize = size; + InkLayer.Children.Clear(); - var w = PadFrame.Bounds.Width; - var h = PadFrame.Bounds.Height; - if (w <= 0 || h <= 0) return; + var w = size.Width; + var h = size.Height; var pending = InnerPad.PendingStroke; var strokes = InnerPad.Strokes; diff --git a/src/PostIt/PostIt/Helpers/FileSaveHelpers.cs b/src/PostIt/PostIt/Helpers/FileSaveHelpers.cs new file mode 100644 index 000000000..b99a33c87 --- /dev/null +++ b/src/PostIt/PostIt/Helpers/FileSaveHelpers.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform.Storage; + +namespace PostIt.Helpers; + +/// +/// Saves a downloaded byte payload to a file chosen by the user through +/// the platform save picker. This is the first file-save (as opposed to +/// file-open) helper in PostIt; the open picker is already used for blog +/// attachment and avatar uploads +/// (, ). +/// +/// The TopLevel hosting the picker is resolved from the +/// application's root MainView (App.View), which works for +/// both the desktop and single-view lifetimes. +/// +public static class FileSaveHelpers +{ + /// + /// Prompt the user to choose a destination and write + /// to it. Returns false when the user + /// cancels the picker; throws on IO/platform errors so the caller + /// can surface them. + /// + public static async Task SaveAsync( + string suggestedName, + string defaultExtension, + byte[] content, + CancellationToken ct = default) + { + var view = (Application.Current as App)?.View; + var topLevel = view is null ? null : TopLevel.GetTopLevel(view); + if (topLevel is null) + throw new InvalidOperationException("Aucune fenêtre active pour enregistrer le fichier."); + + var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Enregistrer sous", + DefaultExtension = defaultExtension, + SuggestedFileName = suggestedName, + FileTypeChoices = new[] + { + new FilePickerFileType($"{defaultExtension.ToUpper()} file") + { + Patterns = new[] { $"*.{defaultExtension}" } + } + } + }).ConfigureAwait(true); + + if (file is null) + return false; // user cancelled + + await using var stream = await file.OpenWriteAsync().ConfigureAwait(true); + await stream.WriteAsync(content, ct).ConfigureAwait(true); + return true; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 04dbce1fd..555a7c675 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -478,6 +478,41 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable return "Avatar mis à jour."; } + /// + /// Download a binary resource (PDF, TeX, …) from the API. Unlike + /// , + /// which JSON-decodes the response, this returns the raw response + /// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once + /// contract as . + /// + public virtual async Task DownloadAsync( + HttpMethod method, + string path, + CancellationToken ct = default) + { + if (_tokens is null) + throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first."); + + await EnsureFreshTokenAsync(ct).ConfigureAwait(false); + + var sendOnce = async () => + { + using var request = new HttpRequestMessage(method, path); + return await Http.SendAsync(request, ct).ConfigureAwait(false); + }; + + var response = await sendOnce().ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + response.Dispose(); + await ForceRefreshAsync(ct).ConfigureAwait(false); + response = await sendOnce().ConfigureAwait(false); + } + + await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); + return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + } + public async Task LogoutAsync() { _store.Clear(); diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs index fa12fd3cc..dbd84e5c2 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; using PostIt.Helpers; using Yavsc; using Yavsc.Api.Client; @@ -130,11 +131,13 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV try { var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); + var frontClient = app.ServiceProvider?.GetRequiredService(); var vm = new BillingQueryDetailsPageViewModel( Activity, Performer, Form, _billingClient, + frontClient, details, IsReadOnly); await app.PushPageAsync(vm).ConfigureAwait(true); diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs index 362edb088..9ab1b42fd 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs @@ -14,6 +14,7 @@ namespace PostIt.ViewModels; public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel { private readonly BillingApiClient _billingClient; + private readonly FrontOfficeApiClient _frontClient; private readonly BillingQueryDetailsDto _details; public ActivityInfo Activity { get; } @@ -68,6 +69,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient, + FrontOfficeApiClient frontClient, BillingQueryDetailsDto details, bool isReadOnly) { @@ -75,6 +77,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt Performer = performer ?? throw new ArgumentNullException(nameof(performer)); Form = form ?? throw new ArgumentNullException(nameof(form)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); + _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); _details = details ?? throw new ArgumentNullException(nameof(details)); IsReadOnly = isReadOnly; @@ -131,6 +134,53 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt await app.GoBackAsync().ConfigureAwait(true); } + /// + /// Download the estimate linked to this command as a LaTeX source + /// (GET api/v1/front/query/{Id}/estimate.tex). The estimate is + /// resolved server-side from Estimate.CommandId == query Id. + /// + [RelayCommand] + public async Task DownloadTexAsync() + { + await DownloadAsync( + () => _frontClient.GetEstimateTexAsync(_details.Id), + $"devis-{_details.Id}", "tex").ConfigureAwait(true); + } + + /// + /// Download the estimate linked to this command as a compiled PDF + /// (GET api/v1/front/query/{Id}/estimate.pdf). Requires + /// texi2pdf on the server host. + /// + [RelayCommand] + public async Task DownloadPdfAsync() + { + await DownloadAsync( + () => _frontClient.GetEstimatePdfAsync(_details.Id), + $"devis-{_details.Id}", "pdf").ConfigureAwait(true); + } + + private async Task DownloadAsync(Func> fetch, string fileName, string ext) + { + IsBusy = true; + try + { + var bytes = await fetch().ConfigureAwait(true); + var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true); + this.SetInfoStatus(saved + ? $"Fichier .{ext} enregistré." + : "Téléchargement annulé."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléchargement: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + private static string EmptyAsPlaceholder(string? value, string placeholder) => string.IsNullOrWhiteSpace(value) ? placeholder : value; diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs index 64db04bd9..c90a6d884 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs @@ -19,10 +19,10 @@ namespace PostIt.ViewModels; /// public enum EstimateListPerspective { - /// « Mes devis à valider » — l'utilisateur courant est le client. + /// « Devis en attente de signature client » — l'utilisateur courant est le client. Client, - /// « Mes devis en attente » — l'utilisateur courant est le fournisseur. + /// « Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur. Provider, } @@ -40,8 +40,8 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie public EstimateListPerspective Perspective => _perspective; public string Title => _perspective == EstimateListPerspective.Client - ? "Mes devis à valider" - : "Mes devis en attente"; + ? "Devis en attente de signature client" + : "Devis en attente de signature fournisseur"; public string Subtitle => _perspective == EstimateListPerspective.Client ? "Point de vue client" diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs index a0cae5080..87347c2ce 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs @@ -10,27 +10,34 @@ using PostIt.Controls; using PostIt.Helpers; using PostIt.Models; using Yavsc.Api.Client; +using Yavsc.Models.Billing; namespace PostIt.ViewModels; /// -/// Validation (accept / reject) of a single estimate, with an optional -/// signature. Backs the estimate validation page opened from +/// Validation (accept / reject) of a single estimate, with optional +/// signatures. Backs the estimate validation page opened from /// for either perspective. /// -/// The server (POST api/v1/front/query/accept) resolves the -/// caller's role from the authenticated user compared to the query's -/// PerformerId/ClientId; the perspective passed in here -/// is only a UI hint (the ). The billing code -/// sent to the server is the estimate's -/// and the query id is its . +/// Two signatures are shown, each in its own pad: the provider's +/// (Pro) and the client's (Client). Both are rendered from the estimate +/// payload when present, so reopening a partly-signed estimate shows +/// what was already captured. Only the pad matching the current +/// is writable — the server +/// (POST api/v1/front/query/accept) independently enforces the +/// same rule (it resolves the caller's role from the authenticated user +/// vs the query's PerformerId/ClientId), and the UI marks +/// the other pad read-only. The billing code sent to the server is the +/// estimate's and the query id is +/// its . /// public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel { private readonly EstimateDto _estimate; private readonly EstimateListPerspective _perspective; private readonly FrontOfficeApiClient _frontClient; - private SignaturePadControl? _pad; + private SignaturePadControl? _proPad; + private SignaturePadControl? _clientPad; public string Title => _estimate.Title; public string Description => _estimate.Description; @@ -44,9 +51,22 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta ? "Vous validez en tant que prestataire" : "Vous validez en tant que client"; + /// + /// True when the current user may sign the provider's signature + /// (i.e. they opened the list as the provider). The other party's + /// pad is read-only. + /// + public bool CanWritePro => _perspective == EstimateListPerspective.Provider; + + /// + /// True when the current user may sign the client's signature + /// (i.e. they opened the list as the client). + /// + public bool CanWriteClient => _perspective == EstimateListPerspective.Client; + /// /// Human-readable summary of who has already validated this - /// estimate, shown above the signature pad. + /// estimate, shown above the signature pads. /// public string ValidationStateText { @@ -78,15 +98,29 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta [ObservableProperty] public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); + /// Stroke/point counts for the writable pad. [ObservableProperty] public partial int StrokeCount { get; set; } [ObservableProperty] public partial int PointCount { get; set; } + /// True when the writable pad holds a signature. [ObservableProperty] public partial bool HasSignature { get; set; } + [ObservableProperty] + public partial bool HasProSignature { get; set; } + + [ObservableProperty] + public partial bool HasClientSignature { get; set; } + + [ObservableProperty] + public partial string ProSignatureHint { get; set; } = string.Empty; + + [ObservableProperty] + public partial string ClientSignatureHint { get; set; } = string.Empty; + public override bool CanNavigateNext { get => false; @@ -110,58 +144,80 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta } /// - /// Bind the page's signature pad control (the inner control of the - /// page's ). Called from the view's - /// code-behind once the view is in the visual tree. Also calls - /// so a previously drawn - /// signature — carried on the estimate payload — is rendered back - /// into the pad on reopen. + /// Bind the page's two signature pads (the inner controls of the + /// page's s): the provider's pad and + /// the client's pad. Called from the view's code-behind once the + /// view is in the visual tree. Marks the non-author pad read-only, + /// then renders any signatures already carried on the estimate + /// payload. /// - public void Attach(SignaturePadControl pad) + public void Attach(SignaturePadControl proPad, SignaturePadControl clientPad) { - if (pad is null) throw new ArgumentNullException(nameof(pad)); + if (proPad is null) throw new ArgumentNullException(nameof(proPad)); + if (clientPad is null) throw new ArgumentNullException(nameof(clientPad)); Detach(); - _pad = pad; - _pad.RedrawRequested += OnPadRedraw; + _proPad = proPad; + _clientPad = clientPad; + _proPad.IsReadOnly = !CanWritePro; + _clientPad.IsReadOnly = !CanWriteClient; + _proPad.RedrawRequested += OnPadRedraw; + _clientPad.RedrawRequested += OnPadRedraw; + LoadExistingSignatures(); RefreshFromPad(); - LoadExistingSignature(); } public void Detach() { - if (_pad is null) return; - _pad.RedrawRequested -= OnPadRedraw; - _pad = null; + if (_proPad is not null) + _proPad.RedrawRequested -= OnPadRedraw; + if (_clientPad is not null) + _clientPad.RedrawRequested -= OnPadRedraw; + _proPad = null; + _clientPad = null; } private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad(); + /// The pad the current user may sign in. + private SignaturePadControl? WritablePad => CanWritePro ? _proPad : _clientPad; + /// - /// Render the signature already stored for this side of the - /// estimate back into the pad. The strokes are carried on the - /// estimate payload (); the - /// side is chosen by the current — the - /// provider reopens to see their "Pro" signature, the client their - /// "Client" signature. No network round-trip: the payload is used - /// as-is, in the same wire format PostIt captured. + /// Render the signatures already stored for this estimate back into + /// their pads. The strokes are carried on the estimate payload as + /// two dedicated slots — and + /// — so each pad loads + /// its own side directly, in the same wire format PostIt captured. + /// No network round-trip. /// - public void LoadExistingSignature() + public void LoadExistingSignatures() { - if (_pad is null) return; - // SignatureType.Pro = 0, SignatureType.Client = 1 (server enum). - int wanted = _perspective == EstimateListPerspective.Provider ? 0 : 1; - var sig = _estimate.Signatures?.FirstOrDefault(s => s.Type == wanted); - if (sig?.Strokes is { Length: > 0 } strokes) - { - _pad.Load(strokes); - RefreshFromPad(); - } + LoadSide(_proPad, _estimate.SignaturePro); + LoadSide(_clientPad, _estimate.SignatureClient); + RefreshFromPad(); + } + + private static void LoadSide(SignaturePadControl? pad, EstimateSignatureDto? sig) + { + if (pad is null || sig is null) return; + if (sig.Strokes is { Length: > 0 } strokes) + pad.Load(strokes); } private void RefreshFromPad() { - if (_pad is null) return; - var snap = _pad.Snapshot(); + HasProSignature = _proPad?.Snapshot().IsEmpty == false; + HasClientSignature = _clientPad?.Snapshot().IsEmpty == false; + + ProSignatureHint = CanWritePro + ? (HasProSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que prestataire") + : (HasProSignature ? "Signature du prestataire (lecture seule)" : "En attente de signature du prestataire"); + ClientSignatureHint = CanWriteClient + ? (HasClientSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que client") + : (HasClientSignature ? "Signature du client (lecture seule)" : "En attente de signature du client"); + + var pad = WritablePad; + if (pad is null) return; + var snap = pad.Snapshot(); StrokeCount = snap.StrokeCount; PointCount = snap.PointCount; HasSignature = !snap.IsEmpty; @@ -188,13 +244,14 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être validé."); return; } - if (_pad is null || _pad.Snapshot().IsEmpty) + var pad = WritablePad; + if (pad is null || pad.Snapshot().IsEmpty) { this.SetWarningStatus("Veuillez signer avant de valider."); return; } - var snap = _pad.Snapshot(); + var snap = pad.Snapshot(); IsBusy = true; try { @@ -203,6 +260,10 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta Strokes = snap.Strokes, CoordinateMax = SignaturePadData.CoordinateMax, CapturedAtUtc = DateTime.UtcNow, + // Declare which side the caller is signing as, so the + // server stores the signature under the right author + // (Pro/Client) even when one user is both parties. + SignatureType = CanWritePro ? SignatureType.Pro : SignatureType.Client, }; await _frontClient.AcceptQueryAsync(_estimate.CommandType, queryId, body) @@ -264,10 +325,65 @@ public partial class EstimateValidationPageViewModel : ViewModelBase, IActionSta [RelayCommand] public void ClearSignature() { - _pad?.Clear(); + WritablePad?.Clear(); RefreshFromPad(); } + /// + /// Download the estimate as a LaTeX source document. The server + /// renders the Estimate_tex template to a .tex byte stream + /// (GET api/v1/front/query/{CommandId}/estimate.tex); the + /// user picks a destination via the platform save picker. + /// + [RelayCommand] + public async Task DownloadTexAsync() + { + await DownloadAsync( + () => _frontClient.GetEstimateTexAsync(_estimate.CommandId!.Value), + $"devis-{EstimateId}", "tex").ConfigureAwait(true); + } + + /// + /// Download the estimate as a compiled PDF + /// (GET api/v1/front/query/{CommandId}/estimate.pdf). + /// Requires texi2pdf on the server host; a failure is + /// surfaced as a status message. + /// + [RelayCommand] + public async Task DownloadPdfAsync() + { + await DownloadAsync( + () => _frontClient.GetEstimatePdfAsync(_estimate.CommandId!.Value), + $"devis-{EstimateId}", "pdf").ConfigureAwait(true); + } + + private async Task DownloadAsync(Func> fetch, string fileName, string ext) + { + if (_estimate.CommandId is not long) + { + this.SetWarningStatus("Ce devis n'est pas lié à une demande."); + return; + } + + IsBusy = true; + try + { + var bytes = await fetch().ConfigureAwait(true); + var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true); + this.SetInfoStatus(saved + ? $"Fichier .{ext} enregistré." + : "Téléchargement annulé."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléchargement: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + [RelayCommand] private async Task BackAsync() => await GoBackAsync().ConfigureAwait(true); diff --git a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs index 360476152..75a5bdc9a 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; using PostIt.Helpers; using Yavsc; using Yavsc.Abstract.Workflow; @@ -153,12 +154,14 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi .GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id) .ConfigureAwait(true); var (activity, performer, form) = BuildNavigationContext(SelectedQuery); + var frontClient = app.ServiceProvider?.GetRequiredService(); var vm = new BillingQueryDetailsPageViewModel( activity, performer, form, _billingClient, + frontClient, details, isReadOnly: false); diff --git a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml index 82231ad83..904f17350 100644 --- a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml +++ b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml @@ -14,7 +14,7 @@ - +