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 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();
|
||||
|
||||
|
|
|
|||
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);
|
||||
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<ProviderOngoingRequestsPage>();
|
||||
services.AddTransient<EstimateEditionPage>();
|
||||
services.AddTransient<EstimateListPage>();
|
||||
services.AddTransient<EstimateValidationPage>();
|
||||
|
||||
// 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<IReverseGeocodingService>(reverseGeocoding);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
services.AddSingleton<HomePageViewModel>();
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ public class ViewLocator : IDataTemplate
|
|||
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
|
||||
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
||||
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
||||
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
||||
null => new TextBlock { Text = "No view for <null>" },
|
||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<EstimateDto> _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);
|
||||
|
||||
/// <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.");
|
||||
}
|
||||
|
||||
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 app.PushPageAsync(vm);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,10 +85,12 @@ public partial class SignaturePageViewModel : ViewModelBase, IActionStatusViewMo
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="SignaturePadControl.CaptureArea"/> is wired).
|
||||
/// Bind the inner <see cref="SignaturePadControl"/> of the page's
|
||||
/// <see cref="SignaturePadView"/> to this VM. Called from the
|
||||
/// page's code-behind once the view is in the visual tree (so the
|
||||
/// 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>
|
||||
public void Attach(SignaturePadControl control)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -29,34 +29,40 @@
|
|||
CornerRadius="8"
|
||||
Padding="10"
|
||||
Margin="0,0,0,8">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Text="{Binding Title}"
|
||||
FontWeight="Bold"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Text="{Binding Id}"
|
||||
FontSize="11"
|
||||
Opacity="0.75"
|
||||
HorizontalAlignment="Right" />
|
||||
<StackPanel Spacing="8">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Text="{Binding Title}"
|
||||
FontWeight="Bold"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Text="{Binding Id}"
|
||||
FontSize="11"
|
||||
Opacity="0.75"
|
||||
HorizontalAlignment="Right" />
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Margin="0,6,0,0"
|
||||
Text="{Binding Description}"
|
||||
FontSize="11"
|
||||
Opacity="0.75"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="0,6,0,0"
|
||||
Text="{Binding Bill.Count}"
|
||||
FontSize="11"
|
||||
Opacity="0.65"
|
||||
HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Margin="0,6,0,0"
|
||||
Text="{Binding Description}"
|
||||
FontSize="11"
|
||||
Opacity="0.75"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="0,6,0,0"
|
||||
Text="{Binding Bill.Count}"
|
||||
FontSize="11"
|
||||
Opacity="0.65"
|
||||
HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
<Button Content="Ouvrir"
|
||||
HorizontalAlignment="Right"
|
||||
Command="{Binding $parent[ContentPage].((vm:EstimateListPageViewModel)DataContext).OpenEstimateCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</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" />
|
||||
|
||||
<!--
|
||||
Capture surface. The SignaturePadControl is render-
|
||||
agnostic, so we host it inside a fixed-size Border
|
||||
that is the PART_CaptureArea, and overlay a Canvas
|
||||
for the visual feedback. The view's code-behind
|
||||
repaints the canvas on every RedrawRequested.
|
||||
Capture surface. SignaturePadView owns the SignaturePadControl,
|
||||
the capture-area Border, the ink overlay canvas, and the
|
||||
repaint logic; the page just sizes it from the VM and reads
|
||||
the captured data through the view's API.
|
||||
-->
|
||||
<Border Grid.Row="1"
|
||||
x:Name="PadFrame"
|
||||
Width="{Binding Width}"
|
||||
Height="{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>
|
||||
<controls:SignaturePadView Grid.Row="1"
|
||||
x:Name="Pad"
|
||||
CaptureWidth="{Binding Width}"
|
||||
CaptureHeight="{Binding Height}" />
|
||||
|
||||
<StackPanel Grid.Row="2"
|
||||
Orientation="Horizontal"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Media;
|
||||
using PostIt.Controls;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
|
|
@ -11,26 +6,15 @@ namespace PostIt.Views;
|
|||
|
||||
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 SignaturePadControl? _control;
|
||||
|
||||
public SignaturePage()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Wire the capture area: the Pad itself is the control, the
|
||||
// surrounding Border (PadFrame) is the hit-test region. We
|
||||
// set CaptureArea once the control's template has been
|
||||
// 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;
|
||||
|
||||
// SignaturePadView owns the capture area wiring and the ink
|
||||
// repaint; the page only forwards the view to the VM so it can
|
||||
// read snapshots / subscribe to stroke events.
|
||||
DataContextChanged += (_, _) => 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<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