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 <noreply@anthropic.com>
This commit is contained in:
parent
806eb05bcc
commit
35c5a7f611
18 changed files with 1149 additions and 151 deletions
|
|
@ -11,7 +11,8 @@ public class EstimateListPageViewModelTests
|
||||||
{
|
{
|
||||||
var api = new StubEstimateListApi();
|
var api = new StubEstimateListApi();
|
||||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
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();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
|
@ -28,7 +29,8 @@ public class EstimateListPageViewModelTests
|
||||||
{
|
{
|
||||||
var api = new StubEstimateListApi();
|
var api = new StubEstimateListApi();
|
||||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
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();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
|
@ -42,7 +44,8 @@ public class EstimateListPageViewModelTests
|
||||||
{
|
{
|
||||||
var api = new StubEstimateListApi();
|
var api = new StubEstimateListApi();
|
||||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
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();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
|
@ -63,7 +66,8 @@ public class EstimateListPageViewModelTests
|
||||||
{
|
{
|
||||||
var api = new StubEstimateListApi { Empty = true };
|
var api = new StubEstimateListApi { Empty = true };
|
||||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
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();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
|
|
||||||
166
src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs
Normal file
166
src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using PostIt.Controls;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the
|
||||||
|
/// signature pad + perspective into the right
|
||||||
|
/// <see cref="FrontOfficeApiClient"/> call. The view (XAML, rendering)
|
||||||
|
/// is not tested here; the VM is driven through the same
|
||||||
|
/// <see cref="SignaturePadControl"/> test hooks the signature tests use,
|
||||||
|
/// and the API client through a recording fake
|
||||||
|
/// <see cref="IYavscApiClient"/>. The test host's
|
||||||
|
/// <c>TestApp</c> makes <c>GoBackAsync</c> a no-op, so the success paths
|
||||||
|
/// run to completion without navigation side effects.
|
||||||
|
/// </summary>
|
||||||
|
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<QueryAcceptanceRequestDto>(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<T> CallAsync<T>(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<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs
Normal file
121
src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
using System.Net.Http;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="FrontOfficeApiClient"/> issues the right
|
||||||
|
/// <c>api/v1/front/query/accept</c> / <c>.../reject</c> requests with
|
||||||
|
/// the PostIt signature payload, using a recording fake
|
||||||
|
/// <see cref="IYavscApiClient"/> (no network).
|
||||||
|
/// </summary>
|
||||||
|
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<ArgumentException>(() => client.AcceptQueryAsync("", 42, SampleBody()));
|
||||||
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.AcceptQueryAsync("Rdv", 0, SampleBody()));
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => client.AcceptQueryAsync("Rdv", 42, null!));
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() => client.RejectQueryAsync("", 42));
|
||||||
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => 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<T> CallAsync<T>(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<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync<T>(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
|
=> CallAsync(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/PostIt/PostIt/Controls/SignaturePadView.axaml
Normal file
28
src/PostIt/PostIt/Controls/SignaturePadView.axaml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:controls="using:PostIt.Controls"
|
||||||
|
x:Class="PostIt.Controls.SignaturePadView"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch">
|
||||||
|
<!--
|
||||||
|
Capture surface. The SignaturePadControl is render-agnostic,
|
||||||
|
so we host it inside a Border that is the capture area, and
|
||||||
|
overlay a Canvas for the visual feedback. The code-behind
|
||||||
|
repaints the canvas on every RedrawRequested. The Border size
|
||||||
|
is driven by the CaptureWidth / CaptureHeight styled properties.
|
||||||
|
-->
|
||||||
|
<Border x:Name="PadFrame"
|
||||||
|
BorderBrush="Black"
|
||||||
|
BorderThickness="1"
|
||||||
|
Background="White"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<Grid>
|
||||||
|
<controls:SignaturePadControl x:Name="InnerPad" />
|
||||||
|
<Canvas x:Name="InkLayer"
|
||||||
|
Background="Transparent"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
168
src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs
Normal file
168
src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reusable signature-capture surface: wraps the render-agnostic
|
||||||
|
/// <see cref="SignaturePadControl"/> in a sized <see cref="Border"/>
|
||||||
|
/// (the hit-test region) with an overlay <see cref="Canvas"/> 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 <c>SignaturePage</c>, the estimate validation page,
|
||||||
|
/// ...) shares one implementation.
|
||||||
|
///
|
||||||
|
/// Hosts bind <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>
|
||||||
|
/// (DIPs, defaults 600×200) to size the surface. View models attach
|
||||||
|
/// to the inner <see cref="SignaturePad"/> control (exposed here) to
|
||||||
|
/// read snapshots, drive <c>Clear</c>, and subscribe to
|
||||||
|
/// <see cref="SignaturePadControl.StrokeCompleted"/> /
|
||||||
|
/// <see cref="SignaturePadControl.RedrawRequested"/> — the same
|
||||||
|
/// contract the headless tests use, since a <see cref="UserControl"/>
|
||||||
|
/// needs its XAML loaded to be drivable.
|
||||||
|
/// </summary>
|
||||||
|
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<double> CaptureWidthProperty =
|
||||||
|
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureWidth), 600);
|
||||||
|
|
||||||
|
public static readonly StyledProperty<double> CaptureHeightProperty =
|
||||||
|
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureHeight), 200);
|
||||||
|
|
||||||
|
public double CaptureWidth
|
||||||
|
{
|
||||||
|
get => GetValue(CaptureWidthProperty);
|
||||||
|
set => SetValue(CaptureWidthProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double CaptureHeight
|
||||||
|
{
|
||||||
|
get => GetValue(CaptureHeightProperty);
|
||||||
|
set => SetValue(CaptureHeightProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The underlying capture control. View models attach to this to
|
||||||
|
/// read <see cref="SignaturePadControl.Snapshot"/> / call
|
||||||
|
/// <see cref="SignaturePadControl.Clear"/> and subscribe to its
|
||||||
|
/// events; this view repaints the ink layer from the same buffer.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
|
||||||
|
/// <summary>Defensive copy of the current buffer.</summary>
|
||||||
|
public SignaturePadData Snapshot() => InnerPad.Snapshot();
|
||||||
|
|
||||||
|
/// <summary>Forget every captured stroke.</summary>
|
||||||
|
public void Clear() => InnerPad.Clear();
|
||||||
|
|
||||||
|
/// <summary>Wire-format strokes, forwarded from the inner control.</summary>
|
||||||
|
public IReadOnlyList<int> 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<Point>(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<Point>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ public static class ServiceCollectionHelpers
|
||||||
() => settings.Authentication?.Authority);
|
() => settings.Authentication?.Authority);
|
||||||
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
|
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
|
||||||
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
||||||
|
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
||||||
var userDirectory = new UserDirectory(userSearchClient);
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||||
|
|
||||||
|
|
@ -63,6 +64,7 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddTransient<ProviderOngoingRequestsPage>();
|
services.AddTransient<ProviderOngoingRequestsPage>();
|
||||||
services.AddTransient<EstimateEditionPage>();
|
services.AddTransient<EstimateEditionPage>();
|
||||||
services.AddTransient<EstimateListPage>();
|
services.AddTransient<EstimateListPage>();
|
||||||
|
services.AddTransient<EstimateValidationPage>();
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
|
|
@ -74,6 +76,7 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton(activityClient);
|
services.AddSingleton(activityClient);
|
||||||
services.AddSingleton(billingClient);
|
services.AddSingleton(billingClient);
|
||||||
services.AddSingleton(estimateClient);
|
services.AddSingleton(estimateClient);
|
||||||
|
services.AddSingleton(frontClient);
|
||||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
services.AddSingleton<HomePageViewModel>();
|
services.AddSingleton<HomePageViewModel>();
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ public class ViewLocator : IDataTemplate
|
||||||
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
|
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
|
||||||
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
||||||
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
||||||
|
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
||||||
null => new TextBlock { Text = "No view for <null>" },
|
null => new TextBlock { Text = "No view for <null>" },
|
||||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using PostIt.Helpers;
|
using PostIt.Helpers;
|
||||||
|
|
@ -32,6 +33,7 @@ public enum EstimateListPerspective
|
||||||
public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
|
public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
{
|
{
|
||||||
private readonly EstimateApiClient _estimateClient;
|
private readonly EstimateApiClient _estimateClient;
|
||||||
|
private readonly FrontOfficeApiClient _frontClient;
|
||||||
private readonly EstimateListPerspective _perspective;
|
private readonly EstimateListPerspective _perspective;
|
||||||
private List<EstimateDto> _allEstimates = new();
|
private List<EstimateDto> _allEstimates = new();
|
||||||
|
|
||||||
|
|
@ -83,10 +85,14 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
|
||||||
protected set { _ = value; }
|
protected set { _ = value; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective)
|
public EstimateListPageViewModel(
|
||||||
|
EstimateApiClient estimateClient,
|
||||||
|
EstimateListPerspective perspective,
|
||||||
|
FrontOfficeApiClient frontClient)
|
||||||
{
|
{
|
||||||
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
|
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
|
||||||
_perspective = perspective;
|
_perspective = perspective;
|
||||||
|
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task InitializeAsync() => RefreshAsync();
|
public Task InitializeAsync() => RefreshAsync();
|
||||||
|
|
@ -153,4 +159,25 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
|
||||||
private static bool ContainsInsensitive(string? value, string query)
|
private static bool ContainsInsensitive(string? value, string query)
|
||||||
=> !string.IsNullOrWhiteSpace(value)
|
=> !string.IsNullOrWhiteSpace(value)
|
||||||
&& value.Contains(query, StringComparison.OrdinalIgnoreCase);
|
&& value.Contains(query, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validation (accept / reject) of a single estimate, with an optional
|
||||||
|
/// signature. Backs the estimate validation page opened from
|
||||||
|
/// <see cref="EstimateListPageViewModel"/> for either perspective.
|
||||||
|
///
|
||||||
|
/// The server (<c>POST api/v1/front/query/accept</c>) resolves the
|
||||||
|
/// caller's role from the authenticated user compared to the query's
|
||||||
|
/// <c>PerformerId</c>/<c>ClientId</c>; the perspective passed in here
|
||||||
|
/// is only a UI hint (the <see cref="RoleLabel"/>). The billing code
|
||||||
|
/// sent to the server is the estimate's <see cref="EstimateDto.CommandType"/>
|
||||||
|
/// and the query id is its <see cref="EstimateDto.CommandId"/>.
|
||||||
|
/// </summary>
|
||||||
|
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<EstimateLineDto> 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";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Human-readable summary of who has already validated this
|
||||||
|
/// estimate, shown above the signature pad.
|
||||||
|
/// </summary>
|
||||||
|
public string ValidationStateText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the estimate has a linked query to accept/reject.
|
||||||
|
/// Estimates without a <see cref="EstimateDto.CommandId"/> are
|
||||||
|
/// templates and cannot be validated through the front-office
|
||||||
|
/// endpoint.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bind the page's signature pad control (the inner control of the
|
||||||
|
/// page's <see cref="SignaturePadView"/>). Called from the view's
|
||||||
|
/// code-behind once the view is in the visual tree.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -90,7 +90,13 @@ public class HomePageViewModel : ViewModelBase
|
||||||
throw new InvalidOperationException("Client devis indisponible.");
|
throw new InvalidOperationException("Client devis indisponible.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var vm = new EstimateListPageViewModel(estimateClient, perspective);
|
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||||
|
if (frontClient is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Client front-office indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = new EstimateListPageViewModel(estimateClient, perspective, frontClient);
|
||||||
await vm.InitializeAsync();
|
await vm.InitializeAsync();
|
||||||
await app.PushPageAsync(vm);
|
await app.PushPageAsync(vm);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,12 @@ public partial class SignaturePageViewModel : ViewModelBase, IActionStatusViewMo
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bind a freshly-constructed (or re-templated) control to this
|
/// Bind the inner <see cref="SignaturePadControl"/> of the page's
|
||||||
/// VM. Called from the view's code-behind once the control has
|
/// <see cref="SignaturePadView"/> to this VM. Called from the
|
||||||
/// been added to the visual tree and its template applied (so
|
/// page's code-behind once the view is in the visual tree (so the
|
||||||
/// <see cref="SignaturePadControl.CaptureArea"/> is wired).
|
/// control's <see cref="SignaturePadControl.CaptureArea"/> is
|
||||||
|
/// wired). Attaching to the control directly (rather than the
|
||||||
|
/// view) keeps the same contract the headless tests use.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Attach(SignaturePadControl control)
|
public void Attach(SignaturePadControl control)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -29,34 +29,40 @@
|
||||||
CornerRadius="8"
|
CornerRadius="8"
|
||||||
Padding="10"
|
Padding="10"
|
||||||
Margin="0,0,0,8">
|
Margin="0,0,0,8">
|
||||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Grid.Row="0"
|
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||||
Grid.Column="0"
|
<TextBlock Grid.Row="0"
|
||||||
Text="{Binding Title}"
|
Grid.Column="0"
|
||||||
FontWeight="Bold"
|
Text="{Binding Title}"
|
||||||
TextWrapping="Wrap" />
|
FontWeight="Bold"
|
||||||
<TextBlock Grid.Row="0"
|
TextWrapping="Wrap" />
|
||||||
Grid.Column="1"
|
<TextBlock Grid.Row="0"
|
||||||
Text="{Binding Id}"
|
Grid.Column="1"
|
||||||
FontSize="11"
|
Text="{Binding Id}"
|
||||||
Opacity="0.75"
|
FontSize="11"
|
||||||
HorizontalAlignment="Right" />
|
Opacity="0.75"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
|
||||||
<TextBlock Grid.Row="1"
|
<TextBlock Grid.Row="1"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="0,6,0,0"
|
Margin="0,6,0,0"
|
||||||
Text="{Binding Description}"
|
Text="{Binding Description}"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Opacity="0.75"
|
Opacity="0.75"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
<TextBlock Grid.Row="1"
|
<TextBlock Grid.Row="1"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="0,6,0,0"
|
Margin="0,6,0,0"
|
||||||
Text="{Binding Bill.Count}"
|
Text="{Binding Bill.Count}"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Opacity="0.65"
|
Opacity="0.65"
|
||||||
HorizontalAlignment="Right" />
|
HorizontalAlignment="Right" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Button Content="Ouvrir"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Command="{Binding $parent[ContentPage].((vm:EstimateListPageViewModel)DataContext).OpenEstimateCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
|
|
|
||||||
126
src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml
Normal file
126
src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:controls="using:PostIt.Controls"
|
||||||
|
xmlns:dto="using:Yavsc.Api.Client"
|
||||||
|
xmlns:postitControls="using:PostIt.Controls"
|
||||||
|
x:Class="PostIt.Views.EstimateValidationPage"
|
||||||
|
x:DataType="vm:EstimateValidationPageViewModel"
|
||||||
|
Header="Validation du devis">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="3" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="{Binding Title}"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="Bold"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock Text="{Binding RoleLabel}" Opacity="0.75" />
|
||||||
|
<TextBlock Text="{Binding ValidationStateText}"
|
||||||
|
FontSize="11"
|
||||||
|
Opacity="0.75"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#22000000"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="10">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<Grid ColumnDefinitions="Auto,12,*">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="N°"
|
||||||
|
FontWeight="SemiBold" />
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
|
Text="{Binding EstimateId}" />
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="Auto,12,*">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="Type"
|
||||||
|
FontWeight="SemiBold" />
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
|
Text="{Binding CommandType}" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{Binding Description}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Opacity="0.85" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#22000000"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="10">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Lignes du devis" FontWeight="SemiBold" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Bill}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="dto:EstimateLineDto">
|
||||||
|
<Grid ColumnDefinitions="*,8,60,8,120" Margin="0,0,0,4">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Name}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
|
Text="{Binding Count}"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
<StackPanel Grid.Column="4"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Spacing="4">
|
||||||
|
<TextBlock Text="{Binding UnitaryCost, StringFormat='{}{0:N2}'}" />
|
||||||
|
<TextBlock Text="{Binding Currency}" Opacity="0.7" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#22000000"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="10">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Signature" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="Signez ci-dessous pour valider ce devis."
|
||||||
|
FontSize="11"
|
||||||
|
Opacity="0.7" />
|
||||||
|
<controls:SignaturePadView x:Name="Pad"
|
||||||
|
CaptureWidth="560"
|
||||||
|
CaptureHeight="200" />
|
||||||
|
<TextBlock FontSize="11"
|
||||||
|
Foreground="DarkSlateGray">
|
||||||
|
<Run Text="Traits: " />
|
||||||
|
<Run Text="{Binding StrokeCount, Mode=OneWay}" />
|
||||||
|
<Run Text=" · Points: " />
|
||||||
|
<Run Text="{Binding PointCount, Mode=OneWay}" />
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto" Margin="0,12,0,0">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Content="Effacer"
|
||||||
|
Command="{Binding ClearSignatureCommand}" />
|
||||||
|
<Button Content="Valider"
|
||||||
|
Command="{Binding ValidateCommand}" />
|
||||||
|
<Button Content="Refuser"
|
||||||
|
Command="{Binding RejectCommand}" />
|
||||||
|
<Button Content="Retour"
|
||||||
|
Command="{Binding BackCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Spacing="8"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<ProgressBar Width="120"
|
||||||
|
IsIndeterminate="True"
|
||||||
|
IsVisible="{Binding IsBusy}" />
|
||||||
|
<postitControls:StatusBar DataContext="{Binding ActionStatus}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ContentPage>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,29 +29,15 @@
|
||||||
FontWeight="Bold" />
|
FontWeight="Bold" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Capture surface. The SignaturePadControl is render-
|
Capture surface. SignaturePadView owns the SignaturePadControl,
|
||||||
agnostic, so we host it inside a fixed-size Border
|
the capture-area Border, the ink overlay canvas, and the
|
||||||
that is the PART_CaptureArea, and overlay a Canvas
|
repaint logic; the page just sizes it from the VM and reads
|
||||||
for the visual feedback. The view's code-behind
|
the captured data through the view's API.
|
||||||
repaints the canvas on every RedrawRequested.
|
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="1"
|
<controls:SignaturePadView Grid.Row="1"
|
||||||
x:Name="PadFrame"
|
x:Name="Pad"
|
||||||
Width="{Binding Width}"
|
CaptureWidth="{Binding Width}"
|
||||||
Height="{Binding Height}"
|
CaptureHeight="{Binding Height}" />
|
||||||
BorderBrush="Black"
|
|
||||||
BorderThickness="1"
|
|
||||||
Background="White"
|
|
||||||
HorizontalAlignment="Left"
|
|
||||||
VerticalAlignment="Top"
|
|
||||||
ClipToBounds="True">
|
|
||||||
<Grid>
|
|
||||||
<controls:SignaturePadControl x:Name="Pad" />
|
|
||||||
<Canvas x:Name="InkLayer"
|
|
||||||
Background="Transparent"
|
|
||||||
IsHitTestVisible="False" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<StackPanel Grid.Row="2"
|
<StackPanel Grid.Row="2"
|
||||||
Orientation="Horizontal"
|
Orientation="Horizontal"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Shapes;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using PostIt.Controls;
|
using PostIt.Controls;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
|
|
@ -11,26 +6,15 @@ namespace PostIt.Views;
|
||||||
|
|
||||||
public partial class SignaturePage : ContentPage
|
public partial class SignaturePage : ContentPage
|
||||||
{
|
{
|
||||||
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;
|
|
||||||
|
|
||||||
private SignaturePageViewModel? _vm;
|
private SignaturePageViewModel? _vm;
|
||||||
private SignaturePadControl? _control;
|
|
||||||
|
|
||||||
public SignaturePage()
|
public SignaturePage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
// Wire the capture area: the Pad itself is the control, the
|
// SignaturePadView owns the capture area wiring and the ink
|
||||||
// surrounding Border (PadFrame) is the hit-test region. We
|
// repaint; the page only forwards the view to the VM so it can
|
||||||
// set CaptureArea once the control's template has been
|
// read snapshots / subscribe to stroke events.
|
||||||
// applied — for an inline control with no template, that
|
|
||||||
// happens on first measure, which is guaranteed before
|
|
||||||
// the user can interact, so attaching here is safe.
|
|
||||||
_control = Pad;
|
|
||||||
_control.CaptureArea = PadFrame;
|
|
||||||
|
|
||||||
DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel);
|
DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,82 +23,12 @@ public partial class SignaturePage : ContentPage
|
||||||
if (_vm is not null)
|
if (_vm is not null)
|
||||||
{
|
{
|
||||||
_vm.Detach();
|
_vm.Detach();
|
||||||
_control!.RedrawRequested -= OnRedrawRequested;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_vm = vm;
|
_vm = vm;
|
||||||
|
|
||||||
if (_vm is null || _control is null) return;
|
if (_vm is null || Pad is null) return;
|
||||||
|
|
||||||
_vm.Attach(_control);
|
_vm.Attach(Pad.SignaturePad);
|
||||||
_control.RedrawRequested += OnRedrawRequested;
|
|
||||||
Repaint();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
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<Point>(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<Point>(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs
Normal file
44
src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Body of <c>POST api/v1/front/query/accept</c> and
|
||||||
|
/// <c>.../query/reject</c>, mirroring the server-side
|
||||||
|
/// <c>Yavsc.ViewModels.FrontOffice.QueryAcceptanceRequest</c>.
|
||||||
|
/// <see cref="Strokes"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class QueryAcceptanceRequestDto
|
||||||
|
{
|
||||||
|
public int[]? Strokes { get; set; }
|
||||||
|
|
||||||
|
public int CoordinateMax { get; set; } = 10_000;
|
||||||
|
|
||||||
|
public DateTime? CapturedAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response of <c>POST .../query/accept</c> / <c>.../query/reject</c>
|
||||||
|
/// (<c>Ok(new { queryId, status, signature = ... })</c>). The
|
||||||
|
/// <see cref="Signature"/> ref is non-null only when a signature was
|
||||||
|
/// staged on accept.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
108
src/Yavsc.Api.Client/FrontOfficeApiClient.cs
Normal file
108
src/Yavsc.Api.Client/FrontOfficeApiClient.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP client for the front-office query routes
|
||||||
|
/// (<c>api/v1/front/query/accept</c> / <c>.../reject</c>, served by
|
||||||
|
/// <see cref="Yavsc.ApiControllers.FrontOfficeApiController"/>). Same
|
||||||
|
/// DTO↔path mapper shape as <see cref="EstimateApiClient"/>: transport
|
||||||
|
/// (base URL, JSON, Bearer auth, silent refresh on 401) is delegated
|
||||||
|
/// to <see cref="IYavscApiClient"/>.
|
||||||
|
///
|
||||||
|
/// The server resolves the caller's role (provider vs client vs admin)
|
||||||
|
/// from the authenticated user compared to the query's
|
||||||
|
/// <c>PerformerId</c>/<c>ClientId</c>; the client does not declare a
|
||||||
|
/// role. <paramref name="billingCode"/> is the estimate's
|
||||||
|
/// <see cref="EstimateDto.CommandType"/> and <paramref name="queryId"/>
|
||||||
|
/// is its <see cref="EstimateDto.CommandId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FrontOfficeApiClient
|
||||||
|
{
|
||||||
|
private const string PathPrefix = "front";
|
||||||
|
|
||||||
|
private readonly IYavscApiClient _api;
|
||||||
|
private readonly Func<string> _businessBaseAddress;
|
||||||
|
|
||||||
|
public FrontOfficeApiClient(IYavscApiClient api, string businessBaseAddress)
|
||||||
|
: this(api, () => businessBaseAddress)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public FrontOfficeApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
|
||||||
|
{
|
||||||
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||||
|
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
|
||||||
|
_ = ResolveBusinessBaseAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accept (validate) the query <paramref name="queryId"/> under
|
||||||
|
/// billing code <paramref name="billingCode"/>. When
|
||||||
|
/// <paramref name="body"/> carries a non-empty signature, the
|
||||||
|
/// server stages it on the query's linked estimate and stamps the
|
||||||
|
/// matching validation date.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<QueryAcceptanceResponseDto> 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<QueryAcceptanceResponseDto>(
|
||||||
|
HttpMethod.Post,
|
||||||
|
Absolute(path),
|
||||||
|
body: body,
|
||||||
|
ct: ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reject the query <paramref name="queryId"/> under billing code
|
||||||
|
/// <paramref name="billingCode"/>. A signature is not meaningful on
|
||||||
|
/// a rejection; the server refuses one with 400, so this sends no
|
||||||
|
/// body.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<QueryAcceptanceResponseDto> 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<QueryAcceptanceResponseDto>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue