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">
-
-
-
+
+
+
+
-
-
-
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml b/src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml
new file mode 100644
index 000000000..b015bed8b
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml.cs
new file mode 100644
index 000000000..8411c233f
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml.cs
@@ -0,0 +1,32 @@
+using Avalonia.Controls;
+using PostIt.Controls;
+using PostIt.ViewModels;
+
+namespace PostIt.Views;
+
+public partial class EstimateValidationPage : ContentPage
+{
+ private EstimateValidationPageViewModel? _vm;
+
+ public EstimateValidationPage()
+ {
+ InitializeComponent();
+
+ DataContextChanged += (_, _) => RebindViewModel(DataContext as EstimateValidationPageViewModel);
+ Unloaded += (_, _) => _vm?.Detach();
+ }
+
+ private void RebindViewModel(EstimateValidationPageViewModel? vm)
+ {
+ if (_vm is not null)
+ {
+ _vm.Detach();
+ }
+
+ _vm = vm;
+
+ if (_vm is null || Pad is null) return;
+
+ _vm.Attach(Pad.SignaturePad);
+ }
+}
\ No newline at end of file
diff --git a/src/PostIt/PostIt/Views/Signature/SignaturePage.axaml b/src/PostIt/PostIt/Views/Signature/SignaturePage.axaml
index 021208b54..74d26241e 100644
--- a/src/PostIt/PostIt/Views/Signature/SignaturePage.axaml
+++ b/src/PostIt/PostIt/Views/Signature/SignaturePage.axaml
@@ -29,29 +29,15 @@
FontWeight="Bold" />
-
-
-
-
-
-
+
RebindViewModel(DataContext as SignaturePageViewModel);
}
@@ -39,82 +23,12 @@ public partial class SignaturePage : ContentPage
if (_vm is not null)
{
_vm.Detach();
- _control!.RedrawRequested -= OnRedrawRequested;
}
_vm = vm;
- if (_vm is null || _control is null) return;
+ if (_vm is null || Pad is null) return;
- _vm.Attach(_control);
- _control.RedrawRequested += OnRedrawRequested;
- Repaint();
+ _vm.Attach(Pad.SignaturePad);
}
-
- private void OnRedrawRequested(object? sender, EventArgs e) => Repaint();
-
- private void Repaint()
- {
- if (_control is null || 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 = _control.PendingStroke;
- var strokes = _control.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/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs b/src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs
new file mode 100644
index 000000000..71d8c28df
--- /dev/null
+++ b/src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs
@@ -0,0 +1,44 @@
+using System;
+
+namespace Yavsc.Api.Client;
+
+///
+/// Body of POST api/v1/front/query/accept and
+/// .../query/reject, mirroring the server-side
+/// Yavsc.ViewModels.FrontOffice.QueryAcceptanceRequest.
+/// is optional: when non-empty on accept it is
+/// the PostIt wire-format signature captured with the acceptance; null
+/// or empty means no signature (the only valid value on reject).
+///
+public sealed class QueryAcceptanceRequestDto
+{
+ public int[]? Strokes { get; set; }
+
+ public int CoordinateMax { get; set; } = 10_000;
+
+ public DateTime? CapturedAtUtc { get; set; }
+}
+
+///
+/// Response of POST .../query/accept / .../query/reject
+/// (Ok(new { queryId, status, signature = ... })). The
+/// ref is non-null only when a signature was
+/// staged on accept.
+///
+public sealed class QueryAcceptanceResponseDto
+{
+ public long QueryId { get; set; }
+
+ public string? Status { get; set; }
+
+ public QuerySignatureRefDto? Signature { get; set; }
+}
+
+public sealed class QuerySignatureRefDto
+{
+ public long Id { get; set; }
+
+ public long EstimateId { get; set; }
+
+ public string? Type { get; set; }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Api.Client/FrontOfficeApiClient.cs b/src/Yavsc.Api.Client/FrontOfficeApiClient.cs
new file mode 100644
index 000000000..c0cc84215
--- /dev/null
+++ b/src/Yavsc.Api.Client/FrontOfficeApiClient.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Yavsc.Api.Client;
+
+///
+/// HTTP client for the front-office query routes
+/// (api/v1/front/query/accept / .../reject, served by
+/// ). Same
+/// DTO↔path mapper shape as : transport
+/// (base URL, JSON, Bearer auth, silent refresh on 401) is delegated
+/// to .
+///
+/// The server resolves the caller's role (provider vs client vs admin)
+/// from the authenticated user compared to the query's
+/// PerformerId/ClientId; the client does not declare a
+/// role. is the estimate's
+/// and
+/// is its .
+///
+public sealed class FrontOfficeApiClient
+{
+ private const string PathPrefix = "front";
+
+ private readonly IYavscApiClient _api;
+ private readonly Func _businessBaseAddress;
+
+ public FrontOfficeApiClient(IYavscApiClient api, string businessBaseAddress)
+ : this(api, () => businessBaseAddress)
+ {
+ }
+
+ public FrontOfficeApiClient(IYavscApiClient api, Func businessBaseAddress)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
+ _ = ResolveBusinessBaseAddress();
+ }
+
+ ///
+ /// Accept (validate) the query under
+ /// billing code . When
+ /// carries a non-empty signature, the
+ /// server stages it on the query's linked estimate and stamps the
+ /// matching validation date.
+ ///
+ public async Task AcceptQueryAsync(
+ string billingCode,
+ long queryId,
+ QueryAcceptanceRequestDto body,
+ CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(billingCode))
+ throw new ArgumentException("billingCode is required.", nameof(billingCode));
+ if (queryId <= 0)
+ throw new ArgumentOutOfRangeException(nameof(queryId));
+ if (body is null)
+ throw new ArgumentNullException(nameof(body));
+
+ var path = $"{PathPrefix}/query/accept?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
+ var response = await _api.CallAsync(
+ HttpMethod.Post,
+ Absolute(path),
+ body: body,
+ ct: ct).ConfigureAwait(false);
+
+ return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
+ }
+
+ ///
+ /// Reject the query under billing code
+ /// . A signature is not meaningful on
+ /// a rejection; the server refuses one with 400, so this sends no
+ /// body.
+ ///
+ public async Task RejectQueryAsync(
+ string billingCode,
+ long queryId,
+ CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(billingCode))
+ throw new ArgumentException("billingCode is required.", nameof(billingCode));
+ if (queryId <= 0)
+ throw new ArgumentOutOfRangeException(nameof(queryId));
+
+ var path = $"{PathPrefix}/query/reject?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
+ var response = await _api.CallAsync(
+ HttpMethod.Post,
+ Absolute(path),
+ body: null,
+ ct: ct).ConfigureAwait(false);
+
+ return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
+ }
+
+ private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
+
+ private Uri ResolveBusinessBaseAddress()
+ {
+ var raw = _businessBaseAddress();
+ if (string.IsNullOrWhiteSpace(raw))
+ throw new InvalidOperationException("Business base address is required.");
+
+ return new Uri(raw, UriKind.Absolute);
+ }
+}
\ No newline at end of file