From 36ffe4683c42735a4cc4f3b6591e71ba65f127e8 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 21 Sep 2026 17:33:18 +0100 Subject: [PATCH 01/12] PostIt: validate/refuse an estimate with a signature (pro or client) Adds the client-side ability to open an estimate from the list, draw a signature on a reusable SignaturePadView control, and accept or reject it against /api/v1/front/query/accept|reject. The server resolves the actor role (provider vs client) from the caller's identity, so the same page works for either party. - Yavsc.Api.Client: QueryAcceptanceDto + FrontOfficeApiClient (accept/reject) - PostIt: extract SignaturePadView UserControl from SignaturePage; new EstimateValidationPage (VM + view) with Valider/Refuser/Effacer/Retour; wire OpenEstimate entry point from EstimateListPage; register client/page in DI + ViewLocator - Tests: FrontOfficeApiClient + EstimateValidationPageViewModel unit tests; update EstimateListPageViewModelTests ctor Co-Authored-By: Claude Code --- .../EstimateListPageViewModelTests.cs | 12 +- .../EstimateValidationPageViewModelTests.cs | 166 ++++++++++++ .../PostIt.Tests/FrontOfficeApiClientTests.cs | 121 +++++++++ .../PostIt/Controls/SignaturePadView.axaml | 28 ++ .../PostIt/Controls/SignaturePadView.axaml.cs | 168 ++++++++++++ .../Helpers/ServiceCollectionHelpers.cs | 3 + src/PostIt/PostIt/ViewLocator.cs | 1 + .../Activity/EstimateListPageViewModel.cs | 29 +- .../EstimateValidationPageViewModel.cs | 256 ++++++++++++++++++ .../ViewModels/Layout/HomePageViewModel.cs | 8 +- .../Signature/SignaturePageViewModel.cs | 10 +- .../Views/Activity/EstimateListPage.axaml | 60 ++-- .../Activity/EstimateValidationPage.axaml | 126 +++++++++ .../Activity/EstimateValidationPage.axaml.cs | 32 +++ .../Views/Signature/SignaturePage.axaml | 30 +- .../Views/Signature/SignaturePage.axaml.cs | 98 +------ .../Dtos/QueryAcceptanceDto.cs | 44 +++ src/Yavsc.Api.Client/FrontOfficeApiClient.cs | 108 ++++++++ 18 files changed, 1149 insertions(+), 151 deletions(-) create mode 100644 src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs create mode 100644 src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs create mode 100644 src/PostIt/PostIt/Controls/SignaturePadView.axaml create mode 100644 src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs create mode 100644 src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs create mode 100644 src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml create mode 100644 src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml.cs create mode 100644 src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs create mode 100644 src/Yavsc.Api.Client/FrontOfficeApiClient.cs diff --git a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs index 6158b4132..ed66f11e4 100644 --- a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs @@ -11,7 +11,8 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front); await vm.InitializeAsync(); @@ -28,7 +29,8 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front); await vm.InitializeAsync(); @@ -42,7 +44,8 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front); await vm.InitializeAsync(); @@ -63,7 +66,8 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi { Empty = true }; var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front); await vm.InitializeAsync(); diff --git a/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs new file mode 100644 index 000000000..27e4456f7 --- /dev/null +++ b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs @@ -0,0 +1,166 @@ +using System; +using System.Net.Http; +using PostIt.Controls; +using PostIt.ViewModels; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +/// +/// Verifies wires the +/// signature pad + perspective 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, +/// and the API client through a recording fake +/// . The test host's +/// TestApp makes GoBackAsync a no-op, so the success paths +/// run to completion without navigation side effects. +/// +public class EstimateValidationPageViewModelTests +{ + private static EstimateDto SampleEstimate(long? commandId = 42) => new() + { + Id = 7, + CommandId = commandId, + CommandType = "Rdv", + Title = "Devis plomberie", + Description = "Réparation", + ClientId = "bob", + OwnerId = "alice", + }; + + private static void DrawOneStroke(SignaturePadControl pad) + { + pad.AppendPointForTest(100, 200); + pad.AppendPointForTest(300, 400); + pad.SealStrokeForTest(); + } + + [Fact] + public async Task Validate_calls_accept_with_strokes_and_command_id() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + + var pad = new SignaturePadControl(); + vm.Attach(pad); + DrawOneStroke(pad); + + await vm.ValidateAsync(); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Contains("front/query/accept", api.LastPath); + Assert.Contains("billingCode=Rdv", api.LastPath); + Assert.Contains("queryId=42", api.LastPath); + var body = Assert.IsType(api.LastBody); + Assert.NotNull(body.Strokes); + Assert.NotEmpty(body.Strokes); + Assert.Equal(10_000, body.CoordinateMax); + } + + [Fact] + public async Task Reject_calls_reject_endpoint_with_no_body() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + + vm.Attach(new SignaturePadControl()); + + await vm.RejectAsync(); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Contains("front/query/reject", api.LastPath); + Assert.Contains("queryId=42", api.LastPath); + Assert.Null(api.LastBody); + } + + [Fact] + public async Task Validate_without_signature_warns_and_does_not_call() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + + vm.Attach(new SignaturePadControl()); // empty pad + + await vm.ValidateAsync(); // no throw: returns before GoBack + + Assert.Null(api.LastMethod); + Assert.Contains("signer", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Validate_command_gates_on_signature_and_command() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(commandId: null), EstimateListPerspective.Provider, front); + + var pad = new SignaturePadControl(); + vm.Attach(pad); + + // No CommandId ⇒ cannot validate even after signing. + DrawOneStroke(pad); + Assert.False(vm.ValidateCommand.CanExecute(null)); + + var vm2 = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + var pad2 = new SignaturePadControl(); + vm2.Attach(pad2); + Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet + DrawOneStroke(pad2); + Assert.True(vm2.ValidateCommand.CanExecute(null)); + } + + [Fact] + public void RoleLabel_reflects_perspective() + { + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + var pro = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + var cli = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + + Assert.Contains("prestataire", pro.RoleLabel, StringComparison.OrdinalIgnoreCase); + Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase); + } + + private sealed class RecordingApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public HttpMethod? LastMethod { get; private set; } + public string? LastPath { get; private set; } + public object? LastBody { get; private set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + return Task.CompletedTask; + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs b/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs new file mode 100644 index 000000000..a3c1e326b --- /dev/null +++ b/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs @@ -0,0 +1,121 @@ +using System.Net.Http; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +/// +/// Verifies issues the right +/// api/v1/front/query/accept / .../reject requests with +/// the PostIt signature payload, using a recording fake +/// (no network). +/// +public class FrontOfficeApiClientTests +{ + private static QueryAcceptanceRequestDto SampleBody() => new() + { + Strokes = new[] { 2, 100, 200, 300, 400 }, + CoordinateMax = 10_000, + CapturedAtUtc = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc), + }; + + [Fact] + public async Task Accept_posts_to_accept_endpoint_with_signature_body() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + var body = SampleBody(); + await client.AcceptQueryAsync("Rdv", 42, body); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/accept?billingCode=Rdv&queryId=42", + api.LastPath); + Assert.Same(body, api.LastBody); + } + + [Fact] + public async Task Reject_posts_to_reject_endpoint_with_no_body() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await client.RejectQueryAsync("Rdv", 42); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/reject?billingCode=Rdv&queryId=42", + api.LastPath); + Assert.Null(api.LastBody); + } + + [Fact] + public async Task Accept_returns_deserialized_response() + { + var api = new RecordingApi + { + Response = new QueryAcceptanceResponseDto + { + QueryId = 42, + Status = "Accepted", + Signature = new QuerySignatureRefDto { Id = 9, EstimateId = 5, Type = "Pro" }, + }, + }; + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + var response = await client.AcceptQueryAsync("Rdv", 42, SampleBody()); + + Assert.Equal(42, response.QueryId); + Assert.Equal("Accepted", response.Status); + Assert.NotNull(response.Signature); + Assert.Equal("Pro", response.Signature!.Type); + } + + [Fact] + public async Task Accept_rejects_invalid_arguments() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await Assert.ThrowsAsync(() => client.AcceptQueryAsync("", 42, SampleBody())); + await Assert.ThrowsAsync(() => client.AcceptQueryAsync("Rdv", 0, SampleBody())); + await Assert.ThrowsAsync(() => client.AcceptQueryAsync("Rdv", 42, null!)); + await Assert.ThrowsAsync(() => client.RejectQueryAsync("", 42)); + await Assert.ThrowsAsync(() => client.RejectQueryAsync("Rdv", 0)); + } + + private sealed class RecordingApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public HttpMethod? LastMethod { get; private set; } + public string? LastPath { get; private set; } + public object? LastBody { get; private set; } + public QueryAcceptanceResponseDto? Response { get; set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + + var response = Response ?? new QueryAcceptanceResponseDto(); + return Task.FromResult((T)(object)response); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + return Task.CompletedTask; + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Controls/SignaturePadView.axaml b/src/PostIt/PostIt/Controls/SignaturePadView.axaml new file mode 100644 index 000000000..cb0a99de1 --- /dev/null +++ b/src/PostIt/PostIt/Controls/SignaturePadView.axaml @@ -0,0 +1,28 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs b/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs new file mode 100644 index 000000000..c54954ca1 --- /dev/null +++ b/src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Media; +using PostIt.Models; + +namespace PostIt.Controls; + +/// +/// Reusable signature-capture surface: wraps the render-agnostic +/// in a sized +/// (the hit-test region) with an overlay that +/// the code-behind repaints from the control's stroke buffer. Owns +/// the ink-rendering logic so every page that captures a signature +/// (the dev-only SignaturePage, the estimate validation page, +/// ...) shares one implementation. +/// +/// Hosts bind / +/// (DIPs, defaults 600×200) to size the surface. View models attach +/// to the inner control (exposed here) to +/// read snapshots, drive Clear, and subscribe to +/// / +/// — the same +/// contract the headless tests use, since a +/// needs its XAML loaded to be drivable. +/// +public partial class SignaturePadView : UserControl +{ + private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10)); + private const double StrokeThickness = 2.0; + private const double CoordinateMax = 10_000.0; + + public static readonly StyledProperty CaptureWidthProperty = + AvaloniaProperty.Register(nameof(CaptureWidth), 600); + + public static readonly StyledProperty CaptureHeightProperty = + AvaloniaProperty.Register(nameof(CaptureHeight), 200); + + public double CaptureWidth + { + get => GetValue(CaptureWidthProperty); + set => SetValue(CaptureWidthProperty, value); + } + + public double CaptureHeight + { + get => GetValue(CaptureHeightProperty); + set => SetValue(CaptureHeightProperty, value); + } + + /// + /// The underlying capture control. View models attach to this to + /// read / call + /// and subscribe to its + /// events; this view repaints the ink layer from the same buffer. + /// + public SignaturePadControl SignaturePad => InnerPad; + + public SignaturePadView() + { + InitializeComponent(); + + // Wire the capture area: the Pad is the control, the + // surrounding Border (PadFrame) is the hit-test region. For an + // inline control with no template, capture wiring happens on + // first measure, which is guaranteed before the user can + // interact, so attaching here is safe. + InnerPad.CaptureArea = PadFrame; + ApplyFrameSize(); + + InnerPad.RedrawRequested += OnPadRedrawRequested; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == CaptureWidthProperty || change.Property == CaptureHeightProperty) + { + ApplyFrameSize(); + Repaint(); + } + } + + private void ApplyFrameSize() + { + if (PadFrame is null) return; + PadFrame.Width = Math.Max(1, CaptureWidth); + PadFrame.Height = Math.Max(1, CaptureHeight); + } + + private void OnPadRedrawRequested(object? sender, EventArgs e) => Repaint(); + + /// Defensive copy of the current buffer. + public SignaturePadData Snapshot() => InnerPad.Snapshot(); + + /// Forget every captured stroke. + public void Clear() => InnerPad.Clear(); + + /// Wire-format strokes, forwarded from the inner control. + public IReadOnlyList Strokes => InnerPad.Strokes; + + private void Repaint() + { + if (InkLayer is null) return; + + InkLayer.Children.Clear(); + var w = PadFrame.Bounds.Width; + var h = PadFrame.Bounds.Height; + if (w <= 0 || h <= 0) return; + + var pending = InnerPad.PendingStroke; + var strokes = InnerPad.Strokes; + int sealedCount = strokes.Count - pending.Count; + if (sealedCount < 0) + { + sealedCount = 0; + } + + int i = 0; + while (i < sealedCount) + { + int k = strokes[i]; + if (k <= 0) break; + i++; // skip the length prefix + + var poly = new Polyline + { + Stroke = StrokeBrush, + StrokeThickness = StrokeThickness, + StrokeLineCap = PenLineCap.Round, + StrokeJoin = PenLineJoin.Round, + }; + var pts = new List(k); + for (int p = 0; p < k; p++) + { + int nx = strokes[i++]; + int ny = strokes[i++]; + pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h)); + } + poly.Points = pts; + InkLayer.Children.Add(poly); + } + + if (pending.Count > 0) + { + var poly = new Polyline + { + Stroke = StrokeBrush, + StrokeThickness = StrokeThickness, + StrokeLineCap = PenLineCap.Round, + StrokeJoin = PenLineJoin.Round, + }; + + var pts = new List(pending.Count / 2); + for (int p = 0; p < pending.Count; p += 2) + { + int nx = pending[p]; + int ny = pending[p + 1]; + pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h)); + } + + poly.Points = pts; + InkLayer.Children.Add(poly); + } + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index bc5e7298f..2b243f2d5 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -31,6 +31,7 @@ public static class ServiceCollectionHelpers () => settings.Authentication?.Authority); var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl); + var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl); var userDirectory = new UserDirectory(userSearchClient); var reverseGeocoding = new NominatimReverseGeocodingService(); @@ -63,6 +64,7 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); @@ -74,6 +76,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(activityClient); services.AddSingleton(billingClient); services.AddSingleton(estimateClient); + services.AddSingleton(frontClient); services.AddSingleton(reverseGeocoding); services.AddSingleton(userDirectory); services.AddSingleton(); diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 11797b714..ca9a7ff65 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -55,6 +55,7 @@ public class ViewLocator : IDataTemplate ProviderOngoingRequestsPageViewModel => services.GetRequiredService(), EstimateEditionPageViewModel => services.GetRequiredService(), EstimateListPageViewModel => services.GetRequiredService(), + EstimateValidationPageViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs index e5c4d355f..64db04bd9 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; +using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using PostIt.Helpers; @@ -32,6 +33,7 @@ public enum EstimateListPerspective public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel { private readonly EstimateApiClient _estimateClient; + private readonly FrontOfficeApiClient _frontClient; private readonly EstimateListPerspective _perspective; private List _allEstimates = new(); @@ -83,10 +85,14 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie protected set { _ = value; } } - public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective) + public EstimateListPageViewModel( + EstimateApiClient estimateClient, + EstimateListPerspective perspective, + FrontOfficeApiClient frontClient) { _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); _perspective = perspective; + _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); } public Task InitializeAsync() => RefreshAsync(); @@ -153,4 +159,25 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie private static bool ContainsInsensitive(string? value, string query) => !string.IsNullOrWhiteSpace(value) && value.Contains(query, StringComparison.OrdinalIgnoreCase); + + /// + /// Open the estimate validation (accept/reject with signature) + /// page for a specific estimate. The perspective the list was + /// opened in is forwarded as the actor-role UI hint; the server + /// independently enforces the real authorization. + /// + [RelayCommand] + private async Task OpenEstimateAsync(EstimateDto? estimate) + { + if (estimate is null) return; + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var vm = new EstimateValidationPageViewModel(estimate, _perspective, _frontClient); + await app.PushPageAsync(vm).ConfigureAwait(true); + } } diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs new file mode 100644 index 000000000..3b80c051a --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateValidationPageViewModel.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Controls; +using PostIt.Helpers; +using PostIt.Models; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +/// +/// Validation (accept / reject) of a single estimate, with an optional +/// signature. 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 . +/// +public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel +{ + private readonly EstimateDto _estimate; + private readonly EstimateListPerspective _perspective; + private readonly FrontOfficeApiClient _frontClient; + private SignaturePadControl? _pad; + + public string Title => _estimate.Title; + public string Description => _estimate.Description; + public long EstimateId => _estimate.Id; + public string CommandType => _estimate.CommandType; + public IReadOnlyList Bill => _estimate.Bill; + public DateTime ProviderValidationDate => _estimate.ProviderValidationDate; + public DateTime ClientValidationDate => _estimate.ClientValidationDate; + + public string RoleLabel => _perspective == EstimateListPerspective.Provider + ? "Vous validez en tant que prestataire" + : "Vous validez en tant que client"; + + /// + /// Human-readable summary of who has already validated this + /// estimate, shown above the signature pad. + /// + public string ValidationStateText + { + get + { + var parts = new List(); + if (ProviderValidationDate != default) + parts.Add($"Prestataire validé le {ProviderValidationDate.ToLocalTime():g}"); + if (ClientValidationDate != default) + parts.Add($"Client validé le {ClientValidationDate.ToLocalTime():g}"); + return parts.Count == 0 ? "Devis en attente de validation." : string.Join(" · ", parts); + } + } + + /// + /// True when the estimate has a linked query to accept/reject. + /// Estimates without a are + /// templates and cannot be validated through the front-office + /// endpoint. + /// + public bool HasCommand => _estimate.CommandId is not null; + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Prêt."; + + [ObservableProperty] + public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); + + [ObservableProperty] + public partial int StrokeCount { get; set; } + + [ObservableProperty] + public partial int PointCount { get; set; } + + [ObservableProperty] + public partial bool HasSignature { get; set; } + + public override bool CanNavigateNext + { + get => false; + protected set { _ = value; } + } + + public override bool CanNavigatePrevious + { + get => true; + protected set { _ = value; } + } + + public EstimateValidationPageViewModel( + EstimateDto estimate, + EstimateListPerspective perspective, + FrontOfficeApiClient frontClient) + { + _estimate = estimate ?? throw new ArgumentNullException(nameof(estimate)); + _perspective = perspective; + _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); + } + + /// + /// 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. + /// + public void Attach(SignaturePadControl pad) + { + if (pad is null) throw new ArgumentNullException(nameof(pad)); + Detach(); + _pad = pad; + _pad.RedrawRequested += OnPadRedraw; + RefreshFromPad(); + } + + public void Detach() + { + if (_pad is null) return; + _pad.RedrawRequested -= OnPadRedraw; + _pad = null; + } + + private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad(); + + private void RefreshFromPad() + { + if (_pad is null) return; + var snap = _pad.Snapshot(); + StrokeCount = snap.StrokeCount; + PointCount = snap.PointCount; + HasSignature = !snap.IsEmpty; + } + + partial void OnIsBusyChanged(bool value) + { + ValidateCommand.NotifyCanExecuteChanged(); + RejectCommand.NotifyCanExecuteChanged(); + } + + partial void OnHasSignatureChanged(bool value) + => ValidateCommand.NotifyCanExecuteChanged(); + + private bool CanValidate => !IsBusy && HasSignature && HasCommand; + + private bool CanReject => !IsBusy && HasCommand; + + [RelayCommand(CanExecute = nameof(CanValidate))] + public async Task ValidateAsync() + { + if (_estimate.CommandId is not long queryId) + { + this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être validé."); + return; + } + if (_pad is null || _pad.Snapshot().IsEmpty) + { + this.SetWarningStatus("Veuillez signer avant de valider."); + return; + } + + var snap = _pad.Snapshot(); + IsBusy = true; + try + { + var body = new QueryAcceptanceRequestDto + { + Strokes = snap.Strokes, + CoordinateMax = SignaturePadData.CoordinateMax, + CapturedAtUtc = DateTime.UtcNow, + }; + + await _frontClient.AcceptQueryAsync(_estimate.CommandType, queryId, body) + .ConfigureAwait(true); + + this.SetInfoStatus("Devis validé."); + await GoBackAsync().ConfigureAwait(true); + } + catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden) + { + this.SetWarningStatus("Vous n'êtes pas partie à ce devis."); + } + catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.BadRequest) + { + this.SetWarningStatus($"Validation refusée: {ex.Message}"); + } + catch (Exception ex) + { + this.SetErrorStatus($"Erreur: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand(CanExecute = nameof(CanReject))] + public async Task RejectAsync() + { + if (_estimate.CommandId is not long queryId) + { + this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être refusé."); + return; + } + + IsBusy = true; + try + { + await _frontClient.RejectQueryAsync(_estimate.CommandType, queryId) + .ConfigureAwait(true); + + this.SetInfoStatus("Devis refusé."); + await GoBackAsync().ConfigureAwait(true); + } + catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden) + { + this.SetWarningStatus("Vous n'êtes pas partie à ce devis."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Erreur: {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + public void ClearSignature() + { + _pad?.Clear(); + RefreshFromPad(); + } + + [RelayCommand] + private async Task BackAsync() => await GoBackAsync().ConfigureAwait(true); + + private static async Task GoBackAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + await app.GoBackAsync().ConfigureAwait(true); + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index ea92f1d0a..9d466b26c 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -90,7 +90,13 @@ public class HomePageViewModel : ViewModelBase throw new InvalidOperationException("Client devis indisponible."); } - var vm = new EstimateListPageViewModel(estimateClient, perspective); + var frontClient = app.ServiceProvider?.GetRequiredService(); + if (frontClient is null) + { + throw new InvalidOperationException("Client front-office indisponible."); + } + + var vm = new EstimateListPageViewModel(estimateClient, perspective, frontClient); await vm.InitializeAsync(); await app.PushPageAsync(vm); } diff --git a/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs index 23ba70cf9..1e7df9c60 100644 --- a/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Signature/SignaturePageViewModel.cs @@ -85,10 +85,12 @@ public partial class SignaturePageViewModel : ViewModelBase, IActionStatusViewMo } /// - /// Bind a freshly-constructed (or re-templated) control to this - /// VM. Called from the view's code-behind once the control has - /// been added to the visual tree and its template applied (so - /// is wired). + /// Bind the inner of the page's + /// to this VM. Called from the + /// page's code-behind once the view is in the visual tree (so the + /// control's is + /// wired). Attaching to the control directly (rather than the + /// view) keeps the same contract the headless tests use. /// public void Attach(SignaturePadControl control) { diff --git a/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml index d5055dd1f..3fdb4fc7b 100644 --- a/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml +++ b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml @@ -29,34 +29,40 @@ CornerRadius="8" Padding="10" Margin="0,0,0,8"> - - - + + + + - - - + + + +